From c689122774388fdf05e997f952b157411d9580cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:49:19 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 539 ++++++++++++++++++++++++++-- web/static/i18n/en-US.json | 12 + web/static/i18n/zh-CN.json | 12 + web/static/js/assets.js | 25 +- web/static/js/chat.js | 92 ++++- web/static/js/info-collect.js | 653 ++++++++++++++++++++++++++++++++-- web/static/js/monitor.js | 75 +++- web/static/js/settings.js | 19 + web/templates/index.html | 156 +++++--- 9 files changed, 1443 insertions(+), 140 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 659e0e36..df9af976 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -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; } diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 5c7022e3..bb11a110 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -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" diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 06126cbc..f955e247 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -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": "填入并查询" diff --git a/web/static/js/assets.js b/web/static/js/assets.js index 94f9cf40..c41c0d1a 100644 --- a/web/static/js/assets.js +++ b/web/static/js/assets.js @@ -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; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 487ee3d4..2af5c3d1 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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') { diff --git a/web/static/js/info-collect.js b/web/static/js/info-collect.js index 38eb310e..755b9ee0 100644 --- a/web/static/js/info-collect.js +++ b/web/static/js/info-collect.js @@ -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 + ? `官方文档` + : ''; + const expanded = !!infoCollectState.syntaxGuideExpanded; + const sections = (guide.sections || []).map(([title, examples]) => { + const chips = (examples || []).map(example => { + return ``; + }).join(''); + return `
${escapeHtml(title)}
${chips}
`; + }).join(''); + container.hidden = false; + container.classList.toggle('is-expanded', expanded); + container.innerHTML = ` +
+
${escapeHtml(guide.summary || '')}
+
+ ${docsLink} + +
+
+
${sections}
+ `; +} + +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 ``; + }).join(''); + } + if (fieldPresets) { + fieldPresets.innerHTML = cfg.fieldPresets.map(([label, fields]) => { + return ``; + }).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) {
- - + + ${_t('infoCollect.confirmBeforeQuery')}
@@ -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 = `