This commit is contained in:
2026-04-13 21:42:28 -04:00
parent 5021138da6
commit 45a21ac933
6 changed files with 134 additions and 144 deletions

View File

@@ -212,7 +212,7 @@ if [ "$DEPLOY" == "1" ]; then
esac esac
echo "deployed $PROJECT $REMOTE/ai-$PROJECT:$TAG" echo "deployed $PROJECT $REMOTE/ai-$PROJECT:$TAG"
else else
YAML=$(sed "s#image: dexorder/ai-$PROJECT*#image: $REMOTE/ai-$PROJECT:$TAG#" deploy/k8s/$KUBERNETES.yaml) YAML=$(sed "s#image: dexorder/ai-$PROJECT*#image: $REMOTE/ai-$PROJECT:$TAG#" deploy/k8s/base/$KUBERNETES.yaml)
echo "$YAML" | kubectl apply -f - || { echo "$YAML"; echo "kubectl apply failed"; exit 1; } echo "$YAML" | kubectl apply -f - || { echo "$YAML"; echo "kubectl apply failed"; exit 1; }
echo deployed $KUBERNETES.yaml $REMOTE/ai-$PROJECT:$TAG echo deployed $KUBERNETES.yaml $REMOTE/ai-$PROJECT:$TAG
fi fi

View File

@@ -505,8 +505,6 @@ export class AgentHarness {
const messagesCopy = [...messages]; const messagesCopy = [...messages];
let iterations = 0; let iterations = 0;
// Track last char of last yielded text chunk to detect missing spaces between tokens
let lastChunkTail = '';
while (iterations < maxIterations) { while (iterations < maxIterations) {
if (signal?.aborted) break; if (signal?.aborted) break;
@@ -525,23 +523,14 @@ export class AgentHarness {
try { try {
const stream = await model.stream(messagesCopy, { signal }); const stream = await model.stream(messagesCopy, { signal });
for await (const chunk of stream) { for await (const chunk of stream) {
const contents: string[] = [];
if (typeof chunk.content === 'string' && chunk.content.length > 0) { if (typeof chunk.content === 'string' && chunk.content.length > 0) {
contents.push(chunk.content); yield { type: 'chunk', content: chunk.content };
} else if (Array.isArray(chunk.content)) { } else if (Array.isArray(chunk.content)) {
for (const block of chunk.content) { for (const block of chunk.content) {
if (block.type === 'text' && block.text) contents.push(block.text); if (block.type === 'text' && block.text) {
yield { type: 'chunk', content: block.text };
} }
} }
for (const content of contents) {
// DeepInfra/GLM streams tokens without leading spaces; inject one when
// both the tail of the previous chunk and the head of this chunk are
// word characters (\w), which would otherwise merge two words.
if (lastChunkTail && /\w/.test(lastChunkTail) && /\w/.test(content[0])) {
yield { type: 'chunk', content: ' ' };
}
lastChunkTail = content[content.length - 1];
yield { type: 'chunk', content };
} }
response = response ? response.concat(chunk) : chunk; response = response ? response.concat(chunk) : chunk;
} }
@@ -700,7 +689,6 @@ export class AgentHarness {
// After all tool calls complete, emit a space separator before the next LLM streaming pass // After all tool calls complete, emit a space separator before the next LLM streaming pass
yield { type: 'chunk', content: ' ' }; yield { type: 'chunk', content: ' ' };
lastChunkTail = ' ';
} }
// Max iterations reached - yield done with apology // Max iterations reached - yield done with apology

View File

@@ -1,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import ChartView from './components/ChartView.vue' import ChartView from './components/ChartView.vue'
import ChatPanel from './components/ChatPanel.vue' import ChatPanel from './components/ChatPanel.vue'
import LoginScreen from './components/LoginScreen.vue' import LoginScreen from './components/LoginScreen.vue'
@@ -17,10 +15,35 @@ import { authService } from './composables/useAuth'
const isAuthenticated = authService.isAuthenticated const isAuthenticated = authService.isAuthenticated
const authError = ref<string>() const authError = ref<string>()
const isDragging = ref(false)
const isMobile = ref(false) const isMobile = ref(false)
let stateSyncCleanup: (() => void) | null = null let stateSyncCleanup: (() => void) | null = null
// Horizontal split: chart width in pixels (initialized on mount)
const CHART_MIN_PX = 300
const CHAT_MIN_PX = 240
const CHART_DEFAULT_RATIO = 0.62
const chartWidth = ref(0)
let hDragStartX = 0
let hDragStartWidth = 0
function initChartWidth() {
chartWidth.value = Math.round(window.innerWidth * CHART_DEFAULT_RATIO)
}
function startHDrag(e: PointerEvent) {
e.preventDefault()
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
hDragStartX = e.clientX
hDragStartWidth = chartWidth.value
}
function onHDragMove(e: PointerEvent) {
if (!e.buttons) return
const delta = e.clientX - hDragStartX
const maxWidth = window.innerWidth - CHAT_MIN_PX - 4
chartWidth.value = Math.max(CHART_MIN_PX, Math.min(maxWidth, hDragStartWidth + delta))
}
// Check screen width for mobile layout // Check screen width for mobile layout
const checkMobile = () => { const checkMobile = () => {
isMobile.value = window.innerWidth < 768 isMobile.value = window.innerWidth < 768
@@ -31,7 +54,6 @@ const chartStore = useChartStore()
// Watch mobile state and update ChartStore // Watch mobile state and update ChartStore
watch(isMobile, (mobile) => { watch(isMobile, (mobile) => {
if (mobile) { if (mobile) {
// Set all chart state to null when chart is hidden
chartStore.symbol = null as any chartStore.symbol = null as any
chartStore.start_time = null chartStore.start_time = null
chartStore.end_time = null chartStore.end_time = null
@@ -41,13 +63,15 @@ watch(isMobile, (mobile) => {
// Check if user is already authenticated on page load // Check if user is already authenticated on page load
onMounted(async () => { onMounted(async () => {
// Try to restore session from stored token initChartWidth()
checkMobile()
window.addEventListener('resize', checkMobile)
const token = authService.getToken() const token = authService.getToken()
if (token) { if (token) {
const sessionValid = await authService.checkAuth() const sessionValid = await authService.checkAuth()
if (sessionValid) { if (sessionValid) {
isAuthenticated.value = true isAuthenticated.value = true
// Connect WebSocket with existing token
try { try {
await wsManager.connect(token) await wsManager.connect(token)
await initializeApp() await initializeApp()
@@ -56,33 +80,21 @@ onMounted(async () => {
} }
} }
} }
// Initialize mobile check
checkMobile()
window.addEventListener('resize', checkMobile)
}) })
const handleAuthenticate = async (email: string, password: string) => { const handleAuthenticate = async (email: string, password: string) => {
authError.value = undefined authError.value = undefined
try { try {
// Step 1: Login via HTTP to get JWT token
const result = await authService.login(email, password) const result = await authService.login(email, password)
if (!result.success) { if (!result.success) {
authError.value = result.error || 'Login failed' authError.value = result.error || 'Login failed'
return return
} }
if (!result.token) { if (!result.token) {
authError.value = 'No token received from server' authError.value = 'No token received from server'
return return
} }
// Step 2: Connect WebSocket with JWT token for real-time sync
await wsManager.connect(result.token) await wsManager.connect(result.token)
// Step 3: Initialize application
await initializeApp() await initializeApp()
} catch (err: any) { } catch (err: any) {
authError.value = err.message || 'Authentication failed' authError.value = err.message || 'Authentication failed'
@@ -91,7 +103,6 @@ const handleAuthenticate = async (email: string, password: string) => {
} }
const initializeApp = async () => { const initializeApp = async () => {
// Initialize state sync after successful authentication
const chartStore = useChartStore() const chartStore = useChartStore()
const shapeStore = useShapeStore() const shapeStore = useShapeStore()
const indicatorStore = useIndicatorStore() const indicatorStore = useIndicatorStore()
@@ -107,26 +118,9 @@ const initializeApp = async () => {
stateSyncCleanup = stateSync.cleanup stateSyncCleanup = stateSync.cleanup
} }
onMounted(() => {
// Listen for splitter drag events
document.addEventListener('mousedown', (e) => {
// Check if the mousedown is on a splitter gutter
const target = e.target as HTMLElement
if (target.closest('.p-splitter-gutter')) {
isDragging.value = true
}
})
document.addEventListener('mouseup', () => {
isDragging.value = false
})
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile) window.removeEventListener('resize', checkMobile)
if (stateSyncCleanup) { if (stateSyncCleanup) stateSyncCleanup()
stateSyncCleanup()
}
wsManager.disconnect() wsManager.disconnect()
}) })
</script> </script>
@@ -140,35 +134,28 @@ onBeforeUnmount(() => {
/> />
<div v-else-if="!isMobile" class="desktop-layout"> <div v-else-if="!isMobile" class="desktop-layout">
<div class="top-area"> <div class="top-area">
<Splitter class="main-splitter"> <div class="chart-panel" :style="{ width: chartWidth + 'px' }">
<SplitterPanel :size="62" :minSize="40" class="chart-panel">
<ChartView /> <ChartView />
</SplitterPanel> </div>
<SplitterPanel :size="38" :minSize="20" class="chat-panel"> <div class="h-grabber" @pointerdown="startHDrag" @pointermove="onHDragMove" />
<div class="chat-panel">
<ChatPanel /> <ChatPanel />
</SplitterPanel> </div>
</Splitter>
</div> </div>
<BottomTray /> <BottomTray />
</div> </div>
<div v-else class="mobile-layout"> <div v-else class="mobile-layout">
<ChatPanel /> <ChatPanel />
</div> </div>
<!-- Transparent overlay to prevent iframe from capturing mouse events during drag -->
<div v-if="isDragging" class="drag-overlay"></div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.app-container { .app-container {
width: 100vw !important; width: 100vw;
height: 100vh !important; height: 100vh;
overflow: hidden; overflow: hidden;
background: #0f0f0f !important; background: #0f0f0f;
}
.app-container.dark {
background: #0f0f0f !important;
} }
.desktop-layout { .desktop-layout {
@@ -181,46 +168,38 @@ onBeforeUnmount(() => {
.top-area { .top-area {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: hidden;
}
.main-splitter {
height: 100% !important;
background: #0f0f0f !important;
}
.main-splitter :deep(.p-splitter-gutter) {
background: #2e2e2e !important;
}
.main-splitter :deep(.p-splitter-gutter-handle) {
background: #2e2e2e !important;
}
.chart-panel,
.chat-panel {
height: 100% !important;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
overflow: hidden; overflow: hidden;
} }
.chart-panel :deep(.p-splitter-panel-content), .chart-panel {
.chat-panel :deep(.p-splitter-panel-content) { flex-shrink: 0;
height: 100%; height: 100%;
overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.drag-overlay { .h-grabber {
position: fixed; width: 4px;
top: 0; flex-shrink: 0;
left: 0; height: 100%;
right: 0; background: #2e2e2e;
bottom: 0; cursor: col-resize;
z-index: 9999; }
cursor: auto;
background: transparent; .h-grabber:hover {
background: #444;
}
.chat-panel {
flex: 1;
min-width: 0;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
} }
.mobile-layout { .mobile-layout {
@@ -230,10 +209,4 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 767px) {
.main-splitter {
display: none;
}
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onBeforeUnmount, type Component } from 'vue' import { ref, computed, type Component } from 'vue'
import Tabs from 'primevue/tabs' import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist' import TabList from 'primevue/tablist'
import Tab from 'primevue/tab' import Tab from 'primevue/tab'
@@ -53,29 +53,19 @@ function closeTab(tabId: string) {
let resizeStartY = 0 let resizeStartY = 0
let resizeStartHeight = 0 let resizeStartHeight = 0
function startResize(e: MouseEvent) { function startResize(e: PointerEvent) {
e.preventDefault() e.preventDefault()
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
resizeStartY = e.clientY resizeStartY = e.clientY
resizeStartHeight = expandedHeight.value resizeStartHeight = expandedHeight.value
document.addEventListener('mousemove', onResizeMove)
document.addEventListener('mouseup', stopResize)
} }
function onResizeMove(e: MouseEvent) { function onResizeMove(e: PointerEvent) {
if (!e.buttons) return
const delta = resizeStartY - e.clientY // dragging up increases height const delta = resizeStartY - e.clientY // dragging up increases height
expandedHeight.value = Math.max(MIN_EXPANDED, resizeStartHeight + delta) expandedHeight.value = Math.max(MIN_EXPANDED, resizeStartHeight + delta)
} }
function stopResize() {
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', stopResize)
}
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onResizeMove)
document.removeEventListener('mouseup', stopResize)
})
defineExpose({ defineExpose({
openTab(id: string, label: string, component: Component, props?: Record<string, any>) { openTab(id: string, label: string, component: Component, props?: Record<string, any>) {
const existing = tempTabs.value.find(t => t.id === id) const existing = tempTabs.value.find(t => t.id === id)
@@ -90,7 +80,7 @@ defineExpose({
<template> <template>
<div class="bottom-tray" :style="trayStyle"> <div class="bottom-tray" :style="trayStyle">
<div v-if="isExpanded" class="tray-resize-handle" @mousedown="startResize" /> <div v-if="isExpanded" class="tray-resize-handle" @pointerdown="startResize" @pointermove="onResizeMove" />
<Tabs :value="activeTab" class="tray-tabs"> <Tabs :value="activeTab" class="tray-tabs">
<TabList class="tray-tab-list"> <TabList class="tray-tab-list">
<Tab value="orders" @click="onTabClick('orders')">Orders</Tab> <Tab value="orders" @click="onTabClick('orders')">Orders</Tab>
@@ -107,7 +97,9 @@ defineExpose({
<button class="tab-close-btn" @click.stop="closeTab(tab.id)">×</button> <button class="tab-close-btn" @click.stop="closeTab(tab.id)">×</button>
</Tab> </Tab>
<div class="tray-spacer" /> <div class="tray-spacer" />
<button v-if="isExpanded" class="tray-close-btn" @click="isExpanded = false"></button> <button v-if="isExpanded" class="tray-close-btn" @click="isExpanded = false" title="Minimize">
<i class="pi pi-chevron-down" />
</button>
</TabList> </TabList>
<TabPanels v-if="isExpanded" class="tray-panels"> <TabPanels v-if="isExpanded" class="tray-panels">
<TabPanel value="orders" class="tray-panel"><OrdersTab /></TabPanel> <TabPanel value="orders" class="tray-panel"><OrdersTab /></TabPanel>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed, onBeforeUnmount, watch, nextTick } from 'vue'
import { register } from 'vue-advanced-chat' import { register } from 'vue-advanced-chat'
import Badge from 'primevue/badge' import Badge from 'primevue/badge'
import Button from 'primevue/button' import Button from 'primevue/button'
@@ -11,6 +11,26 @@ register()
const channelStore = useChannelStore() const channelStore = useChannelStore()
// Measure container height and feed a concrete pixel value to vue-advanced-chat,
// because height: 100% doesn't reliably resolve through flex chains.
const chatContainerRef = ref<HTMLElement | null>(null)
const chatHeight = ref('400px')
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
if (chatContainerRef.value) {
resizeObserver = new ResizeObserver(entries => {
const h = entries[0].contentRect.height
if (h > 0) chatHeight.value = h + 'px'
})
resizeObserver.observe(chatContainerRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
const SESSION_ID = 'default' const SESSION_ID = 'default'
const CURRENT_USER_ID = 'user-123' const CURRENT_USER_ID = 'user-123'
const AGENT_ID = 'agent' const AGENT_ID = 'agent'
@@ -543,16 +563,8 @@ onMounted(() => {
wsManager.addHandler(handleMessage) wsManager.addHandler(handleMessage)
setTimeout(() => { setTimeout(() => {
// Inject styles into shadow DOM to widen message bubbles // Inject styles into shadow DOM to widen message bubbles (fallback if already ready at mount)
const chatEl = document.querySelector('vue-advanced-chat') injectShadowStyles()
if (chatEl?.shadowRoot) {
const style = document.createElement('style')
style.textContent = `
.vac-message-wrapper .vac-message-box { max-width: 80%; }
.vac-message-wrapper .vac-offset-current { margin-left: 20%; }
`
chatEl.shadowRoot.appendChild(style)
}
const chatInput = document.querySelector('.vac-textarea') as HTMLTextAreaElement const chatInput = document.querySelector('.vac-textarea') as HTMLTextAreaElement
if (chatInput) { if (chatInput) {
@@ -561,13 +573,38 @@ onMounted(() => {
}, 300) }, 300)
}) })
const injectShadowStyles = () => {
const chatEl = document.querySelector('vue-advanced-chat')
if (chatEl?.shadowRoot) {
// Remove any previously injected style to avoid duplicates
chatEl.shadowRoot.querySelector('#vac-width-override')?.remove()
const style = document.createElement('style')
style.id = 'vac-width-override'
style.textContent = `
.vac-message-wrapper .vac-message-box { max-width: 80%; }
.vac-message-wrapper .vac-offset-current { margin-left: 20%; }
`
chatEl.shadowRoot.appendChild(style)
}
}
watch(() => channelStore.isReady, async (ready) => {
if (!ready) return
await nextTick()
setTimeout(() => {
injectShadowStyles()
const chatInput = document.querySelector('.vac-textarea') as HTMLTextAreaElement
chatInput?.focus()
}, 100)
})
onUnmounted(() => { onUnmounted(() => {
wsManager.removeHandler(handleMessage) wsManager.removeHandler(handleMessage)
}) })
</script> </script>
<template> <template>
<div class="chat-container"> <div class="chat-container" ref="chatContainerRef">
<!-- <!--
<div class="chat-header-custom"> <div class="chat-header-custom">
<span class="chat-title">AI Agent Chat</span> <span class="chat-title">AI Agent Chat</span>
@@ -586,7 +623,7 @@ onUnmounted(() => {
<vue-advanced-chat <vue-advanced-chat
v-else v-else
height="100vh" :height="chatHeight"
:current-user-id="CURRENT_USER_ID" :current-user-id="CURRENT_USER_ID"
:rooms="JSON.stringify(rooms)" :rooms="JSON.stringify(rooms)"
:messages="JSON.stringify(messages)" :messages="JSON.stringify(messages)"
@@ -640,13 +677,13 @@ onUnmounted(() => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
background: #131722; background: #0f0f0f;
color: #787B86; color: #888;
} }
.workspace-loading-spinner { .workspace-loading-spinner {
font-size: 2rem; font-size: 2rem;
color: #787B86; color: #089981;
} }
.workspace-loading-message { .workspace-loading-message {

View File

@@ -129,7 +129,7 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, var(--p-surface-0) 0%, var(--p-surface-100) 100%); background: #0f0f0f;
} }
.login-container { .login-container {
@@ -139,7 +139,7 @@ onMounted(() => {
} }
.login-card { .login-card {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.06);
} }
.login-title { .login-title {