mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-15 12:58:01 +02:00
Add files via upload
This commit is contained in:
+154
-29
@@ -330,7 +330,25 @@ func (b *Builder) formatReActInputFromJSON(reactInputJSON string) string {
|
||||
|
||||
// buildSimplePrompt 构建简化的prompt
|
||||
func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
return fmt.Sprintf(`你是一个专业的安全测试分析师。请根据以下信息生成攻击链图。
|
||||
return fmt.Sprintf(`你是一个专业的安全测试分析师。请根据以下对话和工具执行记录,生成清晰、有教育意义的攻击链图。
|
||||
|
||||
## 核心原则
|
||||
|
||||
**目标:让不懂渗透测试的同学可以通过这个攻击链路学习到知识,而不是无数个节点看花眼。**
|
||||
**即便某些工具执行或漏洞挖掘没有成功,只要它们提供了关键线索、错误提示或下一步思路,也要被保留下来。**
|
||||
|
||||
**关键要求:**
|
||||
1. **节点标签必须简洁明了**:每个节点标签控制在15-25个汉字以内,使用简洁的动宾结构
|
||||
- action节点要描述"做了什么"和"发现了什么"(如"扫描端口发现22/80/443"、"验证SQL注入成功"、"WAF拦截暴露厂商")
|
||||
- 避免冗长描述,关键信息放在metadata中详细说明
|
||||
2. **严格控制节点数量**:优先保留关键步骤,避免生成过多细碎节点。理想情况下,单个目标的攻击链应控制在8-15个节点以内
|
||||
- 如果节点太多(>20个),优先保留最重要的节点,合并或删除次要节点
|
||||
- 合并相似的action节点(如同一工具的连续调用,如果结果相似)
|
||||
- 对于同一类型的多个发现,考虑合并为一个节点(如"发现多个开放端口"而不是为每个端口创建节点)
|
||||
3. **确保DAG结构**:生成的图必须是有向无环图(DAG),不允许出现循环。边的方向必须符合时间顺序和逻辑关系(从早期步骤指向后期步骤)
|
||||
- 生成后必须检查:确保图中不存在循环(即不存在路径A→B→...→A)
|
||||
- 如果发现循环,必须断开形成循环的边,保留最重要的连接
|
||||
4. **层次清晰**:攻击链应该呈现清晰的层次结构:目标 → 信息收集 → 漏洞发现 → 漏洞利用 → 后续行动
|
||||
|
||||
## ⚠️ 重要原则 - 严禁杜撰
|
||||
|
||||
@@ -342,8 +360,6 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
|
||||
如果ReAct输入中没有实际的工具执行记录,或者模型输出中明确表示任务未完成/被取消,必须返回空的攻击链(空的nodes和edges数组)。
|
||||
|
||||
|
||||
|
||||
## 最后一轮ReAct的输入(历史对话上下文)
|
||||
%s
|
||||
|
||||
@@ -352,30 +368,127 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
|
||||
## 任务要求
|
||||
|
||||
请根据上述信息,**仅基于实际执行的数据**生成一个清晰的攻击链图。攻击链应该包含:
|
||||
1. **target(目标)**:从用户输入中提取的实际测试目标(必须是用户明确提供的)
|
||||
2. **action(行动)**:从ReAct输入中提取的**实际执行的**工具调用和测试步骤(必须有tool_calls证据)
|
||||
3. **vulnerability(漏洞)**:从模型输出中提取的**实际发现的**漏洞(必须在输出中明确提及,不能推测)
|
||||
### 1. 节点类型(简化,只保留3种)
|
||||
|
||||
**关键检查点:**
|
||||
- 如果ReAct输入中没有tool_calls,说明没有实际执行工具 → 只能生成target节点
|
||||
- 如果模型输出中没有明确提到发现的漏洞,不要编造vulnerability节点
|
||||
- 如果任务被取消或未完成,返回空攻击链
|
||||
**target(目标)**:从用户输入中提取测试目标(IP、域名、URL等)
|
||||
- **重要:如果对话中测试了多个不同的目标(如先测试A网页,后测试B网页),必须:**
|
||||
- 为每个不同的目标创建独立的target节点
|
||||
- 每个target节点只关联属于它的action和vulnerability节点
|
||||
- 不同目标的节点之间**不应该**建立任何关联关系
|
||||
- 这样会形成多个独立的攻击链分支,每个分支对应一个测试目标
|
||||
|
||||
**action(行动)**:**工具执行 + AI分析结果 = 一个action节点**
|
||||
- 将每个工具执行和AI对该工具结果的分析合并为一个action节点
|
||||
- **节点标签必须简洁**:控制在15-25个汉字,使用动宾结构,描述"做了什么"和"发现了什么"
|
||||
- 好的示例:"扫描端口发现22/80/443"、"验证SQL注入成功"、"WAF拦截暴露厂商"
|
||||
- 避免冗长描述,关键信息放在metadata中详细说明
|
||||
- 若为失败但有线索的行动,请在metadata.status中标记为"failed_insight",并在findings中写清线索价值
|
||||
- **重要:action节点必须关联到正确的target节点(通过工具执行参数判断目标)**
|
||||
- **risk_score**:**action节点没有风险,risk_score必须设置为0**(只有vulnerability节点才有风险等级)
|
||||
|
||||
**vulnerability(漏洞)**:从工具执行结果和AI分析中提取的**真实漏洞**(不是所有发现都是漏洞)
|
||||
- 若验证失败但能明确表明某个漏洞利用方向不可行,可作为行动节点的线索描述,而不是漏洞节点
|
||||
- **risk_score**:反映实际发现的漏洞的风险等级(高危80-100,中危60-80,低危40-60)
|
||||
|
||||
### 2. 简化结构
|
||||
|
||||
- 只创建target、action、vulnerability三种节点
|
||||
- 不要创建discovery、decision等节点
|
||||
- 让攻击链清晰、有教育意义
|
||||
|
||||
### 3. 过滤规则(重要!)
|
||||
|
||||
- **必须忽略**没有任何输出、没有线索的失败执行
|
||||
- **必须保留**失败但提供关键线索的执行,确保metadata里解释清楚
|
||||
- 只保留对学习或溯源有帮助的节点
|
||||
|
||||
### 4. 关联关系(确保DAG结构)
|
||||
|
||||
- target → action:目标指向属于它的所有行动(通过工具执行参数判断目标)
|
||||
- action → action:按时间顺序连接,但只连接有逻辑关系的
|
||||
- **重要:只连接属于同一目标的action节点,不同目标的action节点之间不应该连接**
|
||||
- **必须确保无环**:只能从早期步骤指向后期步骤,不能形成循环
|
||||
- 优先连接直接相关的步骤,避免过度连接
|
||||
- action → vulnerability:行动发现的漏洞
|
||||
- vulnerability → vulnerability:漏洞间的因果关系
|
||||
- **重要:只连接属于同一目标的漏洞,不同目标的漏洞之间不应该连接**
|
||||
- **必须确保无环**:漏洞间的因果关系也必须是单向的
|
||||
|
||||
### 5. 节点属性
|
||||
|
||||
每个节点需要:id, type, label, risk_score, metadata
|
||||
|
||||
**重要:risk_score规则**
|
||||
- **target节点**:可以设置适当的risk_score(如40),表示目标本身的风险
|
||||
- **action节点**:**必须设置为0**,因为行动本身没有风险,只有漏洞才有风险
|
||||
- **vulnerability节点**:必须根据漏洞严重程度设置risk_score(高危80-100,中危60-80,低危40-60)
|
||||
|
||||
**action节点metadata必须包含:**
|
||||
- tool_name: 工具名称(必须与ReAct中的tool_calls一致)
|
||||
- tool_intent: 工具调用意图(如"端口扫描"、"漏洞扫描"、"目录枚举"等)
|
||||
- ai_analysis: AI对工具结果的分析总结(不超过100字,失败节点需解释线索价值)
|
||||
- findings: 关键发现列表(数组,如["发现80端口开放", "检测到WAF"])
|
||||
- status: 可选,若为失败但有线索的行动,标记为"failed_insight",并在findings中写清线索价值
|
||||
|
||||
**target节点metadata必须包含:**
|
||||
- target: 测试目标(URL、IP、域名等)
|
||||
|
||||
**vulnerability节点metadata必须包含:**
|
||||
- vulnerability_type: 漏洞类型
|
||||
- description: 实际发现的漏洞描述(必须与模型输出中明确提及的漏洞一致)
|
||||
- severity: 严重程度("critical"|"high"|"medium"|"low")
|
||||
- location: 漏洞位置
|
||||
|
||||
## 输出格式
|
||||
|
||||
请以JSON格式返回攻击链,格式如下:
|
||||
请以JSON格式返回攻击链,严格按照以下格式:
|
||||
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node_1",
|
||||
"type": "target|action|vulnerability",
|
||||
"label": "节点标签",
|
||||
"risk_score": 0-100,
|
||||
"type": "target",
|
||||
"label": "测试目标: example.com",
|
||||
"risk_score": 40,
|
||||
"metadata": {
|
||||
"target": "目标(target节点)",
|
||||
"tool_name": "工具名称(action节点,必须是实际调用的工具)",
|
||||
"description": "描述(vulnerability节点,必须是实际发现的漏洞)"
|
||||
"target": "example.com"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_2",
|
||||
"type": "action",
|
||||
"label": "扫描端口发现80/443",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "nmap",
|
||||
"tool_intent": "端口扫描",
|
||||
"ai_analysis": "使用nmap扫描发现80和443端口开放,目标运行标准Web服务",
|
||||
"findings": ["80端口开放", "443端口开放"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_3",
|
||||
"type": "action",
|
||||
"label": "SQLMap扫描(工具未安装)",
|
||||
"risk_score": 0,
|
||||
"metadata": {
|
||||
"tool_name": "sqlmap",
|
||||
"tool_intent": "SQL注入测试",
|
||||
"ai_analysis": "sqlmap工具未安装,无法进行SQL注入测试,需要先安装工具",
|
||||
"findings": ["工具未安装,需要先安装sqlmap工具"],
|
||||
"status": "failed_insight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "node_4",
|
||||
"type": "vulnerability",
|
||||
"label": "SQL注入漏洞",
|
||||
"risk_score": 85,
|
||||
"metadata": {
|
||||
"vulnerability_type": "SQL注入",
|
||||
"description": "在/admin/login.php发现SQL注入漏洞",
|
||||
"severity": "high",
|
||||
"location": "/admin/login.php"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -383,25 +496,38 @@ func (b *Builder) buildSimplePrompt(reactInput, modelOutput string) string {
|
||||
{
|
||||
"source": "node_1",
|
||||
"target": "node_2",
|
||||
"type": "leads_to|discovers|enables",
|
||||
"weight": 1-5
|
||||
"type": "leads_to",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"source": "node_2",
|
||||
"target": "node_4",
|
||||
"type": "discovers",
|
||||
"weight": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
**关键要求:**
|
||||
- 节点id必须从"node_1"开始,按顺序递增(node_1, node_2, node_3, ...)
|
||||
- 所有边的source节点id必须小于target节点id(确保DAG无环)
|
||||
- target节点必须是node_1(如果是多目标,第一个target是node_1,第二个target是node_2,以此类推)
|
||||
- 节点之间必须形成清晰的路径,不能有孤立节点
|
||||
- 如果有vulnerability节点,必须展示从target到vulnerability的完整路径
|
||||
- 边的类型只能是:leads_to、discovers、enables
|
||||
|
||||
**再次强调:如果没有实际数据,返回空的nodes和edges数组。严禁杜撰!**
|
||||
|
||||
只返回JSON,不要包含其他解释文字。`, reactInput, modelOutput)
|
||||
}
|
||||
|
||||
// saveChain 保存攻击链到数据库(简化版本,移除tool_execution_id)
|
||||
// saveChain 保存攻击链到数据库
|
||||
func (b *Builder) saveChain(conversationID string, nodes []Node, edges []Edge) error {
|
||||
// 先删除旧的攻击链数据
|
||||
if err := b.db.DeleteAttackChain(conversationID); err != nil {
|
||||
b.logger.Warn("删除旧攻击链失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 保存节点(不保存tool_execution_id)
|
||||
for _, node := range nodes {
|
||||
metadataJSON, _ := json.Marshal(node.Metadata)
|
||||
if err := b.db.SaveAttackChainNode(conversationID, node.ID, node.Type, node.Label, "", string(metadataJSON), node.RiskScore); err != nil {
|
||||
@@ -496,12 +622,11 @@ func (b *Builder) callAIForChainGeneration(ctx context.Context, prompt string) (
|
||||
// ChainJSON 攻击链JSON结构
|
||||
type ChainJSON struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
ToolExecutionID string `json:"tool_execution_id,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
} `json:"nodes"`
|
||||
Edges []struct {
|
||||
Source string `json:"source"`
|
||||
|
||||
+97
-24
@@ -1871,7 +1871,7 @@ function renderAttackChain(chainData) {
|
||||
return isComplexGraph ? 110 : 120;
|
||||
},
|
||||
'shape': function(ele) {
|
||||
// 所有节点都使用圆角矩形,参考图片风格
|
||||
// 所有节点都使用圆角矩形
|
||||
return 'round-rectangle';
|
||||
},
|
||||
'background-color': function(ele) {
|
||||
@@ -1883,11 +1883,32 @@ function renderAttackChain(chainData) {
|
||||
return '#bbdefb'; // 更深的浅蓝色
|
||||
}
|
||||
|
||||
// 其他节点根据风险分数,使用更饱和的背景色,增加视觉层次
|
||||
if (riskScore >= 80) return '#ffcdd2'; // 更饱和的浅红色
|
||||
if (riskScore >= 60) return '#ffe0b2'; // 更饱和的浅橙色
|
||||
if (riskScore >= 40) return '#fff9c4'; // 更饱和的浅黄色
|
||||
return '#dcedc8'; // 更饱和的浅绿色
|
||||
// action节点根据执行有效性显示不同颜色
|
||||
if (type === 'action') {
|
||||
const metadata = ele.data('metadata') || {};
|
||||
const findings = metadata.findings || [];
|
||||
const status = metadata.status || '';
|
||||
|
||||
// 有效执行:有findings且status不是failed_insight
|
||||
const hasFindings = Array.isArray(findings) && findings.length > 0;
|
||||
const isFailedInsight = status === 'failed_insight';
|
||||
|
||||
if (hasFindings && !isFailedInsight) {
|
||||
return '#e8f5e9'; // 浅绿色:有效执行
|
||||
} else {
|
||||
return '#f5f5f5'; // 浅灰色:无效执行
|
||||
}
|
||||
}
|
||||
|
||||
// vulnerability节点根据风险分数显示不同颜色
|
||||
if (type === 'vulnerability') {
|
||||
if (riskScore >= 80) return '#ffcdd2'; // 更饱和的浅红色
|
||||
if (riskScore >= 60) return '#ffe0b2'; // 更饱和的浅橙色
|
||||
if (riskScore >= 40) return '#fff9c4'; // 更饱和的浅黄色
|
||||
return '#dcedc8'; // 更饱和的浅绿色
|
||||
}
|
||||
|
||||
return '#f5f5f5'; // 默认浅灰色
|
||||
},
|
||||
// 根据节点类型和风险分数设置文字颜色
|
||||
// 注意:由于标签包含类型标签和内容,颜色适用于所有文本
|
||||
@@ -1899,10 +1920,32 @@ function renderAttackChain(chainData) {
|
||||
return '#1976d2'; // 深蓝色文字
|
||||
}
|
||||
|
||||
if (riskScore >= 80) return '#c62828'; // 深红色
|
||||
if (riskScore >= 60) return '#e65100'; // 深橙色
|
||||
if (riskScore >= 40) return '#f57f17'; // 深黄色
|
||||
return '#558b2f'; // 深绿色
|
||||
// action节点根据执行有效性显示不同文字颜色
|
||||
if (type === 'action') {
|
||||
const metadata = ele.data('metadata') || {};
|
||||
const findings = metadata.findings || [];
|
||||
const status = metadata.status || '';
|
||||
|
||||
// 有效执行:有findings且status不是failed_insight
|
||||
const hasFindings = Array.isArray(findings) && findings.length > 0;
|
||||
const isFailedInsight = status === 'failed_insight';
|
||||
|
||||
if (hasFindings && !isFailedInsight) {
|
||||
return '#2e7d32'; // 深绿色:有效执行
|
||||
} else {
|
||||
return '#757575'; // 深灰色:无效执行
|
||||
}
|
||||
}
|
||||
|
||||
// vulnerability节点根据风险分数显示不同文字颜色
|
||||
if (type === 'vulnerability') {
|
||||
if (riskScore >= 80) return '#c62828'; // 深红色
|
||||
if (riskScore >= 60) return '#e65100'; // 深橙色
|
||||
if (riskScore >= 40) return '#f57f17'; // 深黄色
|
||||
return '#558b2f'; // 深绿色
|
||||
}
|
||||
|
||||
return '#424242'; // 默认深灰色
|
||||
},
|
||||
'font-size': function(ele) {
|
||||
// 由于标签包含类型标签和内容,使用合适的字体大小
|
||||
@@ -1929,7 +1972,7 @@ function renderAttackChain(chainData) {
|
||||
if (type === 'target') return 5;
|
||||
return 4;
|
||||
},
|
||||
'border-radius': '12px', // 增加圆角半径,使节点更圆润美观
|
||||
'border-radius': '12px', // 所有节点都使用圆角
|
||||
'border-color': function(ele) {
|
||||
const type = ele.data('type');
|
||||
const riskScore = ele.data('riskScore') || 0;
|
||||
@@ -1938,15 +1981,37 @@ function renderAttackChain(chainData) {
|
||||
return '#1976d2'; // 蓝色边框
|
||||
}
|
||||
|
||||
if (riskScore >= 80) return '#d32f2f'; // 红色边框
|
||||
if (riskScore >= 60) return '#f57c00'; // 橙色边框
|
||||
if (riskScore >= 40) return '#fbc02d'; // 黄色边框
|
||||
return '#689f38'; // 绿色边框
|
||||
// action节点根据执行有效性显示不同边框颜色
|
||||
if (type === 'action') {
|
||||
const metadata = ele.data('metadata') || {};
|
||||
const findings = metadata.findings || [];
|
||||
const status = metadata.status || '';
|
||||
|
||||
// 有效执行:有findings且status不是failed_insight
|
||||
const hasFindings = Array.isArray(findings) && findings.length > 0;
|
||||
const isFailedInsight = status === 'failed_insight';
|
||||
|
||||
if (hasFindings && !isFailedInsight) {
|
||||
return '#66bb6a'; // 绿色边框:有效执行
|
||||
} else {
|
||||
return '#9e9e9e'; // 灰色边框:无效执行
|
||||
}
|
||||
}
|
||||
|
||||
// vulnerability节点根据风险分数显示不同边框颜色
|
||||
if (type === 'vulnerability') {
|
||||
if (riskScore >= 80) return '#d32f2f'; // 红色边框
|
||||
if (riskScore >= 60) return '#f57c00'; // 橙色边框
|
||||
if (riskScore >= 40) return '#fbc02d'; // 黄色边框
|
||||
return '#689f38'; // 绿色边框
|
||||
}
|
||||
|
||||
return '#9e9e9e'; // 默认灰色边框
|
||||
},
|
||||
'border-style': function(ele) {
|
||||
const type = ele.data('type');
|
||||
// target和vulnerability使用实线,action可以使用虚线
|
||||
if (type === 'action') return 'solid';
|
||||
// action节点使用虚线边框,其他使用实线
|
||||
if (type === 'action') return 'dashed';
|
||||
return 'solid';
|
||||
},
|
||||
'overlay-padding': '12px',
|
||||
@@ -2073,27 +2138,28 @@ function renderAttackChain(chainData) {
|
||||
// 目标:使用容器宽度的85-90%,让图充分展开
|
||||
const maxLevelWidth = Math.max(1, Math.ceil(nodeCount / estimatedDepth));
|
||||
const targetGraphWidth = containerWidth * 0.88; // 使用88%的容器宽度
|
||||
const minNodeSep = avgNodeWidth * 0.6; // 最小间距为节点宽度的60%
|
||||
const minNodeSep = avgNodeWidth * 0.8; // 最小间距为节点宽度的80%(从60%增加到80%,增加水平间距)
|
||||
const calculatedNodeSep = Math.max(
|
||||
minNodeSep,
|
||||
Math.min(
|
||||
(targetGraphWidth - avgNodeWidth * maxLevelWidth) / Math.max(1, maxLevelWidth - 1),
|
||||
avgNodeWidth * 1.5 // 最大间距不超过节点宽度的1.5倍
|
||||
avgNodeWidth * 2.0 // 最大间距不超过节点宽度的2.0倍(从1.5增加到2.0)
|
||||
)
|
||||
);
|
||||
|
||||
// 动态计算层级间距:基于容器高度和层级数
|
||||
// 增加最小间距,避免节点重合
|
||||
const targetGraphHeight = containerHeight * 0.85;
|
||||
const calculatedRankSep = Math.max(
|
||||
avgNodeHeight * 1.2, // 最小为节点高度的1.2倍
|
||||
avgNodeHeight * 1.8, // 最小为节点高度的1.8倍(从1.2增加到1.8,增加层级间距)
|
||||
Math.min(
|
||||
targetGraphHeight / Math.max(estimatedDepth - 1, 1),
|
||||
avgNodeHeight * 2.5 // 最大不超过节点高度的2.5倍
|
||||
avgNodeHeight * 3.5 // 最大不超过节点高度的3.5倍(从2.5增加到3.5)
|
||||
)
|
||||
);
|
||||
|
||||
// 边间距:基于节点间距的合理比例
|
||||
const calculatedEdgeSep = Math.max(30, calculatedNodeSep * 0.25);
|
||||
const calculatedEdgeSep = Math.max(40, calculatedNodeSep * 0.3); // 增加边间距(从30增加到40,从0.25增加到0.3)
|
||||
|
||||
// 根据图的复杂度调整布局参数,优化可读性和空间利用率
|
||||
layoutOptions = {
|
||||
@@ -2145,12 +2211,12 @@ function renderAttackChain(chainData) {
|
||||
const estimatedDepth = Math.ceil(Math.log2(Math.max(nodeCount, 2))) + 1;
|
||||
const maxLevelWidth = Math.max(1, Math.ceil(nodeCount / estimatedDepth));
|
||||
const targetGraphWidth = containerWidth * 0.88;
|
||||
const minNodeSep = avgNodeWidth * 0.6;
|
||||
const minNodeSep = avgNodeWidth * 0.8; // 与布局计算保持一致
|
||||
const spacing = Math.max(
|
||||
minNodeSep,
|
||||
Math.min(
|
||||
(targetGraphWidth - avgNodeWidth * maxLevelWidth) / Math.max(1, maxLevelWidth - 1),
|
||||
avgNodeWidth * 1.5
|
||||
avgNodeWidth * 2.0 // 与布局计算保持一致
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2696,6 +2762,13 @@ function showNodeDetails(nodeData) {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (nodeData.metadata.status === 'failed_insight') {
|
||||
html += `
|
||||
<div class="node-detail-item">
|
||||
<strong>执行状态:</strong> <span style="color: #ff9800; font-weight: bold;">失败但有线索</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (nodeData.metadata.ai_analysis) {
|
||||
html += `
|
||||
<div class="node-detail-item">
|
||||
|
||||
Reference in New Issue
Block a user