major agent refactoring: wiki knowledge base, no RAG, no Qdrant, no Ollama

This commit is contained in:
2026-04-21 21:03:24 -04:00
parent 7e4b54d701
commit 44a1688657
80 changed files with 2699 additions and 4267 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import ChartView from './components/ChartView.vue'
import ChatPanel from './components/ChatPanel.vue'
import LoginScreen from './components/LoginScreen.vue'
@@ -20,44 +20,48 @@ const authError = ref<string>()
const isMobile = ref(false)
let stateSyncCleanup: (() => void) | null = null
// Horizontal split: chart width in pixels (initialized on mount)
// Horizontal split: chat width in pixels (fixed, anchored to right edge)
const CHART_MIN_PX = 300
const CHAT_MIN_PX = 240
const CHART_DEFAULT_RATIO = 0.62
const chartWidth = ref(0)
const CHAT_DEFAULT_RATIO = 0.38
const chatWidth = ref(0)
const windowWidth = ref(0)
let hDragStartX = 0
let hDragStartWidth = 0
function initChartWidth() {
chartWidth.value = Math.round(window.innerWidth * CHART_DEFAULT_RATIO)
const chartVisible = computed(() => windowWidth.value - chatWidth.value - 4 >= CHART_MIN_PX)
function initChatWidth() {
windowWidth.value = window.innerWidth
chatWidth.value = Math.round(window.innerWidth * CHAT_DEFAULT_RATIO)
}
function startHDrag(e: PointerEvent) {
e.preventDefault()
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
hDragStartX = e.clientX
hDragStartWidth = chartWidth.value
hDragStartWidth = chatWidth.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))
// dragging right shrinks chat; dragging left grows chat
const delta = hDragStartX - e.clientX
const maxChatWidth = windowWidth.value - CHART_MIN_PX - 4
chatWidth.value = Math.max(CHAT_MIN_PX, Math.min(Math.max(maxChatWidth, CHAT_MIN_PX), hDragStartWidth + delta))
}
// Clamp chartWidth so chart + chat always fit within the window
function clampChartWidth() {
const maxWidth = window.innerWidth - CHAT_MIN_PX - 4
if (maxWidth >= CHART_MIN_PX) {
chartWidth.value = Math.max(CHART_MIN_PX, Math.min(maxWidth, chartWidth.value))
}
// Clamp chatWidth so chat stays within valid range on resize
function clampChatWidth() {
windowWidth.value = window.innerWidth
const maxChatWidth = windowWidth.value - CHART_MIN_PX - 4
chatWidth.value = Math.max(CHAT_MIN_PX, Math.min(Math.max(maxChatWidth, CHAT_MIN_PX), chatWidth.value))
}
// Check screen width for mobile layout
const checkMobile = () => {
isMobile.value = window.innerWidth < 768
if (!isMobile.value) clampChartWidth()
if (!isMobile.value) clampChatWidth()
}
const chartStore = useChartStore()
@@ -74,7 +78,7 @@ watch(isMobile, (mobile) => {
// Check if user is already authenticated on page load
onMounted(async () => {
initChartWidth()
initChatWidth()
checkMobile()
window.addEventListener('resize', checkMobile)
@@ -149,11 +153,11 @@ onBeforeUnmount(() => {
/>
<div v-else-if="!isMobile" class="desktop-layout">
<div class="top-area">
<div class="chart-panel" :style="{ width: chartWidth + 'px' }">
<div v-if="chartVisible" class="chart-panel">
<ChartView />
</div>
<div class="h-grabber" @pointerdown="startHDrag" @pointermove="onHDragMove" />
<div class="chat-panel">
<div v-if="chartVisible" class="h-grabber" @pointerdown="startHDrag" @pointermove="onHDragMove" />
<div class="chat-panel" :style="{ width: chatWidth + 'px' }">
<ChatPanel />
</div>
</div>
@@ -189,7 +193,8 @@ onBeforeUnmount(() => {
}
.chart-panel {
flex-shrink: 0;
flex: 1;
min-width: 0;
height: 100%;
overflow: hidden;
display: flex;
@@ -209,8 +214,7 @@ onBeforeUnmount(() => {
}
.chat-panel {
flex: 1;
min-width: 240px;
flex-shrink: 0;
height: 100%;
overflow: hidden;
display: flex;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import DetailsEditDialog from './DetailsEditDialog.vue'
import ResearchViewDialog from './ResearchViewDialog.vue'
const props = defineProps<{
category: 'indicator' | 'strategy' | 'research'
@@ -10,11 +11,19 @@ const props = defineProps<{
const dialogVisible = ref(false)
const editingName = ref('')
const viewDialogVisible = ref(false)
const viewingName = ref('')
function openEdit(name: string) {
editingName.value = name
dialogVisible.value = true
}
function openView(name: string) {
viewingName.value = name
viewDialogVisible.value = true
}
function onUpdated(_payload: { category: string; name: string; success: boolean; error?: string }) {
// Hook for handling the details_updated response — add logic here as needed
}
@@ -26,10 +35,18 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
<div v-for="row in rows" :key="row.id" class="item-row">
<span class="item-name">{{ row.display_name }}</span>
<span class="item-desc">{{ row.description ?? '' }}</span>
<button v-if="category === 'research'" class="view-btn" @click="openView(row.display_name)">View</button>
<button class="edit-btn" @click="openEdit(row.display_name)">Edit</button>
</div>
</div>
<ResearchViewDialog
v-if="category === 'research'"
v-model:visible="viewDialogVisible"
:category="category"
:name="viewingName"
/>
<DetailsEditDialog
v-model:visible="dialogVisible"
:category="category"
@@ -88,6 +105,23 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent 100%);
}
.view-btn {
flex-shrink: 0;
background: none;
border: 1px solid #3d3d3d;
color: #888;
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
line-height: 18px;
}
.view-btn:hover {
border-color: #4a9eca;
color: #4a9eca;
}
.edit-btn {
flex-shrink: 0;
background: none;

View File

@@ -131,6 +131,93 @@ function close() {
<i class="pi pi-exclamation-triangle" /> {{ loadError }}
</div>
<div class="editor-wrap">
<div v-if="editor" class="toolbar">
<div class="toolbar-group">
<button
class="tb-btn"
:class="{ active: editor.isActive('bold') }"
title="Bold"
@click="editor.chain().focus().toggleBold().run()"
><b>B</b></button>
<button
class="tb-btn"
:class="{ active: editor.isActive('italic') }"
title="Italic"
@click="editor.chain().focus().toggleItalic().run()"
><i>I</i></button>
<button
class="tb-btn"
:class="{ active: editor.isActive('strike') }"
title="Strikethrough"
@click="editor.chain().focus().toggleStrike().run()"
><s>S</s></button>
</div>
<div class="toolbar-sep" />
<div class="toolbar-group">
<button
class="tb-btn"
:class="{ active: editor.isActive('heading', { level: 1 }) }"
title="Heading 1"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
>H1</button>
<button
class="tb-btn"
:class="{ active: editor.isActive('heading', { level: 2 }) }"
title="Heading 2"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>H2</button>
<button
class="tb-btn"
:class="{ active: editor.isActive('heading', { level: 3 }) }"
title="Heading 3"
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
>H3</button>
</div>
<div class="toolbar-sep" />
<div class="toolbar-group">
<button
class="tb-btn"
:class="{ active: editor.isActive('bulletList') }"
title="Bullet list"
@click="editor.chain().focus().toggleBulletList().run()"
><i class="pi pi-list" /></button>
<button
class="tb-btn"
:class="{ active: editor.isActive('orderedList') }"
title="Ordered list"
@click="editor.chain().focus().toggleOrderedList().run()"
><i class="pi pi-sort-amount-down" /></button>
</div>
<div class="toolbar-sep" />
<div class="toolbar-group">
<button
class="tb-btn"
:class="{ active: editor.isActive('code') }"
title="Inline code"
@click="editor.chain().focus().toggleCode().run()"
><code style="font-size:11px">`c`</code></button>
<button
class="tb-btn"
:class="{ active: editor.isActive('codeBlock') }"
title="Code block"
@click="editor.chain().focus().toggleCodeBlock().run()"
><i class="pi pi-code" /></button>
<button
class="tb-btn"
:class="{ active: editor.isActive('blockquote') }"
title="Blockquote"
@click="editor.chain().focus().toggleBlockquote().run()"
>&ldquo;&rdquo;</button>
</div>
<div class="toolbar-sep" />
<div class="toolbar-group">
<button
class="tb-btn"
title="Horizontal rule"
@click="editor.chain().focus().setHorizontalRule().run()"
></button>
</div>
</div>
<EditorContent :editor="editor" class="tiptap-editor" />
</div>
</template>
@@ -206,6 +293,59 @@ function close() {
overflow: auto;
background: #0d0d0d;
min-height: 300px;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 5px 8px;
border-bottom: 1px solid #2e2e2e;
background: #111;
flex-shrink: 0;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.toolbar-sep {
width: 1px;
height: 16px;
background: #2e2e2e;
margin: 0 4px;
}
.tb-btn {
background: none;
border: none;
color: #8a8a8a;
cursor: pointer;
padding: 3px 7px;
border-radius: 3px;
font-size: 12px;
font-family: inherit;
line-height: 1;
min-width: 26px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s, color 0.1s;
}
.tb-btn:hover {
background: #1e1e1e;
color: #dbdbdb;
}
.tb-btn.active {
background: #1e3a3f;
color: #89d4e0;
}
.confirm-overlay {
@@ -245,6 +385,11 @@ function close() {
<style>
/* Global (unscoped) so TipTap's .ProseMirror gets styled */
.details-dialog .tiptap-editor {
flex: 1;
overflow: auto;
}
.details-dialog .tiptap-editor .ProseMirror {
padding: 12px 14px;
outline: none;

View File

@@ -0,0 +1,294 @@
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import Dialog from 'primevue/dialog'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import { Markdown } from 'tiptap-markdown'
import { wsManager, type WebSocketMessage } from '../composables/useWebSocket'
const props = defineProps<{
visible: boolean
category: string
name: string
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
}>()
type LoadState = 'idle' | 'loading' | 'ready' | 'error' | 'empty'
const loadState = ref<LoadState>('idle')
const loadError = ref('')
const images = ref<Array<{ mimeType: string; data: string }>>([])
const lightboxSrc = ref<string | null>(null)
// Keep editable:true so setContent works reliably; read-only appearance is via CSS
const editor = useEditor({
extensions: [
StarterKit,
Markdown.configure({ html: false, transformPastedText: false }),
],
content: '',
})
watch(() => props.visible, (v) => {
if (v) {
loadState.value = 'loading'
loadError.value = ''
images.value = []
lightboxSrc.value = null
editor.value?.commands.setContent('')
wsManager.send({ type: 'read_output', category: props.category, name: props.name })
} else {
loadState.value = 'idle'
images.value = []
lightboxSrc.value = null
editor.value?.commands.setContent('')
}
})
const messageHandler = (msg: WebSocketMessage) => {
if (msg.category !== props.category || msg.name !== props.name) return
if (msg.type === 'output_data') {
const hasAnalysis = !!msg.analysis
const hasImages = Array.isArray(msg.images) && msg.images.length > 0
if (!hasAnalysis && !hasImages) {
loadState.value = 'empty'
return
}
if (hasAnalysis) {
editor.value?.commands.setContent(msg.analysis ?? '')
}
images.value = hasImages ? msg.images : []
loadState.value = 'ready'
} else if (msg.type === 'output_error') {
loadError.value = msg.error ?? 'Failed to load output'
loadState.value = 'error'
}
}
wsManager.addHandler(messageHandler)
onUnmounted(() => wsManager.removeHandler(messageHandler))
function openLightbox(src: string) {
lightboxSrc.value = src
}
function closeLightbox() {
lightboxSrc.value = null
}
</script>
<template>
<Dialog
:visible="visible"
:header="`Output — ${name}`"
:modal="true"
:closable="true"
:style="{ width: '800px', maxWidth: '95vw' }"
class="research-view-dialog"
@update:visible="emit('update:visible', false)"
>
<div class="dialog-body">
<div v-if="loadState === 'loading'" class="state-msg">
<i class="pi pi-spin pi-spinner" /> Loading output
</div>
<div v-else-if="loadState === 'error'" class="state-msg error">
<i class="pi pi-exclamation-triangle" /> {{ loadError }}
</div>
<div v-else-if="loadState === 'empty'" class="state-msg muted">
No output yet run the script first.
</div>
<div v-else-if="loadState === 'ready'" class="content-scroll">
<img
v-for="(img, idx) in images"
:key="idx"
:src="`data:${img.mimeType};base64,${img.data}`"
class="output-image"
:alt="`Chart ${idx + 1}`"
@click="openLightbox(`data:${img.mimeType};base64,${img.data}`)"
/>
<EditorContent v-if="editor" :editor="editor" class="tiptap-viewer" />
</div>
</div>
<!-- Lightbox overlay -->
<Teleport to="body">
<div v-if="lightboxSrc" class="lightbox-overlay" @click="closeLightbox">
<img :src="lightboxSrc" class="lightbox-img" alt="Full size" @click.stop />
<button class="lightbox-close" @click="closeLightbox">
<i class="pi pi-times" />
</button>
</div>
</Teleport>
</Dialog>
</template>
<style scoped>
.dialog-body {
min-height: 200px;
display: flex;
flex-direction: column;
}
.state-msg {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #8a8a8a;
font-size: 13px;
min-height: 200px;
}
.state-msg.error { color: #e06c6c; }
.state-msg.muted { color: #555; }
.content-scroll {
overflow-y: auto;
max-height: 65vh;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 4px;
}
.output-image {
max-width: 100%;
border-radius: 4px;
border: 1px solid #2e2e2e;
display: block;
cursor: zoom-in;
transition: border-color 0.15s;
}
.output-image:hover {
border-color: #4a9eca;
}
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: zoom-out;
}
.lightbox-img {
max-width: 92vw;
max-height: 92vh;
border-radius: 4px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6);
cursor: default;
}
.lightbox-close {
position: absolute;
top: 16px;
right: 20px;
background: rgba(30, 30, 30, 0.9);
border: 1px solid #3d3d3d;
color: #aaa;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 13px;
}
.lightbox-close:hover {
color: #fff;
border-color: #666;
}
</style>
<style>
/* Global so TipTap's .ProseMirror gets styled */
.research-view-dialog .tiptap-viewer .ProseMirror {
padding: 12px 14px;
outline: none;
font-size: 13px;
line-height: 1.6;
color: #dbdbdb;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
/* Read-only appearance without disabling setContent */
caret-color: transparent;
cursor: default;
}
.research-view-dialog .tiptap-viewer .ProseMirror h1,
.research-view-dialog .tiptap-viewer .ProseMirror h2,
.research-view-dialog .tiptap-viewer .ProseMirror h3 {
color: #eaeaea;
margin: 0.8em 0 0.4em;
font-size: 1.1em;
font-weight: 700;
letter-spacing: 0.01em;
}
.research-view-dialog .tiptap-viewer .ProseMirror h1 { font-size: 1.3em; }
.research-view-dialog .tiptap-viewer .ProseMirror h2 { font-size: 1.15em; }
.research-view-dialog .tiptap-viewer .ProseMirror p { margin: 0 0 0.6em; }
.research-view-dialog .tiptap-viewer .ProseMirror strong {
color: #eaeaea;
font-weight: 700;
}
.research-view-dialog .tiptap-viewer .ProseMirror em {
color: #bbb;
font-style: italic;
}
.research-view-dialog .tiptap-viewer .ProseMirror code {
background: #1a1a1a;
border-radius: 3px;
padding: 1px 5px;
font-size: 0.9em;
color: #89d4e0;
}
.research-view-dialog .tiptap-viewer .ProseMirror pre {
background: #141414;
border: 1px solid #2e2e2e;
border-radius: 4px;
padding: 10px 14px;
overflow-x: auto;
margin: 0.6em 0;
}
.research-view-dialog .tiptap-viewer .ProseMirror pre code {
background: none;
padding: 0;
color: #dbdbdb;
}
.research-view-dialog .tiptap-viewer .ProseMirror ul,
.research-view-dialog .tiptap-viewer .ProseMirror ol {
padding-left: 1.4em;
margin: 0.4em 0;
}
.research-view-dialog .tiptap-viewer .ProseMirror blockquote {
border-left: 3px solid #3d3d3d;
margin: 0.6em 0;
padding-left: 12px;
color: #8a8a8a;
}
.research-view-dialog .tiptap-viewer .ProseMirror hr {
border: none;
border-top: 1px solid #2e2e2e;
margin: 1em 0;
}
</style>

View File

@@ -553,6 +553,7 @@ export function useCustomIndicators(tvWidget: any) {
// ------------------------------------------------------------------
async function registerCustomStudy(indicator: IndicatorInstance) {
const meta = indicator.custom_metadata
?? indicatorTypesStore.types[indicator.pandas_ta_name]?.metadata
if (!meta) {
console.warn('[CustomIndicators] No custom_metadata on indicator:', indicator.id)
return