feat: add @tag model override support and remove Qdrant dependencies

- Add model-tags parser for @Tag syntax in chat messages
- Support Anthropic models (Sonnet, Haiku, Opus) via @tag
- Remove Qdrant vector database from infrastructure and configs
- Simplify license model config to use null fallbacks
- Add greeting stream after model switch via @tag
- Fix protobuf field names to camelCase for v7 compatibility
- Add 429 rate limit retry logic with exponential backoff
- Remove RAG references from agent harness documentation
This commit is contained in:
2026-04-27 20:55:18 -04:00
parent 6f937f9e5e
commit d41fcd0499
50 changed files with 956 additions and 798 deletions

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import DetailsEditDialog from './DetailsEditDialog.vue'
import ResearchViewDialog from './ResearchViewDialog.vue'
import { useIndicatorStore } from '../stores/indicators'
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
import { useStrategyTypesStore } from '../stores/strategyTypes'
import { useResearchTypesStore } from '../stores/researchTypes'
const props = defineProps<{
category: 'indicator' | 'strategy' | 'research'
@@ -16,8 +20,13 @@ const editingName = ref('')
const viewDialogVisible = ref(false)
const viewingName = ref('')
const deleteDialogVisible = ref(false)
const deletingRow = ref<{ id: string; display_name: string } | null>(null)
const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
const strategyTypesStore = useStrategyTypesStore()
const researchTypesStore = useResearchTypesStore()
function openEdit(name: string) {
editingName.value = name
@@ -50,6 +59,25 @@ function addToChart(pandasTaName: string, displayName: string) {
})
}
function requestDelete(row: { id: string; display_name: string }) {
deletingRow.value = row
deleteDialogVisible.value = true
}
function confirmDelete() {
if (!deletingRow.value) return
const id = deletingRow.value.id
if (props.category === 'indicator') {
indicatorTypesStore.removeType(id)
} else if (props.category === 'strategy') {
strategyTypesStore.removeType(id)
} else {
researchTypesStore.removeType(id)
}
deleteDialogVisible.value = false
deletingRow.value = null
}
function onUpdated(_payload: { category: string; name: string; success: boolean; error?: string }) {
}
</script>
@@ -63,6 +91,7 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
<button class="edit-btn" @click="openEdit(row.display_name)">Spec</button>
<button v-if="category === 'research'" class="view-btn" @click="openView(row.display_name)">Result</button>
<button v-if="category === 'indicator'" class="use-btn" @click="addToChart(row.id, row.display_name)">Use</button>
<button class="delete-btn" title="Delete" @click.stop="requestDelete(row)"><i class="pi pi-trash" /></button>
</div>
</div>
@@ -79,6 +108,21 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
:name="editingName"
@updated="onUpdated"
/>
<Dialog
v-model:visible="deleteDialogVisible"
:modal="true"
:closable="true"
:header="`Delete ${category}?`"
:style="{ width: '360px' }"
class="delete-confirm-dialog"
>
<p class="delete-msg">Delete <strong>{{ deletingRow?.display_name }}</strong>? This cannot be undone.</p>
<template #footer>
<Button label="Cancel" size="small" outlined @click="deleteDialogVisible = false" />
<Button label="Delete" size="small" severity="danger" @click="confirmDelete" />
</template>
</Dialog>
</template>
<style scoped>
@@ -181,4 +225,33 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
border-color: #4a9eca;
color: #4a9eca;
}
.delete-btn {
flex-shrink: 0;
background: none;
border: none;
color: #555;
cursor: pointer;
font-size: 12px;
padding: 2px 4px;
border-radius: 3px;
line-height: 1;
display: flex;
align-items: center;
}
.delete-btn:hover {
color: #e06c6c;
}
.delete-msg {
color: #dbdbdb;
font-size: 13px;
margin: 0;
padding: 4px 0 8px;
}
.delete-msg strong {
color: #fff;
}
</style>

View File

@@ -41,6 +41,12 @@ const messages = ref<any[]>([])
const messagesLoaded = ref(false)
const isConnected = wsManager.isConnected
// Model tag state
const availableModelTags = ref<string[]>([])
const currentModelTag = ref<string | null>(null)
// Content typed after @Tag, held until model_switched arrives to re-insert as first user message
let pendingModelSwitchContent = ''
// Reactive rooms that update based on WebSocket connection and agent processing state
const rooms = computed(() => [{
roomId: SESSION_ID,
@@ -48,7 +54,8 @@ const rooms = computed(() => [{
avatar: null,
users: [
{ _id: CURRENT_USER_ID, username: 'You' },
{ _id: AGENT_ID, username: 'AI Agent' }
{ _id: AGENT_ID, username: 'AI Agent' },
...availableModelTags.value.map(tag => ({ _id: `model-${tag.toLowerCase().replace(/[^a-z0-9]/g, '-')}`, username: tag })),
],
unreadCount: 0,
typingUsers: isAgentProcessing.value ? [AGENT_ID] : []
@@ -153,6 +160,63 @@ const streamingImages = ref<any[]>([])
const handleMessage = (data: WebSocketMessage) => {
console.log('[ChatPanel] Received message:', data)
if (data.type === 'connected') {
if (Array.isArray(data.modelTags)) {
availableModelTags.value = data.modelTags
}
return
}
if (data.type === 'model_switched') {
// Reset all streaming state from previous conversation
currentStreamingMessageId = null
toolCallMessageId = null
lastSentMessageId = null
streamingBuffer = ''
streamingImages.value = []
subagentContentMap = new Map()
currentModelTag.value = data.tag ?? null
const timestamp = new Date().toTimeString().split(' ')[0].slice(0, 5)
const date = new Date().toLocaleDateString()
// System notice at the top of the new conversation
const systemMsg = {
_id: generateMessageId(),
content: `Switched to ${data.tag} — conversation history cleared`,
system: true,
timestamp,
date,
}
const newMessages: any[] = [systemMsg]
// Re-add the user's first message (content after the @Tag)
const userContent: string = pendingModelSwitchContent || data.rest || ''
if (userContent) {
const userMsgId = generateMessageId()
lastSentMessageId = userMsgId
newMessages.push({
_id: userMsgId,
content: userContent,
senderId: CURRENT_USER_ID,
timestamp,
date,
saved: true,
distributed: true,
seen: false,
files: [],
})
}
messages.value = newMessages
pendingModelSwitchContent = ''
// Show thinking bubble now that the message list is set up
addToolCallBubble('Thinking...')
return
}
if (data.type === 'conversation_history') {
messages.value = (data.messages as any[]).map((m: any) => {
const ts = new Date(m.timestamp / 1000) // microseconds → ms
@@ -189,7 +253,7 @@ const handleMessage = (data: WebSocketMessage) => {
return
}
if (data.type === 'subagent_chunk') {
if (data.type === 'subagent_chunk' || data.type === 'subagent_thinking') {
appendSubagentChunk(data.agentName, data.content)
return
}
@@ -363,7 +427,17 @@ const sendMessage = async (event: any) => {
// Extract data from CustomEvent.detail[0]
const data = event.detail?.[0] || event
const content = data.content || ''
// Convert any model @-tags from vue-advanced-chat's <usertag> encoding back to @TagName
const rawContent: string = data.content || ''
const usersTag: any[] = data.usersTag || []
let content = rawContent
let modelSwitchUser: any = null
for (const user of usersTag) {
if (typeof user._id === 'string' && user._id.startsWith('model-')) {
content = content.replace(`<usertag>${user._id}</usertag>`, `@${user.username}`)
if (!modelSwitchUser) modelSwitchUser = user
}
}
const files = data.files
const roomId = data.roomId
@@ -411,7 +485,24 @@ const sendMessage = async (event: any) => {
}
}
// Add user message to UI
// Send to backend via WebSocket
const wsMessage = {
type: 'agent_user_message',
session_id: roomId || SESSION_ID,
content: content,
attachments: attachments
}
if (modelSwitchUser) {
// Model switch: store content-after-tag for model_switched handler to re-insert.
// Don't add a user message bubble now — model_switched will set up the full initial state.
pendingModelSwitchContent = content.replace(`@${modelSwitchUser.username}`, '').trim()
wsManager.send(wsMessage)
isAgentProcessing.value = true
return
}
// Normal message: add to UI immediately
const userMessage = {
_id: messageId,
content: content,
@@ -425,14 +516,6 @@ const sendMessage = async (event: any) => {
}
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)
// Track this message so the agent_chunk handler can mark it seen
@@ -692,8 +775,12 @@ onUnmounted(() => {
<span class="workspace-loading-message">{{ channelStore.statusMessage || 'Connecting...' }}</span>
</div>
<template v-else>
<div v-if="currentModelTag" class="model-tag-badge">
Model: {{ currentModelTag }}
</div>
<vue-advanced-chat
v-else
:height="chatHeight"
:current-user-id="CURRENT_USER_ID"
:rooms="JSON.stringify(rooms)"
@@ -751,6 +838,7 @@ onUnmounted(() => {
</svg>
</div>
</vue-advanced-chat>
</template>
</div>
</template>
@@ -765,6 +853,20 @@ onUnmounted(() => {
position: relative;
}
.model-tag-badge {
position: absolute;
top: 6px;
right: 12px;
z-index: 10;
font-size: 0.7rem;
color: #089981;
background: rgba(8, 153, 129, 0.12);
border: 1px solid rgba(8, 153, 129, 0.3);
border-radius: 4px;
padding: 2px 7px;
pointer-events: none;
}
.workspace-loading {
position: fixed;
inset: 0;

View File

@@ -13,5 +13,6 @@ export interface CustomIndicatorType {
export const useIndicatorTypesStore = defineStore('indicator_types', () => {
const types = ref<Record<string, CustomIndicatorType>>({})
return { types }
const removeType = (id: string) => { delete types.value[id] }
return { types, removeType }
})

View File

@@ -10,5 +10,6 @@ export interface ResearchType {
export const useResearchTypesStore = defineStore('research_types', () => {
const types = ref<Record<string, ResearchType>>({})
return { types }
const removeType = (id: string) => { delete types.value[id] }
return { types, removeType }
})

View File

@@ -10,5 +10,6 @@ export interface StrategyType {
export const useStrategyTypesStore = defineStore('strategy_types', () => {
const types = ref<Record<string, StrategyType>>({})
return { types }
const removeType = (id: string) => { delete types.value[id] }
return { types, removeType }
})