Files
evotraders/frontend/src/components/TraderView.jsx
cillin 16b54d5ccc feat(agent): complete EvoAgent integration for all 6 agent roles
Migrate all agent roles from Legacy to EvoAgent architecture:
- fundamentals_analyst, technical_analyst, sentiment_analyst, valuation_analyst
- risk_manager, portfolio_manager

Key changes:
- EvoAgent now supports Portfolio Manager compatibility methods (_make_decision,
  get_decisions, get_portfolio_state, load_portfolio_state, update_portfolio)
- Add UnifiedAgentFactory for centralized agent creation
- ToolGuard with batch approval API and WebSocket broadcast
- Legacy agents marked deprecated (AnalystAgent, RiskAgent, PMAgent)
- Remove backend/agents/compat.py migration shim
- Add run_id alongside workspace_id for semantic clarity
- Complete integration test coverage (13 tests)
- All smoke tests passing for 6 agent roles

Constraint: Must maintain backward compatibility with existing run configs
Constraint: Memory support must work with EvoAgent (no fallback to Legacy)
Rejected: Separate PM implementation for EvoAgent | unified approach cleaner
Confidence: high
Scope-risk: broad
Directive: EVO_AGENT_IDS env var still respected but defaults to all roles
Not-tested: Kubernetes sandbox mode for skill execution
2026-04-02 00:55:08 +08:00

933 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
runFilesByAgent,
selectedAgentId,
selectedAgentProfile,
selectedAgentSkills,
skillDetailsByName,
localSkillDraftsByKey,
skillDetailLoadingKey,
editableFiles,
selectedRunFile,
runFileContent,
runDraftContent,
isConnected,
isAgentSkillsLoading,
agentSkillsSavingKey,
agentSkillsFeedback,
isRunFileLoading,
runFileSavingKey,
runFileFeedback,
onAgentChange,
onCreateLocalSkill,
onSkillDetailRequest,
onLocalSkillDraftChange,
onLocalSkillDelete,
onLocalSkillSave,
onRemoveSharedSkill,
onSkillToggle,
onWorkspaceFileChange,
onRunDraftChange,
onRunFileSave,
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' }}>
Agent 运行档案
</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 === selectedRunFile;
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}-${selectedRunFile || 'file'}`}
name={`workspace_editor_${selectedAgentId}_${selectedRunFile || 'file'}`}
aria-label={`编辑 ${selectedRunFile || '运行文件'} 内容`}
value={runDraftContent}
onChange={(e) => onRunDraftChange(e.target.value)}
placeholder={isRunFileLoading ? '加载中...' : '输入 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' }}>
当前运行文件: {selectedRunFile}
</span>
<button
onClick={onRunFileSave}
disabled={!isConnected || isRunFileLoading || runFileSavingKey !== null || runDraftContent === runFileContent}
style={{
padding: '9px 14px',
borderRadius: 6,
border: '1px solid #1565C0',
background: isConnected && !isRunFileLoading && runFileSavingKey === null && runDraftContent !== runFileContent ? '#0D47A1' : '#94A3B8',
color: '#FFFFFF',
fontSize: 11,
fontWeight: 700,
cursor: isConnected && !isRunFileLoading && runFileSavingKey === null && runDraftContent !== runFileContent ? 'pointer' : 'not-allowed'
}}
>
{runFileSavingKey ? '保存中' : '保存文件'}
</button>
</div>
{runFileFeedback && (
<span style={{
color: runFileFeedback.type === 'success' ? '#00C853' : '#FF5252',
fontSize: 11,
fontFamily: '"Courier New", monospace'
}}>
{runFileFeedback.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>
);
}