feat: frontend conversation-based chat UI (#60)
- Add Conversation, Message, ConversationWithMessages types - Add conversations API client methods (list, create, get, delete, chat, setBot) - Create conversationStore for state management - Create ChatLayout component with left/right panes - Create ConversationList component for left pane - Create ChatArea component for messages display - Create ChatInput component for message input - Create BotInfoPanel component for right pane - Create AnonymousBanner component for non-logged users - Create CandlestickLoader animation component - Add /home and /chat routes - Handle anonymous user rate limits (40 warning, 50 max) - Handle system rate limits (429) and auth limits (403)
This commit is contained in:
@@ -8,7 +8,10 @@ import type {
|
|||||||
AuthResponse,
|
AuthResponse,
|
||||||
BotChatRequest,
|
BotChatRequest,
|
||||||
BotChatResponse,
|
BotChatResponse,
|
||||||
StrategyConfig
|
StrategyConfig,
|
||||||
|
Conversation,
|
||||||
|
ConversationWithMessages,
|
||||||
|
Message
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
||||||
@@ -237,5 +240,58 @@ export const api = {
|
|||||||
});
|
});
|
||||||
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
|
return handleResponse<{ symbol: string; chain: string; name: string }[]>(response);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
conversations: {
|
||||||
|
async list(): Promise<Conversation[]> {
|
||||||
|
const response = await fetch(`${API_URL}/conversations`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Conversation[]>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(): Promise<Conversation> {
|
||||||
|
const response = await fetch(`${API_URL}/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<Conversation>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: string): Promise<ConversationWithMessages> {
|
||||||
|
const response = await fetch(`${API_URL}/conversations/${id}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
return handleResponse<ConversationWithMessages>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_URL}/conversations/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error ${response.status}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async chat(id: string, message: string, signal?: AbortSignal): Promise<ConversationWithMessages> {
|
||||||
|
const response = await fetch(`${API_URL}/conversations/${id}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ message }),
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
return handleResponse<ConversationWithMessages>(response);
|
||||||
|
},
|
||||||
|
|
||||||
|
async setBot(id: string, botId: string): Promise<Conversation> {
|
||||||
|
const response = await fetch(`${API_URL}/conversations/${id}/set-bot`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ bot_id: botId })
|
||||||
|
});
|
||||||
|
return handleResponse<Conversation>(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -186,3 +186,24 @@ export interface TokenSearchResult {
|
|||||||
address: string;
|
address: string;
|
||||||
chain: string;
|
chain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
bot_id: string | null;
|
||||||
|
title: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationWithMessages extends Conversation {
|
||||||
|
messages: Message[];
|
||||||
|
}
|
||||||
|
|||||||
59
src/frontend/src/lib/components/AnonymousBanner.svelte
Normal file
59
src/frontend/src/lib/components/AnonymousBanner.svelte
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
chatCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chatCount = 0 }: Props = $props();
|
||||||
|
|
||||||
|
const showWarning = chatCount >= 40;
|
||||||
|
const limit = 50;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-2 text-center text-sm {
|
||||||
|
showWarning ? 'bg-yellow-900 text-yellow-200' : 'bg-gray-800 text-gray-400'
|
||||||
|
}">
|
||||||
|
{#if showWarning}
|
||||||
|
<span>
|
||||||
|
Warning: You've used {chatCount}/{limit} messages.
|
||||||
|
<a href="/login" class="underline text-yellow-200 hover:text-yellow-100">Login to continue</a>
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span>
|
||||||
|
Your progress is not saved.
|
||||||
|
<a href="/login" class="underline hover:text-gray-300">Login to save</a>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.p-2 {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.bg-gray-800 {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.bg-yellow-900 {
|
||||||
|
background-color: rgb(113 63 18);
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
.text-yellow-200 {
|
||||||
|
color: rgb(254 240 138);
|
||||||
|
}
|
||||||
|
.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.hover\:text-yellow-100:hover {
|
||||||
|
color: rgb(253 230 138);
|
||||||
|
}
|
||||||
|
.hover\:text-gray-300:hover {
|
||||||
|
color: rgb(209 213 219);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
125
src/frontend/src/lib/components/BotInfoPanel.svelte
Normal file
125
src/frontend/src/lib/components/BotInfoPanel.svelte
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Bot } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
bot?: Bot | null;
|
||||||
|
onSelectBot?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { bot = null, onSelectBot }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-gray-200">
|
||||||
|
{#if !bot}
|
||||||
|
<p class="text-gray-400 mb-2">No bot selected</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onSelectBot}
|
||||||
|
class="mt-2 text-green-500 hover:underline"
|
||||||
|
>
|
||||||
|
Select Bot
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<h3 class="font-bold text-lg mb-2">{bot.name}</h3>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Chain:</span> {bot.chain || 'bsc'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-400">Status:</span>
|
||||||
|
<span class={bot.status === 'active' ? 'text-green-500' : 'text-gray-500'}>
|
||||||
|
{bot.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if bot.strategy_config && bot.strategy_config.conditions && bot.strategy_config.conditions.length > 0}
|
||||||
|
<div class="border-t border-gray-700 pt-2 mt-2">
|
||||||
|
<span class="text-gray-400">Strategy:</span>
|
||||||
|
<p class="mt-1 text-gray-300">{bot.strategy_config.conditions.length} condition(s) configured</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if onSelectBot}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onSelectBot}
|
||||||
|
class="mt-4 w-full py-2 border border-gray-600 rounded hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Change Bot
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text-gray-200 {
|
||||||
|
color: rgb(229 231 235);
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
.text-gray-300 {
|
||||||
|
color: rgb(209 213 219);
|
||||||
|
}
|
||||||
|
.text-green-500 {
|
||||||
|
color: rgb(34 197 94);
|
||||||
|
}
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.mt-2 {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.mt-4 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.space-y-2 > * + * {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
.font-bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.border-t {
|
||||||
|
border-top-width: 1px;
|
||||||
|
border-top-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
.pt-2 {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.py-2 {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
.border-gray-600 {
|
||||||
|
border-color: rgb(75 85 99);
|
||||||
|
}
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.hover\:bg-gray-800:hover {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: background-color;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
.hover\:underline:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
src/frontend/src/lib/components/CandlestickLoader.svelte
Normal file
47
src/frontend/src/lib/components/CandlestickLoader.svelte
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { class: className = '' } = $props();
|
||||||
|
|
||||||
|
const bars = [0, 1, 2, 3];
|
||||||
|
const heights = ['12px', '18px', '14px', '20px'];
|
||||||
|
const delays = ['0s', '0.15s', '0.3s', '0.45s'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 h-8 {className}">
|
||||||
|
{#each bars as i}
|
||||||
|
<div
|
||||||
|
class="w-2 bg-green-500 rounded-sm animate-pulse"
|
||||||
|
style="height: {heights[i]}; animation-delay: {delays[i]};"
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.gap-1 {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.h-8 {
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
.w-2 {
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
.bg-green-500 {
|
||||||
|
background-color: rgb(34 197 94);
|
||||||
|
}
|
||||||
|
.rounded-sm {
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scaleY(1); }
|
||||||
|
50% { opacity: 1; transform: scaleY(1.2); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
src/frontend/src/lib/components/ChatArea.svelte
Normal file
218
src/frontend/src/lib/components/ChatArea.svelte
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Message } from '$lib/api';
|
||||||
|
import { parseMarkdown, parseInlineElements, type InlineSegment } from '$lib/utils/markdown';
|
||||||
|
import CandlestickLoader from './CandlestickLoader.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
conversationId?: string;
|
||||||
|
messages?: Message[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { conversationId, messages = [], isLoading = false }: Props = $props();
|
||||||
|
|
||||||
|
let messageInput = $state('');
|
||||||
|
let isSending = $state(false);
|
||||||
|
let chatContainer: HTMLDivElement;
|
||||||
|
let expandedThinking: Record<string, boolean> = $state({});
|
||||||
|
|
||||||
|
function toggleThinkingExpand(messageId: string) {
|
||||||
|
expandedThinking[messageId] = !expandedThinking[messageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent(content: string) {
|
||||||
|
return parseMarkdown(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(segments: InlineSegment[]): string {
|
||||||
|
return segments.map(seg => {
|
||||||
|
switch (seg.type) {
|
||||||
|
case 'bold': return `<strong>${seg.content}</strong>`;
|
||||||
|
case 'italic': return `<em>${seg.content}</em>`;
|
||||||
|
case 'code': return `<code class="inline-code">${seg.content}</code>`;
|
||||||
|
case 'link': return `<a href="${seg.href || '#'}" target="_blank" rel="noopener noreferrer">${seg.content}</a>`;
|
||||||
|
default: return seg.content;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (messages.length && chatContainer) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getInputValue(): string {
|
||||||
|
return messageInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setInputValue(val: string): void {
|
||||||
|
messageInput = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSending(val: boolean): void {
|
||||||
|
isSending = val;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto p-4" bind:this={chatContainer}>
|
||||||
|
{#if !conversationId}
|
||||||
|
<div class="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Select a conversation or start a new one
|
||||||
|
</div>
|
||||||
|
{:else if messages.length === 0 && !isLoading}
|
||||||
|
<div class="flex items-center justify-center h-full text-gray-400">
|
||||||
|
Send a message to start the conversation
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each messages as msg (msg.id)}
|
||||||
|
<div class="mb-4 {msg.role === 'user' ? 'text-right' : 'text-left'}">
|
||||||
|
<div class="inline-block max-w-[70%] p-3 rounded {
|
||||||
|
msg.role === 'user'
|
||||||
|
? 'bg-gray-700 text-white'
|
||||||
|
: 'bg-blue-900 text-gray-200'
|
||||||
|
}">
|
||||||
|
{#each renderContent(msg.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>{@html renderInline(parseInlineElements(item))}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if segment.type === 'lineBreak'}
|
||||||
|
<br />
|
||||||
|
{:else}
|
||||||
|
{segment.content}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1 px-2">
|
||||||
|
{new Date(msg.created_at).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="mb-4 text-left">
|
||||||
|
<div class="inline-block p-3 rounded bg-blue-900">
|
||||||
|
<CandlestickLoader />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.overflow-y-auto {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.text-left {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.inline-block {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.max-w-\[70\%\] {
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.bg-gray-700 {
|
||||||
|
background-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
.bg-blue-900 {
|
||||||
|
background-color: rgb(30 58 138);
|
||||||
|
}
|
||||||
|
.text-white {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.text-gray-200 {
|
||||||
|
color: rgb(229 231 235);
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
.text-gray-500 {
|
||||||
|
color: rgb(107 114 128);
|
||||||
|
}
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.px-2 {
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
pre.code-block {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
src/frontend/src/lib/components/ChatInput.svelte
Normal file
118
src/frontend/src/lib/components/ChatInput.svelte
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSend: (message: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSend, disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
let messageInput = $state('');
|
||||||
|
|
||||||
|
function handleSend() {
|
||||||
|
if (!messageInput.trim() || disabled) return;
|
||||||
|
onSend(messageInput);
|
||||||
|
messageInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex gap-3 p-4 border-t border-gray-700">
|
||||||
|
<textarea
|
||||||
|
bind:value={messageInput}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
placeholder="Describe your trading strategy... (or type / for commands)"
|
||||||
|
rows="1"
|
||||||
|
{disabled}
|
||||||
|
class="flex-1 p-3 rounded-lg border border-gray-600 bg-gray-800 text-white resize-none focus:outline-none focus:border-blue-500"
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
onclick={handleSend}
|
||||||
|
{disabled}
|
||||||
|
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.gap-3 {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.border-t {
|
||||||
|
border-top-width: 1px;
|
||||||
|
}
|
||||||
|
.border-gray-700 {
|
||||||
|
border-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.border {
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
.border-gray-600 {
|
||||||
|
border-color: rgb(75 85 99);
|
||||||
|
}
|
||||||
|
.bg-gray-800 {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.text-white {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.resize-none {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
.focus\:outline-none:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.focus\:border-blue-500:focus {
|
||||||
|
border-color: rgb(59 130 246);
|
||||||
|
}
|
||||||
|
.px-6 {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
}
|
||||||
|
.py-3 {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.bg-blue-600 {
|
||||||
|
background-color: rgb(37 99 235);
|
||||||
|
}
|
||||||
|
.hover\:bg-blue-700:hover {
|
||||||
|
background-color: rgb(29 78 216);
|
||||||
|
}
|
||||||
|
.disabled\:bg-gray-600:disabled {
|
||||||
|
background-color: rgb(75 85 99);
|
||||||
|
}
|
||||||
|
.disabled\:cursor-not-allowed:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: background-color;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/frontend/src/lib/components/ChatLayout.svelte
Normal file
67
src/frontend/src/lib/components/ChatLayout.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
leftPane?: any;
|
||||||
|
rightPane?: any;
|
||||||
|
children?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { leftPane, rightPane, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen">
|
||||||
|
{#if leftPane}
|
||||||
|
<div class="w-64 border-r border-gray-700 flex-shrink-0">
|
||||||
|
{@render leftPane()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col min-w-0">
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rightPane}
|
||||||
|
<div class="w-72 border-l border-gray-700 p-4 flex-shrink-0 overflow-y-auto">
|
||||||
|
{@render rightPane()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.h-screen {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.flex-shrink-0 {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.min-w-0 {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.border-r {
|
||||||
|
border-right-width: 1px;
|
||||||
|
border-right-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
.border-l {
|
||||||
|
border-left-width: 1px;
|
||||||
|
border-left-color: rgb(55 65 81);
|
||||||
|
}
|
||||||
|
.w-64 {
|
||||||
|
width: 16rem;
|
||||||
|
}
|
||||||
|
.w-72 {
|
||||||
|
width: 18rem;
|
||||||
|
}
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.overflow-y-auto {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
204
src/frontend/src/lib/components/ConversationList.svelte
Normal file
204
src/frontend/src/lib/components/ConversationList.svelte
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { conversationStore, setConversations, addConversation } from '$lib/stores';
|
||||||
|
|
||||||
|
let conversations = $state<any[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const unsub = conversationStore.subscribe(state => {
|
||||||
|
conversations = state.conversations;
|
||||||
|
});
|
||||||
|
return unsub;
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loadConversations();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConversations() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const data = await api.conversations.list();
|
||||||
|
setConversations(data);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load conversations';
|
||||||
|
if (error.includes('not authenticated') || error.includes('401')) {
|
||||||
|
console.log('Anonymous user - conversations list empty');
|
||||||
|
setConversations([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createConversation() {
|
||||||
|
try {
|
||||||
|
const newConv = await api.conversations.create();
|
||||||
|
addConversation(newConv);
|
||||||
|
goto(`/chat/${newConv.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to create conversation:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActiveConversation(convId: string): boolean {
|
||||||
|
let currentId = '';
|
||||||
|
const unsub = page.subscribe(p => {
|
||||||
|
currentId = p.params.conversationId || '';
|
||||||
|
});
|
||||||
|
unsub();
|
||||||
|
return currentId === convId;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="h-full flex flex-col bg-gray-900">
|
||||||
|
<div class="p-2 border-b border-gray-800">
|
||||||
|
<button
|
||||||
|
onclick={createConversation}
|
||||||
|
class="w-full p-2 bg-green-600 rounded hover:bg-green-700 transition-colors text-white font-medium"
|
||||||
|
>
|
||||||
|
+ New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto">
|
||||||
|
{#if loading}
|
||||||
|
<div class="p-4 text-gray-400">Loading...</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="p-4 text-red-400 text-sm">{error}</div>
|
||||||
|
{:else if conversations.length === 0}
|
||||||
|
<div class="p-4 text-gray-500 text-sm text-center">No conversations yet</div>
|
||||||
|
{:else}
|
||||||
|
{#each conversations as conv (conv.id)}
|
||||||
|
<div
|
||||||
|
class="p-3 cursor-pointer hover:bg-gray-800 transition-colors border-b border-gray-800 {isActiveConversation(conv.id) ? 'bg-gray-800' : ''}"
|
||||||
|
onclick={() => goto(`/chat/${conv.id}`)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && goto(`/chat/${conv.id}`)}
|
||||||
|
>
|
||||||
|
<div class="font-medium text-gray-200 truncate">{conv.title || 'New Chat'}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">{formatDate(conv.updated_at)}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.h-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.bg-gray-900 {
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
.bg-gray-800 {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.p-2 {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
.p-3 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.border-b {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.border-gray-800 {
|
||||||
|
border-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.overflow-y-auto {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.hover\:bg-gray-800:hover {
|
||||||
|
background-color: rgb(31 41 55);
|
||||||
|
}
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: background-color;
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
.text-white {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.text-gray-200 {
|
||||||
|
color: rgb(229 231 235);
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
.text-gray-500 {
|
||||||
|
color: rgb(107 114 128);
|
||||||
|
}
|
||||||
|
.text-red-400 {
|
||||||
|
color: rgb(248 113 113);
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.mt-1 {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.bg-green-600 {
|
||||||
|
background-color: rgb(22 163 74);
|
||||||
|
}
|
||||||
|
.hover\:bg-green-700:hover {
|
||||||
|
background-color: rgb(21 128 61);
|
||||||
|
}
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,4 +8,11 @@ export { default as PortfolioSummary } from './PortfolioSummary.svelte';
|
|||||||
export { default as BacktestChart } from './BacktestChart.svelte';
|
export { default as BacktestChart } from './BacktestChart.svelte';
|
||||||
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
|
export { default as ProUpgradeBanner } from './ProUpgradeBanner.svelte';
|
||||||
export { default as TokenPicker } from './TokenPicker.svelte';
|
export { default as TokenPicker } from './TokenPicker.svelte';
|
||||||
export { default as ConditionBuilder } from './ConditionBuilder.svelte';
|
export { default as ConditionBuilder } from './ConditionBuilder.svelte';
|
||||||
|
export { default as ChatLayout } from './ChatLayout.svelte';
|
||||||
|
export { default as ConversationList } from './ConversationList.svelte';
|
||||||
|
export { default as ChatArea } from './ChatArea.svelte';
|
||||||
|
export { default as ChatInput } from './ChatInput.svelte';
|
||||||
|
export { default as BotInfoPanel } from './BotInfoPanel.svelte';
|
||||||
|
export { default as AnonymousBanner } from './AnonymousBanner.svelte';
|
||||||
|
export { default as CandlestickLoader } from './CandlestickLoader.svelte';
|
||||||
96
src/frontend/src/lib/stores/conversationStore.ts
Normal file
96
src/frontend/src/lib/stores/conversationStore.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { writable, derived } from 'svelte/store';
|
||||||
|
import type { Conversation, Message } from '$lib/api';
|
||||||
|
|
||||||
|
export interface ConversationState {
|
||||||
|
conversations: Conversation[];
|
||||||
|
currentConversationId: string | null;
|
||||||
|
messages: Message[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
anonymousChatCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ConversationState = {
|
||||||
|
conversations: [],
|
||||||
|
currentConversationId: null,
|
||||||
|
messages: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
anonymousChatCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const conversationStore = writable<ConversationState>(initialState);
|
||||||
|
|
||||||
|
export function setConversations(conversations: Conversation[]) {
|
||||||
|
conversationStore.update(state => ({ ...state, conversations }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addConversation(conversation: Conversation) {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
conversations: [conversation, ...state.conversations]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeConversation(conversationId: string) {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
conversations: state.conversations.filter(c => c.id !== conversationId),
|
||||||
|
currentConversationId: state.currentConversationId === conversationId ? null : state.currentConversationId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentConversation(conversationId: string | null) {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
currentConversationId: conversationId,
|
||||||
|
messages: []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMessages(messages: Message[]) {
|
||||||
|
conversationStore.update(state => ({ ...state, messages }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMessage(message: Message) {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
messages: [...state.messages, message]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLoading(isLoading: boolean) {
|
||||||
|
conversationStore.update(state => ({ ...state, isLoading }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setError(error: string | null) {
|
||||||
|
conversationStore.update(state => ({ ...state, error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incrementAnonymousChatCount() {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
anonymousChatCount: state.anonymousChatCount + 1
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAnonymousChatCount() {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
anonymousChatCount: 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateConversationTitle(conversationId: string, title: string) {
|
||||||
|
conversationStore.update(state => ({
|
||||||
|
...state,
|
||||||
|
conversations: state.conversations.map(c =>
|
||||||
|
c.id === conversationId ? { ...c, title, updated_at: new Date().toISOString() } : c
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currentConversation = derived(
|
||||||
|
conversationStore,
|
||||||
|
$state => $state.conversations.find(c => c.id === $state.currentConversationId) ?? null
|
||||||
|
);
|
||||||
@@ -28,3 +28,18 @@ export {
|
|||||||
register,
|
register,
|
||||||
logout
|
logout
|
||||||
} from './authStore';
|
} from './authStore';
|
||||||
|
export {
|
||||||
|
conversationStore,
|
||||||
|
setConversations,
|
||||||
|
addConversation,
|
||||||
|
removeConversation,
|
||||||
|
setCurrentConversation,
|
||||||
|
setMessages as setConversationMessages,
|
||||||
|
addMessage as addConversationMessage,
|
||||||
|
setLoading as setConversationLoading,
|
||||||
|
setError as setConversationError,
|
||||||
|
incrementAnonymousChatCount,
|
||||||
|
resetAnonymousChatCount,
|
||||||
|
updateConversationTitle,
|
||||||
|
currentConversation
|
||||||
|
} from './conversationStore';
|
||||||
|
|||||||
41
src/frontend/src/routes/chat/+page.svelte
Normal file
41
src/frontend/src/routes/chat/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { isAuthenticated } from '$lib/stores';
|
||||||
|
import ChatLayout from '$lib/components/ChatLayout.svelte';
|
||||||
|
import ConversationList from '$lib/components/ConversationList.svelte';
|
||||||
|
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Chat - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !$isAuthenticated}
|
||||||
|
<AnonymousBanner chatCount={0} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ChatLayout leftPane={ConversationList}>
|
||||||
|
<div class="flex-1 flex items-center justify-center text-gray-400 bg-gray-900">
|
||||||
|
<p>Select a conversation or start a new one</p>
|
||||||
|
</div>
|
||||||
|
</ChatLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.bg-gray-900 {
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
185
src/frontend/src/routes/chat/[conversationId]/+page.svelte
Normal file
185
src/frontend/src/routes/chat/[conversationId]/+page.svelte
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { isAuthenticated, conversationStore, setConversationMessages, setConversationLoading, setConversationError, incrementAnonymousChatCount } from '$lib/stores';
|
||||||
|
import type { Message, Bot, ConversationWithMessages } from '$lib/api';
|
||||||
|
import ChatLayout from '$lib/components/ChatLayout.svelte';
|
||||||
|
import ConversationList from '$lib/components/ConversationList.svelte';
|
||||||
|
import ChatArea from '$lib/components/ChatArea.svelte';
|
||||||
|
import ChatInput from '$lib/components/ChatInput.svelte';
|
||||||
|
import BotInfoPanel from '$lib/components/BotInfoPanel.svelte';
|
||||||
|
import AnonymousBanner from '$lib/components/AnonymousBanner.svelte';
|
||||||
|
import CandlestickLoader from '$lib/components/CandlestickLoader.svelte';
|
||||||
|
|
||||||
|
let conversationId = $derived($page.params.conversationId);
|
||||||
|
let messages = $state<Message[]>([]);
|
||||||
|
let currentBot = $state<Bot | null>(null);
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let isSending = $state(false);
|
||||||
|
let anonymousChatCount = $state(0);
|
||||||
|
|
||||||
|
let chatAreaComponent = $state<any>(null);
|
||||||
|
let currentConversation = $state<ConversationWithMessages | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (conversationId) {
|
||||||
|
loadConversation(conversationId);
|
||||||
|
} else {
|
||||||
|
messages = [];
|
||||||
|
currentBot = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConversation(id: string) {
|
||||||
|
isLoading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const conv = await api.conversations.get(id);
|
||||||
|
currentConversation = conv;
|
||||||
|
setConversationMessages(conv.messages);
|
||||||
|
messages = conv.messages;
|
||||||
|
|
||||||
|
if (conv.bot_id) {
|
||||||
|
try {
|
||||||
|
currentBot = await api.bots.get(conv.bot_id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load bot:', e);
|
||||||
|
currentBot = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentBot = null;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load conversation';
|
||||||
|
messages = [];
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendMessage(message: string) {
|
||||||
|
if (!conversationId) return;
|
||||||
|
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
if (anonymousChatCount >= 50) {
|
||||||
|
error = 'You have reached the maximum number of messages. Please login to continue.';
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (anonymousChatCount >= 40) {
|
||||||
|
const proceed = confirm('You are about to reach your message limit. Login to save your progress.');
|
||||||
|
if (proceed) {
|
||||||
|
goto('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSending = true;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedConv = await api.conversations.chat(conversationId, message);
|
||||||
|
messages = updatedConv.messages;
|
||||||
|
currentConversation = updatedConv;
|
||||||
|
|
||||||
|
if (updatedConv.bot_id && (!currentBot || currentBot.id !== updatedConv.bot_id)) {
|
||||||
|
try {
|
||||||
|
currentBot = await api.bots.get(updatedConv.bot_id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load bot:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isAuthenticated) {
|
||||||
|
incrementAnonymousChatCount();
|
||||||
|
anonymousChatCount++;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status === 429) {
|
||||||
|
error = 'Rate limited from the agent service. Please come back later.';
|
||||||
|
} else if (e.status === 403) {
|
||||||
|
error = "You've reached the limit. Please create an account to continue.";
|
||||||
|
goto('/login');
|
||||||
|
} else {
|
||||||
|
error = e.message || 'Failed to send message';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectBot() {
|
||||||
|
goto('/dashboard');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Chat - Randebu</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
{#if !$isAuthenticated}
|
||||||
|
<AnonymousBanner chatCount={anonymousChatCount} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ChatLayout
|
||||||
|
leftPane={ConversationList}
|
||||||
|
rightPane={currentBot ? { component: BotInfoPanel, props: { bot: currentBot, onSelectBot: handleSelectBot } } : null}
|
||||||
|
>
|
||||||
|
<div class="flex-1 flex flex-col bg-gray-900">
|
||||||
|
{#if error}
|
||||||
|
<div class="p-4 bg-red-900/50 text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="flex-1 flex items-center justify-center">
|
||||||
|
<CandlestickLoader />
|
||||||
|
</div>
|
||||||
|
{:else if conversationId}
|
||||||
|
<ChatArea bind:this={chatAreaComponent} {conversationId} {messages} {isLoading} />
|
||||||
|
<ChatInput onSend={handleSendMessage} disabled={isSending} />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-gray-400">
|
||||||
|
Select a conversation or start a new one
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</ChatLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.flex-col {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.bg-gray-900 {
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
.p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.bg-red-900\/50 {
|
||||||
|
background-color: rgb(127 29 29 / 0.5);
|
||||||
|
}
|
||||||
|
.text-red-300 {
|
||||||
|
color: rgb(252 165 165);
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
28
src/frontend/src/routes/home/+page.svelte
Normal file
28
src/frontend/src/routes/home/+page.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChatLayout from '$lib/components/ChatLayout.svelte';
|
||||||
|
import ConversationList from '$lib/components/ConversationList.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ChatLayout leftPane={ConversationList}>
|
||||||
|
<div class="flex-1 flex items-center justify-center text-gray-400 bg-gray-900">
|
||||||
|
<p>Select a conversation or start a new one</p>
|
||||||
|
</div>
|
||||||
|
</ChatLayout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flex-1 {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.bg-gray-900 {
|
||||||
|
background-color: rgb(17 24 39);
|
||||||
|
}
|
||||||
|
.text-gray-400 {
|
||||||
|
color: rgb(156 163 175);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user