feat: Add evaluation hooks, skill adaptation and team pipeline config
- Add EvaluationHook for post-execution agent evaluation - Add SkillAdaptationHook for dynamic skill adaptation - Add team/ directory with team coordination logic - Add TEAM_PIPELINE.yaml for smoke_fullstack pipeline config - Update RuntimeView, TraderView and RuntimeSettingsPanel UI - Add runtimeApi and websocket services - Add runtime_state.json to smoke_fullstack state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
||||
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
|
||||
@@ -483,6 +484,78 @@ export default function AgentCard({ agent, onClose, isClosing }) {
|
||||
</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>
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ export default function RuntimeSettingsPanel({
|
||||
initialCash,
|
||||
marginRequirement,
|
||||
enableMemory,
|
||||
mode,
|
||||
pollInterval,
|
||||
startDate,
|
||||
endDate,
|
||||
enableMock,
|
||||
watchlistSymbols,
|
||||
watchlistInputValue,
|
||||
watchlistSuggestions,
|
||||
@@ -26,6 +31,11 @@ export default function RuntimeSettingsPanel({
|
||||
onInitialCashChange,
|
||||
onMarginRequirementChange,
|
||||
onEnableMemoryChange,
|
||||
onModeChange,
|
||||
onPollIntervalChange,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
onEnableMockChange,
|
||||
onWatchlistInputChange,
|
||||
onWatchlistInputKeyDown,
|
||||
onWatchlistAdd,
|
||||
@@ -405,6 +415,101 @@ export default function RuntimeSettingsPanel({
|
||||
/>
|
||||
<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>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enableMock}
|
||||
onChange={(e) => onEnableMockChange(e.target.checked)}
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
accentColor: '#0D47A1',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '11px', color: '#111111', fontWeight: 700 }}>启用模拟数据 (Mock)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
|
||||
@@ -67,6 +67,112 @@ function resolveApprovalTone(approval) {
|
||||
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 }}>
|
||||
@@ -315,6 +421,131 @@ export default function RuntimeView() {
|
||||
)}
|
||||
</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={{
|
||||
@@ -479,6 +710,7 @@ export default function RuntimeView() {
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,8 @@ import ExplainSimilarDaysSection from './explain/ExplainSimilarDaysSection';
|
||||
import ExplainSignalsSection from './explain/ExplainSignalsSection';
|
||||
import ExplainSummarySection from './explain/ExplainSummarySection';
|
||||
import ExplainTradesSection from './explain/ExplainTradesSection';
|
||||
import ExplainInsiderSection from './explain/ExplainInsiderSection';
|
||||
import ExplainTechnicalSection from './explain/ExplainTechnicalSection';
|
||||
import { EVENT_CATEGORY_META, eventDateKey } from './explain/explainUtils';
|
||||
import useExplainModel from './explain/useExplainModel';
|
||||
import { formatDateTime, formatNumber, formatTickerPrice } from '../utils/formatters';
|
||||
@@ -28,9 +30,13 @@ export default function StockExplainView({
|
||||
selectedHistorySource,
|
||||
explainEventsSnapshot,
|
||||
newsSnapshot,
|
||||
insiderTradesSnapshot,
|
||||
technicalIndicatorsSnapshot,
|
||||
onRequestRangeExplain,
|
||||
onRequestNewsForDate,
|
||||
onRequestStory,
|
||||
onRequestInsiderTrades,
|
||||
onRequestTechnicalIndicators,
|
||||
currentDate,
|
||||
onRequestSimilarDays,
|
||||
onRequestStockEnrich
|
||||
@@ -49,6 +55,8 @@ export default function StockExplainView({
|
||||
const [isMaintenanceOpen, setIsMaintenanceOpen] = useState(false);
|
||||
const [isStoryOpen, setIsStoryOpen] = useState(false);
|
||||
const [isTradesOpen, setIsTradesOpen] = useState(false);
|
||||
const [isInsiderOpen, setIsInsiderOpen] = useState(false);
|
||||
const [isTechnicalOpen, setIsTechnicalOpen] = useState(true);
|
||||
const [isSimilarDaysOpen, setIsSimilarDaysOpen] = useState(false);
|
||||
const [enrichStartDate, setEnrichStartDate] = useState('');
|
||||
const [enrichEndDate, setEnrichEndDate] = useState('');
|
||||
@@ -163,6 +171,16 @@ export default function StockExplainView({
|
||||
onRequestSimilarDays(selectedSymbol, selectedEventDate);
|
||||
}, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSymbol || !onRequestTechnicalIndicators) {
|
||||
return;
|
||||
}
|
||||
if (technicalIndicatorsSnapshot) {
|
||||
return;
|
||||
}
|
||||
onRequestTechnicalIndicators(selectedSymbol);
|
||||
}, [selectedSymbol, onRequestTechnicalIndicators, technicalIndicatorsSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRangeWindow || !selectedSymbol || !onRequestRangeExplain) {
|
||||
return;
|
||||
@@ -368,6 +386,21 @@ export default function StockExplainView({
|
||||
onToggle={() => setIsTradesOpen((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)}
|
||||
/>
|
||||
|
||||
<ExplainMentionsSection
|
||||
recentMentions={recentMentions}
|
||||
isOpen={isMentionsPanelOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import JSZip from 'jszip';
|
||||
import { getModelIcon, getShortModelName } from '../utils/modelIcons';
|
||||
|
||||
export default function TraderView({
|
||||
@@ -34,10 +35,14 @@ export default function TraderView({
|
||||
onSkillToggle,
|
||||
onWorkspaceFileChange,
|
||||
onWorkspaceDraftChange,
|
||||
onWorkspaceFileSave
|
||||
onWorkspaceFileSave,
|
||||
onUploadExternalSkill
|
||||
}) {
|
||||
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(
|
||||
@@ -59,6 +64,50 @@ export default function TraderView({
|
||||
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%',
|
||||
@@ -679,6 +728,85 @@ export default function TraderView({
|
||||
</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' }}>
|
||||
<input
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user