Initial commit of integrated agent system
This commit is contained in:
33
frontend/.gitignore
vendored
Normal file
33
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.env.local
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build output
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
66
frontend/README.md
Normal file
66
frontend/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
## Frontend Quick Start
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Default dev URL: `http://localhost:5173`
|
||||
|
||||
The frontend expects the 大时代 gateway WebSocket on `ws://localhost:8765` unless overridden.
|
||||
|
||||
## Recommended Local Backend Stack
|
||||
|
||||
Start the split backend services from the project root:
|
||||
|
||||
```bash
|
||||
./start-dev.sh
|
||||
```
|
||||
|
||||
That gives you:
|
||||
|
||||
- control plane at `http://localhost:8000/api`
|
||||
- trading service at `http://localhost:8001`
|
||||
- news service at `http://localhost:8002`
|
||||
- runtime service at `http://localhost:8003/api/runtime`
|
||||
- gateway WebSocket at `ws://localhost:8765`
|
||||
|
||||
## Frontend Environment Variables
|
||||
|
||||
You can point the frontend directly at those services with:
|
||||
|
||||
```bash
|
||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||
VITE_WS_URL=ws://localhost:8765
|
||||
```
|
||||
|
||||
There is also a starter template at [frontend/env.template](./env.template).
|
||||
|
||||
For production deployments, prefer:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
This ensures the deployed frontend matches the checked-in `package-lock.json`.
|
||||
|
||||
## Direct-Service Coverage
|
||||
|
||||
Current direct-call coverage includes:
|
||||
|
||||
- runtime panel data loading
|
||||
- gateway port/runtime discovery
|
||||
- `story`
|
||||
- `similar days`
|
||||
- `range explain`
|
||||
- `news for date`
|
||||
- `news categories`
|
||||
- selected trading reads such as price history and insider trades
|
||||
|
||||
If these variables are not set, the frontend falls back to local defaults and compatibility paths where they still exist.
|
||||
22
frontend/components.json
Normal file
22
frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
24
frontend/env.template
Normal file
24
frontend/env.template
Normal file
@@ -0,0 +1,24 @@
|
||||
# Frontend Environment Variables Template
|
||||
# 复制此文件为 .env 并修改配置
|
||||
|
||||
# 控制面 API(agent/workspaces/guard)
|
||||
VITE_CONTROL_API_BASE_URL=http://localhost:8000/api
|
||||
|
||||
# 运行时 API(start/stop/runtime info)
|
||||
VITE_RUNTIME_API_BASE_URL=http://localhost:8003/api/runtime
|
||||
|
||||
# 新闻服务(可选,未配置时走默认回退)
|
||||
VITE_NEWS_SERVICE_URL=http://localhost:8002
|
||||
|
||||
# 交易数据服务(可选,未配置时走默认回退)
|
||||
VITE_TRADING_SERVICE_URL=http://localhost:8001
|
||||
|
||||
# WebSocket Gateway
|
||||
VITE_WS_URL=ws://localhost:8765
|
||||
|
||||
# 生产环境示例
|
||||
# VITE_CONTROL_API_BASE_URL=https://your-domain.com/api
|
||||
# VITE_RUNTIME_API_BASE_URL=https://your-domain.com/api/runtime
|
||||
# VITE_NEWS_SERVICE_URL=https://your-domain.com/news
|
||||
# VITE_TRADING_SERVICE_URL=https://your-domain.com/trading
|
||||
# VITE_WS_URL=wss://your-domain.com/ws
|
||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{js,jsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
68
frontend/index.css
Normal file
68
frontend/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/trading_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>大时代</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
72
frontend/package.json
Normal file
72
frontend/package.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"name": "live-trading-demo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"preview:host": "vite preview --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dicebear/collection": "^9.4.2",
|
||||
"@dicebear/core": "^9.4.2",
|
||||
"@lobehub/icons": "^1.97.2",
|
||||
"@lobehub/ui": "^1.171.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"antd": "^5.23.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.13",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-is": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"three": "^0.180.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
frontend/public/trading_logo.png
Normal file
BIN
frontend/public/trading_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
500
frontend/src/App.jsx
Normal file
500
frontend/src/App.jsx
Normal file
@@ -0,0 +1,500 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import AppShell from './components/AppShell.jsx';
|
||||
import RuntimeLogsModal from './components/RuntimeLogsModal.jsx';
|
||||
import { AGENTS } from './config/constants';
|
||||
import { useAgentDataRequests } from './hooks/useAgentDataRequests';
|
||||
import { useFeedProcessor } from './hooks/useFeedProcessor';
|
||||
import { useRuntimeControls } from './hooks/useRuntimeControls';
|
||||
import { useStockDataRequests } from './hooks/useStockDataRequests';
|
||||
import { useWebSocketConnection } from './hooks/useWebSocketConnection';
|
||||
import { fetchRuntimeLogs } from './services/runtimeApi';
|
||||
import { useAgentStore } from './store/agentStore';
|
||||
import { useMarketStore } from './store/marketStore';
|
||||
import { usePortfolioStore } from './store/portfolioStore';
|
||||
import { useRuntimeStore } from './store/runtimeStore';
|
||||
import { useUIStore } from './store/uiStore';
|
||||
|
||||
const EDITABLE_AGENT_WORKSPACE_FILES = [
|
||||
'SOUL.md',
|
||||
'PROFILE.md',
|
||||
'AGENTS.md',
|
||||
'MEMORY.md',
|
||||
'POLICY.md'
|
||||
];
|
||||
|
||||
export default function LiveTradingApp() {
|
||||
const {
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
serverMode,
|
||||
marketStatus,
|
||||
virtualTime,
|
||||
dataSources,
|
||||
currentDate,
|
||||
runtimeConfig,
|
||||
} = useRuntimeStore();
|
||||
|
||||
const {
|
||||
currentView,
|
||||
chartTab,
|
||||
isInitialAnimating,
|
||||
lastUpdate,
|
||||
isUpdating,
|
||||
now,
|
||||
setNow,
|
||||
setLastUpdate,
|
||||
setIsUpdating,
|
||||
leftWidth,
|
||||
isResizing,
|
||||
bubbles,
|
||||
} = useUIStore();
|
||||
|
||||
const {
|
||||
tickers,
|
||||
rollingTickers,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
explainEventsByTicker,
|
||||
newsByTicker,
|
||||
insiderTradesByTicker,
|
||||
technicalIndicatorsByTicker,
|
||||
selectedExplainSymbol,
|
||||
historySourceByTicker,
|
||||
setSelectedExplainSymbol,
|
||||
} = useMarketStore();
|
||||
|
||||
const {
|
||||
portfolioData,
|
||||
holdings,
|
||||
trades,
|
||||
stats,
|
||||
leaderboard,
|
||||
} = usePortfolioStore();
|
||||
|
||||
const {
|
||||
selectedSkillAgentId,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
skillDetailsByName,
|
||||
localSkillDraftsByKey,
|
||||
isAgentSkillsLoading,
|
||||
skillDetailLoadingKey,
|
||||
agentSkillsSavingKey,
|
||||
agentSkillsFeedback,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFilesByAgent,
|
||||
workspaceDraftContent,
|
||||
isWorkspaceFileLoading,
|
||||
workspaceFileSavingKey,
|
||||
workspaceFileFeedback,
|
||||
setSelectedWorkspaceFile,
|
||||
setSelectedSkillAgentId,
|
||||
setWorkspaceDraftContent,
|
||||
} = useAgentStore();
|
||||
|
||||
const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage, clearFeed } = useFeedProcessor();
|
||||
const resetRuntimeViewState = useCallback(() => {
|
||||
clearFeed();
|
||||
|
||||
useMarketStore.getState().setPriceHistoryByTicker({});
|
||||
useMarketStore.getState().setOhlcHistoryByTicker({});
|
||||
useMarketStore.getState().setHistorySourceByTicker({});
|
||||
useMarketStore.getState().setExplainEventsByTicker({});
|
||||
useMarketStore.getState().setNewsByTicker({});
|
||||
useMarketStore.getState().setInsiderTradesByTicker({});
|
||||
useMarketStore.getState().setTechnicalIndicatorsByTicker({});
|
||||
|
||||
usePortfolioStore.getState().setHoldings([]);
|
||||
usePortfolioStore.getState().setTrades([]);
|
||||
usePortfolioStore.getState().setStats(null);
|
||||
usePortfolioStore.getState().setLeaderboard([]);
|
||||
usePortfolioStore.getState().setPortfolioData({
|
||||
netValue: 10000,
|
||||
pnl: 0,
|
||||
equity: [],
|
||||
baseline: [],
|
||||
baseline_vw: [],
|
||||
momentum: [],
|
||||
strategies: [],
|
||||
equity_return: 0,
|
||||
baseline_return: 0,
|
||||
baseline_vw_return: 0,
|
||||
momentum_return: 0,
|
||||
});
|
||||
|
||||
useRuntimeStore.getState().setLastDayHistory([]);
|
||||
useUIStore.getState().setBubbles({});
|
||||
}, [clearFeed]);
|
||||
|
||||
const {
|
||||
clientRef,
|
||||
setRequestStockHistory,
|
||||
setRequestStockNewsTimeline,
|
||||
setRequestStockNewsCategories,
|
||||
} = useWebSocketConnection({
|
||||
processHistoricalFeed,
|
||||
processFeedEvent,
|
||||
addSystemMessage,
|
||||
});
|
||||
|
||||
const runtimeControls = useRuntimeControls({
|
||||
clientRef,
|
||||
currentTickers: tickers,
|
||||
addSystemMessage,
|
||||
onRuntimeStarted: resetRuntimeViewState,
|
||||
});
|
||||
|
||||
const stockRequests = useStockDataRequests(clientRef, {
|
||||
setRequestStockHistory,
|
||||
setRequestStockNewsTimeline,
|
||||
setRequestStockNewsCategories,
|
||||
});
|
||||
const {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
requestWorkspaceFile,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill,
|
||||
} = useAgentDataRequests(clientRef);
|
||||
|
||||
const [isRuntimeLogsOpen, setIsRuntimeLogsOpen] = useState(false);
|
||||
const [isRuntimeLogsLoading, setIsRuntimeLogsLoading] = useState(false);
|
||||
const [runtimeLogsPayload, setRuntimeLogsPayload] = useState(null);
|
||||
const [runtimeLogsError, setRuntimeLogsError] = useState(null);
|
||||
const agentFeedRef = useRef(null);
|
||||
const isSocketReady = isConnected && connectionStatus === 'connected';
|
||||
|
||||
const selectedAgentId = selectedSkillAgentId || AGENTS[0]?.id || null;
|
||||
const selectedAgentProfile = selectedAgentId ? (agentProfilesByAgent[selectedAgentId] || null) : null;
|
||||
const selectedAgentSkills = selectedAgentId ? (agentSkillsByAgent[selectedAgentId] || []) : [];
|
||||
const selectedWorkspaceContent = selectedAgentId && selectedWorkspaceFile
|
||||
? (workspaceFilesByAgent[selectedAgentId]?.[selectedWorkspaceFile] || '')
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSkillAgentId && AGENTS.length > 0) {
|
||||
setSelectedSkillAgentId(AGENTS[0].id);
|
||||
}
|
||||
}, [selectedSkillAgentId, setSelectedSkillAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedWorkspaceFile) {
|
||||
setSelectedWorkspaceFile('MEMORY.md');
|
||||
}
|
||||
}, [selectedWorkspaceFile, setSelectedWorkspaceFile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSocketReady || !selectedAgentId || !clientRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agentProfilesByAgent[selectedAgentId]) {
|
||||
requestAgentProfile(selectedAgentId);
|
||||
}
|
||||
|
||||
if (!Array.isArray(agentSkillsByAgent[selectedAgentId])) {
|
||||
requestAgentSkills(selectedAgentId);
|
||||
}
|
||||
|
||||
if (
|
||||
selectedWorkspaceFile
|
||||
&& workspaceFilesByAgent[selectedAgentId]?.[selectedWorkspaceFile] === undefined
|
||||
) {
|
||||
requestWorkspaceFile(selectedAgentId, selectedWorkspaceFile);
|
||||
}
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
clientRef,
|
||||
isSocketReady,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedAgentId,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFilesByAgent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSocketReady || !clientRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
AGENTS.forEach((agent) => {
|
||||
if (!agent?.id) {
|
||||
return;
|
||||
}
|
||||
if (!agentProfilesByAgent[agent.id]) {
|
||||
requestAgentProfile(agent.id);
|
||||
}
|
||||
});
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
clientRef,
|
||||
isSocketReady,
|
||||
requestAgentProfile,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const symbols = runtimeControls.displayTickers
|
||||
.map((ticker) => ticker.symbol)
|
||||
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
|
||||
|
||||
if (!symbols.length) {
|
||||
setSelectedExplainSymbol('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) {
|
||||
setSelectedExplainSymbol(symbols[0]);
|
||||
}
|
||||
}, [runtimeControls.displayTickers, selectedExplainSymbol, setSelectedExplainSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (virtualTime) {
|
||||
setNow(new Date(virtualTime));
|
||||
const id = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}
|
||||
|
||||
const id = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [setNow, virtualTime]);
|
||||
|
||||
useEffect(() => {
|
||||
setLastUpdate(new Date());
|
||||
setIsUpdating(true);
|
||||
const timer = setTimeout(() => setIsUpdating(false), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [holdings, stats, trades, portfolioData.netValue, setIsUpdating, setLastUpdate]);
|
||||
|
||||
const marketStatusLabel = useMemo(() => {
|
||||
if (!marketStatus) return null;
|
||||
const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
|
||||
const normalized = raw.toLowerCase();
|
||||
const byStatus = {
|
||||
open: '开盘',
|
||||
closed: '休市',
|
||||
premarket: '盘前',
|
||||
afterhours: '盘后',
|
||||
};
|
||||
const byText = {
|
||||
'market closed (non-trading day)': '休市',
|
||||
'market open': '开盘',
|
||||
'market closed': '收盘',
|
||||
'pre-market': '盘前',
|
||||
'after-hours': '盘后',
|
||||
'after hours': '盘后',
|
||||
'backtest mode': '回测模式',
|
||||
};
|
||||
if (normalized && byText[normalized]) return byText[normalized];
|
||||
if (marketStatus.status && byStatus[marketStatus.status]) return byStatus[marketStatus.status];
|
||||
return raw || '状态未知';
|
||||
}, [marketStatus]);
|
||||
|
||||
const providerLabelMap = useMemo(() => ({
|
||||
yfinance: 'YFINANCE',
|
||||
finnhub: 'FINNHUB',
|
||||
financial_datasets: 'FINANCIAL DATASETS',
|
||||
local_csv: 'CSV',
|
||||
polygon: 'POLYGON',
|
||||
backtest: 'BACKTEST',
|
||||
}), []);
|
||||
|
||||
const dataSourceLabel = useMemo(() => {
|
||||
const source = dataSources?.last_success?.prices
|
||||
|| marketStatus?.live_quote_provider
|
||||
|| (Array.isArray(dataSources?.preferred) ? dataSources.preferred[0] : null);
|
||||
if (!source) return null;
|
||||
const normalized = String(source).trim().toLowerCase();
|
||||
return `数据源 ${providerLabelMap[normalized] || String(source).trim()}`;
|
||||
}, [dataSources, marketStatus, providerLabelMap]);
|
||||
|
||||
const bubbleFor = useCallback((idOrName) => {
|
||||
let bubble = bubbles[idOrName];
|
||||
if (bubble) return bubble;
|
||||
const agent = AGENTS.find((item) => item.name === idOrName || item.id === idOrName);
|
||||
if (agent) {
|
||||
bubble = bubbles[agent.id];
|
||||
if (bubble) return bubble;
|
||||
}
|
||||
return null;
|
||||
}, [bubbles]);
|
||||
|
||||
const handleManualTrigger = useCallback(() => {
|
||||
if (!isSocketReady || !clientRef.current) {
|
||||
addSystemMessage('连接未就绪,无法手动触发');
|
||||
return;
|
||||
}
|
||||
const success = clientRef.current.send({ type: 'trigger_strategy' });
|
||||
if (!success) {
|
||||
addSystemMessage('手动触发发送失败,请检查连接状态');
|
||||
return;
|
||||
}
|
||||
addSystemMessage('已发送手动触发请求');
|
||||
}, [addSystemMessage, clientRef, isSocketReady]);
|
||||
|
||||
const loadRuntimeLogs = useCallback(async () => {
|
||||
setIsRuntimeLogsLoading(true);
|
||||
setRuntimeLogsError(null);
|
||||
try {
|
||||
const payload = await fetchRuntimeLogs();
|
||||
setRuntimeLogsPayload(payload);
|
||||
} catch (error) {
|
||||
setRuntimeLogsError(error.message || '无法读取运行日志');
|
||||
} finally {
|
||||
setIsRuntimeLogsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const agentRequests = {
|
||||
agents: AGENTS,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
workspaceFilesByAgent,
|
||||
selectedAgentId,
|
||||
selectedAgentProfile,
|
||||
selectedAgentSkills,
|
||||
skillDetailsByName,
|
||||
localSkillDraftsByKey,
|
||||
skillDetailLoadingKey,
|
||||
editableFiles: EDITABLE_AGENT_WORKSPACE_FILES,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFileContent: selectedWorkspaceContent,
|
||||
workspaceDraftContent,
|
||||
isConnected,
|
||||
isAgentSkillsLoading,
|
||||
agentSkillsSavingKey,
|
||||
agentSkillsFeedback,
|
||||
isWorkspaceFileLoading,
|
||||
workspaceFileSavingKey,
|
||||
workspaceFileFeedback,
|
||||
onAgentChange: handleSkillAgentChange,
|
||||
onCreateLocalSkill: handleCreateLocalSkill,
|
||||
onSkillDetailRequest: requestSkillDetail,
|
||||
onLocalSkillDraftChange: handleLocalSkillDraftChange,
|
||||
onLocalSkillDelete: handleLocalSkillDelete,
|
||||
onLocalSkillSave: handleLocalSkillSave,
|
||||
onRemoveSharedSkill: handleRemoveSharedSkill,
|
||||
onSkillToggle: handleAgentSkillToggle,
|
||||
onWorkspaceFileChange: handleWorkspaceFileChange,
|
||||
onWorkspaceDraftChange: setWorkspaceDraftContent,
|
||||
onWorkspaceFileSave: handleWorkspaceFileSave,
|
||||
onUploadExternalSkill: handleUploadExternalSkill,
|
||||
clientRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell
|
||||
isConnected={isConnected}
|
||||
virtualTime={virtualTime}
|
||||
now={now}
|
||||
marketStatus={marketStatus}
|
||||
serverMode={serverMode}
|
||||
marketStatusLabel={marketStatusLabel}
|
||||
dataSourceLabel={dataSourceLabel}
|
||||
runtimeSummaryLabel={runtimeControls.runtimeSummaryLabel}
|
||||
isUpdating={isUpdating}
|
||||
onManualTrigger={handleManualTrigger}
|
||||
onOpenRuntimeLogs={() => {
|
||||
setIsRuntimeLogsOpen(true);
|
||||
void loadRuntimeLogs();
|
||||
}}
|
||||
onRuntimeSettingsToggle={runtimeControls.handleRuntimeSettingsToggle}
|
||||
isRuntimeSettingsOpen={runtimeControls.isRuntimeSettingsOpen}
|
||||
isRuntimeConfigSaving={runtimeControls.isRuntimeConfigSaving}
|
||||
isWatchlistSaving={runtimeControls.isWatchlistSaving}
|
||||
runtimeConfigFeedback={runtimeControls.runtimeConfigFeedback}
|
||||
watchlistFeedback={runtimeControls.watchlistFeedback}
|
||||
launchModeDraft={runtimeControls.launchModeDraft}
|
||||
restoreRunIdDraft={runtimeControls.restoreRunIdDraft}
|
||||
runtimeHistoryRuns={runtimeControls.runtimeHistoryRuns}
|
||||
scheduleModeDraft={runtimeControls.scheduleModeDraft}
|
||||
intervalMinutesDraft={runtimeControls.intervalMinutesDraft}
|
||||
triggerTimeDraft={runtimeControls.triggerTimeDraft}
|
||||
maxCommCyclesDraft={runtimeControls.maxCommCyclesDraft}
|
||||
initialCashDraft={runtimeControls.initialCashDraft}
|
||||
marginRequirementDraft={runtimeControls.marginRequirementDraft}
|
||||
enableMemoryDraft={runtimeControls.enableMemoryDraft}
|
||||
modeDraft={runtimeControls.modeDraft}
|
||||
pollIntervalDraft={runtimeControls.pollIntervalDraft}
|
||||
startDateDraft={runtimeControls.startDateDraft}
|
||||
endDateDraft={runtimeControls.endDateDraft}
|
||||
watchlistDraftSymbols={runtimeControls.watchlistDraftSymbols}
|
||||
watchlistInputValue={runtimeControls.watchlistInputValue}
|
||||
watchlistSuggestions={runtimeControls.watchlistSuggestions}
|
||||
onLaunchModeChange={runtimeControls.setLaunchModeDraft}
|
||||
onRestoreRunIdChange={runtimeControls.setRestoreRunIdDraft}
|
||||
onScheduleModeChange={runtimeControls.setScheduleModeDraft}
|
||||
onIntervalMinutesChange={runtimeControls.setIntervalMinutesDraft}
|
||||
onTriggerTimeChange={runtimeControls.setTriggerTimeDraft}
|
||||
onMaxCommCyclesChange={runtimeControls.setMaxCommCyclesDraft}
|
||||
onInitialCashChange={runtimeControls.setInitialCashDraft}
|
||||
onMarginRequirementChange={runtimeControls.setMarginRequirementDraft}
|
||||
onEnableMemoryChange={runtimeControls.setEnableMemoryDraft}
|
||||
onModeChange={runtimeControls.setModeDraft}
|
||||
onPollIntervalChange={runtimeControls.setPollIntervalDraft}
|
||||
onStartDateChange={runtimeControls.setStartDateDraft}
|
||||
onEndDateChange={runtimeControls.setEndDateDraft}
|
||||
onWatchlistInputChange={runtimeControls.handleWatchlistInputChange}
|
||||
onWatchlistInputKeyDown={runtimeControls.handleWatchlistInputKeyDown}
|
||||
onWatchlistAdd={runtimeControls.handleWatchlistAdd}
|
||||
onWatchlistRemove={runtimeControls.handleWatchlistRemove}
|
||||
onWatchlistRestoreCurrent={runtimeControls.handleWatchlistRestoreCurrent}
|
||||
onWatchlistRestoreDefault={runtimeControls.handleWatchlistRestoreDefault}
|
||||
onWatchlistSuggestionClick={runtimeControls.handleWatchlistSuggestionClick}
|
||||
onLaunchConfigSave={runtimeControls.handleLaunchConfigSave}
|
||||
onRestoreDefaults={runtimeControls.handleRuntimeDefaultsRestore}
|
||||
displayTickers={runtimeControls.displayTickers}
|
||||
portfolioData={portfolioData}
|
||||
rollingTickers={rollingTickers}
|
||||
feed={feed}
|
||||
bubbles={bubbles}
|
||||
bubbleFor={bubbleFor}
|
||||
leaderboard={leaderboard}
|
||||
currentView={currentView}
|
||||
chartTab={chartTab}
|
||||
holdings={holdings}
|
||||
trades={trades}
|
||||
stats={stats}
|
||||
priceHistoryByTicker={priceHistoryByTicker}
|
||||
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||
selectedExplainSymbol={selectedExplainSymbol}
|
||||
onSelectedExplainSymbolChange={setSelectedExplainSymbol}
|
||||
historySourceByTicker={historySourceByTicker}
|
||||
explainEventsByTicker={explainEventsByTicker}
|
||||
newsByTicker={newsByTicker}
|
||||
insiderTradesByTicker={insiderTradesByTicker}
|
||||
technicalIndicatorsByTicker={technicalIndicatorsByTicker}
|
||||
currentDate={currentDate}
|
||||
stockRequests={stockRequests}
|
||||
agentRequests={agentRequests}
|
||||
agentProfilesByAgent={agentProfilesByAgent}
|
||||
leftWidth={leftWidth}
|
||||
isResizing={isResizing}
|
||||
onMouseDown={() => useUIStore.getState().setIsResizing(true)}
|
||||
agentFeedRef={agentFeedRef}
|
||||
/>
|
||||
|
||||
<RuntimeLogsModal
|
||||
isOpen={isRuntimeLogsOpen}
|
||||
isLoading={isRuntimeLogsLoading}
|
||||
logPayload={runtimeLogsPayload}
|
||||
error={runtimeLogsError}
|
||||
onClose={() => setIsRuntimeLogsOpen(false)}
|
||||
onRefresh={loadRuntimeLogs}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
594
frontend/src/components/AgentCard.jsx
Normal file
594
frontend/src/components/AgentCard.jsx
Normal file
@@ -0,0 +1,594 @@
|
||||
import React from 'react';
|
||||
import { ASSETS } from '../config/constants';
|
||||
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
||||
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||
|
||||
/**
|
||||
* Get rank medal/trophy
|
||||
*/
|
||||
function getRankMedal(rank) {
|
||||
if (rank === 1) return { emoji: '🏆', color: '#FFD700', label: '金牌' };
|
||||
if (rank === 2) return { emoji: '🥈', color: '#C0C0C0', label: '银牌' };
|
||||
if (rank === 3) return { emoji: '🥉', color: '#CD7F32', label: '铜牌' };
|
||||
return { emoji: `#${rank}`, color: '#333333', label: `#${rank}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Performance Card Component
|
||||
* Horizontal dropdown panel displayed below the agent indicator bar
|
||||
*/
|
||||
export default function AgentCard({ agent, onClose, isClosing }) {
|
||||
if (!agent) return null;
|
||||
|
||||
const bullTotal = agent.bull?.n || 0;
|
||||
const bullWins = agent.bull?.win || 0;
|
||||
const bullUnknown = agent.bull?.unknown || 0;
|
||||
const bearTotal = agent.bear?.n || 0;
|
||||
const bearWins = agent.bear?.win || 0;
|
||||
const bearUnknown = agent.bear?.unknown || 0;
|
||||
const totalSignals = bullTotal + bearTotal;
|
||||
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
|
||||
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
|
||||
const evaluatedTotal = evaluatedBull + evaluatedBear;
|
||||
const bullWinRate = evaluatedBull > 0 ? (bullWins / evaluatedBull) : null;
|
||||
const bearWinRate = evaluatedBear > 0 ? (bearWins / evaluatedBear) : null;
|
||||
const overallWinRate = agent.winRate != null
|
||||
? agent.winRate
|
||||
: (evaluatedTotal > 0 ? ((bullWins + bearWins) / evaluatedTotal) : null);
|
||||
const overallColor = overallWinRate != null
|
||||
? (overallWinRate >= 0.5 ? '#00C853' : '#FF1744')
|
||||
: '#555555';
|
||||
|
||||
const rankMedal = agent.rank ? getRankMedal(agent.rank) : null;
|
||||
const isPortfolioManager = agent.id === 'portfolio_manager';
|
||||
const isRiskManager = agent.id === 'risk_manager';
|
||||
const isValuationAnalyst = agent.id === 'valuation_analyst';
|
||||
const displayName = isPortfolioManager ? '团队' : agent.name;
|
||||
|
||||
// Get model icon configuration
|
||||
const modelInfo = getModelIcon(agent.modelName, agent.modelProvider);
|
||||
const shortModelName = getShortModelName(agent.modelName);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: '#ffffff',
|
||||
borderBottom: '2px solid #000000',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
zIndex: 800,
|
||||
animation: isClosing ? 'slideUp 0.2s ease-out forwards' : 'slideDown 0.25s ease-out'
|
||||
}}>
|
||||
{/* Horizontal scrollable content */}
|
||||
<div style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
padding: '12px',
|
||||
|
||||
/* Hide scrollbar for all browsers */
|
||||
scrollbarWidth: 'none', /* Firefox */
|
||||
msOverflowStyle: 'none', /* IE and Edge */
|
||||
}}>
|
||||
<style>
|
||||
{`
|
||||
div::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
minWidth: 'max-content'
|
||||
}}>
|
||||
{/* Agent Info with Rank */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '8px 12px',
|
||||
background: '#fafafa',
|
||||
border: '2px solid #000000',
|
||||
minWidth: 200
|
||||
}}>
|
||||
{isPortfolioManager ? (
|
||||
<img
|
||||
src={ASSETS.teamLogo}
|
||||
alt="Team"
|
||||
style={{
|
||||
height: 50,
|
||||
width: 50,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : agent.avatar ? (
|
||||
<img
|
||||
src={agent.avatar}
|
||||
alt={agent.name}
|
||||
style={{
|
||||
height: 50,
|
||||
width: 50,
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: '#000000',
|
||||
marginBottom: 2
|
||||
}}>
|
||||
{displayName}
|
||||
</div>
|
||||
{rankMedal && !isPortfolioManager && (
|
||||
<div style={{ fontSize: 18 }}>
|
||||
{rankMedal.emoji} 第 {agent.rank} 名
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Manager Note */}
|
||||
{isRiskManager && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#FFF9E6',
|
||||
border: '2px solid #FFA726',
|
||||
minWidth: 220,
|
||||
maxWidth: 280,
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#E65100',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word'
|
||||
}}>
|
||||
ⓘ 风控经理专注于风险管理,不参与预测准确率排名。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Portfolio Manager Note */}
|
||||
{isPortfolioManager && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#E8F5E9',
|
||||
border: '2px solid #66BB6A',
|
||||
minWidth: 220,
|
||||
maxWidth: 280,
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#2E7D32',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word'
|
||||
}}>
|
||||
ⓘ 投资经理综合所有分析师建议,提供团队最终交易信号,不参与排名。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Info Card */}
|
||||
{agent.modelName && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#ffffff',
|
||||
border: `2px solid ${modelInfo.color}`,
|
||||
minWidth: 140,
|
||||
position: 'relative',
|
||||
cursor: 'help'
|
||||
}}
|
||||
title={`模型:${agent.modelName}\n提供方:${modelInfo.provider}`}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: modelInfo.color,
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
模型
|
||||
</div>
|
||||
<div style={{
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 4
|
||||
}}>
|
||||
{agent.modelName || modelInfo.logoPath ? (
|
||||
<LobeModelLogo
|
||||
model={agent.modelName}
|
||||
provider={agent.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={36}
|
||||
type="color"
|
||||
shape="square"
|
||||
style={{
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
lineHeight: 1
|
||||
}}>
|
||||
🤖
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: modelInfo.color,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
{shortModelName}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8,
|
||||
color: '#666666',
|
||||
marginTop: 2
|
||||
}}>
|
||||
{modelInfo.provider}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overall Win Rate */}
|
||||
{!isRiskManager && !isPortfolioManager && (
|
||||
<div style={{
|
||||
padding: '8px 14px',
|
||||
background: '#fafafa',
|
||||
border: '2px solid #e0e0e0',
|
||||
textAlign: 'center',
|
||||
minWidth: 160
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: '#333333',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
胜率
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 700,
|
||||
color: overallColor,
|
||||
fontFamily: '"Courier New", monospace',
|
||||
lineHeight: 1,
|
||||
marginBottom: 2
|
||||
}}>
|
||||
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color: '#555555'
|
||||
}}>
|
||||
{bullWins + bearWins}胜 / {evaluatedTotal}评
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8,
|
||||
color: '#888888',
|
||||
marginTop: 4,
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'pre-line'
|
||||
}}>
|
||||
评估: 总评估多空信号数。{'\n'}胜率 = 正确信号 / 总评估信号
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bull Stats */}
|
||||
{!isRiskManager && !isPortfolioManager && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#F0FFF4',
|
||||
border: '2px solid #00C853',
|
||||
minWidth: 140
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#00C853',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
牛市胜率
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#333333') : '#555555',
|
||||
marginBottom: 2,
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color: '#333333'
|
||||
}}>
|
||||
{bullWins}胜 / {evaluatedBull}评
|
||||
{bullUnknown > 0 && ` / ${bullUnknown}P`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bear Stats */}
|
||||
{!isRiskManager && !isPortfolioManager && (
|
||||
<div style={{
|
||||
padding: '8px 12px',
|
||||
background: '#FFF5F5',
|
||||
border: '2px solid #FF1744',
|
||||
minWidth: 140
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#FF1744',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
熊市胜率
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#333333') : '#555555',
|
||||
marginBottom: 2,
|
||||
lineHeight: 1
|
||||
}}>
|
||||
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color: '#333333'
|
||||
}}>
|
||||
{bearWins}胜 / {evaluatedBear}评
|
||||
{bearUnknown > 0 && ` / ${bearUnknown}P`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Signals - Horizontal scroll */}
|
||||
{agent.signals && agent.signals.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
padding: '8px 12px',
|
||||
background: '#fafafa',
|
||||
border: '2px solid #e0e0e0'
|
||||
}}>
|
||||
{[...agent.signals]
|
||||
.filter(signal => signal && signal.signal)
|
||||
.sort((a, b) => {
|
||||
// Sort by date descending (newest first)
|
||||
const dateA = a.date || '';
|
||||
const dateB = b.date || '';
|
||||
return dateB.localeCompare(dateA);
|
||||
})
|
||||
.slice(0, 35)
|
||||
.map((signal, idx) => {
|
||||
const signalType = signal.signal.toLowerCase();
|
||||
const isBull = signalType.includes('bull') || signalType === 'long';
|
||||
const isBear = signalType.includes('bear') || signalType === 'short';
|
||||
const isNeutral = (!isBull && !isBear) || signalType.includes('neutral') || signalType === 'hold';
|
||||
const isCorrect = signal.is_correct === true;
|
||||
const isUnknown = signal.is_correct === 'unknown' || signal.is_correct === null;
|
||||
|
||||
// Determine result symbol/text and color: unknown has priority over neutral
|
||||
let resultDisplay;
|
||||
let resultColor = '#555555';
|
||||
let resultFontSize = 18;
|
||||
|
||||
if (isNeutral) {
|
||||
resultDisplay = '-';
|
||||
resultColor = '#555555'; // Gray for neutral
|
||||
} else if (isUnknown) {
|
||||
resultDisplay = '?';
|
||||
resultColor = '#FFA726'; // Orange for unknown
|
||||
resultFontSize = 14; // Smaller font for text
|
||||
} else {
|
||||
resultDisplay = isCorrect ? '✓' : '✗';
|
||||
resultColor = isCorrect ? '#00C853' : '#FF1744'; // Green for correct, Red for wrong
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
fontSize: 9,
|
||||
fontFamily: '"Courier New", monospace',
|
||||
padding: '6px 8px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
minWidth: 70
|
||||
}}>
|
||||
<div style={{
|
||||
fontWeight: 700,
|
||||
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#555555'
|
||||
}}>
|
||||
{signal.ticker}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#555555'
|
||||
}}>
|
||||
{isBull ? '看涨' : isBear ? '看跌' : '中性'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8,
|
||||
color: '#555555'
|
||||
}}>
|
||||
{signal.date?.substring(5, 10) || '暂无'}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: resultFontSize,
|
||||
fontWeight: 700,
|
||||
color: resultColor
|
||||
}}>
|
||||
{resultDisplay}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Info card explaining signal display */}
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
fontFamily: '"Courier New", monospace',
|
||||
padding: '6px 8px',
|
||||
background: '#E3F2FD',
|
||||
border: '1px solid #90CAF9',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
minWidth: 70,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#1976D2'
|
||||
}}>
|
||||
ⓘ 说明
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 8,
|
||||
color: '#1976D2',
|
||||
lineHeight: 1.2
|
||||
}}>
|
||||
仅显示最近5个交易日(1周)的信号
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Valuation Results Card - Only show for valuation_analyst */}
|
||||
{isValuationAnalyst && agent.signals && agent.signals.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
padding: '8px 12px',
|
||||
background: '#f5f5f5',
|
||||
border: '2px solid #7B1FA2'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: '#7B1FA2',
|
||||
minWidth: 80,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
估值分析
|
||||
</div>
|
||||
{agent.signals
|
||||
.filter(signal => signal && signal.intrinsic_value != null)
|
||||
.slice(0, 5)
|
||||
.map((signal, idx) => {
|
||||
const fairValue = signal.fair_value_range;
|
||||
const hasValuation = signal.intrinsic_value || fairValue;
|
||||
if (!hasValuation) return null;
|
||||
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
fontSize: 9,
|
||||
fontFamily: '"Courier New", monospace',
|
||||
padding: '6px 8px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #7B1FA2',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
minWidth: 90
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, color: '#333' }}>
|
||||
{signal.ticker}
|
||||
</div>
|
||||
{signal.intrinsic_value && (
|
||||
<div style={{ color: '#00C853', fontSize: 10 }}>
|
||||
内在 ${signal.intrinsic_value.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
{signal.value_gap_pct != null && (
|
||||
<div style={{
|
||||
color: signal.value_gap_pct > 0 ? '#00C853' : '#FF1744',
|
||||
fontSize: 9
|
||||
}}>
|
||||
{signal.value_gap_pct > 0 ? '+' : ''}{signal.value_gap_pct.toFixed(1)}%
|
||||
</div>
|
||||
)}
|
||||
{fairValue && (
|
||||
<div style={{ fontSize: 8, color: '#666' }}>
|
||||
区间 ${fairValue.bear?.toFixed(0) || '?'}-
|
||||
${fairValue.bull?.toFixed(0) || '?'}
|
||||
</div>
|
||||
)}
|
||||
{signal.valuation_methods && signal.valuation_methods.length > 0 && (
|
||||
<div style={{ fontSize: 7, color: '#999' }}>
|
||||
{signal.valuation_methods[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
{`
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
667
frontend/src/components/AgentFeed.jsx
Normal file
667
frontend/src/components/AgentFeed.jsx
Normal file
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { formatTime } from '../utils/formatters';
|
||||
import { MESSAGE_COLORS, getAgentColors, AGENTS, ASSETS } from '../config/constants';
|
||||
import { getModelIcon } from '../utils/modelIcons';
|
||||
import MarkdownModal from './MarkdownModal';
|
||||
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||
|
||||
const isAnalyst = (agentId, agentName) => {
|
||||
if (agentId && agentId.includes('analyst')) return true;
|
||||
if (agentName && agentName.toLowerCase().includes('analyst')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isManager = (agentId, agentName) => {
|
||||
if (agentId && agentId.includes('manager')) return true;
|
||||
if (agentName && agentName.toLowerCase().includes('manager')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const stripMarkdown = (text) => {
|
||||
return text
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '$1')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/__(.+?)__/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.+?)_/g, '$1')
|
||||
.replace(/`(.+?)`/g, '$1')
|
||||
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
||||
.replace(/!\[.*?\]\(.+?\)/g, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*\d+\.\s+/gm, '')
|
||||
.replace(/^\s*>\s+/gm, '')
|
||||
.replace(/\|/g, ' ')
|
||||
.replace(/^[-=]+$/gm, '');
|
||||
};
|
||||
|
||||
const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => {
|
||||
const feedContentRef = useRef(null);
|
||||
const [highlightedId, setHighlightedId] = useState(null);
|
||||
const [selectedAgent, setSelectedAgent] = useState('all');
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const getAgentModelInfo = (agentId) => {
|
||||
if (!agentId) return { modelName: null, modelProvider: null };
|
||||
const profile = agentProfilesByAgent?.[agentId];
|
||||
if (profile?.model_name) {
|
||||
return {
|
||||
modelName: profile.model_name,
|
||||
modelProvider: profile.model_provider
|
||||
};
|
||||
}
|
||||
if (!leaderboard) return { modelName: null, modelProvider: null };
|
||||
const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId);
|
||||
return {
|
||||
modelName: agentData?.modelName,
|
||||
modelProvider: agentData?.modelProvider
|
||||
};
|
||||
};
|
||||
|
||||
// Get agent info by name
|
||||
const getAgentInfoByName = (agentName) => {
|
||||
if (!agentName) return null;
|
||||
const agentConfig = AGENTS.find((agent) => agent.name === agentName);
|
||||
const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null;
|
||||
if (agentConfig && profile?.model_name) {
|
||||
return {
|
||||
agentId: agentConfig.id,
|
||||
modelName: profile.model_name,
|
||||
modelProvider: profile.model_provider
|
||||
};
|
||||
}
|
||||
if (!leaderboard) return null;
|
||||
const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName);
|
||||
if (!agentData) return null;
|
||||
return {
|
||||
agentId: agentData.id || agentData.agentId,
|
||||
modelName: agentData.modelName,
|
||||
modelProvider: agentData.modelProvider
|
||||
};
|
||||
};
|
||||
|
||||
// Get unique agent names from feed (only registered agents in AGENTS)
|
||||
const getUniqueAgents = () => {
|
||||
const agentNamesInFeed = new Set();
|
||||
|
||||
// Collect all agent names that appear in the feed
|
||||
feed.forEach(item => {
|
||||
if (item.type === 'message' && item.data?.agent) {
|
||||
agentNamesInFeed.add(item.data.agent);
|
||||
} else if (item.type === 'conference' && item.data?.messages) {
|
||||
item.data.messages.forEach(msg => {
|
||||
if (msg.agent) {
|
||||
agentNamesInFeed.add(msg.agent);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Filter to only include registered agents and sort by AGENTS array order
|
||||
const registeredAgentNames = AGENTS.map(a => a.name);
|
||||
return registeredAgentNames.filter(name => agentNamesInFeed.has(name));
|
||||
};
|
||||
|
||||
// Filter feed based on selected agent
|
||||
const filteredFeed = selectedAgent === 'all'
|
||||
? feed
|
||||
: feed.filter(item => {
|
||||
if (item.type === 'message') {
|
||||
return item.data?.agent === selectedAgent;
|
||||
} else if (item.type === 'conference') {
|
||||
return item.data?.messages?.some(msg => msg.agent === selectedAgent);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToMessage: (bubble) => {
|
||||
if (!bubble || !feedContentRef.current) return;
|
||||
|
||||
// Direct feedItemId match (used by replay mode)
|
||||
if (bubble.feedItemId) {
|
||||
const element = document.getElementById(`feed-item-${bubble.feedItemId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightedId(bubble.feedItemId);
|
||||
setTimeout(() => setHighlightedId(null), 2000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bubbleTimestamp = bubble.ts || bubble.timestamp;
|
||||
|
||||
// Check if a message matches the bubble
|
||||
const isMatch = (msg, checkTime = true) => {
|
||||
const agentMatch = msg.agentId === bubble.agentId || msg.agent === bubble.agentName;
|
||||
if (!agentMatch || !checkTime) return agentMatch;
|
||||
return Math.abs(msg.timestamp - bubbleTimestamp) < 5000;
|
||||
};
|
||||
|
||||
// Check if a feed item contains the target message
|
||||
const itemContains = (item, checkTime = true) => {
|
||||
if (item.type === 'message' && item.data) return isMatch(item.data, checkTime);
|
||||
if (item.type === 'conference' && item.data?.messages) {
|
||||
return item.data.messages.some(msg => isMatch(msg, checkTime));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Find exact match first, then fallback to agent match
|
||||
const targetItem = feed.find(item => itemContains(item, true))
|
||||
|| feed.find(item => itemContains(item, false));
|
||||
|
||||
if (targetItem) {
|
||||
const element = document.getElementById(`feed-item-${targetItem.id}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightedId(targetItem.id);
|
||||
setTimeout(() => setHighlightedId(null), 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [feed]);
|
||||
|
||||
const uniqueAgents = getUniqueAgents();
|
||||
|
||||
// Get current selection display info
|
||||
const getCurrentSelectionInfo = () => {
|
||||
if (selectedAgent === 'all') {
|
||||
return { label: '全部角色', modelInfo: null, agentInfo: null };
|
||||
}
|
||||
const agentInfo = getAgentInfoByName(selectedAgent);
|
||||
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
|
||||
return { label: selectedAgent, modelInfo, agentInfo };
|
||||
};
|
||||
|
||||
const currentSelection = getCurrentSelectionInfo();
|
||||
|
||||
return (
|
||||
<div className="agent-feed">
|
||||
<div className="agent-feed-header">
|
||||
<h3 className="agent-feed-title">活动 feed</h3>
|
||||
<div className="agent-filter-wrapper">
|
||||
<label className="agent-filter-label">筛选:</label>
|
||||
<div className="custom-select-wrapper">
|
||||
<button
|
||||
className="custom-select-trigger"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
onBlur={() => setTimeout(() => setDropdownOpen(false), 200)}
|
||||
>
|
||||
<div className="custom-select-value">
|
||||
{(currentSelection.agentInfo?.modelName || currentSelection.modelInfo?.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={currentSelection.agentInfo?.modelName}
|
||||
provider={currentSelection.agentInfo?.modelProvider}
|
||||
fallbackSrc={currentSelection.modelInfo?.logoPath}
|
||||
alt={currentSelection.modelInfo?.provider}
|
||||
size={18}
|
||||
className="select-model-icon"
|
||||
shape="square"
|
||||
type="color"
|
||||
/>
|
||||
)}
|
||||
<span>{currentSelection.label}</span>
|
||||
</div>
|
||||
<span className="custom-select-arrow">▼</span>
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<div className="custom-select-dropdown">
|
||||
<div
|
||||
className={`custom-select-option ${selectedAgent === 'all' ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedAgent('all');
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>全部角色</span>
|
||||
</div>
|
||||
{uniqueAgents.map(agent => {
|
||||
const agentInfo = getAgentInfoByName(agent);
|
||||
const modelInfo = agentInfo ? getModelIcon(agentInfo.modelName, agentInfo.modelProvider) : null;
|
||||
return (
|
||||
<div
|
||||
key={agent}
|
||||
className={`custom-select-option ${selectedAgent === agent ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedAgent(agent);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{(agentInfo?.modelName || modelInfo?.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentInfo?.modelName}
|
||||
provider={agentInfo?.modelProvider}
|
||||
fallbackSrc={modelInfo?.logoPath}
|
||||
alt={modelInfo?.provider}
|
||||
size={18}
|
||||
className="select-model-icon"
|
||||
shape="square"
|
||||
type="color"
|
||||
/>
|
||||
)}
|
||||
<span>{agent}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="feed-content" ref={feedContentRef}>
|
||||
{filteredFeed.length === 0 && (
|
||||
<div className="empty-state">
|
||||
{selectedAgent === 'all'
|
||||
? '等待系统更新...'
|
||||
: `${selectedAgent} 没有消息`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredFeed.map(item => {
|
||||
const isHighlighted = item.id === highlightedId;
|
||||
if (item.type === 'conference') {
|
||||
return <ConferenceItem key={item.id} conference={item.data} itemId={item.id} isHighlighted={isHighlighted} getAgentModelInfo={getAgentModelInfo} />;
|
||||
} else if (item.type === 'memory') {
|
||||
return <MemoryItem key={item.id} memory={item.data} itemId={item.id} isHighlighted={isHighlighted} />;
|
||||
} else if (item.data?.agent === 'System') {
|
||||
return <SystemDivider key={item.id} message={item.data} itemId={item.id} />;
|
||||
} else {
|
||||
return <MessageItem key={item.id} message={item.data} itemId={item.id} isHighlighted={isHighlighted} getAgentModelInfo={getAgentModelInfo} />;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AgentFeed.displayName = 'AgentFeed';
|
||||
|
||||
export default AgentFeed;
|
||||
|
||||
function SystemDivider({ message, itemId }) {
|
||||
const content = String(message.content || '');
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`feed-item-${itemId}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, height: '1px', backgroundColor: '#d0d0d0' }} />
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: '#888',
|
||||
whiteSpace: 'normal',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.3px',
|
||||
}}>
|
||||
{content}
|
||||
</span>
|
||||
<div style={{ flex: 1, height: '1px', backgroundColor: '#d0d0d0' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConferenceItem({ conference, itemId, isHighlighted, getAgentModelInfo }) {
|
||||
const colors = MESSAGE_COLORS.conference;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`feed-item-${itemId}`}
|
||||
className="feed-item"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
outline: isHighlighted ? '2px solid #615CED' : 'none',
|
||||
transition: 'outline 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<div className="feed-item-header">
|
||||
<span className="feed-item-title" style={{ color: colors.text }}>
|
||||
会议
|
||||
</span>
|
||||
{conference.isLive && <span className="feed-live-badge">● 实时</span>}
|
||||
<span className="feed-item-time">{formatTime(conference.startTime)}</span>
|
||||
</div>
|
||||
|
||||
<div className="feed-item-subtitle" style={{ color: colors.text }}>
|
||||
{conference.title}
|
||||
</div>
|
||||
|
||||
<div className="conference-messages">
|
||||
{conference.messages.map((msg, idx) => (
|
||||
<ConferenceMessage key={idx} message={msg} getAgentModelInfo={getAgentModelInfo} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConferenceMessage({ message, getAgentModelInfo }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const agentColors = message.agent === 'System' ? MESSAGE_COLORS.system :
|
||||
message.agent === 'Memory' ? MESSAGE_COLORS.memory :
|
||||
getAgentColors(message.agentId, message.agent);
|
||||
|
||||
const agentModelData = message.agentId && getAgentModelInfo ?
|
||||
getAgentModelInfo(message.agentId) :
|
||||
{ modelName: null, modelProvider: null };
|
||||
const modelInfo = getModelIcon(agentModelData.modelName, agentModelData.modelProvider);
|
||||
|
||||
let content = message.content || '';
|
||||
if (typeof content === 'object') {
|
||||
content = JSON.stringify(content, null, 2);
|
||||
} else {
|
||||
content = String(content);
|
||||
}
|
||||
|
||||
const needsTruncation = content.length > 200;
|
||||
const MAX_EXPANDED_LENGTH = 10000;
|
||||
|
||||
let displayContent = content;
|
||||
if (!expanded && needsTruncation) {
|
||||
displayContent = content.substring(0, 200) + '...';
|
||||
} else if (expanded && content.length > MAX_EXPANDED_LENGTH) {
|
||||
displayContent = content.substring(0, MAX_EXPANDED_LENGTH) + '...';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="conf-message-item">
|
||||
<div className="conf-agent-name" style={{ color: agentColors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
{(agentModelData.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentModelData.modelName}
|
||||
provider={agentModelData.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={20}
|
||||
shape="circle"
|
||||
type="color"
|
||||
style={{ borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
{message.agent}
|
||||
</div>
|
||||
<div className="conf-message-content-wrapper">
|
||||
<span className="conf-message-content">{stripMarkdown(displayContent)}</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
className="conf-expand-btn"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '« 收起' : '更多 »'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryItem({ memory, itemId, isHighlighted }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const colors = MESSAGE_COLORS.memory;
|
||||
|
||||
let content = memory.content || '';
|
||||
if (typeof content === 'object') {
|
||||
content = JSON.stringify(content, null, 2);
|
||||
} else {
|
||||
content = String(content);
|
||||
}
|
||||
|
||||
const needsTruncation = content.length > 200;
|
||||
const MAX_EXPANDED_LENGTH = 10000;
|
||||
|
||||
let displayContent = content;
|
||||
if (!expanded && needsTruncation) {
|
||||
displayContent = content.substring(0, 200) + '...';
|
||||
} else if (expanded && content.length > MAX_EXPANDED_LENGTH) {
|
||||
displayContent = content.substring(0, MAX_EXPANDED_LENGTH) + '...';
|
||||
}
|
||||
|
||||
const agentLabel = memory.agent && memory.agent !== 'Memory'
|
||||
? `记忆 · ${memory.agent}`
|
||||
: '记忆';
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`feed-item-${itemId}`}
|
||||
className="feed-item"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #F0F9FF 0%, #F6F4FF 100%)',
|
||||
border: '1px solid rgba(0, 194, 255, 0.15)',
|
||||
outline: isHighlighted ? '2px solid #615CED' : 'none',
|
||||
transition: 'outline 0.3s ease',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<div className="feed-item-header">
|
||||
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<div
|
||||
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
|
||||
onMouseEnter={() => setShowTooltip(true)}
|
||||
onMouseLeave={() => setShowTooltip(false)}
|
||||
>
|
||||
<span
|
||||
style={{ display: 'flex', alignItems: 'center', textDecoration: 'none' }}
|
||||
>
|
||||
<img
|
||||
src={ASSETS.remeLogo}
|
||||
alt="Memory"
|
||||
style={{
|
||||
cursor: 'default',
|
||||
height: '12px',
|
||||
width: 'auto',
|
||||
objectFit: 'contain',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: showTooltip ? 1 : 0.9,
|
||||
filter: showTooltip ? 'brightness(1.1)' : 'none'
|
||||
}}
|
||||
/>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
marginLeft: '4px',
|
||||
opacity: showTooltip ? 0.6 : 0,
|
||||
transform: showTooltip ? 'translate(0, 0)' : 'translate(-4px, 2px)',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: colors.text,
|
||||
lineHeight: 1,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
MEMORY
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span style={{
|
||||
background: 'linear-gradient(90deg, #00C2FF 0%, #5C4CE0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{agentLabel}
|
||||
</span>
|
||||
</span>
|
||||
<span className="feed-item-time">{formatTime(memory.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '34px',
|
||||
left: '12px',
|
||||
right: '12px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
color: '#334155',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
zIndex: 100,
|
||||
boxShadow: '0 4px 12px rgba(0, 194, 255, 0.1)',
|
||||
opacity: showTooltip ? 1 : 0,
|
||||
visibility: showTooltip ? 'visible' : 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
pointerEvents: 'none',
|
||||
border: '1px solid rgba(0, 194, 255, 0.15)'
|
||||
}}>
|
||||
<div style={{
|
||||
fontWeight: '700',
|
||||
marginBottom: '3px',
|
||||
background: 'linear-gradient(90deg, #00C2FF 0%, #5C4CE0 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
display: 'inline-block'
|
||||
}}>
|
||||
Runtime Memory Layer
|
||||
</div>
|
||||
<div style={{ color: '#475569', opacity: 0.9 }}>
|
||||
Retrieves relevant historical context and produces guidance for the current task based on the latest conversation state.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="feed-item-content">{stripMarkdown(displayContent)}</div>
|
||||
|
||||
{needsTruncation && (
|
||||
<button
|
||||
className="feed-expand-btn"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '« 收起' : '更多 »'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageItem({ message, itemId, isHighlighted, getAgentModelInfo }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
const colors = message.agent === 'Memory' ? MESSAGE_COLORS.memory :
|
||||
getAgentColors(message.agentId, message.agent);
|
||||
const title = message.agent === 'Memory' ? '记忆' : message.agent || '智能体';
|
||||
|
||||
const agentModelData = message.agentId && getAgentModelInfo ?
|
||||
getAgentModelInfo(message.agentId) :
|
||||
{ modelName: null, modelProvider: null };
|
||||
const modelInfo = getModelIcon(agentModelData.modelName, agentModelData.modelProvider);
|
||||
|
||||
const isAnalystAgent = isAnalyst(message.agentId, message.agent);
|
||||
const isManagerAgent = isManager(message.agentId, message.agent);
|
||||
const useModalView = isAnalystAgent || isManagerAgent;
|
||||
|
||||
let content = message.content || '';
|
||||
if (typeof content === 'object') {
|
||||
content = JSON.stringify(content, null, 2);
|
||||
} else {
|
||||
content = String(content);
|
||||
}
|
||||
|
||||
let displayContent = content;
|
||||
let showExpandButton = false;
|
||||
|
||||
if (useModalView) {
|
||||
displayContent = content.length > 150 ? content.substring(0, 150) + '...' : content;
|
||||
} else {
|
||||
const needsTruncation = content.length > 200;
|
||||
const MAX_EXPANDED_LENGTH = 8000;
|
||||
|
||||
if (!expanded && needsTruncation) {
|
||||
displayContent = content.substring(0, 200) + '...';
|
||||
showExpandButton = true;
|
||||
} else if (expanded && content.length > MAX_EXPANDED_LENGTH) {
|
||||
displayContent = content.substring(0, MAX_EXPANDED_LENGTH) + '...';
|
||||
showExpandButton = needsTruncation;
|
||||
} else {
|
||||
showExpandButton = needsTruncation;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id={`feed-item-${itemId}`}
|
||||
className="feed-item"
|
||||
style={{
|
||||
backgroundColor: colors.bg,
|
||||
outline: isHighlighted ? '2px solid #615CED' : 'none',
|
||||
transition: 'outline 0.3s ease'
|
||||
}}
|
||||
>
|
||||
<div className="feed-item-header">
|
||||
<span className="feed-item-title" style={{ color: colors.text, display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px' }}>
|
||||
{message.agent !== 'Memory' && (agentModelData.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentModelData.modelName}
|
||||
provider={agentModelData.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={20}
|
||||
shape="circle"
|
||||
type="color"
|
||||
style={{ borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
</span>
|
||||
<span className="feed-item-time">{formatTime(message.timestamp)}</span>
|
||||
</div>
|
||||
|
||||
<div className="feed-item-content">{stripMarkdown(displayContent)}</div>
|
||||
|
||||
{useModalView && (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: isHovering ? '#000' : '#666',
|
||||
fontWeight: '700',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '4px 0',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
outline: 'none'
|
||||
}}
|
||||
>
|
||||
📄 {isManagerAgent ? '查看决策日志 »' : '查看完整报告 »'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showExpandButton && (
|
||||
<button
|
||||
className="feed-expand-btn"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? '« 收起' : '更多 »'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{useModalView && (
|
||||
<MarkdownModal
|
||||
isOpen={showModal}
|
||||
onClose={() => setShowModal(false)}
|
||||
content={content}
|
||||
agentName={message.agent}
|
||||
reportType={isManagerAgent ? 'decision' : 'analysis'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
511
frontend/src/components/AppShell.jsx
Normal file
511
frontend/src/components/AppShell.jsx
Normal file
@@ -0,0 +1,511 @@
|
||||
import React, { Suspense, lazy, useRef, useEffect, useMemo } from 'react';
|
||||
import GlobalStyles from '../styles/GlobalStyles';
|
||||
import Header from './Header.jsx';
|
||||
import RuntimeSettingsPanel from './RuntimeSettingsPanel.jsx';
|
||||
import NetValueChart from './NetValueChart.jsx';
|
||||
import { AGENTS } from '../config/constants';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import { useUIStore } from '../store/uiStore';
|
||||
import { formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||
|
||||
const RoomView = lazy(() => import('./RoomView'));
|
||||
const AgentFeed = lazy(() => import('./AgentFeed'));
|
||||
const StatisticsView = lazy(() => import('./StatisticsView'));
|
||||
const StockExplainView = lazy(() => import('./StockExplainView.jsx'));
|
||||
const TraderView = lazy(() => import('./TraderView.jsx'));
|
||||
|
||||
function ViewLoadingFallback({ label = '加载中...' }) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: 240,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.4
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AppShell - Layout shell containing Header, TickerBar, ViewNavBar, View container, and AgentFeed
|
||||
*/
|
||||
export default function AppShell({
|
||||
// Connection & status
|
||||
isConnected,
|
||||
virtualTime,
|
||||
now,
|
||||
marketStatus,
|
||||
serverMode,
|
||||
marketStatusLabel,
|
||||
dataSourceLabel,
|
||||
runtimeSummaryLabel,
|
||||
isUpdating,
|
||||
// Handlers
|
||||
onManualTrigger,
|
||||
onOpenRuntimeLogs,
|
||||
onRuntimeSettingsToggle,
|
||||
// Runtime settings panel props
|
||||
isRuntimeSettingsOpen,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSaving,
|
||||
runtimeConfigFeedback,
|
||||
watchlistFeedback,
|
||||
launchModeDraft,
|
||||
restoreRunIdDraft,
|
||||
runtimeHistoryRuns,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
onLaunchModeChange,
|
||||
onRestoreRunIdChange,
|
||||
onScheduleModeChange,
|
||||
onIntervalMinutesChange,
|
||||
onTriggerTimeChange,
|
||||
onMaxCommCyclesChange,
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onModeChange,
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
onWatchlistRemove,
|
||||
onWatchlistRestoreCurrent,
|
||||
onWatchlistRestoreDefault,
|
||||
onWatchlistSuggestionClick,
|
||||
onLaunchConfigSave,
|
||||
onRestoreDefaults,
|
||||
// Ticker and portfolio data
|
||||
displayTickers,
|
||||
portfolioData,
|
||||
rollingTickers,
|
||||
// Feed data
|
||||
feed,
|
||||
bubbles,
|
||||
bubbleFor,
|
||||
leaderboard,
|
||||
// Views data
|
||||
currentView,
|
||||
chartTab,
|
||||
holdings,
|
||||
trades,
|
||||
stats,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedExplainSymbol,
|
||||
onSelectedExplainSymbolChange,
|
||||
historySourceByTicker,
|
||||
explainEventsByTicker,
|
||||
newsByTicker,
|
||||
insiderTradesByTicker,
|
||||
technicalIndicatorsByTicker,
|
||||
currentDate,
|
||||
// Stock request handlers
|
||||
stockRequests,
|
||||
// Agent request handlers
|
||||
agentRequests,
|
||||
agentProfilesByAgent,
|
||||
// Layout
|
||||
leftWidth,
|
||||
isResizing,
|
||||
onMouseDown,
|
||||
agentFeedRef
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const { setIsRuntimeSettingsOpen, setIsWatchlistPanelOpen } = useRuntimeStore();
|
||||
const { setChartTab, setCurrentView, setIsResizing, setLeftWidth } = useUIStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'openclaw') {
|
||||
setCurrentView('statistics');
|
||||
}
|
||||
}, [currentView, setCurrentView]);
|
||||
|
||||
// Resize handler
|
||||
useEffect(() => {
|
||||
if (!isResizing) return;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!containerRef.current) return;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
|
||||
if (newLeftWidth >= 30 && newLeftWidth <= 85) {
|
||||
setLeftWidth(newLeftWidth);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => setIsResizing(false);
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isResizing, setIsResizing, setLeftWidth]);
|
||||
|
||||
const handleJumpToMessage = (bubble) => {
|
||||
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
|
||||
agentFeedRef.current.scrollToMessage(bubble);
|
||||
}
|
||||
};
|
||||
|
||||
const viewClassName = useMemo(() => {
|
||||
const base = `view-slider-five ${currentView === 'traders' ? 'show-traders' :
|
||||
currentView === 'room' ? 'show-room' :
|
||||
currentView === 'explain' ? 'show-explain' :
|
||||
currentView === 'chart' ? 'show-chart' :
|
||||
'show-statistics'}`;
|
||||
return base;
|
||||
}, [currentView]);
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<GlobalStyles />
|
||||
|
||||
{/* Header */}
|
||||
<div className="header">
|
||||
<Header />
|
||||
|
||||
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
|
||||
{/* Unified Status Indicator */}
|
||||
<div className="header-status-inline">
|
||||
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
|
||||
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
|
||||
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
|
||||
</span>
|
||||
{marketStatus && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
|
||||
{marketStatusLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{dataSourceLabel && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className="market-text backtest">{dataSourceLabel}</span>
|
||||
</>
|
||||
)}
|
||||
{runtimeSummaryLabel && (
|
||||
<>
|
||||
<span className="status-sep">·</span>
|
||||
<span className="market-text backtest" title="当前运行配置">{runtimeSummaryLabel}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="status-sep">·</span>
|
||||
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||||
</div>
|
||||
|
||||
{serverMode !== 'backtest' && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{onOpenRuntimeLogs && (
|
||||
<button
|
||||
onClick={onOpenRuntimeLogs}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.4px',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
title="查看当前运行日志"
|
||||
>
|
||||
运行日志
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onManualTrigger}
|
||||
disabled={!isConnected}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: 4,
|
||||
background: isConnected ? '#111111' : '#8a8a8a',
|
||||
border: '1px solid #111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontWeight: 700,
|
||||
cursor: isConnected ? 'pointer' : 'not-allowed',
|
||||
letterSpacing: '0.4px',
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
title="手动触发一轮分析与交易决策"
|
||||
>
|
||||
手动运行
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RuntimeSettingsPanel
|
||||
showTrigger={false}
|
||||
isOpen={isRuntimeSettingsOpen}
|
||||
isConnected={isConnected}
|
||||
isSaving={isRuntimeConfigSaving || isWatchlistSaving}
|
||||
feedback={runtimeConfigFeedback || watchlistFeedback}
|
||||
launchMode={launchModeDraft}
|
||||
restoreRunId={restoreRunIdDraft}
|
||||
runtimeHistoryRuns={runtimeHistoryRuns}
|
||||
scheduleMode={scheduleModeDraft}
|
||||
intervalMinutes={intervalMinutesDraft}
|
||||
triggerTime={triggerTimeDraft}
|
||||
maxCommCycles={maxCommCyclesDraft}
|
||||
initialCash={initialCashDraft}
|
||||
marginRequirement={marginRequirementDraft}
|
||||
enableMemory={enableMemoryDraft}
|
||||
mode={modeDraft}
|
||||
pollInterval={pollIntervalDraft}
|
||||
startDate={startDateDraft}
|
||||
endDate={endDateDraft}
|
||||
watchlistSymbols={watchlistDraftSymbols}
|
||||
watchlistInputValue={watchlistInputValue}
|
||||
watchlistSuggestions={watchlistSuggestions}
|
||||
onToggle={onRuntimeSettingsToggle}
|
||||
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||||
onLaunchModeChange={onLaunchModeChange}
|
||||
onRestoreRunIdChange={onRestoreRunIdChange}
|
||||
onScheduleModeChange={onScheduleModeChange}
|
||||
onIntervalMinutesChange={onIntervalMinutesChange}
|
||||
onTriggerTimeChange={onTriggerTimeChange}
|
||||
onMaxCommCyclesChange={onMaxCommCyclesChange}
|
||||
onInitialCashChange={onInitialCashChange}
|
||||
onMarginRequirementChange={onMarginRequirementChange}
|
||||
onEnableMemoryChange={onEnableMemoryChange}
|
||||
onModeChange={onModeChange}
|
||||
onPollIntervalChange={onPollIntervalChange}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
onWatchlistInputChange={onWatchlistInputChange}
|
||||
onWatchlistInputKeyDown={onWatchlistInputKeyDown}
|
||||
onWatchlistAdd={onWatchlistAdd}
|
||||
onWatchlistRemove={onWatchlistRemove}
|
||||
onWatchlistRestoreCurrent={onWatchlistRestoreCurrent}
|
||||
onWatchlistRestoreDefault={onWatchlistRestoreDefault}
|
||||
onWatchlistSuggestionClick={onWatchlistSuggestionClick}
|
||||
onSave={onLaunchConfigSave}
|
||||
onRestoreDefaults={onRestoreDefaults}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<>
|
||||
{/* Ticker Bar */}
|
||||
<div className="ticker-bar">
|
||||
<div className="ticker-track">
|
||||
{[0, 1].map((groupIdx) => (
|
||||
<div key={groupIdx} className="ticker-group">
|
||||
{displayTickers.map(ticker => (
|
||||
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
|
||||
<span className="ticker-symbol">{ticker.symbol}</span>
|
||||
<span className="ticker-price">
|
||||
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
|
||||
{ticker.price !== null && ticker.price !== undefined
|
||||
? `$${formatTickerPrice(ticker.price)}` : '-'}
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ticker-change ${
|
||||
ticker.change === null || ticker.change === undefined
|
||||
? '' : ticker.change >= 0 ? 'positive' : 'negative'
|
||||
}`}>
|
||||
{ticker.change !== null && ticker.change !== undefined
|
||||
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="portfolio-value">
|
||||
<span className="portfolio-label">投资组合</span>
|
||||
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="main-container" ref={containerRef}>
|
||||
{/* Left Panel */}
|
||||
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
|
||||
<div className="chart-section">
|
||||
<div className="view-container">
|
||||
<div className="view-nav-bar">
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'traders' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('traders')}
|
||||
>
|
||||
交易员
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('room')}
|
||||
>
|
||||
交易室
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('explain')}
|
||||
>
|
||||
个股分析
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('chart')}
|
||||
>
|
||||
业绩图表
|
||||
</button>
|
||||
<button
|
||||
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
|
||||
onClick={() => setCurrentView('statistics')}
|
||||
>
|
||||
统计
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={viewClassName}>
|
||||
{/* Traders View */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||||
<TraderView {...agentRequests} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Room View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||||
<RoomView
|
||||
bubbles={bubbles}
|
||||
bubbleFor={bubbleFor}
|
||||
leaderboard={leaderboard}
|
||||
agentProfilesByAgent={agentProfilesByAgent}
|
||||
feed={feed}
|
||||
onJumpToMessage={handleJumpToMessage}
|
||||
onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Stock Explain View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||||
<StockExplainView
|
||||
tickers={displayTickers}
|
||||
holdings={holdings}
|
||||
trades={trades}
|
||||
leaderboard={leaderboard}
|
||||
feed={feed}
|
||||
priceHistoryByTicker={priceHistoryByTicker}
|
||||
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||||
selectedSymbol={selectedExplainSymbol}
|
||||
onSelectedSymbolChange={onSelectedExplainSymbolChange}
|
||||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||||
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||||
insiderTradesSnapshot={insiderTradesByTicker[selectedExplainSymbol] || null}
|
||||
technicalIndicatorsSnapshot={technicalIndicatorsByTicker[selectedExplainSymbol] || null}
|
||||
onRequestHistory={stockRequests?.requestStockHistory}
|
||||
onRequestExplainEvents={stockRequests?.requestStockExplainEvents}
|
||||
onRequestNews={stockRequests?.requestStockNews}
|
||||
onRequestRangeExplain={stockRequests?.requestStockRangeExplain}
|
||||
onRequestNewsForDate={stockRequests?.requestStockNewsForDate}
|
||||
onRequestStory={stockRequests?.requestStockStory}
|
||||
onRequestInsiderTrades={stockRequests?.requestStockInsiderTrades}
|
||||
onRequestTechnicalIndicators={stockRequests?.requestStockTechnicalIndicators}
|
||||
currentDate={currentDate}
|
||||
onRequestSimilarDays={stockRequests?.requestStockSimilarDays}
|
||||
onRequestStockEnrich={stockRequests?.requestStockEnrich}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Chart View Panel */}
|
||||
<div className="view-panel">
|
||||
<div className="chart-container">
|
||||
<div className="chart-tabs-floating">
|
||||
<button
|
||||
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setChartTab('all')}
|
||||
>
|
||||
日线
|
||||
</button>
|
||||
</div>
|
||||
{currentView === 'chart' ? (
|
||||
<NetValueChart
|
||||
equity={portfolioData.equity}
|
||||
baseline={portfolioData.baseline}
|
||||
baseline_vw={portfolioData.baseline_vw}
|
||||
momentum={portfolioData.momentum}
|
||||
strategies={portfolioData.strategies}
|
||||
equity_return={portfolioData.equity_return}
|
||||
baseline_return={portfolioData.baseline_return}
|
||||
baseline_vw_return={portfolioData.baseline_vw_return}
|
||||
momentum_return={portfolioData.momentum_return}
|
||||
chartTab={chartTab}
|
||||
virtualTime={virtualTime}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ height: '100%', minHeight: 320 }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics View Panel */}
|
||||
<div className="view-panel">
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||||
<StatisticsView
|
||||
trades={trades}
|
||||
holdings={holdings}
|
||||
stats={stats}
|
||||
portfolioData={portfolioData}
|
||||
baseline_vw={portfolioData.baseline_vw}
|
||||
equity={portfolioData.equity}
|
||||
leaderboard={leaderboard}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizer */}
|
||||
<div className={`resizer ${isResizing ? 'resizing' : ''}`} onMouseDown={onMouseDown} />
|
||||
|
||||
{/* Right Panel: Agent Feed */}
|
||||
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
|
||||
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||||
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} agentProfilesByAgent={agentProfilesByAgent} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/Header.jsx
Normal file
29
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Header Component
|
||||
* Reusable header brand for 大时代.
|
||||
*/
|
||||
export default function Header() {
|
||||
return (
|
||||
<div className="header-title" style={{ flex: '0 1 auto', minWidth: 0 }}>
|
||||
<span
|
||||
className="header-link"
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '3px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/trading_logo.png"
|
||||
alt="大时代 Logo"
|
||||
style={{ height: '24px', width: 'auto' }}
|
||||
/>
|
||||
大时代
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/LobeModelLogo.jsx
Normal file
78
frontend/src/components/LobeModelLogo.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import ModelIcon from '@lobehub/icons/es/features/ModelIcon';
|
||||
import ProviderIcon from '@lobehub/icons/es/features/ProviderIcon';
|
||||
|
||||
export default function LobeModelLogo({
|
||||
model,
|
||||
provider,
|
||||
fallbackSrc = null,
|
||||
alt = '',
|
||||
size = 28,
|
||||
shape = 'square',
|
||||
type = 'color',
|
||||
style = {},
|
||||
className = '',
|
||||
}) {
|
||||
const hasModel = typeof model === 'string' && model.trim().length > 0;
|
||||
const hasProvider = typeof provider === 'string' && provider.trim().length > 0;
|
||||
|
||||
try {
|
||||
if (hasModel) {
|
||||
return (
|
||||
<ModelIcon
|
||||
model={model}
|
||||
size={size}
|
||||
shape={shape}
|
||||
type={type}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasProvider) {
|
||||
return (
|
||||
<ProviderIcon
|
||||
provider={provider.toLowerCase()}
|
||||
size={size}
|
||||
shape={shape}
|
||||
type={type}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to local fallback asset.
|
||||
}
|
||||
|
||||
if (fallbackSrc) {
|
||||
return (
|
||||
<img
|
||||
src={fallbackSrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
objectFit: 'contain',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: shape === 'circle' ? '50%' : 8,
|
||||
background: '#F3F4F6',
|
||||
border: '1px solid #D1D5DB',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
276
frontend/src/components/MarkdownModal.jsx
Normal file
276
frontend/src/components/MarkdownModal.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
function MarkdownModal({ isOpen, onClose, content, agentName, reportType = 'analysis' }) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const subtitle = reportType === 'decision' ? 'Decision Log' : 'Financial Analysis Report';
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '2px',
|
||||
padding: '0',
|
||||
maxWidth: '900px',
|
||||
maxHeight: '85vh',
|
||||
overflow: 'hidden',
|
||||
width: '90%',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '24px 32px',
|
||||
borderBottom: '2px solid #000',
|
||||
backgroundColor: '#fafafa',
|
||||
}}>
|
||||
<div>
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '18px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
color: '#000',
|
||||
}}>
|
||||
{agentName}
|
||||
</h2>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.3px',
|
||||
}}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: '#000',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
outline: 'none',
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#333'}
|
||||
onMouseOut={(e) => e.currentTarget.style.backgroundColor = '#000'}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
padding: '32px 32px 24px 32px',
|
||||
overflow: 'auto',
|
||||
backgroundColor: '#fff',
|
||||
flex: 1,
|
||||
}}>
|
||||
<style>{`
|
||||
.markdown-content {
|
||||
color: #1a1a1a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin: 32px 0 16px 0;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid #000;
|
||||
color: #000;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.markdown-content h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 28px 0 12px 0;
|
||||
color: #000;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #d0d0d0;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 24px 0 10px 0;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.markdown-content h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin: 20px 0 8px 0;
|
||||
color: #2a2a2a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin: 12px 0;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
color: #2a2a2a;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
font-size: 13px;
|
||||
border: 1px solid #000;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #000;
|
||||
color: #fff;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.markdown-content td {
|
||||
border: 1px solid #d0d0d0;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.markdown-content tr:nth-child(even) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.markdown-content tr:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin: 16px 0;
|
||||
padding-left: 28px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin: 8px 0;
|
||||
color: #2a2a2a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-content li::marker {
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-content strong {
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.markdown-content em {
|
||||
font-style: italic;
|
||||
color: #3a3a3a;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
color: #000;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: #fafafa;
|
||||
padding: 16px;
|
||||
border-radius: 2px;
|
||||
overflow-x: auto;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-left: 3px solid #000;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #000;
|
||||
margin: 20px 0;
|
||||
padding: 12px 20px;
|
||||
background-color: #fafafa;
|
||||
color: #2a2a2a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #d0d0d0;
|
||||
margin: 32px 0;
|
||||
}
|
||||
`}</style>
|
||||
<div className="markdown-content">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownModal;
|
||||
|
||||
830
frontend/src/components/NetValueChart.jsx
Normal file
830
frontend/src/components/NetValueChart.jsx
Normal file
@@ -0,0 +1,830 @@
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { formatNumber, formatFullNumber } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Helper function to get the start time of the most recent trading session
|
||||
* Trading session: 22:30 - next day 05:00
|
||||
* @param {Date|null} virtualTime - Virtual time from server, or null to use real time
|
||||
*/
|
||||
function getRecentTradingSessionStart(virtualTime = null) {
|
||||
// Use virtual time if provided, otherwise use real time
|
||||
let now;
|
||||
if (virtualTime) {
|
||||
// Ensure virtualTime is a valid Date object
|
||||
if (virtualTime instanceof Date && !isNaN(virtualTime.getTime())) {
|
||||
now = virtualTime;
|
||||
} else if (typeof virtualTime === 'string') {
|
||||
now = new Date(virtualTime);
|
||||
if (isNaN(now.getTime())) {
|
||||
console.warn('Invalid virtualTime string, using current time:', virtualTime);
|
||||
now = new Date();
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid virtualTime type, using current time:', typeof virtualTime);
|
||||
now = new Date();
|
||||
}
|
||||
} else {
|
||||
now = new Date();
|
||||
}
|
||||
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
|
||||
// Check if currently in trading session
|
||||
const isInTradingSession = (currentHour === 22 && currentMinute >= 30) ||
|
||||
currentHour >= 23 ||
|
||||
(currentHour >= 0 && currentHour < 5) ||
|
||||
(currentHour === 5 && currentMinute === 0);
|
||||
|
||||
let sessionStartTime;
|
||||
if (isInTradingSession) {
|
||||
// Currently in trading session, find today's 22:30
|
||||
sessionStartTime = new Date(now);
|
||||
sessionStartTime.setHours(22, 30, 0, 0);
|
||||
// If current time is before 22:30, it means yesterday's 22:30
|
||||
if (now < sessionStartTime) {
|
||||
sessionStartTime.setDate(sessionStartTime.getDate() - 1);
|
||||
}
|
||||
} else {
|
||||
// Not in trading session, find previous session start (yesterday 22:30)
|
||||
sessionStartTime = new Date(now);
|
||||
sessionStartTime.setDate(sessionStartTime.getDate() - 1);
|
||||
sessionStartTime.setHours(22, 30, 0, 0);
|
||||
}
|
||||
|
||||
return sessionStartTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to filter strategy data for live view
|
||||
* NOTE: Live mode returns are now pre-processed by the backend, restricted to the
|
||||
* latest trading session and already starting at 0% at session start. This helper
|
||||
* is kept for potential future use but is no longer used in live mode.
|
||||
*/
|
||||
function filterStrategyDataForLive(strategyData, equity, sessionStartTime) {
|
||||
if (!strategyData || strategyData.length === 0 || !equity || equity.length === 0) return [];
|
||||
|
||||
try {
|
||||
if (!sessionStartTime || isNaN(sessionStartTime.getTime())) {
|
||||
console.warn('Invalid sessionStartTime in filterStrategyDataForLive');
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionStartTimestamp = sessionStartTime.getTime();
|
||||
|
||||
// Find the last index before session
|
||||
let lastDataBeforeSession = null;
|
||||
for (let i = equity.length - 1; i >= 0; i--) {
|
||||
if (equity[i] && typeof equity[i].t === 'number' && equity[i].t < sessionStartTimestamp) {
|
||||
if (strategyData[i] && strategyData[i].v !== undefined && strategyData[i].v !== null) {
|
||||
lastDataBeforeSession = strategyData[i];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find data points in the session
|
||||
const sessionData = [];
|
||||
for (let i = 0; i < equity.length; i++) {
|
||||
if (equity[i] && typeof equity[i].t === 'number' &&
|
||||
equity[i].t >= sessionStartTimestamp &&
|
||||
strategyData[i] &&
|
||||
strategyData[i].v !== undefined && strategyData[i].v !== null) {
|
||||
sessionData.push(strategyData[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a value before session and session data, add the start point
|
||||
// Create a start point with timestamp just before session start
|
||||
if (lastDataBeforeSession && sessionData.length > 0) {
|
||||
const startPoint = {
|
||||
t: sessionStartTimestamp - 1,
|
||||
v: lastDataBeforeSession.v
|
||||
};
|
||||
return [startPoint, ...sessionData];
|
||||
}
|
||||
|
||||
return sessionData;
|
||||
} catch (error) {
|
||||
console.error('Error in filterStrategyDataForLive:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Net Value Chart Component
|
||||
* Displays portfolio value over time with multiple strategy comparisons
|
||||
*/
|
||||
export default function NetValueChart({ equity, baseline, baseline_vw, momentum, strategies, equity_return, baseline_return, baseline_vw_return, momentum_return, chartTab = 'all', virtualTime = null }) {
|
||||
const [activePoint, setActivePoint] = useState(null);
|
||||
const [stableYRange, setStableYRange] = useState(null);
|
||||
const [legendTooltip, setLegendTooltip] = useState(null);
|
||||
|
||||
// Legend descriptions
|
||||
const legendDescriptions = {
|
||||
'大时代': '大时代 is our agents investment strategy',
|
||||
'Buy & Hold (EW)': 'Equal Weight: Can be viewed as an equal-weighted index of all invested stocks',
|
||||
'Buy & Hold (VW)': 'Value Weighted: Can be viewed as a market-cap weighted index of all invested stocks',
|
||||
'Momentum': 'Momentum Strategy: Buy stocks that have performed well in the past',
|
||||
};
|
||||
|
||||
|
||||
// For live mode, use cumulative returns calculated by backend
|
||||
// For all mode, use portfolio values directly
|
||||
const dataSource = useMemo(() => {
|
||||
if (chartTab === 'live') {
|
||||
return {
|
||||
equity: equity_return || equity,
|
||||
baseline: baseline_return || baseline,
|
||||
baseline_vw: baseline_vw_return || baseline_vw,
|
||||
momentum: momentum_return || momentum
|
||||
};
|
||||
}
|
||||
return {
|
||||
equity: equity,
|
||||
baseline: baseline,
|
||||
baseline_vw: baseline_vw,
|
||||
momentum: momentum
|
||||
};
|
||||
}, [chartTab, equity, baseline, baseline_vw, momentum, equity_return, baseline_return, baseline_vw_return, momentum_return]);
|
||||
// Filter equity data based on chartTab
|
||||
const filteredEquity = useMemo(() => {
|
||||
if (chartTab === 'all') {
|
||||
const sourceEquity = dataSource.equity;
|
||||
if (!sourceEquity || sourceEquity.length === 0) return [];
|
||||
|
||||
// ALL chart: Show only the last point per day
|
||||
// Logic: Keep the last equity value before 22:30 each day (the last equity value before US next trading day opens)
|
||||
// Data after 22:30 belongs to the next trading day's session and is not shown in this chart
|
||||
// Time handling: timestamp(ms) -> UTC -> Asia/Shanghai timezone, then group and filter based on Asia/Shanghai time
|
||||
const dailyData = {};
|
||||
|
||||
sourceEquity.forEach((d) => {
|
||||
// Timestamp is in milliseconds, first create UTC time, then convert to Asia/Shanghai timezone
|
||||
// Equivalent to: pd.to_datetime(timestamp, unit='ms', utc=True).dt.tz_convert('Asia/Shanghai')
|
||||
const utcDate = new Date(d.t); // timestamp(ms) -> UTC time
|
||||
|
||||
// Use Intl API to get date/time components in Asia/Shanghai timezone
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(utcDate);
|
||||
const year = parts.find(p => p.type === 'year').value;
|
||||
const month = parts.find(p => p.type === 'month').value;
|
||||
const day = parts.find(p => p.type === 'day').value;
|
||||
const hour = parseInt(parts.find(p => p.type === 'hour').value);
|
||||
const minute = parseInt(parts.find(p => p.type === 'minute').value);
|
||||
|
||||
// Check if before 22:30 (Asia/Shanghai timezone)
|
||||
const isBefore2230 = hour < 22 || (hour === 22 && minute < 30);
|
||||
|
||||
// Only process data before 22:30
|
||||
if (isBefore2230) {
|
||||
// Use Asia/Shanghai timezone date as key
|
||||
const dateKey = `${year}-${month}-${day}`;
|
||||
|
||||
// Update if this day has no data yet, or if current data is later in time
|
||||
if (!dailyData[dateKey] || new Date(d.t) > new Date(dailyData[dateKey].t)) {
|
||||
dailyData[dateKey] = d;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort by time
|
||||
return Object.values(dailyData).sort((a, b) => a.t - b.t);
|
||||
} else if (chartTab === 'live') {
|
||||
// LIVE chart: Show all updates from the most recent trading session (22:30-05:00)
|
||||
// Live mode: Backend has already returned return curves for "current trading session + 0% starting point", frontend can use directly
|
||||
const sourceEquity = dataSource.equity;
|
||||
if (!sourceEquity || sourceEquity.length === 0) return [];
|
||||
return sourceEquity;
|
||||
}
|
||||
return dataSource.equity || [];
|
||||
}, [dataSource.equity, chartTab, virtualTime]);
|
||||
// Helper function to get daily indices for 'all' view
|
||||
const getDailyIndices = useMemo(() => {
|
||||
if (!equity || equity.length === 0) return new Set();
|
||||
const dailyIndices = new Set();
|
||||
const dailyData = {};
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
equity.forEach((d, idx) => {
|
||||
const utcDate = new Date(d.t);
|
||||
const parts = formatter.formatToParts(utcDate);
|
||||
const hour = parseInt(parts.find(p => p.type === 'hour').value);
|
||||
const minute = parseInt(parts.find(p => p.type === 'minute').value);
|
||||
|
||||
// Check if before 22:30 (Asia/Shanghai timezone)
|
||||
const isBefore2230 = hour < 22 || (hour === 22 && minute < 30);
|
||||
|
||||
// Only process data before 22:30
|
||||
if (isBefore2230) {
|
||||
const year = parts.find(p => p.type === 'year').value;
|
||||
const month = parts.find(p => p.type === 'month').value;
|
||||
const day = parts.find(p => p.type === 'day').value;
|
||||
const dateKey = `${year}-${month}-${day}`;
|
||||
|
||||
if (!dailyData[dateKey] || new Date(d.t) > new Date(dailyData[dateKey].t)) {
|
||||
dailyData[dateKey] = { data: d, index: idx };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Object.values(dailyData).forEach(({ index }) => dailyIndices.add(index));
|
||||
return dailyIndices;
|
||||
}, [equity]);
|
||||
|
||||
// Filter baseline, baseline_vw, momentum, strategies to match filteredEquity indices
|
||||
const filteredBaseline = useMemo(() => {
|
||||
const sourceBaseline = dataSource.baseline;
|
||||
if (!sourceBaseline || sourceBaseline.length === 0 || !equity || equity.length === 0) return [];
|
||||
if (chartTab === 'all') {
|
||||
return sourceBaseline.filter((_, idx) => getDailyIndices.has(idx));
|
||||
} else if (chartTab === 'live') {
|
||||
// Live mode: Use backend pre-processed baseline return curves directly
|
||||
return sourceBaseline;
|
||||
}
|
||||
return sourceBaseline;
|
||||
}, [dataSource.baseline, equity, chartTab, getDailyIndices, virtualTime]);
|
||||
const filteredBaselineVw = useMemo(() => {
|
||||
const sourceBaselineVw = dataSource.baseline_vw;
|
||||
if (!sourceBaselineVw || sourceBaselineVw.length === 0 || !equity || equity.length === 0) return [];
|
||||
if (chartTab === 'all') {
|
||||
return sourceBaselineVw.filter((_, idx) => getDailyIndices.has(idx));
|
||||
} else if (chartTab === 'live') {
|
||||
// Live mode: Use backend pre-processed baseline return curves directly
|
||||
return sourceBaselineVw;
|
||||
}
|
||||
return sourceBaselineVw;
|
||||
}, [dataSource.baseline_vw, equity, chartTab, getDailyIndices, virtualTime]);
|
||||
const filteredMomentum = useMemo(() => {
|
||||
const sourceMomentum = dataSource.momentum;
|
||||
if (!sourceMomentum || sourceMomentum.length === 0 || !equity || equity.length === 0) return [];
|
||||
if (chartTab === 'all') {
|
||||
return sourceMomentum.filter((_, idx) => getDailyIndices.has(idx));
|
||||
} else if (chartTab === 'live') {
|
||||
// Live mode: Use backend pre-processed momentum return curves directly
|
||||
return sourceMomentum;
|
||||
}
|
||||
return sourceMomentum;
|
||||
}, [dataSource.momentum, equity, chartTab, getDailyIndices, virtualTime]);
|
||||
const filteredStrategies = useMemo(() => {
|
||||
if (!strategies || strategies.length === 0 || !equity || equity.length === 0) return [];
|
||||
if (chartTab === 'all') {
|
||||
return strategies.filter((_, idx) => getDailyIndices.has(idx));
|
||||
} else if (chartTab === 'live') {
|
||||
const sessionStartTime = getRecentTradingSessionStart(virtualTime);
|
||||
return filterStrategyDataForLive(strategies, equity, sessionStartTime);
|
||||
}
|
||||
return strategies;
|
||||
}, [strategies, equity, chartTab, getDailyIndices, virtualTime]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!filteredEquity || filteredEquity.length === 0) return [];
|
||||
|
||||
try {
|
||||
// LIVE mode: Align all curves by timestamp with forward filling to ensure consistent point counts and aligned starting points
|
||||
if (chartTab === 'live') {
|
||||
// Build timestamp -> value mapping
|
||||
const toMap = (arr) => {
|
||||
const m = new Map();
|
||||
if (Array.isArray(arr)) {
|
||||
arr.forEach((p) => {
|
||||
if (p && typeof p.t === 'number' && typeof p.v === 'number') {
|
||||
m.set(p.t, p.v);
|
||||
}
|
||||
});
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
const portfolioMap = toMap(filteredEquity);
|
||||
const baselineMap = toMap(filteredBaseline);
|
||||
const baselineVwMap = toMap(filteredBaselineVw);
|
||||
const momentumMap = toMap(filteredMomentum);
|
||||
const strategyMap = toMap(filteredStrategies);
|
||||
|
||||
// Collect all timestamps, sort by time
|
||||
const timestampSet = new Set();
|
||||
[filteredEquity, filteredBaseline, filteredBaselineVw, filteredMomentum, filteredStrategies].forEach(arr => {
|
||||
if (Array.isArray(arr)) {
|
||||
arr.forEach(p => {
|
||||
if (p && typeof p.t === 'number') timestampSet.add(p.t);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const timestamps = Array.from(timestampSet).sort((a, b) => a - b);
|
||||
if (timestamps.length === 0) return [];
|
||||
|
||||
// Current values for forward filling, initialized to 0% to ensure starting point alignment
|
||||
let currentPortfolio = 0;
|
||||
let currentBaseline = 0;
|
||||
let currentBaselineVw = 0;
|
||||
let currentMomentum = 0;
|
||||
let currentStrategy = 0;
|
||||
|
||||
return timestamps.map((t, idx) => {
|
||||
if (portfolioMap.has(t)) currentPortfolio = portfolioMap.get(t);
|
||||
if (baselineMap.has(t)) currentBaseline = baselineMap.get(t);
|
||||
if (baselineVwMap.has(t)) currentBaselineVw = baselineVwMap.get(t);
|
||||
if (momentumMap.has(t)) currentMomentum = momentumMap.get(t);
|
||||
if (strategyMap.has(t)) currentStrategy = strategyMap.get(t);
|
||||
|
||||
const date = new Date(t);
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('Invalid timestamp in live chart data:', t);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index: idx,
|
||||
time:
|
||||
date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
timestamp: t,
|
||||
portfolio: currentPortfolio,
|
||||
baseline: currentBaseline,
|
||||
baseline_vw: currentBaselineVw,
|
||||
momentum: currentMomentum,
|
||||
strategy: currentStrategy,
|
||||
};
|
||||
}).filter(item => item !== null);
|
||||
}
|
||||
|
||||
// ALL mode: Keep the original index-based alignment logic
|
||||
return filteredEquity.map((d, idx) => {
|
||||
if (!d || typeof d.t !== 'number' || typeof d.v !== 'number') {
|
||||
console.warn('Invalid equity data point:', d);
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(d.t);
|
||||
if (isNaN(date.getTime())) {
|
||||
console.warn('Invalid timestamp:', d.t);
|
||||
return null;
|
||||
}
|
||||
|
||||
const baselineVal = filteredBaseline?.[idx]
|
||||
? (typeof filteredBaseline[idx] === 'object' ? filteredBaseline[idx].v : filteredBaseline[idx])
|
||||
: null;
|
||||
const baselineVwVal = filteredBaselineVw?.[idx]
|
||||
? (typeof filteredBaselineVw[idx] === 'object' ? filteredBaselineVw[idx].v : filteredBaselineVw[idx])
|
||||
: null;
|
||||
const momentumVal = filteredMomentum?.[idx]
|
||||
? (typeof filteredMomentum[idx] === 'object' ? filteredMomentum[idx].v : filteredMomentum[idx])
|
||||
: null;
|
||||
const strategyVal = filteredStrategies?.[idx]
|
||||
? (typeof filteredStrategies[idx] === 'object' ? filteredStrategies[idx].v : filteredStrategies[idx])
|
||||
: null;
|
||||
|
||||
return {
|
||||
index: idx,
|
||||
time:
|
||||
date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}),
|
||||
timestamp: d.t,
|
||||
portfolio: d.v,
|
||||
baseline: baselineVal || null,
|
||||
baseline_vw: baselineVwVal || null,
|
||||
momentum: momentumVal || null,
|
||||
strategy: strategyVal || null,
|
||||
};
|
||||
}).filter(item => item !== null); // Remove null entries
|
||||
} catch (error) {
|
||||
console.error('Error processing chart data:', error);
|
||||
return [];
|
||||
}
|
||||
}, [filteredEquity, filteredBaseline, filteredBaselineVw, filteredMomentum, filteredStrategies, chartTab]);
|
||||
|
||||
const { yMin, yMax, xTickIndices } = useMemo(() => {
|
||||
if (chartData.length === 0) return { yMin: 0, yMax: 1, xTickIndices: [] };
|
||||
|
||||
// Calculate min and max from all series
|
||||
const allValues = chartData.flatMap(d =>
|
||||
[d.portfolio, d.baseline, d.baseline_vw, d.momentum, d.strategy].filter(v => v !== null && isFinite(v))
|
||||
);
|
||||
|
||||
if (allValues.length === 0) {
|
||||
return { yMin: 0, yMax: 1000000, xTickIndices: [] };
|
||||
}
|
||||
|
||||
const dataMin = Math.min(...allValues);
|
||||
const dataMax = Math.max(...allValues);
|
||||
const range = dataMax - dataMin || 1;
|
||||
|
||||
// For live mode (percentage data), use smaller padding and finer rounding
|
||||
// For all mode (dollar amounts), use larger padding and coarser rounding
|
||||
const isLiveMode = chartTab === 'live';
|
||||
|
||||
const paddingFactor = isLiveMode ? range * 0.15 : range * 0.03;
|
||||
|
||||
let yMinCalc = dataMin - paddingFactor;
|
||||
let yMaxCalc = dataMax + paddingFactor;
|
||||
|
||||
// Smart rounding based on magnitude and mode
|
||||
const magnitude = Math.max(Math.abs(yMinCalc), Math.abs(yMaxCalc));
|
||||
let roundTo;
|
||||
|
||||
if (isLiveMode) {
|
||||
// For percentage data, use much finer rounding
|
||||
if (magnitude >= 100) {
|
||||
roundTo = 10;
|
||||
} else if (magnitude >= 10) {
|
||||
roundTo = 1;
|
||||
} else if (magnitude >= 1) {
|
||||
roundTo = 0.1;
|
||||
} else {
|
||||
roundTo = 0.01;
|
||||
}
|
||||
} else {
|
||||
// For dollar amounts, use coarser rounding
|
||||
if (magnitude >= 1e6) {
|
||||
roundTo = 10000;
|
||||
} else if (magnitude >= 1e5) {
|
||||
roundTo = 5000;
|
||||
} else if (magnitude >= 1e4) {
|
||||
roundTo = 1000;
|
||||
} else {
|
||||
roundTo = 100;
|
||||
}
|
||||
}
|
||||
|
||||
yMinCalc = Math.floor(yMinCalc / roundTo) * roundTo;
|
||||
yMaxCalc = Math.ceil(yMaxCalc / roundTo) * roundTo;
|
||||
|
||||
// Stable range to prevent frequent updates
|
||||
if (stableYRange) {
|
||||
const { min: stableMin, max: stableMax } = stableYRange;
|
||||
const stableRange = stableMax - stableMin;
|
||||
const threshold = stableRange * 0.05;
|
||||
|
||||
const needsUpdate =
|
||||
dataMin < (stableMin + threshold) ||
|
||||
dataMax > (stableMax - threshold);
|
||||
|
||||
if (!needsUpdate) {
|
||||
yMinCalc = stableMin;
|
||||
yMaxCalc = stableMax;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate x-axis tick indices
|
||||
const safeLength = Math.min(chartData.length, 10000);
|
||||
const targetTicks = Math.min(8, Math.max(5, Math.floor(safeLength / 10)));
|
||||
const step = Math.max(1, Math.floor(safeLength / (targetTicks - 1)));
|
||||
|
||||
const indices = [];
|
||||
for (let i = 0; i < safeLength && indices.length < 100; i += step) {
|
||||
indices.push(i);
|
||||
}
|
||||
|
||||
if (safeLength > 0 && indices[indices.length - 1] !== safeLength - 1) {
|
||||
indices.push(safeLength - 1);
|
||||
}
|
||||
|
||||
return { yMin: yMinCalc, yMax: yMaxCalc, xTickIndices: indices };
|
||||
}, [chartData, stableYRange]);
|
||||
|
||||
// Update stableYRange in useEffect to avoid infinite re-renders
|
||||
// Use functional update to avoid dependency on stableYRange
|
||||
useEffect(() => {
|
||||
if (yMin !== undefined && yMax !== undefined && yMin !== null && yMax !== null && isFinite(yMin) && isFinite(yMax)) {
|
||||
setStableYRange(prevRange => {
|
||||
if (!prevRange) {
|
||||
// Initialize stable range
|
||||
return { min: yMin, max: yMax };
|
||||
} else {
|
||||
// Check if update is needed (5% threshold)
|
||||
const stableRange = prevRange.max - prevRange.min;
|
||||
const threshold = stableRange * 0.05;
|
||||
const needsUpdate =
|
||||
yMin < (prevRange.min + threshold) ||
|
||||
yMax > (prevRange.max - threshold);
|
||||
|
||||
if (needsUpdate) {
|
||||
return { min: yMin, max: yMax };
|
||||
}
|
||||
// No update needed, return previous range
|
||||
return prevRange;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [yMin, yMax]);
|
||||
|
||||
if (!equity || equity.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#cccccc',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
暂无图表数据
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomTooltip = ({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const isLiveMode = chartTab === 'live';
|
||||
return (
|
||||
<div style={{
|
||||
background: '#000000',
|
||||
border: '1px solid #333333',
|
||||
padding: '10px 14px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '10px',
|
||||
color: '#ffffff'
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: '6px', fontSize: '11px' }}>
|
||||
{payload[0].payload.time}
|
||||
</div>
|
||||
{payload.map((entry, index) => (
|
||||
<div key={index} style={{ color: entry.color, marginTop: '2px' }}>
|
||||
<span style={{ fontWeight: 700 }}>{entry.name}:</span> {isLiveMode ? `${entry.value.toFixed(2)}%` : `$${formatNumber(entry.value)}`} </div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const CustomDot = ({ dataKey, ...props }) => {
|
||||
const { cx, cy, payload, index } = props;
|
||||
const isActive = activePoint === index;
|
||||
const isLastPoint = index === chartData.length - 1;
|
||||
|
||||
// Only show dot for the last point
|
||||
if (!isLastPoint) {
|
||||
return null;
|
||||
}
|
||||
const colors = {
|
||||
portfolio: '#00C853',
|
||||
baseline: '#FF6B00',
|
||||
baseline_vw: '#9C27B0',
|
||||
momentum: '#2196F3',
|
||||
strategy: '#795548'
|
||||
};
|
||||
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={isActive ? 6 : 8}
|
||||
fill={colors[dataKey]}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => setActivePoint(index)}
|
||||
onMouseLeave={() => setActivePoint(null)}
|
||||
onClick={() => console.log('Clicked point:', { dataKey, ...payload })}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomXAxisTick = ({ x, y, payload }) => {
|
||||
const shouldShow = xTickIndices.includes(payload.index);
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={16}
|
||||
textAnchor="middle"
|
||||
fill="#666666"
|
||||
fontSize="10px"
|
||||
fontFamily='"Courier New", monospace'
|
||||
fontWeight="700"
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomLegend = ({ payload }) => {
|
||||
if (!payload || payload.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
padding: '10px 0',
|
||||
position: 'relative',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{payload.map((entry, index) => {
|
||||
const description = legendDescriptions[entry.value] || '';
|
||||
const isActive = legendTooltip === entry.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: isActive ? '#f0f0f0' : 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onMouseEnter={() => setLegendTooltip(entry.value)}
|
||||
onMouseLeave={() => setLegendTooltip(null)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLegendTooltip(isActive ? null : entry.value);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '14px',
|
||||
height: '3px',
|
||||
backgroundColor: entry.color,
|
||||
border: 'none'
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#000000'
|
||||
}}
|
||||
>
|
||||
{entry.value}
|
||||
</span>
|
||||
{isActive && description && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
marginBottom: '8px',
|
||||
padding: '8px 12px',
|
||||
background: '#000000',
|
||||
color: '#ffffff',
|
||||
fontSize: '10px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
whiteSpace: 'normal',
|
||||
maxWidth: '300px',
|
||||
zIndex: 1000,
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none',
|
||||
lineHeight: 1.4
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, bottom: 50, left: 60 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#666666"
|
||||
tick={<CustomXAxisTick />}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[yMin, yMax]}
|
||||
stroke="#000000"
|
||||
style={{ fontFamily: '"Courier New", monospace', fontSize: '11px', fontWeight: 700 }}
|
||||
tick={{ fill: '#000000' }}
|
||||
tickFormatter={(value) => chartTab === 'live' ? `${value.toFixed(2)}%` : formatFullNumber(value)}
|
||||
width={75}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
content={<CustomLegend />}
|
||||
/>
|
||||
|
||||
{/* Portfolio line */}
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="portfolio"
|
||||
name="大时代"
|
||||
stroke="#00C853"
|
||||
strokeWidth={2.5}
|
||||
dot={(props) => <CustomDot {...props} dataKey="portfolio" />}
|
||||
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
{/* Baseline Equal Weight */}
|
||||
{baseline && baseline.length > 0 && (
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="baseline"
|
||||
name="Buy & Hold (EW)"
|
||||
stroke="#FF6B00"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
dot={(props) => <CustomDot {...props} dataKey="baseline" />}
|
||||
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Baseline Value Weighted */}
|
||||
{baseline_vw && baseline_vw.length > 0 && (
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="baseline_vw"
|
||||
name="Buy & Hold (VW)"
|
||||
stroke="#9C27B0"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="8 4"
|
||||
dot={(props) => <CustomDot {...props} dataKey="baseline_vw" />}
|
||||
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Momentum Strategy */}
|
||||
{momentum && momentum.length > 0 && (
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="momentum"
|
||||
name="Momentum"
|
||||
stroke="#2196F3"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
dot={(props) => <CustomDot {...props} dataKey="momentum" />}
|
||||
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Other Strategies */}
|
||||
{strategies && strategies.length > 0 && (
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="strategy"
|
||||
name="Strategy"
|
||||
stroke="#795548"
|
||||
strokeWidth={2}
|
||||
dot={(props) => <CustomDot {...props} dataKey="strategy" />}
|
||||
activeDot={{ r: 6, stroke: '#ffffff', strokeWidth: 2 }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
1086
frontend/src/components/OpenClawStatus.jsx
Normal file
1086
frontend/src/components/OpenClawStatus.jsx
Normal file
File diff suppressed because it is too large
Load Diff
5
frontend/src/components/OpenClawView.jsx
Normal file
5
frontend/src/components/OpenClawView.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { OpenClawStatus } from './OpenClawStatus';
|
||||
|
||||
export default function OpenClawView() {
|
||||
return <OpenClawStatus />;
|
||||
}
|
||||
235
frontend/src/components/PerformanceView.jsx
Normal file
235
frontend/src/components/PerformanceView.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Performance View Component
|
||||
* Displays agent performance leaderboard and signal history
|
||||
*/
|
||||
export default function PerformanceView({ leaderboard }) {
|
||||
const rankedAgents = Array.isArray(leaderboard)
|
||||
? leaderboard.filter(agent => agent.agentId !== 'risk_manager')
|
||||
: [];
|
||||
return (
|
||||
<div>
|
||||
{/* Agent Performance Section */}
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析师表现 - 信号准确率</h2>
|
||||
</div>
|
||||
|
||||
{rankedAgents.length === 0 ? (
|
||||
<div className="empty-state">暂无排行榜数据</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>排名</th>
|
||||
<th>分析师</th>
|
||||
<th>胜率</th>
|
||||
<th>看涨信号</th>
|
||||
<th>看涨胜率</th>
|
||||
<th>看跌信号</th>
|
||||
<th>看跌胜率</th>
|
||||
<th>总信号数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rankedAgents.map(agent => {
|
||||
const bullTotal = agent.bull?.n || 0;
|
||||
const bullWins = agent.bull?.win || 0;
|
||||
const bullUnknown = agent.bull?.unknown || 0;
|
||||
const bearTotal = agent.bear?.n || 0;
|
||||
const bearWins = agent.bear?.win || 0;
|
||||
const bearUnknown = agent.bear?.unknown || 0;
|
||||
const totalSignals = bullTotal + bearTotal;
|
||||
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
|
||||
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
|
||||
const evaluatedTotal = evaluatedBull + evaluatedBear;
|
||||
const bullWinRate = evaluatedBull > 0 ? (bullWins / evaluatedBull) : null;
|
||||
const bearWinRate = evaluatedBear > 0 ? (bearWins / evaluatedBear) : null;
|
||||
const overallWinRate = agent.winRate != null
|
||||
? agent.winRate
|
||||
: (evaluatedTotal > 0 ? ((bullWins + bearWins) / evaluatedTotal) : null);
|
||||
const overallColor = overallWinRate != null
|
||||
? (overallWinRate >= 0.5 ? '#00C853' : '#FF1744')
|
||||
: '#999999';
|
||||
|
||||
return (
|
||||
<tr key={agent.agentId}>
|
||||
<td>
|
||||
<span className={`rank-badge ${agent.rank === 1 ? 'first' : agent.rank === 2 ? 'second' : agent.rank === 3 ? 'third' : ''}`}>
|
||||
{agent.rank === 1 ? '★ 1' : agent.rank}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 700, color: '#000000' }}>{agent.name}</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{agent.role}</div>
|
||||
</td>
|
||||
<td style={{ fontWeight: 700, color: overallColor }}>
|
||||
{overallWinRate != null ? `${(overallWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontSize: 12 }}>{bullTotal} 个信号</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{bullWins} 次命中</div>
|
||||
{bullUnknown > 0 && (
|
||||
<div style={{ fontSize: 10, color: '#999999' }}>{bullUnknown} 条未判定</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ color: bullWinRate != null ? (bullWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
|
||||
{bullWinRate != null ? `${(bullWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ fontSize: 12 }}>{bearTotal} 个信号</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{bearWins} 次命中</div>
|
||||
{bearUnknown > 0 && (
|
||||
<div style={{ fontSize: 10, color: '#999999' }}>{bearUnknown} 条未判定</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ color: bearWinRate != null ? (bearWinRate >= 0.5 ? '#00C853' : '#999999') : '#999999' }}>
|
||||
{bearWinRate != null ? `${(bearWinRate * 100).toFixed(1)}%` : '暂无'}
|
||||
</td>
|
||||
<td style={{ fontWeight: 700 }}>{totalSignals}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signal History with Dates */}
|
||||
{rankedAgents.length > 0 && rankedAgents.some(agent => agent.signals && agent.signals.length > 0) && (
|
||||
<div className="section" style={{ marginTop: 32 }}>
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">信号历史</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: 20 }}>
|
||||
{rankedAgents.map(agent => {
|
||||
if (!agent.signals || agent.signals.length === 0) return null;
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
const sortedSignals = [...agent.signals].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={agent.agentId} style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
padding: 16,
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<div style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
marginBottom: 12,
|
||||
paddingBottom: 8,
|
||||
borderBottom: '2px solid #000000',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div style={{
|
||||
maxHeight: 500,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8
|
||||
}}>
|
||||
{sortedSignals.map((signal, idx) => {
|
||||
const signalType = signal.signal.toLowerCase();
|
||||
const isBull = signalType.includes('bull') || signalType === 'long';
|
||||
const isBear = signalType.includes('bear') || signalType === 'short';
|
||||
const isNeutral = signalType.includes('neutral') || signalType === 'hold';
|
||||
const resultStatus = signal.is_correct;
|
||||
const isCorrect = resultStatus === true;
|
||||
const isResultUnknown = resultStatus === 'unknown' || resultStatus === null || typeof resultStatus === 'undefined';
|
||||
const realReturnValue = signal.real_return;
|
||||
const hasRealReturn = typeof realReturnValue === 'number' && Number.isFinite(realReturnValue);
|
||||
const realReturnDisplay = hasRealReturn
|
||||
? `${realReturnValue >= 0 ? '+' : ''}${(realReturnValue * 100).toFixed(2)}%`
|
||||
: '未判定';
|
||||
const realReturnColor = hasRealReturn
|
||||
? (realReturnValue >= 0 ? '#00C853' : '#FF1744')
|
||||
: '#999999';
|
||||
const statusColor = isResultUnknown ? '#999999' : (isCorrect ? '#00C853' : '#FF1744');
|
||||
const statusSymbol = isResultUnknown ? '?' : (isCorrect ? '✓' : '✗');
|
||||
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
fontSize: 11,
|
||||
fontFamily: '"Courier New", monospace',
|
||||
lineHeight: 1.4,
|
||||
padding: '8px 10px',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<span style={{
|
||||
color: '#666666',
|
||||
fontSize: 10,
|
||||
marginRight: 10,
|
||||
fontWeight: 600
|
||||
}}>
|
||||
{signal.date}
|
||||
</span>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#999999'
|
||||
}}>
|
||||
{signal.ticker}
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: 6,
|
||||
color: isBull ? '#00C853' : isBear ? '#FF1744' : '#999999',
|
||||
fontSize: 12
|
||||
}}>
|
||||
{isBull ? '看涨' : isBear ? '看跌' : '中性'}
|
||||
</span>
|
||||
{!isNeutral && (
|
||||
<span style={{
|
||||
marginLeft: 8,
|
||||
fontSize: 10,
|
||||
color: realReturnColor
|
||||
}}>
|
||||
{realReturnDisplay}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isNeutral && (
|
||||
<span style={{
|
||||
fontSize: 14,
|
||||
marginLeft: 10,
|
||||
color: statusColor
|
||||
}}>
|
||||
{statusSymbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: 10,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
fontSize: 10,
|
||||
color: '#666666',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
共 {sortedSignals.length} 条信号
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
795
frontend/src/components/RoomView.jsx
Normal file
795
frontend/src/components/RoomView.jsx
Normal file
@@ -0,0 +1,795 @@
|
||||
import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { ASSETS, SCENE_NATIVE, AGENT_SEATS, AGENTS } from '../config/constants';
|
||||
import AgentCard from './AgentCard';
|
||||
import { getModelIcon } from '../utils/modelIcons';
|
||||
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||
|
||||
/**
|
||||
* Custom hook to load an image
|
||||
*/
|
||||
function useImage(src) {
|
||||
const [img, setImg] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setImg(null);
|
||||
return;
|
||||
}
|
||||
// Reset image state when backend changes
|
||||
setImg(null);
|
||||
const image = new Image();
|
||||
image.src = src;
|
||||
image.onload = () => setImg(image);
|
||||
image.onerror = () => {
|
||||
console.error(`Failed to load image: ${src}`);
|
||||
setImg(null);
|
||||
};
|
||||
// Cleanup: cancel loading if backend changes
|
||||
return () => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
};
|
||||
}, [src]);
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rank medal/trophy for display
|
||||
*/
|
||||
function getRankMedal(rank) {
|
||||
if (rank === 1) return '🏆';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room View Component
|
||||
* Displays the conference room with agents, speech bubbles, and agent cards
|
||||
* Supports click and hover (1.5s) to show agent performance cards
|
||||
* Supports replay mode - completely independent from live mode
|
||||
*/
|
||||
export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) {
|
||||
const canvasRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// Agent selection and hover state
|
||||
const [selectedAgent, setSelectedAgent] = useState(null);
|
||||
const [hoveredAgent, setHoveredAgent] = useState(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const hoverTimerRef = useRef(null);
|
||||
const closeTimerRef = useRef(null);
|
||||
|
||||
// Bubble expansion state
|
||||
const [expandedBubbles, setExpandedBubbles] = useState({});
|
||||
|
||||
// Hidden bubbles (locally dismissed)
|
||||
const [hiddenBubbles, setHiddenBubbles] = useState({});
|
||||
|
||||
// Handle bubble close
|
||||
const handleCloseBubble = (agentId, bubbleKey, e) => {
|
||||
e.stopPropagation();
|
||||
setHiddenBubbles(prev => ({
|
||||
...prev,
|
||||
[bubbleKey]: true
|
||||
}));
|
||||
};
|
||||
|
||||
// Replay state (must be defined before using in useMemo)
|
||||
const [isReplaying, setIsReplaying] = useState(false);
|
||||
const [replayBubbles, setReplayBubbles] = useState({});
|
||||
const [modeTransition, setModeTransition] = useState(null); // 'entering-replay' | 'exiting-replay' | null
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const replayTimerRef = useRef(null);
|
||||
const replayTimeoutsRef = useRef([]);
|
||||
const replayStateRef = useRef({ messages: [], currentIndex: 0 });
|
||||
|
||||
// Background image
|
||||
const roomBgSrc = ASSETS.roomBg;
|
||||
|
||||
const bgImg = useImage(roomBgSrc);
|
||||
|
||||
// Calculate scale to fit canvas in container (80% of available space)
|
||||
const [scale, setScale] = useState(0.8);
|
||||
|
||||
useEffect(() => {
|
||||
const updateScale = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { clientWidth, clientHeight } = container;
|
||||
if (clientWidth <= 0 || clientHeight <= 0) return;
|
||||
|
||||
const scaleX = clientWidth / SCENE_NATIVE.width;
|
||||
const scaleY = clientHeight / SCENE_NATIVE.height;
|
||||
const newScale = Math.min(scaleX, scaleY, 1.0) * 0.8; // Scale to 80% of original size
|
||||
setScale(Math.max(0.3, newScale));
|
||||
};
|
||||
|
||||
updateScale();
|
||||
const resizeObserver = new ResizeObserver(updateScale);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
window.addEventListener('resize', updateScale);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener('resize', updateScale);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Set canvas size
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.width = SCENE_NATIVE.width;
|
||||
canvas.height = SCENE_NATIVE.height;
|
||||
|
||||
const displayWidth = Math.round(SCENE_NATIVE.width * scale);
|
||||
const displayHeight = Math.round(SCENE_NATIVE.height * scale);
|
||||
canvas.style.width = `${displayWidth}px`;
|
||||
canvas.style.height = `${displayHeight}px`;
|
||||
}, [scale]);
|
||||
|
||||
// Draw room background
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// Clear canvas first
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw image if loaded
|
||||
if (bgImg) {
|
||||
ctx.drawImage(bgImg, 0, 0, SCENE_NATIVE.width, SCENE_NATIVE.height);
|
||||
}
|
||||
}, [bgImg, scale, roomBgSrc]);
|
||||
|
||||
// Determine which agents are speaking
|
||||
const speakingAgents = useMemo(() => {
|
||||
const speaking = {};
|
||||
AGENTS.forEach(agent => {
|
||||
const bubble = bubbleFor(agent.name);
|
||||
speaking[agent.id] = !!bubble;
|
||||
});
|
||||
return speaking;
|
||||
}, [bubbles, bubbleFor]);
|
||||
|
||||
// Find agent data from leaderboard
|
||||
const getAgentData = (agentId) => {
|
||||
const agent = AGENTS.find(a => a.id === agentId);
|
||||
if (!agent) return null;
|
||||
const profile = agentProfilesByAgent?.[agentId] || null;
|
||||
|
||||
// If no leaderboard data, return agent with default stats
|
||||
if (!leaderboard || !Array.isArray(leaderboard)) {
|
||||
return {
|
||||
...agent,
|
||||
modelName: profile?.model_name || null,
|
||||
modelProvider: profile?.model_provider || null,
|
||||
bull: { n: 0, win: 0, unknown: 0 },
|
||||
bear: { n: 0, win: 0, unknown: 0 },
|
||||
winRate: null,
|
||||
signals: [],
|
||||
rank: null
|
||||
};
|
||||
}
|
||||
|
||||
const leaderboardData = leaderboard.find(lb => lb.agentId === agentId);
|
||||
|
||||
// If agent not in leaderboard, return agent with default stats
|
||||
if (!leaderboardData) {
|
||||
return {
|
||||
...agent,
|
||||
modelName: profile?.model_name || null,
|
||||
modelProvider: profile?.model_provider || null,
|
||||
bull: { n: 0, win: 0, unknown: 0 },
|
||||
bear: { n: 0, win: 0, unknown: 0 },
|
||||
winRate: null,
|
||||
signals: [],
|
||||
rank: null
|
||||
};
|
||||
}
|
||||
|
||||
// Merge data but preserve the correct avatar from AGENTS config
|
||||
return {
|
||||
...agent,
|
||||
...leaderboardData,
|
||||
modelName: profile?.model_name || leaderboardData.modelName || null,
|
||||
modelProvider: profile?.model_provider || leaderboardData.modelProvider || null,
|
||||
avatar: agent.avatar // Always use the frontend's avatar URL
|
||||
};
|
||||
};
|
||||
|
||||
// Get agent rank for display
|
||||
const getAgentRank = (agentId) => {
|
||||
const agentData = getAgentData(agentId);
|
||||
return agentData?.rank || null;
|
||||
};
|
||||
|
||||
// Handle agent click
|
||||
const handleAgentClick = (agentId) => {
|
||||
// Cancel any closing animation
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsClosing(false);
|
||||
|
||||
const agentData = getAgentData(agentId);
|
||||
if (agentData) {
|
||||
setSelectedAgent(agentData);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle agent hover
|
||||
const handleAgentMouseEnter = (agentId) => {
|
||||
setHoveredAgent(agentId);
|
||||
// Clear any existing timer
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
// Cancel any closing animation
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsClosing(false);
|
||||
|
||||
// If there's already a selected agent, switch immediately
|
||||
// Otherwise, show after a short delay (0ms = immediate)
|
||||
const agentData = getAgentData(agentId);
|
||||
if (agentData) {
|
||||
if (selectedAgent) {
|
||||
// Already have a card open, switch immediately
|
||||
setSelectedAgent(agentData);
|
||||
} else {
|
||||
// No card open, show after delay (currently 0ms = immediate)
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
setSelectedAgent(agentData);
|
||||
hoverTimerRef.current = null;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentMouseLeave = () => {
|
||||
setHoveredAgent(null);
|
||||
// Clear timer if mouse leaves before 1.5 seconds
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle closing with animation
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
// Wait for animation to complete before removing
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setSelectedAgent(null);
|
||||
setIsClosing(false);
|
||||
closeTimerRef.current = null;
|
||||
}, 200); // Match the slideUp animation duration
|
||||
};
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
}
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
// Clean up replay timers
|
||||
if (replayTimerRef.current) {
|
||||
clearTimeout(replayTimerRef.current);
|
||||
}
|
||||
replayTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
replayTimeoutsRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Show replay button when not in replay mode and has feed history
|
||||
const showReplayButton = !isReplaying && feed && feed.length > 0;
|
||||
|
||||
// Start replay with feed data
|
||||
const handleReplayClick = useCallback(() => {
|
||||
if (!feed || feed.length === 0) {
|
||||
return;
|
||||
}
|
||||
startReplay(feed);
|
||||
}, [feed]);
|
||||
|
||||
// Extract agent messages from feed items
|
||||
const extractAgentMessages = useCallback((feedItems) => {
|
||||
const messages = [];
|
||||
|
||||
feedItems.forEach((item, itemIndex) => {
|
||||
if (item.type === 'message' && item.data) {
|
||||
const msg = item.data;
|
||||
// Skip system messages
|
||||
if (msg.agent === 'System') return;
|
||||
// Find matching agent
|
||||
const agent = AGENTS.find(a =>
|
||||
a.id === msg.agentId ||
|
||||
a.name === msg.agent
|
||||
);
|
||||
if (agent) {
|
||||
messages.push({
|
||||
feedItemId: item.id,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp
|
||||
});
|
||||
}
|
||||
} else if (item.type === 'conference' && item.data?.messages) {
|
||||
item.data.messages.forEach((msg, msgIndex) => {
|
||||
if (msg.agent === 'System') return;
|
||||
const agent = AGENTS.find(a =>
|
||||
a.id === msg.agentId ||
|
||||
a.name === msg.agent
|
||||
);
|
||||
if (agent) {
|
||||
messages.push({
|
||||
feedItemId: item.id,
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return messages;
|
||||
}, []);
|
||||
|
||||
// Show next message in replay
|
||||
const showNextMessage = useCallback(() => {
|
||||
const { messages, currentIndex } = replayStateRef.current;
|
||||
if (currentIndex >= messages.length) {
|
||||
// End replay
|
||||
setModeTransition('exiting-replay');
|
||||
setTimeout(() => {
|
||||
setModeTransition(null);
|
||||
setIsReplaying(false);
|
||||
setIsPaused(false);
|
||||
setReplayBubbles({});
|
||||
replayStateRef.current = { messages: [], currentIndex: 0 };
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = messages[currentIndex];
|
||||
const bubbleId = `replay_${msg.agentId}_${currentIndex}`;
|
||||
|
||||
setReplayBubbles(prev => ({
|
||||
...prev,
|
||||
[bubbleId]: {
|
||||
id: bubbleId,
|
||||
feedItemId: msg.feedItemId,
|
||||
agentId: msg.agentId,
|
||||
agentName: msg.agentName,
|
||||
text: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
ts: msg.timestamp
|
||||
}
|
||||
}));
|
||||
|
||||
// Remove bubble after 10 seconds (previously 5s) to keep replay text visible longer
|
||||
const hideTimeout = setTimeout(() => {
|
||||
setReplayBubbles(prev => {
|
||||
const newBubbles = { ...prev };
|
||||
delete newBubbles[bubbleId];
|
||||
return newBubbles;
|
||||
});
|
||||
}, 10000);
|
||||
replayTimeoutsRef.current.push(hideTimeout);
|
||||
|
||||
// Schedule next message
|
||||
replayStateRef.current.currentIndex = currentIndex + 1;
|
||||
// Wait longer before next bubble to match extended visibility (was 3s)
|
||||
const nextTimeout = setTimeout(() => {
|
||||
showNextMessage();
|
||||
}, 6000);
|
||||
replayTimerRef.current = nextTimeout;
|
||||
replayTimeoutsRef.current.push(nextTimeout);
|
||||
}, []);
|
||||
|
||||
// Start replay with feed data
|
||||
const startReplay = useCallback((feedItems) => {
|
||||
if (!feedItems || feedItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentMessages = extractAgentMessages(feedItems).reverse();
|
||||
if (agentMessages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store messages for pause/resume
|
||||
replayStateRef.current = { messages: agentMessages, currentIndex: 0 };
|
||||
|
||||
// Start transition animation
|
||||
setModeTransition('entering-replay');
|
||||
setIsReplaying(true);
|
||||
setIsPaused(false);
|
||||
setReplayBubbles({});
|
||||
|
||||
// Clear any existing timeouts
|
||||
replayTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
replayTimeoutsRef.current = [];
|
||||
|
||||
// Clear transition and start replay after animation completes
|
||||
setTimeout(() => {
|
||||
setModeTransition(null);
|
||||
showNextMessage();
|
||||
}, 500);
|
||||
}, [extractAgentMessages, showNextMessage]);
|
||||
|
||||
// Pause replay
|
||||
const pauseReplay = useCallback(() => {
|
||||
if (replayTimerRef.current) {
|
||||
clearTimeout(replayTimerRef.current);
|
||||
replayTimerRef.current = null;
|
||||
}
|
||||
setIsPaused(true);
|
||||
}, []);
|
||||
|
||||
// Resume replay
|
||||
const resumeReplay = useCallback(() => {
|
||||
setIsPaused(false);
|
||||
showNextMessage();
|
||||
}, [showNextMessage]);
|
||||
|
||||
// Stop replay
|
||||
const stopReplay = useCallback(() => {
|
||||
// Clear all timeouts
|
||||
replayTimeoutsRef.current.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
replayTimeoutsRef.current = [];
|
||||
|
||||
if (replayTimerRef.current) {
|
||||
clearTimeout(replayTimerRef.current);
|
||||
replayTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Transition out of replay mode
|
||||
setModeTransition('exiting-replay');
|
||||
// Clear transition and replay state after animation completes
|
||||
setTimeout(() => {
|
||||
setModeTransition(null);
|
||||
setIsReplaying(false);
|
||||
setIsPaused(false);
|
||||
setReplayBubbles({});
|
||||
replayStateRef.current = { messages: [], currentIndex: 0 };
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Get bubble for specific agent (supports both live and replay mode)
|
||||
const getBubbleForAgent = useCallback((agentName) => {
|
||||
if (isReplaying) {
|
||||
// Find replay bubble for this agent
|
||||
const bubble = Object.values(replayBubbles).find(b => {
|
||||
const agent = AGENTS.find(a => a.id === b.agentId);
|
||||
return agent && agent.name === agentName;
|
||||
});
|
||||
return bubble || null;
|
||||
} else {
|
||||
// Use normal bubbleFor function
|
||||
return bubbleFor(agentName);
|
||||
}
|
||||
}, [isReplaying, replayBubbles, bubbleFor]);
|
||||
|
||||
return (
|
||||
<div className="room-view">
|
||||
{/* Agents Indicator Bar */}
|
||||
<div className="room-agents-indicator">
|
||||
{AGENTS.map((agent, index) => {
|
||||
const rank = getAgentRank(agent.id);
|
||||
const medal = rank ? getRankMedal(rank) : null;
|
||||
const agentData = getAgentData(agent.id);
|
||||
const modelInfo = getModelIcon(agentData?.modelName, agentData?.modelProvider);
|
||||
|
||||
return (
|
||||
<React.Fragment key={agent.id}>
|
||||
<div
|
||||
className={`agent-indicator ${speakingAgents[agent.id] ? 'speaking' : ''} ${hoveredAgent === agent.id ? 'hovered' : ''}`}
|
||||
onClick={() => handleAgentClick(agent.id)}
|
||||
onMouseEnter={() => handleAgentMouseEnter(agent.id)}
|
||||
onMouseLeave={handleAgentMouseLeave}
|
||||
>
|
||||
<div className="agent-avatar-wrapper">
|
||||
<img
|
||||
src={agent.avatar}
|
||||
alt={agent.name}
|
||||
className="agent-avatar"
|
||||
/>
|
||||
<span className="agent-indicator-dot"></span>
|
||||
{medal && (
|
||||
<span className="agent-rank-medal">
|
||||
{medal}
|
||||
</span>
|
||||
)}
|
||||
{(agentData?.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentData?.modelName}
|
||||
provider={agentData?.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={25}
|
||||
shape="circle"
|
||||
type="color"
|
||||
className="agent-model-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -12,
|
||||
right: -12,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #ffffff',
|
||||
background: '#ffffff',
|
||||
padding: 2,
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
</div>
|
||||
{/* Divider after Risk Manager (index 1) */}
|
||||
{index === 1 && (
|
||||
<div style={{
|
||||
width: 2,
|
||||
height: 60,
|
||||
background: 'linear-gradient(to bottom, transparent, #333333, transparent)',
|
||||
margin: '0 12px',
|
||||
alignSelf: 'center'
|
||||
}} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Hint Text */}
|
||||
<div className="agent-hint-text">
|
||||
点击头像查看详情
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room Canvas */}
|
||||
<div className="room-canvas-container" ref={containerRef}>
|
||||
<div className="room-scene">
|
||||
<div className="room-scene-wrapper" style={{ width: Math.round(SCENE_NATIVE.width * scale), height: Math.round(SCENE_NATIVE.height * scale) }}>
|
||||
<canvas ref={canvasRef} className="room-canvas" />
|
||||
|
||||
{/* Speech Bubbles */}
|
||||
{AGENTS.map((agent, idx) => {
|
||||
const bubble = getBubbleForAgent(agent.name);
|
||||
if (!bubble) return null;
|
||||
|
||||
const bubbleKey = `${agent.id}_${bubble.timestamp || bubble.id || bubble.ts}`;
|
||||
|
||||
// Check if bubble is hidden
|
||||
if (hiddenBubbles[bubbleKey]) return null;
|
||||
|
||||
const pos = AGENT_SEATS[idx];
|
||||
const scaledWidth = SCENE_NATIVE.width * scale;
|
||||
const scaledHeight = SCENE_NATIVE.height * scale;
|
||||
|
||||
// Bubble left-bottom corner aligns to agent position
|
||||
const left = Math.round(pos.x * scaledWidth);
|
||||
const bottom = Math.round(pos.y * scaledHeight);
|
||||
|
||||
// Get agent data for model info
|
||||
const agentData = getAgentData(agent.id);
|
||||
const modelInfo = getModelIcon(agentData?.modelName, agentData?.modelProvider);
|
||||
|
||||
// Truncate long text - 200 collapsed, 500 expanded max
|
||||
const maxLength = 200;
|
||||
const maxExpandedLength = 500;
|
||||
const isTruncated = bubble.text.length > maxLength;
|
||||
const isExpanded = expandedBubbles[bubbleKey];
|
||||
const displayText = (!isExpanded && isTruncated)
|
||||
? bubble.text.substring(0, maxLength) + '...'
|
||||
: (isExpanded && bubble.text.length > maxExpandedLength)
|
||||
? bubble.text.substring(0, maxExpandedLength) + '...'
|
||||
: bubble.text;
|
||||
|
||||
const toggleExpand = (e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedBubbles(prev => ({
|
||||
...prev,
|
||||
[bubbleKey]: !prev[bubbleKey]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleJumpToFeed = (e) => {
|
||||
e.stopPropagation();
|
||||
if (onJumpToMessage) {
|
||||
onJumpToMessage(bubble);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="room-bubble"
|
||||
style={{ left, bottom }}
|
||||
>
|
||||
{/* Action buttons */}
|
||||
<div className="bubble-action-buttons">
|
||||
<button
|
||||
className="bubble-jump-btn"
|
||||
onClick={handleJumpToFeed}
|
||||
title="跳转到消息"
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
<button
|
||||
className="bubble-close-btn"
|
||||
onClick={(e) => handleCloseBubble(agent.id, bubbleKey, e)}
|
||||
title="关闭"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Agent header with model icon */}
|
||||
<div className="room-bubble-header">
|
||||
{(agentData?.modelName || modelInfo.logoPath) && (
|
||||
<LobeModelLogo
|
||||
model={agentData?.modelName}
|
||||
provider={agentData?.modelProvider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={18}
|
||||
shape="circle"
|
||||
type="color"
|
||||
className="bubble-model-icon"
|
||||
/>
|
||||
)}
|
||||
<div className="room-bubble-name">{bubble.agentName || agent.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="room-bubble-divider"></div>
|
||||
|
||||
{/* Message content */}
|
||||
<div className="room-bubble-content">
|
||||
{displayText}
|
||||
{isTruncated && (
|
||||
<button
|
||||
className="bubble-expand-btn"
|
||||
onClick={toggleExpand}
|
||||
>
|
||||
{isExpanded ? ' ↑' : ' ↓'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Card - Dropdown style below indicator bar */}
|
||||
{selectedAgent && (
|
||||
<>
|
||||
{/* Transparent overlay to close card */}
|
||||
<div
|
||||
className="agent-card-overlay"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Agent Card */}
|
||||
<AgentCard
|
||||
agent={selectedAgent}
|
||||
isClosing={isClosing}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mode Transition Overlay - sweeps in the dark gradient */}
|
||||
{modeTransition === 'entering-replay' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0.3) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 40,
|
||||
clipPath: 'inset(0 100% 0 0)',
|
||||
animation: 'clipReveal 0.5s ease-out forwards'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mode Transition Overlay - sweeps out the dark gradient */}
|
||||
{modeTransition === 'exiting-replay' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0.3) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 40,
|
||||
clipPath: 'inset(0 0 0 0)',
|
||||
animation: 'clipHide 0.5s ease-out forwards'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Room Controls */}
|
||||
{(showReplayButton || onOpenLaunchConfig) && (
|
||||
<div className="replay-button-container">
|
||||
{onOpenLaunchConfig && (
|
||||
<button
|
||||
className="replay-button"
|
||||
onClick={onOpenLaunchConfig}
|
||||
title="打开启动配置"
|
||||
style={{ background: '#FFFFFF', color: '#000000' }}
|
||||
>
|
||||
<span>启动</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="replay-button"
|
||||
onClick={handleReplayClick}
|
||||
title="Replay feed history"
|
||||
disabled={!showReplayButton}
|
||||
>
|
||||
<span className="replay-icon">▶▶</span>
|
||||
<span>回放</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Replay Mode Background + Indicator */}
|
||||
{isReplaying && !modeTransition && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0.3) 100%)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 40
|
||||
}}
|
||||
/>
|
||||
<div className="replay-indicator">
|
||||
<span className="replay-status">{isPaused ? '已暂停' : '回放模式'}</span>
|
||||
<button
|
||||
className="replay-button"
|
||||
onClick={isPaused ? resumeReplay : pauseReplay}
|
||||
style={{ padding: '6px 12px' }}
|
||||
>
|
||||
<span>{isPaused ? '▶' : '⏸'}</span>
|
||||
</button>
|
||||
<button className="replay-button" onClick={stopReplay} style={{ padding: '6px 12px' }}>
|
||||
<span>■</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
frontend/src/components/RuntimeLogsModal.jsx
Normal file
190
frontend/src/components/RuntimeLogsModal.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export default function RuntimeLogsModal({
|
||||
isOpen,
|
||||
isLoading,
|
||||
logPayload,
|
||||
error,
|
||||
onClose,
|
||||
onRefresh
|
||||
}) {
|
||||
const logRef = useRef(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [followTail, setFollowTail] = useState(true);
|
||||
|
||||
const refreshIntervalMs = useMemo(() => 2000, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !autoRefresh) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
onRefresh();
|
||||
}, refreshIntervalMs);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [autoRefresh, isOpen, onRefresh, refreshIntervalMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !followTail || !logRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}, [followTail, isOpen, logPayload?.content]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.32)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 10000
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(980px, 94vw)',
|
||||
maxHeight: '82vh',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto minmax(0, 1fr)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '18px 20px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>运行日志</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
{logPayload?.run_id ? `任务 ${logPayload.run_id}` : '当前运行任务'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #111111',
|
||||
background: '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '0 20px 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||
{logPayload?.log_path || '未找到日志文件'}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#2563EB', fontWeight: 700 }}>加载中...</div>
|
||||
) : error ? (
|
||||
<div style={{ fontSize: 11, color: '#B91C1C', fontWeight: 700 }}>{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '0 20px 12px',
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(event) => setAutoRefresh(event.target.checked)}
|
||||
/>
|
||||
实时刷新
|
||||
</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#374151', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={followTail}
|
||||
onChange={(event) => setFollowTail(event.target.checked)}
|
||||
/>
|
||||
自动滚底
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 20px 20px', minHeight: 0 }}>
|
||||
<pre
|
||||
ref={logRef}
|
||||
style={{
|
||||
margin: 0,
|
||||
height: '100%',
|
||||
minHeight: 320,
|
||||
maxHeight: 'calc(82vh - 140px)',
|
||||
overflow: 'auto',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#0F172A',
|
||||
color: '#E2E8F0',
|
||||
padding: 16,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: '"SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
>
|
||||
{logPayload?.content || '暂无日志输出'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
645
frontend/src/components/RuntimeSettingsPanel.jsx
Normal file
645
frontend/src/components/RuntimeSettingsPanel.jsx
Normal file
@@ -0,0 +1,645 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const formatHistorySummary = (run) => {
|
||||
const updatedAt = run?.updated_at ? String(run.updated_at).replace("T", " ").slice(0, 16) : "未知时间";
|
||||
const mode = run?.bootstrap?.mode ? String(run.bootstrap.mode).toUpperCase() : "LIVE";
|
||||
const tickers = Array.isArray(run?.bootstrap?.tickers) ? run.bootstrap.tickers.length : 0;
|
||||
const assetValue = Number(run?.total_asset_value ?? 0).toFixed(2);
|
||||
const trades = Number(run?.total_trades ?? 0);
|
||||
return `${run.run_id} · ${updatedAt} · ${mode} · ${tickers}标的 · ${trades}笔交易 · $${assetValue}`;
|
||||
};
|
||||
|
||||
export default function RuntimeSettingsPanel({
|
||||
showTrigger = true,
|
||||
isOpen,
|
||||
isConnected,
|
||||
isSaving,
|
||||
feedback,
|
||||
launchMode,
|
||||
restoreRunId,
|
||||
runtimeHistoryRuns,
|
||||
scheduleMode,
|
||||
intervalMinutes,
|
||||
triggerTime,
|
||||
maxCommCycles,
|
||||
initialCash,
|
||||
marginRequirement,
|
||||
enableMemory,
|
||||
mode,
|
||||
pollInterval,
|
||||
startDate,
|
||||
endDate,
|
||||
watchlistSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
onToggle,
|
||||
onClose,
|
||||
onScheduleModeChange,
|
||||
onLaunchModeChange,
|
||||
onRestoreRunIdChange,
|
||||
onIntervalMinutesChange,
|
||||
onTriggerTimeChange,
|
||||
onMaxCommCyclesChange,
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onModeChange,
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
onWatchlistRemove,
|
||||
onWatchlistRestoreCurrent,
|
||||
onWatchlistRestoreDefault,
|
||||
onWatchlistSuggestionClick,
|
||||
onSave,
|
||||
onRestoreDefaults
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{showTrigger && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #333333',
|
||||
background: isOpen ? '#1E1E1E' : '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.6px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
启动配置
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && createPortal((
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.28)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 9998
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(760px, 92vw)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||
padding: 18,
|
||||
paddingTop: 22,
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
borderRadius: 999,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
color: '#111111',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
|
||||
}}
|
||||
aria-label="关闭启动配置"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>启动配置</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
配置本次任务的启动参数与调度方式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>启动形式</div>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>任务模式</span>
|
||||
<select
|
||||
value={launchMode}
|
||||
onChange={(e) => onLaunchModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="fresh">重新启动</option>
|
||||
<option value="restore">从历史任务恢复</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{launchMode === 'restore' && (
|
||||
<>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>历史任务</span>
|
||||
<select
|
||||
value={restoreRunId}
|
||||
onChange={(e) => onRestoreRunIdChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="">请选择历史任务</option>
|
||||
{runtimeHistoryRuns.map((run) => (
|
||||
<option key={run.run_id} value={run.run_id}>
|
||||
{formatHistorySummary(run)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#6B7280',
|
||||
lineHeight: 1.6,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: '#FFFFFF',
|
||||
border: '1px dashed #D0D7DE'
|
||||
}}>
|
||||
恢复启动会从所选历史任务复制运行状态、组合、交易记录和 Agent 工作区资产,并以新的任务 ID 继续运行。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{launchMode === 'fresh' && (
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>自选股</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
minHeight: 40,
|
||||
padding: '2px 0'
|
||||
}}>
|
||||
{watchlistSymbols.map((symbol) => (
|
||||
<button
|
||||
key={symbol}
|
||||
onClick={() => onWatchlistRemove(symbol)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span>{symbol}</span>
|
||||
<span style={{ color: '#777777' }}>×</span>
|
||||
</button>
|
||||
))}
|
||||
{watchlistSymbols.length === 0 && (
|
||||
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
|
||||
还没有股票,输入代码后回车添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={watchlistInputValue}
|
||||
onChange={(e) => onWatchlistInputChange(e.target.value)}
|
||||
onKeyDown={onWatchlistInputKeyDown}
|
||||
placeholder="输入股票代码,回车添加"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onWatchlistAdd}
|
||||
style={{
|
||||
padding: '9px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{watchlistSuggestions.map((symbol) => {
|
||||
const active = watchlistSymbols.includes(symbol);
|
||||
return (
|
||||
<button
|
||||
key={symbol}
|
||||
onClick={() => onWatchlistSuggestionClick(symbol)}
|
||||
disabled={active}
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid',
|
||||
borderColor: active ? '#B6E3C5' : '#D0D7DE',
|
||||
background: active ? '#ECFDF3' : '#FFFFFF',
|
||||
color: active ? '#157347' : '#4A5568',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
cursor: active ? 'default' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onWatchlistRestoreCurrent}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复当前
|
||||
</button>
|
||||
<button
|
||||
onClick={onWatchlistRestoreDefault}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{launchMode === 'fresh' && (
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>调度参数</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>调度模式</span>
|
||||
<select
|
||||
value={scheduleMode}
|
||||
onChange={(e) => onScheduleModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="daily">每日定时</option>
|
||||
<option value="intraday">盘中轮询</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>间隔(分钟)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={intervalMinutes}
|
||||
onChange={(e) => onIntervalMinutesChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>每日定时时间 (NYSE)</span>
|
||||
<input
|
||||
type="time"
|
||||
value={triggerTime}
|
||||
onChange={(e) => onTriggerTimeChange(e.target.value)}
|
||||
disabled={scheduleMode !== 'daily'}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: scheduleMode === 'daily' ? '#FFFFFF' : '#F3F4F6',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>讨论轮数上限</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={maxCommCycles}
|
||||
onChange={(e) => onMaxCommCyclesChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>初始资金</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1000"
|
||||
value={initialCash}
|
||||
onChange={(e) => onInitialCashChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>保证金要求</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={marginRequirement}
|
||||
onChange={(e) => onMarginRequirementChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableMemory}
|
||||
onChange={(e) => onEnableMemoryChange(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
accentColor: '#0D47A1',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用长期记忆</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>运行模式</span>
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(e) => onModeChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
>
|
||||
<option value="live">实盘模式 (Live)</option>
|
||||
<option value="backtest">回测模式 (Backtest)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{mode === 'backtest' && (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>回测开始日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>回测结束日期</span>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: '10px', color: '#4B5563', fontWeight: 700 }}>轮询间隔(秒)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={pollInterval}
|
||||
onChange={(e) => onPollIntervalChange(e.target.value)}
|
||||
style={{
|
||||
padding: '9px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>操作</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onRestoreDefaults}
|
||||
style={{
|
||||
padding: '9px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isConnected || isSaving}
|
||||
style={{
|
||||
padding: '9px 14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.4px',
|
||||
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
{isSaving ? '启动中' : '启动任务'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<span style={{
|
||||
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{feedback.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
806
frontend/src/components/RuntimeView.jsx
Normal file
806
frontend/src/components/RuntimeView.jsx
Normal file
@@ -0,0 +1,806 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
approvePendingApproval,
|
||||
denyPendingApproval,
|
||||
loadAllRuntimeState
|
||||
} from '../services/runtimeApi';
|
||||
|
||||
const AUTO_REFRESH_MS = 5000;
|
||||
|
||||
const STATUS_LABELS = {
|
||||
idle: '空闲',
|
||||
registered: '已注册',
|
||||
initializing: '初始化中',
|
||||
ready: '就绪',
|
||||
running: '运行中',
|
||||
analysis_in_progress: '分析中',
|
||||
risk_review_in_progress: '风控处理中',
|
||||
discussion_in_progress: '会商中',
|
||||
decision_in_progress: '决策中',
|
||||
execution_in_progress: '执行中',
|
||||
settlement_in_progress: '结算中',
|
||||
reflection_in_progress: '复盘中',
|
||||
waiting_approval: '等待审批',
|
||||
approved: '已批准',
|
||||
denied: '已拒绝',
|
||||
completed: '已完成',
|
||||
error: '异常',
|
||||
stopped: '已停止'
|
||||
};
|
||||
|
||||
const EVENT_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: '全部事件' },
|
||||
{ value: 'cycle', label: '运行周期' },
|
||||
{ value: 'approval', label: '审批事件' }
|
||||
];
|
||||
|
||||
const SR_ONLY_STYLE = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
function metricCard(label, value, accent, helper = null) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">
|
||||
{label}
|
||||
</div>
|
||||
<div className="stat-card-value" style={{ color: accent }}>
|
||||
{value}
|
||||
</div>
|
||||
{helper && (
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666666', lineHeight: 1.5 }}>
|
||||
{helper}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveApprovalTone(approval) {
|
||||
const findings = Array.isArray(approval.findings) ? approval.findings : [];
|
||||
const levels = findings.map((item) => item?.severity).filter(Boolean);
|
||||
if (levels.includes('critical')) {
|
||||
return { border: '#7F1D1D', bg: '#FEF2F2', text: '#991B1B', badgeBg: '#FECACA' };
|
||||
}
|
||||
if (levels.includes('high')) {
|
||||
return { border: '#9A3412', bg: '#FFF7ED', text: '#C2410C', badgeBg: '#FED7AA' };
|
||||
}
|
||||
if (levels.includes('medium')) {
|
||||
return { border: '#92400E', bg: '#FFFBEB', text: '#B45309', badgeBg: '#FDE68A' };
|
||||
}
|
||||
return { border: '#D1D5DB', bg: '#FCFCFC', text: '#374151', badgeBg: '#E5E7EB' };
|
||||
}
|
||||
|
||||
// 评估指标配置
|
||||
const METRICS_CONFIG = {
|
||||
hit_rate: {
|
||||
label: '命中率',
|
||||
icon: '◎',
|
||||
goodThreshold: 0.7,
|
||||
warnThreshold: 0.5
|
||||
},
|
||||
risk_violation: {
|
||||
label: '风控违例',
|
||||
icon: '⚠',
|
||||
goodThreshold: 0.1,
|
||||
warnThreshold: 0.3,
|
||||
inverted: true // 值越小越好
|
||||
},
|
||||
decision_latency: {
|
||||
label: '决策延迟',
|
||||
icon: '◷',
|
||||
goodThreshold: 5000,
|
||||
warnThreshold: 10000,
|
||||
inverted: true,
|
||||
unit: 'ms'
|
||||
},
|
||||
signal_consistency: {
|
||||
label: '信号一致性',
|
||||
icon: '≡',
|
||||
goodThreshold: 0.8,
|
||||
warnThreshold: 0.6
|
||||
}
|
||||
};
|
||||
|
||||
function getMetricColor(value, config) {
|
||||
if (value === null || value === undefined || isNaN(value)) {
|
||||
return { color: '#9CA3AF', bg: '#F9FAFB', arrow: '-' };
|
||||
}
|
||||
const isInverted = config.inverted;
|
||||
const effectiveValue = isInverted ? value : value;
|
||||
const effectiveGood = isInverted ? config.goodThreshold : config.goodThreshold;
|
||||
const effectiveWarn = isInverted ? config.warnThreshold : config.warnThreshold;
|
||||
|
||||
if (effectiveValue <= effectiveGood) {
|
||||
return { color: '#059669', bg: '#ECFDF5', arrow: '↑' };
|
||||
} else if (effectiveValue <= effectiveWarn) {
|
||||
return { color: '#D97706', bg: '#FFFBEB', arrow: '→' };
|
||||
} else {
|
||||
return { color: '#DC2626', bg: '#FEF2F2', arrow: '↓' };
|
||||
}
|
||||
}
|
||||
|
||||
function MetricBadge({ metricKey, value }) {
|
||||
const config = METRICS_CONFIG[metricKey];
|
||||
if (!config) return null;
|
||||
|
||||
const displayValue = value !== null && value !== undefined && !isNaN(value)
|
||||
? (config.unit === 'ms' ? `${Math.round(value)}${config.unit}` : `${(value * 100).toFixed(1)}%`)
|
||||
: '-';
|
||||
const { color, bg, arrow } = getMetricColor(value, config);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 6px',
|
||||
background: bg,
|
||||
border: `1px solid ${color}`,
|
||||
borderRadius: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
color: color
|
||||
}}>
|
||||
<span>{config.icon}</span>
|
||||
<span>{displayValue}</span>
|
||||
<span style={{ marginLeft: 2 }}>{arrow}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentMetricsPanel({ agent }) {
|
||||
const extensions = agent.extensions || {};
|
||||
const metrics = [
|
||||
{ key: 'hit_rate', value: extensions.hit_rate },
|
||||
{ key: 'risk_violation', value: extensions.risk_violation },
|
||||
{ key: 'decision_latency', value: extensions.decision_latency },
|
||||
{ key: 'signal_consistency', value: extensions.signal_consistency }
|
||||
];
|
||||
|
||||
const hasMetrics = metrics.some(m => m.value !== null && m.value !== undefined && !isNaN(m.value));
|
||||
if (!hasMetrics) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 6,
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px dashed #E5E7EB'
|
||||
}}>
|
||||
{metrics.map(({ key, value }) => (
|
||||
<MetricBadge key={key} metricKey={key} value={value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sectionTitle(label, action = null) {
|
||||
return (
|
||||
<div className="section-header" style={{ marginBottom: 0 }}>
|
||||
<div className="section-title" style={{ fontSize: 14 }}>
|
||||
{label}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatStatusLabel(status) {
|
||||
if (!status) {
|
||||
return '-';
|
||||
}
|
||||
return STATUS_LABELS[status] || status.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function formatSessionLabel(sessionId) {
|
||||
return sessionId || '无会话';
|
||||
}
|
||||
|
||||
function formatEventLabel(eventName) {
|
||||
if (!eventName) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const [group, action] = String(eventName).split(':');
|
||||
if (group === 'cycle') {
|
||||
if (action === 'start') return '周期开始';
|
||||
if (action === 'complete') return '周期完成';
|
||||
if (action === 'error') return '周期异常';
|
||||
return '运行周期';
|
||||
}
|
||||
if (group === 'approval') {
|
||||
if (action === 'created') return '创建审批';
|
||||
if (action === 'approved') return '审批通过';
|
||||
if (action === 'denied') return '审批拒绝';
|
||||
if (action === 'expired') return '审批超时';
|
||||
return '审批事件';
|
||||
}
|
||||
if (group === 'agent') {
|
||||
if (action === 'status') return '状态更新';
|
||||
if (action === 'registered') return '注册 Agent';
|
||||
return 'Agent 事件';
|
||||
}
|
||||
|
||||
return String(eventName).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export default function RuntimeView() {
|
||||
const [runtimeState, setRuntimeState] = useState(null);
|
||||
const [runtimeError, setRuntimeError] = useState(null);
|
||||
const [isRuntimeLoading, setIsRuntimeLoading] = useState(false);
|
||||
const [approvalActionId, setApprovalActionId] = useState(null);
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
|
||||
const [eventFilter, setEventFilter] = useState('all');
|
||||
|
||||
const refreshRuntimeState = () => {
|
||||
setIsRuntimeLoading(true);
|
||||
loadAllRuntimeState(
|
||||
(state) => {
|
||||
setRuntimeState(state);
|
||||
setRuntimeError(null);
|
||||
setIsRuntimeLoading(false);
|
||||
},
|
||||
(error) => {
|
||||
setRuntimeError(error.message || '无法加载运行状态');
|
||||
setIsRuntimeLoading(false);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshRuntimeState();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefreshEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
refreshRuntimeState();
|
||||
}, AUTO_REFRESH_MS);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [autoRefreshEnabled]);
|
||||
|
||||
const handleApprovalAction = async (approvalId, action) => {
|
||||
setApprovalActionId(approvalId);
|
||||
try {
|
||||
if (action === 'approve') {
|
||||
await approvePendingApproval(approvalId);
|
||||
} else {
|
||||
await denyPendingApproval(approvalId);
|
||||
}
|
||||
refreshRuntimeState();
|
||||
} catch (error) {
|
||||
setRuntimeError(error.message || '审批操作失败');
|
||||
setIsRuntimeLoading(false);
|
||||
} finally {
|
||||
setApprovalActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const agents = runtimeState?.agents || [];
|
||||
const approvals = runtimeState?.approvals || [];
|
||||
const events = runtimeState?.events || [];
|
||||
const activeAgentsCount = agents.filter((agent) => agent.status && agent.status !== 'idle').length;
|
||||
const visibleEvents = events
|
||||
.filter((event) => eventFilter === 'all' || event.event.startsWith(eventFilter))
|
||||
.slice()
|
||||
.reverse();
|
||||
|
||||
return (
|
||||
<div className="performance-page" style={{ height: '100%', minHeight: 0 }}>
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<div className="section-title" style={{ fontSize: 18 }}>
|
||||
运行态控制台
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: '#666666',
|
||||
marginTop: 4,
|
||||
maxWidth: 760,
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
查看当前运行上下文、分析师状态、待审批请求与近期事件。这里是监控面板,不再和运行设置挤在同一个小弹层里。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshRuntimeState}
|
||||
disabled={isRuntimeLoading}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #111111',
|
||||
background: isRuntimeLoading ? '#8A8A8A' : '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.4px',
|
||||
cursor: isRuntimeLoading ? 'not-allowed' : 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{isRuntimeLoading ? '刷新中' : '刷新运行态'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
{metricCard('活跃 Agent', activeAgentsCount, '#2563EB', `共 ${agents.length} 个 agent 已注册`)}
|
||||
{metricCard('待审批', approvals.length, approvals.length > 0 ? '#C2410C' : '#059669', approvals.length > 0 ? '需要人工处理' : '当前无待处理审批')}
|
||||
{metricCard('运行事件', events.length, '#111111', '最近运行阶段和状态变化')}
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">
|
||||
自动刷新
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAutoRefreshEnabled((value) => !value)}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #000000',
|
||||
background: autoRefreshEnabled ? '#000000' : '#FFFFFF',
|
||||
color: autoRefreshEnabled ? '#FFFFFF' : '#000000',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
textTransform: 'uppercase',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{autoRefreshEnabled ? `开启 / ${AUTO_REFRESH_MS / 1000}秒` : '关闭'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runtimeError && (
|
||||
<div className="section" style={{
|
||||
borderColor: '#FF1744',
|
||||
background: '#FFF5F7',
|
||||
color: '#B91C1C',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{runtimeError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 20,
|
||||
alignContent: 'start'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(320px, 0.95fr) minmax(360px, 1.25fr)',
|
||||
gap: 20,
|
||||
alignItems: 'start'
|
||||
}}>
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('运行上下文')}
|
||||
{runtimeState?.context ? (
|
||||
<div style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>配置名</div>
|
||||
<div style={{ fontSize: 18, color: '#111111', fontWeight: 800, marginTop: 3 }}>
|
||||
{runtimeState.context.config_name}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>运行目录</div>
|
||||
<div style={{ fontSize: 11, color: '#111111', lineHeight: 1.5, marginTop: 3, wordBreak: 'break-all' }}>
|
||||
{runtimeState.context.run_dir}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase' }}>启动参数</div>
|
||||
<pre style={{
|
||||
margin: '6px 0 0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.7,
|
||||
color: '#111111',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(runtimeState.context.bootstrap_values || {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无运行上下文</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('团队协作状态')}
|
||||
<div style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 12
|
||||
}}>
|
||||
{/* 自动广播状态 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>📣</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>自动广播</span>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
border: '1px solid #000000',
|
||||
background: (runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast)
|
||||
? '#000000'
|
||||
: '#FFFFFF',
|
||||
color: (runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast)
|
||||
? '#FFFFFF'
|
||||
: '#000000',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
{(runtimeState?.context?.auto_broadcast || runtimeState?.context?.team_config?.auto_broadcast) ? '已启用' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Fan-out Pipeline */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>👥</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>Fan-out Pipeline</span>
|
||||
</div>
|
||||
<span style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
border: '1px solid #2563EB',
|
||||
background: '#EFF6FF',
|
||||
color: '#2563EB',
|
||||
letterSpacing: '0.5px'
|
||||
}}>
|
||||
{runtimeState?.context?.fanout_pipeline?.length || 0} Agents
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 活跃分析师列表 */}
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 14 }}>📈</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#111111' }}>活跃分析师</span>
|
||||
</div>
|
||||
{(() => {
|
||||
const activeAnalysts = (runtimeState?.agents || []).filter(
|
||||
(agent) => agent.status && agent.status !== 'idle' && agent.status !== 'stopped'
|
||||
);
|
||||
if (activeAnalysts.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 10,
|
||||
border: '1px dashed #999999',
|
||||
background: '#FAFAFA',
|
||||
fontSize: 11,
|
||||
color: '#9CA3AF'
|
||||
}}>
|
||||
当前无活跃分析师
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{activeAnalysts.map((agent) => (
|
||||
<span
|
||||
key={agent.agent_id}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
border: '1px solid #059669',
|
||||
background: '#ECFDF5',
|
||||
color: '#059669',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.3px'
|
||||
}}
|
||||
>
|
||||
{agent.agent_id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 团队配置详情 */}
|
||||
{runtimeState?.context?.team_config && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', textTransform: 'uppercase', marginBottom: 6 }}>
|
||||
团队配置
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 8,
|
||||
background: '#FFFFFF',
|
||||
border: '1px solid #E5E7EB',
|
||||
fontSize: 10,
|
||||
lineHeight: 1.5,
|
||||
color: '#374151',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(runtimeState.context.team_config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('待审批请求')}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
maxHeight: 640,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4
|
||||
}}>
|
||||
{approvals.length ? approvals.map((approval) => {
|
||||
const tone = resolveApprovalTone(approval);
|
||||
return (
|
||||
<div
|
||||
key={approval.approval_id}
|
||||
style={{
|
||||
border: `1px solid ${tone.border}`,
|
||||
background: '#FFFFFF',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#111111' }}>
|
||||
{approval.tool_name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.5px',
|
||||
padding: '4px 6px',
|
||||
background: tone.badgeBg,
|
||||
color: tone.text,
|
||||
border: `1px solid ${tone.border}`,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
{formatStatusLabel(approval.status)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
{approval.agent_id} · {approval.workspace_id} · {formatSessionLabel(approval.session_id)}
|
||||
</div>
|
||||
{approval.tool_input && (
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 10,
|
||||
background: '#FAFAFA',
|
||||
border: '1px solid #000000',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
color: '#111111',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(approval.tool_input, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{approval.findings?.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{approval.findings.map((finding, index) => (
|
||||
<span
|
||||
key={`${approval.approval_id}-finding-${index}`}
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
background: '#FFFFFF',
|
||||
border: `1px solid ${tone.border}`,
|
||||
color: tone.text,
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{finding.severity}: {finding.message}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => handleApprovalAction(approval.approval_id, 'deny')}
|
||||
disabled={approvalActionId === approval.approval_id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #000000',
|
||||
background: '#FFFFFF',
|
||||
color: '#000000',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprovalAction(approval.approval_id, 'approve')}
|
||||
disabled={approvalActionId === approval.approval_id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #000000',
|
||||
background: '#000000',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
cursor: approvalActionId === approval.approval_id ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
批准
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}) : (
|
||||
<div style={{
|
||||
border: '1px dashed #999999',
|
||||
padding: 16,
|
||||
fontSize: 12,
|
||||
color: '#666666',
|
||||
background: '#FAFAFA'
|
||||
}}>
|
||||
当前无待审批请求
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(320px, 1fr) minmax(360px, 1fr)',
|
||||
gap: 20,
|
||||
alignItems: 'start'
|
||||
}}>
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle('Agent 状态')}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4
|
||||
}}>
|
||||
{runtimeState?.agents?.length ? runtimeState.agents.map((agent) => (
|
||||
<div
|
||||
key={agent.agent_id}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 10,
|
||||
display: 'grid',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{agent.agent_id}</span>
|
||||
<span style={{ fontSize: 11, color: '#2563EB', fontFamily: '"Courier New", monospace' }}>{formatStatusLabel(agent.status)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
会话: {formatSessionLabel(agent.last_session)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', lineHeight: 1.5 }}>
|
||||
更新时间: {agent.last_updated}
|
||||
</div>
|
||||
<AgentMetricsPanel agent={agent} />
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>暂无 agent 状态</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ display: 'grid', gap: 12, marginBottom: 0 }}>
|
||||
{sectionTitle(
|
||||
'近期事件',
|
||||
<select
|
||||
id="runtime-event-filter"
|
||||
name="runtime_event_filter"
|
||||
aria-label="筛选近期事件"
|
||||
value={eventFilter}
|
||||
onChange={(event) => setEventFilter(event.target.value)}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #000000',
|
||||
background: '#FFFFFF',
|
||||
color: '#000000',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>
|
||||
{EVENT_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<label htmlFor="runtime-event-filter" style={SR_ONLY_STYLE}>
|
||||
筛选近期事件
|
||||
</label>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gap: 8,
|
||||
maxHeight: 420,
|
||||
overflowY: 'auto',
|
||||
paddingRight: 4
|
||||
}}>
|
||||
{visibleEvents.length ? visibleEvents.map((event, index) => (
|
||||
<div
|
||||
key={`${event.timestamp}-${event.event}-${index}`}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#FAFAFA',
|
||||
padding: 10,
|
||||
display: 'grid',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>{formatEventLabel(event.event)}</span>
|
||||
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>{formatSessionLabel(event.session)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280' }}>{event.timestamp}</div>
|
||||
{event.details && Object.keys(event.details).length > 0 && (
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
fontSize: 10,
|
||||
lineHeight: 1.6,
|
||||
color: '#374151',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{JSON.stringify(event.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)) : (
|
||||
<div style={{ fontSize: 12, color: '#9CA3AF' }}>当前筛选条件下暂无运行事件</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
685
frontend/src/components/StatisticsView.jsx
Normal file
685
frontend/src/components/StatisticsView.jsx
Normal file
@@ -0,0 +1,685 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { formatNumber, formatDateTime } from '../utils/formatters';
|
||||
|
||||
/**
|
||||
* Statistics View Component
|
||||
* Displays portfolio overview, holdings, and trade history in a side-by-side layout
|
||||
* Left: Performance Overview (35%) | Right: Holdings + Trades (65%)
|
||||
* No scrolling - content fits within viewport with pagination
|
||||
*/
|
||||
export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) {
|
||||
const [holdingsPage, setHoldingsPage] = useState(1);
|
||||
const [tradesPage, setTradesPage] = useState(1);
|
||||
const holdingsPerPage = 5;
|
||||
const tradesPerPage = 8;
|
||||
|
||||
const effectiveStats = React.useMemo(() => {
|
||||
const base = stats && typeof stats === 'object' ? stats : {};
|
||||
const netValue = Number(portfolioData?.netValue ?? 0);
|
||||
const pnl = Number(portfolioData?.pnl ?? 0);
|
||||
const hasPortfolioValue = Number.isFinite(netValue) && netValue > 0;
|
||||
const hasMeaningfulStats = Number(base?.totalAssetValue ?? 0) > 0;
|
||||
|
||||
if (hasMeaningfulStats || !hasPortfolioValue) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const cashHolding = Array.isArray(holdings)
|
||||
? holdings.find((item) => String(item?.ticker || '').toUpperCase() === 'CASH')
|
||||
: null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
totalAssetValue: netValue,
|
||||
totalReturn: pnl,
|
||||
cashPosition: Number(cashHolding?.marketValue ?? cashHolding?.currentPrice ?? 0),
|
||||
totalTrades: Array.isArray(trades) ? trades.length : 0,
|
||||
};
|
||||
}, [holdings, portfolioData, stats, trades]);
|
||||
|
||||
// Calculate pagination for holdings
|
||||
const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage);
|
||||
const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage;
|
||||
const holdingsEndIndex = holdingsStartIndex + holdingsPerPage;
|
||||
const currentHoldings = holdings.slice(holdingsStartIndex, holdingsEndIndex);
|
||||
|
||||
// Calculate pagination for trades
|
||||
const totalTradesPages = Math.ceil(trades.length / tradesPerPage);
|
||||
const tradesStartIndex = (tradesPage - 1) * tradesPerPage;
|
||||
const tradesEndIndex = tradesStartIndex + tradesPerPage;
|
||||
const currentTrades = trades.slice(tradesStartIndex, tradesEndIndex);
|
||||
|
||||
// Calculate excess return (Evatraders return - benchmark value-weighted return)
|
||||
const calculateExcessReturn = () => {
|
||||
if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get Evatraders return from stats
|
||||
const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage
|
||||
|
||||
// Calculate benchmark return from baseline_vw
|
||||
// baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...]
|
||||
let benchmarkInitialValue, benchmarkCurrentValue;
|
||||
|
||||
if (baseline_vw.length > 0) {
|
||||
const firstPoint = baseline_vw[0];
|
||||
const lastPoint = baseline_vw[baseline_vw.length - 1];
|
||||
|
||||
benchmarkInitialValue = typeof firstPoint === 'object' ? firstPoint.v : firstPoint;
|
||||
benchmarkCurrentValue = typeof lastPoint === 'object' ? lastPoint.v : lastPoint;
|
||||
|
||||
if (benchmarkInitialValue && benchmarkInitialValue > 0 && benchmarkCurrentValue) {
|
||||
const benchmarkReturn = ((benchmarkCurrentValue - benchmarkInitialValue) / benchmarkInitialValue) * 100;
|
||||
const excessReturn = evatradersReturn - benchmarkReturn;
|
||||
return {
|
||||
excessReturn: excessReturn,
|
||||
benchmarkReturn: benchmarkReturn,
|
||||
evatradersReturn: evatradersReturn
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const excessReturnData = calculateExcessReturn();
|
||||
|
||||
// Calculate Portfolio Manager's win rate (similar logic to AgentCard)
|
||||
const calculatePortfolioManagerWinRate = () => {
|
||||
if (!leaderboard || !Array.isArray(leaderboard)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find portfolio_manager in leaderboard
|
||||
const pmData = leaderboard.find(agent => agent.agentId === 'portfolio_manager');
|
||||
|
||||
if (!pmData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract bull and bear data
|
||||
const bullTotal = pmData.bull?.n || 0;
|
||||
const bullWins = pmData.bull?.win || 0;
|
||||
const bullUnknown = pmData.bull?.unknown || 0;
|
||||
const bearTotal = pmData.bear?.n || 0;
|
||||
const bearWins = pmData.bear?.win || 0;
|
||||
const bearUnknown = pmData.bear?.unknown || 0;
|
||||
|
||||
// Calculate evaluated counts (exclude unknown)
|
||||
const evaluatedBull = Math.max(bullTotal - bullUnknown, 0);
|
||||
const evaluatedBear = Math.max(bearTotal - bearUnknown, 0);
|
||||
const evaluatedTotal = evaluatedBull + evaluatedBear;
|
||||
|
||||
// Calculate win rate
|
||||
const totalWins = bullWins + bearWins;
|
||||
const winRate = evaluatedTotal > 0 ? (totalWins / evaluatedTotal) : null;
|
||||
|
||||
return {
|
||||
winRate,
|
||||
totalWins,
|
||||
evaluatedTotal,
|
||||
bullWins,
|
||||
bearWins,
|
||||
evaluatedBull,
|
||||
evaluatedBear
|
||||
};
|
||||
};
|
||||
|
||||
const pmWinRateData = calculatePortfolioManagerWinRate();
|
||||
|
||||
// Reset to page 1 when data changes
|
||||
useEffect(() => {
|
||||
setHoldingsPage(1);
|
||||
}, [holdings.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setTradesPage(1);
|
||||
}, [trades.length]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#f5f5f5'
|
||||
}}>
|
||||
{/* Left Panel: Performance Overview (35%) */}
|
||||
<div style={{
|
||||
width: '35%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#ffffff',
|
||||
borderRight: '2px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{effectiveStats ? (
|
||||
<div style={{
|
||||
padding: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
marginBottom: 24,
|
||||
paddingBottom: 16,
|
||||
borderBottom: '3px solid #000000'
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 2,
|
||||
margin: 0,
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
业绩表现
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Main Stats - Hierarchical Layout */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* Primary Metric - Total Asset Value */}
|
||||
<div style={{
|
||||
padding: '20px 0',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: '#666666',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1.5,
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
总资产价值
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 700,
|
||||
color: '#000000',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
lineHeight: 1
|
||||
}}>
|
||||
${formatNumber(effectiveStats.totalAssetValue || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Metrics - Grid: Excess Return, Win Rate, Absolute Return */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: excessReturnData ? '1fr 1fr 1fr' : '1fr 1fr',
|
||||
gap: 16,
|
||||
paddingBottom: 20,
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
}}>
|
||||
{/* 1. Excess Return */}
|
||||
{excessReturnData ? (
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color: '#999999',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
超额收益
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: excessReturnData.excessReturn >= 0 ? '#00C853' : '#FF1744',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{excessReturnData.excessReturn >= 0 ? '+' : ''}{excessReturnData.excessReturn.toFixed(2)}%
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 7,
|
||||
color: '#999999',
|
||||
marginTop: 4,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
vs 市值加权: {excessReturnData.benchmarkReturn >= 0 ? '+' : ''}{excessReturnData.benchmarkReturn.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 2. Win Rate */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color: '#999999',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
胜率
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: pmWinRateData?.winRate != null ? '#00C853' : '#000000',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{pmWinRateData?.winRate != null
|
||||
? `${(pmWinRateData.winRate * 100).toFixed(1)}%`
|
||||
: '暂无'}
|
||||
</div>
|
||||
{pmWinRateData && (
|
||||
<div style={{
|
||||
fontSize: 7,
|
||||
color: '#999999',
|
||||
marginTop: 4,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{pmWinRateData.totalWins}胜 / {pmWinRateData.evaluatedTotal}评
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Absolute Return */}
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: 9,
|
||||
color: '#999999',
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
绝对收益
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tertiary Metrics - Compact List */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: '#666666',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
现金头寸
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: '#000000',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
${formatNumber(effectiveStats.cashPosition || 0)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: '#666666',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
总交易数
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: '#000000',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{effectiveStats.totalTrades || 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ticker Weights - Compact */}
|
||||
{effectiveStats?.tickerWeights && Object.keys(effectiveStats.tickerWeights).length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 'auto',
|
||||
paddingTop: 20,
|
||||
borderTop: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
marginBottom: 12,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
color: '#666666'
|
||||
}}>
|
||||
组合权重
|
||||
</div>
|
||||
<div className="statistics-table-container" style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 8,
|
||||
maxHeight: 120
|
||||
}}>
|
||||
{Object.entries(effectiveStats.tickerWeights).map(([ticker, weight]) => {
|
||||
const weightValue = Number(weight);
|
||||
const isNegative = weightValue < 0;
|
||||
const displayWeight = (weightValue * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<div key={ticker} style={{
|
||||
padding: '6px 10px',
|
||||
background: '#fafafa',
|
||||
border: '1px solid #e0e0e0',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
<span style={{ color: '#000000' }}>{ticker}</span>
|
||||
<span style={{ color: isNegative ? '#FF1744' : '#00C853' }}>
|
||||
{displayWeight}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: '#999999',
|
||||
fontSize: 12,
|
||||
letterSpacing: 0.5
|
||||
}}>
|
||||
暂无统计数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Holdings + Trades (65%) */}
|
||||
<div style={{
|
||||
width: '65%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#ffffff',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Portfolio Holdings - Top Half */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#ffffff',
|
||||
margin: '16px 16px 8px 16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '2px solid #000000',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1.5,
|
||||
margin: 0,
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
持仓明细
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{holdings.length === 0 ? (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#999999',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.5
|
||||
}}>
|
||||
当前无持仓
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="statistics-table-container" style={{ flex: 1 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>代码</th>
|
||||
<th>数量</th>
|
||||
<th>价格</th>
|
||||
<th>市值</th>
|
||||
<th>权重</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentHoldings.map(h => {
|
||||
// For short positions, quantity should be negative and weight should also be negative
|
||||
const isShort = h.ticker !== 'CASH' && Number(h.quantity) < 0;
|
||||
const displayWeight = isShort ? -Math.abs(Number(h.weight)) : Number(h.weight);
|
||||
|
||||
return (
|
||||
<tr key={h.ticker}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 700, color: '#000000' }}>{h.ticker}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{h.ticker === 'CASH' ? '-' : h.quantity}</td>
|
||||
<td>{h.ticker === 'CASH' ? '-' : `$${Number(h.currentPrice).toFixed(2)}`}</td>
|
||||
<td style={{ fontWeight: 700 }}>${formatNumber(h.marketValue)}</td>
|
||||
<td style={{ color: isShort ? '#FF1744' : '#000000' }}>
|
||||
{(displayWeight * 100).toFixed(2)}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalHoldingsPages > 1 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setHoldingsPage(p => Math.max(1, p - 1))}
|
||||
disabled={holdingsPage === 1}
|
||||
>
|
||||
◀ 上一页
|
||||
</button>
|
||||
|
||||
<div className="pagination-info">
|
||||
{holdingsPage} / {totalHoldingsPages}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setHoldingsPage(p => Math.min(totalHoldingsPages, p + 1))}
|
||||
disabled={holdingsPage === totalHoldingsPages}
|
||||
>
|
||||
下一页 ▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction History - Bottom Half */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#ffffff',
|
||||
margin: '8px 16px 16px 16px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px 20px',
|
||||
borderBottom: '2px solid #000000',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<h2 style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 1.5,
|
||||
margin: 0,
|
||||
color: '#000000',
|
||||
textTransform: 'uppercase'
|
||||
}}>
|
||||
交易历史
|
||||
</h2>
|
||||
{trades.length > 0 && (
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
color: '#666666',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
共 {trades.length} 笔
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{trades.length === 0 ? (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#999999',
|
||||
fontSize: 11,
|
||||
letterSpacing: 0.5
|
||||
}}>
|
||||
暂无交易记录
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="statistics-table-container" style={{ flex: 1 }}>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>股票</th>
|
||||
<th>方向</th>
|
||||
<th>数量</th>
|
||||
<th>价格</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentTrades.map((t, idx) => (
|
||||
<tr key={t.id || `${t.ticker}-${t.timestamp}-${idx}`}>
|
||||
<td style={{ fontSize: 10, color: '#666666', fontFamily: '"Courier New", monospace' }}>
|
||||
{formatDateTime(t.timestamp)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontWeight: 700, color: '#000000' }}>{t.ticker}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '2px 6px',
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
border: `1px solid ${t.side === 'LONG' ? '#00C853' : t.side === 'SHORT' ? '#FF1744' : '#666666'}`,
|
||||
color: t.side === 'LONG' ? '#00C853' : t.side === 'SHORT' ? '#FF1744' : '#666666'
|
||||
}}>
|
||||
{t.side}
|
||||
</span>
|
||||
</td>
|
||||
<td>{t.qty}</td>
|
||||
<td>${Number(t.price).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalTradesPages > 1 && (
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
background: '#fafafa'
|
||||
}}>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setTradesPage(p => Math.max(1, p - 1))}
|
||||
disabled={tradesPage === 1}
|
||||
>
|
||||
◀ 上一页
|
||||
</button>
|
||||
|
||||
<div className="pagination-info">
|
||||
{tradesPage} / {totalTradesPages}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="pagination-btn"
|
||||
onClick={() => setTradesPage(p => Math.min(totalTradesPages, p + 1))}
|
||||
disabled={tradesPage === totalTradesPages}
|
||||
>
|
||||
下一页 ▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
frontend/src/components/StockExplainView.jsx
Normal file
213
frontend/src/components/StockExplainView.jsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import ExplainNewsSection from './explain/ExplainNewsSection';
|
||||
import ExplainPriceSection from './explain/ExplainPriceSection';
|
||||
import ExplainInsiderSection from './explain/ExplainInsiderSection';
|
||||
import ExplainTechnicalSection from './explain/ExplainTechnicalSection';
|
||||
import useExplainModel from './explain/useExplainModel';
|
||||
import { formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||
|
||||
export default function StockExplainView({
|
||||
tickers,
|
||||
holdings,
|
||||
trades,
|
||||
leaderboard,
|
||||
feed,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedSymbol,
|
||||
onSelectedSymbolChange,
|
||||
selectedHistorySource,
|
||||
newsSnapshot,
|
||||
insiderTradesSnapshot,
|
||||
technicalIndicatorsSnapshot,
|
||||
onRequestHistory,
|
||||
onRequestNews,
|
||||
onRequestInsiderTrades,
|
||||
onRequestTechnicalIndicators,
|
||||
}) {
|
||||
const [activeNewsCategory, setActiveNewsCategory] = useState('all');
|
||||
const [activeNewsSentiment, setActiveNewsSentiment] = useState('all');
|
||||
const [isPriceOpen, setIsPriceOpen] = useState(true);
|
||||
const [isNewsOpen, setIsNewsOpen] = useState(true);
|
||||
const [isInsiderOpen, setIsInsiderOpen] = useState(false);
|
||||
const [isTechnicalOpen, setIsTechnicalOpen] = useState(true);
|
||||
|
||||
const {
|
||||
availableSymbols,
|
||||
selectedTicker,
|
||||
holding,
|
||||
tickerNews,
|
||||
visibleNews,
|
||||
newsCategories,
|
||||
visibleNewsByCategory,
|
||||
selectedNewsFreshness,
|
||||
priceColor,
|
||||
exposureWeight,
|
||||
ohlcSeries,
|
||||
priceSeries,
|
||||
chartModel
|
||||
} = useExplainModel({
|
||||
tickers,
|
||||
holdings,
|
||||
trades,
|
||||
leaderboard,
|
||||
feed,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedSymbol,
|
||||
newsSnapshot,
|
||||
selectedEventDate: '',
|
||||
activeEventCategory: 'all',
|
||||
activeNewsCategory,
|
||||
activeNewsSentiment
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!availableSymbols.length) {
|
||||
onSelectedSymbolChange?.('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSymbol || !availableSymbols.includes(selectedSymbol)) {
|
||||
onSelectedSymbolChange?.(availableSymbols[0]);
|
||||
}
|
||||
}, [availableSymbols, onSelectedSymbolChange, selectedSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveNewsCategory('all');
|
||||
setActiveNewsSentiment('all');
|
||||
}, [selectedSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onRequestHistory && (!Array.isArray(ohlcHistoryByTicker?.[selectedSymbol]) || ohlcHistoryByTicker[selectedSymbol].length === 0)) {
|
||||
onRequestHistory(selectedSymbol);
|
||||
}
|
||||
|
||||
if (onRequestNews && (!Array.isArray(newsSnapshot?.items) || newsSnapshot.items.length === 0)) {
|
||||
onRequestNews(selectedSymbol);
|
||||
}
|
||||
}, [
|
||||
newsSnapshot,
|
||||
ohlcHistoryByTicker,
|
||||
onRequestHistory,
|
||||
onRequestNews,
|
||||
selectedSymbol,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol || !onRequestTechnicalIndicators) {
|
||||
return;
|
||||
}
|
||||
if (technicalIndicatorsSnapshot) {
|
||||
return;
|
||||
}
|
||||
onRequestTechnicalIndicators(selectedSymbol);
|
||||
}, [selectedSymbol, onRequestTechnicalIndicators, technicalIndicatorsSnapshot]);
|
||||
|
||||
return (
|
||||
<div className="performance-page">
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">个股分析</h2>
|
||||
<div className="section-tabs" style={{ flexWrap: 'wrap', maxWidth: '100%' }}>
|
||||
{availableSymbols.map((symbol) => (
|
||||
<button
|
||||
key={symbol}
|
||||
className={`section-tab ${selectedSymbol === symbol ? 'active' : ''}`}
|
||||
onClick={() => onSelectedSymbolChange?.(symbol)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<span>{symbol}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedSymbol ? (
|
||||
<div className="empty-state">暂无可解释股票</div>
|
||||
) : (
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))' }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">当前价格</div>
|
||||
<div className="stat-card-value" style={{ color: priceColor }}>
|
||||
{selectedTicker?.price != null ? `$${formatTickerPrice(selectedTicker.price)}` : '-'}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: priceColor, fontWeight: 700 }}>
|
||||
{selectedTicker?.change != null ? `${selectedTicker.change >= 0 ? '+' : ''}${selectedTicker.change.toFixed(2)}%` : '暂无涨跌幅'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">当前仓位</div>
|
||||
<div className="stat-card-value">
|
||||
{holding ? Number(holding.quantity) : 0}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
|
||||
{holding ? `持仓市值 $${formatNumber(holding.marketValue || 0)}` : '当前无持仓'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">组合权重</div>
|
||||
<div className={`stat-card-value ${exposureWeight > 0 ? 'positive' : exposureWeight < 0 ? 'negative' : ''}`}>
|
||||
{exposureWeight != null ? `${exposureWeight.toFixed(2)}%` : '0.00%'}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
|
||||
{holding ? `最新价格 $${Number(holding.currentPrice || 0).toFixed(2)}` : '未进入投资组合'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedSymbol && (
|
||||
<>
|
||||
<ExplainPriceSection
|
||||
ohlcSeries={ohlcSeries}
|
||||
priceSeries={priceSeries}
|
||||
selectedHistorySource={selectedHistorySource}
|
||||
chartModel={chartModel}
|
||||
selectedTicker={selectedTicker}
|
||||
isOpen={isPriceOpen}
|
||||
onToggle={() => setIsPriceOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<ExplainNewsSection
|
||||
newsSnapshot={newsSnapshot}
|
||||
visibleNewsByCategory={visibleNewsByCategory}
|
||||
visibleNews={visibleNews}
|
||||
selectedNewsFreshness={selectedNewsFreshness}
|
||||
activeNewsCategory={activeNewsCategory}
|
||||
onSelectNewsCategory={setActiveNewsCategory}
|
||||
activeNewsSentiment={activeNewsSentiment}
|
||||
onSelectNewsSentiment={setActiveNewsSentiment}
|
||||
newsCategories={newsCategories}
|
||||
tickerNews={tickerNews}
|
||||
isOpen={isNewsOpen}
|
||||
onToggle={() => setIsNewsOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<ExplainInsiderSection
|
||||
insiderTrades={insiderTradesSnapshot?.trades || []}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isOpen={isInsiderOpen}
|
||||
onToggle={() => setIsInsiderOpen((prev) => !prev)}
|
||||
onRequest={onRequestInsiderTrades}
|
||||
/>
|
||||
|
||||
<ExplainTechnicalSection
|
||||
technicalIndicators={technicalIndicatorsSnapshot}
|
||||
selectedSymbol={selectedSymbol}
|
||||
isOpen={isTechnicalOpen}
|
||||
onToggle={() => setIsTechnicalOpen((prev) => !prev)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
932
frontend/src/components/TraderView.jsx
Normal file
932
frontend/src/components/TraderView.jsx
Normal file
@@ -0,0 +1,932 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import JSZip from 'jszip';
|
||||
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
||||
import LobeModelLogo from './LobeModelLogo.jsx';
|
||||
|
||||
export default function TraderView({
|
||||
agents,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
workspaceFilesByAgent,
|
||||
selectedAgentId,
|
||||
selectedAgentProfile,
|
||||
selectedAgentSkills,
|
||||
skillDetailsByName,
|
||||
localSkillDraftsByKey,
|
||||
skillDetailLoadingKey,
|
||||
editableFiles,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFileContent,
|
||||
workspaceDraftContent,
|
||||
isConnected,
|
||||
isAgentSkillsLoading,
|
||||
agentSkillsSavingKey,
|
||||
agentSkillsFeedback,
|
||||
isWorkspaceFileLoading,
|
||||
workspaceFileSavingKey,
|
||||
workspaceFileFeedback,
|
||||
onAgentChange,
|
||||
onCreateLocalSkill,
|
||||
onSkillDetailRequest,
|
||||
onLocalSkillDraftChange,
|
||||
onLocalSkillDelete,
|
||||
onLocalSkillSave,
|
||||
onRemoveSharedSkill,
|
||||
onSkillToggle,
|
||||
onWorkspaceFileChange,
|
||||
onWorkspaceDraftChange,
|
||||
onWorkspaceFileSave,
|
||||
onUploadExternalSkill
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
const [expandedSkillKey, setExpandedSkillKey] = useState(null);
|
||||
const [newLocalSkillName, setNewLocalSkillName] = useState('');
|
||||
const [externalSkillFile, setExternalSkillFile] = useState(null);
|
||||
const [isExternalSkillChecking, setIsExternalSkillChecking] = useState(false);
|
||||
const [externalSkillCheck, setExternalSkillCheck] = useState({ type: null, text: '' });
|
||||
const [isSkillPickerOpen, setIsSkillPickerOpen] = useState(false);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => agents.find((agent) => agent.id === selectedAgentId) || agents[0] || null,
|
||||
[agents, selectedAgentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedSkillKey(null);
|
||||
}, [selectedAgentId]);
|
||||
|
||||
if (!selectedAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = selectedAgentProfile || {};
|
||||
const modelInfo = getModelIcon(profile.model_name, profile.model_provider);
|
||||
const activeSkills = selectedAgentSkills.filter((item) => item.status === 'enabled' || item.status === 'active');
|
||||
const installedSkills = selectedAgentSkills.filter((item) => item.status !== 'available');
|
||||
const availableSkills = selectedAgentSkills.filter((item) => item.status === 'available');
|
||||
|
||||
const validateExternalSkillZip = async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setExternalSkillCheck({ type: 'error', text: '请选择 zip 文件' });
|
||||
return false;
|
||||
}
|
||||
if (!file.name.toLowerCase().endsWith('.zip')) {
|
||||
setExternalSkillCheck({ type: 'error', text: '仅支持 .zip 文件' });
|
||||
return false;
|
||||
}
|
||||
|
||||
setIsExternalSkillChecking(true);
|
||||
setExternalSkillCheck({ type: null, text: '' });
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(file);
|
||||
const entries = Object.keys(zip.files);
|
||||
const skillFilePath = entries.find((entry) => {
|
||||
const item = zip.files[entry];
|
||||
return !item.dir && /(^|\/)SKILL\.md$/i.test(entry);
|
||||
});
|
||||
|
||||
if (!skillFilePath) {
|
||||
setExternalSkillCheck({
|
||||
type: 'error',
|
||||
text: '压缩包中未检测到 SKILL.md,请检查目录结构'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
setExternalSkillCheck({
|
||||
type: 'success',
|
||||
text: `预检通过,检测到: ${skillFilePath}`
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
setExternalSkillCheck({
|
||||
type: 'error',
|
||||
text: `无法解析 zip: ${error?.message || '未知错误'}`
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setIsExternalSkillChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: '18px',
|
||||
background: 'linear-gradient(180deg, #ffffff 0%, #f4f7fb 100%)',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'auto auto 1fr',
|
||||
gap: 18
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, letterSpacing: '0.5px', color: '#111111' }}>
|
||||
交易员档案
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
聚焦查看每个 Agent 的模型、工具组、技能编排和工作区记忆,不展示交易表现数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '120px minmax(0, 1fr)',
|
||||
gap: 16,
|
||||
alignItems: 'stretch',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Left: agent avatar list */}
|
||||
<div style={{
|
||||
border: '1px solid #D9E0E7',
|
||||
borderRadius: 14,
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
|
||||
padding: 12,
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
alignContent: 'start'
|
||||
}}>
|
||||
{agents.map((agent) => {
|
||||
const isSelected = agent.id === selectedAgentId;
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
onClick={() => onAgentChange(agent.id)}
|
||||
title={agent.name}
|
||||
style={{
|
||||
border: isSelected ? `2px solid ${agent.colors.accent}` : '1px solid #D9E0E7',
|
||||
borderRadius: 16,
|
||||
background: isSelected ? `${agent.colors.accent}10` : '#FFFFFF',
|
||||
boxShadow: isSelected ? `0 10px 20px ${agent.colors.accent}18` : 'none',
|
||||
padding: 8,
|
||||
display: 'grid',
|
||||
gap: 6,
|
||||
justifyItems: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={agent.avatar}
|
||||
alt={agent.name}
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${agent.colors.accent}33`
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
color: isSelected ? agent.colors.accent : '#374151',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.4
|
||||
}}>
|
||||
{agent.name}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right: agent detail content */}
|
||||
<div style={{
|
||||
border: '1px solid #D9E0E7',
|
||||
borderRadius: 14,
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 10px 24px rgba(15, 23, 42, 0.06)',
|
||||
padding: 18,
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
alignContent: 'start'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<img
|
||||
src={selectedAgent.avatar}
|
||||
alt={selectedAgent.name}
|
||||
style={{
|
||||
width: 58,
|
||||
height: 58,
|
||||
borderRadius: 12,
|
||||
objectFit: 'cover',
|
||||
border: `1px solid ${selectedAgent.colors.accent}33`
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: '#111111' }}>{selectedAgent.name}</div>
|
||||
<div style={{ fontSize: 12, color: '#6B7280' }}>{selectedAgent.role}</div>
|
||||
<div style={{ fontSize: 11, color: selectedAgent.colors.accent, fontWeight: 700 }}>
|
||||
当前档案已展开
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: `1px solid ${modelInfo.color}2e`,
|
||||
background: modelInfo.bgColor,
|
||||
borderRadius: 12,
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
}}>
|
||||
<LobeModelLogo
|
||||
model={profile.model_name}
|
||||
provider={profile.model_provider}
|
||||
fallbackSrc={modelInfo.logoPath}
|
||||
alt={modelInfo.provider}
|
||||
size={26}
|
||||
shape="circle"
|
||||
type="color"
|
||||
style={{ borderRadius: 999 }}
|
||||
/>
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<div style={{ fontSize: 11, color: '#4B5563', fontWeight: 700 }}>模型</div>
|
||||
<div style={{ fontSize: 12, color: '#111111', fontWeight: 800 }}>
|
||||
{getShortModelName(profile.model_name)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(300px, 420px) minmax(0, 1fr)',
|
||||
gap: 16,
|
||||
alignItems: 'start',
|
||||
minHeight: 0
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>技能</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||
已启用: {activeSkills.length} / 已安装: {installedSkills.length}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSkillPickerOpen(true)}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #1565C0',
|
||||
background: '#EFF6FF',
|
||||
color: '#1565C0',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
aria-label="管理技能"
|
||||
>
|
||||
⚙ 技能管理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5E7EB',
|
||||
background: '#F8FAFC',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
maxHeight: 520,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{isAgentSkillsLoading ? (
|
||||
<div style={{ fontSize: 11, color: '#666666', fontFamily: '"Courier New", monospace' }}>加载技能中...</div>
|
||||
) : installedSkills.length === 0 ? (
|
||||
<div style={{ fontSize: 11, color: '#666666', fontFamily: '"Courier New", monospace' }}>暂无技能</div>
|
||||
) : installedSkills.map((skill) => {
|
||||
const isEnabled = skill.status === 'enabled' || skill.status === 'active';
|
||||
const saving = agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:content` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:delete` || agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}:remove`;
|
||||
const isExpanded = expandedSkillKey === skill.skill_name;
|
||||
const detailKey = `${selectedAgentId}:${skill.skill_name}`;
|
||||
const skillDetail = skillDetailsByName?.[detailKey] || null;
|
||||
const skillDraft = localSkillDraftsByKey?.[detailKey] ?? '';
|
||||
const isDetailLoading = skillDetailLoadingKey === detailKey;
|
||||
const isLocalSkill = skill.source === 'local';
|
||||
return (
|
||||
<div
|
||||
key={skill.skill_name}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 7,
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px dashed #D7DEE7'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isExpanded && !skillDetail && onSkillDetailRequest) {
|
||||
onSkillDetailRequest(skill.skill_name);
|
||||
}
|
||||
setExpandedSkillKey((prev) => (prev === skill.skill_name ? null : skill.skill_name));
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'grid',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280', fontWeight: 700 }}>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>
|
||||
{skill.name || '未命名技能'}
|
||||
</span>
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${isLocalSkill ? selectedAgent.colors.accent : '#D0D7DE'}`,
|
||||
color: isLocalSkill ? selectedAgent.colors.accent : '#6B7280',
|
||||
fontSize: 9,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{isLocalSkill ? '本地' : '共享'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#4B5563', marginLeft: 20 }}>
|
||||
{skill.description || '-'}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', marginLeft: 20 }}>
|
||||
{isExpanded ? '点击收起详情' : '点击展开详情'}
|
||||
</div>
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSkillToggle(skill.skill_name, !isEnabled)}
|
||||
disabled={!isConnected || saving}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${isEnabled ? '#C62828' : '#1565C0'}`,
|
||||
background: isConnected && !saving ? (isEnabled ? '#FFF5F5' : '#EFF6FF') : '#E5E7EB',
|
||||
color: isEnabled ? '#C62828' : '#1565C0',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? '处理中' : isEnabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
{isLocalSkill ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLocalSkillDelete(skill.skill_name)}
|
||||
disabled={!isConnected || saving}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #C62828',
|
||||
background: isConnected && !saving ? '#FFF5F5' : '#E5E7EB',
|
||||
color: '#C62828',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? '处理中' : '删除'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveSharedSkill(skill.skill_name)}
|
||||
disabled={!isConnected || saving}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #C62828',
|
||||
background: isConnected && !saving ? '#FFF5F5' : '#E5E7EB',
|
||||
color: '#C62828',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? '处理中' : '移除'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
marginLeft: 20,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #E5E7EB',
|
||||
background: '#FFFFFF',
|
||||
padding: '10px 12px',
|
||||
display: 'grid',
|
||||
gap: 8
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: 11,
|
||||
color: '#1F2937',
|
||||
lineHeight: 1.6,
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{isDetailLoading
|
||||
? '加载技能说明中...'
|
||||
: (skillDetail?.content || '暂无更详细的技能说明')}
|
||||
</div>
|
||||
{isLocalSkill && !isDetailLoading && (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 10, color: '#6B7280', fontWeight: 700 }}>
|
||||
本地技能 SKILL.md
|
||||
</div>
|
||||
<textarea
|
||||
id={`local-skill-${selectedAgentId}-${skill.skill_name}`}
|
||||
name={`local_skill_${selectedAgentId}_${skill.skill_name}`}
|
||||
aria-label={`${skill.skill_name} 本地技能内容`}
|
||||
value={skillDraft}
|
||||
onChange={(e) => onLocalSkillDraftChange(skill.skill_name, e.target.value)}
|
||||
style={{
|
||||
minHeight: 220,
|
||||
resize: 'vertical',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
padding: '10px 12px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLocalSkillSave(skill.skill_name)}
|
||||
disabled={!isConnected || saving || skillDraft === (skillDetail?.content || '')}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !saving && skillDraft !== (skillDetail?.content || '') ? '#0D47A1' : '#94A3B8',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && !saving && skillDraft !== (skillDetail?.content || '') ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
{saving ? '保存中' : '保存本地技能'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{agentSkillsFeedback && (
|
||||
<span style={{
|
||||
color: agentSkillsFeedback.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontSize: 11,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{agentSkillsFeedback.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>工作区文件编辑</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
直接调整该交易员的人设、协作方式和长期记忆文件
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{editableFiles.map((filename) => {
|
||||
const isActive = filename === selectedWorkspaceFile;
|
||||
return (
|
||||
<button
|
||||
key={filename}
|
||||
onClick={() => onWorkspaceFileChange(filename)}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${isActive ? selectedAgent.colors.accent : '#D0D7DE'}`,
|
||||
background: isActive ? `${selectedAgent.colors.accent}12` : '#FFFFFF',
|
||||
color: isActive ? selectedAgent.colors.accent : '#4B5563',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
>
|
||||
{filename}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={`workspace-editor-${selectedAgentId}-${selectedWorkspaceFile || 'file'}`}
|
||||
name={`workspace_editor_${selectedAgentId}_${selectedWorkspaceFile || 'file'}`}
|
||||
aria-label={`编辑 ${selectedWorkspaceFile || '工作区文件'} 内容`}
|
||||
value={workspaceDraftContent}
|
||||
onChange={(e) => onWorkspaceDraftChange(e.target.value)}
|
||||
placeholder={isWorkspaceFileLoading ? '加载中...' : '输入 markdown 内容'}
|
||||
style={{
|
||||
minHeight: 280,
|
||||
resize: 'vertical',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
padding: '12px 14px',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 10, color: '#6B7280', fontFamily: '"Courier New", monospace' }}>
|
||||
当前文件: {selectedWorkspaceFile}
|
||||
</span>
|
||||
<button
|
||||
onClick={onWorkspaceFileSave}
|
||||
disabled={!isConnected || isWorkspaceFileLoading || workspaceFileSavingKey !== null || workspaceDraftContent === workspaceFileContent}
|
||||
style={{
|
||||
padding: '9px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !isWorkspaceFileLoading && workspaceFileSavingKey === null && workspaceDraftContent !== workspaceFileContent ? '#0D47A1' : '#94A3B8',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && !isWorkspaceFileLoading && workspaceFileSavingKey === null && workspaceDraftContent !== workspaceFileContent ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
{workspaceFileSavingKey ? '保存中' : '保存文件'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspaceFileFeedback && (
|
||||
<span style={{
|
||||
color: workspaceFileFeedback.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontSize: 11,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{workspaceFileFeedback.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isSkillPickerOpen && createPortal((
|
||||
<div
|
||||
onClick={() => setIsSkillPickerOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(15, 23, 42, 0.28)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
zIndex: 9998
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: 'min(760px, 92vw)',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
borderRadius: 16,
|
||||
border: '1px solid #D9E0E7',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 24px 60px rgba(15, 23, 42, 0.18)',
|
||||
padding: 18,
|
||||
paddingTop: 22,
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
position: 'relative',
|
||||
zIndex: 9999
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSkillPickerOpen(false)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
borderRadius: 999,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
color: '#111111',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(15, 23, 42, 0.08)'
|
||||
}}
|
||||
aria-label="关闭技能管理"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', paddingRight: 56 }}>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#111111' }}>技能管理</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
为 {selectedAgent.name} 添加共享技能,或创建本地技能
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>创建本地技能</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<label htmlFor="new-local-skill-name" style={srOnlyStyle}>
|
||||
输入本地技能名称
|
||||
</label>
|
||||
<input
|
||||
id="new-local-skill-name"
|
||||
name="new_local_skill_name"
|
||||
aria-label="输入本地技能名称"
|
||||
value={newLocalSkillName}
|
||||
onChange={(e) => setNewLocalSkillName(e.target.value)}
|
||||
placeholder="输入技能名,例如 event_playbook"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11,
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (onCreateLocalSkill) {
|
||||
onCreateLocalSkill(newLocalSkillName);
|
||||
setNewLocalSkillName('');
|
||||
}
|
||||
}}
|
||||
disabled={!isConnected || !newLocalSkillName.trim()}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && newLocalSkillName.trim() ? '#EFF6FF' : '#E5E7EB',
|
||||
color: '#1565C0',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && newLocalSkillName.trim() ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>上传外部技能包</div>
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>
|
||||
支持上传 .zip(包内需包含一个技能目录及 SKILL.md)
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="external-skill-zip" style={srOnlyStyle}>
|
||||
上传外部技能 zip 包
|
||||
</label>
|
||||
<input
|
||||
id="external-skill-zip"
|
||||
name="external_skill_zip"
|
||||
aria-label="上传外部技能 zip 包"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0] || null;
|
||||
setExternalSkillFile(file);
|
||||
if (!file) {
|
||||
setExternalSkillCheck({ type: null, text: '' });
|
||||
return;
|
||||
}
|
||||
await validateExternalSkillZip(file);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 220,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: 11
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!onUploadExternalSkill || !externalSkillFile) {
|
||||
return;
|
||||
}
|
||||
const valid = await validateExternalSkillZip(externalSkillFile);
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
await onUploadExternalSkill(externalSkillFile);
|
||||
setExternalSkillFile(null);
|
||||
setExternalSkillCheck({ type: null, text: '' });
|
||||
}}
|
||||
disabled={!isConnected || !externalSkillFile || isExternalSkillChecking || externalSkillCheck.type === 'error'}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && externalSkillFile && !isExternalSkillChecking && externalSkillCheck.type !== 'error' ? '#EFF6FF' : '#E5E7EB',
|
||||
color: '#1565C0',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && externalSkillFile && !isExternalSkillChecking && externalSkillCheck.type !== 'error' ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{isExternalSkillChecking ? '预检中...' : '上传并安装'}
|
||||
</button>
|
||||
</div>
|
||||
{externalSkillCheck.text ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: externalSkillCheck.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
>
|
||||
{externalSkillCheck.text}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid #E5EAF1',
|
||||
borderRadius: 12,
|
||||
background: '#FCFDFE',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 10
|
||||
}}>
|
||||
<div style={{ fontSize: 12, fontWeight: 800, color: '#111111' }}>添加共享技能</div>
|
||||
<div style={{
|
||||
border: '1px solid #E5E7EB',
|
||||
background: '#FFFFFF',
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
display: 'grid',
|
||||
gap: 10,
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{availableSkills.length === 0 ? (
|
||||
<div style={{ fontSize: 11, color: '#6B7280' }}>没有可添加的共享技能</div>
|
||||
) : availableSkills.map((skill) => {
|
||||
const saving = agentSkillsSavingKey === `${selectedAgentId}:${skill.skill_name}`;
|
||||
return (
|
||||
<div
|
||||
key={skill.skill_name}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'flex-start',
|
||||
paddingBottom: 10,
|
||||
borderBottom: '1px dashed #D7DEE7'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#111111' }}>
|
||||
{skill.name || skill.skill_name}
|
||||
</span>
|
||||
<span style={{
|
||||
padding: '2px 6px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid #D0D7DE',
|
||||
color: '#6B7280',
|
||||
fontSize: 9,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
共享
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#4B5563' }}>
|
||||
{skill.description || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSkillToggle(skill.skill_name, true)}
|
||||
disabled={!isConnected || saving}
|
||||
style={{
|
||||
padding: '7px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !saving ? '#EFF6FF' : '#E5E7EB',
|
||||
color: '#1565C0',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: isConnected && !saving ? 'pointer' : 'not-allowed',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{saving ? '处理中' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
frontend/src/components/WatchlistPanel.jsx
Normal file
262
frontend/src/components/WatchlistPanel.jsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function WatchlistPanel({
|
||||
isOpen,
|
||||
isConnected,
|
||||
isSaving,
|
||||
draftSymbols,
|
||||
inputValue,
|
||||
feedback,
|
||||
suggestions,
|
||||
onToggle,
|
||||
onClose,
|
||||
onInputChange,
|
||||
onInputKeyDown,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onRestoreCurrent,
|
||||
onRestoreDefault,
|
||||
onSuggestionClick,
|
||||
onSave
|
||||
}) {
|
||||
const srOnlyStyle = {
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #333333',
|
||||
background: isOpen ? '#1E1E1E' : '#111111',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.6px',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
自选股
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 10px)',
|
||||
right: 0,
|
||||
width: 360,
|
||||
maxWidth: 'min(360px, 92vw)',
|
||||
padding: '14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #D9D9D9',
|
||||
background: '#FFFFFF',
|
||||
boxShadow: '0 12px 36px rgba(0, 0, 0, 0.14)',
|
||||
zIndex: 40,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: '#111111', letterSpacing: '0.3px' }}>
|
||||
自选股管理
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#666666', marginTop: 2 }}>
|
||||
保存后会立即更新当前 run 的 watchlist
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#666666',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
minHeight: 36,
|
||||
padding: '2px 0'
|
||||
}}>
|
||||
{draftSymbols.map((symbol) => (
|
||||
<button
|
||||
key={symbol}
|
||||
onClick={() => onRemove(symbol)}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#F7F9FB',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<span>{symbol}</span>
|
||||
<span style={{ color: '#777777' }}>×</span>
|
||||
</button>
|
||||
))}
|
||||
{draftSymbols.length === 0 && (
|
||||
<div style={{ fontSize: '11px', color: '#888888', padding: '8px 2px' }}>
|
||||
还没有股票,输入代码后回车添加
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<label htmlFor="watchlist-symbol-input" style={srOnlyStyle}>
|
||||
输入股票代码
|
||||
</label>
|
||||
<input
|
||||
id="watchlist-symbol-input"
|
||||
name="watchlist_symbol"
|
||||
aria-label="输入股票代码"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder="输入股票代码,回车添加"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '9px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '12px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
style={{
|
||||
padding: '9px 12px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#F7F9FB',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{suggestions.map((symbol) => {
|
||||
const active = draftSymbols.includes(symbol);
|
||||
return (
|
||||
<button
|
||||
key={symbol}
|
||||
onClick={() => onSuggestionClick(symbol)}
|
||||
disabled={active}
|
||||
style={{
|
||||
padding: '5px 8px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid',
|
||||
borderColor: active ? '#B6E3C5' : '#D0D7DE',
|
||||
background: active ? '#ECFDF3' : '#FFFFFF',
|
||||
color: active ? '#157347' : '#4A5568',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
cursor: active ? 'default' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onRestoreCurrent}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复当前
|
||||
</button>
|
||||
<button
|
||||
onClick={onRestoreDefault}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #D0D7DE',
|
||||
background: '#FFFFFF',
|
||||
color: '#111111',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isConnected || isSaving}
|
||||
style={{
|
||||
padding: '9px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #1565C0',
|
||||
background: isConnected && !isSaving ? '#0D47A1' : '#94A3B8',
|
||||
color: '#FFFFFF',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.4px',
|
||||
cursor: isConnected && !isSaving ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
{isSaving ? '保存中' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedback && (
|
||||
<span style={{
|
||||
color: feedback.type === 'success' ? '#00C853' : '#FF5252',
|
||||
fontSize: '11px',
|
||||
fontFamily: '"Courier New", monospace'
|
||||
}}>
|
||||
{feedback.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
frontend/src/components/explain/ExplainEventsSection.jsx
Normal file
157
frontend/src/components/explain/ExplainEventsSection.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainEventsSection({
|
||||
explainTimeline,
|
||||
isOpen,
|
||||
onToggle,
|
||||
availableEventDates,
|
||||
selectedEventDate,
|
||||
onSelectEventDate,
|
||||
eventCategoryCounts,
|
||||
activeEventCategory,
|
||||
onSelectEventCategory,
|
||||
eventCategoryMeta,
|
||||
visibleExplainEvents,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">关键事件时间线</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
图上点击事件点可切换对应日期
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起关键事件' : `展开关键事件 ${explainTimeline.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{explainTimeline.length === 0 ? (
|
||||
<div className="empty-state">当前还没有可以串起来看的关键事件。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">关键事件默认收起,需要时再展开查看和筛选。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{availableEventDates.map((dateKey) => {
|
||||
const isActive = dateKey === selectedEventDate;
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
onClick={() => onSelectEventDate(dateKey)}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isActive ? '#111111' : '#ffffff',
|
||||
color: isActive ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{dateKey}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{Object.entries(eventCategoryMeta)
|
||||
.filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all')
|
||||
.map(([key, meta]) => {
|
||||
const isActive = key === activeEventCategory;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelectEventCategory(key)}
|
||||
style={{
|
||||
border: `1px solid ${meta.color}`,
|
||||
background: isActive ? meta.color : '#ffffff',
|
||||
color: isActive ? '#ffffff' : meta.color,
|
||||
padding: '8px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{meta.label} {eventCategoryCounts[key] || 0}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{visibleExplainEvents.length === 0 ? (
|
||||
<div className="empty-state">当前日期下没有符合筛选条件的事件</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{visibleExplainEvents.map((event) => {
|
||||
const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000';
|
||||
const categoryMeta = eventCategoryMeta[event.category] || eventCategoryMeta.other;
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
minHeight: 180
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${categoryMeta.color}`,
|
||||
color: categoryMeta.color,
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{categoryMeta.label}
|
||||
</span>
|
||||
<strong style={{ fontSize: 13 }}>{event.title}</strong>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||
{formatDateTime(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: accent
|
||||
}} />
|
||||
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||
{event.meta}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{event.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/explain/ExplainInsiderSection.jsx
Normal file
107
frontend/src/components/explain/ExplainInsiderSection.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime, formatNumber } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainInsiderSection({
|
||||
insiderTrades,
|
||||
selectedSymbol,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onRequest,
|
||||
}) {
|
||||
const handleRefresh = () => {
|
||||
if (onRequest) {
|
||||
onRequest(selectedSymbol);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">内部人交易</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{insiderTrades.length} 笔内部人交易记录
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: '#ffffff',
|
||||
color: '#111111',
|
||||
padding: '5px 8px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 10,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起' : `展开 ${insiderTrades.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">点击展开查看内部人交易详情</div>
|
||||
) : insiderTrades.length === 0 ? (
|
||||
<div className="empty-state">暂无内部人交易数据</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>交易日期</th>
|
||||
<th>内部人</th>
|
||||
<th>职位</th>
|
||||
<th>方向</th>
|
||||
<th>股份数</th>
|
||||
<th>价格</th>
|
||||
<th>持仓变化</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{insiderTrades.slice(0, 20).map((trade, index) => {
|
||||
const isBuy = trade.is_buy;
|
||||
const holdingChange = trade.holding_change;
|
||||
return (
|
||||
<tr key={trade.transaction_date + '-' + trade.name + '-' + index}>
|
||||
<td>{trade.transaction_date || '-'}</td>
|
||||
<td>{trade.name || '-'}</td>
|
||||
<td>{trade.title || '-'}</td>
|
||||
<td style={{
|
||||
fontWeight: 700,
|
||||
color: isBuy === true ? '#00C853' : isBuy === false ? '#FF1744' : '#666666'
|
||||
}}>
|
||||
{isBuy === true ? '买入' : isBuy === false ? '卖出' : '-'}
|
||||
</td>
|
||||
<td>{trade.transaction_shares != null ? formatNumber(trade.transaction_shares) : '-'}</td>
|
||||
<td>${trade.transaction_price_per_share != null ? Number(trade.transaction_price_per_share).toFixed(2) : '-'}</td>
|
||||
<td style={{
|
||||
color: holdingChange != null ? (holdingChange > 0 ? '#00C853' : '#FF1744') : '#666666',
|
||||
fontWeight: holdingChange != null ? 700 : 400
|
||||
}}>
|
||||
{holdingChange != null ? (holdingChange > 0 ? '+' : '') + formatNumber(holdingChange) : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
frontend/src/components/explain/ExplainMaintenanceSection.jsx
Normal file
249
frontend/src/components/explain/ExplainMaintenanceSection.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
|
||||
function toggleButtonStyle(active, accent = '#111111') {
|
||||
return {
|
||||
border: `1px solid ${accent}`,
|
||||
background: active ? accent : '#ffffff',
|
||||
color: active ? '#ffffff' : accent,
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExplainMaintenanceSection({
|
||||
selectedSymbol,
|
||||
enrichStartDate,
|
||||
enrichEndDate,
|
||||
onChangeStartDate,
|
||||
onChangeEndDate,
|
||||
forceEnrich,
|
||||
onToggleForce,
|
||||
onlyLocalToLlm,
|
||||
onToggleOnlyLocalToLlm,
|
||||
rebuildStory,
|
||||
onToggleRebuildStory,
|
||||
rebuildSimilarDays,
|
||||
onToggleRebuildSimilarDays,
|
||||
isRunning,
|
||||
onRunEnrich,
|
||||
maintenanceStatus,
|
||||
maintenanceHistory,
|
||||
onSelectHistory,
|
||||
onReplayHistory,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const stats = maintenanceStatus?.stats || null;
|
||||
const summary = stats?.execution_summary || null;
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析数据维护</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
当前标的 {selectedSymbol || '-'}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起刷新工具' : '展开刷新工具'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">刷新工具默认收起,需要时再展开重新分析数据或查看历史。</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
|
||||
开始日期
|
||||
<input type="date" value={enrichStartDate} onChange={(e) => onChangeStartDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
|
||||
结束日期
|
||||
<input type="date" value={enrichEndDate} onChange={(e) => onChangeEndDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button onClick={onToggleForce} style={toggleButtonStyle(forceEnrich, '#b91c1c')}>
|
||||
{forceEnrich ? '覆盖已有分析' : '仅补缺失'}
|
||||
</button>
|
||||
<button onClick={onToggleOnlyLocalToLlm} style={toggleButtonStyle(onlyLocalToLlm, '#7c3aed')}>
|
||||
{onlyLocalToLlm ? '仅将规则分析升级为 LLM分析' : '不限制分析来源'}
|
||||
</button>
|
||||
<button onClick={onToggleRebuildStory} style={toggleButtonStyle(rebuildStory, '#2563eb')}>
|
||||
{rebuildStory ? '重建主线叙事' : '跳过主线叙事'}
|
||||
</button>
|
||||
<button onClick={onToggleRebuildSimilarDays} style={toggleButtonStyle(rebuildSimilarDays, '#15803d')}>
|
||||
{rebuildSimilarDays ? '重建相似交易日' : '跳过相似交易日'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onRunEnrich}
|
||||
disabled={isRunning || !selectedSymbol || !enrichStartDate || !enrichEndDate}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isRunning ? '#d1d5db' : '#111111',
|
||||
color: '#ffffff',
|
||||
padding: '9px 14px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: isRunning ? 'wait' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isRunning ? '执行中...' : '重新分析当前区间'}
|
||||
</button>
|
||||
{maintenanceStatus?.updatedAt ? (
|
||||
<span style={{ fontSize: 11, color: '#666666' }}>
|
||||
最近一次执行: {maintenanceStatus.updatedAt}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{maintenanceStatus?.error ? (
|
||||
<div style={{ fontSize: 11, color: '#991b1b', lineHeight: 1.7 }}>
|
||||
执行失败: {maintenanceStatus.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
|
||||
{[
|
||||
['新闻总数', stats.news_count],
|
||||
['待处理', stats.queued_count],
|
||||
['已分析', stats.analyzed],
|
||||
['已跳过', stats.skipped_existing_count],
|
||||
['去重数', stats.deduped_count],
|
||||
['LLM分析', stats.llm_count],
|
||||
['规则分析', stats.local_count],
|
||||
['升级数', stats.upgraded_local_to_llm_count],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} style={{ border: '1px solid #111111', padding: 10 }}>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>{value ?? '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div style={{ border: '1px solid #111111', padding: 12, fontSize: 11, lineHeight: 1.8 }}>
|
||||
{summary.upgraded_dates?.length ? (
|
||||
<div><strong>升级日期:</strong> {summary.upgraded_dates.join(', ')}</div>
|
||||
) : null}
|
||||
{summary.remaining_local_titles?.length ? (
|
||||
<div><strong>仍为规则分析:</strong> {summary.remaining_local_titles.join(' / ')}</div>
|
||||
) : null}
|
||||
{typeof summary.skipped_non_local_count === 'number' ? (
|
||||
<div><strong>跳过非规则分析:</strong> {summary.skipped_non_local_count}</div>
|
||||
) : null}
|
||||
{typeof summary.skipped_missing_analysis_count === 'number' ? (
|
||||
<div><strong>跳过无历史分析:</strong> {summary.skipped_missing_analysis_count}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(maintenanceHistory) && maintenanceHistory.length > 0 ? (
|
||||
<div style={{ border: '1px solid #111111', padding: 12, display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700 }}>最近刷新历史</div>
|
||||
{maintenanceHistory.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={`${item.timestamp || 'history'}-${index}`}
|
||||
style={{
|
||||
borderTop: index === 0 ? 'none' : '1px solid #e5e7eb',
|
||||
paddingTop: index === 0 ? 0 : 8,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{item.startDate || '-'}</strong> ~ <strong>{item.endDate || '-'}</strong>
|
||||
{' · '}
|
||||
{item.onlyLocalToLlm ? '规则分析→LLM分析' : item.force ? '覆盖重跑' : '补缺失'}
|
||||
{item.storyStatus ? ' · 主线叙事' : ''}
|
||||
{item.similarStatus ? ' · 相似交易日' : ''}
|
||||
</div>
|
||||
<div style={{ color: item.error ? '#991b1b' : '#4b5563' }}>
|
||||
{item.timestamp || '-'}
|
||||
{item.error
|
||||
? ` · 失败: ${item.error}`
|
||||
: ` · 已分析 ${item.stats?.analyzed ?? 0},已升级 ${item.stats?.upgraded_local_to_llm_count ?? 0}`}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => onSelectHistory?.(item)}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: '#ffffff',
|
||||
color: '#111111',
|
||||
padding: '4px 8px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
回填到表单
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReplayHistory?.(item)}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
border: '1px solid #111111',
|
||||
background: '#111111',
|
||||
color: '#ffffff',
|
||||
padding: '4px 8px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
直接重跑
|
||||
</button>
|
||||
{!item.error ? (
|
||||
<span style={{ marginLeft: 8, fontSize: 10, color: '#666666' }}>
|
||||
{item.stats?.execution_summary?.upgraded_dates?.length
|
||||
? `升级日 ${item.stats.execution_summary.upgraded_dates.join(', ')}`
|
||||
: '无升级日期摘要'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/explain/ExplainMentionsSection.jsx
Normal file
77
frontend/src/components/explain/ExplainMentionsSection.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainMentionsSection({
|
||||
recentMentions,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">讨论提及</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
从交易讨论和分析 feed 提取
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起讨论摘录' : `展开讨论摘录 ${recentMentions.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentMentions.length === 0 ? (
|
||||
<div className="empty-state">最近没有在讨论里提到这只股票。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">讨论摘录默认收起,需要时再展开查看。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{recentMentions.map((message, index) => (
|
||||
<div
|
||||
key={`${message.feedId || message.id}-${index}`}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#fafafa',
|
||||
padding: 14,
|
||||
minHeight: 150
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: '#000000' }}>{message.agent || '未知角色'}</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>
|
||||
{message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||
{formatDateTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
color: '#000000',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{String(message.content || '')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
320
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
function renderFreshness(freshness) {
|
||||
if (!freshness || typeof freshness !== 'object') return null;
|
||||
const lastFetch = freshness.last_news_fetch || '-';
|
||||
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
|
||||
}
|
||||
|
||||
function categoryLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const labels = {
|
||||
market: '市场交易',
|
||||
policy: '政策监管',
|
||||
earnings: '业绩财报',
|
||||
product_tech: '产品技术',
|
||||
competition: '竞争格局',
|
||||
management: '管理层动态',
|
||||
};
|
||||
return labels[normalized] || value || '';
|
||||
}
|
||||
|
||||
function relevanceLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const labels = {
|
||||
high: '高相关',
|
||||
medium: '中相关',
|
||||
low: '低相关',
|
||||
relevant: '高相关',
|
||||
};
|
||||
return labels[normalized] || value || '';
|
||||
}
|
||||
|
||||
function analysisSourceLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'llm') return 'LLM分析';
|
||||
if (normalized === 'local') return '规则分析';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function sentimentStyle(sentiment) {
|
||||
const normalized = String(sentiment || '').trim().toLowerCase();
|
||||
if (normalized === 'positive') {
|
||||
return { border: '#16a34a', background: '#f0fdf4', color: '#166534', label: '利多' };
|
||||
}
|
||||
if (normalized === 'negative') {
|
||||
return { border: '#dc2626', background: '#fef2f2', color: '#991b1b', label: '利空' };
|
||||
}
|
||||
return { border: '#6b7280', background: '#f9fafb', color: '#4b5563', label: '中性' };
|
||||
}
|
||||
|
||||
export default function ExplainNewsSection({
|
||||
newsSnapshot,
|
||||
visibleNewsByCategory,
|
||||
visibleNews,
|
||||
selectedNewsFreshness,
|
||||
activeNewsCategory,
|
||||
onSelectNewsCategory,
|
||||
activeNewsSentiment,
|
||||
onSelectNewsSentiment,
|
||||
newsCategories,
|
||||
tickerNews,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">新闻面板</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`}
|
||||
</div>
|
||||
{renderFreshness(selectedNewsFreshness) ? (
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{renderFreshness(selectedNewsFreshness)}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起新闻面板' : '展开新闻面板'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">新闻面板已收起,需要时再展开查看分类、情绪和新闻卡片。</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<button
|
||||
onClick={() => onSelectNewsCategory('all')}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: activeNewsCategory === 'all' ? '#111111' : '#ffffff',
|
||||
color: activeNewsCategory === 'all' ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
全部 {visibleNews.length}
|
||||
</button>
|
||||
{Object.entries(newsCategories)
|
||||
.filter(([, meta]) => Number(meta?.count || 0) > 0)
|
||||
.map(([key, meta]) => {
|
||||
const isActive = activeNewsCategory === key;
|
||||
const pos = Number(meta?.positive_ids?.length || 0);
|
||||
const neg = Number(meta?.negative_ids?.length || 0);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelectNewsCategory(key)}
|
||||
style={{
|
||||
border: '1px solid #2563eb',
|
||||
background: isActive ? '#2563eb' : '#ffffff',
|
||||
color: isActive ? '#ffffff' : '#2563eb',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{categoryLabel(meta.label || key)} {meta.count}{pos || neg ? ` · +${pos}/-${neg}` : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
{[
|
||||
{ key: 'all', label: '全部情绪' },
|
||||
{ key: 'positive', label: '利多' },
|
||||
{ key: 'negative', label: '利空' },
|
||||
{ key: 'neutral', label: '中性' }
|
||||
].map((item) => {
|
||||
const isActive = activeNewsSentiment === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => onSelectNewsSentiment(item.key)}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isActive ? '#111111' : '#ffffff',
|
||||
color: isActive ? '#ffffff' : '#111111',
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tickerNews.length === 0 ? (
|
||||
<div className="empty-state">当前数据源没有返回相关新闻</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{visibleNewsByCategory.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
minHeight: 180
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const sentimentMeta = sentimentStyle(item.sentiment);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${sentimentMeta.border}`,
|
||||
background: sentimentMeta.background,
|
||||
color: sentimentMeta.color,
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{sentimentMeta.label}
|
||||
</span>
|
||||
{item.relevance ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{relevanceLabel(item.relevance)}
|
||||
</span>
|
||||
) : null}
|
||||
{item.analysisSource ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #6b7280',
|
||||
color: '#4b5563',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{analysisSourceLabel(item.analysisSource)}
|
||||
</span>
|
||||
) : null}
|
||||
{item.analysisModelLabel ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #9ca3af',
|
||||
color: '#374151',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{item.analysisModelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof item.retT0 === 'number' ? (
|
||||
<span style={{ fontSize: 10, color: item.retT0 >= 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}>
|
||||
T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{item.category ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{categoryLabel(item.category)}
|
||||
</span>
|
||||
) : null}
|
||||
<strong style={{ fontSize: 13 }}>{item.title}</strong>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||
{formatDateTime(item.date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||
{item.source}
|
||||
</span>
|
||||
{item.related ? (
|
||||
<span style={{ fontSize: 10, color: '#666666' }}>
|
||||
关联: {item.related}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{item.summary || '该新闻没有可用摘要。'}
|
||||
</div>
|
||||
|
||||
{item.keyDiscussion ? (
|
||||
<div style={{ marginTop: 10, fontSize: 11, lineHeight: 1.7, color: '#374151' }}>
|
||||
<strong>核心讨论:</strong> {item.keyDiscussion}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.reasonGrowth ? (
|
||||
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#166534' }}>
|
||||
<strong>利多逻辑:</strong> {item.reasonGrowth}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.reasonDecrease ? (
|
||||
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#991b1b' }}>
|
||||
<strong>利空逻辑:</strong> {item.reasonDecrease}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.url ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ fontSize: 11, fontWeight: 700, color: '#111111', textDecoration: 'underline' }}
|
||||
>
|
||||
查看原文
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
frontend/src/components/explain/ExplainPriceSection.jsx
Normal file
206
frontend/src/components/explain/ExplainPriceSection.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import { formatTickerPrice } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainPriceSection({
|
||||
ohlcSeries,
|
||||
priceSeries,
|
||||
selectedHistorySource,
|
||||
chartModel,
|
||||
selectedTicker,
|
||||
onSelectEventDate,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const timeTicks = (() => {
|
||||
const candles = Array.isArray(chartModel?.candles) ? chartModel.candles : [];
|
||||
if (!candles.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetCount = Math.min(4, candles.length);
|
||||
const step = Math.max(1, Math.floor((candles.length - 1) / Math.max(targetCount - 1, 1)));
|
||||
const ticks = [];
|
||||
|
||||
for (let index = 0; index < candles.length; index += step) {
|
||||
const candle = candles[index];
|
||||
const rawLabel = candle.startLabel || candle.time || candle.date || '';
|
||||
ticks.push({
|
||||
x: candle.centerX,
|
||||
label: String(rawLabel).slice(5, 16).replace('T', ' '),
|
||||
});
|
||||
}
|
||||
|
||||
const lastCandle = candles[candles.length - 1];
|
||||
const lastLabel = String(lastCandle.endLabel || lastCandle.time || lastCandle.date || '').slice(5, 16).replace('T', ' ');
|
||||
if (ticks.length === 0 || ticks[ticks.length - 1]?.x !== lastCandle.centerX) {
|
||||
ticks.push({
|
||||
x: lastCandle.centerX,
|
||||
label: lastLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return ticks;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">价格与事件</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{ohlcSeries.length > 1
|
||||
? `最近 ${ohlcSeries.length} 根日线K线${selectedHistorySource ? ` · ${selectedHistorySource}` : ''}`
|
||||
: `最近 ${priceSeries.length} 个价格点聚合为 ${chartModel.bucketCount || 0} 根简化K线`}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起价格区' : '展开价格区'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ohlcSeries.length === 0 && priceSeries.length === 0 ? (
|
||||
<div className="empty-state">当前还没有可绘制的价格历史</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">价格区已收起,需要时再展开查看图表和事件点。</div>
|
||||
) : (
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${chartModel.width} ${chartModel.height}`}
|
||||
style={{ width: '100%', height: '220px', display: 'block', overflow: 'visible' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="stockExplainFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(0,0,0,0.18)" />
|
||||
<stop offset="100%" stopColor="rgba(0,0,0,0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={chartModel.width} height={chartModel.height} fill="#fafafa" />
|
||||
<line
|
||||
x1={chartModel.padding}
|
||||
y1={chartModel.height - chartModel.padding}
|
||||
x2={chartModel.width - chartModel.padding}
|
||||
y2={chartModel.height - chartModel.padding}
|
||||
stroke="#000000"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{timeTicks.map((tick) => (
|
||||
<g key={`${tick.x}-${tick.label}`}>
|
||||
<line
|
||||
x1={tick.x}
|
||||
y1={chartModel.height - chartModel.padding}
|
||||
x2={tick.x}
|
||||
y2={chartModel.height - chartModel.padding + 4}
|
||||
stroke="#666666"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={tick.x}
|
||||
y={chartModel.height - chartModel.padding + 16}
|
||||
fontSize="10"
|
||||
fill="#666666"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{tick.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
|
||||
const rising = candle.close >= candle.open;
|
||||
const stroke = rising ? '#00C853' : '#FF1744';
|
||||
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
||||
return (
|
||||
<g key={candle.id}>
|
||||
<title>{`${candle.startLabel || candle.time || candle.date || ''} → ${candle.endLabel || candle.time || candle.date || ''}`}</title>
|
||||
<line
|
||||
x1={candle.centerX}
|
||||
y1={candle.highY}
|
||||
x2={candle.centerX}
|
||||
y2={candle.lowY}
|
||||
stroke={stroke}
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
<rect
|
||||
x={candle.x}
|
||||
y={candle.bodyY}
|
||||
width={candle.width}
|
||||
height={candle.bodyHeight}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}) : chartModel.path && (
|
||||
<>
|
||||
<path d={`${chartModel.path} L${chartModel.width - chartModel.padding},${chartModel.height - chartModel.padding} L${chartModel.padding},${chartModel.height - chartModel.padding} Z`} fill="url(#stockExplainFill)" />
|
||||
<path d={chartModel.path} fill="none" stroke="#000000" strokeWidth="2.5" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{chartModel.markers.map((marker) => {
|
||||
const fill = marker.tone === 'positive'
|
||||
? '#00C853'
|
||||
: marker.tone === 'negative'
|
||||
? '#FF1744'
|
||||
: marker.tone === 'news'
|
||||
? '#2563eb'
|
||||
: '#000000';
|
||||
return (
|
||||
<g
|
||||
key={marker.id}
|
||||
onClick={() => onSelectEventDate(marker.dateKey)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<line x1={marker.x} y1={marker.y} x2={marker.x} y2={chartModel.height - chartModel.padding} stroke={fill} strokeDasharray="3 3" strokeWidth="1" />
|
||||
<circle
|
||||
cx={marker.x}
|
||||
cy={marker.y}
|
||||
r={marker.markerType === 'news'
|
||||
? (marker.isSelected ? '5.5' : '4')
|
||||
: (marker.isSelected ? '6' : '4.5')}
|
||||
fill={fill}
|
||||
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
||||
/>
|
||||
<title>{`${marker.title} · ${marker.timestamp || marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<text x={chartModel.padding} y="14" fontSize="11" fill="#666666">
|
||||
{chartModel.maxPrice != null ? `高点 $${formatTickerPrice(chartModel.maxPrice)}` : ''}
|
||||
</text>
|
||||
<text x={chartModel.padding} y={chartModel.height - 6} fontSize="11" fill="#666666">
|
||||
{chartModel.minPrice != null ? `低点 $${formatTickerPrice(chartModel.minPrice)}` : ''}
|
||||
</text>
|
||||
<text x={chartModel.width - chartModel.padding} y="14" fontSize="11" fill="#666666" textAnchor="end">
|
||||
{selectedTicker?.price != null ? `现价 $${formatTickerPrice(selectedTicker.price)}` : ''}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
图表说明:{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#2563eb' }}>蓝点:新闻日期</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/explain/ExplainRangeSection.jsx
Normal file
231
frontend/src/components/explain/ExplainRangeSection.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React from 'react';
|
||||
import { formatTickerPrice } from '../../utils/formatters';
|
||||
|
||||
function renderFreshness(freshness) {
|
||||
if (!freshness || typeof freshness !== 'object') return null;
|
||||
const lastFetch = freshness.last_news_fetch || '-';
|
||||
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
|
||||
}
|
||||
|
||||
function renderSentimentLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'positive') return '利多';
|
||||
if (normalized === 'negative') return '利空';
|
||||
if (normalized === 'neutral') return '中性';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function renderCategoryLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const labels = {
|
||||
market: '市场交易',
|
||||
policy: '政策监管',
|
||||
earnings: '业绩财报',
|
||||
product_tech: '产品技术',
|
||||
competition: '竞争格局',
|
||||
management: '管理层动态',
|
||||
};
|
||||
return labels[normalized] || value || '';
|
||||
}
|
||||
|
||||
function renderAnalysisSourceLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'llm') return 'LLM分析';
|
||||
if (normalized === 'local') return '规则分析';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function MetricRow({ label, value, valueColor = '#111111' }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, gap: 12 }}>
|
||||
<span style={{ color: '#4b5563' }}>{label}</span>
|
||||
<strong style={{ color: valueColor, textAlign: 'right' }}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({ items, tone = 'neutral', emptyText }) {
|
||||
const palette = {
|
||||
positive: { border: '#86efac', background: '#f0fdf4', color: '#166534' },
|
||||
negative: { border: '#fca5a5', background: '#fef2f2', color: '#991b1b' },
|
||||
neutral: { border: '#d1d5db', background: '#f9fafb', color: '#374151' },
|
||||
};
|
||||
const colors = palette[tone] || palette.neutral;
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${tone}-${index}-${item}`}
|
||||
style={{
|
||||
border: `1px solid ${colors.border}`,
|
||||
background: colors.background,
|
||||
color: colors.color,
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExplainRangeSection({
|
||||
selectedRangeWindow,
|
||||
selectedRangeExplain,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">区间涨跌分析</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedRangeWindow
|
||||
? `${selectedRangeWindow.startDate} ~ ${selectedRangeWindow.endDate}`
|
||||
: '先在图上选择一个事件日期'}
|
||||
</div>
|
||||
{selectedRangeExplain?.analysis?.analysis_source ? (
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedRangeExplain.analysis.analysis_source === 'llm'
|
||||
? `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)} · ${selectedRangeExplain.analysis.analysis_model_label || 'LLM'}`
|
||||
: `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)}`}
|
||||
</div>
|
||||
) : null}
|
||||
{renderFreshness(selectedRangeExplain?.freshness) ? (
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{renderFreshness(selectedRangeExplain?.freshness)}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起区间涨跌分析' : '展开区间涨跌分析'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedRangeWindow ? (
|
||||
<div className="empty-state">选择图上的日期后,会自动生成最近 7 天的区间涨跌分析。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">区间涨跌分析已收起,需要时再展开查看摘要和快照。</div>
|
||||
) : !selectedRangeExplain ? (
|
||||
<div className="empty-state">正在生成区间涨跌分析...</div>
|
||||
) : selectedRangeExplain.error ? (
|
||||
<div className="empty-state">{selectedRangeExplain.error}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
区间摘要
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
|
||||
{selectedRangeExplain.analysis?.summary || '暂无区间摘要'}
|
||||
</div>
|
||||
{selectedRangeExplain.analysis?.trend_analysis ? (
|
||||
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.7, color: '#4b5563' }}>
|
||||
<strong>趋势拆解:</strong> {selectedRangeExplain.analysis.trend_analysis}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 14, display: 'grid', gap: 8 }}>
|
||||
{(selectedRangeExplain.analysis?.key_events || []).slice(0, 6).map((event, index) => (
|
||||
<div key={`${event.id || event.title}-${index}`} style={{ borderTop: index === 0 ? 'none' : '1px solid #e5e7eb', paddingTop: index === 0 ? 0 : 8 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 4 }}>
|
||||
{event.date || '-'} {event.category ? `· ${renderCategoryLabel(event.category)}` : ''} {event.sentiment ? `· ${renderSentimentLabel(event.sentiment)}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 4 }}>{event.title}</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6 }}>{event.summary || '暂无摘要'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
区间快照
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>事实概览</div>
|
||||
<MetricRow
|
||||
label="区间涨跌"
|
||||
value={`${Number(selectedRangeExplain.price_change_pct) >= 0 ? '+' : ''}${Number(selectedRangeExplain.price_change_pct || 0).toFixed(2)}%`}
|
||||
valueColor={Number(selectedRangeExplain.price_change_pct) >= 0 ? '#00C853' : '#FF1744'}
|
||||
/>
|
||||
<MetricRow label="关联新闻" value={selectedRangeExplain.news_count || 0} />
|
||||
<MetricRow label="区间高点" value={`$${formatTickerPrice(selectedRangeExplain.high_price)}`} />
|
||||
<MetricRow label="区间低点" value={`$${formatTickerPrice(selectedRangeExplain.low_price)}`} />
|
||||
<MetricRow label="交易日数" value={selectedRangeExplain.trading_days || 0} />
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>主题分布</div>
|
||||
{(selectedRangeExplain.dominant_categories || []).length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRangeExplain.dominant_categories.map((item) => (
|
||||
<div
|
||||
key={`${item.category}-${item.count}`}
|
||||
style={{
|
||||
border: '1px solid #d1d5db',
|
||||
background: '#f9fafb',
|
||||
color: '#374151',
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{renderCategoryLabel(item.category)} · {item.count}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>
|
||||
当前没有识别出明显的主题聚类。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>驱动因素</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#166534' }}>利多因素</div>
|
||||
<TagList
|
||||
items={selectedRangeExplain.analysis?.bullish_factors || []}
|
||||
tone="positive"
|
||||
emptyText="当前区间内未提炼出明确的利多因素。"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#991b1b' }}>利空因素</div>
|
||||
<TagList
|
||||
items={selectedRangeExplain.analysis?.bearish_factors || []}
|
||||
tone="negative"
|
||||
emptyText="当前区间内未提炼出明确的利空因素。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/explain/ExplainSignalsSection.jsx
Normal file
123
frontend/src/components/explain/ExplainSignalsSection.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ExplainSignalsSection({
|
||||
tickerSignals,
|
||||
signalSummary,
|
||||
latestSignal,
|
||||
eventDateKey,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析师观点</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
最近 {tickerSignals.length} 条相关信号
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起分析师观点' : '展开分析师观点'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">分析师观点已收起,需要时再展开查看信号统计和明细。</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stats-grid" style={{ marginBottom: 16 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">看涨</div>
|
||||
<div className="stat-card-value positive">{signalSummary.bullish}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">看跌</div>
|
||||
<div className="stat-card-value negative">{signalSummary.bearish}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">中性</div>
|
||||
<div className="stat-card-value">{signalSummary.neutral}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">最新结论</div>
|
||||
<div className="stat-card-value" style={{ fontSize: 22 }}>
|
||||
{latestSignal
|
||||
? latestSignal.normalizedDirection === 'bullish'
|
||||
? '偏多'
|
||||
: latestSignal.normalizedDirection === 'bearish'
|
||||
? '偏空'
|
||||
: '观望'
|
||||
: '暂无'}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
|
||||
{latestSignal ? `${latestSignal.agentName} · ${latestSignal.date || eventDateKey(latestSignal.timestamp)}` : '还没有历史信号'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tickerSignals.length === 0 ? (
|
||||
<div className="empty-state">该股票还没有分析师信号记录</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>分析师</th>
|
||||
<th>方向</th>
|
||||
<th>实际收益</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickerSignals.slice(0, 8).map((signal, index) => {
|
||||
const realReturn = typeof signal.real_return === 'number'
|
||||
? `${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
|
||||
: '未判定';
|
||||
const status = signal.is_correct === true ? '命中' : signal.is_correct === false ? '未命中' : '待判定';
|
||||
const directionText = signal.normalizedDirection === 'bullish'
|
||||
? '看涨'
|
||||
: signal.normalizedDirection === 'bearish'
|
||||
? '看跌'
|
||||
: '中性';
|
||||
const directionColor = signal.normalizedDirection === 'bullish'
|
||||
? '#00C853'
|
||||
: signal.normalizedDirection === 'bearish'
|
||||
? '#FF1744'
|
||||
: '#666666';
|
||||
|
||||
return (
|
||||
<tr key={signal.id || `${signal.agentId}-${signal.date}-${index}`}>
|
||||
<td>{signal.date || eventDateKey(signal.timestamp) || '-'}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 700 }}>{signal.agentName}</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{signal.role}</div>
|
||||
</td>
|
||||
<td style={{ color: directionColor, fontWeight: 700 }}>{directionText}</td>
|
||||
<td>{realReturn}</td>
|
||||
<td>{status}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
frontend/src/components/explain/ExplainSimilarDaysSection.jsx
Normal file
122
frontend/src/components/explain/ExplainSimilarDaysSection.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
|
||||
function renderFreshness(freshness) {
|
||||
if (!freshness || typeof freshness !== 'object') return null;
|
||||
const lastFetch = freshness.last_news_fetch || '-';
|
||||
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
|
||||
}
|
||||
|
||||
export default function ExplainSimilarDaysSection({
|
||||
selectedSimilarDays,
|
||||
selectedEventDate,
|
||||
onSelectSimilarDate,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">历史相似交易日</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedEventDate || '先选择一个事件日期'}
|
||||
</div>
|
||||
{renderFreshness(selectedSimilarDays?.freshness) ? (
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{renderFreshness(selectedSimilarDays?.freshness)}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起相似交易日' : '展开相似交易日'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedEventDate ? (
|
||||
<div className="empty-state">选择图上的日期后,会检索这只股票历史上的相似交易日。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">相似交易日默认收起,需要时再展开查看。</div>
|
||||
) : !selectedSimilarDays ? (
|
||||
<div className="empty-state">正在检索相似交易日...</div>
|
||||
) : selectedSimilarDays.error ? (
|
||||
<div className="empty-state">{selectedSimilarDays.error}</div>
|
||||
) : !Array.isArray(selectedSimilarDays.items) || selectedSimilarDays.items.length === 0 ? (
|
||||
<div className="empty-state">当前没有足够历史样本来计算相似交易日。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
目标日快照
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>新闻数量</div>
|
||||
<strong>{selectedSimilarDays.target_features?.n_articles ?? 0}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>情绪分数</div>
|
||||
<strong>{Number(selectedSimilarDays.target_features?.sentiment_score ?? 0).toFixed(2)}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>前一日涨跌</div>
|
||||
<strong>{Number(selectedSimilarDays.target_features?.ret_1d ?? 0).toFixed(2)}%</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>高相关新闻</div>
|
||||
<strong>{selectedSimilarDays.target_features?.high_relevance_count ?? 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||
{selectedSimilarDays.items.map((item) => (
|
||||
<button
|
||||
key={item.date}
|
||||
onClick={() => onSelectSimilarDate?.(item.date)}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: 13 }}>{item.date}</strong>
|
||||
<span style={{ fontSize: 11, color: '#666666' }}>
|
||||
相似度 {(Number(item.score || 0) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 12, marginBottom: 10 }}>
|
||||
<div>新闻数 {item.n_articles ?? 0}</div>
|
||||
<div>情绪分数 {Number(item.sentiment_score ?? 0).toFixed(2)}</div>
|
||||
<div>前一日涨跌 {Number(item.ret_1d ?? 0).toFixed(2)}%</div>
|
||||
<div>次日表现 {item.ret_t1_after != null ? `${item.ret_t1_after >= 0 ? '+' : ''}${Number(item.ret_t1_after).toFixed(2)}%` : '-'}</div>
|
||||
<div>三日表现 {item.ret_t3_after != null ? `${item.ret_t3_after >= 0 ? '+' : ''}${Number(item.ret_t3_after).toFixed(2)}%` : '-'}</div>
|
||||
</div>
|
||||
{(item.top_reasons || []).length > 0 ? (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.7, color: '#4b5563' }}>
|
||||
<strong>主要线索:</strong> {item.top_reasons.join(' / ')}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/explain/ExplainStorySection.jsx
Normal file
69
frontend/src/components/explain/ExplainStorySection.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
function renderFreshness(freshness) {
|
||||
if (!freshness || typeof freshness !== 'object') return null;
|
||||
const lastFetch = freshness.last_news_fetch || '-';
|
||||
return `新闻更新到 ${lastFetch}${freshness.refreshed ? ' · 本次已刷新' : ''}`;
|
||||
}
|
||||
|
||||
export default function ExplainStorySection({
|
||||
selectedStory,
|
||||
selectedSymbol,
|
||||
currentDate,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">主线叙事</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedStory?.asOfDate || currentDate || '按当前解释窗口生成'}
|
||||
</div>
|
||||
{renderFreshness(selectedStory?.freshness) ? (
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{renderFreshness(selectedStory?.freshness)}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起主线叙事' : '展开主线叙事'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedSymbol ? (
|
||||
<div className="empty-state">先选择一只股票</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">主线叙事默认收起,需要时再展开查看完整叙事。</div>
|
||||
) : !selectedStory?.story ? (
|
||||
<div className="empty-state">正在生成主线叙事...</div>
|
||||
) : (
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 18 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
{selectedStory?.source ? `来源 · ${selectedStory.source}` : '自动生成'}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{selectedStory.story}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/explain/ExplainSummarySection.jsx
Normal file
77
frontend/src/components/explain/ExplainSummarySection.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ExplainSummarySection({
|
||||
explainSummary,
|
||||
tickerTrades,
|
||||
tickerNews,
|
||||
selectedSymbol,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析摘要</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
基于当前持仓、成交和新闻自动汇总
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起分析摘要' : '展开分析摘要'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">分析摘要已收起,需要时再展开查看概览和密度信息。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
当前解释
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{explainSummary.map((line, index) => (
|
||||
<div key={`${selectedSymbol}-summary-${index}`} style={{ fontSize: 13, lineHeight: 1.7, color: '#000000' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
分析概览
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span>成交记录</span>
|
||||
<strong>{tickerTrades.length}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span>新闻条目</span>
|
||||
<strong>{tickerNews.length}</strong>
|
||||
</div>
|
||||
<div style={{ height: 1, background: '#e0e0e0', margin: '4px 0' }} />
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#666666' }}>
|
||||
当前分析综合读取信号、成交、新闻与已生成的解释结果。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
frontend/src/components/explain/ExplainTechnicalSection.jsx
Normal file
309
frontend/src/components/explain/ExplainTechnicalSection.jsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React from 'react';
|
||||
import { formatNumber } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainTechnicalSection({
|
||||
technicalIndicators,
|
||||
selectedSymbol,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const formatPct = (value) => {
|
||||
if (value == null) return '-';
|
||||
return `${value >= 0 ? '+' : ''}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const formatPrice = (value) => {
|
||||
if (value == null) return '-';
|
||||
return `$${value.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const rsiStatusColor = (status) => {
|
||||
if (status === 'oversold') return '#00C853';
|
||||
if (status === 'overbought') return '#FF1744';
|
||||
return '#666666';
|
||||
};
|
||||
|
||||
const riskColor = (level) => {
|
||||
if (level === 'HIGH RISK') return '#FF1744';
|
||||
if (level === 'MODERATE RISK') return '#FF9800';
|
||||
return '#00C853';
|
||||
};
|
||||
|
||||
if (!technicalIndicators) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">技术指标</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
加载中...
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起' : '展开'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="empty-state">正在加载技术指标数据...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">技术指标</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{technicalIndicators.trend} · {technicalIndicators.mean_reversion}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起技术指标' : '展开技术指标'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">点击展开查看技术指标详情</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
||||
{/* MA Section */}
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
移动平均线
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>MA5</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma5)}</span>
|
||||
<span style={{ color: technicalIndicators.ma?.distance?.ma5 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
|
||||
{formatPct(technicalIndicators.ma?.distance?.ma5)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>MA10</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma10)}</span>
|
||||
<span style={{ color: technicalIndicators.ma?.distance?.ma10 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
|
||||
{formatPct(technicalIndicators.ma?.distance?.ma10)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>MA20</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma20)}</span>
|
||||
<span style={{ color: technicalIndicators.ma?.distance?.ma20 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
|
||||
{formatPct(technicalIndicators.ma?.distance?.ma20)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>MA50</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma50)}</span>
|
||||
<span style={{ color: technicalIndicators.ma?.distance?.ma50 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
|
||||
{formatPct(technicalIndicators.ma?.distance?.ma50)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>MA200</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.ma?.ma200)}</span>
|
||||
<span style={{ color: technicalIndicators.ma?.distance?.ma200 > 0 ? '#00C853' : '#FF1744', fontWeight: 700 }}>
|
||||
{formatPct(technicalIndicators.ma?.distance?.ma200)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RSI Section */}
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
RSI (14)
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: rsiStatusColor(technicalIndicators.rsi?.status) }}>
|
||||
{technicalIndicators.rsi?.rsi14?.toFixed(1) || '-'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{
|
||||
padding: '2px 8px',
|
||||
background: technicalIndicators.rsi?.status === 'oversold' ? '#E8F5E9' :
|
||||
technicalIndicators.rsi?.status === 'overbought' ? '#FFEBEE' : '#F5F5F5',
|
||||
color: rsiStatusColor(technicalIndicators.rsi?.status),
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
borderRadius: 4
|
||||
}}>
|
||||
{technicalIndicators.rsi?.status === 'oversold' ? '超卖' :
|
||||
technicalIndicators.rsi?.status === 'overbought' ? '超买' : '中性'}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>
|
||||
<30 超卖 >70 超买
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* RSI Gauge */}
|
||||
<div style={{ marginTop: 12, height: 8, background: '#E0E0E0', borderRadius: 4, position: 'relative' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
width: `${Math.min(100, Math.max(0, technicalIndicators.rsi?.rsi14 || 0))}%`,
|
||||
height: '100%',
|
||||
background: rsiStatusColor(technicalIndicators.rsi?.status),
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.3s'
|
||||
}} />
|
||||
<div style={{ position: 'absolute', left: '30%', top: -4, width: 1, height: 16, background: '#00C853' }} />
|
||||
<div style={{ position: 'absolute', left: '70%', top: -4, width: 1, height: 16, background: '#FF1744' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MACD Section */}
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
MACD
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>MACD 线</span>
|
||||
<span style={{ fontWeight: 600, color: technicalIndicators.macd?.macd > 0 ? '#00C853' : '#FF1744' }}>
|
||||
{formatPrice(technicalIndicators.macd?.macd)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>Signal 线</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.macd?.signal)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>柱状图</span>
|
||||
<span style={{ fontWeight: 600, color: technicalIndicators.macd?.histogram > 0 ? '#00C853' : '#FF1744' }}>
|
||||
{formatPrice(technicalIndicators.macd?.histogram)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bollinger Bands Section */}
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
布林带
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>上轨</span>
|
||||
<span style={{ fontWeight: 600, color: '#FF1744' }}>
|
||||
{formatPrice(technicalIndicators.bollinger?.upper)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>中轨</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPrice(technicalIndicators.bollinger?.mid)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>下轨</span>
|
||||
<span style={{ fontWeight: 600, color: '#00C853' }}>
|
||||
{formatPrice(technicalIndicators.bollinger?.lower)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volatility Section */}
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
波动率
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>10日</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPct(technicalIndicators.volatility?.vol_10d)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>20日</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPct(technicalIndicators.volatility?.vol_20d)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>60日</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatPct(technicalIndicators.volatility?.vol_60d)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 8, paddingTop: 8, borderTop: '1px solid #E0E0E0' }}>
|
||||
<span style={{ color: '#666666' }}>年化波动率</span>
|
||||
<span style={{ fontWeight: 700 }}>{formatPct(technicalIndicators.volatility?.annualized)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#666666' }}>风险等级</span>
|
||||
<span style={{
|
||||
fontWeight: 700,
|
||||
color: riskColor(technicalIndicators.volatility?.risk_level)
|
||||
}}>
|
||||
{technicalIndicators.volatility?.risk_level || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trend Summary */}
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
趋势判断
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
padding: '4px 12px',
|
||||
background: technicalIndicators.trend?.includes('BULLISH') ? '#E8F5E9' :
|
||||
technicalIndicators.trend?.includes('BEARISH') ? '#FFEBEE' : '#F5F5F5',
|
||||
color: technicalIndicators.trend?.includes('BULLISH') ? '#00C853' :
|
||||
technicalIndicators.trend?.includes('BEARISH') ? '#FF1744' : '#666666',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
borderRadius: 4
|
||||
}}>
|
||||
{technicalIndicators.trend || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
padding: '4px 12px',
|
||||
background: technicalIndicators.mean_reversion?.includes('OVERBOUGHT') ? '#FFEBEE' :
|
||||
technicalIndicators.mean_reversion?.includes('OVERSOLD') ? '#E8F5E9' : '#F5F5F5',
|
||||
color: technicalIndicators.mean_reversion?.includes('OVERBOUGHT') ? '#FF1744' :
|
||||
technicalIndicators.mean_reversion?.includes('OVERSOLD') ? '#00C853' : '#666666',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
borderRadius: 4
|
||||
}}>
|
||||
{technicalIndicators.mean_reversion || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#666666', marginTop: 4 }}>
|
||||
当前价格: {formatPrice(technicalIndicators.current_price)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/explain/ExplainTradesSection.jsx
Normal file
74
frontend/src/components/explain/ExplainTradesSection.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainTradesSection({
|
||||
tickerTrades,
|
||||
selectedSymbol,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const sideLabel = (value) => {
|
||||
if (value === 'LONG') return '做多';
|
||||
if (value === 'SHORT') return '做空';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">成交记录</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{tickerTrades.length} 笔与 {selectedSymbol} 相关的交易
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起成交记录' : `展开成交记录 ${tickerTrades.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tickerTrades.length === 0 ? (
|
||||
<div className="empty-state">该股票暂无成交记录</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">成交记录默认收起,需要时再展开查看。</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>方向</th>
|
||||
<th>数量</th>
|
||||
<th>价格</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickerTrades.slice(0, 10).map((trade, index) => (
|
||||
<tr key={trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}>
|
||||
<td>{formatDateTime(trade.timestamp)}</td>
|
||||
<td style={{ fontWeight: 700, color: trade.side === 'LONG' ? '#00C853' : trade.side === 'SHORT' ? '#FF1744' : '#000000' }}>
|
||||
{sideLabel(trade.side)}
|
||||
</td>
|
||||
<td>{trade.qty}</td>
|
||||
<td>${Number(trade.price).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
frontend/src/components/explain/explainUtils.js
Normal file
281
frontend/src/components/explain/explainUtils.js
Normal file
@@ -0,0 +1,281 @@
|
||||
export function normalizeSignalDirection(signal) {
|
||||
const value = String(signal || '').trim().toLowerCase();
|
||||
if (!value) return 'neutral';
|
||||
if (value.includes('bull') || value === 'long' || value === 'buy') return 'bullish';
|
||||
if (value.includes('bear') || value === 'short' || value === 'sell') return 'bearish';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function includesTicker(content, ticker) {
|
||||
if (!ticker || typeof content !== 'string') return false;
|
||||
const normalized = ticker.trim().toUpperCase();
|
||||
if (!normalized) return false;
|
||||
return new RegExp(`\\b${normalized}\\b`, 'i').test(content);
|
||||
}
|
||||
|
||||
export function flattenFeedMessages(feed) {
|
||||
if (!Array.isArray(feed)) return [];
|
||||
const items = [];
|
||||
|
||||
feed.forEach((item) => {
|
||||
if (!item || !item.type || !item.data) return;
|
||||
|
||||
if (item.type === 'message' || item.type === 'memory') {
|
||||
items.push({ ...item.data, feedType: item.type, feedId: item.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'conference' && Array.isArray(item.data.messages)) {
|
||||
item.data.messages.forEach((message) => {
|
||||
items.push({
|
||||
...message,
|
||||
feedType: 'conference',
|
||||
feedId: item.id,
|
||||
conferenceTitle: item.data.title
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function snippetText(content, ticker) {
|
||||
const raw = String(content || '').replace(/\s+/g, ' ').trim();
|
||||
if (!raw) return '';
|
||||
const normalizedTicker = String(ticker || '').trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
|
||||
}
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
const idx = upper.indexOf(normalizedTicker);
|
||||
if (idx === -1) {
|
||||
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - 90);
|
||||
const end = Math.min(raw.length, idx + normalizedTicker.length + 130);
|
||||
const snippet = raw.slice(start, end).trim();
|
||||
return `${start > 0 ? '...' : ''}${snippet}${end < raw.length ? '...' : ''}`;
|
||||
}
|
||||
|
||||
export function buildLinePath(points, width, height, padding) {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prices = points.map((point) => Number(point.price)).filter(Number.isFinite);
|
||||
if (!prices.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const span = maxPrice - minPrice || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
|
||||
return points.map((point, index) => {
|
||||
const x = padding + (innerWidth * index) / Math.max(points.length - 1, 1);
|
||||
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
|
||||
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function parsePointTime(point) {
|
||||
const raw = point?.timestamp ?? point?.label;
|
||||
if (!raw) return NaN;
|
||||
const direct = new Date(raw).getTime();
|
||||
if (Number.isFinite(direct)) return direct;
|
||||
return new Date(`${raw}T00:00:00`).getTime();
|
||||
}
|
||||
|
||||
export function aggregatePriceSeriesToCandles(points) {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bucketTarget = points.length >= 36 ? 12 : points.length >= 18 ? 8 : 4;
|
||||
const bucketSize = Math.max(1, Math.ceil(points.length / bucketTarget));
|
||||
const candles = [];
|
||||
|
||||
for (let index = 0; index < points.length; index += bucketSize) {
|
||||
const bucket = points.slice(index, index + bucketSize);
|
||||
const prices = bucket.map((point) => Number(point.price)).filter(Number.isFinite);
|
||||
if (!prices.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candles.push({
|
||||
id: `${bucket[0]?.timestamp || index}-${bucket[bucket.length - 1]?.timestamp || index + bucket.length}`,
|
||||
open: Number(bucket[0].price),
|
||||
high: Math.max(...prices),
|
||||
low: Math.min(...prices),
|
||||
close: Number(bucket[bucket.length - 1].price),
|
||||
startTimestamp: parsePointTime(bucket[0]),
|
||||
endTimestamp: parsePointTime(bucket[bucket.length - 1]),
|
||||
startLabel: bucket[0]?.label || bucket[0]?.timestamp || '',
|
||||
endLabel: bucket[bucket.length - 1]?.label || bucket[bucket.length - 1]?.timestamp || ''
|
||||
});
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
||||
export function eventDateKey(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const parsed = new Date(timestamp);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
return String(timestamp).slice(0, 10);
|
||||
}
|
||||
|
||||
export function resolveEventCategory(event) {
|
||||
if (!event) return 'other';
|
||||
if (event.type === 'trade') return 'trade';
|
||||
if (event.type === 'mention') return 'discussion';
|
||||
if (event.type !== 'signal') return 'other';
|
||||
|
||||
const role = String(event.meta || '').toLowerCase();
|
||||
if (role.includes('technical')) return 'technical';
|
||||
if (role.includes('fundamental')) return 'fundamental';
|
||||
if (role.includes('sentiment')) return 'sentiment';
|
||||
if (role.includes('valuation')) return 'valuation';
|
||||
if (role.includes('risk')) return 'risk';
|
||||
if (role.includes('portfolio')) return 'portfolio';
|
||||
return 'signal';
|
||||
}
|
||||
|
||||
export function normalizeTradeRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const timestamp = row.timestamp || row.ts || row.created_at || null;
|
||||
const ticker = row.ticker || '';
|
||||
const side = row.side || '';
|
||||
const qtyValue = Number(row.qty ?? row.quantity ?? 0);
|
||||
const priceValue = Number(row.price ?? 0);
|
||||
return {
|
||||
id: row.id || `trade-${ticker}-${timestamp || fallbackIndex}-${fallbackIndex}`,
|
||||
timestamp,
|
||||
trading_date: row.trading_date || row.trade_date || null,
|
||||
ticker,
|
||||
side,
|
||||
qty: Number.isFinite(qtyValue) ? qtyValue : 0,
|
||||
price: Number.isFinite(priceValue) ? priceValue : 0
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSignalRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const timestamp = row.timestamp || row.created_at || null;
|
||||
const date = row.date || row.trade_date || eventDateKey(timestamp) || '';
|
||||
const rawSignal = row.signal || row.title || '';
|
||||
const normalizedDirection = normalizeSignalDirection(rawSignal);
|
||||
const confidenceValue = Number(row.confidence);
|
||||
const realReturnValue = Number(row.real_return);
|
||||
const parsedCorrect = typeof row.is_correct === 'string'
|
||||
? row.is_correct.toLowerCase() === 'true'
|
||||
? true
|
||||
: row.is_correct.toLowerCase() === 'false'
|
||||
? false
|
||||
: null
|
||||
: typeof row.is_correct === 'boolean'
|
||||
? row.is_correct
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id || `signal-${row.agent_id || row.agentId || 'agent'}-${date || fallbackIndex}-${fallbackIndex}`,
|
||||
timestamp,
|
||||
date,
|
||||
ticker: row.ticker || '',
|
||||
signal: rawSignal,
|
||||
confidence: Number.isFinite(confidenceValue) ? confidenceValue : null,
|
||||
real_return: Number.isFinite(realReturnValue) ? realReturnValue : null,
|
||||
is_correct: parsedCorrect,
|
||||
agentId: row.agent_id || row.agentId || '',
|
||||
agentName: row.agent_name || row.agentName || row.meta || '未知分析师',
|
||||
role: row.role || row.meta || '',
|
||||
normalizedDirection
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMentionRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
return {
|
||||
id: row.id || `mention-${fallbackIndex}`,
|
||||
feedId: row.id || `mention-${fallbackIndex}`,
|
||||
timestamp: row.timestamp || null,
|
||||
agent: row.agent || row.agentName || '未知角色',
|
||||
content: row.body || row.content || '',
|
||||
conferenceTitle: row.meta || '',
|
||||
feedType: 'sqlite'
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNewsRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const date = row.date || row.published_utc || row.timestamp || null;
|
||||
const source = row.source || row.publisher || '新闻源';
|
||||
const title = row.title || '未命名新闻';
|
||||
const summary = row.summary || row.description || '';
|
||||
return {
|
||||
id: row.id || row.url || `news-${fallbackIndex}`,
|
||||
date,
|
||||
dateKey: eventDateKey(date),
|
||||
ticker: row.ticker || '',
|
||||
title,
|
||||
source,
|
||||
category: row.category || '',
|
||||
related: row.related || '',
|
||||
summary,
|
||||
url: row.url || row.article_url || '',
|
||||
tradeDate: row.trade_date || null,
|
||||
relevance: row.relevance || '',
|
||||
sentiment: row.sentiment || '',
|
||||
keyDiscussion: row.key_discussion || '',
|
||||
reasonGrowth: row.reason_growth || '',
|
||||
reasonDecrease: row.reason_decrease || '',
|
||||
retT0: Number.isFinite(Number(row.ret_t0)) ? Number(row.ret_t0) : null,
|
||||
retT1: Number.isFinite(Number(row.ret_t1)) ? Number(row.ret_t1) : null,
|
||||
retT3: Number.isFinite(Number(row.ret_t3)) ? Number(row.ret_t3) : null,
|
||||
retT5: Number.isFinite(Number(row.ret_t5)) ? Number(row.ret_t5) : null,
|
||||
retT10: Number.isFinite(Number(row.ret_t10)) ? Number(row.ret_t10) : null,
|
||||
analysisSource: row.analysis_source || '',
|
||||
analysisModelLabel: row.analysis_model_label || ''
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNewsTimelineRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const date = row.date || row.trade_date || null;
|
||||
if (!date) return null;
|
||||
const countValue = Number(row.count ?? 0);
|
||||
const sourceCountValue = Number(row.source_count ?? 0);
|
||||
return {
|
||||
id: row.id || `news-timeline-${date}-${fallbackIndex}`,
|
||||
date,
|
||||
dateKey: eventDateKey(date),
|
||||
count: Number.isFinite(countValue) ? countValue : 0,
|
||||
sourceCount: Number.isFinite(sourceCountValue) ? sourceCountValue : 0,
|
||||
topTitle: row.top_title || '',
|
||||
positiveCount: Number.isFinite(Number(row.positive_count)) ? Number(row.positive_count) : 0,
|
||||
negativeCount: Number.isFinite(Number(row.negative_count)) ? Number(row.negative_count) : 0,
|
||||
neutralCount: Number.isFinite(Number(row.neutral_count)) ? Number(row.neutral_count) : 0,
|
||||
highRelevanceCount: Number.isFinite(Number(row.high_relevance_count)) ? Number(row.high_relevance_count) : 0
|
||||
};
|
||||
}
|
||||
|
||||
export const EVENT_CATEGORY_META = {
|
||||
all: { label: '全部事件', color: '#111111' },
|
||||
discussion: { label: '讨论', color: '#555555' },
|
||||
signal: { label: '信号', color: '#0f766e' },
|
||||
technical: { label: '技术', color: '#2563eb' },
|
||||
fundamental: { label: '基本面', color: '#059669' },
|
||||
sentiment: { label: '情绪', color: '#7c3aed' },
|
||||
valuation: { label: '估值', color: '#d97706' },
|
||||
risk: { label: '风控', color: '#dc2626' },
|
||||
portfolio: { label: '组合', color: '#111827' },
|
||||
trade: { label: '成交', color: '#b91c1c' },
|
||||
other: { label: '其他', color: '#6b7280' }
|
||||
};
|
||||
489
frontend/src/components/explain/useExplainModel.js
Normal file
489
frontend/src/components/explain/useExplainModel.js
Normal file
@@ -0,0 +1,489 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
aggregatePriceSeriesToCandles,
|
||||
buildLinePath,
|
||||
eventDateKey,
|
||||
normalizeNewsRow,
|
||||
normalizeNewsTimelineRow,
|
||||
normalizeSignalDirection,
|
||||
normalizeSignalRow,
|
||||
parsePointTime,
|
||||
resolveEventCategory
|
||||
} from './explainUtils';
|
||||
|
||||
export default function useExplainModel({
|
||||
tickers,
|
||||
holdings,
|
||||
trades,
|
||||
leaderboard,
|
||||
feed,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedSymbol,
|
||||
explainEventsSnapshot,
|
||||
newsSnapshot,
|
||||
selectedEventDate,
|
||||
activeEventCategory,
|
||||
activeNewsCategory,
|
||||
activeNewsSentiment = 'all'
|
||||
}) {
|
||||
const availableSymbols = useMemo(() => (
|
||||
Array.isArray(tickers)
|
||||
? tickers.map((ticker) => ticker?.symbol).filter((symbol) => typeof symbol === 'string' && symbol.trim())
|
||||
: []
|
||||
), [tickers]);
|
||||
|
||||
const selectedTicker = useMemo(
|
||||
() => tickers.find((ticker) => ticker.symbol === selectedSymbol) || null,
|
||||
[selectedSymbol, tickers]
|
||||
);
|
||||
|
||||
const holding = useMemo(
|
||||
() => holdings.find((item) => item.ticker === selectedSymbol) || null,
|
||||
[holdings, selectedSymbol]
|
||||
);
|
||||
|
||||
const tickerSignals = useMemo(() => {
|
||||
const snapshotSignals = Array.isArray(explainEventsSnapshot?.signals)
|
||||
? explainEventsSnapshot.signals.map((signal, index) => normalizeSignalRow(signal, index)).filter(Boolean)
|
||||
: [];
|
||||
if (snapshotSignals.length > 0) {
|
||||
return snapshotSignals.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime());
|
||||
}
|
||||
if (!selectedSymbol) return [];
|
||||
return (Array.isArray(leaderboard) ? leaderboard : []).flatMap((agent) => {
|
||||
const signals = Array.isArray(agent.signals) ? agent.signals : [];
|
||||
return signals
|
||||
.filter((signal) => signal.ticker === selectedSymbol)
|
||||
.map((signal) => ({
|
||||
agentId: agent.agentId,
|
||||
agentName: agent.name,
|
||||
role: agent.role,
|
||||
...signal,
|
||||
normalizedDirection: normalizeSignalDirection(signal.signal)
|
||||
}));
|
||||
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [explainEventsSnapshot, leaderboard, selectedSymbol]);
|
||||
|
||||
const tickerNews = useMemo(() => {
|
||||
const items = Array.isArray(newsSnapshot?.items)
|
||||
? newsSnapshot.items.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean)
|
||||
: [];
|
||||
return items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [newsSnapshot]);
|
||||
|
||||
const dateScopedNews = useMemo(() => {
|
||||
if (!selectedEventDate || !newsSnapshot?.byDate || typeof newsSnapshot.byDate !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const rows = Array.isArray(newsSnapshot.byDate[selectedEventDate])
|
||||
? newsSnapshot.byDate[selectedEventDate]
|
||||
: [];
|
||||
return rows.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean);
|
||||
}, [newsSnapshot, selectedEventDate]);
|
||||
|
||||
const visibleNews = useMemo(() => tickerNews, [tickerNews]);
|
||||
|
||||
const tickerNewsTimeline = useMemo(() => {
|
||||
const items = Array.isArray(newsSnapshot?.timeline)
|
||||
? newsSnapshot.timeline.map((item, index) => normalizeNewsTimelineRow(item, index)).filter(Boolean)
|
||||
: [];
|
||||
return items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
}, [newsSnapshot]);
|
||||
|
||||
const newsCategories = useMemo(() => (
|
||||
newsSnapshot?.categories && typeof newsSnapshot.categories === 'object'
|
||||
? newsSnapshot.categories
|
||||
: {}
|
||||
), [newsSnapshot]);
|
||||
|
||||
const visibleNewsByCategory = useMemo(() => {
|
||||
let scopedNews = visibleNews;
|
||||
if (activeNewsCategory !== 'all') {
|
||||
const categoryMeta = newsCategories?.[activeNewsCategory];
|
||||
const allowedIds = Array.isArray(categoryMeta?.article_ids)
|
||||
? new Set(categoryMeta.article_ids)
|
||||
: null;
|
||||
if (allowedIds && allowedIds.size > 0) {
|
||||
scopedNews = scopedNews.filter((item) => allowedIds.has(item.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (activeNewsSentiment === 'all') {
|
||||
return scopedNews;
|
||||
}
|
||||
return scopedNews.filter((item) => {
|
||||
const sentiment = String(item.sentiment || '').trim().toLowerCase() || 'neutral';
|
||||
return sentiment === activeNewsSentiment;
|
||||
});
|
||||
}, [activeNewsCategory, activeNewsSentiment, newsCategories, visibleNews]);
|
||||
|
||||
const selectedRangeWindow = useMemo(() => {
|
||||
if (!selectedEventDate) return null;
|
||||
const endDate = new Date(`${selectedEventDate}T00:00:00`);
|
||||
if (Number.isNaN(endDate.getTime())) return null;
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - 6);
|
||||
return {
|
||||
startDate: startDate.toISOString().slice(0, 10),
|
||||
endDate: selectedEventDate
|
||||
};
|
||||
}, [selectedEventDate]);
|
||||
|
||||
const selectedRangeExplain = useMemo(() => {
|
||||
if (!selectedRangeWindow) return null;
|
||||
const key = `${selectedRangeWindow.startDate}:${selectedRangeWindow.endDate}`;
|
||||
return newsSnapshot?.rangeExplainCache?.[key] || null;
|
||||
}, [newsSnapshot, selectedRangeWindow]);
|
||||
|
||||
const selectedStory = useMemo(() => {
|
||||
const storyCache = newsSnapshot?.storyCache;
|
||||
if (!storyCache || typeof storyCache !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(storyCache).sort();
|
||||
if (!keys.length) {
|
||||
return null;
|
||||
}
|
||||
return storyCache[keys[keys.length - 1]] || null;
|
||||
}, [newsSnapshot]);
|
||||
|
||||
const selectedNewsFreshness = useMemo(
|
||||
() => newsSnapshot?.freshness || newsSnapshot?.categoriesFreshness || newsSnapshot?.timelineFreshness || null,
|
||||
[newsSnapshot]
|
||||
);
|
||||
|
||||
const priceColor = selectedTicker?.change > 0 ? '#00C853' : selectedTicker?.change < 0 ? '#FF1744' : '#000000';
|
||||
const exposureWeight = holding && Number.isFinite(Number(holding.weight)) ? Number(holding.weight) * 100 : null;
|
||||
|
||||
const ohlcSeries = useMemo(() => {
|
||||
const raw = ohlcHistoryByTicker?.[selectedSymbol];
|
||||
return Array.isArray(raw) ? raw.filter((candle) => Number.isFinite(Number(candle.close))).slice(-60) : [];
|
||||
}, [ohlcHistoryByTicker, selectedSymbol]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
const raw = priceHistoryByTicker?.[selectedSymbol];
|
||||
return Array.isArray(raw) ? raw.filter((point) => Number.isFinite(Number(point.price))).slice(-60) : [];
|
||||
}, [priceHistoryByTicker, selectedSymbol]);
|
||||
|
||||
const explainTimeline = useMemo(() => {
|
||||
const signalEvents = tickerSignals.slice(0, 12).map((signal, index) => ({
|
||||
id: `signal-${signal.agentId}-${signal.date}-${index}`,
|
||||
type: 'signal',
|
||||
timestamp: new Date(`${signal.date}T08:00:00`).toISOString(),
|
||||
title: `${signal.agentName} 给出${signal.normalizedDirection === 'bullish' ? '看涨' : signal.normalizedDirection === 'bearish' ? '看跌' : '中性'}信号`,
|
||||
meta: signal.role,
|
||||
body: typeof signal.real_return === 'number'
|
||||
? `后验收益 ${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
|
||||
: '该信号暂未完成后验评估',
|
||||
tone: signal.normalizedDirection === 'bullish' ? 'positive' : signal.normalizedDirection === 'bearish' ? 'negative' : 'neutral'
|
||||
}));
|
||||
|
||||
const fallbackTimeline = [...signalEvents]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 24)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
dateKey: eventDateKey(event.timestamp),
|
||||
category: resolveEventCategory(event)
|
||||
}));
|
||||
if (!explainEventsSnapshot) {
|
||||
return fallbackTimeline;
|
||||
}
|
||||
|
||||
const dbSignalEvents = (Array.isArray(explainEventsSnapshot.signals) ? explainEventsSnapshot.signals : [])
|
||||
.map((signal, index) => {
|
||||
if (signal?.type === 'signal' && signal?.timestamp) {
|
||||
return signal;
|
||||
}
|
||||
const normalized = normalizeSignalRow(signal, index);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
id: normalized.id,
|
||||
type: 'signal',
|
||||
timestamp: normalized.timestamp || (normalized.date ? new Date(`${normalized.date}T08:00:00`).toISOString() : null),
|
||||
title: `${normalized.agentName} 给出${
|
||||
normalized.normalizedDirection === 'bullish'
|
||||
? '看涨'
|
||||
: normalized.normalizedDirection === 'bearish'
|
||||
? '看跌'
|
||||
: '中性'
|
||||
}信号`,
|
||||
meta: normalized.role,
|
||||
body: typeof normalized.real_return === 'number'
|
||||
? `后验收益 ${normalized.real_return >= 0 ? '+' : ''}${(normalized.real_return * 100).toFixed(2)}%`
|
||||
: '该信号暂未完成后验评估',
|
||||
tone: normalized.normalizedDirection === 'bullish'
|
||||
? 'positive'
|
||||
: normalized.normalizedDirection === 'bearish'
|
||||
? 'negative'
|
||||
: 'neutral'
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dbEvents = [...dbSignalEvents]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 24)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
dateKey: eventDateKey(event.timestamp),
|
||||
category: resolveEventCategory(event)
|
||||
}));
|
||||
|
||||
return dbEvents.length > 0 ? dbEvents : fallbackTimeline;
|
||||
}, [explainEventsSnapshot, selectedSymbol, tickerSignals]);
|
||||
|
||||
const availableEventDates = useMemo(
|
||||
() => Array.from(new Set(explainTimeline.map((event) => event.dateKey).filter(Boolean))),
|
||||
[explainTimeline]
|
||||
);
|
||||
|
||||
const eventCategoryCounts = useMemo(() => {
|
||||
const scopedEvents = selectedEventDate
|
||||
? explainTimeline.filter((event) => event.dateKey === selectedEventDate)
|
||||
: explainTimeline;
|
||||
const counts = { all: scopedEvents.length };
|
||||
scopedEvents.forEach((event) => {
|
||||
counts[event.category] = (counts[event.category] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [explainTimeline, selectedEventDate]);
|
||||
|
||||
const visibleExplainEvents = useMemo(() => explainTimeline.filter((event) => {
|
||||
if (selectedEventDate && event.dateKey !== selectedEventDate) {
|
||||
return false;
|
||||
}
|
||||
if (activeEventCategory !== 'all' && event.category !== activeEventCategory) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}), [activeEventCategory, explainTimeline, selectedEventDate]);
|
||||
|
||||
const chartModel = useMemo(() => {
|
||||
const width = 720;
|
||||
const height = 220;
|
||||
const padding = 18;
|
||||
if (!ohlcSeries.length && !priceSeries.length) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
path: '',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
markers: [],
|
||||
candles: [],
|
||||
linePoints: [],
|
||||
bucketCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (ohlcSeries.length > 1) {
|
||||
const prices = ohlcSeries.flatMap((candle) => [Number(candle.low), Number(candle.high)]);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const span = maxPrice - minPrice || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
const candleWidth = Math.max(8, Math.min(18, (innerWidth / ohlcSeries.length) * 0.55));
|
||||
const startTime = parsePointTime({ timestamp: ohlcSeries[0]?.time });
|
||||
const endTime = parsePointTime({ timestamp: ohlcSeries[ohlcSeries.length - 1]?.time });
|
||||
const timeSpan = Math.max(endTime - startTime, 1);
|
||||
|
||||
const candles = ohlcSeries.map((candle, index) => {
|
||||
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(ohlcSeries.length, 1);
|
||||
const openY = height - padding - ((Number(candle.open) - minPrice) / span) * innerHeight;
|
||||
const closeY = height - padding - ((Number(candle.close) - minPrice) / span) * innerHeight;
|
||||
const highY = height - padding - ((Number(candle.high) - minPrice) / span) * innerHeight;
|
||||
const lowY = height - padding - ((Number(candle.low) - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
...candle,
|
||||
id: `${candle.time || index}`,
|
||||
centerX,
|
||||
x: centerX - candleWidth / 2,
|
||||
width: candleWidth,
|
||||
openY,
|
||||
closeY,
|
||||
highY,
|
||||
lowY,
|
||||
bodyY: Math.min(openY, closeY),
|
||||
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
|
||||
};
|
||||
});
|
||||
|
||||
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
|
||||
const timestamp = new Date(event.timestamp).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = candles.length <= 1
|
||||
? 0
|
||||
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
|
||||
const nearestCandle = candles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
|
||||
}).filter(Boolean);
|
||||
|
||||
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
|
||||
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = candles.length <= 1
|
||||
? 0
|
||||
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
|
||||
const nearestCandle = candles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
id: item.id || `news-marker-${index}`,
|
||||
title: item.topTitle || `当日 ${item.count} 条新闻`,
|
||||
dateKey: item.dateKey,
|
||||
tone: 'news',
|
||||
x,
|
||||
y,
|
||||
isSelected: item.dateKey === selectedEventDate,
|
||||
markerType: 'news',
|
||||
count: item.count
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
path: '',
|
||||
minPrice,
|
||||
maxPrice,
|
||||
markers: [...newsMarkers, ...explainMarkers],
|
||||
candles,
|
||||
linePoints: [],
|
||||
bucketCount: candles.length
|
||||
};
|
||||
}
|
||||
|
||||
const prices = priceSeries.map((point) => Number(point.price));
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const span = maxPrice - minPrice || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
const startTime = parsePointTime(priceSeries[0]);
|
||||
const endTime = parsePointTime(priceSeries[priceSeries.length - 1]);
|
||||
const timeSpan = Math.max(endTime - startTime, 1);
|
||||
const candles = aggregatePriceSeriesToCandles(priceSeries);
|
||||
|
||||
const linePoints = priceSeries.map((point, index) => {
|
||||
const x = padding + (innerWidth * index) / Math.max(priceSeries.length - 1, 1);
|
||||
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const candleWidth = candles.length > 1
|
||||
? Math.max(8, Math.min(24, (innerWidth / candles.length) * 0.58))
|
||||
: 14;
|
||||
|
||||
const mappedCandles = candles.map((candle, index) => {
|
||||
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(candles.length, 1);
|
||||
const openY = height - padding - ((candle.open - minPrice) / span) * innerHeight;
|
||||
const closeY = height - padding - ((candle.close - minPrice) / span) * innerHeight;
|
||||
const highY = height - padding - ((candle.high - minPrice) / span) * innerHeight;
|
||||
const lowY = height - padding - ((candle.low - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
...candle,
|
||||
centerX,
|
||||
x: centerX - candleWidth / 2,
|
||||
width: candleWidth,
|
||||
openY,
|
||||
closeY,
|
||||
highY,
|
||||
lowY,
|
||||
bodyY: Math.min(openY, closeY),
|
||||
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
|
||||
};
|
||||
});
|
||||
|
||||
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
|
||||
const timestamp = new Date(event.timestamp).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = mappedCandles.length <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
mappedCandles.length - 1,
|
||||
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
|
||||
);
|
||||
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
|
||||
}).filter(Boolean);
|
||||
|
||||
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
|
||||
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = mappedCandles.length <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
mappedCandles.length - 1,
|
||||
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
|
||||
);
|
||||
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
id: item.id || `news-marker-${index}`,
|
||||
title: item.topTitle || `当日 ${item.count} 条新闻`,
|
||||
dateKey: item.dateKey,
|
||||
tone: 'news',
|
||||
x,
|
||||
y,
|
||||
isSelected: item.dateKey === selectedEventDate,
|
||||
markerType: 'news',
|
||||
count: item.count
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
path: buildLinePath(priceSeries, width, height, padding),
|
||||
minPrice,
|
||||
maxPrice,
|
||||
markers: [...newsMarkers, ...explainMarkers],
|
||||
candles: mappedCandles,
|
||||
linePoints,
|
||||
bucketCount: mappedCandles.length
|
||||
};
|
||||
}, [explainTimeline, ohlcSeries, priceSeries, selectedEventDate, tickerNewsTimeline]);
|
||||
|
||||
return {
|
||||
availableSymbols,
|
||||
selectedTicker,
|
||||
holding,
|
||||
tickerSignals,
|
||||
tickerNews,
|
||||
visibleNews,
|
||||
newsCategories,
|
||||
visibleNewsByCategory,
|
||||
selectedNewsFreshness,
|
||||
selectedRangeWindow,
|
||||
selectedRangeExplain,
|
||||
selectedStory,
|
||||
priceColor,
|
||||
exposureWeight,
|
||||
ohlcSeries,
|
||||
priceSeries,
|
||||
explainTimeline,
|
||||
availableEventDates,
|
||||
eventCategoryCounts,
|
||||
visibleExplainEvents,
|
||||
chartModel
|
||||
};
|
||||
}
|
||||
150
frontend/src/components/explain/useExplainModel.test.jsx
Normal file
150
frontend/src/components/explain/useExplainModel.test.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useExplainModel from './useExplainModel';
|
||||
|
||||
function buildBaseProps() {
|
||||
return {
|
||||
tickers: [{ symbol: 'AAPL', price: 105.12, change: 1.34 }],
|
||||
holdings: [{ ticker: 'AAPL', quantity: 10, weight: 0.2, marketValue: 1051.2, currentPrice: 105.12 }],
|
||||
trades: [],
|
||||
leaderboard: [],
|
||||
feed: [],
|
||||
priceHistoryByTicker: {
|
||||
AAPL: [
|
||||
{ timestamp: '2026-03-08T10:00:00Z', price: 100 },
|
||||
{ timestamp: '2026-03-09T10:00:00Z', price: 103 },
|
||||
{ timestamp: '2026-03-10T10:00:00Z', price: 105 }
|
||||
]
|
||||
},
|
||||
ohlcHistoryByTicker: {},
|
||||
selectedSymbol: 'AAPL',
|
||||
explainEventsSnapshot: {
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-1',
|
||||
ticker: 'AAPL',
|
||||
date: '2026-03-10',
|
||||
signal: 'bullish',
|
||||
confidence: 0.88,
|
||||
agent_id: 'agent-1',
|
||||
agent_name: 'Alpha',
|
||||
role: 'technical'
|
||||
}
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'mention-1',
|
||||
timestamp: '2026-03-10T12:00:00Z',
|
||||
agent: 'Research',
|
||||
body: 'AAPL momentum remains strong after earnings.',
|
||||
meta: 'morning note'
|
||||
}
|
||||
],
|
||||
trades: [
|
||||
{
|
||||
id: 'trade-1',
|
||||
timestamp: '2026-03-10T15:00:00Z',
|
||||
ticker: 'AAPL',
|
||||
side: 'LONG',
|
||||
qty: 5,
|
||||
price: 104.5
|
||||
}
|
||||
]
|
||||
},
|
||||
newsSnapshot: {
|
||||
items: [
|
||||
{
|
||||
id: 'news-1',
|
||||
ticker: 'AAPL',
|
||||
date: '2026-03-10T09:00:00Z',
|
||||
title: 'Apple earnings beat expectations',
|
||||
summary: 'Revenue topped consensus estimates.',
|
||||
source: 'Polygon',
|
||||
sentiment: 'positive'
|
||||
},
|
||||
{
|
||||
id: 'news-2',
|
||||
ticker: 'AAPL',
|
||||
date: '2026-03-09T09:00:00Z',
|
||||
title: 'Supplier update',
|
||||
summary: 'Supply chain improves.',
|
||||
source: 'Polygon',
|
||||
sentiment: 'negative'
|
||||
}
|
||||
],
|
||||
timeline: [
|
||||
{ id: 'timeline-1', date: '2026-03-09', count: 1, source_count: 1, top_title: 'Supplier update' },
|
||||
{ id: 'timeline-2', date: '2026-03-10', count: 1, source_count: 1, top_title: 'Apple earnings beat expectations' }
|
||||
],
|
||||
categories: {
|
||||
earnings: {
|
||||
count: 1,
|
||||
article_ids: ['news-1']
|
||||
}
|
||||
},
|
||||
rangeExplainCache: {
|
||||
'2026-03-03:2026-03-10': {
|
||||
summary: '区间内主要由财报催化推动。'
|
||||
}
|
||||
},
|
||||
similarDaysCache: {
|
||||
'2026-03-10': {
|
||||
target_features: {
|
||||
sentiment_score: 0.5,
|
||||
n_articles: 2
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: '2026-02-18',
|
||||
score: 0.92,
|
||||
n_articles: 2,
|
||||
sentiment_score: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
selectedEventDate: '2026-03-10',
|
||||
activeEventCategory: 'all',
|
||||
activeNewsCategory: 'earnings',
|
||||
activeNewsSentiment: 'all'
|
||||
};
|
||||
}
|
||||
|
||||
describe('useExplainModel', () => {
|
||||
it('derives visible news and range explain data from snapshots', () => {
|
||||
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
|
||||
|
||||
expect(result.current.availableSymbols).toEqual(['AAPL']);
|
||||
expect(result.current.visibleNews).toHaveLength(2);
|
||||
expect(result.current.visibleNewsByCategory).toHaveLength(1);
|
||||
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
|
||||
expect(result.current.selectedRangeWindow).toEqual({
|
||||
startDate: '2026-03-03',
|
||||
endDate: '2026-03-10'
|
||||
});
|
||||
expect(result.current.selectedRangeExplain).toEqual({
|
||||
summary: '区间内主要由财报催化推动。'
|
||||
});
|
||||
});
|
||||
|
||||
it('builds timeline, counts, and chart markers from explain data', () => {
|
||||
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
|
||||
|
||||
expect(result.current.availableEventDates).toContain('2026-03-10');
|
||||
expect(result.current.chartModel.markers.length).toBeGreaterThan(0);
|
||||
expect(result.current.chartModel.path).toMatch(/^M/);
|
||||
});
|
||||
|
||||
it('filters visible news by sentiment when requested', () => {
|
||||
const props = buildBaseProps();
|
||||
props.activeNewsCategory = 'all';
|
||||
props.activeNewsSentiment = 'positive';
|
||||
|
||||
const { result } = renderHook(() => useExplainModel(props));
|
||||
|
||||
expect(result.current.visibleNewsByCategory).toHaveLength(1);
|
||||
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
|
||||
});
|
||||
});
|
||||
172
frontend/src/config/constants.js
Normal file
172
frontend/src/config/constants.js
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Application Configuration Constants
|
||||
*/
|
||||
|
||||
const trimTrailingSlash = (value) => value.replace(/\/+$/, "");
|
||||
const isLocalDevHost = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
const host = String(window.location.hostname || "").trim().toLowerCase();
|
||||
return host === "localhost" || host === "127.0.0.1";
|
||||
};
|
||||
|
||||
// Centralized CDN asset URLs
|
||||
export const CDN_ASSETS = {
|
||||
companyRoom: {
|
||||
agent_1: "https://img.alicdn.com/imgextra/i4/O1CN01Lr7SOl1lSExV0tOwv_!!6000000004817-2-tps-370-320.png",
|
||||
agent_2: "https://img.alicdn.com/imgextra/i3/O1CN017Kb8cY1VQNUmuK47o_!!6000000002647-2-tps-368-312.png",
|
||||
agent_3: "https://img.alicdn.com/imgextra/i3/O1CN010Fp55w1YqtGpVjgsS_!!6000000003111-2-tps-370-320.png",
|
||||
agent_4: "https://img.alicdn.com/imgextra/i3/O1CN01VnUsML1Dkq6fHw3ks_!!6000000000255-2-tps-366-316.png",
|
||||
agent_5: "https://img.alicdn.com/imgextra/i4/O1CN01o0kCQw1kyvbulBSl7_!!6000000004753-2-tps-370-314.png",
|
||||
agent_6: "https://img.alicdn.com/imgextra/i2/O1CN01cLV0zl1FI6ULAunTp_!!6000000000463-2-tps-368-320.png",
|
||||
team_logo: "https://img.alicdn.com/imgextra/i2/O1CN01n2S8aV25hcZhhNH95_!!6000000007558-2-tps-616-700.png",
|
||||
reme_logo: "https://img.alicdn.com/imgextra/i2/O1CN01FhncuT1Tqp8LfCaft_!!6000000002434-2-tps-915-250.png",
|
||||
full_room_dark: "https://img.alicdn.com/imgextra/i2/O1CN014sOgzK28re5haGC3X_!!6000000007986-2-tps-1248-832.png",
|
||||
full_room_with_roles_tech_style: "https://img.alicdn.com/imgextra/i1/O1CN01qhupIj1KU4vF3yoT2_!!6000000001166-2-tps-1248-832.png",
|
||||
},
|
||||
llmModelLogos: {
|
||||
"Zhipu AI": "https://img.alicdn.com/imgextra/i4/O1CN01PavE4h1SdFmbeUj6h_!!6000000002269-2-tps-92-92.png",
|
||||
"Alibaba": "https://img.alicdn.com/imgextra/i4/O1CN01mTs8oZ1gsHOj0xy7O_!!6000000004197-0-tps-204-192.jpg",
|
||||
"DeepSeek": "https://img.alicdn.com/imgextra/i3/O1CN01ocd9iO1D7S2qgEIXQ_!!6000000000169-2-tps-203-148.png",
|
||||
"Moonshot": "https://img.alicdn.com/imgextra/i3/O1CN01rFzJg01wE0QFHNGLy_!!6000000006275-0-tps-182-148.jpg",
|
||||
"Anthropic": "https://img.alicdn.com/imgextra/i4/O1CN01Sg8gbo1HKVnoU16rm_!!6000000000739-2-tps-148-148.png",
|
||||
"Google": "https://img.alicdn.com/imgextra/i1/O1CN01fZwVYk1caBHdzh9qh_!!6000000003616-0-tps-148-148.jpg",
|
||||
"OpenAI": "https://img.alicdn.com/imgextra/i3/O1CN01T1eaM8287qU0nZm91_!!6000000007886-2-tps-148-148.png",
|
||||
"Groq": "https://img.alicdn.com/imgextra/i1/O1CN01WxASMc1QjXzhVl3eQ_!!6000000002012-2-tps-170-148.png",
|
||||
"Ollama": "https://img.alicdn.com/imgextra/i1/O1CN01pN615e1i4vxLkQjVd_!!6000000004360-2-tps-204-192.png",
|
||||
},
|
||||
};
|
||||
|
||||
// Derived asset shortcuts
|
||||
export const ASSETS = {
|
||||
roomBg: CDN_ASSETS.companyRoom.full_room_with_roles_tech_style,
|
||||
teamLogo: CDN_ASSETS.companyRoom.team_logo,
|
||||
remeLogo: CDN_ASSETS.companyRoom.reme_logo,
|
||||
};
|
||||
|
||||
// Scene dimensions (actual image size)
|
||||
export const SCENE_NATIVE = { width: 1248, height: 832 };
|
||||
|
||||
// Agent seat positions (percentage relative to image, origin at bottom-left)
|
||||
// Format: { x: horizontal %, y: vertical % from bottom }
|
||||
export const AGENT_SEATS = [
|
||||
{ x: 0.44, y: 0.58 }, // portfolio_manager
|
||||
{ x: 0.55, y: 0.58 }, // risk_manager
|
||||
{ x: 0.33, y: 0.52 }, // valuation_analyst
|
||||
{ x: 0.42, y: 0.42 }, // sentiment_analyst
|
||||
{ x: 0.56, y: 0.42 }, // fundamentals_analyst
|
||||
{ x: 0.61, y: 0.49 }, // technical_analyst
|
||||
];
|
||||
|
||||
// Agent definitions with subtle color schemes (very light backgrounds)
|
||||
export const AGENTS = [
|
||||
{
|
||||
id: "portfolio_manager",
|
||||
name: "投资经理",
|
||||
role: "投资经理",
|
||||
avatar: CDN_ASSETS.companyRoom.agent_1,
|
||||
colors: { bg: "#F9FDFF", text: "#1565C0", accent: "#1565C0" }
|
||||
},
|
||||
{
|
||||
id: "risk_manager",
|
||||
name: "风控经理",
|
||||
role: "风控经理",
|
||||
avatar: CDN_ASSETS.companyRoom.agent_2,
|
||||
colors: { bg: "#FFF8F8", text: "#C62828", accent: "#C62828" }
|
||||
},
|
||||
{
|
||||
id: "valuation_analyst",
|
||||
name: "估值分析师",
|
||||
role: "估值分析师",
|
||||
avatar: CDN_ASSETS.companyRoom.agent_3,
|
||||
colors: { bg: "#FAFFFA", text: "#2E7D32", accent: "#2E7D32" }
|
||||
},
|
||||
{
|
||||
id: "sentiment_analyst",
|
||||
name: "情绪分析师",
|
||||
role: "情绪分析师",
|
||||
avatar: CDN_ASSETS.companyRoom.agent_4,
|
||||
colors: { bg: "#FCFAFF", text: "#6A1B9A", accent: "#6A1B9A" }
|
||||
},
|
||||
{
|
||||
id: "fundamentals_analyst",
|
||||
name: "基本面分析师",
|
||||
role: "基本面分析师",
|
||||
avatar: CDN_ASSETS.companyRoom.agent_5,
|
||||
colors: { bg: "#FFFCF7", text: "#E65100", accent: "#E65100" }
|
||||
},
|
||||
{
|
||||
id: "technical_analyst",
|
||||
name: "技术分析师",
|
||||
role: "技术分析师",
|
||||
avatar: CDN_ASSETS.companyRoom.agent_6,
|
||||
colors: { bg: "#F9FEFF", text: "#00838F", accent: "#00838F" }
|
||||
},
|
||||
];
|
||||
|
||||
// LLM logo URLs for reuse
|
||||
export const LLM_MODEL_LOGOS = { ...CDN_ASSETS.llmModelLogos };
|
||||
|
||||
// Message type colors (very subtle backgrounds)
|
||||
export const MESSAGE_COLORS = {
|
||||
system: { bg: "#FAFAFA", text: "#424242", accent: "#424242" },
|
||||
memory: { bg: "#F2FDFF", text: "#00838F", accent: "#00838F" },
|
||||
conference: { bg: "#F1F4FF", text: "#3949AB", accent: "#3949AB" }
|
||||
};
|
||||
|
||||
// Helper function to get agent colors by ID or name
|
||||
export const getAgentColors = (agentId, agentName) => {
|
||||
const agent = AGENTS.find(a => a.id === agentId || a.name === agentName);
|
||||
return agent?.colors || MESSAGE_COLORS.system;
|
||||
};
|
||||
|
||||
// UI timing constants
|
||||
export const BUBBLE_LIFETIME_MS = 8000;
|
||||
export const CHART_MARGIN = { left: 60, right: 20, top: 20, bottom: 40 };
|
||||
export const AXIS_TICKS = 5;
|
||||
|
||||
// WebSocket configuration
|
||||
const DEFAULT_CONTROL_API_BASE = isLocalDevHost()
|
||||
? "http://localhost:8000/api"
|
||||
: "/api";
|
||||
const DEFAULT_RUNTIME_API_BASE = isLocalDevHost()
|
||||
? "http://localhost:8003/api/runtime"
|
||||
: `${DEFAULT_CONTROL_API_BASE}/runtime`;
|
||||
export const CONTROL_API_BASE =
|
||||
trimTrailingSlash(import.meta.env.VITE_CONTROL_API_BASE_URL || "") || DEFAULT_CONTROL_API_BASE;
|
||||
export const RUNTIME_API_BASE =
|
||||
trimTrailingSlash(import.meta.env.VITE_RUNTIME_API_BASE_URL || "") ||
|
||||
DEFAULT_RUNTIME_API_BASE;
|
||||
const FALLBACK_WS_PROTOCOL =
|
||||
typeof window !== "undefined" && window.location.protocol === "https:"
|
||||
? "wss:"
|
||||
: "ws:";
|
||||
const FALLBACK_WS_HOST =
|
||||
typeof window !== "undefined" ? window.location.hostname : "localhost";
|
||||
const FALLBACK_WS_PORT =
|
||||
typeof window !== "undefined" && window.location.port
|
||||
? `:${window.location.port}`
|
||||
: "";
|
||||
export const WS_URL =
|
||||
import.meta.env.VITE_WS_URL ||
|
||||
(isLocalDevHost()
|
||||
? `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}:8765`
|
||||
: `${FALLBACK_WS_PROTOCOL}//${FALLBACK_WS_HOST}${FALLBACK_WS_PORT}/ws`);
|
||||
|
||||
// Initial ticker symbols for the production watchlist
|
||||
export const INITIAL_TICKERS = [
|
||||
{ symbol: "AAPL", price: null, change: null },
|
||||
{ symbol: "MSFT", price: null, change: null },
|
||||
{ symbol: "GOOGL", price: null, change: null },
|
||||
{ symbol: "AMZN", price: null, change: null },
|
||||
{ symbol: "NVDA", price: null, change: null },
|
||||
{ symbol: "META", price: null, change: null },
|
||||
{ symbol: "TSLA", price: null, change: null },
|
||||
{ symbol: "AMD", price: null, change: null },
|
||||
{ symbol: "NFLX", price: null, change: null },
|
||||
{ symbol: "AVGO", price: null, change: null },
|
||||
{ symbol: "PLTR", price: null, change: null },
|
||||
{ symbol: "COIN", price: null, change: null }
|
||||
];
|
||||
|
||||
388
frontend/src/hooks/useAgentDataRequests.js
Normal file
388
frontend/src/hooks/useAgentDataRequests.js
Normal file
@@ -0,0 +1,388 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
createAgentLocalSkill,
|
||||
deleteAgentLocalSkill,
|
||||
disableAgentSkill,
|
||||
enableAgentSkill,
|
||||
fetchAgentProfile,
|
||||
fetchAgentSkillDetail,
|
||||
fetchAgentSkills,
|
||||
fetchAgentWorkspaceFile,
|
||||
fetchCurrentRuntime,
|
||||
updateAgentLocalSkill,
|
||||
updateAgentWorkspaceFile,
|
||||
uploadAgentSkillZip
|
||||
} from '../services/runtimeApi';
|
||||
import { useAgentStore } from '../store/agentStore';
|
||||
|
||||
/**
|
||||
* Custom hook for agent operation callbacks.
|
||||
* Takes clientRef, uses agentStore.
|
||||
*/
|
||||
export function useAgentDataRequests(clientRef) {
|
||||
const {
|
||||
selectedSkillAgentId,
|
||||
setSelectedSkillAgentId,
|
||||
setAgentProfilesByAgent,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey,
|
||||
setSkillDetailLoadingKey,
|
||||
setAgentSkillsByAgent,
|
||||
setSkillDetailsByName,
|
||||
localSkillDraftsByKey,
|
||||
selectedWorkspaceFile,
|
||||
setWorkspaceFilesByAgent,
|
||||
setWorkspaceDraftContent,
|
||||
workspaceDraftContent,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
setIsWorkspaceFileLoading
|
||||
} = useAgentStore();
|
||||
|
||||
const resolveWorkspaceId = useCallback(async () => {
|
||||
const runtime = await fetchCurrentRuntime();
|
||||
const workspaceId = runtime?.run_id;
|
||||
if (!workspaceId) {
|
||||
throw new Error('未检测到正在运行的任务');
|
||||
}
|
||||
return workspaceId;
|
||||
}, []);
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (!normalized) return false;
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentSkills(workspaceId, normalized))
|
||||
.then((payload) => {
|
||||
setAgentSkillsByAgent((prev) => ({ ...prev, [normalized]: Array.isArray(payload?.skills) ? payload.skills : [] }));
|
||||
setIsAgentSkillsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setIsAgentSkillsLoading(false);
|
||||
return;
|
||||
}
|
||||
console.debug('REST agent skills request failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized });
|
||||
if (!success) {
|
||||
setIsAgentSkillsLoading(false);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, setAgentSkillsByAgent, setIsAgentSkillsLoading, setAgentSkillsFeedback]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
if (!normalized) return false;
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentProfile(workspaceId, normalized))
|
||||
.then((payload) => {
|
||||
setAgentProfilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[normalized]: payload?.profile && typeof payload.profile === 'object' ? payload.profile : {}
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (clientRef.current) {
|
||||
console.debug('REST agent profile request failed, falling back to websocket compatibility path');
|
||||
clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, setAgentProfilesByAgent]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||
if (!normalized) return false;
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentSkillDetail(workspaceId, selectedSkillAgentId, normalized))
|
||||
.then((payload) => {
|
||||
setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: payload?.skill || null }));
|
||||
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: typeof payload?.skill?.content === 'string' ? payload.skill.content : ''
|
||||
}));
|
||||
setSkillDetailLoadingKey(null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setSkillDetailLoadingKey(null);
|
||||
return;
|
||||
}
|
||||
console.debug('REST skill detail request failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||
if (!success) {
|
||||
setSkillDetailLoadingKey(null);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, setSkillDetailLoadingKey, setSkillDetailsByName]);
|
||||
|
||||
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === 'string' ? skillName.trim() : '';
|
||||
if (!normalized) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => createAgentLocalSkill(workspaceId, selectedSkillAgentId, normalized))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `已创建本地技能 ${normalized}` });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
requestSkillDetail(normalized);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST local skill create failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
useAgentStore.getState().setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content }));
|
||||
}, [selectedSkillAgentId]);
|
||||
|
||||
const handleLocalSkillSave = useCallback((skillName) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
const content = localSkillDraftsByKey[detailKey];
|
||||
if (typeof content !== 'string') return;
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => updateAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName, content))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已保存` });
|
||||
requestSkillDetail(skillName);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST local skill save failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, localSkillDraftsByKey, requestSkillDetail, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => deleteAgentLocalSkill(workspaceId, selectedSkillAgentId, skillName))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 的本地技能 ${skillName} 已删除` });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST local skill delete failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => disableAgentSkill(workspaceId, selectedSkillAgentId, skillName))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${selectedSkillAgentId} 已移除共享技能 ${skillName}` });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST shared skill remove failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => enabled
|
||||
? enableAgentSkill(workspaceId, agentId, skillName)
|
||||
: disableAgentSkill(workspaceId, agentId, skillName))
|
||||
.then(() => {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'success', text: `${agentId} ${enabled ? '已启用' : '已禁用'} ${skillName}` });
|
||||
requestAgentSkills(agentId);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST skill toggle failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled });
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, requestAgentSkills, resolveWorkspaceId, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [requestAgentProfile, requestAgentSkills, setSelectedSkillAgentId, selectedWorkspaceFile]);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||
const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : '';
|
||||
const normalizedFilename = typeof filename === 'string' ? filename.trim() : '';
|
||||
if (!normalizedAgentId || !normalizedFilename) return false;
|
||||
setIsWorkspaceFileLoading(true);
|
||||
setWorkspaceFileFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => fetchAgentWorkspaceFile(workspaceId, normalizedAgentId, normalizedFilename))
|
||||
.then((payload) => {
|
||||
setWorkspaceFilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[normalizedAgentId]: {
|
||||
...(prev[normalizedAgentId] || {}),
|
||||
[normalizedFilename]: typeof payload?.content === 'string' ? payload.content : ''
|
||||
}
|
||||
}));
|
||||
setWorkspaceDraftContent(typeof payload?.content === 'string' ? payload.content : '');
|
||||
setIsWorkspaceFileLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setIsWorkspaceFileLoading(false);
|
||||
return;
|
||||
}
|
||||
console.debug('REST workspace file read failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename });
|
||||
if (!success) {
|
||||
setIsWorkspaceFileLoading(false);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}, [clientRef, resolveWorkspaceId, setIsWorkspaceFileLoading, setWorkspaceDraftContent, setWorkspaceFileFeedback, setWorkspaceFilesByAgent]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
useAgentStore.getState().setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
void resolveWorkspaceId()
|
||||
.then((workspaceId) => updateAgentWorkspaceFile(workspaceId, selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent))
|
||||
.then((payload) => {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'success', text: `${selectedSkillAgentId} 的 ${selectedWorkspaceFile} 已保存` });
|
||||
setWorkspaceFilesByAgent((prev) => ({
|
||||
...prev,
|
||||
[selectedSkillAgentId]: {
|
||||
...(prev[selectedSkillAgentId] || {}),
|
||||
[selectedWorkspaceFile]: typeof payload?.content === 'string' ? payload.content : workspaceDraftContent
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
|
||||
return;
|
||||
}
|
||||
console.debug('REST workspace file save failed, falling back to websocket compatibility path');
|
||||
const success = clientRef.current.send({
|
||||
type: 'update_agent_workspace_file',
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
|
||||
}
|
||||
});
|
||||
}, [clientRef, resolveWorkspaceId, selectedSkillAgentId, selectedWorkspaceFile, setWorkspaceFileFeedback, setWorkspaceFileSavingKey, setWorkspaceFilesByAgent, workspaceDraftContent]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '请选择 zip 文件后再上传' });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: '未选择目标 Agent' });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({ agentId: selectedSkillAgentId, file, activate: true });
|
||||
setAgentSkillsFeedback({ type: 'success', text: `已上传并安装技能 ${result.skill_name || ''}`.trim() });
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({ type: 'error', text: `上传失败: ${error.message || '未知错误'}` });
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [requestAgentSkills, selectedSkillAgentId, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
requestWorkspaceFile,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
385
frontend/src/hooks/useAgentWorkspacePanel.js
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { AGENTS } from "../config/constants";
|
||||
import { uploadAgentSkillZip } from "../services/runtimeApi";
|
||||
|
||||
export function useAgentWorkspacePanel({
|
||||
clientRef,
|
||||
currentView,
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
selectedWorkspaceContent,
|
||||
localSkillDraftsByKey,
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
workspaceFilesByAgent,
|
||||
workspaceDraftContent,
|
||||
setSelectedSkillAgentId,
|
||||
setSelectedWorkspaceFile,
|
||||
setWorkspaceDraftContent,
|
||||
setIsAgentSkillsLoading,
|
||||
setAgentSkillsFeedback,
|
||||
setSkillDetailLoadingKey,
|
||||
setAgentSkillsSavingKey,
|
||||
setLocalSkillDraftsByKey,
|
||||
setIsWorkspaceFileLoading,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey
|
||||
}) {
|
||||
const sendWithRetry = useCallback((payload, retries = 3, delayMs = 250) => {
|
||||
const attemptSend = (remaining) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
const sent = client.send(payload);
|
||||
if (sent || remaining <= 0) {
|
||||
return sent;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
attemptSend(remaining - 1);
|
||||
}, delayMs);
|
||||
return false;
|
||||
};
|
||||
|
||||
return attemptSend(retries);
|
||||
}, [clientRef]);
|
||||
|
||||
const requestAgentSkills = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsAgentSkillsLoading(true);
|
||||
setAgentSkillsFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_skills",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setAgentSkillsFeedback, setIsAgentSkillsLoading]);
|
||||
|
||||
const requestAgentProfile = useCallback((agentId) => {
|
||||
const normalized = typeof agentId === "string" ? agentId.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return sendWithRetry({
|
||||
type: "get_agent_profile",
|
||||
agent_id: normalized
|
||||
});
|
||||
}, [clientRef, sendWithRetry]);
|
||||
|
||||
const requestSkillDetail = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${normalized}`;
|
||||
setSkillDetailLoadingKey(detailKey);
|
||||
return sendWithRetry({
|
||||
type: "get_skill_detail",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setSkillDetailLoadingKey]);
|
||||
|
||||
const handleCreateLocalSkill = useCallback((skillName) => {
|
||||
const normalized = typeof skillName === "string" ? skillName.trim() : "";
|
||||
if (!normalized) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "技能名称不能为空" });
|
||||
return;
|
||||
}
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "create_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: normalized
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleLocalSkillDraftChange = useCallback((skillName, content) => {
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
setLocalSkillDraftsByKey((prev) => ({
|
||||
...prev,
|
||||
[detailKey]: content
|
||||
}));
|
||||
}, [selectedSkillAgentId, setLocalSkillDraftsByKey]);
|
||||
|
||||
const handleLocalSkillSave = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const detailKey = `${selectedSkillAgentId}:${skillName}`;
|
||||
const content = localSkillDraftsByKey[detailKey];
|
||||
if (typeof content !== "string") {
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName,
|
||||
content
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
localSkillDraftsByKey,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
const handleLocalSkillDelete = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "delete_agent_local_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleRemoveSharedSkill = useCallback((skillName) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "remove_agent_skill",
|
||||
agent_id: selectedSkillAgentId,
|
||||
skill_name: skillName
|
||||
});
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agentId, filename) => {
|
||||
const normalizedAgentId = typeof agentId === "string" ? agentId.trim() : "";
|
||||
const normalizedFilename = typeof filename === "string" ? filename.trim() : "";
|
||||
if (!normalizedAgentId || !normalizedFilename || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
setIsWorkspaceFileLoading(true);
|
||||
setWorkspaceFileFeedback(null);
|
||||
return sendWithRetry({
|
||||
type: "get_agent_workspace_file",
|
||||
agent_id: normalizedAgentId,
|
||||
filename: normalizedFilename
|
||||
});
|
||||
}, [clientRef, sendWithRetry, setIsWorkspaceFileLoading, setWorkspaceFileFeedback]);
|
||||
|
||||
const handleAgentSkillToggle = useCallback((skillName, enabled) => {
|
||||
if (!clientRef.current) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = selectedSkillAgentId;
|
||||
setAgentSkillsSavingKey(`${agentId}:${skillName}`);
|
||||
setAgentSkillsFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_skill",
|
||||
agent_id: agentId,
|
||||
skill_name: skillName,
|
||||
enabled
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setAgentSkillsSavingKey(null);
|
||||
setAgentSkillsFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [clientRef, selectedSkillAgentId, sendWithRetry, setAgentSkillsFeedback, setAgentSkillsSavingKey]);
|
||||
|
||||
const handleSkillAgentChange = useCallback((agentId) => {
|
||||
setSelectedSkillAgentId(agentId);
|
||||
requestAgentProfile(agentId);
|
||||
requestAgentSkills(agentId);
|
||||
requestWorkspaceFile(agentId, selectedWorkspaceFile);
|
||||
}, [
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedWorkspaceFile,
|
||||
setSelectedSkillAgentId
|
||||
]);
|
||||
|
||||
const handleWorkspaceFileChange = useCallback((filename) => {
|
||||
setSelectedWorkspaceFile(filename);
|
||||
requestWorkspaceFile(selectedSkillAgentId, filename);
|
||||
}, [requestWorkspaceFile, selectedSkillAgentId, setSelectedWorkspaceFile]);
|
||||
|
||||
const handleWorkspaceFileSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setWorkspaceFileFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`;
|
||||
setWorkspaceFileSavingKey(key);
|
||||
setWorkspaceFileFeedback(null);
|
||||
const success = sendWithRetry({
|
||||
type: "update_agent_workspace_file",
|
||||
agent_id: selectedSkillAgentId,
|
||||
filename: selectedWorkspaceFile,
|
||||
content: workspaceDraftContent
|
||||
});
|
||||
if (!success) {
|
||||
setWorkspaceFileSavingKey(null);
|
||||
setWorkspaceFileFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
sendWithRetry,
|
||||
setWorkspaceFileFeedback,
|
||||
setWorkspaceFileSavingKey,
|
||||
workspaceDraftContent
|
||||
]);
|
||||
|
||||
const handleUploadExternalSkill = useCallback(async (file) => {
|
||||
if (!(file instanceof File)) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "请选择 zip 文件后再上传" });
|
||||
return;
|
||||
}
|
||||
if (!selectedSkillAgentId) {
|
||||
setAgentSkillsFeedback({ type: "error", text: "未选择目标 Agent" });
|
||||
return;
|
||||
}
|
||||
setAgentSkillsSavingKey(`${selectedSkillAgentId}:__upload__`);
|
||||
setAgentSkillsFeedback(null);
|
||||
try {
|
||||
const result = await uploadAgentSkillZip({
|
||||
agentId: selectedSkillAgentId,
|
||||
file,
|
||||
activate: true
|
||||
});
|
||||
setAgentSkillsFeedback({
|
||||
type: "success",
|
||||
text: `已上传并安装技能 ${result.skill_name || ""}`.trim()
|
||||
});
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
} catch (error) {
|
||||
setAgentSkillsFeedback({
|
||||
type: "error",
|
||||
text: `上传失败: ${error.message || "未知错误"}`
|
||||
});
|
||||
} finally {
|
||||
setAgentSkillsSavingKey(null);
|
||||
}
|
||||
}, [
|
||||
requestAgentSkills,
|
||||
selectedSkillAgentId,
|
||||
setAgentSkillsFeedback,
|
||||
setAgentSkillsSavingKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkspaceDraftContent(selectedWorkspaceContent);
|
||||
}, [selectedWorkspaceContent, setWorkspaceDraftContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders") {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
AGENTS.forEach((agent) => {
|
||||
if (!agentProfilesByAgent[agent.id]) {
|
||||
requestAgentProfile(agent.id);
|
||||
}
|
||||
if (!agentSkillsByAgent[agent.id]) {
|
||||
requestAgentSkills(agent.id);
|
||||
}
|
||||
if (!workspaceFilesByAgent[agent.id]?.["MEMORY.md"]) {
|
||||
requestWorkspaceFile(agent.id, "MEMORY.md");
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "traders" || !selectedSkillAgentId) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
if (!agentProfilesByAgent[selectedSkillAgentId]) {
|
||||
requestAgentProfile(selectedSkillAgentId);
|
||||
}
|
||||
if (!agentSkillsByAgent[selectedSkillAgentId]) {
|
||||
requestAgentSkills(selectedSkillAgentId);
|
||||
}
|
||||
if (selectedWorkspaceFile && !workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile]) {
|
||||
requestWorkspaceFile(selectedSkillAgentId, selectedWorkspaceFile);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [
|
||||
agentProfilesByAgent,
|
||||
agentSkillsByAgent,
|
||||
connectionStatus,
|
||||
currentView,
|
||||
isConnected,
|
||||
requestAgentProfile,
|
||||
requestAgentSkills,
|
||||
requestWorkspaceFile,
|
||||
selectedSkillAgentId,
|
||||
selectedWorkspaceFile,
|
||||
workspaceFilesByAgent
|
||||
]);
|
||||
|
||||
return {
|
||||
requestAgentSkills,
|
||||
requestAgentProfile,
|
||||
requestSkillDetail,
|
||||
requestWorkspaceFile,
|
||||
handleCreateLocalSkill,
|
||||
handleLocalSkillDraftChange,
|
||||
handleLocalSkillSave,
|
||||
handleLocalSkillDelete,
|
||||
handleRemoveSharedSkill,
|
||||
handleAgentSkillToggle,
|
||||
handleSkillAgentChange,
|
||||
handleWorkspaceFileChange,
|
||||
handleWorkspaceFileSave,
|
||||
handleUploadExternalSkill
|
||||
};
|
||||
}
|
||||
445
frontend/src/hooks/useFeedProcessor.js
Normal file
445
frontend/src/hooks/useFeedProcessor.js
Normal file
@@ -0,0 +1,445 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { AGENTS } from "../config/constants";
|
||||
|
||||
const MAX_FEED_ITEMS = 200;
|
||||
|
||||
const normalizeSystemContent = (content) => {
|
||||
if (typeof content !== "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed === "Runtime assets reloaded." || trimmed === "运行时配置已热更新") {
|
||||
return "配置已刷新";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("Watchlist updated:")) {
|
||||
const symbols = trimmed.replace("Watchlist updated:", "").trim();
|
||||
return symbols ? `自选已更新: ${symbols}` : "自选已更新";
|
||||
}
|
||||
|
||||
if (trimmed === "已连接实时数据服务") {
|
||||
return "已连接";
|
||||
}
|
||||
|
||||
if (trimmed === "正在尝试连接数据服务...") {
|
||||
return "连接中...";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("day_start:")) {
|
||||
const value = trimmed.replace("day_start:", "").trim();
|
||||
return value ? `交易日开始:${value}` : "交易日开始";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("day_complete:")) {
|
||||
const value = trimmed.replace("day_complete:", "").trim();
|
||||
return value ? `交易日结束:${value}` : "交易日结束";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("day_error:")) {
|
||||
const value = trimmed.replace("day_error:", "").trim();
|
||||
return value ? `交易日异常:${value}` : "交易日异常";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeConferenceTitle = (title) => {
|
||||
if (typeof title !== "string") {
|
||||
return "投资讨论";
|
||||
}
|
||||
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) {
|
||||
return "投资讨论";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("Investment Discussion -")) {
|
||||
const date = trimmed.replace("Investment Discussion -", "").trim();
|
||||
return date ? `投资讨论 · ${date}` : "投资讨论";
|
||||
}
|
||||
|
||||
if (trimmed === "Team Conference") {
|
||||
return "投资讨论";
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const normalizeAgentLabel = (agentName, agentId) => {
|
||||
if (typeof agentName === "string") {
|
||||
const trimmed = agentName.trim();
|
||||
if (trimmed.toLowerCase() === "conference summary") {
|
||||
return "会议总结";
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof agentId === "string" && agentId.trim().toLowerCase() === "conference summary") {
|
||||
return "会议总结";
|
||||
}
|
||||
|
||||
return agentName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique ID for feed items
|
||||
*/
|
||||
const generateId = (prefix = "item") => `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
/**
|
||||
* Convert raw event to a message object (for use within conferences or standalone)
|
||||
*/
|
||||
const eventToMessage = (evt) => {
|
||||
if (!evt || !evt.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const agent = AGENTS.find(a => a.id === evt.agentId);
|
||||
const timestamp = evt.timestamp || evt.ts || Date.now();
|
||||
|
||||
switch (evt.type) {
|
||||
case "agent_message":
|
||||
case "conference_message":
|
||||
return {
|
||||
id: generateId("msg"),
|
||||
timestamp,
|
||||
agentId: evt.agentId,
|
||||
agent: normalizeAgentLabel(agent?.name || evt.agentName || evt.agentId || "Agent", evt.agentId),
|
||||
role: agent?.role || evt.role || "Agent",
|
||||
content: evt.content
|
||||
};
|
||||
|
||||
case "memory":
|
||||
return {
|
||||
id: generateId("memory"),
|
||||
timestamp,
|
||||
agentId: evt.agentId,
|
||||
agent: agent?.name || evt.agentId || "Memory",
|
||||
role: "Memory",
|
||||
content: evt.content || evt.text || ""
|
||||
};
|
||||
|
||||
case "system":
|
||||
case "day_start":
|
||||
case "day_complete":
|
||||
case "day_error":
|
||||
return {
|
||||
id: generateId("sys"),
|
||||
timestamp,
|
||||
agent: "System",
|
||||
role: "System",
|
||||
content: normalizeSystemContent(evt.content || `${evt.type}: ${evt.date || ""}`)
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert raw event to a standalone feed item (non-conference)
|
||||
*/
|
||||
const eventToFeedItem = (evt) => {
|
||||
if (!evt || !evt.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = eventToMessage(evt);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (evt.type === "memory") {
|
||||
return {
|
||||
type: "memory",
|
||||
id: message.id,
|
||||
data: {
|
||||
timestamp: message.timestamp,
|
||||
agentId: message.agentId,
|
||||
agent: message.agent,
|
||||
content: message.content
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "message",
|
||||
id: message.id,
|
||||
data: message
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook for processing feed events with conference aggregation
|
||||
*/
|
||||
export function useFeedProcessor() {
|
||||
const [feed, setFeed] = useState([]);
|
||||
|
||||
// Active conference ref for real-time event handling
|
||||
const activeConferenceRef = useRef(null);
|
||||
|
||||
/**
|
||||
* Process historical events from server
|
||||
* Events come in reverse chronological order (newest first)
|
||||
* So conference_end appears BEFORE conference_start in the array
|
||||
*/
|
||||
const processHistoricalFeed = useCallback((events) => {
|
||||
if (!Array.isArray(events)) {
|
||||
console.warn("processHistoricalFeed: expected array, got", typeof events);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📋 Processing historical events:", events.length);
|
||||
|
||||
const feedItems = [];
|
||||
let currentConference = null;
|
||||
|
||||
// Process in chronological order (reverse the array)
|
||||
const chronological = [...events].reverse();
|
||||
|
||||
for (const evt of chronological) {
|
||||
if (!evt || !evt.type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (evt.type === "conference_start") {
|
||||
// Start a new conference
|
||||
currentConference = {
|
||||
id: evt.conferenceId || generateId("conf"),
|
||||
title: normalizeConferenceTitle(evt.title || "Team Conference"),
|
||||
startTime: evt.timestamp || evt.ts || Date.now(),
|
||||
endTime: null,
|
||||
isLive: false,
|
||||
participants: evt.participants || [],
|
||||
messages: []
|
||||
};
|
||||
} else if (evt.type === "conference_end") {
|
||||
// End current conference
|
||||
if (currentConference) {
|
||||
currentConference.endTime = evt.timestamp || evt.ts || Date.now();
|
||||
currentConference.isLive = false;
|
||||
feedItems.push({
|
||||
type: "conference",
|
||||
id: currentConference.id,
|
||||
data: currentConference
|
||||
});
|
||||
currentConference = null;
|
||||
}
|
||||
} else if (evt.type === "conference_message") {
|
||||
// Add to current conference if exists
|
||||
const message = eventToMessage(evt);
|
||||
if (message && currentConference) {
|
||||
currentConference.messages.push(message);
|
||||
} else if (message) {
|
||||
// Fallback: show as standalone message if no active conference
|
||||
feedItems.push({
|
||||
type: "message",
|
||||
id: message.id,
|
||||
data: message
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Non-conference events
|
||||
const feedItem = eventToFeedItem(evt);
|
||||
if (feedItem) {
|
||||
if (currentConference) {
|
||||
// Add to conference messages
|
||||
currentConference.messages.push(feedItem.data);
|
||||
} else {
|
||||
feedItems.push(feedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing historical event:", evt.type, error);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's an unclosed conference, it's still live
|
||||
if (currentConference) {
|
||||
currentConference.isLive = true;
|
||||
feedItems.push({
|
||||
type: "conference",
|
||||
id: currentConference.id,
|
||||
data: currentConference
|
||||
});
|
||||
// Store as active for real-time updates
|
||||
activeConferenceRef.current = currentConference;
|
||||
console.log(`🔴 Restored active conference: ${currentConference.id} with ${currentConference.messages.length} messages`);
|
||||
}
|
||||
|
||||
// Reverse back to newest-first order
|
||||
setFeed(feedItems.reverse());
|
||||
console.log(`✅ Processed ${feedItems.length} feed items from ${events.length} events`);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Process a single real-time event
|
||||
* Handles conference aggregation for live events
|
||||
*/
|
||||
const processFeedEvent = useCallback((evt) => {
|
||||
if (!evt || !evt.type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle conference start
|
||||
if (evt.type === "conference_start") {
|
||||
const conference = {
|
||||
id: evt.conferenceId || generateId("conf"),
|
||||
title: normalizeConferenceTitle(evt.title || "Team Conference"),
|
||||
startTime: evt.timestamp || evt.ts || Date.now(),
|
||||
endTime: null,
|
||||
isLive: true,
|
||||
participants: evt.participants || [],
|
||||
messages: []
|
||||
};
|
||||
activeConferenceRef.current = conference;
|
||||
setFeed(prev => [{ type: "conference", id: conference.id, data: conference }, ...prev].slice(0, MAX_FEED_ITEMS));
|
||||
return conference;
|
||||
}
|
||||
|
||||
// Handle conference end
|
||||
if (evt.type === "conference_end") {
|
||||
const activeConf = activeConferenceRef.current;
|
||||
activeConferenceRef.current = null;
|
||||
|
||||
if (activeConf) {
|
||||
const ended = {
|
||||
...activeConf,
|
||||
endTime: evt.timestamp || evt.ts || Date.now(),
|
||||
isLive: false
|
||||
};
|
||||
setFeed(prev => prev.map(item =>
|
||||
item.type === "conference" && item.id === activeConf.id
|
||||
? { ...item, data: ended }
|
||||
: item
|
||||
));
|
||||
return ended;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle conference message
|
||||
if (evt.type === "conference_message") {
|
||||
const message = eventToMessage(evt);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeConf = activeConferenceRef.current;
|
||||
if (activeConf) {
|
||||
// Add to active conference
|
||||
const updated = {
|
||||
...activeConf,
|
||||
messages: [...activeConf.messages, message]
|
||||
};
|
||||
activeConferenceRef.current = updated;
|
||||
setFeed(prev => prev.map(item =>
|
||||
item.type === "conference" && item.id === activeConf.id
|
||||
? { ...item, data: updated }
|
||||
: item
|
||||
));
|
||||
return message;
|
||||
} else {
|
||||
// No active conference, show as standalone
|
||||
const feedItem = { type: "message", id: message.id, data: message };
|
||||
setFeed(prev => [feedItem, ...prev].slice(0, MAX_FEED_ITEMS));
|
||||
return feedItem;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other feed events (agent_message, memory, system, etc.)
|
||||
const feedEventTypes = ["agent_message", "memory", "system", "day_start", "day_complete", "day_error"];
|
||||
if (!feedEventTypes.includes(evt.type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const feedItem = eventToFeedItem(evt);
|
||||
if (!feedItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeConf = activeConferenceRef.current;
|
||||
if (activeConf) {
|
||||
// Add to active conference
|
||||
const updated = {
|
||||
...activeConf,
|
||||
messages: [...activeConf.messages, feedItem.data]
|
||||
};
|
||||
activeConferenceRef.current = updated;
|
||||
setFeed(prev => prev.map(item =>
|
||||
item.type === "conference" && item.id === activeConf.id
|
||||
? { ...item, data: updated }
|
||||
: item
|
||||
));
|
||||
return feedItem.data;
|
||||
} else {
|
||||
// No active conference, add as standalone
|
||||
setFeed(prev => [feedItem, ...prev].slice(0, MAX_FEED_ITEMS));
|
||||
return feedItem;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add a system message to the feed
|
||||
*/
|
||||
const addSystemMessage = useCallback((content) => {
|
||||
const message = {
|
||||
id: generateId("sys"),
|
||||
timestamp: Date.now(),
|
||||
agent: "System",
|
||||
role: "System",
|
||||
content: normalizeSystemContent(content)
|
||||
};
|
||||
|
||||
const activeConf = activeConferenceRef.current;
|
||||
if (activeConf) {
|
||||
const updated = {
|
||||
...activeConf,
|
||||
messages: [...activeConf.messages, message]
|
||||
};
|
||||
activeConferenceRef.current = updated;
|
||||
setFeed(prev => prev.map(item =>
|
||||
item.type === "conference" && item.id === activeConf.id
|
||||
? { ...item, data: updated }
|
||||
: item
|
||||
));
|
||||
} else {
|
||||
const feedItem = { type: "message", id: message.id, data: message };
|
||||
setFeed(prev => [feedItem, ...prev].slice(0, MAX_FEED_ITEMS));
|
||||
}
|
||||
return message;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear all feed items and reset active conference
|
||||
*/
|
||||
const clearFeed = useCallback(() => {
|
||||
setFeed([]);
|
||||
activeConferenceRef.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if there's an active conference
|
||||
*/
|
||||
const hasActiveConference = useCallback(() => {
|
||||
return activeConferenceRef.current !== null;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
feed,
|
||||
setFeed,
|
||||
processHistoricalFeed,
|
||||
processFeedEvent,
|
||||
addSystemMessage,
|
||||
clearFeed,
|
||||
hasActiveConference
|
||||
};
|
||||
}
|
||||
|
||||
export default useFeedProcessor;
|
||||
356
frontend/src/hooks/useOpenClawPanel.js
Normal file
356
frontend/src/hooks/useOpenClawPanel.js
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useCallback } from "react";
|
||||
import { useOpenClawStore } from "../store/openclawStore";
|
||||
|
||||
const RETRY_DELAY_MS = 250;
|
||||
|
||||
function sendWithRetry(clientRef, payload, retries = 3) {
|
||||
const attemptSend = (remaining) => {
|
||||
const client = clientRef.current;
|
||||
if (!client) return false;
|
||||
const sent = client.send(typeof payload === "string" ? payload : JSON.stringify(payload));
|
||||
if (sent || remaining <= 0) return sent;
|
||||
window.setTimeout(() => attemptSend(remaining - 1), RETRY_DELAY_MS);
|
||||
return false;
|
||||
};
|
||||
return attemptSend(retries);
|
||||
}
|
||||
|
||||
export function useOpenClawPanel() {
|
||||
// Access store state directly — do NOT destructure store as a useCallback dep
|
||||
// or every store update will recreate all callbacks and trigger infinite loops.
|
||||
const getStore = () => useOpenClawStore.getState();
|
||||
|
||||
const requestStatus = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setStatusLoading(true);
|
||||
store.setStatusError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_status" });
|
||||
}, []);
|
||||
|
||||
const requestSessions = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSessionsLoading(true);
|
||||
store.setSessionsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_sessions" });
|
||||
}, []);
|
||||
|
||||
const requestSessionDetail = useCallback((sessionKey) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSelectedSessionKey(sessionKey);
|
||||
store.setSessionDetailLoading(true);
|
||||
store.setSessionDetailError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_session_detail", session_key: sessionKey });
|
||||
}, []);
|
||||
|
||||
const requestSessionHistory = useCallback((sessionKey, limit = 20) => {
|
||||
const client = getStore().clientRef?.current;
|
||||
if (!client) return;
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "get_openclaw_session_history",
|
||||
session_key: sessionKey,
|
||||
limit,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resolveSession = useCallback(({ agentId, label = null, channel = null, includeGlobal = true }) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setChatError?.(null);
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_resolve_session",
|
||||
agent_id: agentId,
|
||||
label,
|
||||
channel,
|
||||
include_global: includeGlobal,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const createSession = useCallback(({ agentId, label = null, model = null, initialMessage = null }) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !agentId) return;
|
||||
store.setChatError?.(null);
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_create_session",
|
||||
agent_id: agentId,
|
||||
label,
|
||||
model,
|
||||
initial_message: initialMessage,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const subscribeSession = useCallback((sessionKey) => {
|
||||
const client = getStore().clientRef?.current;
|
||||
if (!client || !sessionKey) return;
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_subscribe_session",
|
||||
session_key: sessionKey,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unsubscribeSession = useCallback((sessionKey) => {
|
||||
const client = getStore().clientRef?.current;
|
||||
if (!client || !sessionKey) return;
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_unsubscribe_session",
|
||||
session_key: sessionKey,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetSession = useCallback((sessionKey) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !sessionKey) return;
|
||||
store.setChatError?.(null);
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_reset_session",
|
||||
session_key: sessionKey,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const deleteSession = useCallback((sessionKey) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !sessionKey) return;
|
||||
store.setChatError?.(null);
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_delete_session",
|
||||
session_key: sessionKey,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sendSessionMessage = useCallback((sessionKey, message, thinking = null) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !sessionKey || !message?.trim()) return;
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_subscribe_session",
|
||||
session_key: sessionKey,
|
||||
});
|
||||
store.setOpenclawChatSendingForSession?.(sessionKey, true);
|
||||
store.setChatError?.(null);
|
||||
sendWithRetry({ current: client }, {
|
||||
type: "openclaw_send_message",
|
||||
session_key: sessionKey,
|
||||
message: message.trim(),
|
||||
thinking,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const requestCron = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setCronLoading(true);
|
||||
store.setCronError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_cron" });
|
||||
}, []);
|
||||
|
||||
const requestApprovals = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setApprovalsLoading(true);
|
||||
store.setApprovalsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_approvals" });
|
||||
}, []);
|
||||
|
||||
const requestAgents = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setAgentsLoading(true);
|
||||
store.setAgentsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_agents" });
|
||||
}, []);
|
||||
|
||||
const requestAgentsPresence = useCallback(() => {
|
||||
const client = getStore().clientRef?.current;
|
||||
if (!client) return;
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_agents_presence" });
|
||||
}, []);
|
||||
|
||||
const requestSkills = useCallback((agentId = null) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSkillsLoading(true);
|
||||
store.setSkillsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_skills", agent_id: agentId });
|
||||
}, []);
|
||||
|
||||
const requestModels = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsLoading(true);
|
||||
store.setModelsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models" });
|
||||
}, []);
|
||||
|
||||
const requestHooks = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setHooksLoading(true);
|
||||
store.setHooksError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_hooks" });
|
||||
}, []);
|
||||
|
||||
const requestPlugins = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setPluginsLoading(true);
|
||||
store.setPluginsError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_plugins" });
|
||||
}, []);
|
||||
|
||||
const requestSecretsAudit = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSecretsAuditLoading(true);
|
||||
store.setSecretsAuditError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_secrets_audit" });
|
||||
}, []);
|
||||
|
||||
const requestSecurityAudit = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSecurityAuditLoading(true);
|
||||
store.setSecurityAuditError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_security_audit" });
|
||||
}, []);
|
||||
|
||||
const requestDaemonStatus = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setDaemonStatusLoading(true);
|
||||
store.setDaemonStatusError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_daemon_status" });
|
||||
}, []);
|
||||
|
||||
const requestPairing = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setPairingLoading(true);
|
||||
store.setPairingError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_pairing" });
|
||||
}, []);
|
||||
|
||||
const requestQrCode = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setQrCodeLoading(true);
|
||||
store.setQrCodeError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_qr" });
|
||||
}, []);
|
||||
|
||||
const requestUpdateStatus = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setUpdateStatusLoading(true);
|
||||
store.setUpdateStatusError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_update_status" });
|
||||
}, []);
|
||||
|
||||
const requestModelsAliases = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsAliasesLoading(true);
|
||||
store.setModelsAliasesError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models_aliases" });
|
||||
}, []);
|
||||
|
||||
const requestModelsFallbacks = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsFallbacksLoading(true);
|
||||
store.setModelsFallbacksError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models_fallbacks" });
|
||||
}, []);
|
||||
|
||||
const requestModelsImageFallbacks = useCallback(() => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setModelsImageFallbacksLoading(true);
|
||||
store.setModelsImageFallbacksError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_models_image_fallbacks" });
|
||||
}, []);
|
||||
|
||||
const requestSkillUpdate = useCallback((slug = null, all = false) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client) return;
|
||||
store.setSkillUpdateLoading(true);
|
||||
store.setSkillUpdateError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_skill_update", slug, all });
|
||||
}, []);
|
||||
|
||||
const requestWorkspaceFiles = useCallback((workspace) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !workspace) return;
|
||||
store.setWorkspaceFilesLoading(true);
|
||||
store.setWorkspaceFilesError(null);
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_files", workspace });
|
||||
}, []);
|
||||
|
||||
const requestWorkspaceFile = useCallback((agent_id, file_name) => {
|
||||
const store = getStore();
|
||||
const client = store.clientRef?.current;
|
||||
if (!client || !agent_id || !file_name) return;
|
||||
console.log("[DEBUG] requestWorkspaceFile:", { type: "get_openclaw_workspace_file", agent_id, file_name });
|
||||
sendWithRetry({ current: client }, { type: "get_openclaw_workspace_file", agent_id, file_name });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
requestStatus,
|
||||
requestSessions,
|
||||
requestSessionDetail,
|
||||
requestSessionHistory,
|
||||
resolveSession,
|
||||
createSession,
|
||||
subscribeSession,
|
||||
unsubscribeSession,
|
||||
resetSession,
|
||||
deleteSession,
|
||||
sendSessionMessage,
|
||||
requestCron,
|
||||
requestApprovals,
|
||||
requestAgents,
|
||||
requestAgentsPresence,
|
||||
requestSkills,
|
||||
requestModels,
|
||||
requestHooks,
|
||||
requestPlugins,
|
||||
requestSecretsAudit,
|
||||
requestSecurityAudit,
|
||||
requestDaemonStatus,
|
||||
requestPairing,
|
||||
requestQrCode,
|
||||
requestUpdateStatus,
|
||||
requestModelsAliases,
|
||||
requestModelsFallbacks,
|
||||
requestModelsImageFallbacks,
|
||||
requestSkillUpdate,
|
||||
requestWorkspaceFiles,
|
||||
requestWorkspaceFile,
|
||||
};
|
||||
}
|
||||
581
frontend/src/hooks/useRuntimeControls.js
Normal file
581
frontend/src/hooks/useRuntimeControls.js
Normal file
@@ -0,0 +1,581 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { INITIAL_TICKERS } from "../config/constants";
|
||||
import { fetchRuntimeHistory, startRuntime } from "../services/runtimeApi";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeTickerSymbols,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
parseWatchlistInput
|
||||
} from "../services/runtimeControls";
|
||||
import { useAgentStore } from "../store/agentStore";
|
||||
import { useRuntimeStore } from "../store/runtimeStore";
|
||||
|
||||
const DEFAULT_SCHEDULE_MODE = "daily";
|
||||
const DEFAULT_INTERVAL_MINUTES = "60";
|
||||
const DEFAULT_TRIGGER_TIME = "now";
|
||||
const DEFAULT_MAX_COMM_CYCLES = "2";
|
||||
const DEFAULT_INITIAL_CASH = "100000";
|
||||
const DEFAULT_MARGIN_REQUIREMENT = "0";
|
||||
const DEFAULT_MODE = "live";
|
||||
const DEFAULT_POLL_INTERVAL = "10";
|
||||
|
||||
export function useRuntimeControls({ clientRef, currentTickers, addSystemMessage, onRuntimeStarted }) {
|
||||
const {
|
||||
runtimeConfig,
|
||||
setRuntimeConfig,
|
||||
isWatchlistPanelOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
setWatchlistInputValue,
|
||||
watchlistFeedback,
|
||||
setWatchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
setIsWatchlistSaving,
|
||||
launchModeDraft,
|
||||
setLaunchModeDraft,
|
||||
restoreRunIdDraft,
|
||||
setRestoreRunIdDraft,
|
||||
runtimeHistoryRuns,
|
||||
setRuntimeHistoryRuns,
|
||||
scheduleModeDraft,
|
||||
setScheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
setIntervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
setTriggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
setInitialCashDraft,
|
||||
marginRequirementDraft,
|
||||
setMarginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
setEnableMemoryDraft,
|
||||
modeDraft,
|
||||
setModeDraft,
|
||||
pollIntervalDraft,
|
||||
setPollIntervalDraft,
|
||||
startDateDraft,
|
||||
setStartDateDraft,
|
||||
endDateDraft,
|
||||
setEndDateDraft,
|
||||
runtimeConfigFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
} = useRuntimeStore();
|
||||
|
||||
const {
|
||||
setAgentSkillsFeedback,
|
||||
setWorkspaceFileFeedback
|
||||
} = useAgentStore();
|
||||
|
||||
const isWatchlistSavingRef = useRef(false);
|
||||
const isRuntimeConfigSavingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isWatchlistSavingRef.current = isWatchlistSaving;
|
||||
}, [isWatchlistSaving]);
|
||||
|
||||
useEffect(() => {
|
||||
isRuntimeConfigSavingRef.current = isRuntimeConfigSaving;
|
||||
}, [isRuntimeConfigSaving]);
|
||||
|
||||
const displayTickers = useMemo(
|
||||
() => normalizeTickerSymbols(runtimeConfig?.tickers, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeWatchlistSymbols = useMemo(
|
||||
() => normalizeRuntimeWatchlistSymbols(runtimeConfig, currentTickers),
|
||||
[currentTickers, runtimeConfig]
|
||||
);
|
||||
|
||||
const runtimeSummaryLabel = useMemo(
|
||||
() => buildRuntimeSummaryLabel(runtimeConfig),
|
||||
[runtimeConfig]
|
||||
);
|
||||
|
||||
const watchlistSuggestions = useMemo(
|
||||
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
|
||||
[]
|
||||
);
|
||||
|
||||
const isWatchlistDraftDirty = useMemo(() => {
|
||||
if (watchlistInputValue.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
|
||||
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((!isWatchlistPanelOpen && !isRuntimeSettingsOpen) || !isWatchlistDraftDirty) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
if (!isWatchlistPanelOpen && !isRuntimeSettingsOpen) {
|
||||
setWatchlistInputValue("");
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
runtimeWatchlistSymbols,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
setScheduleModeDraft(String(runtimeConfig.schedule_mode || DEFAULT_SCHEDULE_MODE));
|
||||
setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || DEFAULT_INTERVAL_MINUTES));
|
||||
setTriggerTimeDraft(String(runtimeConfig.trigger_time || DEFAULT_TRIGGER_TIME));
|
||||
setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || DEFAULT_MAX_COMM_CYCLES));
|
||||
setInitialCashDraft(String(runtimeConfig.initial_cash ?? DEFAULT_INITIAL_CASH));
|
||||
setMarginRequirementDraft(String(runtimeConfig.margin_requirement ?? DEFAULT_MARGIN_REQUIREMENT));
|
||||
setEnableMemoryDraft(Boolean(runtimeConfig.enable_memory ?? false));
|
||||
}, [
|
||||
runtimeConfig,
|
||||
setEnableMemoryDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setScheduleModeDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRuntimeSettingsOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void fetchRuntimeHistory(20)
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
const runs = Array.isArray(payload?.runs) ? payload.runs : [];
|
||||
setRuntimeHistoryRuns(runs);
|
||||
if (!restoreRunIdDraft && runs.length > 0) {
|
||||
setRestoreRunIdDraft(runs[0].run_id);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRuntimeHistoryRuns([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isRuntimeSettingsOpen, restoreRunIdDraft, setRestoreRunIdDraft, setRuntimeHistoryRuns]);
|
||||
|
||||
const commitWatchlistInput = useCallback((value) => {
|
||||
const parsed = parseWatchlistInput(value);
|
||||
if (parsed.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
|
||||
setWatchlistInputValue("");
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return parsed;
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRemove = useCallback((symbolToRemove) => {
|
||||
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistPanelToggle = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setIsWatchlistPanelOpen((open) => {
|
||||
const nextOpen = !open;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue
|
||||
]);
|
||||
|
||||
const handleWatchlistInputChange = useCallback((value) => {
|
||||
setWatchlistInputValue(value);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistInputValue, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistInputKeyDown = useCallback((event) => {
|
||||
if (event.key === "Enter" || event.key === ",") {
|
||||
event.preventDefault();
|
||||
commitWatchlistInput(watchlistInputValue);
|
||||
}
|
||||
}, [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
const handleWatchlistSuggestionClick = useCallback((symbol) => {
|
||||
if (watchlistDraftSymbols.includes(symbol)) {
|
||||
return;
|
||||
}
|
||||
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
|
||||
if (watchlistFeedback) {
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
}, [setWatchlistDraftSymbols, watchlistDraftSymbols, setWatchlistFeedback, watchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreCurrent = useCallback(() => {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [runtimeWatchlistSymbols, setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback]);
|
||||
|
||||
const handleWatchlistRestoreDefault = useCallback(() => {
|
||||
setWatchlistDraftSymbols(watchlistSuggestions);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}, [setWatchlistDraftSymbols, setWatchlistInputValue, setWatchlistFeedback, watchlistSuggestions]);
|
||||
|
||||
const handleWatchlistSave = useCallback(() => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setWatchlistFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
setWatchlistFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsWatchlistSaving(true);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue("");
|
||||
const success = clientRef.current.send({
|
||||
type: "update_watchlist",
|
||||
tickers: nextTickers
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsWatchlistSaving(false);
|
||||
setWatchlistFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
setIsWatchlistSaving,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeConfigSave = useCallback(() => {
|
||||
if (!clientRef.current) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "连接未就绪,稍后重试" });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
const success = clientRef.current.send({
|
||||
type: "update_runtime_config",
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: Number(initialCashDraft),
|
||||
margin_requirement: Number(marginRequirementDraft),
|
||||
enable_memory: Boolean(enableMemoryDraft)
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setRuntimeConfigFeedback({ type: "error", text: "发送失败,请检查连接状态" });
|
||||
}
|
||||
}, [
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
triggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleLaunchConfigSave = useCallback(async () => {
|
||||
const pendingTickers = parseWatchlistInput(watchlistInputValue);
|
||||
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
|
||||
if (nextTickers.length === 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "至少输入 1 个有效股票代码" });
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = Number(intervalMinutesDraft);
|
||||
const maxCommCycles = Number(maxCommCyclesDraft);
|
||||
const initialCash = Number(initialCashDraft);
|
||||
const marginRequirement = Number(marginRequirementDraft);
|
||||
if (!Number.isInteger(interval) || interval <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "间隔必须是正整数分钟" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "讨论轮数必须是正整数" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(initialCash) || initialCash <= 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "初始资金必须是正数" });
|
||||
return;
|
||||
}
|
||||
if (!Number.isFinite(marginRequirement) || marginRequirement < 0) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "保证金要求不能为负数" });
|
||||
return;
|
||||
}
|
||||
if (launchModeDraft === "restore" && !restoreRunIdDraft) {
|
||||
setRuntimeConfigFeedback({ type: "error", text: "请选择一个历史任务用于恢复启动" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRuntimeConfigSaving(true);
|
||||
setIsWatchlistSaving(true);
|
||||
setRuntimeConfigFeedback(null);
|
||||
setWatchlistFeedback(null);
|
||||
setWatchlistDraftSymbols(nextTickers);
|
||||
setWatchlistInputValue("");
|
||||
|
||||
try {
|
||||
const result = await startRuntime({
|
||||
launch_mode: launchModeDraft,
|
||||
restore_run_id: launchModeDraft === "restore" ? restoreRunIdDraft : null,
|
||||
tickers: nextTickers,
|
||||
schedule_mode: scheduleModeDraft,
|
||||
interval_minutes: interval,
|
||||
trigger_time: triggerTimeDraft,
|
||||
max_comm_cycles: maxCommCycles,
|
||||
initial_cash: initialCash,
|
||||
margin_requirement: marginRequirement,
|
||||
enable_memory: Boolean(enableMemoryDraft),
|
||||
mode: modeDraft || DEFAULT_MODE,
|
||||
poll_interval: Number(pollIntervalDraft) || Number(DEFAULT_POLL_INTERVAL),
|
||||
start_date: startDateDraft || null,
|
||||
end_date: endDateDraft || null,
|
||||
});
|
||||
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "success",
|
||||
text: `任务已启动: ${result.run_id}`
|
||||
});
|
||||
addSystemMessage(`新任务已启动: ${result.run_id}`);
|
||||
onRuntimeStarted?.(result);
|
||||
} catch (error) {
|
||||
setIsRuntimeConfigSaving(false);
|
||||
setIsWatchlistSaving(false);
|
||||
setRuntimeConfigFeedback({
|
||||
type: "error",
|
||||
text: `启动失败: ${error.message}`
|
||||
});
|
||||
}
|
||||
}, [
|
||||
addSystemMessage,
|
||||
clientRef,
|
||||
enableMemoryDraft,
|
||||
endDateDraft,
|
||||
initialCashDraft,
|
||||
intervalMinutesDraft,
|
||||
launchModeDraft,
|
||||
marginRequirementDraft,
|
||||
maxCommCyclesDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
restoreRunIdDraft,
|
||||
scheduleModeDraft,
|
||||
setIsRuntimeConfigSaving,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistSaving,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
startDateDraft,
|
||||
onRuntimeStarted,
|
||||
triggerTimeDraft,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue
|
||||
]);
|
||||
|
||||
const handleRuntimeDefaultsRestore = useCallback(() => {
|
||||
setScheduleModeDraft(DEFAULT_SCHEDULE_MODE);
|
||||
setIntervalMinutesDraft(DEFAULT_INTERVAL_MINUTES);
|
||||
setTriggerTimeDraft(DEFAULT_TRIGGER_TIME);
|
||||
setMaxCommCyclesDraft(DEFAULT_MAX_COMM_CYCLES);
|
||||
setInitialCashDraft(DEFAULT_INITIAL_CASH);
|
||||
setMarginRequirementDraft(DEFAULT_MARGIN_REQUIREMENT);
|
||||
setEnableMemoryDraft(false);
|
||||
setLaunchModeDraft("fresh");
|
||||
setRestoreRunIdDraft("");
|
||||
setModeDraft(DEFAULT_MODE);
|
||||
setPollIntervalDraft(DEFAULT_POLL_INTERVAL);
|
||||
setStartDateDraft("");
|
||||
setEndDateDraft("");
|
||||
setRuntimeConfigFeedback(null);
|
||||
}, [
|
||||
setEnableMemoryDraft,
|
||||
setEndDateDraft,
|
||||
setInitialCashDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setLaunchModeDraft,
|
||||
setMarginRequirementDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setRestoreRunIdDraft,
|
||||
setRuntimeConfigFeedback,
|
||||
setScheduleModeDraft,
|
||||
setStartDateDraft,
|
||||
setTriggerTimeDraft
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsToggle = useCallback(() => {
|
||||
setRuntimeConfigFeedback(null);
|
||||
setAgentSkillsFeedback(null);
|
||||
setWorkspaceFileFeedback(null);
|
||||
setIsRuntimeSettingsOpen((prev) => {
|
||||
const nextOpen = !prev;
|
||||
if (nextOpen) {
|
||||
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
|
||||
setWatchlistInputValue("");
|
||||
setWatchlistFeedback(null);
|
||||
}
|
||||
return nextOpen;
|
||||
});
|
||||
setIsWatchlistPanelOpen(false);
|
||||
}, [
|
||||
runtimeWatchlistSymbols,
|
||||
setAgentSkillsFeedback,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setIsWatchlistPanelOpen,
|
||||
setRuntimeConfigFeedback,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistFeedback,
|
||||
setWatchlistInputValue,
|
||||
setWorkspaceFileFeedback
|
||||
]);
|
||||
|
||||
const handleRuntimeSettingsClose = useCallback(() => {
|
||||
setIsRuntimeSettingsOpen(false);
|
||||
}, [setIsRuntimeSettingsOpen]);
|
||||
|
||||
const handleWatchlistAdd = useCallback(() => commitWatchlistInput(watchlistInputValue), [commitWatchlistInput, watchlistInputValue]);
|
||||
|
||||
return {
|
||||
runtimeConfig,
|
||||
displayTickers,
|
||||
runtimeWatchlistSymbols,
|
||||
runtimeSummaryLabel,
|
||||
watchlistSuggestions,
|
||||
isWatchlistDraftDirty,
|
||||
isWatchlistPanelOpen,
|
||||
isRuntimeSettingsOpen,
|
||||
watchlistDraftSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistFeedback,
|
||||
isWatchlistSaving,
|
||||
launchModeDraft,
|
||||
restoreRunIdDraft,
|
||||
runtimeHistoryRuns,
|
||||
scheduleModeDraft,
|
||||
intervalMinutesDraft,
|
||||
triggerTimeDraft,
|
||||
maxCommCyclesDraft,
|
||||
initialCashDraft,
|
||||
marginRequirementDraft,
|
||||
enableMemoryDraft,
|
||||
modeDraft,
|
||||
pollIntervalDraft,
|
||||
startDateDraft,
|
||||
endDateDraft,
|
||||
runtimeConfigFeedback,
|
||||
isRuntimeConfigSaving,
|
||||
isWatchlistSavingRef,
|
||||
isRuntimeConfigSavingRef,
|
||||
commitWatchlistInput,
|
||||
handleWatchlistRemove,
|
||||
handleWatchlistPanelToggle,
|
||||
handleWatchlistInputChange,
|
||||
handleWatchlistInputKeyDown,
|
||||
handleWatchlistSuggestionClick,
|
||||
handleWatchlistRestoreCurrent,
|
||||
handleWatchlistRestoreDefault,
|
||||
handleWatchlistSave,
|
||||
handleWatchlistAdd,
|
||||
handleRuntimeConfigSave,
|
||||
handleLaunchConfigSave,
|
||||
handleRuntimeDefaultsRestore,
|
||||
handleRuntimeSettingsToggle,
|
||||
handleRuntimeSettingsClose,
|
||||
setRuntimeConfig,
|
||||
setWatchlistDraftSymbols,
|
||||
setWatchlistInputValue,
|
||||
setWatchlistFeedback,
|
||||
setRuntimeConfigFeedback,
|
||||
setIsWatchlistPanelOpen,
|
||||
setIsRuntimeSettingsOpen,
|
||||
setScheduleModeDraft,
|
||||
setIntervalMinutesDraft,
|
||||
setTriggerTimeDraft,
|
||||
setMaxCommCyclesDraft,
|
||||
setInitialCashDraft,
|
||||
setMarginRequirementDraft,
|
||||
setEnableMemoryDraft,
|
||||
setLaunchModeDraft,
|
||||
setRestoreRunIdDraft,
|
||||
setModeDraft,
|
||||
setPollIntervalDraft,
|
||||
setStartDateDraft,
|
||||
setEndDateDraft,
|
||||
setIsWatchlistSaving,
|
||||
setIsRuntimeConfigSaving
|
||||
};
|
||||
}
|
||||
352
frontend/src/hooks/useStockDataRequests.js
Normal file
352
frontend/src/hooks/useStockDataRequests.js
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useMarketStore } from '../store/marketStore';
|
||||
import { useRuntimeStore } from '../store/runtimeStore';
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from '../services/newsApi';
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from '../services/tradingApi';
|
||||
|
||||
/**
|
||||
* Custom hook for stock data request callbacks.
|
||||
* Takes clientRef, calls store setters directly.
|
||||
*/
|
||||
export function useStockDataRequests(clientRef, { setRequestStockHistory, setRequestStockNewsTimeline, setRequestStockNewsCategories }) {
|
||||
const requestedStockHistoryRef = useRef(new Set());
|
||||
|
||||
const { currentDate } = useRuntimeStore();
|
||||
const { setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker,
|
||||
setNewsByTicker, setInsiderTradesByTicker } = useMarketStore();
|
||||
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) return null;
|
||||
return { timestamp: String(timestamp), label: String(timestamp), price };
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: 'trading_service' }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct stock-history fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: 'get_stock_history',
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 });
|
||||
if (success) requestedStockHistoryRef.current.add(normalized);
|
||||
return success;
|
||||
}, [clientRef, currentDate, setOhlcHistoryByTicker, setPriceHistoryByTicker, setHistorySourceByTicker]);
|
||||
|
||||
const requestStockExplainEvents = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNews = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === 'string' ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: { ...((prev[normalized] && prev[normalized].byDate) || {}), [targetDate]: news },
|
||||
byDateFreshness: { ...((prev[normalized] && prev[normalized].byDateFreshness) || {}), [targetDate]: freshness }
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-for-date fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct news-categories fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 });
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: { ticker: normalized, startDate, endDate, trades: rows }
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct insider-trades fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_insider_trades', ticker: normalized, start_date: startDate, end_date: endDate, limit: 50 });
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_technical_indicators', ticker: normalized });
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !startDate || !endDate) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) return;
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: { ...result, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct range explain fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === 'string' ? payload.as_of_date.trim() : '';
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: { story: payload.story || '', source: payload.source || 'news_service', asOfDate: storyDate, freshness }
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct story fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !date) return false;
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === 'string' ? payload.target_date.trim() : date;
|
||||
if (!targetDate) return;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Direct similar-days fetch failed, falling back to websocket:', error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) return false;
|
||||
return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK });
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
|
||||
if (!normalized || !clientRef.current) return false;
|
||||
const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : '';
|
||||
const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : '';
|
||||
if (!startDate || !endDate) return false;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null }
|
||||
}
|
||||
}));
|
||||
return clientRef.current.send({
|
||||
type: 'run_stock_enrich',
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
force: Boolean(options.force),
|
||||
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||
rebuild_story: Boolean(options.rebuildStory),
|
||||
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||
story_date: options.storyDate || null,
|
||||
target_date: options.targetDate || null
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
// Register request functions with WebSocket connection hook
|
||||
if (setRequestStockHistory) setRequestStockHistory(requestStockHistory);
|
||||
if (setRequestStockNewsTimeline) setRequestStockNewsTimeline(requestStockNewsTimeline);
|
||||
if (setRequestStockNewsCategories) setRequestStockNewsCategories(requestStockNewsCategories);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
546
frontend/src/hooks/useStockExplainData.js
Normal file
546
frontend/src/hooks/useStockExplainData.js
Normal file
@@ -0,0 +1,546 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import {
|
||||
fetchNewsCategoriesDirect,
|
||||
fetchNewsForDateDirect,
|
||||
fetchRangeExplainDirect,
|
||||
fetchSimilarDaysDirect,
|
||||
fetchStockStoryDirect,
|
||||
hasDirectNewsService
|
||||
} from "../services/newsApi";
|
||||
import {
|
||||
fetchInsiderTradesDirect,
|
||||
fetchStockHistoryDirect,
|
||||
hasDirectTradingService
|
||||
} from "../services/tradingApi";
|
||||
|
||||
export function useStockExplainData({
|
||||
clientRef,
|
||||
currentDate,
|
||||
currentView,
|
||||
selectedExplainSymbol,
|
||||
requestedStockHistoryRef,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker,
|
||||
setHistorySourceByTicker,
|
||||
setNewsByTicker,
|
||||
setInsiderTradesByTicker
|
||||
}) {
|
||||
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!force && requestedStockHistoryRef.current.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 120);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchStockHistoryDirect(normalized, startDate, endDate)
|
||||
.then((payload) => {
|
||||
const prices = Array.isArray(payload?.prices) ? payload.prices : [];
|
||||
setOhlcHistoryByTicker((prev) => ({ ...prev, [normalized]: prices }));
|
||||
setPriceHistoryByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: prices
|
||||
.map((point) => {
|
||||
const price = Number(point?.close);
|
||||
const timestamp = point?.time;
|
||||
if (!timestamp || !Number.isFinite(price)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
timestamp: String(timestamp),
|
||||
label: String(timestamp),
|
||||
price
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
}));
|
||||
setHistorySourceByTicker((prev) => ({ ...prev, [normalized]: "trading_service" }));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct stock-history fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const success = clientRef.current.send({
|
||||
type: "get_stock_history",
|
||||
ticker: normalized,
|
||||
lookback_days: 120
|
||||
});
|
||||
|
||||
if (success) {
|
||||
requestedStockHistoryRef.current.add(normalized);
|
||||
}
|
||||
|
||||
return success;
|
||||
}, [
|
||||
clientRef,
|
||||
currentDate,
|
||||
requestedStockHistoryRef,
|
||||
setHistorySourceByTicker,
|
||||
setOhlcHistoryByTicker,
|
||||
setPriceHistoryByTicker
|
||||
]);
|
||||
|
||||
const requestStockExplainEvents = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_explain_events",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNews = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news",
|
||||
ticker: normalized,
|
||||
lookback_days: 45,
|
||||
limit: 12
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsForDate = useCallback((symbol, date) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsForDateDirect(normalized, date, 20)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.date === "string" ? payload.date.trim() : date;
|
||||
const news = Array.isArray(payload?.news) ? payload.news : [];
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
byDate: {
|
||||
...((prev[normalized] && prev[normalized].byDate) || {}),
|
||||
[targetDate]: news
|
||||
},
|
||||
byDateFreshness: {
|
||||
...((prev[normalized] && prev[normalized].byDateFreshness) || {}),
|
||||
[targetDate]: freshness
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-for-date fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_for_date",
|
||||
ticker: normalized,
|
||||
date,
|
||||
limit: 20
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockNewsTimeline = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_timeline",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockNewsCategories = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endDate = currentDate
|
||||
? String(currentDate).slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
const end = new Date(`${endDate}T00:00:00`);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 90);
|
||||
const startDate = start.toISOString().slice(0, 10);
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchNewsCategoriesDirect(normalized, startDate, endDate, 200)
|
||||
.then((payload) => {
|
||||
const freshness = payload?.freshness || null;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
categories: payload?.categories || {},
|
||||
categoriesStartDate: startDate,
|
||||
categoriesEndDate: endDate,
|
||||
categoriesFreshness: freshness
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct news-categories fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_news_categories",
|
||||
ticker: normalized,
|
||||
lookback_days: 90
|
||||
});
|
||||
}, [clientRef, currentDate, setNewsByTicker]);
|
||||
|
||||
const requestStockInsiderTrades = useCallback((symbol, startDate = null, endDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectTradingService()) {
|
||||
void fetchInsiderTradesDirect(normalized, startDate, endDate, 50)
|
||||
.then((payload) => {
|
||||
const rows = Array.isArray(payload?.insider_trades) ? payload.insider_trades : [];
|
||||
setInsiderTradesByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
ticker: normalized,
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
trades: rows
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct insider-trades fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_insider_trades",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
limit: 50
|
||||
});
|
||||
}, [clientRef, setInsiderTradesByTicker]);
|
||||
|
||||
const requestStockTechnicalIndicators = useCallback((symbol) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_technical_indicators",
|
||||
ticker: normalized
|
||||
});
|
||||
}, [clientRef]);
|
||||
|
||||
const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchRangeExplainDirect(normalized, startDate, endDate, articleIds)
|
||||
.then((payload) => {
|
||||
const result = payload?.result && typeof payload.result === "object" ? payload.result : null;
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!result?.start_date || !result?.end_date) {
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${result.start_date}:${result.end_date}`;
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
rangeExplainCache: {
|
||||
...((prev[normalized] && prev[normalized].rangeExplainCache) || {}),
|
||||
[cacheKey]: {
|
||||
...result,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct range explain fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_range_explain",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
article_ids: Array.isArray(articleIds) ? articleIds : []
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockStory = useCallback((symbol, asOfDate = null) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchStockStoryDirect(normalized, asOfDate)
|
||||
.then((payload) => {
|
||||
const storyDate = typeof payload?.as_of_date === "string" ? payload.as_of_date.trim() : "";
|
||||
const freshness = payload?.freshness || null;
|
||||
if (!storyDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
storyCache: {
|
||||
...((prev[normalized] && prev[normalized].storyCache) || {}),
|
||||
[storyDate]: {
|
||||
story: payload.story || "",
|
||||
source: payload.source || "news_service",
|
||||
asOfDate: storyDate,
|
||||
freshness
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct story fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_story",
|
||||
ticker: normalized,
|
||||
as_of_date: asOfDate
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !date) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasDirectNewsService()) {
|
||||
void fetchSimilarDaysDirect(normalized, date, topK)
|
||||
.then((payload) => {
|
||||
const targetDate = typeof payload?.target_date === "string" ? payload.target_date.trim() : date;
|
||||
if (!targetDate) {
|
||||
return;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
similarDaysCache: {
|
||||
...((prev[normalized] && prev[normalized].similarDaysCache) || {}),
|
||||
[targetDate]: payload
|
||||
}
|
||||
}
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Direct similar-days fetch failed, falling back to websocket:", error);
|
||||
if (clientRef.current) {
|
||||
clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return clientRef.current.send({
|
||||
type: "get_stock_similar_days",
|
||||
ticker: normalized,
|
||||
date,
|
||||
top_k: topK
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
const requestStockEnrich = useCallback((symbol, options = {}) => {
|
||||
const normalized = typeof symbol === "string" ? symbol.trim().toUpperCase() : "";
|
||||
if (!normalized || !clientRef.current) {
|
||||
return false;
|
||||
}
|
||||
const startDate = typeof options.startDate === "string" ? options.startDate.trim() : "";
|
||||
const endDate = typeof options.endDate === "string" ? options.endDate.trim() : "";
|
||||
if (!startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
setNewsByTicker((prev) => ({
|
||||
...prev,
|
||||
[normalized]: {
|
||||
...(prev[normalized] || {}),
|
||||
maintenanceStatus: {
|
||||
running: true,
|
||||
error: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
stats: null
|
||||
}
|
||||
}
|
||||
}));
|
||||
return clientRef.current.send({
|
||||
type: "run_stock_enrich",
|
||||
ticker: normalized,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
force: Boolean(options.force),
|
||||
only_local_to_llm: Boolean(options.onlyLocalToLlm),
|
||||
rebuild_story: Boolean(options.rebuildStory),
|
||||
rebuild_similar_days: Boolean(options.rebuildSimilarDays),
|
||||
story_date: options.storyDate || null,
|
||||
target_date: options.targetDate || null
|
||||
});
|
||||
}, [clientRef, setNewsByTicker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView !== "explain" || !selectedExplainSymbol) {
|
||||
return;
|
||||
}
|
||||
requestStockHistory(selectedExplainSymbol);
|
||||
requestStockExplainEvents(selectedExplainSymbol);
|
||||
requestStockNews(selectedExplainSymbol);
|
||||
requestStockNewsTimeline(selectedExplainSymbol);
|
||||
requestStockNewsCategories(selectedExplainSymbol);
|
||||
requestStockStory(selectedExplainSymbol, currentDate);
|
||||
}, [
|
||||
currentDate,
|
||||
currentView,
|
||||
requestStockExplainEvents,
|
||||
requestStockHistory,
|
||||
requestStockNews,
|
||||
requestStockNewsCategories,
|
||||
requestStockNewsTimeline,
|
||||
requestStockStory,
|
||||
selectedExplainSymbol
|
||||
]);
|
||||
|
||||
return {
|
||||
requestStockHistory,
|
||||
requestStockExplainEvents,
|
||||
requestStockNews,
|
||||
requestStockNewsForDate,
|
||||
requestStockNewsTimeline,
|
||||
requestStockNewsCategories,
|
||||
requestStockInsiderTrades,
|
||||
requestStockTechnicalIndicators,
|
||||
requestStockRangeExplain,
|
||||
requestStockStory,
|
||||
requestStockSimilarDays,
|
||||
requestStockEnrich
|
||||
};
|
||||
}
|
||||
1492
frontend/src/hooks/useWebSocketConnection.js
Normal file
1492
frontend/src/hooks/useWebSocketConnection.js
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
29
frontend/src/hooks/useWebsocketSessionSync.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* useWebsocketSessionSync - DEPRECATED
|
||||
*
|
||||
* This hook is deprecated. WebSocket connection and event handling is now managed
|
||||
* by useWebSocketConnection.js. This file is kept for backwards compatibility
|
||||
* but will be removed in a future version.
|
||||
*
|
||||
* All functionality has been consolidated into:
|
||||
* - useWebSocketConnection.js: WebSocket lifecycle and event handlers
|
||||
* - useStockDataRequests.js: Stock data request callbacks
|
||||
* - useAgentDataRequests.js: Agent operation callbacks
|
||||
*/
|
||||
|
||||
import { useWebSocketConnection } from './useWebSocketConnection';
|
||||
|
||||
/**
|
||||
* @deprecated Use useWebSocketConnection directly instead.
|
||||
* This hook is a thin wrapper that delegates to useWebSocketConnection
|
||||
* for backwards compatibility.
|
||||
*/
|
||||
export function useWebsocketSessionSync(props) {
|
||||
// Delegate to useWebSocketConnection
|
||||
const { clientRef } = useWebSocketConnection();
|
||||
|
||||
// Return clientRef so existing code can still access it
|
||||
return { clientRef };
|
||||
}
|
||||
|
||||
export default useWebsocketSessionSync;
|
||||
68
frontend/src/index.css
Normal file
68
frontend/src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
8
frontend/src/main.jsx
Normal file
8
frontend/src/main.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
)
|
||||
110
frontend/src/services/newsApi.js
Normal file
110
frontend/src/services/newsApi.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
|
||||
const isLocalDevHost = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const host = String(window.location.hostname || '').trim().toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1';
|
||||
};
|
||||
const NEWS_SERVICE_BASE = trimTrailingSlash(import.meta.env.VITE_NEWS_SERVICE_URL || '') || (
|
||||
isLocalDevHost() ? 'http://localhost:8002' : ''
|
||||
);
|
||||
|
||||
export function hasDirectNewsService() {
|
||||
return Boolean(NEWS_SERVICE_BASE);
|
||||
}
|
||||
|
||||
export async function fetchStockStoryDirect(ticker, asOfDate) {
|
||||
if (!NEWS_SERVICE_BASE) {
|
||||
throw new Error('Direct news service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (asOfDate) {
|
||||
params.set('as_of_date', asOfDate);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const url = `${NEWS_SERVICE_BASE}/api/stories/${encodeURIComponent(ticker)}${query ? `?${query}` : ''}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchSimilarDaysDirect(ticker, date, topK = 8) {
|
||||
if (!NEWS_SERVICE_BASE) {
|
||||
throw new Error('Direct news service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('ticker', ticker);
|
||||
params.set('date', date);
|
||||
params.set('n_similar', String(topK));
|
||||
|
||||
const response = await fetch(`${NEWS_SERVICE_BASE}/api/similar-days?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchRangeExplainDirect(ticker, startDate, endDate, articleIds = []) {
|
||||
if (!NEWS_SERVICE_BASE) {
|
||||
throw new Error('Direct news service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('ticker', ticker);
|
||||
params.set('start_date', startDate);
|
||||
params.set('end_date', endDate);
|
||||
for (const articleId of Array.isArray(articleIds) ? articleIds : []) {
|
||||
params.append('article_ids', articleId);
|
||||
}
|
||||
|
||||
const response = await fetch(`${NEWS_SERVICE_BASE}/api/range-explain?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchNewsForDateDirect(ticker, date, limit = 20) {
|
||||
if (!NEWS_SERVICE_BASE) {
|
||||
throw new Error('Direct news service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('ticker', ticker);
|
||||
params.set('date', date);
|
||||
params.set('limit', String(limit));
|
||||
|
||||
const response = await fetch(`${NEWS_SERVICE_BASE}/api/news-for-date?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchNewsCategoriesDirect(ticker, startDate, endDate, limit = 200) {
|
||||
if (!NEWS_SERVICE_BASE) {
|
||||
throw new Error('Direct news service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('ticker', ticker);
|
||||
params.set('limit', String(limit));
|
||||
if (startDate) {
|
||||
params.set('start_date', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
params.set('end_date', endDate);
|
||||
}
|
||||
|
||||
const response = await fetch(`${NEWS_SERVICE_BASE}/api/categories?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
229
frontend/src/services/runtimeApi.js
Normal file
229
frontend/src/services/runtimeApi.js
Normal file
@@ -0,0 +1,229 @@
|
||||
import { startTransition } from 'react';
|
||||
import { CONTROL_API_BASE, RUNTIME_API_BASE } from '../config/constants';
|
||||
|
||||
async function safeFetch(basePath, endpoint) {
|
||||
const response = await fetch(`${basePath}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function safeRequest(basePath, endpoint, options = {}) {
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const response = await fetch(`${basePath}${endpoint}`, {
|
||||
headers: isFormData
|
||||
? { ...(options.headers || {}) }
|
||||
: {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {})
|
||||
},
|
||||
...options
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export function fetchRuntimeContext() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/context');
|
||||
}
|
||||
|
||||
export function fetchRuntimeAgents() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/agents');
|
||||
}
|
||||
|
||||
export function fetchRuntimeEvents() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/events');
|
||||
}
|
||||
|
||||
export function fetchRuntimeHistory(limit = 20) {
|
||||
return safeFetch(RUNTIME_API_BASE, `/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function fetchPendingApprovals() {
|
||||
return safeFetch(CONTROL_API_BASE, '/guard/pending');
|
||||
}
|
||||
|
||||
export function approvePendingApproval(approvalId) {
|
||||
return safeRequest(CONTROL_API_BASE, '/guard/approve', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
approval_id: approvalId,
|
||||
one_time: true,
|
||||
expires_in_minutes: 30
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function denyPendingApproval(approvalId, reason = 'Rejected from runtime panel') {
|
||||
return safeRequest(CONTROL_API_BASE, '/guard/deny', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
approval_id: approvalId,
|
||||
reason
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function loadAllRuntimeState(onSuccess, onError) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const [context, agents, approvals, events] = await Promise.all([
|
||||
fetchRuntimeContext(),
|
||||
fetchRuntimeAgents(),
|
||||
fetchPendingApprovals(),
|
||||
fetchRuntimeEvents()
|
||||
]);
|
||||
onSuccess({
|
||||
context,
|
||||
agents: agents.agents,
|
||||
approvals: approvals.approvals,
|
||||
events: events.events
|
||||
});
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new trading runtime with the given configuration.
|
||||
* If a runtime is already running, it will be forcefully stopped first.
|
||||
*/
|
||||
export function startRuntime(config) {
|
||||
return safeRequest(RUNTIME_API_BASE, '/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current running runtime.
|
||||
*/
|
||||
export function stopRuntime(force = true) {
|
||||
return safeRequest(RUNTIME_API_BASE, `/stop?force=${force}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the runtime with a new configuration.
|
||||
*/
|
||||
export function restartRuntime(config) {
|
||||
return safeRequest(RUNTIME_API_BASE, '/restart', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the currently running runtime.
|
||||
*/
|
||||
export function fetchCurrentRuntime() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/current');
|
||||
}
|
||||
|
||||
export function fetchRuntimeLogs() {
|
||||
return safeFetch(RUNTIME_API_BASE, '/logs');
|
||||
}
|
||||
|
||||
export function fetchAgentProfile(workspaceId, agentId) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/profile`);
|
||||
}
|
||||
|
||||
export function fetchAgentSkills(workspaceId, agentId) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills`);
|
||||
}
|
||||
|
||||
export function fetchAgentSkillDetail(workspaceId, agentId, skillName) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}`);
|
||||
}
|
||||
|
||||
export function fetchAgentWorkspaceFile(workspaceId, agentId, filename) {
|
||||
return safeFetch(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(filename)}`);
|
||||
}
|
||||
|
||||
export function createAgentLocalSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ skill_name: skillName })
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAgentLocalSkill(workspaceId, agentId, skillName, content) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local/${encodeURIComponent(skillName)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content })
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAgentLocalSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/local/${encodeURIComponent(skillName)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
export function enableAgentSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}/enable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function disableAgentSkill(workspaceId, agentId, skillName) {
|
||||
return safeRequest(CONTROL_API_BASE, `/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/${encodeURIComponent(skillName)}/disable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAgentWorkspaceFile(workspaceId, agentId, filename, content) {
|
||||
return fetch(`${CONTROL_API_BASE}/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/files/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain'
|
||||
},
|
||||
body: content
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadAgentSkillZip({
|
||||
agentId,
|
||||
file,
|
||||
activate = true,
|
||||
name,
|
||||
runId
|
||||
}) {
|
||||
if (!agentId) {
|
||||
throw new Error('agentId is required');
|
||||
}
|
||||
if (!(file instanceof File)) {
|
||||
throw new Error('valid zip file is required');
|
||||
}
|
||||
const runtime = runId ? { run_id: runId } : await fetchCurrentRuntime();
|
||||
const workspaceId = runtime?.run_id;
|
||||
if (!workspaceId) {
|
||||
throw new Error('未检测到正在运行的任务');
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('activate', String(Boolean(activate)));
|
||||
if (name && String(name).trim()) {
|
||||
formData.append('name', String(name).trim());
|
||||
}
|
||||
|
||||
return safeRequest(
|
||||
CONTROL_API_BASE,
|
||||
`/workspaces/${encodeURIComponent(workspaceId)}/agents/${encodeURIComponent(agentId)}/skills/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
}
|
||||
81
frontend/src/services/runtimeControls.js
Normal file
81
frontend/src/services/runtimeControls.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const normalizeSymbol = (symbol) => {
|
||||
if (typeof symbol !== "string") {
|
||||
return "";
|
||||
}
|
||||
return symbol.trim().toUpperCase();
|
||||
};
|
||||
|
||||
export const normalizeTickerSymbols = (symbols, previousTickers = []) => {
|
||||
if (!Array.isArray(symbols) || symbols.length === 0) {
|
||||
return previousTickers;
|
||||
}
|
||||
|
||||
return symbols
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
.reduce((acc, symbol) => {
|
||||
const existing = acc.find((ticker) => ticker.symbol === symbol);
|
||||
if (existing) {
|
||||
return acc;
|
||||
}
|
||||
const prior = previousTickers.find((ticker) => ticker.symbol === symbol);
|
||||
acc.push(
|
||||
prior || {
|
||||
symbol,
|
||||
price: null,
|
||||
change: null
|
||||
}
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const normalizeRuntimeWatchlistSymbols = (runtimeConfig, fallbackTickers = []) => {
|
||||
const runtimeSymbols = Array.isArray(runtimeConfig?.tickers)
|
||||
? runtimeConfig.tickers.map(normalizeSymbol).filter(Boolean)
|
||||
: [];
|
||||
|
||||
if (runtimeSymbols.length > 0) {
|
||||
return runtimeSymbols;
|
||||
}
|
||||
|
||||
return fallbackTickers
|
||||
.map((ticker) => normalizeSymbol(ticker?.symbol))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const parseWatchlistInput = (value) => {
|
||||
if (typeof value !== "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
value
|
||||
.split(/[\s,]+/)
|
||||
.map(normalizeSymbol)
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const buildRuntimeSummaryLabel = (runtimeConfig) => {
|
||||
if (!runtimeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scheduleMode = String(runtimeConfig.schedule_mode || "daily");
|
||||
const intervalMinutes = Number(runtimeConfig.interval_minutes || 60);
|
||||
const triggerTime = String(runtimeConfig.trigger_time || "now");
|
||||
const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2);
|
||||
|
||||
if (scheduleMode === "intraday") {
|
||||
return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
if (triggerTime.toLowerCase() === "now") {
|
||||
return `调度 daily / 立即执行 / 讨论 ${maxCommCycles} 轮`;
|
||||
}
|
||||
|
||||
return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`;
|
||||
};
|
||||
59
frontend/src/services/runtimeControls.test.js
Normal file
59
frontend/src/services/runtimeControls.test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRuntimeSummaryLabel,
|
||||
normalizeRuntimeWatchlistSymbols,
|
||||
normalizeTickerSymbols,
|
||||
parseWatchlistInput
|
||||
} from "./runtimeControls";
|
||||
|
||||
describe("runtimeControls", () => {
|
||||
it("normalizes ticker symbols while preserving existing entries", () => {
|
||||
const previous = [
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
];
|
||||
|
||||
expect(normalizeTickerSymbols(["aapl", "nvda", "MSFT"], previous)).toEqual([
|
||||
{ symbol: "AAPL", price: 10, change: 1 },
|
||||
{ symbol: "NVDA", price: null, change: null },
|
||||
{ symbol: "MSFT", price: 20, change: 2 }
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives runtime watchlist symbols from runtime config or fallback tickers", () => {
|
||||
const runtimeConfig = { tickers: ["tsla", "meta", "tsla"] };
|
||||
const fallbackTickers = [{ symbol: "AAPL" }, { symbol: "MSFT" }];
|
||||
|
||||
expect(normalizeRuntimeWatchlistSymbols(runtimeConfig, fallbackTickers)).toEqual([
|
||||
"TSLA",
|
||||
"META",
|
||||
"TSLA"
|
||||
]);
|
||||
expect(normalizeRuntimeWatchlistSymbols({}, fallbackTickers)).toEqual([
|
||||
"AAPL",
|
||||
"MSFT"
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses watchlist input tokens and removes duplicates", () => {
|
||||
expect(parseWatchlistInput(" aapl, msft nvda\nNVDA ")).toEqual([
|
||||
"AAPL",
|
||||
"MSFT",
|
||||
"NVDA"
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds runtime summary labels", () => {
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "daily",
|
||||
trigger_time: "09:30",
|
||||
max_comm_cycles: 3
|
||||
})).toBe("调度 daily / 09:30 ET / 讨论 3 轮");
|
||||
|
||||
expect(buildRuntimeSummaryLabel({
|
||||
schedule_mode: "intraday",
|
||||
interval_minutes: 15,
|
||||
max_comm_cycles: 2
|
||||
})).toBe("调度 intraday / 15m / 讨论 2 轮");
|
||||
});
|
||||
});
|
||||
55
frontend/src/services/tradingApi.js
Normal file
55
frontend/src/services/tradingApi.js
Normal file
@@ -0,0 +1,55 @@
|
||||
const trimTrailingSlash = (value) => value.replace(/\/+$/, '');
|
||||
const isLocalDevHost = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const host = String(window.location.hostname || '').trim().toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1';
|
||||
};
|
||||
|
||||
const TRADING_SERVICE_BASE = trimTrailingSlash(import.meta.env.VITE_TRADING_SERVICE_URL || '') || (
|
||||
isLocalDevHost() ? 'http://localhost:8001' : ''
|
||||
);
|
||||
|
||||
export function hasDirectTradingService() {
|
||||
return Boolean(TRADING_SERVICE_BASE);
|
||||
}
|
||||
|
||||
export async function fetchInsiderTradesDirect(ticker, startDate = null, endDate = null, limit = 50) {
|
||||
if (!TRADING_SERVICE_BASE) {
|
||||
throw new Error('Direct trading service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('ticker', ticker);
|
||||
params.set('limit', String(limit));
|
||||
if (startDate) {
|
||||
params.set('start_date', startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
params.set('end_date', endDate);
|
||||
}
|
||||
|
||||
const response = await fetch(`${TRADING_SERVICE_BASE}/api/insider-trades?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchStockHistoryDirect(ticker, startDate, endDate) {
|
||||
if (!TRADING_SERVICE_BASE) {
|
||||
throw new Error('Direct trading service is not configured');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('ticker', ticker);
|
||||
params.set('start_date', startDate);
|
||||
params.set('end_date', endDate);
|
||||
|
||||
const response = await fetch(`${TRADING_SERVICE_BASE}/api/prices?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
300
frontend/src/services/websocket.js
Normal file
300
frontend/src/services/websocket.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* WebSocket Client with Dynamic Port Resolution
|
||||
* Handles connection, reconnection, and heartbeat
|
||||
* Fetches Gateway port from API before connecting
|
||||
*/
|
||||
|
||||
import { RUNTIME_API_BASE, WS_URL } from "../config/constants";
|
||||
|
||||
// Global port cache
|
||||
let cachedGatewayPort = null;
|
||||
let cachedWsUrl = null;
|
||||
|
||||
/**
|
||||
* Fetch Gateway WebSocket port from API
|
||||
*/
|
||||
export async function fetchGatewayPort() {
|
||||
try {
|
||||
const response = await fetch(`${RUNTIME_API_BASE}/gateway/port`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
if (data.is_running && data.port) {
|
||||
cachedGatewayPort = data.port;
|
||||
cachedWsUrl = data.ws_url;
|
||||
return { status: "running", port: data.port, wsUrl: data.ws_url };
|
||||
}
|
||||
|
||||
return { status: "stopped", port: data.port || null, wsUrl: data.ws_url || null };
|
||||
} catch (error) {
|
||||
console.warn('[Gateway] Failed to fetch port:', error);
|
||||
return { status: "unavailable", port: null, wsUrl: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached or default WebSocket URL
|
||||
*/
|
||||
export function getWebSocketUrl() {
|
||||
if (cachedWsUrl) {
|
||||
return cachedWsUrl;
|
||||
}
|
||||
return WS_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached port (call when Gateway restarts)
|
||||
*/
|
||||
export function clearGatewayCache() {
|
||||
cachedGatewayPort = null;
|
||||
cachedWsUrl = null;
|
||||
}
|
||||
|
||||
export class ReadOnlyClient {
|
||||
constructor(onEvent, { wsUrl = null, reconnectDelay = 3000, heartbeatInterval = 5000 } = {}) {
|
||||
this.onEvent = onEvent;
|
||||
this.wsUrl = wsUrl; // null = auto-resolve from API
|
||||
this.baseReconnectDelay = reconnectDelay;
|
||||
this.reconnectDelay = reconnectDelay;
|
||||
this.maxReconnectDelay = 30000;
|
||||
this.heartbeatInterval = heartbeatInterval;
|
||||
this.ws = null;
|
||||
this.shouldReconnect = false;
|
||||
this.reconnectTimer = null;
|
||||
this.heartbeatTimer = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.lastPongTime = 0;
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.baseReconnectDelay;
|
||||
await this._connect();
|
||||
}
|
||||
|
||||
async _connect() {
|
||||
if (!this.shouldReconnect || this.isConnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
// Resolve WebSocket URL if not set
|
||||
let targetUrl = this.wsUrl;
|
||||
if (!targetUrl) {
|
||||
const gatewayInfo = await fetchGatewayPort();
|
||||
if (gatewayInfo?.status === "running") {
|
||||
// Always use the pre-configured WS_URL (which routes through the
|
||||
// frontend reverse-proxy in production). The ws_url returned by
|
||||
// the API points to localhost and is only useful server-side.
|
||||
targetUrl = WS_URL;
|
||||
console.log(`[WebSocket] Gateway is running, connecting via: ${targetUrl}`);
|
||||
} else if (gatewayInfo?.status === "unavailable") {
|
||||
targetUrl = WS_URL;
|
||||
console.log(`[WebSocket] Using default URL: ${targetUrl}`);
|
||||
} else {
|
||||
this.isConnecting = false;
|
||||
this._safeEmit({
|
||||
type: "system",
|
||||
content: "运行任务尚未启动,等待数据服务上线..."
|
||||
});
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this._connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any existing connection
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onclose = null;
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.close();
|
||||
}
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(targetUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectDelay = this.baseReconnectDelay;
|
||||
this.lastPongTime = Date.now();
|
||||
this._safeEmit({ type: "system", content: "已连接实时数据服务" });
|
||||
console.log("WebSocket connected to", targetUrl);
|
||||
this._startHeartbeat();
|
||||
this.isConnecting = false;
|
||||
};
|
||||
|
||||
this.ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
// Update pong time for any message (server is alive)
|
||||
this.lastPongTime = Date.now();
|
||||
|
||||
if (msg.type === "pong") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[WebSocket] Message received:", msg.type || "unknown");
|
||||
this._safeEmit(msg);
|
||||
} catch (e) {
|
||||
console.error("[WebSocket] Parse error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
this.isConnecting = false;
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
const code = event.code || "未知";
|
||||
console.log(`[WebSocket] Connection closed: Code=${code}, WasClean=${event.wasClean}`);
|
||||
|
||||
this._stopHeartbeat();
|
||||
this.ws = null;
|
||||
this.isConnecting = false;
|
||||
|
||||
// Always attempt reconnect if shouldReconnect is true
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectAttempts++;
|
||||
// Exponential backoff with cap
|
||||
this.reconnectDelay = Math.min(
|
||||
this.baseReconnectDelay * Math.pow(1.5, this.reconnectAttempts),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
this._safeEmit({
|
||||
type: "system",
|
||||
content: "正在尝试连接数据服务..."
|
||||
});
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WebSocket] Reconnect attempt ${this.reconnectAttempts}...`);
|
||||
this._connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] Connection error:", error);
|
||||
this.isConnecting = false;
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this._connect();
|
||||
}, this.reconnectDelay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_safeEmit(msg) {
|
||||
try {
|
||||
this.onEvent(msg);
|
||||
} catch (e) {
|
||||
console.error("[WebSocket] Error in event handler:", e);
|
||||
}
|
||||
}
|
||||
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
this.lastPongTime = Date.now();
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this._sendPing();
|
||||
|
||||
// Check for stale connection (no response in 60s)
|
||||
const timeSinceLastPong = Date.now() - this.lastPongTime;
|
||||
if (timeSinceLastPong > 60000 && this.ws) {
|
||||
console.warn("[WebSocket] Connection appears stale, forcing reconnect");
|
||||
this.ws.close();
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
|
||||
_sendPing() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ type: "ping" }));
|
||||
} catch (e) {
|
||||
console.error("Heartbeat send error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_stopHeartbeat() {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const messageStr = typeof message === "string" ? message : JSON.stringify(message);
|
||||
this.ws.send(messageStr);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error("Send error:", e);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.warn("WebSocket is not connected, cannot send message");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this._stopHeartbeat();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onclose = null;
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch (e) {
|
||||
console.error("Close error:", e);
|
||||
}
|
||||
}
|
||||
this.ws = null;
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect with new port (call after Gateway restart)
|
||||
*/
|
||||
async reconnectWithNewPort() {
|
||||
console.log("[WebSocket] Reconnecting with new port...");
|
||||
clearGatewayCache();
|
||||
this.disconnect();
|
||||
this.shouldReconnect = true;
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
62
frontend/src/store/agentStore.js
Normal file
62
frontend/src/store/agentStore.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Agent Store - Agent skills, profiles, workspaces
|
||||
*/
|
||||
export const useAgentStore = create((set) => ({
|
||||
// Selected agent for skill/workspace editing
|
||||
selectedSkillAgentId: null,
|
||||
setSelectedSkillAgentId: (selectedSkillAgentId) => set((state) => ({ selectedSkillAgentId: resolveValue(selectedSkillAgentId, state.selectedSkillAgentId) })),
|
||||
|
||||
// Agent profiles
|
||||
agentProfilesByAgent: {},
|
||||
setAgentProfilesByAgent: (agentProfilesByAgent) => set((state) => ({ agentProfilesByAgent: resolveValue(agentProfilesByAgent, state.agentProfilesByAgent) })),
|
||||
|
||||
// Agent skills
|
||||
agentSkillsByAgent: {},
|
||||
setAgentSkillsByAgent: (agentSkillsByAgent) => set((state) => ({ agentSkillsByAgent: resolveValue(agentSkillsByAgent, state.agentSkillsByAgent) })),
|
||||
|
||||
// Skill details
|
||||
skillDetailsByName: {},
|
||||
setSkillDetailsByName: (skillDetailsByName) => set((state) => ({ skillDetailsByName: resolveValue(skillDetailsByName, state.skillDetailsByName) })),
|
||||
|
||||
// Local skill drafts
|
||||
localSkillDraftsByKey: {},
|
||||
setLocalSkillDraftsByKey: (localSkillDraftsByKey) => set((state) => ({ localSkillDraftsByKey: resolveValue(localSkillDraftsByKey, state.localSkillDraftsByKey) })),
|
||||
|
||||
// Loading states
|
||||
isAgentSkillsLoading: false,
|
||||
setIsAgentSkillsLoading: (isAgentSkillsLoading) => set((state) => ({ isAgentSkillsLoading: resolveValue(isAgentSkillsLoading, state.isAgentSkillsLoading) })),
|
||||
|
||||
skillDetailLoadingKey: null,
|
||||
setSkillDetailLoadingKey: (skillDetailLoadingKey) => set((state) => ({ skillDetailLoadingKey: resolveValue(skillDetailLoadingKey, state.skillDetailLoadingKey) })),
|
||||
|
||||
agentSkillsSavingKey: null,
|
||||
setAgentSkillsSavingKey: (agentSkillsSavingKey) => set((state) => ({ agentSkillsSavingKey: resolveValue(agentSkillsSavingKey, state.agentSkillsSavingKey) })),
|
||||
|
||||
agentSkillsFeedback: null,
|
||||
setAgentSkillsFeedback: (agentSkillsFeedback) => set((state) => ({ agentSkillsFeedback: resolveValue(agentSkillsFeedback, state.agentSkillsFeedback) })),
|
||||
|
||||
// Workspace files
|
||||
selectedWorkspaceFile: null,
|
||||
setSelectedWorkspaceFile: (selectedWorkspaceFile) => set((state) => ({ selectedWorkspaceFile: resolveValue(selectedWorkspaceFile, state.selectedWorkspaceFile) })),
|
||||
|
||||
workspaceFilesByAgent: {},
|
||||
setWorkspaceFilesByAgent: (workspaceFilesByAgent) => set((state) => ({ workspaceFilesByAgent: resolveValue(workspaceFilesByAgent, state.workspaceFilesByAgent) })),
|
||||
|
||||
workspaceDraftContent: '',
|
||||
setWorkspaceDraftContent: (workspaceDraftContent) => set((state) => ({ workspaceDraftContent: resolveValue(workspaceDraftContent, state.workspaceDraftContent) })),
|
||||
|
||||
isWorkspaceFileLoading: false,
|
||||
setIsWorkspaceFileLoading: (isWorkspaceFileLoading) => set((state) => ({ isWorkspaceFileLoading: resolveValue(isWorkspaceFileLoading, state.isWorkspaceFileLoading) })),
|
||||
|
||||
workspaceFileSavingKey: null,
|
||||
setWorkspaceFileSavingKey: (workspaceFileSavingKey) => set((state) => ({ workspaceFileSavingKey: resolveValue(workspaceFileSavingKey, state.workspaceFileSavingKey) })),
|
||||
|
||||
workspaceFileFeedback: null,
|
||||
setWorkspaceFileFeedback: (workspaceFileFeedback) => set((state) => ({ workspaceFileFeedback: resolveValue(workspaceFileFeedback, state.workspaceFileFeedback) })),
|
||||
}));
|
||||
5
frontend/src/store/index.js
Normal file
5
frontend/src/store/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useRuntimeStore } from './runtimeStore';
|
||||
export { useMarketStore } from './marketStore';
|
||||
export { usePortfolioStore } from './portfolioStore';
|
||||
export { useAgentStore } from './agentStore';
|
||||
export { useUIStore } from './uiStore';
|
||||
48
frontend/src/store/marketStore.js
Normal file
48
frontend/src/store/marketStore.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Market Store - Market data, stock prices, news
|
||||
*/
|
||||
export const useMarketStore = create((set) => ({
|
||||
// Ticker prices
|
||||
tickers: [],
|
||||
setTickers: (tickers) => set((state) => ({ tickers: resolveValue(tickers, state.tickers) })),
|
||||
rollingTickers: {},
|
||||
setRollingTickers: (rollingTickers) => set((state) => ({ rollingTickers: resolveValue(rollingTickers, state.rollingTickers) })),
|
||||
|
||||
// Price history
|
||||
priceHistoryByTicker: {},
|
||||
setPriceHistoryByTicker: (priceHistoryByTicker) => set((state) => ({ priceHistoryByTicker: resolveValue(priceHistoryByTicker, state.priceHistoryByTicker) })),
|
||||
|
||||
// OHLC history
|
||||
ohlcHistoryByTicker: {},
|
||||
setOhlcHistoryByTicker: (ohlcHistoryByTicker) => set((state) => ({ ohlcHistoryByTicker: resolveValue(ohlcHistoryByTicker, state.ohlcHistoryByTicker) })),
|
||||
|
||||
// History source tracking
|
||||
historySourceByTicker: {},
|
||||
setHistorySourceByTicker: (historySourceByTicker) => set((state) => ({ historySourceByTicker: resolveValue(historySourceByTicker, state.historySourceByTicker) })),
|
||||
|
||||
// Explain events
|
||||
explainEventsByTicker: {},
|
||||
setExplainEventsByTicker: (explainEventsByTicker) => set((state) => ({ explainEventsByTicker: resolveValue(explainEventsByTicker, state.explainEventsByTicker) })),
|
||||
|
||||
// Selected explain symbol
|
||||
selectedExplainSymbol: '',
|
||||
setSelectedExplainSymbol: (selectedExplainSymbol) => set((state) => ({ selectedExplainSymbol: resolveValue(selectedExplainSymbol, state.selectedExplainSymbol) })),
|
||||
|
||||
// News by ticker
|
||||
newsByTicker: {},
|
||||
setNewsByTicker: (newsByTicker) => set((state) => ({ newsByTicker: resolveValue(newsByTicker, state.newsByTicker) })),
|
||||
|
||||
// Insider trades
|
||||
insiderTradesByTicker: {},
|
||||
setInsiderTradesByTicker: (insiderTradesByTicker) => set((state) => ({ insiderTradesByTicker: resolveValue(insiderTradesByTicker, state.insiderTradesByTicker) })),
|
||||
|
||||
// Technical indicators
|
||||
technicalIndicatorsByTicker: {},
|
||||
setTechnicalIndicatorsByTicker: (technicalIndicatorsByTicker) => set((state) => ({ technicalIndicatorsByTicker: resolveValue(technicalIndicatorsByTicker, state.technicalIndicatorsByTicker) })),
|
||||
}));
|
||||
354
frontend/src/store/openclawStore.js
Normal file
354
frontend/src/store/openclawStore.js
Normal file
@@ -0,0 +1,354 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export const useOpenClawStore = create(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Raw data
|
||||
openclawStatus: null,
|
||||
openclawSessions: [],
|
||||
openclawSessionsDefaults: null,
|
||||
openclawSessionDetail: null,
|
||||
openclawSessionHistory: [],
|
||||
openclawCronJobs: [],
|
||||
openclawApprovals: [],
|
||||
openclawResolvedSessionKey: null,
|
||||
openclawChatMessagesBySession: {},
|
||||
openclawChatDraftBySession: {},
|
||||
openclawChatSendingBySession: {},
|
||||
openclawSessionSubscriptions: {},
|
||||
|
||||
// Loading states
|
||||
isStatusLoading: false,
|
||||
isSessionsLoading: false,
|
||||
isSessionDetailLoading: false,
|
||||
isCronLoading: false,
|
||||
isApprovalsLoading: false,
|
||||
isChatSending: false,
|
||||
|
||||
// Error states
|
||||
statusError: null,
|
||||
sessionsError: null,
|
||||
sessionDetailError: null,
|
||||
cronError: null,
|
||||
approvalsError: null,
|
||||
chatError: null,
|
||||
|
||||
// Agents state
|
||||
agents: [],
|
||||
agentsLoading: false,
|
||||
agentsError: null,
|
||||
agentsPresence: {},
|
||||
|
||||
// Skills state
|
||||
skills: [],
|
||||
skillsLoading: false,
|
||||
skillsError: null,
|
||||
|
||||
// Models state
|
||||
models: [],
|
||||
modelsLoading: false,
|
||||
modelsError: null,
|
||||
|
||||
// Hooks state
|
||||
hooks: [],
|
||||
hooksLoading: false,
|
||||
hooksError: null,
|
||||
|
||||
// Plugins state
|
||||
plugins: [],
|
||||
pluginsLoading: false,
|
||||
pluginsError: null,
|
||||
|
||||
// Secrets audit state
|
||||
secretsAudit: null,
|
||||
secretsAuditLoading: false,
|
||||
secretsAuditError: null,
|
||||
|
||||
// Security audit state
|
||||
securityAudit: null,
|
||||
securityAuditLoading: false,
|
||||
securityAuditError: null,
|
||||
|
||||
// Daemon status state
|
||||
daemonStatus: null,
|
||||
daemonStatusLoading: false,
|
||||
daemonStatusError: null,
|
||||
|
||||
// Pairing state
|
||||
pairing: null,
|
||||
pairingLoading: false,
|
||||
pairingError: null,
|
||||
|
||||
// QR code state
|
||||
qrCode: null,
|
||||
qrCodeLoading: false,
|
||||
qrCodeError: null,
|
||||
|
||||
// Update status state
|
||||
updateStatus: null,
|
||||
updateStatusLoading: false,
|
||||
updateStatusError: null,
|
||||
|
||||
// Models aliases state
|
||||
modelsAliases: null,
|
||||
modelsAliasesLoading: false,
|
||||
modelsAliasesError: null,
|
||||
|
||||
// Models fallbacks state
|
||||
modelsFallbacks: [],
|
||||
modelsFallbacksLoading: false,
|
||||
modelsFallbacksError: null,
|
||||
|
||||
// Models image fallbacks state
|
||||
modelsImageFallbacks: [],
|
||||
modelsImageFallbacksLoading: false,
|
||||
modelsImageFallbacksError: null,
|
||||
|
||||
// Skill update state
|
||||
skillUpdate: null,
|
||||
skillUpdateLoading: false,
|
||||
skillUpdateError: null,
|
||||
|
||||
// Workspace files state (per agent, keyed by workspace path)
|
||||
workspaceFiles: {},
|
||||
workspaceFilesLoading: false,
|
||||
workspaceFilesError: null,
|
||||
|
||||
// Workspace file content (keyed by "agentId:filename")
|
||||
workspaceFileContent: {},
|
||||
|
||||
// Selected session key for detail/history drill-down
|
||||
selectedSessionKey: null,
|
||||
|
||||
// WebSocket client ref (set by App.jsx on connection)
|
||||
clientRef: null,
|
||||
setClientRef: (ref) => set({ clientRef: ref }),
|
||||
|
||||
// Setters
|
||||
setOpenclawStatus: (data) => set({ openclawStatus: data, statusError: null }),
|
||||
setOpenclawSessions: (data) => set({
|
||||
openclawSessions: data?.sessions || [],
|
||||
openclawSessionsDefaults: data?.defaults || null,
|
||||
sessionsError: null,
|
||||
}),
|
||||
appendOpenclawSession: (session) => set((state) => {
|
||||
const key = session?.key || session?.sessionKey;
|
||||
if (!key) {
|
||||
return {};
|
||||
}
|
||||
const existing = state.openclawSessions || [];
|
||||
const deduped = existing.filter((item) => (item?.key || item?.sessionKey) !== key);
|
||||
return { openclawSessions: [session, ...deduped] };
|
||||
}),
|
||||
removeOpenclawSession: (sessionKey) => set((state) => ({
|
||||
openclawSessions: (state.openclawSessions || []).filter(
|
||||
(item) => (item?.key || item?.sessionKey) !== sessionKey
|
||||
),
|
||||
selectedSessionKey:
|
||||
state.selectedSessionKey === sessionKey ? null : state.selectedSessionKey,
|
||||
})),
|
||||
setOpenclawSessionDetail: (data) => set({ openclawSessionDetail: data?.session || null, sessionDetailError: data?.error || null }),
|
||||
setOpenclawSessionHistory: (data) => set({ openclawSessionHistory: data?.history || [], sessionDetailError: data?.error || null }),
|
||||
setOpenclawCronJobs: (data) => set({ openclawCronJobs: data?.cron || [], cronError: null }),
|
||||
setOpenclawApprovals: (data) => set({ openclawApprovals: data?.approvals || [], approvalsError: null }),
|
||||
setOpenclawResolvedSessionKey: (key) => set({ openclawResolvedSessionKey: key || null }),
|
||||
setOpenclawChatDraft: (sessionKey, value) => set((state) => ({
|
||||
openclawChatDraftBySession: { ...state.openclawChatDraftBySession, [sessionKey]: value },
|
||||
})),
|
||||
appendOpenclawChatMessage: (sessionKey, message) => set((state) => {
|
||||
const current = state.openclawChatMessagesBySession[sessionKey] || [];
|
||||
const sameMessageIndex = current.findIndex((item) => {
|
||||
const sameId = Boolean(message?.id && item?.id && message.id === item.id);
|
||||
const sameMessageId = Boolean(
|
||||
message?.messageId &&
|
||||
item?.messageId &&
|
||||
message.messageId === item.messageId
|
||||
);
|
||||
const sameSeq = Boolean(
|
||||
message?.seq !== undefined &&
|
||||
message?.seq !== null &&
|
||||
item?.seq !== undefined &&
|
||||
item?.seq !== null &&
|
||||
message.seq === item.seq &&
|
||||
message?.role === item?.role
|
||||
);
|
||||
const incomingText = String(message?.text || '').trim();
|
||||
const existingText = String(item?.text || '').trim();
|
||||
const incomingTs = Date.parse(message?.timestamp || '');
|
||||
const existingTs = Date.parse(item?.timestamp || '');
|
||||
const nearInTime =
|
||||
Number.isFinite(incomingTs) &&
|
||||
Number.isFinite(existingTs) &&
|
||||
Math.abs(incomingTs - existingTs) < 1500;
|
||||
const sameAssistantText =
|
||||
message?.role === 'assistant' &&
|
||||
item?.role === 'assistant' &&
|
||||
incomingText &&
|
||||
existingText &&
|
||||
(
|
||||
incomingText === existingText ||
|
||||
incomingText.startsWith(existingText) ||
|
||||
existingText.startsWith(incomingText)
|
||||
) &&
|
||||
nearInTime;
|
||||
return sameId || sameMessageId || sameSeq || sameAssistantText;
|
||||
});
|
||||
|
||||
if (sameMessageIndex >= 0) {
|
||||
const next = [...current];
|
||||
next[sameMessageIndex] = { ...next[sameMessageIndex], ...message };
|
||||
return {
|
||||
openclawChatMessagesBySession: {
|
||||
...state.openclawChatMessagesBySession,
|
||||
[sessionKey]: next,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
openclawChatMessagesBySession: {
|
||||
...state.openclawChatMessagesBySession,
|
||||
[sessionKey]: [...current, message],
|
||||
},
|
||||
};
|
||||
}),
|
||||
replaceOpenclawChatHistory: (sessionKey, messages) => set((state) => {
|
||||
const incoming = Array.isArray(messages) ? messages : [];
|
||||
const existing = state.openclawChatMessagesBySession[sessionKey] || [];
|
||||
const merged = [];
|
||||
const seen = new Set();
|
||||
|
||||
const signatureFor = (message) => {
|
||||
if (!message) return "";
|
||||
if (message.id) return `id:${message.id}`;
|
||||
if (message.messageId) return `mid:${message.messageId}`;
|
||||
if (message.seq !== undefined && message.seq !== null) return `seq:${message.seq}`;
|
||||
return `txt:${message.role || ""}:${String(message.text || "").trim()}`;
|
||||
};
|
||||
|
||||
for (const message of [...incoming, ...existing]) {
|
||||
const signature = signatureFor(message);
|
||||
if (!signature || seen.has(signature)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(signature);
|
||||
merged.push(message);
|
||||
}
|
||||
|
||||
return {
|
||||
openclawChatMessagesBySession: {
|
||||
...state.openclawChatMessagesBySession,
|
||||
[sessionKey]: merged,
|
||||
},
|
||||
};
|
||||
}),
|
||||
setOpenclawChatSendingForSession: (sessionKey, value) => set((state) => ({
|
||||
openclawChatSendingBySession: { ...state.openclawChatSendingBySession, [sessionKey]: Boolean(value) },
|
||||
isChatSending: Boolean(value),
|
||||
})),
|
||||
setOpenclawSessionSubscribed: (sessionKey, value) => set((state) => ({
|
||||
openclawSessionSubscriptions: { ...state.openclawSessionSubscriptions, [sessionKey]: Boolean(value) },
|
||||
})),
|
||||
|
||||
setSelectedSessionKey: (key) => set({ selectedSessionKey: key }),
|
||||
|
||||
setStatusLoading: (v) => set({ isStatusLoading: v }),
|
||||
setSessionsLoading: (v) => set({ isSessionsLoading: v }),
|
||||
setSessionDetailLoading: (v) => set({ isSessionDetailLoading: v }),
|
||||
setCronLoading: (v) => set({ isCronLoading: v }),
|
||||
setApprovalsLoading: (v) => set({ isApprovalsLoading: v }),
|
||||
|
||||
setStatusError: (e) => set({ statusError: e }),
|
||||
setSessionsError: (e) => set({ sessionsError: e }),
|
||||
setSessionDetailError: (e) => set({ sessionDetailError: e }),
|
||||
setCronError: (e) => set({ cronError: e }),
|
||||
setApprovalsError: (e) => set({ approvalsError: e }),
|
||||
setChatError: (e) => set({ chatError: e }),
|
||||
|
||||
setAgents: (agents) => set({ agents }),
|
||||
setAgentsLoading: (loading) => set({ agentsLoading: loading }),
|
||||
setAgentsError: (error) => set({ agentsError: error }),
|
||||
setAgentsPresence: (presence) => set({ agentsPresence: presence }),
|
||||
setSkills: (skills) => set({ skills }),
|
||||
setSkillsLoading: (loading) => set({ skillsLoading: loading }),
|
||||
setSkillsError: (error) => set({ skillsError: error }),
|
||||
setModels: (models) => set({ models }),
|
||||
setModelsLoading: (loading) => set({ modelsLoading: loading }),
|
||||
setModelsError: (error) => set({ modelsError: error }),
|
||||
|
||||
setHooks: (hooks) => set({ hooks }),
|
||||
setHooksLoading: (loading) => set({ hooksLoading: loading }),
|
||||
setHooksError: (error) => set({ hooksError: error }),
|
||||
setPlugins: (plugins) => set({ plugins }),
|
||||
setPluginsLoading: (loading) => set({ pluginsLoading: loading }),
|
||||
setPluginsError: (error) => set({ pluginsError: error }),
|
||||
setSecretsAudit: (data) => set({ secretsAudit: data }),
|
||||
setSecretsAuditLoading: (loading) => set({ secretsAuditLoading: loading }),
|
||||
setSecretsAuditError: (error) => set({ secretsAuditError: error }),
|
||||
setSecurityAudit: (data) => set({ securityAudit: data }),
|
||||
setSecurityAuditLoading: (loading) => set({ securityAuditLoading: loading }),
|
||||
setSecurityAuditError: (error) => set({ securityAuditError: error }),
|
||||
setDaemonStatus: (data) => set({ daemonStatus: data }),
|
||||
setDaemonStatusLoading: (loading) => set({ daemonStatusLoading: loading }),
|
||||
setDaemonStatusError: (error) => set({ daemonStatusError: error }),
|
||||
setPairing: (data) => set({ pairing: data }),
|
||||
setPairingLoading: (loading) => set({ pairingLoading: loading }),
|
||||
setPairingError: (error) => set({ pairingError: error }),
|
||||
setQrCode: (data) => set({ qrCode: data }),
|
||||
setQrCodeLoading: (loading) => set({ qrCodeLoading: loading }),
|
||||
setQrCodeError: (error) => set({ qrCodeError: error }),
|
||||
setUpdateStatus: (data) => set({ updateStatus: data }),
|
||||
setUpdateStatusLoading: (loading) => set({ updateStatusLoading: loading }),
|
||||
setUpdateStatusError: (error) => set({ updateStatusError: error }),
|
||||
setModelsAliases: (data) => set({ modelsAliases: data }),
|
||||
setModelsAliasesLoading: (loading) => set({ modelsAliasesLoading: loading }),
|
||||
setModelsAliasesError: (error) => set({ modelsAliasesError: error }),
|
||||
setModelsFallbacks: (data) => set({ modelsFallbacks: data }),
|
||||
setModelsFallbacksLoading: (loading) => set({ modelsFallbacksLoading: loading }),
|
||||
setModelsFallbacksError: (error) => set({ modelsFallbacksError: error }),
|
||||
setModelsImageFallbacks: (data) => set({ modelsImageFallbacks: data }),
|
||||
setModelsImageFallbacksLoading: (loading) => set({ modelsImageFallbacksLoading: loading }),
|
||||
setModelsImageFallbacksError: (error) => set({ modelsImageFallbacksError: error }),
|
||||
setSkillUpdate: (data) => set({ skillUpdate: data }),
|
||||
setSkillUpdateLoading: (loading) => set({ skillUpdateLoading: loading }),
|
||||
setSkillUpdateError: (error) => set({ skillUpdateError: error }),
|
||||
|
||||
setWorkspaceFiles: (workspace, data) => set((state) => ({
|
||||
workspaceFiles: { ...state.workspaceFiles, [workspace]: data },
|
||||
})),
|
||||
setWorkspaceFilesLoading: (loading) => set({ workspaceFilesLoading: loading }),
|
||||
setWorkspaceFilesError: (error) => set({ workspaceFilesError: error }),
|
||||
setWorkspaceFileContent: (key, content) => set((state) => ({
|
||||
workspaceFileContent: { ...state.workspaceFileContent, [key]: content },
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "openclaw-store",
|
||||
// Skip persisting ephemeral UI state
|
||||
partialize: (state) => ({
|
||||
// Persist only data, not loading/error/UI states
|
||||
openclawStatus: state.openclawStatus,
|
||||
openclawSessions: state.openclawSessions,
|
||||
openclawCronJobs: state.openclawCronJobs,
|
||||
openclawApprovals: state.openclawApprovals,
|
||||
agents: state.agents,
|
||||
agentsPresence: state.agentsPresence,
|
||||
skills: state.skills,
|
||||
models: state.models,
|
||||
hooks: state.hooks,
|
||||
plugins: state.plugins,
|
||||
secretsAudit: state.secretsAudit,
|
||||
securityAudit: state.securityAudit,
|
||||
daemonStatus: state.daemonStatus,
|
||||
pairing: state.pairing,
|
||||
qrCode: state.qrCode,
|
||||
updateStatus: state.updateStatus,
|
||||
modelsAliases: state.modelsAliases,
|
||||
modelsFallbacks: state.modelsFallbacks,
|
||||
modelsImageFallbacks: state.modelsImageFallbacks,
|
||||
skillUpdate: state.skillUpdate,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
42
frontend/src/store/portfolioStore.js
Normal file
42
frontend/src/store/portfolioStore.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Portfolio Store - Portfolio data, holdings, trades, statistics
|
||||
*/
|
||||
export const usePortfolioStore = create((set) => ({
|
||||
// Portfolio data
|
||||
portfolioData: {
|
||||
netValue: 10000,
|
||||
pnl: 0,
|
||||
equity: [],
|
||||
baseline: [],
|
||||
baseline_vw: [],
|
||||
momentum: [],
|
||||
strategies: [],
|
||||
equity_return: 0,
|
||||
baseline_return: 0,
|
||||
baseline_vw_return: 0,
|
||||
momentum_return: 0,
|
||||
},
|
||||
setPortfolioData: (portfolioData) => set((state) => ({ portfolioData: resolveValue(portfolioData, state.portfolioData) })),
|
||||
|
||||
// Holdings
|
||||
holdings: [],
|
||||
setHoldings: (holdings) => set((state) => ({ holdings: resolveValue(holdings, state.holdings) })),
|
||||
|
||||
// Trades
|
||||
trades: [],
|
||||
setTrades: (trades) => set((state) => ({ trades: resolveValue(trades, state.trades) })),
|
||||
|
||||
// Statistics
|
||||
stats: null,
|
||||
setStats: (stats) => set((state) => ({ stats: resolveValue(stats, state.stats) })),
|
||||
|
||||
// Leaderboard
|
||||
leaderboard: [],
|
||||
setLeaderboard: (leaderboard) => set((state) => ({ leaderboard: resolveValue(leaderboard, state.leaderboard) })),
|
||||
}));
|
||||
102
frontend/src/store/runtimeStore.js
Normal file
102
frontend/src/store/runtimeStore.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* Runtime Store - Connection state and runtime configuration
|
||||
*/
|
||||
export const useRuntimeStore = create((set) => ({
|
||||
// Connection state
|
||||
isConnected: false,
|
||||
connectionStatus: 'connecting', // 'connecting' | 'connected' | 'disconnected'
|
||||
setIsConnected: (isConnected) => set((state) => ({ isConnected: resolveValue(isConnected, state.isConnected) })),
|
||||
setConnectionStatus: (connectionStatus) => set((state) => ({ connectionStatus: resolveValue(connectionStatus, state.connectionStatus) })),
|
||||
|
||||
// System state
|
||||
systemStatus: 'initializing', // 'initializing' | 'running' | 'completed'
|
||||
currentDate: null,
|
||||
setSystemStatus: (systemStatus) => set((state) => ({ systemStatus: resolveValue(systemStatus, state.systemStatus) })),
|
||||
setCurrentDate: (currentDate) => set((state) => ({ currentDate: resolveValue(currentDate, state.currentDate) })),
|
||||
|
||||
// Progress
|
||||
progress: { current: 0, total: 0 },
|
||||
setProgress: (progress) => set((state) => ({ progress: resolveValue(progress, state.progress) })),
|
||||
|
||||
// Server mode
|
||||
serverMode: null, // 'live' | 'backtest' | null
|
||||
setServerMode: (serverMode) => set((state) => ({ serverMode: resolveValue(serverMode, state.serverMode) })),
|
||||
|
||||
// Market status
|
||||
marketStatus: null,
|
||||
virtualTime: null,
|
||||
setMarketStatus: (marketStatus) => set((state) => ({ marketStatus: resolveValue(marketStatus, state.marketStatus) })),
|
||||
setVirtualTime: (virtualTime) => set((state) => ({ virtualTime: resolveValue(virtualTime, state.virtualTime) })),
|
||||
|
||||
// Data sources
|
||||
dataSources: null,
|
||||
setDataSources: (dataSources) => set((state) => ({ dataSources: resolveValue(dataSources, state.dataSources) })),
|
||||
|
||||
// Runtime config
|
||||
runtimeConfig: null,
|
||||
setRuntimeConfig: (runtimeConfig) => set((state) => ({ runtimeConfig: resolveValue(runtimeConfig, state.runtimeConfig) })),
|
||||
|
||||
// Watchlist panel
|
||||
isWatchlistPanelOpen: false,
|
||||
setIsWatchlistPanelOpen: (isWatchlistPanelOpen) => set((state) => ({ isWatchlistPanelOpen: resolveValue(isWatchlistPanelOpen, state.isWatchlistPanelOpen) })),
|
||||
|
||||
// Watchlist draft
|
||||
watchlistDraftSymbols: [],
|
||||
watchlistInputValue: '',
|
||||
watchlistFeedback: null,
|
||||
isWatchlistSaving: false,
|
||||
setWatchlistDraftSymbols: (watchlistDraftSymbols) => set((state) => ({ watchlistDraftSymbols: resolveValue(watchlistDraftSymbols, state.watchlistDraftSymbols) })),
|
||||
setWatchlistInputValue: (watchlistInputValue) => set((state) => ({ watchlistInputValue: resolveValue(watchlistInputValue, state.watchlistInputValue) })),
|
||||
setWatchlistFeedback: (watchlistFeedback) => set((state) => ({ watchlistFeedback: resolveValue(watchlistFeedback, state.watchlistFeedback) })),
|
||||
setIsWatchlistSaving: (isWatchlistSaving) => set((state) => ({ isWatchlistSaving: resolveValue(isWatchlistSaving, state.isWatchlistSaving) })),
|
||||
|
||||
// Runtime settings panel
|
||||
isRuntimeSettingsOpen: false,
|
||||
setIsRuntimeSettingsOpen: (isRuntimeSettingsOpen) => set((state) => ({ isRuntimeSettingsOpen: resolveValue(isRuntimeSettingsOpen, state.isRuntimeSettingsOpen) })),
|
||||
|
||||
// Runtime config drafts
|
||||
launchModeDraft: 'fresh',
|
||||
restoreRunIdDraft: '',
|
||||
runtimeHistoryRuns: [],
|
||||
scheduleModeDraft: 'daily',
|
||||
intervalMinutesDraft: '60',
|
||||
triggerTimeDraft: 'now',
|
||||
maxCommCyclesDraft: '2',
|
||||
initialCashDraft: '100000',
|
||||
marginRequirementDraft: '0',
|
||||
enableMemoryDraft: false,
|
||||
modeDraft: 'live',
|
||||
pollIntervalDraft: '10',
|
||||
startDateDraft: '',
|
||||
endDateDraft: '',
|
||||
setLaunchModeDraft: (launchModeDraft) => set((state) => ({ launchModeDraft: resolveValue(launchModeDraft, state.launchModeDraft) })),
|
||||
setRestoreRunIdDraft: (restoreRunIdDraft) => set((state) => ({ restoreRunIdDraft: resolveValue(restoreRunIdDraft, state.restoreRunIdDraft) })),
|
||||
setRuntimeHistoryRuns: (runtimeHistoryRuns) => set((state) => ({ runtimeHistoryRuns: resolveValue(runtimeHistoryRuns, state.runtimeHistoryRuns) })),
|
||||
setScheduleModeDraft: (scheduleModeDraft) => set((state) => ({ scheduleModeDraft: resolveValue(scheduleModeDraft, state.scheduleModeDraft) })),
|
||||
setIntervalMinutesDraft: (intervalMinutesDraft) => set((state) => ({ intervalMinutesDraft: resolveValue(intervalMinutesDraft, state.intervalMinutesDraft) })),
|
||||
setTriggerTimeDraft: (triggerTimeDraft) => set((state) => ({ triggerTimeDraft: resolveValue(triggerTimeDraft, state.triggerTimeDraft) })),
|
||||
setMaxCommCyclesDraft: (maxCommCyclesDraft) => set((state) => ({ maxCommCyclesDraft: resolveValue(maxCommCyclesDraft, state.maxCommCyclesDraft) })),
|
||||
setInitialCashDraft: (initialCashDraft) => set((state) => ({ initialCashDraft: resolveValue(initialCashDraft, state.initialCashDraft) })),
|
||||
setMarginRequirementDraft: (marginRequirementDraft) => set((state) => ({ marginRequirementDraft: resolveValue(marginRequirementDraft, state.marginRequirementDraft) })),
|
||||
setEnableMemoryDraft: (enableMemoryDraft) => set((state) => ({ enableMemoryDraft: resolveValue(enableMemoryDraft, state.enableMemoryDraft) })),
|
||||
setModeDraft: (modeDraft) => set((state) => ({ modeDraft: resolveValue(modeDraft, state.modeDraft) })),
|
||||
setPollIntervalDraft: (pollIntervalDraft) => set((state) => ({ pollIntervalDraft: resolveValue(pollIntervalDraft, state.pollIntervalDraft) })),
|
||||
setStartDateDraft: (startDateDraft) => set((state) => ({ startDateDraft: resolveValue(startDateDraft, state.startDateDraft) })),
|
||||
setEndDateDraft: (endDateDraft) => set((state) => ({ endDateDraft: resolveValue(endDateDraft, state.endDateDraft) })),
|
||||
|
||||
// Runtime config feedback
|
||||
runtimeConfigFeedback: null,
|
||||
isRuntimeConfigSaving: false,
|
||||
setRuntimeConfigFeedback: (runtimeConfigFeedback) => set((state) => ({ runtimeConfigFeedback: resolveValue(runtimeConfigFeedback, state.runtimeConfigFeedback) })),
|
||||
setIsRuntimeConfigSaving: (isRuntimeConfigSaving) => set((state) => ({ isRuntimeConfigSaving: resolveValue(isRuntimeConfigSaving, state.isRuntimeConfigSaving) })),
|
||||
|
||||
// Last day history (for replay)
|
||||
lastDayHistory: [],
|
||||
setLastDayHistory: (lastDayHistory) => set((state) => ({ lastDayHistory: resolveValue(lastDayHistory, state.lastDayHistory) })),
|
||||
}));
|
||||
44
frontend/src/store/uiStore.js
Normal file
44
frontend/src/store/uiStore.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const resolveValue = (updater, currentValue) => (
|
||||
typeof updater === 'function' ? updater(currentValue) : updater
|
||||
);
|
||||
|
||||
/**
|
||||
* UI Store - UI state, view management, layout
|
||||
*/
|
||||
export const useUIStore = create((set) => ({
|
||||
// Current view
|
||||
currentView: 'traders', // 'traders' | 'room' | 'explain' | 'chart' | 'statistics' | 'runtime'
|
||||
setCurrentView: (currentView) => set((state) => ({ currentView: resolveValue(currentView, state.currentView) })),
|
||||
|
||||
// Chart tab
|
||||
chartTab: 'all',
|
||||
setChartTab: (chartTab) => set((state) => ({ chartTab: resolveValue(chartTab, state.chartTab) })),
|
||||
|
||||
// Initial animation
|
||||
isInitialAnimating: true,
|
||||
setIsInitialAnimating: (isInitialAnimating) => set((state) => ({ isInitialAnimating: resolveValue(isInitialAnimating, state.isInitialAnimating) })),
|
||||
|
||||
// Last update timestamp
|
||||
lastUpdate: new Date(),
|
||||
setLastUpdate: (lastUpdate) => set((state) => ({ lastUpdate: resolveValue(lastUpdate, state.lastUpdate) })),
|
||||
|
||||
// Is updating
|
||||
isUpdating: false,
|
||||
setIsUpdating: (isUpdating) => set((state) => ({ isUpdating: resolveValue(isUpdating, state.isUpdating) })),
|
||||
|
||||
// Room bubbles
|
||||
bubbles: {},
|
||||
setBubbles: (bubbles) => set((state) => ({ bubbles: resolveValue(bubbles, state.bubbles) })),
|
||||
|
||||
// Resizable panels
|
||||
leftWidth: 70,
|
||||
setLeftWidth: (leftWidth) => set((state) => ({ leftWidth: resolveValue(leftWidth, state.leftWidth) })),
|
||||
isResizing: false,
|
||||
setIsResizing: (isResizing) => set((state) => ({ isResizing: resolveValue(isResizing, state.isResizing) })),
|
||||
|
||||
// Now timestamp (for current time display)
|
||||
now: new Date(),
|
||||
setNow: (now) => set((state) => ({ now: resolveValue(now, state.now) })),
|
||||
}));
|
||||
1891
frontend/src/styles/GlobalStyles.jsx
Normal file
1891
frontend/src/styles/GlobalStyles.jsx
Normal file
File diff suppressed because it is too large
Load Diff
85
frontend/src/utils/formatters.js
Normal file
85
frontend/src/utils/formatters.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Formatting utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format time from timestamp
|
||||
*/
|
||||
export function formatTime(ts) {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time from timestamp
|
||||
*/
|
||||
export function formatDateTime(ts) {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const date = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
const time = d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
return `${date} ${time}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas (no decimals)
|
||||
*/
|
||||
export function formatNumber(num) {
|
||||
if (!isFinite(num)) {
|
||||
return "-";
|
||||
}
|
||||
return Math.abs(num).toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format full number with commas for Y-axis
|
||||
*/
|
||||
export function formatFullNumber(num) {
|
||||
if (!isFinite(num)) {
|
||||
return "-";
|
||||
}
|
||||
return num.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ticker price with appropriate decimal places
|
||||
*/
|
||||
export function formatTickerPrice(price) {
|
||||
if (!isFinite(price)) {
|
||||
return "-";
|
||||
}
|
||||
if (price >= 1000) {
|
||||
return price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
} else if (price >= 1) {
|
||||
return price.toFixed(2);
|
||||
} else {
|
||||
return price.toFixed(4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate duration between two timestamps
|
||||
*/
|
||||
export function calculateDuration(start, end) {
|
||||
const diff = end - start;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return `${hours}h ${mins}m`;
|
||||
}
|
||||
|
||||
395
frontend/src/utils/modelIcons.js
Normal file
395
frontend/src/utils/modelIcons.js
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Model Icons and Styling Utilities
|
||||
*
|
||||
* Provides icon and styling configuration for different LLM models
|
||||
*/
|
||||
|
||||
import { LLM_MODEL_LOGOS } from "../config/constants";
|
||||
|
||||
/**
|
||||
* Get model icon and styling based on model name
|
||||
* @param {string} modelName - The model name (e.g., "qwen-plus", "gpt-4o")
|
||||
* @param {string} modelProvider - The model provider (e.g., "OPENAI", "ANTHROPIC")
|
||||
* @returns {object} Icon configuration { logoPath, color, bgColor, label, provider }
|
||||
*/
|
||||
export function getModelIcon(modelName, modelProvider) {
|
||||
if (!modelName) {
|
||||
return {
|
||||
logoPath: null,
|
||||
color: "#666666",
|
||||
bgColor: "#f5f5f5",
|
||||
label: "默认",
|
||||
provider: "默认"
|
||||
};
|
||||
}
|
||||
|
||||
const name = modelName.toLowerCase();
|
||||
const provider = (modelProvider || "").toUpperCase();
|
||||
|
||||
// ========== Priority 1: Model Name Based Detection (Highest Priority) ==========
|
||||
// This ensures we infer the correct logo from model name even if provider is OPENAI
|
||||
|
||||
// GLM Models (智谱AI)
|
||||
if (name.includes("glm")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Zhipu AI"],
|
||||
color: "#4A90E2",
|
||||
bgColor: "#E3F2FD",
|
||||
label: "GLM-4.6",
|
||||
provider: "Zhipu AI"
|
||||
};
|
||||
}
|
||||
|
||||
// Qwen Models (阿里云/通义千问)
|
||||
if (name.includes("qwen")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Alibaba"],
|
||||
color: "#FF6A00",
|
||||
bgColor: "#FFF3E0",
|
||||
label: name.includes("max") ? "Qwen-Max" : name.includes("plus") ? "Qwen-Plus" : "Qwen",
|
||||
provider: "Alibaba"
|
||||
};
|
||||
}
|
||||
|
||||
// DeepSeek Models
|
||||
if (name.includes("deepseek")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["DeepSeek"],
|
||||
color: "#1976D2",
|
||||
bgColor: "#E3F2FD",
|
||||
label: "DeepSeek-V3",
|
||||
provider: "DeepSeek"
|
||||
};
|
||||
}
|
||||
|
||||
// Moonshot/Kimi Models (月之暗面)
|
||||
if (name.includes("moonshot") || name.includes("kimi")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Moonshot"],
|
||||
color: "#7B68EE",
|
||||
bgColor: "#F3E5F5",
|
||||
label: "Kimi-K2",
|
||||
provider: "Moonshot"
|
||||
};
|
||||
}
|
||||
|
||||
// Anthropic Claude Models (check model name first)
|
||||
if (name.includes("claude")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Anthropic"],
|
||||
color: "#D97706",
|
||||
bgColor: "#FEF3C7",
|
||||
label: "Claude",
|
||||
provider: "Anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// Google Gemini Models (check model name first)
|
||||
if (name.includes("gemini")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Google"],
|
||||
color: "#4285F4",
|
||||
bgColor: "#E8F0FE",
|
||||
label: "Gemini",
|
||||
provider: "Google"
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI GPT Models (check model name first)
|
||||
if (name.includes("gpt") || name.includes("o1") || name.includes("o2") || name.includes("o3")) {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["OpenAI"],
|
||||
color: "#10A37F",
|
||||
bgColor: "#E8F5E9",
|
||||
label: name.includes("4o") ? "GPT-4o" : name.includes("4.5") ? "GPT-4.5" : name.includes("4") ? "GPT-4" : name.includes("3.5") ? "GPT-3.5" : "OpenAI",
|
||||
provider: "OpenAI"
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Priority 2: Provider Based Detection (Fallback) ==========
|
||||
// Only use provider if model name doesn't match any known patterns
|
||||
|
||||
// Anthropic Claude Models (provider fallback)
|
||||
if (provider === "ANTHROPIC") {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Anthropic"],
|
||||
color: "#D97706",
|
||||
bgColor: "#FEF3C7",
|
||||
label: "Claude",
|
||||
provider: "Anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// Google Gemini Models (provider fallback)
|
||||
if (provider === "GOOGLE") {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Google"],
|
||||
color: "#4285F4",
|
||||
bgColor: "#E8F0FE",
|
||||
label: "Gemini",
|
||||
provider: "Google"
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI Models (provider fallback - only if model name doesn't match)
|
||||
if (provider === "OPENAI") {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["OpenAI"],
|
||||
color: "#10A37F",
|
||||
bgColor: "#E8F5E9",
|
||||
label: "OpenAI",
|
||||
provider: "OpenAI"
|
||||
};
|
||||
}
|
||||
|
||||
// Groq Models
|
||||
if (provider === "GROQ") {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Groq"],
|
||||
color: "#DC2626",
|
||||
bgColor: "#FEE2E2",
|
||||
label: "Groq",
|
||||
provider: "Groq"
|
||||
};
|
||||
}
|
||||
|
||||
// Ollama Models
|
||||
if (provider === "OLLAMA") {
|
||||
return {
|
||||
logoPath: LLM_MODEL_LOGOS["Ollama"],
|
||||
color: "#000000",
|
||||
bgColor: "#F5F5F5",
|
||||
label: "Ollama",
|
||||
provider: "Ollama"
|
||||
};
|
||||
}
|
||||
|
||||
// OpenRouter Models
|
||||
if (provider === "OPENROUTER") {
|
||||
return {
|
||||
logoPath: null,
|
||||
color: "#8B5CF6",
|
||||
bgColor: "#F5F3FF",
|
||||
label: "OpenRouter",
|
||||
provider: "OpenRouter"
|
||||
};
|
||||
}
|
||||
|
||||
// GigaChat Models
|
||||
if (provider === "GIGACHAT") {
|
||||
return {
|
||||
logoPath: null,
|
||||
color: "#9333EA",
|
||||
bgColor: "#FAF5FF",
|
||||
label: "GigaChat",
|
||||
provider: "GigaChat"
|
||||
};
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return {
|
||||
logoPath: null,
|
||||
color: "#666666",
|
||||
bgColor: "#f5f5f5",
|
||||
label: modelName.substring(0, 15),
|
||||
provider: provider || "未知"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short model name for display
|
||||
* @param {string} modelName - The full model name
|
||||
* @returns {string} Short version of the model name (preserves full version numbers and suffixes)
|
||||
*/
|
||||
export function getShortModelName(modelName) {
|
||||
if (!modelName) {
|
||||
return "暂无";
|
||||
}
|
||||
|
||||
const name = modelName.toLowerCase();
|
||||
|
||||
// Helper function to capitalize first letter of each word
|
||||
const capitalizeWords = (str) => {
|
||||
return str.split(/[-_\s]/).map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join("-");
|
||||
};
|
||||
|
||||
// GLM - preserve version numbers
|
||||
if (name.includes("glm")) {
|
||||
// Extract version number if present (e.g., glm-4.6, glm-4.5)
|
||||
const versionMatch = name.match(/glm[_-]?(\d+\.\d+)/);
|
||||
if (versionMatch) {
|
||||
return `GLM-${versionMatch[1]}`;
|
||||
}
|
||||
return "GLM-4.6"; // Default
|
||||
}
|
||||
|
||||
// Qwen - preserve full version and suffixes
|
||||
if (name.includes("qwen")) {
|
||||
// Match patterns like: qwen3-max-preview, qwen-max, qwen-plus, qwen-flash
|
||||
if (name.includes("qwen3-max")) {
|
||||
// Extract suffix if present (e.g., -preview)
|
||||
const fullMatch = name.match(/qwen3-max[_-]?([a-z0-9-]+)?/);
|
||||
if (fullMatch && fullMatch[1]) {
|
||||
return `Qwen3-Max-${capitalizeWords(fullMatch[1])}`;
|
||||
}
|
||||
return "Qwen3-Max";
|
||||
}
|
||||
if (name.includes("qwen-max")) {
|
||||
const fullMatch = name.match(/qwen-max[_-]?([a-z0-9-]+)?/);
|
||||
if (fullMatch && fullMatch[1]) {
|
||||
return `Qwen-Max-${capitalizeWords(fullMatch[1])}`;
|
||||
}
|
||||
return "Qwen-Max";
|
||||
}
|
||||
if (name.includes("qwen-plus")) {
|
||||
const fullMatch = name.match(/qwen-plus[_-]?([a-z0-9-]+)?/);
|
||||
if (fullMatch && fullMatch[1]) {
|
||||
return `Qwen-Plus-${capitalizeWords(fullMatch[1])}`;
|
||||
}
|
||||
return "Qwen-Plus";
|
||||
}
|
||||
if (name.includes("qwen-flash")) {
|
||||
const fullMatch = name.match(/qwen-flash[_-]?([a-z0-9-]+)?/);
|
||||
if (fullMatch && fullMatch[1]) {
|
||||
return `Qwen-Flash-${capitalizeWords(fullMatch[1])}`;
|
||||
}
|
||||
return "Qwen-Flash";
|
||||
}
|
||||
// Generic qwen with version
|
||||
const versionMatch = name.match(/qwen[_-]?(\d+[a-z0-9-]*)?/);
|
||||
if (versionMatch && versionMatch[1]) {
|
||||
return `Qwen-${capitalizeWords(versionMatch[1])}`;
|
||||
}
|
||||
return "Qwen";
|
||||
}
|
||||
|
||||
// DeepSeek - preserve full version numbers and suffixes
|
||||
if (name.includes("deepseek")) {
|
||||
// Match patterns like: deepseek-v3.1, deepseek-v3.2-exp, deepseek-v3
|
||||
// First try to match with version and suffix
|
||||
const fullMatch = name.match(/deepseek[_-]?v?(\d+\.\d+[a-z0-9]*)[_-]?([a-z0-9-]+)?/);
|
||||
if (fullMatch) {
|
||||
const version = fullMatch[1];
|
||||
const suffix = fullMatch[2];
|
||||
if (suffix) {
|
||||
return `DeepSeek-V${version}-${capitalizeWords(suffix)}`;
|
||||
}
|
||||
return `DeepSeek-V${version}`;
|
||||
}
|
||||
// Try to match just version
|
||||
const versionMatch = name.match(/deepseek[_-]?v?(\d+\.\d+)/);
|
||||
if (versionMatch) {
|
||||
return `DeepSeek-V${versionMatch[1]}`;
|
||||
}
|
||||
// Fallback to generic DeepSeek
|
||||
return "DeepSeek";
|
||||
}
|
||||
|
||||
// Moonshot/Kimi - preserve full model names
|
||||
if (name.includes("moonshot") || name.includes("kimi")) {
|
||||
// Match patterns like: moonshot-kimi-k2-instruct, kimi-k2-instruct
|
||||
// First check if it contains k2
|
||||
if (name.includes("k2")) {
|
||||
// Try to extract suffix after k2 (e.g., -instruct)
|
||||
const k2Match = name.match(/k2[_-]?([a-z0-9-]+)?/);
|
||||
if (k2Match && k2Match[1]) {
|
||||
return `Moonshot-Kimi-K2-${capitalizeWords(k2Match[1])}`;
|
||||
}
|
||||
return "Moonshot-Kimi-K2";
|
||||
}
|
||||
if (name.includes("kimi")) {
|
||||
return "Kimi";
|
||||
}
|
||||
return "Moonshot";
|
||||
}
|
||||
|
||||
// OpenAI - preserve full version numbers
|
||||
if (name.includes("gpt") || name.includes("o1") || name.includes("o2") || name.includes("o3")) {
|
||||
// Match patterns like: gpt-4o, gpt-4.5, gpt-4, gpt-3.5-turbo
|
||||
if (name.includes("gpt-4o")) {
|
||||
const suffixMatch = name.match(/gpt-4o[_-]?([a-z0-9-]+)?/);
|
||||
if (suffixMatch && suffixMatch[1]) {
|
||||
return `GPT-4o-${capitalizeWords(suffixMatch[1])}`;
|
||||
}
|
||||
return "GPT-4o";
|
||||
}
|
||||
if (name.includes("gpt-4.5")) {
|
||||
const suffixMatch = name.match(/gpt-4\.5[_-]?([a-z0-9-]+)?/);
|
||||
if (suffixMatch && suffixMatch[1]) {
|
||||
return `GPT-4.5-${capitalizeWords(suffixMatch[1])}`;
|
||||
}
|
||||
return "GPT-4.5";
|
||||
}
|
||||
if (name.includes("gpt-4")) {
|
||||
const suffixMatch = name.match(/gpt-4[_-]?([a-z0-9-]+)?/);
|
||||
if (suffixMatch && suffixMatch[1]) {
|
||||
return `GPT-4-${capitalizeWords(suffixMatch[1])}`;
|
||||
}
|
||||
return "GPT-4";
|
||||
}
|
||||
if (name.includes("gpt-3.5")) {
|
||||
const suffixMatch = name.match(/gpt-3\.5[_-]?([a-z0-9-]+)?/);
|
||||
if (suffixMatch && suffixMatch[1]) {
|
||||
return `GPT-3.5-${capitalizeWords(suffixMatch[1])}`;
|
||||
}
|
||||
return "GPT-3.5";
|
||||
}
|
||||
// O-series models
|
||||
if (name.includes("o3")) {
|
||||
return "O3";
|
||||
}
|
||||
if (name.includes("o2")) {
|
||||
return "O2";
|
||||
}
|
||||
if (name.includes("o1")) {
|
||||
return "O1";
|
||||
}
|
||||
return "OpenAI";
|
||||
}
|
||||
|
||||
// Claude - preserve full model names
|
||||
if (name.includes("claude")) {
|
||||
if (name.includes("claude-opus")) {
|
||||
const versionMatch = name.match(/claude-opus[_-]?(\d+[a-z0-9-]*)?/);
|
||||
if (versionMatch && versionMatch[1]) {
|
||||
return `Claude-Opus-${capitalizeWords(versionMatch[1])}`;
|
||||
}
|
||||
return "Claude-Opus";
|
||||
}
|
||||
if (name.includes("claude-sonnet")) {
|
||||
const versionMatch = name.match(/claude-sonnet[_-]?(\d+[a-z0-9-]*)?/);
|
||||
if (versionMatch && versionMatch[1]) {
|
||||
return `Claude-Sonnet-${capitalizeWords(versionMatch[1])}`;
|
||||
}
|
||||
return "Claude-Sonnet";
|
||||
}
|
||||
if (name.includes("claude-haiku")) {
|
||||
const versionMatch = name.match(/claude-haiku[_-]?(\d+[a-z0-9-]*)?/);
|
||||
if (versionMatch && versionMatch[1]) {
|
||||
return `Claude-Haiku-${capitalizeWords(versionMatch[1])}`;
|
||||
}
|
||||
return "Claude-Haiku";
|
||||
}
|
||||
return "Claude";
|
||||
}
|
||||
|
||||
// Google Gemini
|
||||
if (name.includes("gemini")) {
|
||||
const versionMatch = name.match(/gemini[_-]?([a-z0-9.-]+)?/);
|
||||
if (versionMatch && versionMatch[1]) {
|
||||
return `Gemini-${capitalizeWords(versionMatch[1])}`;
|
||||
}
|
||||
return "Gemini";
|
||||
}
|
||||
|
||||
// If no specific pattern matched, return formatted original name
|
||||
// Truncate only if extremely long (over 30 chars)
|
||||
if (modelName.length > 30) {
|
||||
return capitalizeWords(modelName.substring(0, 27)) + "...";
|
||||
}
|
||||
|
||||
// Return formatted original name
|
||||
return capitalizeWords(modelName);
|
||||
}
|
||||
60
frontend/tailwind.config.js
Normal file
60
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)"
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))"
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))"
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))"
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))"
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))"
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))"
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))"
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
"1": "hsl(var(--chart-1))",
|
||||
"2": "hsl(var(--chart-2))",
|
||||
"3": "hsl(var(--chart-3))",
|
||||
"4": "hsl(var(--chart-4))",
|
||||
"5": "hsl(var(--chart-5))"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [tailwindcssAnimate, require("tailwindcss-animate")],
|
||||
};
|
||||
export default config;
|
||||
4
frontend/test-results/.last-run.json
Normal file
4
frontend/test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": []
|
||||
}
|
||||
BIN
frontend/trader-full.png
Normal file
BIN
frontend/trader-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/trader-view.png
Normal file
BIN
frontend/trader-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
23
frontend/tsconfig.json
Normal file
23
frontend/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["*"]
|
||||
},
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"types": [],
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
79
frontend/vite.config.js
Normal file
79
frontend/vite.config.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8765',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [react(), tsconfigPaths(),tailwindcss()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes("node_modules")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("/three/") ||
|
||||
id.includes("/@react-three/") ||
|
||||
id.includes("/meshline/") ||
|
||||
id.includes("/troika-")
|
||||
) {
|
||||
return "three-stack";
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("/recharts/") ||
|
||||
id.includes("/d3-") ||
|
||||
id.includes("/victory-")
|
||||
) {
|
||||
return "charts";
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("/react-markdown/") ||
|
||||
id.includes("/remark-gfm/") ||
|
||||
id.includes("/remark-") ||
|
||||
id.includes("/mdast-") ||
|
||||
id.includes("/micromark") ||
|
||||
id.includes("/unified/") ||
|
||||
id.includes("/hast-") ||
|
||||
id.includes("/vfile/")
|
||||
) {
|
||||
return "markdown";
|
||||
}
|
||||
|
||||
if (
|
||||
id.includes("/jszip/") ||
|
||||
id.includes("/pako/") ||
|
||||
id.includes("/fflate/")
|
||||
) {
|
||||
return "zip-utils";
|
||||
}
|
||||
|
||||
return "vendor";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom"
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user