3 Commits

Author SHA1 Message Date
5021138da6 bugfix; web tabs 2026-04-13 20:58:40 -04:00
6c82dce6f6 bump sandbox to fcaf3a3 2026-04-13 20:17:33 -04:00
fcaf3a3b6f fix conda package management using conda executable instead of python -m conda 2026-04-13 20:10:38 -04:00
13 changed files with 663 additions and 569 deletions

View File

@@ -30,7 +30,7 @@ data:
namespace: sandbox namespace: sandbox
service_namespace: ai service_namespace: ai
in_cluster: true in_cluster: true
sandbox_image: git.dxod.org/dexorder/dexorder/ai-sandbox:latest sandbox_image: git.dxod.org/dexorder/dexorder/ai-sandbox:fcaf3a3
sidecar_image: git.dxod.org/dexorder/dexorder/ai-lifecycle-sidecar:latest sidecar_image: git.dxod.org/dexorder/dexorder/ai-lifecycle-sidecar:latest
image_pull_policy: Always image_pull_policy: Always
storage_class: ceph-block storage_class: ceph-block

4
doc/test_prompt.md Normal file
View 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?
---

View File

@@ -196,7 +196,7 @@ export class DuckDBClient {
// Fallback: scan Parquet files written directly to conversations bucket // Fallback: scan Parquet files written directly to conversations bucket
if (this.conversationsBucket) { if (this.conversationsBucket) {
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning Parquet cold storage'); 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 = ` const fallbackSql = `
SELECT id, user_id, session_id, role, content, metadata, timestamp SELECT id, user_id, session_id, role, content, metadata, timestamp
FROM read_parquet('${parquetPath}') FROM read_parquet('${parquetPath}')
@@ -709,15 +709,15 @@ export class DuckDBClient {
if (!tablePath) { if (!tablePath) {
// Fallback: scan per-turn Parquet files written directly to S3 // Fallback: scan per-turn Parquet files written directly to S3
if (this.conversationsBucket) { if (this.conversationsBucket) {
this.logger.debug({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files'); this.logger.info({ userId, sessionId }, 'REST catalog miss, scanning blob Parquet files');
const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/**/user_id=${userId}/${sessionId}_*.parquet`; const parquetPath = `s3://${this.conversationsBucket}/gateway/blobs/year=*/month=*/user_id=${userId}/${sessionId}_*.parquet`;
const idClause = blobIds?.length const idClause = blobIds?.length
? `WHERE id IN (${blobIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')})` ? `WHERE id IN (${blobIds.map(id => `'${id.replace(/'/g, "''")}'`).join(', ')})`
: ''; : '';
try { try {
return await this.query(`SELECT * FROM read_parquet('${parquetPath}') ${idClause} ORDER BY timestamp ASC`); return await this.query(`SELECT * FROM read_parquet('${parquetPath}') ${idClause} ORDER BY timestamp ASC`);
} catch { } catch (err: any) {
// No blobs yet for this session this.logger.debug({ err: err.message, userId, sessionId }, 'No blob Parquet files found for session');
} }
} }
return []; return [];

View File

@@ -8,6 +8,8 @@ import type { DynamicStructuredTool } from '@langchain/core/tools';
import { readFile } from 'fs/promises'; import { readFile } from 'fs/promises';
import { join } from 'path'; import { join } from 'path';
import type { HarnessEvent, SubagentChunkEvent, SubagentThinkingEvent } from '../harness-events.js'; 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) * Subagent configuration (loaded from config.yaml)
@@ -73,44 +75,164 @@ export abstract class BaseSubagent {
this.tools = tools || []; this.tools = tools || [];
} }
/** /** Per-subagent recursion limit for the LangGraph agent loop */
* Initialize subagent: load system prompt and memory files protected abstract getRecursionLimit(): number;
*/
async initialize(basePath: string): Promise<void> {
this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
// Load system prompt /** Fallback text returned when the agent produces no output */
if (this.config.systemPromptFile) { protected abstract getFallbackText(): string;
const promptPath = join(basePath, this.config.systemPromptFile);
this.systemPrompt = await this.loadFile(promptPath);
}
// Load memory files /** Whether an MCP client is required; defaults true. Override to false for tool-only subagents. */
for (const memoryFile of this.config.memoryFiles) { protected requiresMCPClient(): boolean {
const memoryPath = join(basePath, 'memory', memoryFile); return true;
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'
);
} }
/** /**
* Execute subagent with given input * Build the system message and final human message for agent invocation.
* Subclasses may override to augment the system message (e.g. injecting dynamic context).
*/ */
abstract execute( protected async buildSystemMessage(
context: SubagentContext, context: SubagentContext,
input: string instruction: string
): Promise<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.getName(),
userId: context.userContext.userId,
instruction: instruction.substring(0, 200),
toolCount: this.tools.length,
toolNames: this.tools.map(t => t.name),
},
`${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. Delegates to executeAgent by default;
* subclasses with side-effects (image capture, etc.) override this.
*/
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,
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) * Stream execution (optional, default to non-streaming)
@@ -169,17 +291,42 @@ export abstract class BaseSubagent {
} }
/** /**
* Stream typed HarnessEvents during execution. * Initialize subagent: load system prompt and memory files
* Subclasses override this to emit subagent_chunk / subagent_tool_call events
* using agent.stream() from LangGraph. Default falls back to execute().
*/ */
async *streamEvents( async initialize(basePath: string): Promise<void> {
context: SubagentContext, this.logger.info({ subagent: this.config.name }, 'Initializing subagent');
input: string,
_signal?: AbortSignal, // Load system prompt
): AsyncGenerator<HarnessEvent, string> { if (this.config.systemPromptFile) {
const result = await this.execute(context, input); const promptPath = join(basePath, this.config.systemPromptFile);
return result; 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;
} }
/** /**

View File

@@ -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 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 { FastifyBaseLogger } from 'fastify';
import type { MCPClientConnector } from '../../mcp-client.js'; import type { MCPClientConnector } from '../../mcp-client.js';
import type { HarnessEvent } from '../../harness-events.js';
/** /**
* Indicator Subagent * Indicator Subagent
@@ -14,127 +11,10 @@ import type { HarnessEvent } from '../../harness-events.js';
* - Read, add, modify, and remove indicators from the indicators store * - Read, add, modify, and remove indicators from the indicators store
* - Create custom indicator scripts via python_* tools * - Create custom indicator scripts via python_* tools
* - Validate indicators using the evaluate_indicator tool * - Validate indicators using the evaluate_indicator tool
*
* Simpler than ResearchSubagent — no image capture needed.
*/ */
export class IndicatorSubagent extends BaseSubagent { export class IndicatorSubagent extends BaseSubagent {
constructor( protected getRecursionLimit() { return 25; }
config: SubagentConfig, protected getFallbackText() { return 'Indicator update completed.'; }
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;
}
} }
/** /**
@@ -147,16 +27,8 @@ export async function createIndicatorSubagent(
mcpClient?: MCPClientConnector, mcpClient?: MCPClientConnector,
tools?: any[] tools?: any[]
): Promise<IndicatorSubagent> { ): Promise<IndicatorSubagent> {
const { readFile } = await import('fs/promises'); const config = await BaseSubagent.loadConfig(basePath);
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 subagent = new IndicatorSubagent(config, model, logger, mcpClient, tools); const subagent = new IndicatorSubagent(config, model, logger, mcpClient, tools);
await subagent.initialize(basePath); await subagent.initialize(basePath);
return subagent; return subagent;
} }

View File

@@ -1,7 +1,7 @@
import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js'; import { BaseSubagent, type SubagentConfig, type SubagentContext } from '../base-subagent.js';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { SystemMessage } from '@langchain/core/messages'; 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 { FastifyBaseLogger } from 'fastify';
import type { MCPClientConnector } from '../../mcp-client.js'; import type { MCPClientConnector } from '../../mcp-client.js';
import type { HarnessEvent } from '../../harness-events.js'; import type { HarnessEvent } from '../../harness-events.js';
@@ -47,6 +47,9 @@ export class ResearchSubagent extends BaseSubagent {
super(config, model, logger, mcpClient, tools); super(config, model, logger, mcpClient, tools);
} }
protected getRecursionLimit() { return 40; }
protected getFallbackText() { return 'Research completed.'; }
setImageCapture(capture: Array<{data: string; mimeType: string}>): void { setImageCapture(capture: Array<{data: string; mimeType: string}>): void {
this.imageCapture = capture; 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. * Execute research request using LangGraph's createReactAgent.
* This is the standard LangChain pattern for agents with tool access — * Wraps executeAgent to manage image capture state.
* createReactAgent handles the tool calling loop automatically.
*/ */
async execute(context: SubagentContext, instruction: string): Promise<string> { 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) // Clear previous images (in-place so tool wrappers keep the same array reference)
this.imageCapture.length = 0; this.imageCapture.length = 0;
this.lastImages = []; this.lastImages = [];
const customIndicatorsSection = await this.fetchCustomIndicatorsSection(); const finalText = await this.executeAgent(context, instruction);
// 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'
);
// Images were captured in real-time by the MCP tool wrappers into this.imageCapture // Images were captured in real-time by the MCP tool wrappers into this.imageCapture
this.lastImages = [...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( this.logger.info(
{ textLength: finalText.length, imageCount: this.lastImages.length }, { textLength: finalText.length, imageCount: this.lastImages.length },
'Research subagent finished' 'Research subagent finished'
@@ -225,73 +185,7 @@ export class ResearchSubagent extends BaseSubagent {
// the first `updates` event fires (after the LLM finishes its first response). // 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...' }; yield { type: 'subagent_tool_call', agentName: this.config.name, toolName: 'Thinking...', label: 'Thinking...' };
const customIndicatorsSection = await this.fetchCustomIndicatorsSection(); const finalText = yield* this.streamEventsCore(context, instruction, signal);
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;
}
}
}
}
}
this.lastImages = [...this.imageCapture]; this.lastImages = [...this.imageCapture];
if (!finalText) { 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> { async *stream(context: SubagentContext, instruction: string): AsyncGenerator<string> {
this.logger.info( this.logger.info(
@@ -351,21 +245,11 @@ export async function createResearchSubagent(
tools?: any[], tools?: any[],
imageCapture?: Array<{data: string; mimeType: string}> imageCapture?: Array<{data: string; mimeType: string}>
): Promise<ResearchSubagent> { ): Promise<ResearchSubagent> {
const { readFile } = await import('fs/promises'); const config = await BaseSubagent.loadConfig(basePath);
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 subagent = new ResearchSubagent(config, model, logger, mcpClient, tools); const subagent = new ResearchSubagent(config, model, logger, mcpClient, tools);
if (imageCapture !== undefined) { if (imageCapture !== undefined) {
subagent.setImageCapture(imageCapture); subagent.setImageCapture(imageCapture);
} }
await subagent.initialize(basePath); await subagent.initialize(basePath);
return subagent; return subagent;
} }

View File

@@ -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 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 { FastifyBaseLogger } from 'fastify';
import type { MCPClientConnector } from '../../mcp-client.js'; import type { MCPClientConnector } from '../../mcp-client.js';
import type { HarnessEvent } from '../../harness-events.js';
/** /**
* Strategy Subagent * Strategy Subagent
* *
* Specialized agent for writing PandasStrategy classes, running backtests, * Specialized agent for writing PandasStrategy classes, running backtests,
* and managing strategy activation/deactivation. * and managing strategy activation/deactivation.
*
* Mirrors the pattern of IndicatorSubagent in indicator/index.ts.
*/ */
export class StrategySubagent extends BaseSubagent { export class StrategySubagent extends BaseSubagent {
constructor( protected getRecursionLimit() { return 30; }
config: SubagentConfig, protected getFallbackText() { return 'Strategy task completed.'; }
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;
}
} }
/** /**
@@ -144,16 +24,8 @@ export async function createStrategySubagent(
mcpClient?: MCPClientConnector, mcpClient?: MCPClientConnector,
tools?: any[] tools?: any[]
): Promise<StrategySubagent> { ): Promise<StrategySubagent> {
const { readFile } = await import('fs/promises'); const config = await BaseSubagent.loadConfig(basePath);
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 subagent = new StrategySubagent(config, model, logger, mcpClient, tools); const subagent = new StrategySubagent(config, model, logger, mcpClient, tools);
await subagent.initialize(basePath); await subagent.initialize(basePath);
return subagent; return subagent;
} }

View File

@@ -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 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 { FastifyBaseLogger } from 'fastify';
import type { HarnessEvent } from '../../harness-events.js';
/** /**
* Web Explore Subagent * Web Explore Subagent
@@ -24,95 +21,9 @@ export class WebExploreSubagent extends BaseSubagent {
super(config, model, logger, undefined, tools); super(config, model, logger, undefined, tools);
} }
async execute(context: SubagentContext, instruction: string): Promise<string> { protected getRecursionLimit() { return 15; }
this.logger.info( protected getFallbackText() { return 'No results found.'; }
{ protected requiresMCPClient() { return false; }
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;
}
} }
/** /**
@@ -124,16 +35,8 @@ export async function createWebExploreSubagent(
basePath: string, basePath: string,
tools?: any[] tools?: any[]
): Promise<WebExploreSubagent> { ): Promise<WebExploreSubagent> {
const { readFile } = await import('fs/promises'); const config = await BaseSubagent.loadConfig(basePath);
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 subagent = new WebExploreSubagent(config, model, logger, tools); const subagent = new WebExploreSubagent(config, model, logger, tools);
await subagent.initialize(basePath); await subagent.initialize(basePath);
return subagent; return subagent;
} }

View File

@@ -90,9 +90,14 @@ def get_installed_packages() -> Set[str]:
Returns: Returns:
Set of package names Set of package names
""" """
conda_exe = get_conda_executable()
if not conda_exe:
log.error("Failed to list conda packages: conda executable not found")
return set()
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "conda", "list", "--json"], [str(conda_exe), "list", "-n", "dexorder", "--json"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
@@ -177,9 +182,20 @@ def install_packages(packages: list[str], data_dir: Optional[Path] = None) -> di
# Install missing packages # Install missing packages
log.info(f"Installing conda packages: {to_install}") log.info(f"Installing conda packages: {to_install}")
conda_exe = get_conda_executable()
if not conda_exe:
log.error("Failed to install packages: conda executable not found")
return {
"success": False,
"installed": [],
"skipped": skipped,
"failed": to_install,
"error": "conda executable not found",
}
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "conda", "install", "-y", "-c", "conda-forge"] + to_install, [str(conda_exe), "install", "-y", "-n", "dexorder", "-c", "conda-forge"] + to_install,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=300, # 5 minute timeout timeout=300, # 5 minute timeout
@@ -247,9 +263,18 @@ def remove_packages(packages: list[str]) -> dict:
log.info(f"Removing conda packages: {packages}") log.info(f"Removing conda packages: {packages}")
conda_exe = get_conda_executable()
if not conda_exe:
log.error("Failed to remove packages: conda executable not found")
return {
"success": False,
"removed": [],
"error": "conda executable not found",
}
try: try:
result = subprocess.run( result = subprocess.run(
[sys.executable, "-m", "conda", "remove", "-y"] + packages, [str(conda_exe), "remove", "-y", "-n", "dexorder"] + packages,
capture_output=True, capture_output=True,
text=True, text=True,
timeout=120, timeout=120,

View File

@@ -5,6 +5,7 @@ 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'
import BottomTray from './components/BottomTray.vue'
import { useChartStore } from './stores/chart' import { useChartStore } from './stores/chart'
import { useShapeStore } from './stores/shapes' import { useShapeStore } from './stores/shapes'
import { useIndicatorStore } from './stores/indicators' import { useIndicatorStore } from './stores/indicators'
@@ -137,14 +138,19 @@ onBeforeUnmount(() => {
:error-message="authError" :error-message="authError"
@authenticate="handleAuthenticate" @authenticate="handleAuthenticate"
/> />
<Splitter v-else-if="!isMobile" class="main-splitter"> <div v-else-if="!isMobile" class="desktop-layout">
<SplitterPanel :size="62" :minSize="40" class="chart-panel"> <div class="top-area">
<ChartView /> <Splitter class="main-splitter">
</SplitterPanel> <SplitterPanel :size="62" :minSize="40" class="chart-panel">
<SplitterPanel :size="38" :minSize="20" class="chat-panel"> <ChartView />
<ChatPanel /> </SplitterPanel>
</SplitterPanel> <SplitterPanel :size="38" :minSize="20" class="chat-panel">
</Splitter> <ChatPanel />
</SplitterPanel>
</Splitter>
</div>
<BottomTray />
</div>
<div v-else class="mobile-layout"> <div v-else class="mobile-layout">
<ChatPanel /> <ChatPanel />
</div> </div>
@@ -165,8 +171,21 @@ onBeforeUnmount(() => {
background: #0f0f0f !important; 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 { .main-splitter {
height: 100vh !important; height: 100% !important;
background: #0f0f0f !important; background: #0f0f0f !important;
} }
@@ -200,7 +219,7 @@ onBeforeUnmount(() => {
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 9999; z-index: 9999;
cursor: col-resize; cursor: auto;
background: transparent; background: transparent;
} }

View 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>

View 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>

View 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>