This commit is contained in:
2026-03-02 01:36:14 -04:00
parent d907c5765e
commit 311df9aeda
21 changed files with 2772 additions and 33 deletions

122
web/src/App.vue Normal file
View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import ChartView from './components/ChartView.vue'
import ChatPanel from './components/ChatPanel.vue'
import LoginScreen from './components/LoginScreen.vue'
import { useOrderStore } from './stores/orders'
import { useChartStore } from './stores/chart'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
const isAuthenticated = ref(false)
const needsConfirmation = ref(false)
const authError = ref<string>()
let stateSyncCleanup: (() => void) | null = null
// Check if we need password confirmation on first load
onMounted(async () => {
// Check if secrets store is initialized by trying to fetch a status endpoint
// For now, just default to false (user will see login screen)
needsConfirmation.value = false
})
const handleAuthenticate = async (
password: string,
confirmPassword?: string,
newPassword?: string,
confirmNewPassword?: string
) => {
authError.value = undefined
try {
const result = await wsManager.connect(password, confirmPassword, newPassword, confirmNewPassword)
if (result.success) {
isAuthenticated.value = true
// Initialize state sync after successful authentication
const orderStore = useOrderStore()
const chartStore = useChartStore()
const stateSync = useStateSync({
OrderStore: orderStore,
ChartStore: chartStore
})
stateSyncCleanup = stateSync.cleanup
} else {
authError.value = result.message
// If server says we need confirmation, update the flag
if (result.needsConfirmation) {
needsConfirmation.value = true
}
}
} catch (err) {
authError.value = 'Connection failed'
console.error('Authentication error:', err)
}
}
onBeforeUnmount(() => {
if (stateSyncCleanup) {
stateSyncCleanup()
}
wsManager.disconnect()
})
</script>
<template>
<div class="app-container dark">
<LoginScreen
v-if="!isAuthenticated"
:needs-confirmation="needsConfirmation"
:error-message="authError"
@authenticate="handleAuthenticate"
/>
<Splitter v-else class="main-splitter">
<SplitterPanel :size="62" :minSize="40" class="chart-panel">
<ChartView />
</SplitterPanel>
<SplitterPanel :size="38" :minSize="20" class="chat-panel">
<ChatPanel />
</SplitterPanel>
</Splitter>
</div>
</template>
<style scoped>
.app-container {
width: 100vw !important;
height: 100vh !important;
overflow: hidden;
background: var(--p-surface-0);
}
.main-splitter {
height: 100vh !important;
}
.main-splitter :deep(.p-splitter-gutter) {
background: var(--p-surface-100);
}
.main-splitter :deep(.p-splitter-gutter-handle) {
background: var(--p-primary-color);
}
.chart-panel,
.chat-panel {
height: 100% !important;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chart-panel :deep(.p-splitter-panel-content),
.chat-panel :deep(.p-splitter-panel-content) {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from 'primevue/card'
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
import { useChartStore } from '../stores/chart'
import type { IChartingLibraryWidget } from '../types/tradingview'
const chartContainer = ref<HTMLDivElement | null>(null)
const chartStore = useChartStore()
let tvWidget: IChartingLibraryWidget | null = null
let datafeed: any = null
let isUpdatingFromChart = false // Flag to prevent circular updates
onMounted(() => {
if (!chartContainer.value) return
// Wait for TradingView library to load
const initChart = () => {
if (!window.TradingView) {
setTimeout(initChart, 100)
return
}
try {
datafeed = createTradingViewDatafeed()
tvWidget = new window.TradingView.widget({
symbol: chartStore.chart_state.symbol, // Use symbol from store
datafeed: datafeed,
interval: chartStore.chart_state.interval as any,
container: chartContainer.value!,
library_path: '/charting_library/',
locale: 'en',
disabled_features: [
'use_localstorage_for_settings',
'header_symbol_search',
'symbol_search_hot_key'
],
enabled_features: ['study_templates'],
fullscreen: false,
autosize: true,
theme: 'Dark',
timezone: 'Etc/UTC'
})
tvWidget.onChartReady(() => {
console.log('TradingView chart ready')
setupChartListeners()
setupStoreWatchers()
// Initialize visible range on chart load
initializeVisibleRange()
})
} catch (error) {
console.error('Failed to initialize TradingView widget:', error)
}
}
initChart()
})
function initializeVisibleRange() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
const timeScale = chart.getVisibleRange()
if (timeScale) {
const startTime = Math.floor(timeScale.from)
const endTime = Math.floor(timeScale.to)
console.log('[ChartView] Initial visible range:', {
from: new Date(startTime * 1000).toISOString(),
to: new Date(endTime * 1000).toISOString()
})
chartStore.chart_state.start_time = startTime
chartStore.chart_state.end_time = endTime
}
}
function setupChartListeners() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
// Listen for symbol changes
chart.onSymbolChanged().subscribe(null, () => {
const symbolInfo = chart.symbolExt()
if (symbolInfo && symbolInfo.ticker) {
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
isUpdatingFromChart = true
chartStore.chart_state.symbol = symbolInfo.ticker
isUpdatingFromChart = false
}
})
// Listen for interval changes
chart.onIntervalChanged().subscribe(null, (interval: string) => {
console.log('[ChartView] Interval changed to:', interval)
isUpdatingFromChart = true
chartStore.chart_state.interval = interval
isUpdatingFromChart = false
})
// Listen for visible range changes (when user pans/zooms)
chart.onVisibleRangeChanged().subscribe(null, () => {
const timeScale = chart.getVisibleRange()
if (timeScale) {
// Convert from seconds to seconds (TradingView uses seconds for visible range)
const startTime = Math.floor(timeScale.from)
const endTime = Math.floor(timeScale.to)
console.log('[ChartView] Visible range changed:', {
from: new Date(startTime * 1000).toISOString(),
to: new Date(endTime * 1000).toISOString()
})
isUpdatingFromChart = true
chartStore.chart_state.start_time = startTime
chartStore.chart_state.end_time = endTime
isUpdatingFromChart = false
}
})
}
function setupStoreWatchers() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
// Watch for external changes to symbol (e.g., from backend/agent)
watch(
() => chartStore.chart_state.symbol,
(newSymbol) => {
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
console.log('[ChartView] Store symbol changed externally to:', newSymbol)
const currentSymbol = chart.symbolExt()
if (currentSymbol && currentSymbol.ticker !== newSymbol) {
chart.setSymbol(newSymbol, () => {
console.log('[ChartView] Chart symbol updated to:', newSymbol)
})
}
}
)
// Watch for external changes to interval
watch(
() => chartStore.chart_state.interval,
(newInterval) => {
if (isUpdatingFromChart) return
console.log('[ChartView] Store interval changed externally to:', newInterval)
if (chart.resolution() !== newInterval) {
chart.setResolution(newInterval, () => {
console.log('[ChartView] Chart interval updated to:', newInterval)
})
}
}
)
// Note: Visible range is typically only set by user interaction,
// but we could add a watcher here if we want the backend to be able
// to change the visible range programmatically
}
onBeforeUnmount(() => {
if (tvWidget) {
tvWidget.remove()
tvWidget = null
}
if (datafeed && typeof datafeed.destroy === 'function') {
datafeed.destroy()
datafeed = null
}
})
</script>
<template>
<Card class="chart-card">
<template #content>
<div ref="chartContainer" class="chart-container"></div>
</template>
</Card>
</template>
<style scoped>
.chart-card {
height: 100% !important;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
background: var(--p-surface-0);
}
.chart-card :deep(.p-card-body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.chart-card :deep(.p-card-content) {
flex: 1;
display: flex;
overflow: hidden;
padding: 0;
}
.chart-container {
flex: 1;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,309 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { register } from 'vue-advanced-chat'
import Badge from 'primevue/badge'
import { wsManager } from '../composables/useWebSocket'
import type { WebSocketMessage } from '../composables/useWebSocket'
register()
const SESSION_ID = 'default'
const CURRENT_USER_ID = 'user-123'
const AGENT_ID = 'agent'
const BACKEND_URL = 'http://localhost:8080'
// Chat state
const messages = ref<any[]>([])
const messagesLoaded = ref(false)
const isConnected = wsManager.isConnected
// Reactive rooms that update based on WebSocket connection
const rooms = computed(() => [{
roomId: SESSION_ID,
roomName: 'AI Agent',
avatar: null,
users: [
{ _id: CURRENT_USER_ID, username: 'You' },
{ _id: AGENT_ID, username: 'AI Agent', status: { state: isConnected.value ? 'online' : 'offline' } }
],
unreadCount: 0
}])
// Streaming state
let currentStreamingMessageId: string | null = null
let streamingBuffer = ''
// Generate message ID
const generateMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
// Handle WebSocket messages
const handleMessage = (data: WebSocketMessage) => {
if (data.type === 'agent_chunk') {
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
if (!currentStreamingMessageId) {
// Start new streaming message
currentStreamingMessageId = generateMessageId()
streamingBuffer = data.content
messages.value = [...messages.value, {
_id: currentStreamingMessageId,
content: streamingBuffer,
senderId: AGENT_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: false,
distributed: false,
seen: false
}]
} else {
// Update existing streaming message
streamingBuffer += data.content
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
content: streamingBuffer
}
messages.value = [...messages.value]
}
}
if (data.done) {
// Mark message as complete
const msgIndex = messages.value.findIndex(m => m._id === currentStreamingMessageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
saved: true,
distributed: true,
seen: true
}
messages.value = [...messages.value]
}
currentStreamingMessageId = null
streamingBuffer = ''
}
}
}
// Send message handler
const sendMessage = async (event: any) => {
// Extract data from CustomEvent.detail[0]
const data = event.detail?.[0] || event
const content = data.content || ''
const files = data.files
const roomId = data.roomId
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
const messageId = generateMessageId()
// Process files
let attachments: string[] = []
let messageFiles: any[] = []
if (files && files.length > 0) {
// Upload files to backend
for (const file of files) {
try {
const formData = new FormData()
// Convert blob URL to actual file if needed
const response = await fetch(file.url)
const blob = await response.blob()
const actualFile = new File([blob], file.name, { type: file.type })
formData.append('file', actualFile)
const uploadResponse = await fetch(`${BACKEND_URL}/api/upload`, {
method: 'POST',
body: formData
})
if (!uploadResponse.ok) {
throw new Error('Upload failed')
}
const uploadData = await uploadResponse.json()
const fileUrl = `${BACKEND_URL}${uploadData.url}`
attachments.push(fileUrl)
messageFiles.push({
name: file.name,
size: uploadData.size,
type: file.extension || file.type,
url: fileUrl,
preview: file.url // Keep the blob URL for preview
})
} catch (error) {
console.error('File upload error:', error)
}
}
}
// Add user message to UI
const userMessage = {
_id: messageId,
content: content,
senderId: CURRENT_USER_ID,
timestamp: timestamp,
date: new Date().toLocaleDateString(),
saved: true,
distributed: false,
seen: false,
files: messageFiles
}
messages.value = [...messages.value, userMessage]
// Send to backend via WebSocket
const wsMessage = {
type: 'agent_user_message',
session_id: roomId || SESSION_ID,
content: content,
attachments: attachments
}
wsManager.send(wsMessage)
// Mark as distributed
setTimeout(() => {
const msgIndex = messages.value.findIndex(m => m._id === messageId)
if (msgIndex !== -1) {
messages.value[msgIndex] = {
...messages.value[msgIndex],
distributed: true,
seen: true
}
messages.value = [...messages.value]
}
}, 100)
}
// Fetch messages handler (for pagination, not needed for simple chat)
const fetchMessages = ({ room, options }: any) => {
messagesLoaded.value = true
}
// Open file handler
const openFile = ({ file }: any) => {
window.open(file.url, '_blank')
}
// Theme configuration for dark mode
const chatTheme = 'light'
// Styles to match PrimeVue theme
const chatStyles = computed(() => JSON.stringify({
general: {
color: 'var(--p-surface-900)',
colorSpinner: 'var(--p-primary-color)',
borderStyle: '1px solid var(--p-surface-200)'
},
container: {
background: 'var(--p-surface-0)'
},
header: {
background: 'var(--p-surface-50)',
colorRoomName: 'var(--p-surface-900)',
colorRoomInfo: 'var(--p-surface-700)'
},
footer: {
background: 'var(--p-surface-50)',
borderStyleInput: '1px solid var(--p-surface-300)',
backgroundInput: 'var(--p-surface-200)',
colorInput: 'var(--p-surface-900)',
colorPlaceholder: 'var(--p-surface-400)',
colorIcons: 'var(--p-surface-400)'
},
content: {
background: 'var(--p-surface-0)'
},
message: {
background: 'var(--p-surface-100)',
backgroundMe: 'var(--p-primary-color)',
color: 'var(--p-surface-900)',
colorMe: 'var(--p-primary-contrast-color)'
}
}))
onMounted(() => {
wsManager.addHandler(handleMessage)
// Mark messages as loaded after initialization
messagesLoaded.value = true
})
onUnmounted(() => {
wsManager.removeHandler(handleMessage)
})
</script>
<template>
<div class="chat-container">
<!--
<div class="chat-header-custom">
<span class="chat-title">AI Agent Chat</span>
<Badge
:value="isConnected ? 'Connected' : 'Disconnected'"
:severity="isConnected ? 'success' : 'danger'"
/>
</div>
-->
<vue-advanced-chat
height="calc(100vh - 60px)"
:current-user-id="CURRENT_USER_ID"
:rooms="JSON.stringify(rooms)"
:messages="JSON.stringify(messages)"
:messages-loaded="messagesLoaded"
:room-id="SESSION_ID"
:theme="chatTheme"
:styles="chatStyles"
:single-room="true"
:rooms-list-opened="false"
:show-add-room="false"
:show-audio="false"
:show-files="true"
:show-emojis="true"
:show-reaction-emojis="false"
:accepted-files="'image/*,video/*,application/pdf'"
@send-message="sendMessage"
@fetch-messages="fetchMessages"
@open-file="openFile"
/>
</div>
</template>
<style scoped>
.chat-container {
height: 100% !important;
display: flex;
flex-direction: column;
background: var(--p-surface-0);
overflow: hidden;
}
.chat-container :deep(.vac-container) {
height: 100% !important;
}
.chat-container :deep(.vac-text-message) {
white-space: pre-wrap;
}
.chat-header-custom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background: var(--p-surface-50);
border-bottom: 1px solid var(--p-surface-200);
flex-shrink: 0;
}
.chat-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--p-surface-900);
}
</style>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
import Message from 'primevue/message'
const props = defineProps<{
needsConfirmation: boolean
errorMessage?: string
}>()
const emit = defineEmits<{
authenticate: [password: string, confirmPassword?: string, newPassword?: string, confirmNewPassword?: string]
}>()
const password = ref('')
const confirmPassword = ref('')
const newPassword = ref('')
const confirmNewPassword = ref('')
const isLoading = ref(false)
const isChangingPassword = ref(false)
const canSubmit = computed(() => {
if (!password.value || isLoading.value) return false
if (props.needsConfirmation && password.value !== confirmPassword.value) return false
if (isChangingPassword.value) {
if (!newPassword.value || !confirmNewPassword.value) return false
if (newPassword.value !== confirmNewPassword.value) return false
}
return true
})
const handleSubmit = () => {
if (!canSubmit.value) return
// Prevent double-submit
if (isLoading.value) return
isLoading.value = true
if (props.needsConfirmation) {
emit('authenticate', password.value, confirmPassword.value)
} else if (isChangingPassword.value) {
emit('authenticate', password.value, undefined, newPassword.value, confirmNewPassword.value)
} else {
emit('authenticate', password.value)
}
// Reset loading state after a timeout (parent will handle actual auth)
setTimeout(() => {
isLoading.value = false
}, 5000)
}
const handleKeypress = (event: KeyboardEvent) => {
if (event.key === 'Enter' && canSubmit.value) {
event.preventDefault() // Prevent form submission if this is in a form
handleSubmit()
}
}
const togglePasswordChange = () => {
isChangingPassword.value = !isChangingPassword.value
newPassword.value = ''
confirmNewPassword.value = ''
}
</script>
<template>
<div class="login-screen">
<div class="login-container">
<Card class="login-card">
<template #title>
<div class="login-title">
<i class="pi pi-lock" style="font-size: 2rem; margin-bottom: 0.5rem;"></i>
<h2>{{ needsConfirmation ? 'Welcome' : 'Authentication' }}</h2>
</div>
</template>
<template #content>
<div class="login-content">
<p v-if="needsConfirmation" class="welcome-message">
This is your first time connecting. Please create a master password to secure your workspace.
</p>
<p v-else-if="isChangingPassword" class="welcome-message">
Enter your current password and choose a new one.
</p>
<p v-else class="welcome-message">
Enter your master password to connect.
</p>
<Message v-if="errorMessage" severity="error" :closable="false">
{{ errorMessage }}
</Message>
<div class="form-field">
<label for="password">{{ isChangingPassword ? 'Current Password' : 'Password' }}</label>
<Password
id="password"
v-model="password"
:feedback="needsConfirmation"
toggleMask
placeholder="Enter password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="current-password"
/>
</div>
<div v-if="needsConfirmation" class="form-field">
<label for="confirm-password">Confirm Password</label>
<Password
id="confirm-password"
v-model="confirmPassword"
:feedback="false"
toggleMask
placeholder="Confirm password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="new-password"
/>
<small v-if="confirmPassword && password !== confirmPassword" class="p-error">
Passwords do not match
</small>
</div>
<!-- Password change fields -->
<div v-if="isChangingPassword" class="password-change-section">
<div class="form-field">
<label for="new-password">New Password</label>
<Password
id="new-password"
v-model="newPassword"
:feedback="true"
toggleMask
placeholder="Enter new password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="new-password"
/>
</div>
<div class="form-field">
<label for="confirm-new-password">Confirm New Password</label>
<Password
id="confirm-new-password"
v-model="confirmNewPassword"
:feedback="false"
toggleMask
placeholder="Confirm new password"
class="password-input"
@keypress="handleKeypress"
:disabled="isLoading"
autocomplete="new-password"
/>
<small v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="p-error">
Passwords do not match
</small>
</div>
</div>
<Button
:label="needsConfirmation ? 'Create & Connect' : isChangingPassword ? 'Change Password & Connect' : 'Connect'"
:icon="isChangingPassword ? 'pi pi-key' : 'pi pi-sign-in'"
@click="handleSubmit"
:disabled="!canSubmit"
:loading="isLoading"
class="connect-button"
/>
<!-- Toggle password change link -->
<div v-if="!needsConfirmation" class="change-password-link">
<a @click="togglePasswordChange" href="#">
{{ isChangingPassword ? 'Cancel password change' : 'Change password' }}
</a>
</div>
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
.login-screen {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--p-surface-0) 0%, var(--p-surface-100) 100%);
}
.login-container {
width: 100%;
max-width: 450px;
padding: 2rem;
}
.login-card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.login-title {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--p-primary-color);
}
.login-title h2 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
}
.login-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.welcome-message {
text-align: center;
color: var(--p-text-secondary-color);
margin: 0;
line-height: 1.6;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label {
font-weight: 600;
color: var(--p-text-color);
}
.password-input {
width: 100%;
}
.password-input :deep(.p-inputtext) {
width: 100%;
}
.connect-button {
width: 100%;
padding: 0.75rem;
font-size: 1.1rem;
margin-top: 0.5rem;
}
.p-error {
color: var(--p-red-500);
font-size: 0.875rem;
}
.password-change-section {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--p-surface-border);
margin-top: 0.5rem;
}
.change-password-link {
text-align: center;
margin-top: -0.5rem;
}
.change-password-link a {
color: var(--p-primary-color);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
transition: opacity 0.2s;
}
.change-password-link a:hover {
opacity: 0.8;
text-decoration: underline;
}
</style>

View File

@@ -102,22 +102,35 @@ export function useStateSync(stores: Record<string, Store>) {
sendJson(msg);
};
// Connect to WebSocket and register handler
const ws = wsManager.connect();
wsManager.addHandler(handleMessage);
console.log('[StateSync] WebSocket ready state:', ws.readyState);
if (ws.readyState === WebSocket.OPEN) {
console.log('[StateSync] WebSocket already open, sending hello');
sendHello();
} else {
console.log('[StateSync] WebSocket not open, waiting for open event');
ws.addEventListener('open', sendHello, { once: true });
}
// Set up watchers for each store to send patches on changes
const unwatchFunctions: (() => void)[] = [];
// Register handler (connection will be initiated after authentication)
wsManager.addHandler(handleMessage);
// Send hello when authenticated
const checkAuthAndSendHello = () => {
if (wsManager.isAuthenticated.value && wsManager.isConnected.value) {
console.log('[StateSync] Authenticated and connected, sending hello');
sendHello();
}
};
// Watch for authentication
const unwatchAuth = wsManager.isAuthenticated.value
? (checkAuthAndSendHello(), () => {})
: (() => {
const interval = setInterval(() => {
if (wsManager.isAuthenticated.value && wsManager.isConnected.value) {
clearInterval(interval);
checkAuthAndSendHello();
}
}, 100);
return () => clearInterval(interval);
})();
unwatchFunctions.push(unwatchAuth);
for (const [storeName, store] of Object.entries(stores)) {
previousStates[storeName] = JSON.parse(JSON.stringify(store.$state));
isApplyingBackendPatch[storeName] = false;

View File

@@ -0,0 +1,289 @@
/**
* TradingView Datafeed implementation using WebSocket backend
*
* This adapter translates TradingView's Datafeed API calls into
* WebSocket messages that match the backend protocol defined in
* backend/src/datasource/websocket_protocol.py
*
* Uses the shared WebSocket connection for all communication.
*/
import { wsManager, type MessageHandler } from './useWebSocket'
import type {
IBasicDataFeed,
DatafeedConfiguration,
LibrarySymbolInfo,
Bar,
SearchSymbolResultItem,
HistoryMetadata
} from '../types/tradingview'
interface PendingRequest {
resolve: (value: any) => void
reject: (reason: any) => void
timeout: number
}
interface Subscription {
symbolInfo: LibrarySymbolInfo
resolution: string
onTick: (bar: Bar) => void
subscriptionId: string
}
export class WebSocketDatafeed implements IBasicDataFeed {
private pendingRequests: Map<string, PendingRequest> = new Map()
private subscriptions: Map<string, Subscription> = new Map()
private requestTimeout = 10000 // 10 seconds
private configuration: DatafeedConfiguration | null = null
private messageHandler: MessageHandler
constructor() {
// Use the shared WebSocket connection (managed by App.vue authentication)
// Register message handler for datafeed messages
this.messageHandler = this.handleMessage.bind(this)
wsManager.addHandler(this.messageHandler)
}
private generateRequestId(): string {
return `tv_${Date.now()}_${Math.random().toString(36).substring(7)}`
}
private sendRequest<T>(message: any): Promise<T> {
const requestId = message.request_id || this.generateRequestId()
message.request_id = requestId
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pendingRequests.delete(requestId)
reject(new Error('Request timeout'))
}, this.requestTimeout)
this.pendingRequests.set(requestId, { resolve, reject, timeout })
wsManager.send(message)
})
}
private handleMessage(message: any): void {
console.log('[TradingView Datafeed] Received message:', message)
// Handle responses to pending requests
if (message.request_id && this.pendingRequests.has(message.request_id)) {
console.log('[TradingView Datafeed] Found pending request for:', message.request_id)
const pending = this.pendingRequests.get(message.request_id)!
this.pendingRequests.delete(message.request_id)
clearTimeout(pending.timeout)
if (message.type === 'error') {
console.log('[TradingView Datafeed] Resolving with error:', message.error_message)
pending.reject(new Error(message.error_message || 'Unknown error'))
} else {
console.log('[TradingView Datafeed] Resolving with response')
pending.resolve(message)
}
} else if (message.request_id) {
console.log('[TradingView Datafeed] No pending request found for:', message.request_id)
}
// Handle real-time bar updates
if (message.type === 'bar_update') {
const subscription = this.subscriptions.get(message.subscription_id)
if (subscription && message.bar) {
const bar: Bar = {
time: message.bar.time * 1000, // Convert to milliseconds
open: parseFloat(message.bar.data.open),
high: parseFloat(message.bar.data.high),
low: parseFloat(message.bar.data.low),
close: parseFloat(message.bar.data.close),
volume: parseFloat(message.bar.data.volume)
}
subscription.onTick(bar)
}
}
}
onReady(callback: (configuration: DatafeedConfiguration) => void): void {
// TradingView requires callback to be called asynchronously
if (this.configuration) {
setTimeout(() => callback(this.configuration!), 0)
return
}
this.sendRequest<any>({
type: 'get_config'
})
.then((response) => {
this.configuration = response.config
setTimeout(() => callback(this.configuration!), 0)
})
.catch((error) => {
console.error('Failed to get datafeed configuration:', error)
// Provide default configuration
const defaultConfig: DatafeedConfiguration = {
supported_resolutions: ['1', '5', '15', '30', '60', '240', '1D', '1W'],
supports_search: true,
supports_group_request: false,
supports_marks: false,
supports_timescale_marks: false,
supports_time: false
}
this.configuration = defaultConfig
setTimeout(() => callback(defaultConfig), 0)
})
}
searchSymbols(
userInput: string,
exchange: string,
symbolType: string,
onResult: (items: SearchSymbolResultItem[]) => void
): void {
this.sendRequest<any>({
type: 'search_symbols',
query: userInput,
exchange: exchange || undefined,
symbol_type: symbolType || undefined,
limit: 30
})
.then((response) => {
onResult(response.results || [])
})
.catch((error) => {
console.error('Symbol search failed:', error)
onResult([])
})
}
resolveSymbol(
symbolName: string,
onResolve: (symbolInfo: LibrarySymbolInfo) => void,
onError: (reason: string) => void
): void {
this.sendRequest<any>({
type: 'resolve_symbol',
symbol: symbolName
})
.then((response) => {
if (response.symbol_info) {
console.log('[TradingView Datafeed] Resolved symbol info:', response.symbol_info)
onResolve(response.symbol_info)
} else {
onError('Symbol not found')
}
})
.catch((error) => {
console.error('Failed to resolve symbol:', error)
onError(error instanceof Error ? error.message : 'Unknown error')
})
}
getBars(
symbolInfo: LibrarySymbolInfo,
resolution: string,
periodParams: {
from: number
to: number
firstDataRequest: boolean
countBack?: number
},
onResult: (bars: Bar[], meta: HistoryMetadata) => void,
onError: (reason: string) => void
): void {
this.sendRequest<any>({
type: 'get_bars',
symbol: symbolInfo.ticker || symbolInfo.name,
resolution: resolution,
from_time: periodParams.from,
to_time: periodParams.to,
countback: periodParams.countBack
})
.then((response) => {
if (response.history) {
const bars: Bar[] = (response.history.bars || []).map((bar: any) => ({
time: bar.time * 1000, // Convert to milliseconds
open: parseFloat(bar.data.open),
high: parseFloat(bar.data.high),
low: parseFloat(bar.data.low),
close: parseFloat(bar.data.close),
volume: parseFloat(bar.data.volume)
}))
const meta: HistoryMetadata = {
noData: bars.length === 0,
nextTime: response.history.next_time
}
onResult(bars, meta)
} else {
onResult([], { noData: true })
}
})
.catch((error) => {
console.error('Failed to get bars:', error)
onError(error instanceof Error ? error.message : 'Unknown error')
})
}
subscribeBars(
symbolInfo: LibrarySymbolInfo,
resolution: string,
onTick: (bar: Bar) => void,
listenerGuid: string,
onResetCacheNeededCallback: () => void
): void {
this.sendRequest<any>({
type: 'subscribe_bars',
symbol: symbolInfo.ticker || symbolInfo.name,
resolution: resolution,
subscription_id: listenerGuid
})
.then((response) => {
if (response.success) {
this.subscriptions.set(listenerGuid, {
symbolInfo,
resolution,
onTick,
subscriptionId: listenerGuid
})
}
})
.catch((error) => {
console.error('Failed to subscribe to bars:', error)
})
}
unsubscribeBars(listenerGuid: string): void {
this.sendRequest<any>({
type: 'unsubscribe_bars',
subscription_id: listenerGuid
})
.then(() => {
this.subscriptions.delete(listenerGuid)
})
.catch((error) => {
console.error('Failed to unsubscribe from bars:', error)
})
}
destroy(): void {
// Clean up all subscriptions
for (const [guid] of this.subscriptions) {
this.unsubscribeBars(guid)
}
this.subscriptions.clear()
// Clear pending requests
for (const [requestId, pending] of this.pendingRequests) {
clearTimeout(pending.timeout)
pending.reject(new Error('Datafeed destroyed'))
}
this.pendingRequests.clear()
// Remove message handler from shared WebSocket
wsManager.removeHandler(this.messageHandler)
}
}
export function createTradingViewDatafeed(): IBasicDataFeed {
return new WebSocketDatafeed()
}

View File

@@ -0,0 +1,181 @@
import { ref } from 'vue'
import type { AuthMessage, AuthResponseMessage } from '../types/sync'
export interface WebSocketMessage {
type: string
[key: string]: any
}
export type MessageHandler = (message: WebSocketMessage) => void
export interface AuthResult {
success: boolean
needsConfirmation: boolean
message: string
}
class WebSocketManager {
private ws: WebSocket | null = null
private handlers: Set<MessageHandler> = new Set()
private reconnectTimeout: number | null = null
public isConnected = ref(false)
public isAuthenticated = ref(false)
private authResolve: ((result: AuthResult) => void) | null = null
private shouldReconnect = false // Disable auto-reconnect (user must re-authenticate)
private storedPassword: string | null = null
private storedConfirmPassword: string | undefined = undefined
async connect(
password: string,
confirmPassword?: string,
changeToPassword?: string,
confirmNewPassword?: string
): Promise<AuthResult> {
// Store password for potential reconnection
this.storedPassword = password
this.storedConfirmPassword = confirmPassword
// Close existing connection if any
if (this.ws) {
this.shouldReconnect = false
this.ws.close()
this.ws = null
}
return new Promise((resolve, reject) => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.hostname
this.ws = new WebSocket(`${protocol}//${host}:8080/ws`)
this.authResolve = resolve
this.ws.onopen = () => {
console.log('WebSocket connected, sending auth...')
// Send authentication message
const authMsg: AuthMessage = {
type: 'auth',
password,
confirm_password: confirmPassword,
change_to_password: changeToPassword,
confirm_new_password: confirmNewPassword
}
this.ws?.send(JSON.stringify(authMsg))
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
// Handle auth response
if (message.type === 'auth_response') {
const authResponse = message as AuthResponseMessage
console.log('Auth response:', authResponse)
if (authResponse.success) {
this.isConnected.value = true
this.isAuthenticated.value = true
this.shouldReconnect = true
console.log('Authentication successful')
} else {
this.isAuthenticated.value = false
console.warn('Authentication failed:', authResponse.message)
}
if (this.authResolve) {
this.authResolve({
success: authResponse.success,
needsConfirmation: authResponse.needs_confirmation || false,
message: authResponse.message
})
this.authResolve = null
}
// Don't return, let other handlers process this message too
}
// Pass to all handlers
this.handlers.forEach(handler => handler(message))
} catch (err) {
console.error('Error parsing WebSocket message:', err)
}
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
this.isConnected.value = false
this.isAuthenticated.value = false
if (this.authResolve) {
this.authResolve({
success: false,
needsConfirmation: false,
message: 'Connection error'
})
this.authResolve = null
}
}
this.ws.onclose = (event) => {
this.isConnected.value = false
this.isAuthenticated.value = false
console.log('WebSocket disconnected:', event.code, event.reason)
if (this.authResolve) {
this.authResolve({
success: false,
needsConfirmation: false,
message: event.reason || 'Connection closed'
})
this.authResolve = null
}
// No auto-reconnect - user must re-authenticate manually
// This prevents reconnection attempts without proper password
}
// Timeout for auth
setTimeout(() => {
if (this.authResolve) {
this.authResolve({
success: false,
needsConfirmation: false,
message: 'Authentication timeout'
})
this.authResolve = null
this.ws?.close()
}
}, 30000)
})
}
addHandler(handler: MessageHandler) {
this.handlers.add(handler)
}
removeHandler(handler: MessageHandler) {
this.handlers.delete(handler)
}
send(message: WebSocketMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
}
}
disconnect() {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
}
if (this.ws) {
this.ws.close()
this.ws = null
}
}
getWebSocket() {
return this.ws
}
}
export const wsManager = new WebSocketManager()

24
web/src/stores/orders.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface SwapOrder {
tokenIn: string
tokenOut: string
route: {
exchange: string
fee: string
}
amount: string
minFillAmount: string
amountIsInput: boolean
outputDirectlyToOwner: boolean
inverted: boolean
conditionalOrder: string
tranches: any[]
}
export const useOrderStore = defineStore('OrderStore', () => {
const orders = ref<SwapOrder[]>([])
return { orders }
})

View File

@@ -1,3 +1,19 @@
export interface AuthMessage {
type: 'auth';
password: string;
confirm_password?: string;
change_to_password?: string;
confirm_new_password?: string;
}
export interface AuthResponseMessage {
type: 'auth_response';
success: boolean;
needs_confirmation?: boolean;
password_changed?: boolean;
message: string;
}
export interface SnapshotMessage {
type: 'snapshot';
store: string;
@@ -17,5 +33,5 @@ export interface HelloMessage {
seqs: Record<string, number>;
}
export type BackendMessage = SnapshotMessage | PatchMessage;
export type FrontendMessage = HelloMessage | PatchMessage;
export type BackendMessage = SnapshotMessage | PatchMessage | AuthResponseMessage;
export type FrontendMessage = AuthMessage | HelloMessage | PatchMessage;

View File

@@ -0,0 +1,136 @@
/**
* TradingView Charting Library type definitions
* Based on TradingView DataFeed API
*/
export interface LibrarySymbolInfo {
name: string
ticker?: string
description: string
type: string
session: string
timezone: string
exchange: string
minmov: number
pricescale: number
has_intraday: boolean
has_daily: boolean
has_weekly_and_monthly: boolean
supported_resolutions: string[]
volume_precision?: number
data_status?: 'streaming' | 'endofday' | 'pulsed' | 'delayed_streaming'
[key: string]: any
}
export interface Bar {
time: number // Unix timestamp in milliseconds
open: number
high: number
low: number
close: number
volume?: number
}
export interface HistoryMetadata {
noData: boolean
nextTime?: number
}
export interface SearchSymbolResultItem {
symbol: string
full_name: string
description: string
exchange: string
ticker?: string
type: string
}
export interface DatafeedConfiguration {
supported_resolutions: string[]
supports_search?: boolean
supports_group_request?: boolean
supports_marks?: boolean
supports_timescale_marks?: boolean
supports_time?: boolean
exchanges?: Array<{ value: string; name: string; desc: string }>
symbols_types?: Array<{ name: string; value: string }>
}
export interface IBasicDataFeed {
onReady(callback: (configuration: DatafeedConfiguration) => void): void
searchSymbols(
userInput: string,
exchange: string,
symbolType: string,
onResult: (items: SearchSymbolResultItem[]) => void
): void
resolveSymbol(
symbolName: string,
onResolve: (symbolInfo: LibrarySymbolInfo) => void,
onError: (reason: string) => void
): void
getBars(
symbolInfo: LibrarySymbolInfo,
resolution: string,
periodParams: {
from: number
to: number
firstDataRequest: boolean
countBack?: number
},
onResult: (bars: Bar[], meta: HistoryMetadata) => void,
onError: (reason: string) => void
): void
subscribeBars(
symbolInfo: LibrarySymbolInfo,
resolution: string,
onTick: (bar: Bar) => void,
listenerGuid: string,
onResetCacheNeededCallback: () => void
): void
unsubscribeBars(listenerGuid: string): void
}
export interface ChartingLibraryWidgetOptions {
symbol: string
datafeed: IBasicDataFeed
interval: string
container: string | HTMLElement
library_path: string
locale?: string
disabled_features?: string[]
enabled_features?: string[]
charts_storage_url?: string
charts_storage_api_version?: string
client_id?: string
user_id?: string
fullscreen?: boolean
autosize?: boolean
theme?: 'Light' | 'Dark'
timezone?: string
debug?: boolean
[key: string]: any
}
export interface IChartingLibraryWidget {
onChartReady(callback: () => void): void
headerReady(): Promise<void>
remove(): void
activeChart(): any
[key: string]: any
}
// Global TradingView namespace
declare global {
interface Window {
TradingView: {
widget: new (options: ChartingLibraryWidgetOptions) => IChartingLibraryWidget
version: () => string
}
}
}