bugfix; web tabs
This commit is contained in:
4
doc/test_prompt.md
Normal file
4
doc/test_prompt.md
Normal file
@@ -0,0 +1,4 @@
|
||||
what conclusions can you make by analyzing historical data on ETH price direction changes near market session overlaps and market sessions changes on monday and tuesday?
|
||||
|
||||
---
|
||||
|
||||
@@ -196,7 +196,7 @@ export class DuckDBClient {
|
||||
// Fallback: scan Parquet files written directly to conversations bucket
|
||||
if (this.conversationsBucket) {
|
||||
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning Parquet cold storage');
|
||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/conversations/**/user_id=${userId}/${sessionId}.parquet`;
|
||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/conversations/year=*/month=*/user_id=${userId}/${sessionId}.parquet`;
|
||||
const fallbackSql = `
|
||||
SELECT id, user_id, session_id, role, content, metadata, timestamp
|
||||
FROM read_parquet('${parquetPath}')
|
||||
@@ -709,15 +709,15 @@ export class DuckDBClient {
|
||||
if (!tablePath) {
|
||||
// Fallback: scan per-turn Parquet files written directly to S3
|
||||
if (this.conversationsBucket) {
|
||||
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
|
||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/**/user_id=${userId}/${sessionId}_*.parquet`;
|
||||
this.logger.info({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
|
||||
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/year=*/month=*/user_id=${userId}/${sessionId}_*.parquet`;
|
||||
const idClause = blobIds?.length
|
||||
? `WHERE id IN (${blobIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')})`
|
||||
: '';
|
||||
try {
|
||||
return await this.query(`SELECT * FROM read_parquet('${parquetPath}') ${idClause} ORDER BY timestamp ASC`);
|
||||
} catch {
|
||||
// No blobs yet for this session
|
||||
} catch (err: any) {
|
||||
this.logger.debug({ err: err.message, userId, sessionId }, 'No blob Parquet files found for session');
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import type { HarnessEvent, SubagentChunkEvent, SubagentThinkingEvent } from '../harness-events.js';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
/**
|
||||
* Subagent configuration (loaded from config.yaml)
|
||||
@@ -73,44 +75,164 @@ export abstract class BaseSubagent {
|
||||
this.tools = tools || [];
|
||||
}
|
||||
|
||||
/** Per-subagent recursion limit for the LangGraph agent loop */
|
||||
protected abstract getRecursionLimit(): number;
|
||||
|
||||
/** Fallback text returned when the agent produces no output */
|
||||
protected abstract getFallbackText(): string;
|
||||
|
||||
/** Whether an MCP client is required; defaults true. Override to false for tool-only subagents. */
|
||||
protected requiresMCPClient(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize subagent: load system prompt and memory files
|
||||
* Build the system message and final human message for agent invocation.
|
||||
* Subclasses may override to augment the system message (e.g. injecting dynamic context).
|
||||
*/
|
||||
async initialize(basePath: string): Promise<void> {
|
||||
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
|
||||
|
||||
// Load system prompt
|
||||
if (this.config.systemPromptFile) {
|
||||
const promptPath = join(basePath, this.config.systemPromptFile);
|
||||
this.systemPrompt = await this.loadFile(promptPath);
|
||||
}
|
||||
|
||||
// Load memory files
|
||||
for (const memoryFile of this.config.memoryFiles) {
|
||||
const memoryPath = join(basePath, 'memory', memoryFile);
|
||||
const content = await this.loadFile(memoryPath);
|
||||
if (content) {
|
||||
this.memoryContext.push(`# ${memoryFile}\n\n${content}`);
|
||||
}
|
||||
protected async buildSystemMessage(
|
||||
context: SubagentContext,
|
||||
instruction: string
|
||||
): Promise<{ systemMessage: SystemMessage; humanMessage: BaseMessage }> {
|
||||
const msgs = this.buildMessages(context, instruction);
|
||||
return {
|
||||
systemMessage: msgs[0] as SystemMessage,
|
||||
humanMessage: msgs[msgs.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared execute body. Subclasses that need pre/post hooks (e.g. image capture)
|
||||
* override execute() and call this method internally.
|
||||
*/
|
||||
protected async executeAgent(context: SubagentContext, instruction: string): Promise<string> {
|
||||
this.logger.info(
|
||||
{
|
||||
subagent: this.config.name,
|
||||
memoryFiles: this.config.memoryFiles.length,
|
||||
systemPromptLoaded: !!this.systemPrompt,
|
||||
subagent: this.getName(),
|
||||
userId: context.userContext.userId,
|
||||
instruction: instruction.substring(0, 200),
|
||||
toolCount: this.tools.length,
|
||||
toolNames: this.tools.map(t => t.name),
|
||||
},
|
||||
'Subagent initialized'
|
||||
`${this.config.name} subagent starting`
|
||||
);
|
||||
|
||||
if (this.requiresMCPClient() && !this.hasMCPClient()) {
|
||||
throw new Error(`MCP client not available for ${this.config.name} subagent`);
|
||||
}
|
||||
|
||||
if (this.requiresMCPClient() && this.tools.length === 0) {
|
||||
this.logger.warn(`${this.config.name} subagent has no tools`);
|
||||
}
|
||||
|
||||
const { systemMessage, humanMessage } = await this.buildSystemMessage(context, instruction);
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage,
|
||||
});
|
||||
|
||||
const result = await agent.invoke(
|
||||
{ messages: [humanMessage] },
|
||||
{ recursionLimit: this.getRecursionLimit() }
|
||||
);
|
||||
|
||||
const allMessages: any[] = result.messages ?? [];
|
||||
|
||||
this.logger.info(
|
||||
{ messageCount: allMessages.length },
|
||||
`${this.config.name} subagent graph completed`
|
||||
);
|
||||
|
||||
const lastAI = [...allMessages].reverse().find(
|
||||
(m: any) => m.constructor?.name === 'AIMessage' || m._getType?.() === 'ai'
|
||||
);
|
||||
|
||||
const finalText = lastAI
|
||||
? (typeof lastAI.content === 'string' ? lastAI.content : JSON.stringify(lastAI.content))
|
||||
: this.getFallbackText();
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, `${this.config.name} subagent finished`);
|
||||
|
||||
return finalText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute subagent with given input
|
||||
* Execute subagent. Delegates to executeAgent by default;
|
||||
* subclasses with side-effects (image capture, etc.) override this.
|
||||
*/
|
||||
abstract execute(
|
||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
||||
return this.executeAgent(context, instruction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared streamEvents loop. Subclasses that need pre/post hooks override streamEvents()
|
||||
* and delegate here via `yield* this.streamEventsCore(...)`.
|
||||
*/
|
||||
protected async *streamEventsCore(
|
||||
context: SubagentContext,
|
||||
input: string
|
||||
): Promise<string>;
|
||||
instruction: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<HarnessEvent, string> {
|
||||
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
|
||||
|
||||
if (this.requiresMCPClient() && !this.hasMCPClient()) {
|
||||
throw new Error(`MCP client not available for ${this.config.name} subagent`);
|
||||
}
|
||||
|
||||
const { systemMessage, humanMessage } = await this.buildSystemMessage(context, instruction);
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage,
|
||||
});
|
||||
|
||||
const stream = agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{ streamMode: ['messages', 'updates'], recursionLimit: this.getRecursionLimit(), signal }
|
||||
);
|
||||
|
||||
let finalText = '';
|
||||
|
||||
for await (const [mode, data] of await stream) {
|
||||
if (signal?.aborted) break;
|
||||
if (mode === 'messages') {
|
||||
for (const chunk of BaseSubagent.extractStreamChunks(data, this.config.name)) {
|
||||
yield chunk;
|
||||
}
|
||||
} else if (mode === 'updates') {
|
||||
if ((data as any).agent?.messages) {
|
||||
for (const msg of (data as any).agent.messages as any[]) {
|
||||
if (msg.tool_calls?.length) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: tc.name, label: tc.name };
|
||||
}
|
||||
} else {
|
||||
const content = BaseSubagent.extractFinalText(msg);
|
||||
if (content) finalText = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
||||
return finalText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream typed HarnessEvents during execution. Delegates to streamEventsCore by default.
|
||||
* Subclasses with pre/post logic override this and use `yield* this.streamEventsCore(...)`.
|
||||
*/
|
||||
async *streamEvents(
|
||||
context: SubagentContext,
|
||||
input: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<HarnessEvent, string> {
|
||||
return yield* this.streamEventsCore(context, input, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream execution (optional, default to non-streaming)
|
||||
@@ -169,17 +291,42 @@ export abstract class BaseSubagent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream typed HarnessEvents during execution.
|
||||
* Subclasses override this to emit subagent_chunk / subagent_tool_call events
|
||||
* using agent.stream() from LangGraph. Default falls back to execute().
|
||||
* Initialize subagent: load system prompt and memory files
|
||||
*/
|
||||
async *streamEvents(
|
||||
context: SubagentContext,
|
||||
input: string,
|
||||
_signal?: AbortSignal,
|
||||
): AsyncGenerator<HarnessEvent, string> {
|
||||
const result = await this.execute(context, input);
|
||||
return result;
|
||||
async initialize(basePath: string): Promise<void> {
|
||||
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
|
||||
|
||||
// Load system prompt
|
||||
if (this.config.systemPromptFile) {
|
||||
const promptPath = join(basePath, this.config.systemPromptFile);
|
||||
this.systemPrompt = await this.loadFile(promptPath);
|
||||
}
|
||||
|
||||
// Load memory files
|
||||
for (const memoryFile of this.config.memoryFiles) {
|
||||
const memoryPath = join(basePath, 'memory', memoryFile);
|
||||
const content = await this.loadFile(memoryPath);
|
||||
if (content) {
|
||||
this.memoryContext.push(`# ${memoryFile}\n\n${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
subagent: this.config.name,
|
||||
memoryFiles: this.config.memoryFiles.length,
|
||||
systemPromptLoaded: !!this.systemPrompt,
|
||||
},
|
||||
'Subagent initialized'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config.yaml from basePath and parse it.
|
||||
*/
|
||||
static async loadConfig(basePath: string): Promise<SubagentConfig> {
|
||||
const configContent = await readFile(join(basePath, 'config.yaml'), 'utf-8');
|
||||
return yaml.load(configContent) as SubagentConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
||||
import { BaseSubagent } from '../base-subagent.js';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
|
||||
/**
|
||||
* Indicator Subagent
|
||||
@@ -14,127 +11,10 @@ import type { HarnessEvent } from '../../harness-events.js';
|
||||
* - Read, add, modify, and remove indicators from the indicators store
|
||||
* - Create custom indicator scripts via python_* tools
|
||||
* - Validate indicators using the evaluate_indicator tool
|
||||
*
|
||||
* Simpler than ResearchSubagent — no image capture needed.
|
||||
*/
|
||||
export class IndicatorSubagent extends BaseSubagent {
|
||||
constructor(
|
||||
config: SubagentConfig,
|
||||
model: BaseChatModel,
|
||||
logger: FastifyBaseLogger,
|
||||
mcpClient?: MCPClientConnector,
|
||||
tools?: any[]
|
||||
) {
|
||||
super(config, model, logger, mcpClient, tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute indicator request using LangGraph's createReactAgent.
|
||||
*/
|
||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
||||
this.logger.info(
|
||||
{
|
||||
subagent: this.getName(),
|
||||
userId: context.userContext.userId,
|
||||
instruction: instruction.substring(0, 200),
|
||||
toolCount: this.tools.length,
|
||||
toolNames: this.tools.map(t => t.name),
|
||||
},
|
||||
'Indicator subagent starting'
|
||||
);
|
||||
|
||||
if (!this.hasMCPClient()) {
|
||||
throw new Error('MCP client not available for indicator subagent');
|
||||
}
|
||||
|
||||
if (this.tools.length === 0) {
|
||||
this.logger.warn('Indicator subagent has no tools — cannot read or patch workspace');
|
||||
}
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
const systemMessage = initialMessages[0];
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage as SystemMessage,
|
||||
});
|
||||
|
||||
const result = await agent.invoke(
|
||||
{ messages: [humanMessage] },
|
||||
{ recursionLimit: 25 }
|
||||
);
|
||||
|
||||
const allMessages: any[] = result.messages ?? [];
|
||||
|
||||
this.logger.info(
|
||||
{ messageCount: allMessages.length },
|
||||
'Indicator subagent graph completed'
|
||||
);
|
||||
|
||||
const lastAI = [...allMessages].reverse().find(
|
||||
(m: any) => m.constructor?.name === 'AIMessage' || m._getType?.() === 'ai'
|
||||
);
|
||||
|
||||
const finalText = lastAI
|
||||
? (typeof lastAI.content === 'string' ? lastAI.content : JSON.stringify(lastAI.content))
|
||||
: 'Indicator update completed.';
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'Indicator subagent finished');
|
||||
|
||||
return finalText;
|
||||
}
|
||||
|
||||
async *streamEvents(context: SubagentContext, instruction: string, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
|
||||
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
|
||||
|
||||
if (!this.hasMCPClient()) {
|
||||
throw new Error('MCP client not available for indicator subagent');
|
||||
}
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
const systemMessage = initialMessages[0];
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage as SystemMessage,
|
||||
});
|
||||
|
||||
const stream = agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{ streamMode: ['messages', 'updates'], recursionLimit: 25, signal }
|
||||
);
|
||||
|
||||
let finalText = '';
|
||||
|
||||
for await (const [mode, data] of await stream) {
|
||||
if (signal?.aborted) break;
|
||||
if (mode === 'messages') {
|
||||
for (const chunk of IndicatorSubagent.extractStreamChunks(data, this.config.name)) {
|
||||
yield chunk;
|
||||
}
|
||||
} else if (mode === 'updates') {
|
||||
if ((data as any).agent?.messages) {
|
||||
for (const msg of (data as any).agent.messages as any[]) {
|
||||
if (msg.tool_calls?.length) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: tc.name, label: tc.name };
|
||||
}
|
||||
} else {
|
||||
const content = IndicatorSubagent.extractFinalText(msg);
|
||||
if (content) finalText = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
||||
return finalText;
|
||||
}
|
||||
protected getRecursionLimit() { return 25; }
|
||||
protected getFallbackText() { return 'Indicator update completed.'; }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,16 +27,8 @@ export async function createIndicatorSubagent(
|
||||
mcpClient?: MCPClientConnector,
|
||||
tools?: any[]
|
||||
): Promise<IndicatorSubagent> {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
const yaml = await import('js-yaml');
|
||||
|
||||
const configPath = join(basePath, 'config.yaml');
|
||||
const configContent = await readFile(configPath, 'utf-8');
|
||||
const config = yaml.load(configContent) as SubagentConfig;
|
||||
|
||||
const config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new IndicatorSubagent(config, model, logger, mcpClient, tools);
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
return subagent;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
@@ -47,6 +47,9 @@ export class ResearchSubagent extends BaseSubagent {
|
||||
super(config, model, logger, mcpClient, tools);
|
||||
}
|
||||
|
||||
protected getRecursionLimit() { return 40; }
|
||||
protected getFallbackText() { return 'Research completed.'; }
|
||||
|
||||
setImageCapture(capture: Array<{data: string; mimeType: string}>): void {
|
||||
this.imageCapture = capture;
|
||||
}
|
||||
@@ -103,81 +106,38 @@ export class ResearchSubagent extends BaseSubagent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Augment system message with custom indicators section.
|
||||
*/
|
||||
protected async buildSystemMessage(
|
||||
context: SubagentContext,
|
||||
instruction: string
|
||||
): Promise<{ systemMessage: SystemMessage; humanMessage: BaseMessage }> {
|
||||
const { systemMessage, humanMessage } = await super.buildSystemMessage(context, instruction);
|
||||
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
||||
if (customIndicatorsSection) {
|
||||
const base = typeof systemMessage.content === 'string'
|
||||
? systemMessage.content
|
||||
: JSON.stringify(systemMessage.content);
|
||||
return { systemMessage: new SystemMessage(base + customIndicatorsSection), humanMessage };
|
||||
}
|
||||
return { systemMessage, humanMessage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute research request using LangGraph's createReactAgent.
|
||||
* This is the standard LangChain pattern for agents with tool access —
|
||||
* createReactAgent handles the tool calling loop automatically.
|
||||
* Wraps executeAgent to manage image capture state.
|
||||
*/
|
||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
||||
this.logger.info(
|
||||
{
|
||||
subagent: this.getName(),
|
||||
userId: context.userContext.userId,
|
||||
instruction: instruction.substring(0, 200),
|
||||
toolCount: this.tools.length,
|
||||
toolNames: this.tools.map(t => t.name),
|
||||
},
|
||||
'Research subagent starting'
|
||||
);
|
||||
|
||||
if (!this.hasMCPClient()) {
|
||||
throw new Error('MCP client not available for research subagent');
|
||||
}
|
||||
|
||||
if (this.tools.length === 0) {
|
||||
this.logger.warn('Research subagent has no tools — cannot write or execute scripts');
|
||||
}
|
||||
|
||||
// Clear previous images (in-place so tool wrappers keep the same array reference)
|
||||
this.imageCapture.length = 0;
|
||||
this.lastImages = [];
|
||||
|
||||
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
||||
|
||||
// Build system prompt (with memory context appended)
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
// buildMessages returns [SystemMessage, ...history, HumanMessage]
|
||||
// Extract system content for createReactAgent's prompt parameter
|
||||
let systemMessage = initialMessages[0] as SystemMessage;
|
||||
if (customIndicatorsSection) {
|
||||
const base = typeof systemMessage.content === 'string' ? systemMessage.content : JSON.stringify(systemMessage.content);
|
||||
systemMessage = new SystemMessage(base + customIndicatorsSection);
|
||||
}
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
// createReactAgent is the standard LangChain/LangGraph pattern for tool-using agents.
|
||||
// It manages the tool calling loop, message accumulation, and termination automatically.
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage,
|
||||
});
|
||||
|
||||
const result = await agent.invoke(
|
||||
{ messages: [humanMessage] },
|
||||
{ recursionLimit: 40 }
|
||||
);
|
||||
|
||||
// The final message in the graph output is the agent's last AIMessage
|
||||
const allMessages: any[] = result.messages ?? [];
|
||||
|
||||
this.logger.info(
|
||||
{ messageCount: allMessages.length },
|
||||
'Research subagent graph completed'
|
||||
);
|
||||
const finalText = await this.executeAgent(context, instruction);
|
||||
|
||||
// Images were captured in real-time by the MCP tool wrappers into this.imageCapture
|
||||
this.lastImages = [...this.imageCapture];
|
||||
|
||||
// Return the final AI response
|
||||
const lastAI = [...allMessages].reverse().find(
|
||||
(m: any) => m.constructor?.name === 'AIMessage' || m._getType?.() === 'ai'
|
||||
);
|
||||
|
||||
const finalText = lastAI
|
||||
? (typeof lastAI.content === 'string' ? lastAI.content : JSON.stringify(lastAI.content))
|
||||
: 'Research completed.';
|
||||
|
||||
this.logger.info(
|
||||
{ textLength: finalText.length, imageCount: this.lastImages.length },
|
||||
'Research subagent finished'
|
||||
@@ -225,73 +185,7 @@ export class ResearchSubagent extends BaseSubagent {
|
||||
// the first `updates` event fires (after the LLM finishes its first response).
|
||||
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: 'Thinking...', label: 'Thinking...' };
|
||||
|
||||
const customIndicatorsSection = await this.fetchCustomIndicatorsSection();
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
let systemMessage = initialMessages[0] as SystemMessage;
|
||||
if (customIndicatorsSection) {
|
||||
const base = typeof systemMessage.content === 'string' ? systemMessage.content : JSON.stringify(systemMessage.content);
|
||||
systemMessage = new SystemMessage(base + customIndicatorsSection);
|
||||
}
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage,
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
{ toolCount: this.tools.length, toolNames: this.tools.map(t => t.name) },
|
||||
'Research subagent: starting stream with tools'
|
||||
);
|
||||
|
||||
const systemChars = typeof systemMessage.content === 'string'
|
||||
? systemMessage.content.length
|
||||
: JSON.stringify(systemMessage.content).length;
|
||||
const humanChars = typeof humanMessage.content === 'string'
|
||||
? humanMessage.content.length
|
||||
: JSON.stringify(humanMessage.content).length;
|
||||
this.logger.info(
|
||||
{ systemChars, humanChars, approxInputKB: Math.round((systemChars + humanChars) / 1024) },
|
||||
'Research subagent: input context size'
|
||||
);
|
||||
|
||||
const stream = agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{ streamMode: ['messages', 'updates'], recursionLimit: 40, signal }
|
||||
);
|
||||
|
||||
let finalText = '';
|
||||
let updateCount = 0;
|
||||
|
||||
for await (const [mode, data] of await stream) {
|
||||
if (signal?.aborted) break;
|
||||
if (mode === 'messages') {
|
||||
// Real-time token streaming from the LLM — data is [BaseMessage, metadata]
|
||||
for (const chunk of ResearchSubagent.extractStreamChunks(data, this.config.name)) {
|
||||
yield chunk;
|
||||
}
|
||||
} else if (mode === 'updates') {
|
||||
updateCount++;
|
||||
const updateKeys = Object.keys(data as any);
|
||||
this.logger.debug({ updateCount, updateKeys }, 'Research subagent: graph update');
|
||||
// Agent node fired — yield tool call decisions before tools run
|
||||
if ((data as any).agent?.messages) {
|
||||
for (const msg of (data as any).agent.messages as any[]) {
|
||||
if (msg.tool_calls?.length) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: tc.name, label: tc.name };
|
||||
}
|
||||
} else {
|
||||
// Capture final text for return value (already streamed via messages above)
|
||||
const content = ResearchSubagent.extractFinalText(msg);
|
||||
if (content) finalText = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const finalText = yield* this.streamEventsCore(context, instruction, signal);
|
||||
|
||||
this.lastImages = [...this.imageCapture];
|
||||
if (!finalText) {
|
||||
@@ -309,7 +203,7 @@ export class ResearchSubagent extends BaseSubagent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream research execution
|
||||
* Stream research execution (raw model streaming, no agent loop)
|
||||
*/
|
||||
async *stream(context: SubagentContext, instruction: string): AsyncGenerator<string> {
|
||||
this.logger.info(
|
||||
@@ -351,21 +245,11 @@ export async function createResearchSubagent(
|
||||
tools?: any[],
|
||||
imageCapture?: Array<{data: string; mimeType: string}>
|
||||
): Promise<ResearchSubagent> {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
const yaml = await import('js-yaml');
|
||||
|
||||
// Load config
|
||||
const configPath = join(basePath, 'config.yaml');
|
||||
const configContent = await readFile(configPath, 'utf-8');
|
||||
const config = yaml.load(configContent) as SubagentConfig;
|
||||
|
||||
// Create and initialize subagent
|
||||
const config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new ResearchSubagent(config, model, logger, mcpClient, tools);
|
||||
if (imageCapture !== undefined) {
|
||||
subagent.setImageCapture(imageCapture);
|
||||
}
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
return subagent;
|
||||
}
|
||||
|
||||
@@ -1,137 +1,17 @@
|
||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
||||
import { BaseSubagent } from '../base-subagent.js';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { MCPClientConnector } from '../../mcp-client.js';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
|
||||
/**
|
||||
* Strategy Subagent
|
||||
*
|
||||
* Specialized agent for writing PandasStrategy classes, running backtests,
|
||||
* and managing strategy activation/deactivation.
|
||||
*
|
||||
* Mirrors the pattern of IndicatorSubagent in indicator/index.ts.
|
||||
*/
|
||||
export class StrategySubagent extends BaseSubagent {
|
||||
constructor(
|
||||
config: SubagentConfig,
|
||||
model: BaseChatModel,
|
||||
logger: FastifyBaseLogger,
|
||||
mcpClient?: MCPClientConnector,
|
||||
tools?: any[]
|
||||
) {
|
||||
super(config, model, logger, mcpClient, tools);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a strategy request using LangGraph's createReactAgent.
|
||||
*/
|
||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
||||
this.logger.info(
|
||||
{
|
||||
subagent: this.getName(),
|
||||
userId: context.userContext.userId,
|
||||
instruction: instruction.substring(0, 200),
|
||||
toolCount: this.tools.length,
|
||||
toolNames: this.tools.map(t => t.name),
|
||||
},
|
||||
'Strategy subagent starting'
|
||||
);
|
||||
|
||||
if (!this.hasMCPClient()) {
|
||||
throw new Error('MCP client not available for strategy subagent');
|
||||
}
|
||||
|
||||
if (this.tools.length === 0) {
|
||||
this.logger.warn('Strategy subagent has no tools');
|
||||
}
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
const systemMessage = initialMessages[0];
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage as SystemMessage,
|
||||
});
|
||||
|
||||
const result = await agent.invoke(
|
||||
{ messages: [humanMessage] },
|
||||
{ recursionLimit: 30 }
|
||||
);
|
||||
|
||||
const allMessages: any[] = result.messages ?? [];
|
||||
|
||||
this.logger.info(
|
||||
{ messageCount: allMessages.length },
|
||||
'Strategy subagent graph completed'
|
||||
);
|
||||
|
||||
const lastAI = [...allMessages].reverse().find(
|
||||
(m: any) => m.constructor?.name === 'AIMessage' || m._getType?.() === 'ai'
|
||||
);
|
||||
|
||||
const finalText = lastAI
|
||||
? (typeof lastAI.content === 'string' ? lastAI.content : JSON.stringify(lastAI.content))
|
||||
: 'Strategy task completed.';
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'Strategy subagent finished');
|
||||
|
||||
return finalText;
|
||||
}
|
||||
|
||||
async *streamEvents(context: SubagentContext, instruction: string, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
|
||||
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
|
||||
|
||||
if (!this.hasMCPClient()) {
|
||||
throw new Error('MCP client not available for strategy subagent');
|
||||
}
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
const systemMessage = initialMessages[0];
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage as SystemMessage,
|
||||
});
|
||||
|
||||
const stream = agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{ streamMode: ['messages', 'updates'], recursionLimit: 30, signal }
|
||||
);
|
||||
|
||||
let finalText = '';
|
||||
|
||||
for await (const [mode, data] of await stream) {
|
||||
if (signal?.aborted) break;
|
||||
if (mode === 'messages') {
|
||||
for (const chunk of StrategySubagent.extractStreamChunks(data, this.config.name)) {
|
||||
yield chunk;
|
||||
}
|
||||
} else if (mode === 'updates') {
|
||||
if ((data as any).agent?.messages) {
|
||||
for (const msg of (data as any).agent.messages as any[]) {
|
||||
if (msg.tool_calls?.length) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: tc.name, label: tc.name };
|
||||
}
|
||||
} else {
|
||||
const content = StrategySubagent.extractFinalText(msg);
|
||||
if (content) finalText = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
||||
return finalText;
|
||||
}
|
||||
protected getRecursionLimit() { return 30; }
|
||||
protected getFallbackText() { return 'Strategy task completed.'; }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,16 +24,8 @@ export async function createStrategySubagent(
|
||||
mcpClient?: MCPClientConnector,
|
||||
tools?: any[]
|
||||
): Promise<StrategySubagent> {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
const yaml = await import('js-yaml');
|
||||
|
||||
const configPath = join(basePath, 'config.yaml');
|
||||
const configContent = await readFile(configPath, 'utf-8');
|
||||
const config = yaml.load(configContent) as SubagentConfig;
|
||||
|
||||
const config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new StrategySubagent(config, model, logger, mcpClient, tools);
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
return subagent;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
|
||||
import { BaseSubagent, type SubagentConfig } from '../base-subagent.js';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { HarnessEvent } from '../../harness-events.js';
|
||||
|
||||
/**
|
||||
* Web Explore Subagent
|
||||
@@ -24,95 +21,9 @@ export class WebExploreSubagent extends BaseSubagent {
|
||||
super(config, model, logger, undefined, tools);
|
||||
}
|
||||
|
||||
async execute(context: SubagentContext, instruction: string): Promise<string> {
|
||||
this.logger.info(
|
||||
{
|
||||
subagent: this.getName(),
|
||||
userId: context.userContext.userId,
|
||||
instruction: instruction.substring(0, 200),
|
||||
toolCount: this.tools.length,
|
||||
toolNames: this.tools.map(t => t.name),
|
||||
},
|
||||
'Web explore subagent starting'
|
||||
);
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
const systemMessage = initialMessages[0];
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage as SystemMessage,
|
||||
});
|
||||
|
||||
const result = await agent.invoke(
|
||||
{ messages: [humanMessage] },
|
||||
{ recursionLimit: 15 }
|
||||
);
|
||||
|
||||
const allMessages: any[] = result.messages ?? [];
|
||||
|
||||
this.logger.info({ messageCount: allMessages.length }, 'Web explore subagent graph completed');
|
||||
|
||||
const lastAI = [...allMessages].reverse().find(
|
||||
(m: any) => m.constructor?.name === 'AIMessage' || m._getType?.() === 'ai'
|
||||
);
|
||||
|
||||
const finalText = lastAI
|
||||
? (typeof lastAI.content === 'string' ? lastAI.content : JSON.stringify(lastAI.content))
|
||||
: 'No results found.';
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'Web explore subagent finished');
|
||||
|
||||
return finalText;
|
||||
}
|
||||
|
||||
async *streamEvents(context: SubagentContext, instruction: string, signal?: AbortSignal): AsyncGenerator<HarnessEvent, string> {
|
||||
this.logger.info({ subagent: this.getName() }, 'streamEvents starting');
|
||||
|
||||
const initialMessages = this.buildMessages(context, instruction);
|
||||
const systemMessage = initialMessages[0];
|
||||
const humanMessage = initialMessages[initialMessages.length - 1];
|
||||
|
||||
const agent = createReactAgent({
|
||||
llm: this.model,
|
||||
tools: this.tools,
|
||||
prompt: systemMessage as SystemMessage,
|
||||
});
|
||||
|
||||
const stream = agent.stream(
|
||||
{ messages: [humanMessage] },
|
||||
{ streamMode: ['messages', 'updates'], recursionLimit: 15, signal }
|
||||
);
|
||||
|
||||
let finalText = '';
|
||||
|
||||
for await (const [mode, data] of await stream) {
|
||||
if (signal?.aborted) break;
|
||||
if (mode === 'messages') {
|
||||
for (const chunk of WebExploreSubagent.extractStreamChunks(data, this.config.name)) {
|
||||
yield chunk;
|
||||
}
|
||||
} else if (mode === 'updates') {
|
||||
if ((data as any).agent?.messages) {
|
||||
for (const msg of (data as any).agent.messages as any[]) {
|
||||
if (msg.tool_calls?.length) {
|
||||
for (const tc of msg.tool_calls) {
|
||||
yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: tc.name, label: tc.name };
|
||||
}
|
||||
} else {
|
||||
const content = WebExploreSubagent.extractFinalText(msg);
|
||||
if (content) finalText = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info({ textLength: finalText.length }, 'streamEvents finished');
|
||||
return finalText;
|
||||
}
|
||||
protected getRecursionLimit() { return 15; }
|
||||
protected getFallbackText() { return 'No results found.'; }
|
||||
protected requiresMCPClient() { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,16 +35,8 @@ export async function createWebExploreSubagent(
|
||||
basePath: string,
|
||||
tools?: any[]
|
||||
): Promise<WebExploreSubagent> {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const { join } = await import('path');
|
||||
const yaml = await import('js-yaml');
|
||||
|
||||
const configPath = join(basePath, 'config.yaml');
|
||||
const configContent = await readFile(configPath, 'utf-8');
|
||||
const config = yaml.load(configContent) as SubagentConfig;
|
||||
|
||||
const config = await BaseSubagent.loadConfig(basePath);
|
||||
const subagent = new WebExploreSubagent(config, model, logger, tools);
|
||||
await subagent.initialize(basePath);
|
||||
|
||||
return subagent;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import SplitterPanel from 'primevue/splitterpanel'
|
||||
import ChartView from './components/ChartView.vue'
|
||||
import ChatPanel from './components/ChatPanel.vue'
|
||||
import LoginScreen from './components/LoginScreen.vue'
|
||||
import BottomTray from './components/BottomTray.vue'
|
||||
import { useChartStore } from './stores/chart'
|
||||
import { useShapeStore } from './stores/shapes'
|
||||
import { useIndicatorStore } from './stores/indicators'
|
||||
@@ -137,7 +138,9 @@ onBeforeUnmount(() => {
|
||||
:error-message="authError"
|
||||
@authenticate="handleAuthenticate"
|
||||
/>
|
||||
<Splitter v-else-if="!isMobile" class="main-splitter">
|
||||
<div v-else-if="!isMobile" class="desktop-layout">
|
||||
<div class="top-area">
|
||||
<Splitter class="main-splitter">
|
||||
<SplitterPanel :size="62" :minSize="40" class="chart-panel">
|
||||
<ChartView />
|
||||
</SplitterPanel>
|
||||
@@ -145,6 +148,9 @@ onBeforeUnmount(() => {
|
||||
<ChatPanel />
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
<BottomTray />
|
||||
</div>
|
||||
<div v-else class="mobile-layout">
|
||||
<ChatPanel />
|
||||
</div>
|
||||
@@ -165,8 +171,21 @@ onBeforeUnmount(() => {
|
||||
background: #0f0f0f !important;
|
||||
}
|
||||
|
||||
.desktop-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-splitter {
|
||||
height: 100vh !important;
|
||||
height: 100% !important;
|
||||
background: #0f0f0f !important;
|
||||
}
|
||||
|
||||
@@ -200,7 +219,7 @@ onBeforeUnmount(() => {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 9999;
|
||||
cursor: col-resize;
|
||||
cursor: auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
279
web/src/components/BottomTray.vue
Normal file
279
web/src/components/BottomTray.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onBeforeUnmount, type Component } from 'vue'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tab from 'primevue/tab'
|
||||
import TabPanels from 'primevue/tabpanels'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import OrdersTab from './tabs/OrdersTab.vue'
|
||||
import PlaceholderTab from './tabs/PlaceholderTab.vue'
|
||||
|
||||
interface TempTab {
|
||||
id: string
|
||||
label: string
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
}
|
||||
|
||||
const COLLAPSED_HEIGHT = 34
|
||||
const DEFAULT_EXPANDED = 260
|
||||
const MIN_EXPANDED = 80
|
||||
|
||||
const isExpanded = ref(false)
|
||||
const expandedHeight = ref(DEFAULT_EXPANDED)
|
||||
const activeTab = ref('orders')
|
||||
const tempTabs = ref<TempTab[]>([])
|
||||
|
||||
const trayStyle = computed(() => ({
|
||||
height: isExpanded.value ? `${expandedHeight.value}px` : `${COLLAPSED_HEIGHT}px`,
|
||||
}))
|
||||
|
||||
function onTabClick(tabId: string) {
|
||||
if (!isExpanded.value) {
|
||||
activeTab.value = tabId
|
||||
isExpanded.value = true
|
||||
} else if (activeTab.value === tabId) {
|
||||
isExpanded.value = false
|
||||
} else {
|
||||
activeTab.value = tabId
|
||||
}
|
||||
}
|
||||
|
||||
function closeTab(tabId: string) {
|
||||
const idx = tempTabs.value.findIndex(t => t.id === tabId)
|
||||
if (idx === -1) return
|
||||
tempTabs.value.splice(idx, 1)
|
||||
if (activeTab.value === tabId) {
|
||||
activeTab.value = tempTabs.value[idx - 1]?.id ?? tempTabs.value[0]?.id ?? 'orders'
|
||||
if (tempTabs.value.length === 0) isExpanded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Resize handle drag
|
||||
let resizeStartY = 0
|
||||
let resizeStartHeight = 0
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
resizeStartY = e.clientY
|
||||
resizeStartHeight = expandedHeight.value
|
||||
document.addEventListener('mousemove', onResizeMove)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function onResizeMove(e: MouseEvent) {
|
||||
const delta = resizeStartY - e.clientY // dragging up increases height
|
||||
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({
|
||||
openTab(id: string, label: string, component: Component, props?: Record<string, any>) {
|
||||
const existing = tempTabs.value.find(t => t.id === id)
|
||||
if (!existing) {
|
||||
tempTabs.value.push({ id, label, component, props })
|
||||
}
|
||||
activeTab.value = id
|
||||
isExpanded.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bottom-tray" :style="trayStyle">
|
||||
<div v-if="isExpanded" class="tray-resize-handle" @mousedown="startResize" />
|
||||
<Tabs :value="activeTab" class="tray-tabs">
|
||||
<TabList class="tray-tab-list">
|
||||
<Tab value="orders" @click="onTabClick('orders')">Orders</Tab>
|
||||
<Tab value="strategies" @click="onTabClick('strategies')">Strategies</Tab>
|
||||
<Tab value="positions" @click="onTabClick('positions')">Positions</Tab>
|
||||
<Tab
|
||||
v-for="tab in tempTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="tab-closeable"
|
||||
@click="onTabClick(tab.id)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<button class="tab-close-btn" @click.stop="closeTab(tab.id)">×</button>
|
||||
</Tab>
|
||||
<div class="tray-spacer" />
|
||||
<button v-if="isExpanded" class="tray-close-btn" @click="isExpanded = false">✕</button>
|
||||
</TabList>
|
||||
<TabPanels v-if="isExpanded" class="tray-panels">
|
||||
<TabPanel value="orders" class="tray-panel"><OrdersTab /></TabPanel>
|
||||
<TabPanel value="strategies" class="tray-panel"><PlaceholderTab label="Strategies" /></TabPanel>
|
||||
<TabPanel value="positions" class="tray-panel"><PlaceholderTab label="Positions" /></TabPanel>
|
||||
<TabPanel
|
||||
v-for="tab in tempTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="tray-panel"
|
||||
>
|
||||
<component :is="tab.component" v-bind="tab.props" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-tray {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
background: #0f0f0f;
|
||||
border-top: 1px solid #2e2e2e;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: height 0.15s ease;
|
||||
}
|
||||
|
||||
.tray-resize-handle {
|
||||
height: 4px;
|
||||
flex-shrink: 0;
|
||||
background: #2e2e2e;
|
||||
cursor: row-resize;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tray-resize-handle:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.tray-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tray-tab-list {
|
||||
height: 34px !important;
|
||||
min-height: 34px !important;
|
||||
flex-shrink: 0;
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
background: #141414 !important;
|
||||
border-bottom: 1px solid #2e2e2e !important;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Override PrimeVue Tab default styles */
|
||||
.tray-tab-list :deep(.p-tab) {
|
||||
padding: 0 12px !important;
|
||||
height: 28px !important;
|
||||
line-height: 28px !important;
|
||||
font-size: 12px !important;
|
||||
color: #888 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 4px !important;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tray-tab-list :deep(.p-tab:hover) {
|
||||
color: #dbdbdb !important;
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.tray-tab-list :deep(.p-tab-active) {
|
||||
color: #dbdbdb !important;
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
/* Hide the PrimeVue active indicator bar */
|
||||
.tray-tab-list :deep(.p-tablist-active-bar) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tab-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-close-btn:hover {
|
||||
color: #dbdbdb;
|
||||
}
|
||||
|
||||
.tray-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tray-close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tray-close-btn:hover {
|
||||
color: #dbdbdb;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.tray-panels {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0 !important;
|
||||
background: #0f0f0f !important;
|
||||
}
|
||||
|
||||
.tray-panels :deep(.p-tabpanels) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
background: #0f0f0f !important;
|
||||
}
|
||||
|
||||
.tray-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.tray-panel :deep(.p-tabpanel-content) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
background: #0f0f0f !important;
|
||||
}
|
||||
</style>
|
||||
69
web/src/components/tabs/OrdersTab.vue
Normal file
69
web/src/components/tabs/OrdersTab.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import { useOrderStore } from '../../stores/orders'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const ordersStore = useOrderStore()
|
||||
const { orders } = storeToRefs(ordersStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DataTable
|
||||
:value="orders"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
size="small"
|
||||
class="orders-table"
|
||||
:empty-message="'No orders'"
|
||||
>
|
||||
<Column field="tokenIn" header="Token In" style="min-width: 90px" />
|
||||
<Column field="tokenOut" header="Token Out" style="min-width: 90px" />
|
||||
<Column field="route.exchange" header="Exchange" style="min-width: 100px" />
|
||||
<Column field="route.fee" header="Fee" style="min-width: 70px" />
|
||||
<Column field="amount" header="Amount" style="min-width: 100px" />
|
||||
<Column field="minFillAmount" header="Min Fill" style="min-width: 100px" />
|
||||
<Column field="amountIsInput" header="Amt Is Input" style="min-width: 100px">
|
||||
<template #body="{ data }">{{ data.amountIsInput ? 'Yes' : 'No' }}</template>
|
||||
</Column>
|
||||
<Column field="conditionalOrder" header="Condition" style="min-width: 100px" />
|
||||
</DataTable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orders-table {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.orders-table :deep(.p-datatable-header-cell) {
|
||||
background: #1a1a1a !important;
|
||||
color: #aaa !important;
|
||||
border-color: #2e2e2e !important;
|
||||
padding: 4px 8px !important;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.orders-table :deep(.p-datatable-row-cell) {
|
||||
background: #0f0f0f !important;
|
||||
color: #dbdbdb !important;
|
||||
border-color: #1e1e1e !important;
|
||||
padding: 3px 8px !important;
|
||||
}
|
||||
|
||||
.orders-table :deep(tr:hover .p-datatable-row-cell) {
|
||||
background: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.orders-table :deep(.p-datatable-empty-message td) {
|
||||
background: #0f0f0f !important;
|
||||
color: #555 !important;
|
||||
text-align: center;
|
||||
padding: 16px !important;
|
||||
}
|
||||
</style>
|
||||
20
web/src/components/tabs/PlaceholderTab.vue
Normal file
20
web/src/components/tabs/PlaceholderTab.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="placeholder-tab">
|
||||
<span>{{ label }} — no data</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.placeholder-tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user