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

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>