Add files via upload

This commit is contained in:
公明
2026-07-20 16:49:19 +08:00
committed by GitHub
parent c1ab063b0d
commit c689122774
9 changed files with 1443 additions and 140 deletions
+501 -38
View File
@@ -3325,7 +3325,7 @@ html[data-theme="dark"] .new-chat-btn:focus-visible {
.message-avatar svg {
width: 17px;
height: 17px;
height: 16px;
flex-shrink: 0;
}
@@ -28735,63 +28735,195 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
/* ==================== 信息收集(FOFA)页面 ==================== */
.info-collect-panel {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
background:
linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(20, 184, 166, 0.05) 42%, transparent 70%),
var(--bg-primary);
border: 1px solid color-mix(in srgb, var(--border-color) 72%, var(--accent-color));
border-radius: 14px;
padding: 18px;
margin-bottom: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08);
}
/* 表单整体增加纵向留白,避免“挤在一起” */
.info-collect-form {
display: flex;
flex-direction: column;
gap: 14px;
gap: 12px;
position: relative;
z-index: 1;
}
/* 将每个表单块做成轻量卡片分组(只作用于 info-collect 顶层 form-group */
.info-collect-form > .form-group,
.info-collect-form > .info-collect-form-row {
padding: 14px 14px;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
.info-collect-form-top {
display: grid;
grid-template-columns: minmax(220px, 0.32fr) minmax(420px, 1fr);
gap: 12px;
}
.info-collect-provider-card,
.info-collect-query-card,
.info-collect-options-card,
.info-collect-fields-card {
border: 1px solid rgba(15, 23, 42, 0.07);
border-radius: 10px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset;
}
.info-collect-form .form-group {
margin-bottom: 0;
}
.info-collect-provider-card,
.info-collect-query-card,
.info-collect-fields-card {
padding: 14px 16px 16px;
}
.info-collect-query-card {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.88)),
var(--bg-primary);
}
.info-collect-options-card {
padding: 14px 16px;
background:
linear-gradient(135deg, rgba(248, 250, 252, 0.94), rgba(239, 246, 255, 0.68));
}
.info-collect-step-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: var(--text-primary);
font-weight: 650;
line-height: 1.3;
}
.info-collect-step-title span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 22px;
border-radius: 7px;
background: rgba(37, 99, 235, 0.1);
color: var(--accent-color);
font-size: 0.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.info-collect-step-title label,
.info-collect-step-title div {
margin: 0;
}
.info-collect-query-mode-panel[hidden] {
display: none !important;
}
.info-collect-query-mode-panel + .info-collect-query-mode-panel {
margin-top: 14px;
}
#info-collect-syntax-panel.is-generated-target {
padding-top: 12px;
border-top: 1px dashed color-mix(in srgb, var(--border-color) 76%, var(--text-secondary));
}
html[data-theme="dark"] #info-collect-syntax-panel.is-generated-target {
border-top-color: color-mix(in srgb, var(--border-color) 58%, var(--accent-color));
}
.info-collect-inner-label {
display: block;
margin: 0 0 8px;
color: var(--text-primary);
font-weight: 650;
}
.info-collect-provider-select .settings-custom-select-trigger {
min-height: 46px;
border-radius: 8px;
background: var(--bg-primary);
font-weight: 650;
}
.info-collect-provider-select .settings-custom-select-menu {
z-index: 3200;
border-radius: 10px;
}
.info-collect-provider-select .settings-custom-select-option {
min-height: 36px;
}
/* 覆盖全局 .form-group textarea(min-height:200px) 的默认大留白 */
.form-group textarea.info-collect-query-input {
min-height: 36px;
min-height: 42px;
max-height: 96px;
overflow: hidden; /* 配合 JS 自动增高 */
resize: none;
padding: 10px 12px;
padding: 11px 12px;
}
.info-collect-nl-row {
#page-info-collect input[type="text"],
#page-info-collect input[type="number"],
#page-info-collect select,
#page-info-collect textarea {
border-radius: 8px;
}
#page-info-collect input:focus,
#page-info-collect textarea:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.info-collect-nl-row,
.info-collect-query-row {
display: flex;
align-items: flex-start;
align-items: stretch;
gap: 10px;
}
.info-collect-nl-row .info-collect-query-input {
.info-collect-nl-row .info-collect-query-input,
.info-collect-query-row .info-collect-query-input {
flex: 1 1 auto;
min-width: 0; /* 允许在 flex 中收缩,避免撑破布局 */
}
.info-collect-nl-row button {
.info-collect-nl-row button,
.info-collect-query-row button {
flex: 0 0 auto;
white-space: nowrap;
min-height: 42px;
padding-inline: 18px;
}
@media (max-width: 980px) {
.info-collect-panel {
padding: 14px;
}
.info-collect-form-top {
grid-template-columns: 1fr;
}
.info-collect-form-row {
grid-template-columns: 1fr;
}
.info-collect-nl-row {
.info-collect-nl-row,
.info-collect-query-row {
flex-direction: column;
align-items: stretch;
}
.syntax-guide-header {
flex-direction: column;
gap: 6px;
}
.info-collect-col-actions {
width: 140px;
}
@@ -28799,15 +28931,45 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
.info-collect-form-row {
display: grid;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 14px;
align-items: end;
grid-template-columns: minmax(160px, 0.7fr) minmax(160px, 0.7fr) minmax(150px, 0.45fr) minmax(280px, 1fr);
gap: 12px;
align-items: center;
}
.info-collect-form-row .form-group {
min-width: 0;
}
.info-collect-form-row #info-collect-full-option {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-self: stretch;
}
.info-collect-form-row #info-collect-full-option::before {
content: '';
display: block;
height: 17px;
margin-bottom: 8px;
}
.info-collect-form-row #info-collect-full-option .checkbox-label {
min-height: 40px;
margin-top: -1px;
align-items: center;
padding: 0 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.info-collect-row-hint {
grid-column: auto;
align-self: end;
margin-bottom: 9px;
}
.info-collect-results {
background: var(--bg-primary);
border: 1px solid var(--border-color);
@@ -28953,6 +29115,110 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
margin-top: 8px;
}
.info-collect-syntax-guide {
margin-top: 10px;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--accent-color));
border-radius: 8px;
background: color-mix(in srgb, var(--bg-secondary) 86%, var(--accent-color) 5%);
}
.info-collect-syntax-guide.is-expanded {
padding: 12px;
}
.syntax-guide-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.info-collect-syntax-guide.is-expanded .syntax-guide-header {
margin-bottom: 10px;
}
.syntax-guide-summary {
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.55;
}
.syntax-guide-actions {
display: flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.info-collect-doc-link {
color: var(--accent-color);
font-size: 0.8125rem;
text-decoration: none;
border-bottom: 1px dashed color-mix(in srgb, var(--accent-color) 52%, transparent);
}
.info-collect-doc-link:hover {
border-bottom-color: var(--accent-color);
}
.syntax-guide-toggle {
border: 1px solid color-mix(in srgb, var(--border-color) 70%, var(--accent-color));
border-radius: 7px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 5px 9px;
font-size: 0.8125rem;
line-height: 1;
cursor: pointer;
white-space: nowrap;
}
.syntax-guide-toggle:hover {
border-color: var(--accent-color);
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 8%, var(--bg-primary));
}
.syntax-guide-section + .syntax-guide-section {
margin-top: 10px;
}
.syntax-guide-title {
margin-bottom: 6px;
color: var(--text-primary);
font-size: 0.8125rem;
font-weight: 650;
}
.syntax-guide-examples {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.syntax-example-chip {
max-width: 100%;
border: 1px solid color-mix(in srgb, var(--border-color) 78%, var(--text-secondary));
border-radius: 7px;
background: var(--bg-primary);
color: var(--text-primary);
padding: 6px 8px;
font-family: var(--font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace);
font-size: 0.75rem;
line-height: 1.25;
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.syntax-example-chip:hover {
border-color: var(--accent-color);
color: var(--accent-color);
background: color-mix(in srgb, var(--accent-color) 8%, var(--bg-primary));
}
.info-collect-query-input {
min-height: 40px;
max-height: 110px;
@@ -29088,21 +29354,21 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
.info-collect-cell-modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
background: rgba(2, 6, 23, 0.62);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 24px;
padding: 32px;
}
.info-collect-cell-modal-content {
width: min(920px, 100%);
max-height: 86vh;
width: min(760px, 100%);
max-height: min(78vh, 720px);
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.20);
box-shadow: 0 24px 70px rgba(0,0,0,0.35);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -29110,42 +29376,91 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
.info-collect-cell-modal-header {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
padding: 12px 14px;
gap: 16px;
padding: 18px 20px 16px;
border-bottom: 1px solid var(--border-color);
}
.info-collect-cell-modal-heading {
min-width: 0;
}
.info-collect-cell-modal-title {
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 12px;
font-size: 1rem;
line-height: 1.4;
}
.info-collect-cell-modal-subtitle {
margin-top: 4px;
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.4;
}
.info-collect-cell-modal-header .btn-icon {
flex: 0 0 auto;
}
.info-collect-cell-modal-body {
padding: 12px 14px;
padding: 18px 20px;
overflow: auto;
min-height: 112px;
}
.info-collect-cell-modal-pre {
margin: 0;
min-height: 64px;
padding: 14px 16px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.875rem;
line-height: 1.55;
color: var(--text-primary);
user-select: text;
}
.info-collect-cell-modal-footer {
padding: 12px 14px;
padding: 14px 20px 18px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 8px;
gap: 10px;
background: var(--bg-primary);
}
@media (max-width: 640px) {
.info-collect-cell-modal {
align-items: flex-end;
padding: 16px;
}
.info-collect-cell-modal-content {
width: 100%;
max-height: 82vh;
}
.info-collect-cell-modal-header {
padding: 16px;
}
.info-collect-cell-modal-body {
padding: 16px;
}
.info-collect-cell-modal-footer {
padding: 12px 16px 16px;
}
}
.info-collect-parse-modal-body {
@@ -29195,6 +29510,107 @@ html[data-theme="dark"] .skills-management-page .skill-card-actions .btn-seconda
padding: 18px 28px;
}
.info-collect-panel,
.info-collect-provider-card,
.info-collect-query-card,
.info-collect-options-card,
.info-collect-fields-card,
.info-collect-step-title,
.info-collect-step-title span,
.info-collect-inner-label,
.info-collect-provider-select .settings-custom-select-trigger,
.info-collect-provider-select .settings-custom-select-menu,
.info-collect-provider-select .settings-custom-select-option,
.info-collect-form-row #info-collect-full-option .checkbox-label,
.info-collect-results,
.info-collect-results-header,
.info-collect-selected,
.info-collect-results-title,
.info-collect-results-meta,
.info-collect-results-table-wrap,
.info-collect-table,
.info-collect-table th,
.info-collect-table td,
.info-collect-table .muted,
.info-collect-actions .btn-icon,
.info-collect-query-input,
.preset-chip,
.info-collect-link,
.info-collect-cell-text,
.info-collect-columns-panel,
.info-collect-columns-title,
.info-collect-col-item,
.info-collect-cell-modal,
.info-collect-cell-modal-content,
.info-collect-cell-modal-header,
.info-collect-cell-modal-title,
.info-collect-cell-modal-subtitle,
.info-collect-cell-modal-pre,
.info-collect-cell-modal-footer,
.info-collect-parse-warnings,
.info-collect-parse-explanation,
#page-info-collect input[type="text"],
#page-info-collect input[type="number"],
#page-info-collect select,
#page-info-collect textarea,
#page-info-collect .form-hint {
transition:
background 0.22s ease,
background-color 0.22s ease,
border-color 0.22s ease,
color 0.22s ease,
box-shadow 0.22s ease;
}
@media (prefers-reduced-motion: reduce) {
.info-collect-panel,
.info-collect-provider-card,
.info-collect-query-card,
.info-collect-options-card,
.info-collect-fields-card,
.info-collect-step-title,
.info-collect-step-title span,
.info-collect-inner-label,
.info-collect-provider-select .settings-custom-select-trigger,
.info-collect-provider-select .settings-custom-select-menu,
.info-collect-provider-select .settings-custom-select-option,
.info-collect-form-row #info-collect-full-option .checkbox-label,
.info-collect-results,
.info-collect-results-header,
.info-collect-selected,
.info-collect-results-title,
.info-collect-results-meta,
.info-collect-results-table-wrap,
.info-collect-table,
.info-collect-table th,
.info-collect-table td,
.info-collect-table .muted,
.info-collect-actions .btn-icon,
.info-collect-query-input,
.preset-chip,
.info-collect-link,
.info-collect-cell-text,
.info-collect-columns-panel,
.info-collect-columns-title,
.info-collect-col-item,
.info-collect-cell-modal,
.info-collect-cell-modal-content,
.info-collect-cell-modal-header,
.info-collect-cell-modal-title,
.info-collect-cell-modal-subtitle,
.info-collect-cell-modal-pre,
.info-collect-cell-modal-footer,
.info-collect-parse-warnings,
.info-collect-parse-explanation,
#page-info-collect input[type="text"],
#page-info-collect input[type="number"],
#page-info-collect select,
#page-info-collect textarea,
#page-info-collect .form-hint {
transition: none;
}
}
@media (max-width: 980px) {
.info-collect-columns-list {
grid-template-columns: repeat(2, minmax(140px, 1fr));
@@ -34381,8 +34797,10 @@ html[data-theme="dark"] #webshell-db-profile-modal .modal-header,
html[data-theme="dark"] #webshell-db-profile-modal .modal-footer,
html[data-theme="dark"] .webshell-db-toolbar label,
html[data-theme="dark"] .webshell-db-output-wrap,
html[data-theme="dark"] .info-collect-form > .form-group,
html[data-theme="dark"] .info-collect-form > .info-collect-form-row,
html[data-theme="dark"] .info-collect-provider-card,
html[data-theme="dark"] .info-collect-query-card,
html[data-theme="dark"] .info-collect-options-card,
html[data-theme="dark"] .info-collect-fields-card,
html[data-theme="dark"] .info-collect-selected,
html[data-theme="dark"] .info-collect-results-table-wrap,
html[data-theme="dark"] .info-collect-presets,
@@ -34394,6 +34812,39 @@ html[data-theme="dark"] .info-collect-col-item {
box-shadow: none !important;
}
html[data-theme="dark"] .info-collect-options-card {
background: #0f172a !important;
}
html[data-theme="dark"] .info-collect-step-title {
color: var(--text-primary) !important;
}
html[data-theme="dark"] .info-collect-step-title span {
background: rgba(96, 165, 250, 0.16) !important;
color: #93c5fd !important;
}
html[data-theme="dark"] .info-collect-provider-select .settings-custom-select-trigger,
html[data-theme="dark"] .info-collect-provider-select .settings-custom-select-menu {
background: #0f172a !important;
border-color: #263244 !important;
color: var(--text-primary) !important;
}
html[data-theme="dark"] .info-collect-provider-select .settings-custom-select-menu {
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.36) !important;
}
html[data-theme="dark"] .info-collect-provider-select .settings-custom-select-option:hover:not(:disabled) {
background: rgba(30, 41, 59, 0.82) !important;
}
html[data-theme="dark"] .info-collect-provider-select .settings-custom-select-option.is-selected {
background: rgba(59, 130, 246, 0.18) !important;
color: #93c5fd !important;
}
html[data-theme="dark"] .hitl-reviewer-toggle,
html[data-theme="dark"] .projects-tabs,
html[data-theme="dark"] .projects-sidebar-head,
@@ -34544,6 +34995,14 @@ html[data-theme="dark"] .info-collect-query-input {
border-color: #2b374b !important;
}
html[data-theme="dark"] #page-info-collect input[type="text"],
html[data-theme="dark"] #page-info-collect input[type="number"],
html[data-theme="dark"] #page-info-collect .info-collect-form-row #info-collect-full-option .checkbox-label {
background: #0f172a !important;
color: var(--text-primary) !important;
border-color: #2b374b !important;
}
html[data-theme="dark"] .hitl-config-select:focus,
html[data-theme="dark"] .hitl-config-input:focus,
html[data-theme="dark"] .hitl-config-textarea:focus,
@@ -34604,6 +35063,10 @@ html[data-theme="dark"] #page-info-collect .preset-chip:hover {
}
html[data-theme="dark"] .info-collect-panel {
background:
linear-gradient(135deg, rgba(96, 165, 250, 0.1), rgba(45, 212, 191, 0.06) 44%, transparent 70%),
#0b1120 !important;
border-color: #263244 !important;
box-shadow: none;
}
+12
View File
@@ -648,7 +648,17 @@
"einoRunRetryTitle": "🔁 Transient error retry",
"einoEmptyResponseContinueTitle": "🔁 Auto resume (no assistant text)",
"einoEmptyResponseContinueMessage": "Session ended without captured assistant text; resuming from trace…",
"einoRunRetryPlan": "Retry progress: attempt {{attempt}}/{{maxAttempts}}, waiting {{backoffSec}}s",
"einoRunRetryReasonKind": "Reason type",
"einoRunRetryErrorDetail": "Error detail",
"einoRunRetryKind_rate_limit": "Rate limited / too many requests",
"einoRunRetryKind_retryable_http": "Retryable HTTP error",
"einoRunRetryKind_upstream_server": "Upstream server error",
"einoRunRetryKind_http_error": "HTTP error",
"einoRunRetryKind_upstream_busy": "Upstream busy",
"einoRunRetryKind_network": "Network connection issue",
"einoRunRetryKind_stream": "Streaming read issue",
"einoRunRetryKind_transient": "Transient issue",
"iterationLimitReachedTitle": "⛔ Iteration limit reached",
"iterationLimitReachedMessage": "Maximum iteration count reached; automatic iteration has stopped.",
"einoPendingOrphanedTitle": "🧹 Tool call reconciliation",
@@ -993,6 +1003,7 @@
"batchScanFailed": "Batch scan failed",
"batchQueueCreated": "Batch scan queue created",
"field": "Field",
"cellValueLength": "{{count}} characters",
"parsePending": "AI parsing...",
"parsePendingClickCancel": "AI parsing... (click button to cancel)",
"parseSlow": "AI parse is taking a while, still processing…",
@@ -1013,6 +1024,7 @@
"none": "None",
"truncated": "truncated",
"resultsMeta": "Total {{total}} · This page {{count}} · page={{page}} · size={{size}}",
"providerReturnedFewer": "Provider returned {{count}}/{{expected}} rows",
"parseModalCancel": "Cancel",
"parseModalApply": "Fill into query",
"parseModalApplyRun": "Fill and query"
+12
View File
@@ -636,7 +636,17 @@
"einoRunRetryTitle": "🔁 临时错误重试",
"einoEmptyResponseContinueTitle": "🔁 自动续跑(无助手正文)",
"einoEmptyResponseContinueMessage": "会话已结束但未捕获到助手正文,正在基于轨迹自动续跑…",
"einoRunRetryPlan": "重试进度:第 {{attempt}}/{{maxAttempts}} 次,等待 {{backoffSec}} 秒",
"einoRunRetryReasonKind": "原因类型",
"einoRunRetryErrorDetail": "具体报错",
"einoRunRetryKind_rate_limit": "限流 / 请求过多",
"einoRunRetryKind_retryable_http": "可重试 HTTP 错误",
"einoRunRetryKind_upstream_server": "上游服务错误",
"einoRunRetryKind_http_error": "HTTP 错误",
"einoRunRetryKind_upstream_busy": "上游繁忙",
"einoRunRetryKind_network": "网络连接异常",
"einoRunRetryKind_stream": "流式读取异常",
"einoRunRetryKind_transient": "临时异常",
"iterationLimitReachedTitle": "⛔ 达到迭代上限",
"iterationLimitReachedMessage": "已达到最大迭代次数,任务已停止继续自动迭代。",
"einoPendingOrphanedTitle": "🧹 工具调用收尾补偿",
@@ -981,6 +991,7 @@
"batchScanFailed": "批量扫描失败",
"batchQueueCreated": "已创建批量扫描队列",
"field": "字段",
"cellValueLength": "共 {{count}} 个字符",
"parsePending": "AI 解析中...",
"parsePendingClickCancel": "AI 解析中...(点击按钮可取消)",
"parseSlow": "AI 解析耗时较长,仍在处理中…",
@@ -1001,6 +1012,7 @@
"none": "无",
"truncated": "已截断",
"resultsMeta": "共 {{total}} 条 · 本页 {{count}} 条 · page={{page}} · size={{size}}",
"providerReturnedFewer": "上游实际返回 {{count}}/{{expected}} 条",
"parseModalCancel": "取消",
"parseModalApply": "填入查询框",
"parseModalApplyRun": "填入并查询"
+17 -8
View File
@@ -1578,18 +1578,26 @@ function fofaResultToAsset(row, fields) {
const idx = Array.isArray(fields) ? fields.indexOf(name) : -1;
return idx >= 0 && Array.isArray(row) && row[idx] != null ? String(row[idx]).trim() : '';
};
const firstValue = names => {
for (const name of names) {
const v = value(name);
if (v) return v;
}
return '';
};
const port = Number.parseInt(value('port'), 10);
const rawIP = value('ip');
const rawIP = firstValue(['ip', 'ip_str']);
const ip = assetEditorIsIPv4(rawIP) || assetEditorIsIPv6(rawIP) ? rawIP.toLowerCase() : '';
const rawDomain = value('domain');
const rawDomain = firstValue(['domain', 'hostname', 'hostnames', 'domains']);
const domain = rawDomain && !assetEditorIsIPv4(rawDomain) && !assetEditorIsIPv6(rawDomain)
? assetEditorNormalizeDomain(rawDomain)
? assetEditorNormalizeDomain(rawDomain.split(',')[0].replace(/^\[|\]$/g, '').replace(/^"|"$/g, ''))
: '';
const rawProtocol = value('protocol').toLowerCase();
const rawProtocol = firstValue(['protocol', 'service.name', 'transport']).toLowerCase();
return {
host: value('host'), ip, port: Number.isFinite(port) ? port : 0, domain,
protocol: /^[a-z][a-z0-9+.-]{0,31}$/.test(rawProtocol) ? rawProtocol : '', title: value('title'), server: value('server'), country: value('country'),
province: value('province'), city: value('city'), source: 'fofa', status: 'active'
host: firstValue(['host', 'hostname']), ip, port: Number.isFinite(port) ? port : 0, domain,
protocol: /^[a-z][a-z0-9+.-]{0,31}$/.test(rawProtocol) ? rawProtocol : '', title: firstValue(['title', 'service.http.title']), server: firstValue(['server', 'product']),
country: firstValue(['country', 'location.country_cn', 'location.country_name']), province: firstValue(['province', 'location.province_cn']), city: firstValue(['city', 'location.city_cn', 'location.city']),
source: (window.infoCollectState?.currentPayload?.provider || 'fofa'), status: 'active'
};
}
@@ -1607,7 +1615,8 @@ async function importFofaAssetsByIndexes(indexes) {
alert(assetT('assets.noValidImportTarget', '所选结果中没有可入库的有效资产目标'));
return;
}
const response = await apiFetch('/api/assets/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assets, source: 'fofa', source_query: current.query || '' }) });
const source = current.provider || 'fofa';
const response = await apiFetch('/api/assets/import', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assets, source, source_query: current.query || '' }) });
if (!response.ok) {
alert(assetT('assets.importFailed', '资产入库失败') + ': ' + await response.text());
return;
+79 -13
View File
@@ -2644,6 +2644,83 @@ function renderProcessDetails(messageId, processDetails, options) {
return s ? ('[' + s + '] ') : '';
}
function formatProcessDetailEinoRunRetryKind(kind) {
if (typeof window.formatEinoRunRetryKind === 'function') {
return window.formatEinoRunRetryKind(kind);
}
const key = String(kind || '').trim();
if (!key) return '';
const labels = {
rate_limit: '限流 / 请求过多',
retryable_http: '可重试 HTTP 错误',
upstream_server: '上游服务错误',
http_error: 'HTTP 错误',
upstream_busy: '上游繁忙',
network: '网络连接异常',
stream: '流式读取异常',
transient: '临时异常'
};
if (typeof window.t === 'function') {
const translated = window.t('chat.einoRunRetryKind_' + key);
if (translated && translated !== 'chat.einoRunRetryKind_' + key) return translated;
}
return labels[key] || key;
}
function formatProcessDetailEinoRunRetryTitle(data) {
if (typeof window.formatEinoRunRetryTitle === 'function') {
return window.formatEinoRunRetryTitle(data);
}
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
return base + '' + attempt + '/' + maxAttempts + '';
}
return base;
}
function formatProcessDetailEinoRunRetryMessage(message, data) {
if (typeof window.formatEinoRunRetryMessage === 'function') {
return window.formatEinoRunRetryMessage(message, data);
}
const d = data && typeof data === 'object' ? data : {};
const base = String(message || '').trim();
const errRaw = d.errorSummary != null && String(d.errorSummary).trim() !== ''
? String(d.errorSummary).trim()
: (d.error != null ? String(d.error).trim() : '');
const lines = [];
if (base) lines.push(base);
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
const backoffSec = Number(d.backoffSec || 0);
const kind = formatProcessDetailEinoRunRetryKind(d.errorKind);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
const retryPlan = typeof window.t === 'function'
? window.t('chat.einoRunRetryPlan', { attempt: attempt, maxAttempts: maxAttempts, backoffSec: Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-' })
: ('重试进度:第 ' + attempt + '/' + maxAttempts + ' 次,等待 ' + (Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-') + ' 秒');
if (!base || base.indexOf(String(attempt) + '/' + String(maxAttempts)) === -1) {
lines.push(retryPlan);
}
}
if (kind) {
const kindLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryReasonKind')
: '原因类型';
lines.push(kindLabel + '' + kind);
}
if (errRaw && (!base || base.indexOf(errRaw) === -1)) {
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
lines.push(detailLabel + '' + errRaw);
}
return lines.join('\n');
}
function renderOneProcessDetail(detail) {
const eventType = detail.eventType || '';
const title = detail.message || '';
@@ -2745,19 +2822,8 @@ function renderProcessDetails(messageId, processDetails, options) {
? window.t('chat.einoEmptyResponseContinueTitle')
: '🔁 自动续跑(无助手正文)';
} else if (eventType === 'eino_run_retry') {
itemTitle = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const errRaw = data && data.error != null ? String(data.error).trim() : '';
if (errRaw) {
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
if (!title || String(title).indexOf(errRaw) === -1) {
const merged = title ? (String(title) + '\n' + detailLabel + '' + errRaw) : (detailLabel + '' + errRaw);
detail.message = merged;
}
}
itemTitle = formatProcessDetailEinoRunRetryTitle(data);
detail.message = formatProcessDetailEinoRunRetryMessage(title, data);
} else if (eventType === 'knowledge_retrieval') {
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
} else if (eventType === 'error') {
+622 -31
View File
@@ -6,11 +6,149 @@ function _t(key, opts) {
const FOFA_FORM_STORAGE_KEY = 'info-collect-fofa-form';
const FOFA_HIDDEN_FIELDS_STORAGE_KEY = 'info-collect-fofa-hidden-fields';
const INFO_COLLECT_PROVIDERS = {
fofa: {
label: 'FOFA',
placeholder: '例如:app="Apache" && country="CN"',
nlPlaceholder: '例如:找美国 Missouri 的 Apache 站点,标题包含 Home',
hint: '查询语法参考 FOFA 文档,支持 && / || / () 等。',
parseHint: '解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'FOFA 返回数量上限与账号权限相关,前端最多允许 10000。',
fullOption: {
label: '完整模式',
hint: '向 FOFA 传 full=true,返回更完整/更实时的数据,可能消耗更多额度。'
},
fields: 'host,ip,port,domain,title,protocol,country,province,city,server',
presets: [
['Apache + 中国', 'app="Apache" && country="CN"'],
['登录页 + 中国', 'title="登录" && country="CN"'],
['指定域名', 'domain="example.com"'],
['指定 IP', 'ip="1.1.1.1"']
],
fieldPresets: [
['最小字段', 'host,ip,port,domain'],
['Web 常用', 'host,title,ip,port,domain,protocol,server,icp,country,province,city'],
['情报增强', 'host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner']
],
syntaxGuide: {
summary: 'FOFA 使用 field="value" 精确匹配,支持 &&、||、! 和括号组合;字符串建议用双引号包裹。',
docsUrl: 'https://en.fofa.info/api',
sections: [
['常用字段', ['app="Apache"', 'title="后台管理"', 'body="Powered by"', 'domain="example.com"', 'host="https://example.com"', 'ip="1.1.1.1"', 'port="443"', 'country="CN"', 'city="Hangzhou"', 'server="nginx"']],
['组合写法', ['app="nginx" && country="CN"', 'title="login" || title="登录"', '(app="Apache" || app="nginx") && port="443"', 'domain="example.com" && !title="404"']],
['场景示例', ['cert="example.com" && port="443"', 'header="JSESSIONID" && country="CN"', 'icon_hash="-247388890"', 'fid="sZyXkR9e" && domain="example.com"']]
]
}
},
zoomeye: {
label: 'ZoomEye',
placeholder: '例如:app="Apache" && country="CN"',
nlPlaceholder: '例如:找中国的 SSH 服务,排除蜜罐',
hint: 'ZoomEye 支持 app/title/domain/ip/port/country/city 等语法。',
parseHint: '解析后会弹窗展示 ZoomEye 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'ZoomEye pagesize 最高支持到 10000,实际额度以账号为准。',
fullOption: null,
fields: 'ip,port,domain,hostname,title,service,app,country,city',
presets: [
['Apache + 中国', 'app="Apache" && country="CN"'],
['SSH 服务', 'service="ssh"'],
['指定域名', 'domain="example.com"'],
['指定 IP', 'ip="1.1.1.1"']
],
fieldPresets: [
['最小字段', 'ip,port,domain,hostname'],
['Web 常用', 'ip,port,domain,hostname,title,service,app,country,city'],
['情报增强', 'ip,port,domain,hostname,title,service,app,country,city,org,isp,ssl']
],
syntaxGuide: {
summary: 'ZoomEye 支持字段检索、引号短语、AND/OR/NOT 与括号组合;字段名以官方控制台实际支持为准。',
docsUrl: 'https://www.zoomeye.ai/help',
sections: [
['常用字段', ['app="Apache"', 'service="ssh"', 'title="登录"', 'domain="example.com"', 'hostname="example.com"', 'ip="1.1.1.1"', 'port=443', 'country="CN"', 'city="Beijing"', 'org="Tencent"']],
['组合写法', ['app="nginx" AND country="CN"', 'service="http" AND (title="login" OR title="登录")', 'domain="example.com" AND NOT app="cloudflare"', 'port=443 AND country="US"']],
['场景示例', ['ssl.cert.fingerprint="SHA256值"', 'iconhash="-247388890"', 'service="rdp" AND country="CN"', 'app="Elasticsearch" AND port=9200']]
]
}
},
quake: {
label: 'Quake',
placeholder: '例如:service.name:"http" AND country_cn:"中国"',
nlPlaceholder: '例如:找中国的 HTTP 服务,标题包含登录',
hint: 'Quake 使用 DSL 语法,常见字段如 service.name、domain、ip、port、country_cn。',
parseHint: '解析后会弹窗展示 Quake DSL(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 10000,
sizeHint: 'Quake size 会消耗积分,建议按需控制返回数量。',
fullOption: {
label: '最新数据',
hint: '向 Quake 传 latest=true,优先查询最新数据。'
},
fields: 'ip,port,domain,service.name,service.http.title,location.country_cn,location.province_cn,location.city_cn',
presets: [
['HTTP + 中国', 'service.name:"http" AND country_cn:"中国"'],
['443 端口', 'port:443'],
['指定域名', 'domain:"example.com"'],
['指定 IP', 'ip:"1.1.1.1"']
],
fieldPresets: [
['最小字段', 'ip,port,domain'],
['Web 常用', 'ip,port,domain,service.name,service.http.title,location.country_cn,location.city_cn'],
['情报增强', 'ip,port,domain,service.name,service.http.title,service.http.server,location.country_cn,location.province_cn,location.city_cn,asn']
],
syntaxGuide: {
summary: 'Quake 使用 Lucene/DSL 风格查询,常见形式是 field:"value",逻辑运算符通常使用 AND、OR、NOT。',
docsUrl: 'https://quake.360.net/quake/#/help',
sections: [
['常用字段', ['service.name:"http"', 'service.http.title:"登录"', 'service.http.server:"nginx"', 'domain:"example.com"', 'ip:"1.1.1.1"', 'port:443', 'country_cn:"中国"', 'province_cn:"浙江"', 'city_cn:"杭州"']],
['组合写法', ['service.name:"http" AND country_cn:"中国"', '(service.name:"http" OR service.name:"https") AND port:443', 'domain:"example.com" AND NOT service.http.title:"404"', 'service.http.title:"login" AND port:443']],
['场景示例', ['service.http.favicon.hash:"-247388890"', 'service.http.response.header:"JSESSIONID"', 'service.name:"ssh" AND country_cn:"中国"', 'service.http.title:"Dashboard" AND NOT ip:"127.0.0.1"']]
]
}
},
shodan: {
label: 'Shodan',
placeholder: '例如:product:nginx country:CN',
nlPlaceholder: '例如:找中国的 nginx 资产,端口 443',
hint: 'Shodan 使用 filter:value 语法,常见字段如 product、port、country、org。',
parseHint: '解析后会弹窗展示 Shodan filter 语法(可编辑),确认无误后再填入查询框并执行查询。',
maxSize: 1000,
sizeHint: 'Shodan 官方每页 100 条;后端会自动翻页聚合,单次最多 1000 条以控制额度消耗。',
fullOption: null,
fields: 'ip_str,port,hostnames,domains,org,isp,location.country_name,location.city,product,transport',
presets: [
['Nginx + 中国', 'product:nginx country:CN'],
['SSH 服务', 'port:22'],
['证书域名', 'ssl.cert.subject.cn:example.com'],
['Amazon 443', 'org:"Amazon" port:443']
],
fieldPresets: [
['最小字段', 'ip_str,port,hostnames,domains'],
['Web 常用', 'ip_str,port,hostnames,domains,product,org,location.country_name,location.city'],
['情报增强', 'ip_str,port,hostnames,domains,org,isp,asn,location.country_name,location.city,product,transport,ssl.cert.subject.cn']
],
syntaxGuide: {
summary: 'Shodan 默认搜索 banner data;精确条件使用 filter:value,值含空格时用双引号,多个过滤器并列表示收窄结果。',
docsUrl: 'https://help.shodan.io/the-basics/search-query-fundamentals',
sections: [
['常用过滤器', ['product:nginx', 'port:443', 'country:CN', 'city:Shanghai', 'org:"Amazon"', 'asn:AS15169', 'hostname:example.com', 'ssl.cert.subject.cn:example.com', 'http.title:"Dashboard"']],
['组合写法', ['product:nginx country:CN', 'apache port:443 country:DE', 'org:"Amazon" port:443', 'ssl.cert.subject.cn:example.com port:443']],
['场景示例', ['http.title:"login" country:CN', 'ssl:true port:443 hostname:example.com', 'vuln:CVE-2021-41773', 'has_screenshot:true product:nginx']]
]
}
}
};
const infoCollectState = {
currentPayload: null, // { fields, results, query, total, page, size }
hiddenFields: new Set(),
selectedRowIndexes: new Set(),
tableBound: false
tableBound: false,
providerSelectBound: false,
presetEventsBound: false,
syntaxGuideExpanded: false,
queryHeightFrame: null,
queryHeightResizeBound: false
};
// AI 解析(自然语言 -> FOFA)交互状态
@@ -31,6 +169,7 @@ if (typeof escapeHtml === 'undefined') {
function getFofaFormElements() {
return {
query: document.getElementById('fofa-query'),
provider: document.getElementById('fofa-provider'),
nl: document.getElementById('fofa-nl'),
size: document.getElementById('fofa-size'),
page: document.getElementById('fofa-page'),
@@ -45,6 +184,25 @@ function getFofaFormElements() {
};
}
function getInfoCollectProvider() {
const provider = (document.getElementById('fofa-provider')?.value || 'fofa').trim().toLowerCase();
return INFO_COLLECT_PROVIDERS[provider] ? provider : 'fofa';
}
function providerLabel(provider) {
return (INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa).label;
}
function getInfoCollectFullOption(provider) {
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
return cfg.fullOption || null;
}
function isInfoCollectFullEnabled(provider) {
const els = getFofaFormElements();
return !!(getInfoCollectFullOption(provider) && els.full && els.full.checked);
}
function loadHiddenFieldsFromStorage() {
try {
const raw = localStorage.getItem(FOFA_HIDDEN_FIELDS_STORAGE_KEY);
@@ -94,13 +252,24 @@ function initInfoCollectPage() {
// 恢复上次输入
const saved = loadFofaFormFromStorage();
let shouldResetProviderFields = false;
if (saved) {
if (typeof saved.provider === 'string' && els.provider && INFO_COLLECT_PROVIDERS[saved.provider]) els.provider.value = saved.provider;
if (typeof saved.query === 'string') els.query.value = saved.query;
if (typeof saved.size === 'number' || typeof saved.size === 'string') els.size.value = saved.size;
if (typeof saved.page === 'number' || typeof saved.page === 'string') els.page.value = saved.page;
if (typeof saved.fields === 'string') els.fields.value = saved.fields;
if (typeof saved.full === 'boolean') els.full.checked = saved.full;
const provider = getInfoCollectProvider();
const savedFields = String(saved.fields || '').trim();
shouldResetProviderFields = provider !== 'fofa' && (
savedFields === INFO_COLLECT_PROVIDERS.fofa.fields ||
savedFields === 'host,ip,port,domain'
);
}
initInfoCollectProviderSelect();
bindInfoCollectPresetEvents();
refreshInfoCollectProviderUI(shouldResetProviderFields);
// 绑定 Enter 快捷查询(在 query 里用 Ctrl/Cmd+Enter
els.query.addEventListener('keydown', (e) => {
@@ -139,23 +308,391 @@ function initInfoCollectPage() {
autoGrowTextarea(els.query);
autoGrowTextarea(els.nl);
}, 0);
setInfoCollectQueryMode('syntax', { focus: false });
if (!infoCollectState.queryHeightResizeBound) {
infoCollectState.queryHeightResizeBound = true;
window.addEventListener('resize', scheduleInfoCollectQueryCardHeightStabilize);
}
// 绑定表格事件(事件委托,只绑定一次)
bindFofaTableEvents();
updateSelectedMeta();
}
function handleInfoCollectProviderChange() {
infoCollectState.syntaxGuideExpanded = false;
refreshInfoCollectProviderUI(true);
}
function setInfoCollectQueryMode(mode, options) {
const shouldFocus = options?.focus !== false;
const syntaxPanel = document.getElementById('info-collect-syntax-panel');
const naturalPanel = document.getElementById('info-collect-natural-panel');
if (syntaxPanel) {
syntaxPanel.hidden = false;
syntaxPanel.classList.add('is-active');
syntaxPanel.classList.add('is-generated-target');
}
if (naturalPanel) {
naturalPanel.hidden = false;
naturalPanel.classList.add('is-active');
}
const queryLabel = document.getElementById('info-collect-query-label');
const cfg = INFO_COLLECT_PROVIDERS[getInfoCollectProvider()] || INFO_COLLECT_PROVIDERS.fofa;
if (queryLabel) {
queryLabel.textContent = cfg.label + ' 查询语法(可编辑,可直接查询)';
}
const nlLabel = document.getElementById('info-collect-nl-label');
if (nlLabel) {
nlLabel.textContent = '自然语言(可选,AI 解析为 ' + cfg.label + ' 语法)';
}
if (shouldFocus) {
const focusTarget = mode === 'natural' ? document.getElementById('fofa-nl') : document.getElementById('fofa-query');
try { focusTarget?.focus(); } catch (e) { /* ignore */ }
}
scheduleInfoCollectQueryCardHeightStabilize();
}
function scheduleInfoCollectQueryCardHeightStabilize() {
if (infoCollectState.queryHeightFrame) {
cancelAnimationFrame(infoCollectState.queryHeightFrame);
}
infoCollectState.queryHeightFrame = requestAnimationFrame(() => {
infoCollectState.queryHeightFrame = null;
stabilizeInfoCollectQueryCardHeight();
});
}
function stabilizeInfoCollectQueryCardHeight() {
const card = document.querySelector('.info-collect-query-card');
if (!card) return;
const rect = card.getBoundingClientRect();
if (!rect.width) return;
const clone = card.cloneNode(true);
clone.style.position = 'absolute';
clone.style.visibility = 'hidden';
clone.style.pointerEvents = 'none';
clone.style.left = '-10000px';
clone.style.top = '0';
clone.style.width = rect.width + 'px';
clone.style.height = 'auto';
clone.style.minHeight = '0';
clone.style.maxHeight = 'none';
const naturalPanel = clone.querySelector('#info-collect-natural-panel');
if (naturalPanel) {
naturalPanel.hidden = false;
naturalPanel.classList.add('is-active');
}
const syntaxPanel = clone.querySelector('#info-collect-syntax-panel');
if (syntaxPanel) {
syntaxPanel.hidden = false;
syntaxPanel.classList.add('is-active', 'is-generated-target');
}
const queryLabel = clone.querySelector('#info-collect-query-label');
if (queryLabel) {
const cfg = INFO_COLLECT_PROVIDERS[getInfoCollectProvider()] || INFO_COLLECT_PROVIDERS.fofa;
queryLabel.textContent = cfg.label + ' 查询语法(可编辑,可直接查询)';
}
document.body.appendChild(clone);
const stableHeight = Math.ceil(clone.getBoundingClientRect().height);
clone.remove();
if (stableHeight > 0) {
card.style.minHeight = stableHeight + 'px';
}
}
function presetDataAttr(value) {
return escapeHtml(String(value == null ? '' : value))
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function bindInfoCollectPresetEvents() {
if (infoCollectState.presetEventsBound) return;
infoCollectState.presetEventsBound = true;
document.addEventListener('click', (event) => {
const queryBtn = event.target.closest?.('[data-info-query-preset]');
if (queryBtn) {
event.preventDefault();
applyFofaQueryPreset(queryBtn.getAttribute('data-info-query-preset') || '');
return;
}
const fieldsBtn = event.target.closest?.('[data-info-fields-preset]');
if (fieldsBtn) {
event.preventDefault();
applyFofaFieldsPreset(fieldsBtn.getAttribute('data-info-fields-preset') || '');
return;
}
const guideToggle = event.target.closest?.('[data-info-syntax-guide-toggle]');
if (guideToggle) {
event.preventDefault();
toggleInfoCollectSyntaxGuide();
}
});
}
function initInfoCollectProviderSelect() {
const els = getFofaFormElements();
const select = els.provider;
if (!select || infoCollectState.providerSelectBound) {
syncInfoCollectProviderSelect();
return;
}
infoCollectState.providerSelectBound = true;
select.classList.add('settings-native-select');
select.tabIndex = -1;
select.setAttribute('aria-hidden', 'true');
const wrapper = document.createElement('div');
wrapper.className = 'settings-custom-select info-collect-provider-select';
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'settings-custom-select-trigger';
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-expanded', 'false');
const value = document.createElement('span');
value.className = 'settings-custom-select-value';
value.id = 'info-collect-provider-select-value';
const caret = document.createElement('span');
caret.className = 'settings-custom-select-caret';
caret.setAttribute('aria-hidden', 'true');
caret.textContent = '▾';
const menu = document.createElement('div');
menu.className = 'settings-custom-select-menu';
menu.id = 'info-collect-provider-select-menu';
menu.setAttribute('role', 'listbox');
trigger.appendChild(value);
trigger.appendChild(caret);
select.parentNode.insertBefore(wrapper, select);
wrapper.appendChild(trigger);
wrapper.appendChild(menu);
wrapper.appendChild(select);
trigger.addEventListener('click', (event) => {
event.stopPropagation();
const willOpen = !wrapper.classList.contains('open');
closeInfoCollectProviderSelect();
wrapper.classList.toggle('open', willOpen);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
});
trigger.addEventListener('keydown', (event) => {
const options = Array.prototype.filter.call(select.options, (option) => !option.disabled);
if (!options.length) return;
const current = Math.max(0, options.indexOf(select.options[select.selectedIndex]));
let next = current;
if (event.key === 'ArrowDown') next = Math.min(options.length - 1, current + 1);
else if (event.key === 'ArrowUp') next = Math.max(0, current - 1);
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = options.length - 1;
else if (event.key === 'Escape') {
closeInfoCollectProviderSelect();
return;
} else if (event.key === 'Enter' || event.key === ' ') {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
event.preventDefault();
return;
} else {
return;
}
event.preventDefault();
const nextOption = options[next];
if (nextOption && select.value !== nextOption.value) {
select.value = nextOption.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncInfoCollectProviderSelect();
});
menu.addEventListener('click', (event) => {
const item = event.target.closest('.settings-custom-select-option');
if (!item || item.disabled) return;
event.stopPropagation();
const option = select.options[Number(item.dataset.index)];
if (option && !option.disabled && select.value !== option.value) {
select.value = option.value;
select.dispatchEvent(new Event('change', { bubbles: true }));
}
syncInfoCollectProviderSelect();
closeInfoCollectProviderSelect();
});
select.addEventListener('change', syncInfoCollectProviderSelect);
document.addEventListener('click', closeInfoCollectProviderSelect);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeInfoCollectProviderSelect();
});
syncInfoCollectProviderSelect();
}
function closeInfoCollectProviderSelect() {
const wrapper = document.querySelector('.info-collect-provider-select');
const trigger = wrapper?.querySelector('.settings-custom-select-trigger');
if (!wrapper) return;
wrapper.classList.remove('open');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
}
function syncInfoCollectProviderSelect() {
const select = document.getElementById('fofa-provider');
const wrapper = document.querySelector('.info-collect-provider-select');
if (!select || !wrapper) return;
const value = wrapper.querySelector('.settings-custom-select-value');
const menu = wrapper.querySelector('.settings-custom-select-menu');
const selected = select.options[select.selectedIndex];
if (value) value.textContent = selected ? selected.textContent : '';
if (!menu) return;
menu.innerHTML = '';
Array.prototype.forEach.call(select.options, (option, index) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'settings-custom-select-option';
item.setAttribute('role', 'option');
item.setAttribute('data-index', String(index));
item.setAttribute('aria-selected', option.selected ? 'true' : 'false');
item.classList.toggle('is-selected', option.selected);
item.disabled = !!option.disabled;
const check = document.createElement('span');
check.className = 'settings-custom-select-check';
check.setAttribute('aria-hidden', 'true');
check.textContent = '✓';
const label = document.createElement('span');
label.className = 'settings-custom-select-label';
label.textContent = option.textContent;
item.appendChild(check);
item.appendChild(label);
menu.appendChild(item);
});
}
function renderInfoCollectSyntaxGuide(cfg) {
const container = document.getElementById('info-collect-syntax-guide');
if (!container) return;
const guide = cfg.syntaxGuide;
if (!guide) {
container.innerHTML = '';
container.hidden = true;
return;
}
const docsLink = guide.docsUrl
? `<a class="info-collect-doc-link" href="${presetDataAttr(guide.docsUrl)}" target="_blank" rel="noopener noreferrer">官方文档</a>`
: '';
const expanded = !!infoCollectState.syntaxGuideExpanded;
const sections = (guide.sections || []).map(([title, examples]) => {
const chips = (examples || []).map(example => {
return `<button class="syntax-example-chip" type="button" data-info-query-preset="${presetDataAttr(example)}" title="填入查询框">${escapeHtml(example)}</button>`;
}).join('');
return `<div class="syntax-guide-section"><div class="syntax-guide-title">${escapeHtml(title)}</div><div class="syntax-guide-examples">${chips}</div></div>`;
}).join('');
container.hidden = false;
container.classList.toggle('is-expanded', expanded);
container.innerHTML = `
<div class="syntax-guide-header">
<div class="syntax-guide-summary">${escapeHtml(guide.summary || '')}</div>
<div class="syntax-guide-actions">
${docsLink}
<button class="syntax-guide-toggle" type="button" data-info-syntax-guide-toggle aria-expanded="${expanded ? 'true' : 'false'}">${expanded ? '收起示例' : '展开示例'}</button>
</div>
</div>
<div class="syntax-guide-body"${expanded ? '' : ' hidden'}>${sections}</div>
`;
}
function toggleInfoCollectSyntaxGuide() {
infoCollectState.syntaxGuideExpanded = !infoCollectState.syntaxGuideExpanded;
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
renderInfoCollectSyntaxGuide(cfg);
scheduleInfoCollectQueryCardHeightStabilize();
}
function refreshInfoCollectProviderUI(resetProviderFields) {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const queryLabel = document.getElementById('info-collect-query-label');
const nlLabel = document.getElementById('info-collect-nl-label');
const queryHint = document.getElementById('info-collect-query-hint');
const parseHint = document.getElementById('info-collect-parse-hint');
const sizeHint = document.getElementById('info-collect-size-hint');
const parseBtn = document.getElementById('fofa-nl-parse-btn');
const presets = document.getElementById('info-collect-query-presets');
const fieldPresets = document.getElementById('info-collect-fields-presets');
const fullOption = document.getElementById('info-collect-full-option');
const fullText = fullOption ? fullOption.querySelector('.checkbox-text') : null;
const fullConfig = getInfoCollectFullOption(provider);
if (queryLabel) queryLabel.textContent = cfg.label + ' 查询语法';
if (nlLabel) nlLabel.textContent = '自然语言(AI 解析为 ' + cfg.label + ' 语法)';
if (queryHint) queryHint.textContent = cfg.hint;
if (parseHint) parseHint.textContent = cfg.parseHint;
if (sizeHint) sizeHint.textContent = cfg.sizeHint;
if (parseBtn && parseBtn.dataset.loading !== '1') parseBtn.title = '将自然语言解析为 ' + cfg.label + ' 查询语法';
if (els.query) els.query.placeholder = cfg.placeholder;
if (els.nl) els.nl.placeholder = cfg.nlPlaceholder;
if (els.size) {
els.size.max = String(cfg.maxSize || 10000);
const currentSize = parseInt(els.size.value, 10) || 100;
if (cfg.maxSize && currentSize > cfg.maxSize) els.size.value = cfg.maxSize;
}
if (fullOption) {
if (fullConfig) {
fullOption.hidden = false;
fullOption.title = fullConfig.hint || '';
if (fullText) fullText.textContent = fullConfig.label || _t('infoCollectPage.fullLabel');
} else {
fullOption.hidden = true;
fullOption.title = '';
if (els.full) els.full.checked = false;
}
}
if (els.fields && (resetProviderFields || !els.fields.value.trim())) els.fields.value = cfg.fields;
if (presets) {
presets.innerHTML = cfg.presets.map(([label, query]) => {
return `<button class="preset-chip" type="button" data-info-query-preset="${presetDataAttr(query)}" title="填入示例">${escapeHtml(label)}</button>`;
}).join('');
}
if (fieldPresets) {
fieldPresets.innerHTML = cfg.fieldPresets.map(([label, fields]) => {
return `<button class="preset-chip" type="button" data-info-fields-preset="${presetDataAttr(fields)}" title="填入字段模板">${escapeHtml(label)}</button>`;
}).join('');
}
renderInfoCollectSyntaxGuide(cfg);
saveFofaFormToStorage({
provider,
query: (els.query?.value || '').trim(),
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: isInfoCollectFullEnabled(provider)
});
setInfoCollectQueryMode('syntax', { focus: false });
scheduleInfoCollectQueryCardHeightStabilize();
}
function applyFofaQueryPreset(preset) {
const els = getFofaFormElements();
if (!els.query) return;
setInfoCollectQueryMode('syntax');
els.query.value = (preset || '').trim();
els.query.focus();
saveFofaFormToStorage({
provider: getInfoCollectProvider(),
query: els.query.value,
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: !!els.full?.checked
full: isInfoCollectFullEnabled(getInfoCollectProvider())
});
}
@@ -165,54 +702,68 @@ function applyFofaFieldsPreset(preset) {
els.fields.value = (preset || '').trim();
els.fields.focus();
saveFofaFormToStorage({
provider: getInfoCollectProvider(),
query: (els.query?.value || '').trim(),
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields.value,
full: !!els.full?.checked
full: isInfoCollectFullEnabled(getInfoCollectProvider())
});
}
function resetFofaForm() {
const els = getFofaFormElements();
if (!els.query) return;
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
els.query.value = '';
if (els.size) els.size.value = 100;
if (els.page) els.page.value = 1;
if (els.fields) els.fields.value = 'host,ip,port,domain,title,protocol,country,province,city,server';
if (els.fields) els.fields.value = cfg.fields;
if (els.full) els.full.checked = false;
if (els.nl) els.nl.value = '';
setInfoCollectQueryMode('syntax');
saveFofaFormToStorage({
provider,
query: els.query.value,
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: els.fields?.value || '',
full: !!els.full?.checked
full: isInfoCollectFullEnabled(provider)
});
renderFofaResults({ query: '', fields: [], results: [], total: 0, page: 1, size: 0 });
}
async function submitFofaSearch() {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const query = (els.query?.value || '').trim();
const size = parseInt(els.size?.value, 10) || 100;
const providerCfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const maxSize = providerCfg.maxSize || 10000;
let size = parseInt(els.size?.value, 10) || 100;
if (size > maxSize) {
size = maxSize;
if (els.size) els.size.value = String(maxSize);
showInlineToast(providerCfg.label + ' 单次最多返回 ' + maxSize + ' 条,已自动调整。');
}
const page = parseInt(els.page?.value, 10) || 1;
const fields = (els.fields?.value || '').trim();
const full = !!els.full?.checked;
const full = isInfoCollectFullEnabled(provider);
if (!query) {
alert(_t('infoCollect.enterFofaQuery'));
return;
}
saveFofaFormToStorage({ query, size, page, fields, full });
setFofaMeta(_t('infoCollect.querying'));
saveFofaFormToStorage({ provider, query, size, page, fields, full });
setFofaMeta(providerLabel(provider) + ' ' + _t('infoCollect.querying'));
setFofaLoading(true);
try {
const response = await apiFetch('/api/fofa/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, size, page, fields, full })
body: JSON.stringify({ provider, query, size, page, fields, full })
});
const result = await response.json().catch(() => ({}));
@@ -221,9 +772,9 @@ async function submitFofaSearch() {
}
renderFofaResults(result);
} catch (e) {
console.error('FOFA 查询失败:', e);
console.error(providerLabel(provider) + ' 查询失败:', e);
setFofaMeta(_t('infoCollect.queryFailed'));
renderFofaResults({ query, fields: [], results: [], total: 0, page: 1, size: 0 });
renderFofaResults({ provider, query, fields: [], results: [], total: 0, page: 1, size: 0 });
alert(_t('infoCollect.queryFailed') + ': ' + (e && e.message ? e.message : String(e)));
} finally {
setFofaLoading(false);
@@ -232,6 +783,7 @@ async function submitFofaSearch() {
async function parseFofaNaturalLanguage() {
const els = getFofaFormElements();
const provider = getInfoCollectProvider();
const text = (els.nl?.value || '').trim();
if (!text) {
alert(_t('infoCollect.enterNaturalLanguage'));
@@ -264,7 +816,7 @@ async function parseFofaNaturalLanguage() {
const resp = await apiFetch('/api/fofa/parse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
body: JSON.stringify({ provider, text }),
signal: fofaParseAbortController.signal
});
const result = await resp.json().catch(() => ({}));
@@ -309,9 +861,11 @@ function setFofaParseLoading(loading, statusText) {
btn.setAttribute('aria-busy', 'true');
btn.disabled = false;
} else {
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
btn.classList.remove('btn-loading');
btn.textContent = btn.dataset.originalText || _t('infoCollectPage.parseBtn');
btn.title = _t('infoCollect.parseToFofa');
btn.title = '将自然语言解析为 ' + cfg.label + ' 查询语法';
btn.disabled = false;
delete btn.dataset.loading;
btn.removeAttribute('aria-busy');
@@ -333,6 +887,8 @@ function showFofaParseModal(nlText, parsed) {
const existing = document.getElementById('fofa-parse-modal');
if (existing) existing.remove();
const provider = getInfoCollectProvider();
const cfg = INFO_COLLECT_PROVIDERS[provider] || INFO_COLLECT_PROVIDERS.fofa;
const safeNL = escapeHtml((nlText || '').trim());
const warnings = Array.isArray(parsed?.warnings) ? parsed.warnings.filter(Boolean).map(x => String(x)) : [];
const explanation = parsed?.explanation != null ? String(parsed.explanation) : '';
@@ -360,8 +916,8 @@ function showFofaParseModal(nlText, parsed) {
</div>
<div class="form-group info-collect-parse-form-group">
<label for="fofa-parse-query">${_t('infoCollect.fofaQueryEditable')}</label>
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${_t('infoCollect.queryPlaceholder')}"></textarea>
<label for="fofa-parse-query">${escapeHtml(cfg.label)} 查询语法(可编辑)</label>
<textarea id="fofa-parse-query" class="info-collect-query-input" rows="2" placeholder="${escapeHtml(cfg.placeholder)}"></textarea>
<small class="form-hint">${_t('infoCollect.confirmBeforeQuery')}</small>
</div>
@@ -420,7 +976,7 @@ function showFofaParseModal(nlText, parsed) {
size: parseInt(els.size?.value, 10) || 100,
page: parseInt(els.page?.value, 10) || 1,
fields: (els.fields?.value || '').trim(),
full: !!els.full?.checked
full: isInfoCollectFullEnabled(getInfoCollectProvider())
});
close();
if (run) submitFofaSearch();
@@ -447,6 +1003,22 @@ function setFofaMeta(text) {
}
}
function buildInfoCollectResultsMeta(provider, total, count, page, size, expectedCount, shortfall) {
let text = providerLabel(provider) + ' · ' + _t('infoCollect.resultsMeta', { total, count, page, size });
if (provider === 'shodan') {
let expected = Number(expectedCount || 0);
if (!Number.isFinite(expected) || expected <= 0) {
const startOffset = Math.max(0, (Number(page) || 1) - 1) * 100;
expected = Math.min(Number(size) || 0, Math.max(0, (Number(total) || 0) - startOffset));
}
const missing = Number(shortfall || 0);
if (expected > 0 && (missing > 0 || count < expected)) {
text += ' · ' + _t('infoCollect.providerReturnedFewer', { expected, count });
}
}
return text;
}
function updateSelectedMeta() {
const els = getFofaFormElements();
if (els.selectedMeta) {
@@ -473,6 +1045,7 @@ function renderFofaResults(payload) {
// 保存当前 payload 到 state
infoCollectState.currentPayload = {
provider: payload.provider || getInfoCollectProvider(),
query: payload.query || '',
total: typeof payload.total === 'number' ? payload.total : 0,
page: typeof payload.page === 'number' ? payload.page : 1,
@@ -496,7 +1069,15 @@ function renderFofaResults(payload) {
const size = typeof payload.size === 'number' ? payload.size : 0;
const page = typeof payload.page === 'number' ? payload.page : 1;
setFofaMeta(_t('infoCollect.resultsMeta', { total, count: results.length, page, size }));
setFofaMeta(buildInfoCollectResultsMeta(
infoCollectState.currentPayload.provider,
total,
results.length,
page,
size,
typeof payload.expected_count === 'number' ? payload.expected_count : 0,
typeof payload.shortfall === 'number' ? payload.shortfall : 0
));
// 可见字段
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
@@ -768,7 +1349,8 @@ function buildScanMessage(target, row, options) {
const fields = Array.isArray(opts.fields) ? opts.fields : [];
const summary = formatFofaRowSummary(row || {}, fields);
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n已知信息(来自 FOFA 该行全部字段):\n${summary}`.trim();
const provider = providerLabel(infoCollectState.currentPayload?.provider || getInfoCollectProvider());
return `对以下目标做信息收集与基础扫描:\n${target}\n\n要求:\n1) 识别服务/框架与关键指纹\n2) 枚举开放端口与常见管理入口\n3) 用 httpx/指纹/目录探测等方式快速确认可访问面\n4) 输出可复现的命令与结论\n\n已知信息(来自 ${provider} 该行全部字段):\n${summary}`.trim();
}
function bindFofaTableEvents() {
@@ -926,12 +1508,14 @@ function exportFofaResults(format) {
const fields = p.fields || [];
const visibleFields = fields.filter(f => !infoCollectState.hiddenFields.has(f));
const provider = p.provider || 'fofa';
const now = new Date();
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
if (format === 'json') {
const payload = {
provider,
query: p.query || '',
total: p.total || 0,
page: p.page || 1,
@@ -939,7 +1523,7 @@ function exportFofaResults(format) {
fields: fields,
results: p.results
};
downloadBlob(JSON.stringify(payload, null, 2), `fofa_results_${ts}.json`, 'application/json;charset=utf-8');
downloadBlob(JSON.stringify(payload, null, 2), `${provider}_results_${ts}.json`, 'application/json;charset=utf-8');
return;
}
@@ -956,7 +1540,7 @@ function exportFofaResults(format) {
const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, _t('infoCollect.batchScanTitle'));
XLSX.writeFile(wb, `fofa_results_${ts}.xlsx`);
XLSX.writeFile(wb, `${provider}_results_${ts}.xlsx`);
return;
}
@@ -968,7 +1552,7 @@ function exportFofaResults(format) {
});
const csv = [header.map(csvEscape).join(','), ...rows.map(cols => cols.join(','))].join('\n');
const csvWithBom = '\uFEFF' + csv;
downloadBlob(csvWithBom, `fofa_results_${ts}.csv`, 'text/csv;charset=utf-8');
downloadBlob(csvWithBom, `${provider}_results_${ts}.csv`, 'text/csv;charset=utf-8');
}
function csvEscape(value) {
@@ -1074,13 +1658,19 @@ function showCellDetailModal(field, fullText) {
const existing = document.getElementById('info-collect-cell-modal');
if (existing) existing.remove();
const text = fullText == null ? '' : String(fullText);
const fieldName = field || _t('infoCollect.field');
const charCountLabel = _t('infoCollect.cellValueLength', { count: Array.from(text).length });
const modal = document.createElement('div');
modal.id = 'info-collect-cell-modal';
modal.className = 'info-collect-cell-modal';
modal.innerHTML = `
<div class="info-collect-cell-modal-content" role="dialog" aria-modal="true">
<div class="info-collect-cell-modal-header">
<div class="info-collect-cell-modal-title">${escapeHtml(field || _t('infoCollect.field'))}</div>
<div class="info-collect-cell-modal-heading">
<div class="info-collect-cell-modal-title">${escapeHtml(fieldName)}</div>
<div class="info-collect-cell-modal-subtitle">${escapeHtml(charCountLabel)}</div>
</div>
<button class="btn-icon" type="button" id="info-collect-cell-modal-close" title="${_t('common.close')}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@@ -1088,7 +1678,7 @@ function showCellDetailModal(field, fullText) {
</button>
</div>
<div class="info-collect-cell-modal-body">
<pre class="info-collect-cell-modal-pre">${escapeHtml(fullText || '')}</pre>
<pre class="info-collect-cell-modal-pre">${escapeHtml(text)}</pre>
</div>
<div class="info-collect-cell-modal-footer">
<button class="btn-secondary" type="button" id="info-collect-cell-modal-copy">${_t('common.copy')}</button>
@@ -1100,7 +1690,13 @@ function showCellDetailModal(field, fullText) {
document.body.appendChild(modal);
openAppModal(modal);
const onKey = (e) => {
if (e.key === 'Escape') {
close();
}
};
const close = function () {
document.removeEventListener('keydown', onKey);
closeAppModal(modal);
modal.remove();
syncAppModalBodyLock();
@@ -1111,16 +1707,10 @@ function showCellDetailModal(field, fullText) {
document.getElementById('info-collect-cell-modal-close')?.addEventListener('click', close);
document.getElementById('info-collect-cell-modal-ok')?.addEventListener('click', close);
document.getElementById('info-collect-cell-modal-copy')?.addEventListener('click', () => {
navigator.clipboard.writeText(fullText || '').then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed')));
navigator.clipboard.writeText(text).then(() => showInlineToast(_t('common.copied'))).catch(() => alert(_t('common.copyFailed')));
});
// Esc 关闭
const onKey = (e) => {
if (e.key === 'Escape') {
close();
document.removeEventListener('keydown', onKey);
}
};
document.addEventListener('keydown', onKey);
}
@@ -1129,6 +1719,7 @@ window.initInfoCollectPage = initInfoCollectPage;
window.resetFofaForm = resetFofaForm;
window.submitFofaSearch = submitFofaSearch;
window.parseFofaNaturalLanguage = parseFofaNaturalLanguage;
window.setInfoCollectQueryMode = setInfoCollectQueryMode;
window.scanFofaRow = scanFofaRow;
window.copyFofaTarget = copyFofaTarget;
window.copyFofaTargetEncoded = copyFofaTargetEncoded;
+65 -10
View File
@@ -1915,17 +1915,72 @@ function mergeMcpExecutionIDLists(prev, next) {
function formatEinoRunRetryMessage(message, data) {
const d = data && typeof data === 'object' ? data : {};
const base = String(message || '').trim();
const errRaw = d.error != null ? String(d.error).trim() : '';
const errRaw = d.errorSummary != null && String(d.errorSummary).trim() !== ''
? String(d.errorSummary).trim()
: (d.error != null ? String(d.error).trim() : '');
const lines = [];
if (base) lines.push(base);
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
const backoffSec = Number(d.backoffSec || 0);
const kind = formatEinoRunRetryKind(d.errorKind);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
const retryPlan = typeof window.t === 'function'
? window.t('chat.einoRunRetryPlan', { attempt: attempt, maxAttempts: maxAttempts, backoffSec: Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-' })
: ('重试进度:第 ' + attempt + '/' + maxAttempts + ' 次,等待 ' + (Number.isFinite(backoffSec) && backoffSec > 0 ? backoffSec : '-') + ' 秒');
if (!base || base.indexOf(String(attempt) + '/' + String(maxAttempts)) === -1) {
lines.push(retryPlan);
}
}
if (kind) {
const kindLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryReasonKind')
: '原因类型';
lines.push(kindLabel + '' + kind);
}
if (!errRaw) {
return base;
return lines.join('\n');
}
const detailLabel = typeof window.t === 'function'
? window.t('chat.einoRunRetryErrorDetail')
: '错误详情';
if (base && base.indexOf(errRaw) !== -1) {
return base;
if (!base || base.indexOf(errRaw) === -1) {
lines.push(detailLabel + '' + errRaw);
}
return base ? (base + '\n' + detailLabel + '' + errRaw) : (detailLabel + '' + errRaw);
return lines.join('\n');
}
function formatEinoRunRetryKind(kind) {
const key = String(kind || '').trim();
if (!key) return '';
const labels = {
rate_limit: '限流 / 请求过多',
retryable_http: '可重试 HTTP 错误',
upstream_server: '上游服务错误',
http_error: 'HTTP 错误',
upstream_busy: '上游繁忙',
network: '网络连接异常',
stream: '流式读取异常',
transient: '临时异常'
};
if (typeof window.t === 'function') {
const translated = window.t('chat.einoRunRetryKind_' + key);
if (translated && translated !== 'chat.einoRunRetryKind_' + key) return translated;
}
return labels[key] || key;
}
function formatEinoRunRetryTitle(data) {
const d = data && typeof data === 'object' ? data : {};
const base = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const attempt = Number(d.attempt || 0);
const maxAttempts = Number(d.maxAttempts || 0);
if (Number.isFinite(attempt) && attempt > 0 && Number.isFinite(maxAttempts) && maxAttempts > 0) {
return base + '' + attempt + '/' + maxAttempts + '';
}
return base;
}
// 处理流式事件
@@ -2425,12 +2480,9 @@ function handleStreamEvent(event, progressElement, progressId,
case 'eino_run_retry': {
const d = event.data || {};
const title = typeof window.t === 'function'
? window.t('chat.einoRunRetryTitle')
: '🔁 临时错误重试';
const msg = formatEinoRunRetryMessage(event.message, d);
addTimelineItem(timeline, 'warning', {
title: title,
title: formatEinoRunRetryTitle(d),
message: msg,
data: d
});
@@ -4226,6 +4278,9 @@ function addTimelineItem(timeline, type, options) {
const itemId = 'timeline-item-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9);
item.id = itemId;
item.className = `timeline-item timeline-item-${type}`;
if (type === 'eino_run_retry') {
item.classList.add('timeline-item-warning');
}
// 记录类型与参数,便于 languagechange 时刷新标题文案
item.dataset.timelineType = type;
if (type === 'iteration') {
@@ -4418,7 +4473,7 @@ function addTimelineItem(timeline, type, options) {
${escapeHtml(options.message || taskCancelledLabel)}
</div>
`;
} else if (type === 'warning' && options.message) {
} else if ((type === 'warning' || type === 'eino_run_retry') && options.message) {
const streamBody = typeof formatTimelineStreamBody === 'function'
? formatTimelineStreamBody(options.message, options.data)
: options.message;
+19
View File
@@ -711,6 +711,13 @@ async function loadConfig(loadTools = true, options = {}) {
const fofaBaseUrlEl = document.getElementById('fofa-base-url');
if (fofaKeyEl) fofaKeyEl.value = fofa.api_key || '';
if (fofaBaseUrlEl) fofaBaseUrlEl.value = fofa.base_url || '';
['zoomeye', 'quake', 'shodan'].forEach((name) => {
const cfg = currentConfig[name] || {};
const keyEl = document.getElementById(`${name}-api-key`);
const baseUrlEl = document.getElementById(`${name}-base-url`);
if (keyEl) keyEl.value = cfg.api_key || '';
if (baseUrlEl) baseUrlEl.value = cfg.base_url || '';
});
// 填充人机协同配置
const hitl = currentConfig.hitl || {};
@@ -1906,6 +1913,18 @@ async function applySettings() {
api_key: document.getElementById('fofa-api-key')?.value.trim() || '',
base_url: document.getElementById('fofa-base-url')?.value.trim() || ''
},
zoomeye: {
api_key: document.getElementById('zoomeye-api-key')?.value.trim() || '',
base_url: document.getElementById('zoomeye-base-url')?.value.trim() || ''
},
quake: {
api_key: document.getElementById('quake-api-key')?.value.trim() || '',
base_url: document.getElementById('quake-base-url')?.value.trim() || ''
},
shodan: {
api_key: document.getElementById('shodan-api-key')?.value.trim() || '',
base_url: document.getElementById('shodan-base-url')?.value.trim() || ''
},
hitl: {
...prevHitl,
audit_model: {
+116 -40
View File
@@ -2026,51 +2026,77 @@
<div class="page-content">
<div class="info-collect-panel">
<div class="info-collect-form">
<div class="form-group">
<label for="fofa-query" data-i18n="infoCollectPage.fofaQuerySyntax">FOFA 查询语法</label>
<textarea id="fofa-query" class="info-collect-query-input" rows="1" data-i18n="infoCollect.queryPlaceholder" data-i18n-attr="placeholder" placeholder='例如:app="Apache" && country="CN"'></textarea>
<small class="form-hint" data-i18n="infoCollectPage.formHint">查询语法参考 FOFA 文档,支持 && / || / () 等。</small>
<div class="info-collect-presets" aria-label="FOFA 查询示例" data-i18n="infoCollectPage.queryPresetsAria" data-i18n-attr="aria-label" data-i18n-skip-text="true">
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('app=&quot;Apache&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetApache" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">Apache + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('title=&quot;登录&quot; &amp;&amp; country=&quot;CN&quot;')" data-i18n="infoCollectPage.presetLogin" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">登录页 + 中国</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('domain=&quot;example.com&quot;')" data-i18n="infoCollectPage.presetDomain" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">指定域名</button>
<button class="preset-chip" type="button" onclick="applyFofaQueryPreset('ip=&quot;1.1.1.1&quot;')" data-i18n="infoCollectPage.presetIp" data-i18n-attr="title" data-i18n-title="infoCollectPage.fillExample" title="填入示例">指定 IP</button>
<div class="info-collect-form-top">
<div class="info-collect-provider-card form-group">
<div class="info-collect-step-title">
<span>01</span>
<label for="fofa-provider">数据源</label>
</div>
<select id="fofa-provider" onchange="handleInfoCollectProviderChange()">
<option value="fofa">FOFA</option>
<option value="zoomeye">ZoomEye</option>
<option value="quake">Quake</option>
<option value="shodan">Shodan</option>
</select>
<small class="form-hint">支持 FOFA、ZoomEye、Quake、ShodanAPI Key 可在系统设置或环境变量中配置。</small>
</div>
<div class="info-collect-query-card form-group">
<div class="info-collect-step-title">
<span>02</span>
<div>查询方式</div>
</div>
<div id="info-collect-natural-panel" class="info-collect-query-mode-panel info-collect-natural-panel">
<label for="fofa-nl" id="info-collect-nl-label" class="info-collect-inner-label">自然语言(可选,AI 解析为查询语法)</label>
<div class="info-collect-nl-row">
<textarea id="fofa-nl" class="info-collect-query-input" rows="1" placeholder="例如:找中国的 Apache 站点"></textarea>
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" data-i18n="infoCollectPage.parseBtn" data-i18n-attr="title" data-i18n-title="infoCollectPage.parseBtnTitle" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
</div>
<div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div>
<small class="form-hint" id="info-collect-parse-hint">解析后会弹窗展示对应数据源语法(可编辑),确认无误后再填入查询框并执行查询。</small>
</div>
<div id="info-collect-syntax-panel" class="info-collect-query-mode-panel is-active">
<label for="fofa-query" id="info-collect-query-label" class="info-collect-inner-label">查询语法(可编辑,可直接查询)</label>
<div class="info-collect-query-row">
<textarea id="fofa-query" class="info-collect-query-input" rows="1" placeholder='例如:app="Apache" && country="CN"'></textarea>
<button id="fofa-query-submit-btn" class="btn-primary" data-require-permission="fofa:execute" type="button" onclick="submitFofaSearch()">查询</button>
</div>
<small class="form-hint" id="info-collect-query-hint">选择数据源后会显示对应查询语法提示。</small>
<div class="info-collect-presets" id="info-collect-query-presets" aria-label="查询示例"></div>
<div class="info-collect-syntax-guide" id="info-collect-syntax-guide" hidden></div>
</div>
</div>
</div>
<div class="form-group">
<label for="fofa-nl" data-i18n="infoCollectPage.naturalLanguage">自然语言(AI 解析为 FOFA 语法)</label>
<div class="info-collect-nl-row">
<textarea id="fofa-nl" class="info-collect-query-input" rows="1" data-i18n="infoCollectPage.nlPlaceholder" data-i18n-attr="placeholder" placeholder="例如:找美国 Missouri 的 Apache 站点,标题包含 Home"></textarea>
<button id="fofa-nl-parse-btn" class="btn-secondary" type="button" onclick="parseFofaNaturalLanguage()" data-i18n="infoCollectPage.parseBtn" data-i18n-attr="title" data-i18n-title="infoCollectPage.parseBtnTitle" title="将自然语言解析为 FOFA 查询语法">AI 解析</button>
<div class="info-collect-options-card">
<div class="info-collect-step-title">
<span>03</span>
<div>返回配置</div>
</div>
<div id="fofa-nl-status" class="fofa-nl-status muted" style="display: none;" aria-live="polite"></div>
<small class="form-hint" data-i18n="infoCollectPage.parseHint">解析后会弹窗展示 FOFA 语法(可编辑),确认无误后再填入查询框并执行查询。</small>
</div>
<div class="info-collect-form-row">
<div class="form-group">
<label for="fofa-size" data-i18n="infoCollectPage.returnCount">返回数量</label>
<input type="number" id="fofa-size" min="1" max="10000" value="100" />
</div>
<div class="form-group">
<label for="fofa-page" data-i18n="infoCollectPage.pageNum">页码</label>
<input type="number" id="fofa-page" min="1" value="1" />
</div>
<div class="form-group">
<label class="checkbox-label" style="margin-top: 24px;">
<input type="checkbox" id="fofa-full" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="infoCollectPage.fullLabel">full</span>
</label>
<div class="info-collect-form-row">
<div class="form-group">
<label for="fofa-size" data-i18n="infoCollectPage.returnCount">返回数量</label>
<input type="number" id="fofa-size" min="1" max="10000" value="100" />
</div>
<div class="form-group">
<label for="fofa-page" data-i18n="infoCollectPage.pageNum">页码</label>
<input type="number" id="fofa-page" min="1" value="1" />
</div>
<div class="form-group" id="info-collect-full-option">
<label class="checkbox-label">
<input type="checkbox" id="fofa-full" class="modern-checkbox" />
<span class="checkbox-custom"></span>
<span class="checkbox-text" data-i18n="infoCollectPage.fullLabel">full</span>
</label>
</div>
<small class="form-hint info-collect-row-hint" id="info-collect-size-hint">不同数据源的单次返回上限可能不同。</small>
</div>
</div>
<div class="form-group">
<label for="fofa-fields" data-i18n="infoCollectPage.returnFields">返回字段名(逗号分隔)</label>
<div class="info-collect-fields-card form-group">
<div class="info-collect-step-title">
<span>04</span>
<label for="fofa-fields" data-i18n="infoCollectPage.returnFields">返回字段名(逗号分隔)</label>
</div>
<input type="text" id="fofa-fields" value="host,ip,port,domain,title,protocol,country,province,city,server" />
<div class="info-collect-presets" aria-label="FOFA 字段模板" data-i18n="infoCollectPage.fieldsPresetsAria" data-i18n-attr="aria-label" data-i18n-skip-text="true">
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain')" data-i18n="infoCollectPage.minFields" data-i18n-attr="title" data-i18n-title="infoCollectPage.minFieldsTitle" title="适合快速导出目标">最小字段</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,title,ip,port,domain,protocol,server,icp,country,province,city')" data-i18n="infoCollectPage.webCommon" data-i18n-attr="title" data-i18n-title="infoCollectPage.webCommonTitle" title="适合浏览和筛选">Web 常用</button>
<button class="preset-chip" type="button" onclick="applyFofaFieldsPreset('host,ip,port,domain,title,protocol,country,province,city,server,as_number,as_organization,icp,header,banner')" data-i18n="infoCollectPage.intelEnhanced" data-i18n-attr="title" data-i18n-title="infoCollectPage.intelEnhancedTitle" title="更偏指纹/情报">情报增强</button>
</div>
<div class="info-collect-presets" id="info-collect-fields-presets" aria-label="字段模板"></div>
</div>
</div>
</div>
@@ -3559,7 +3585,6 @@
</details>
</div>
</div>
<div class="settings-actions">
<button class="btn-primary" onclick="applySettings()" data-require-permission="config:write" data-i18n="settings.apply.button">应用配置</button>
</div>
@@ -3676,6 +3701,57 @@
</div>
</div>
<div class="settings-subsection">
<h4>ZoomEye 配置</h4>
<p class="form-hint">用于信息收集页调用 ZoomEye API;也可设置环境变量 ZOOMEYE_API_KEY。</p>
<div class="settings-form">
<div class="form-group">
<label for="zoomeye-base-url">Base URL</label>
<input type="text" id="zoomeye-base-url" placeholder="https://api.zoomeye.org/v2/search(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
<label for="zoomeye-api-key">API Key</label>
<input type="password" id="zoomeye-api-key" placeholder="输入 ZoomEye API Key" autocomplete="off" />
<small class="form-hint">仅保存在服务器配置中(config.yaml)。</small>
</div>
</div>
</div>
<div class="settings-subsection">
<h4>Quake 配置</h4>
<p class="form-hint">用于信息收集页调用 360 Quake API;也可设置环境变量 QUAKE_API_KEY。</p>
<div class="settings-form">
<div class="form-group">
<label for="quake-base-url">Base URL</label>
<input type="text" id="quake-base-url" placeholder="https://quake.360.cn/api/v3/search/quake_service(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
<label for="quake-api-key">API Token</label>
<input type="password" id="quake-api-key" placeholder="输入 Quake API Token" autocomplete="off" />
<small class="form-hint">仅保存在服务器配置中(config.yaml)。</small>
</div>
</div>
</div>
<div class="settings-subsection">
<h4>Shodan 配置</h4>
<p class="form-hint">用于信息收集页调用 Shodan API;也可设置环境变量 SHODAN_API_KEY。</p>
<div class="settings-form">
<div class="form-group">
<label for="shodan-base-url">Base URL</label>
<input type="text" id="shodan-base-url" placeholder="https://api.shodan.io(可选)" />
<small class="form-hint">留空则使用默认地址。</small>
</div>
<div class="form-group">
<label for="shodan-api-key">API Key</label>
<input type="password" id="shodan-api-key" placeholder="输入 Shodan API Key" autocomplete="off" />
<small class="form-hint">仅保存在服务器配置中(config.yaml)。</small>
</div>
</div>
</div>
<div class="settings-actions">
<button type="button" class="btn-primary" onclick="applySettings()" data-require-permission="config:write" data-i18n="settings.apply.button">应用配置</button>
</div>