feat: Frontend Conversation-based Chat UI (#60) #66

Open
shoko wants to merge 2 commits from fix/issue-60 into main
15 changed files with 1289 additions and 2 deletions
Showing only changes of commit 1b8761d1f4 - Show all commits

View File

@@ -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);
}
} }
}; };

View File

@@ -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[];
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -9,3 +9,10 @@ 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';

View 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
);

View File

@@ -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';

View 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>

View 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>

View 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>