Compare commits

...
Author SHA1 Message Date
RuoJi6 0ed68d9143 Merge pull request #100 from RuoJi6/codex/fix-export-utf8-filenames
修复导出中文文件名乱码
2026-10-30 00:08:44 -07:00
ji ruo 542d7e9ca6 fix: preserve UTF-8 filenames for exports 2026-10-30 15:06:37 +08:00
RuoJi6 35a33b20f9 Merge pull request #98 from RuoJi6/codex/fix-image-drag-ghost
修复截图拖动时的原生拖拽重影
2026-10-29 22:13:34 -07:00
ji ruo 74d8b1efa2 fix: 禁用截图原生拖拽重影 2026-10-30 13:12:35 +08:00
RuoJi6 92e58d3de7 Merge pull request #97 from RuoJi6/codex/image-viewer-pan-zoom
修复截图放大拖动并显示缩放比例
2026-10-29 22:08:42 -07:00
ji ruo 21057b1a6b fix: 支持截图放大拖动并显示缩放比例 2026-10-30 13:07:49 +08:00
RuoJi6 136782f48e Merge pull request #96 from RuoJi6/codex/report-dialog-history-scroll
修复报告历史对话绑定与滚动位置
2026-10-29 22:04:54 -07:00
ji ruo 3af131272c fix: 修复报告对话绑定与滚动位置 2026-10-30 13:01:24 +08:00
RuoJi6 07c0c8ee35 Merge pull request #95 from RuoJi6/codex/backfill-report-conversation-binding
修复历史报告撰写对话回溯绑定
2026-10-29 21:36:50 -07:00
ji ruo 3f2c44be91 fix: 回溯绑定历史报告撰写对话 2026-10-30 12:34:04 +08:00
RuoJi6 57424249ce Merge pull request #94 from RuoJi6/codex/fix-report-conversation-binding
修复工具触发报告对话绑定
2026-10-29 21:29:23 -07:00
ji ruo f48f4ec8e2 fix: 绑定工具触发的报告撰写对话 2026-10-30 12:27:50 +08:00
ji ruo aac79d72e9 Merge pull request #93 after conflict resolution 2026-10-30 12:21:02 +08:00
RuoJi6 a55189a649 Merge pull request #91 from RuoJi6/codex/task-list-column-settings
feat: add task list column visibility settings
2026-10-29 21:20:28 -07:00
ji ruo 9745e7d019 feat: 复用漏洞报告撰写对话 2026-10-30 12:19:43 +08:00
ji ruo af2f9e3560 fix: 归档报告截图并支持弹窗调整 2026-10-30 12:07:10 +08:00
ji ruo 8828d01eae fix: 将报告调整对话改为弹窗 2026-10-30 12:04:39 +08:00
ji ruo 53840faf7f fix: 归档报告截图并支持对话调整 2026-10-30 12:03:21 +08:00
ji ruo 3575b9091e feat: add task list column visibility settings 2026-10-30 11:25:36 +08:00
RuoJi6 4c5b4e9e79 Merge pull request #90 from RuoJi6/codex/fix-hidden-scrollbars
修复筛选与请求重发标签栏滚动条显示
2026-10-29 18:21:58 -07:00
RuoJi6 bffa2b55a8 Merge pull request #89 from RuoJi6/codex/fix-repeater-request-undo
修复 Repeater 请求编辑器撤销,并显示审批调用 Agent
2026-10-29 18:21:50 -07:00
ji ruo 21f68a6a60 feat: event-driven credential processing conversations 2026-10-30 09:18:31 +08:00
ji ruo 524f8bc632 fix: 隐藏筛选与请求标签滚动条 2026-10-30 09:11:05 +08:00
ji ruo 255e11aca6 feat(web): show agent source in approval records 2026-10-30 09:01:57 +08:00
ji ruo 6ad055d24f fix(web): enable repeater request undo 2026-10-30 02:12:36 +08:00
RuoJi6 571b6116e0 fix: place resource status on dashboard header
* fix: place resource status on dashboard header

* fix: show resource metrics as progress bars

* fix: preview unsupported resource layout

* fix: disable non-Linux resource preview
2026-10-29 10:59:32 -07:00
RuoJi6 df9d38af0d fix: support non-Linux resource status builds 2026-10-29 10:41:04 -07:00
RuoJi6 08cf862b0c feat: unify HTTP packet editor
* feat(web): unify HTTP packet editor with CodeMirror

* feat: add container resource monitoring
2026-10-29 10:29:53 -07:00
32 changed files with 1980 additions and 534 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ import (
// Missing host/setting means off. Rechecked on every write for running sessions. // Missing host/setting means off. Rechecked on every write for running sessions.
var IntelligenceEnabled func() bool var IntelligenceEnabled func() bool
var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error) var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error)
var CredentialRecorded func()
func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() } func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() }
func intelligenceSchema() map[string]any { func intelligenceSchema() map[string]any {
+17 -16
View File
@@ -39,22 +39,23 @@ type CredentialTraffic struct {
Accounts []int `json:"accounts"` Accounts []int `json:"accounts"`
} }
type CredentialAnalysis struct { type CredentialAnalysis struct {
ForceExtract bool `json:"force_extract,omitempty"` ConversationID int64 `json:"conversation_id,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"` ForceExtract bool `json:"force_extract,omitempty"`
State string `json:"state"` TrafficNote string `json:"traffic_note,omitempty"`
Lease string `json:"lease,omitempty"` State string `json:"state"`
LeaseUntil time.Time `json:"lease_until,omitempty"` Lease string `json:"lease,omitempty"`
SourceHash string `json:"source_hash,omitempty"` LeaseUntil time.Time `json:"lease_until,omitempty"`
Method string `json:"method,omitempty"` SourceHash string `json:"source_hash,omitempty"`
Accounts []CredentialAccount `json:"accounts"` Method string `json:"method,omitempty"`
Traffic []CredentialTraffic `json:"traffic"` Accounts []CredentialAccount `json:"accounts"`
TrafficState string `json:"traffic_state"` Traffic []CredentialTraffic `json:"traffic"`
Note string `json:"note,omitempty"` TrafficState string `json:"traffic_state"`
Error string `json:"error,omitempty"` Note string `json:"note,omitempty"`
Model string `json:"model,omitempty"` Error string `json:"error,omitempty"`
InputTokens int `json:"input_tokens"` Model string `json:"model,omitempty"`
OutputTokens int `json:"output_tokens"` InputTokens int `json:"input_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"` OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
} }
func CredentialSourceHash(content string) string { func CredentialSourceHash(content string) string {
+62 -18
View File
@@ -23,20 +23,21 @@ type DBFinding struct {
ReportEvidenceVersion int64 ReportEvidenceVersion int64
TrafficBindings []FindingTrafficBinding // populated only for export TrafficBindings []FindingTrafficBinding // populated only for export
ID int64 ID int64
TaskID *int64 TaskID *int64
NodeID *int64 NodeID *int64
VulnClass string VulnClass string
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
Severity string Severity string
Summary string Summary string
Evidence string Evidence string
Worker string Worker string
AssetIDs []int64 AssetIDs []int64
Status string Status string
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带 Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
CreatedAt time.Time ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
TaskDescription string // populated via LEFT JOIN on tasks CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
} }
// Finding triage states (findings.status). // Finding triage states (findings.status).
@@ -106,9 +107,9 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary
// findingSelectCols is the column list (with task_description join) every finding // findingSelectCols is the column list (with task_description join) every finding
// list query selects, so scanFinding stays in sync across callers. // list query selects, so scanFinding stays in sync across callers.
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary, const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary,
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at, f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
COALESCE(t.description, '') AS task_description, f.evidence_version, f.report_evidence_version, COALESCE(t.description, '') AS task_description, f.evidence_version, f.report_evidence_version,
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)` (SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
// scanFindings materializes rows selected via findingSelectCols. // scanFindings materializes rows selected via findingSelectCols.
func scanFindings(rows interface { func scanFindings(rows interface {
@@ -661,13 +662,14 @@ func (d *DB) FindingStats() (*FindingStats, error) {
func (d *DB) GetFinding(id int64) (*DBFinding, error) { func (d *DB) GetFinding(id int64) (*DBFinding, error) {
f := &DBFinding{} f := &DBFinding{}
var aidsJSON string var aidsJSON string
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '') var reportConv sql.NullInt64
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, ''), f.report_conversation_id
FROM findings f FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.id = $1`, id).Scan( WHERE f.id = $1`, id).Scan(
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity, &f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report) &f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report, &reportConv)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -675,9 +677,51 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
return nil, err return nil, err
} }
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs) _ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
if reportConv.Valid {
f.ReportConversationID = &reportConv.Int64
}
if f.ReportConversationID == nil && strings.TrimSpace(f.Report) != "" {
if resolved, resolveErr := d.ResolveFindingReportConversation(f.ID); resolveErr == nil {
f.ReportConversationID = resolved
}
}
return f, nil return f, nil
} }
func (d *DB) SetFindingReportConversationID(findingID, conversationID int64) error {
_, err := d.Exec(`UPDATE findings SET report_conversation_id=$2 WHERE id=$1`, findingID, conversationID)
return err
}
// ResolveFindingReportConversation backfills links for reports generated before
// report_conversation_id was persisted.
func (d *DB) ResolveFindingReportConversation(findingID int64) (*int64, error) {
var id int64
err := d.QueryRow(`SELECT c.id FROM conversations c
JOIN conversation_activities a ON a.conversation_id=c.id
WHERE c.agent_key='reporter' AND (
c.title = '漏洞报告 · finding#' || $1::text OR
c.title ILIKE '%finding#' || $1::text || '%' OR
a.detail ILIKE '%finding_id=' || $1::text || '%' OR
a.detail ILIKE '%"finding_id":' || $1::text || '%'
)
ORDER BY CASE WHEN EXISTS (
SELECT 1 FROM conversation_activities r
WHERE r.conversation_id=c.id AND (r.tool='update_finding_report' OR r.summary ILIKE 'finding % report updated%')
) THEN 0 ELSE 1 END,
c.created_at ASC, c.id ASC LIMIT 1`, findingID).Scan(&id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if err := d.SetFindingReportConversationID(findingID, id); err != nil {
return nil, err
}
return &id, nil
}
// DeleteFinding removes a finding entirely: the standalone findings row and its // DeleteFinding removes a finding entirely: the standalone findings row and its
// originating exploration node (kind='finding'), so it disappears from the findings // originating exploration node (kind='finding'), so it disappears from the findings
// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node // list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node
+2
View File
@@ -1162,11 +1162,13 @@ CREATE TABLE IF NOT EXISTS findings (
status TEXT NOT NULL DEFAULT 'pending', status TEXT NOT NULL DEFAULT 'pending',
-- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。 -- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。
report TEXT NOT NULL DEFAULT '', report TEXT NOT NULL DEFAULT '',
report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT ''; ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT '';
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC);
+7
View File
@@ -233,6 +233,13 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
if err != nil { if err != nil {
return 0, err return 0, err
} }
if item.Kind == "credential" {
// The durable database row is the source of truth; this wake-up only
// reduces latency. The recovery ticker handles missed notifications.
if agent.CredentialRecorded != nil {
agent.CredentialRecorded()
}
}
return item.ID, nil return item.ID, nil
} }
// Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits). // Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits).
+9 -1
View File
@@ -701,6 +701,14 @@ func (s *Server) readTriggerBehavior(agentKey string) triggerBehavior {
// merging by task / all / none); parallel → run each fire in its own concurrent // merging by task / all / none); parallel → run each fire in its own concurrent
// conversation up to trigger_max_parallel. Distinct agents always run concurrently. // conversation up to trigger_max_parallel. Distinct agents always run concurrently.
func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string) { func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string) {
s.startTriggeredRun(agentKey, title, message, taskID, mergeable, taskDesc, taskGoal, 0)
}
func (s *Server) StartTriggeredRunWithConversation(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string, conversationID int64) {
s.startTriggeredRun(agentKey, title, message, taskID, mergeable, taskDesc, taskGoal, conversationID)
}
func (s *Server) startTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string, conversationID int64) {
if s.m.pg == nil || s.chatAgentRef() == nil { if s.m.pg == nil || s.chatAgentRef() == nil {
return return
} }
@@ -714,7 +722,7 @@ func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu) cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
s.queueMu.Lock() s.queueMu.Lock()
s.triggerCfg[agentKey] = cfg s.triggerCfg[agentKey] = cfg
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable}) s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{conversationID: conversationID, agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
s.pumpLocked(agentKey) s.pumpLocked(agentKey)
s.queueMu.Unlock() s.queueMu.Unlock()
} }
+25 -1
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"log" "log"
"net/http" "net/http"
@@ -41,13 +42,16 @@ func (s *Server) seedCredentialProcessor() {
_ = s.m.pg.SetBool(flag, true) _ = s.m.pg.SetBool(flag, true)
} }
func (s *Server) runCredentialProcessor() { func (s *Server) runCredentialProcessor() {
ticker := time.NewTicker(3 * time.Second) // Events wake the worker immediately. The slow ticker is only a recovery
// scan for rows created before a notification or after a process restart.
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for {
select { select {
case <-s.ctx.Done(): case <-s.ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
case <-s.credentialWake:
} }
if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) { if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) {
continue continue
@@ -93,6 +97,14 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
} }
}() }()
result := *item.Credentials result := *item.Credentials
conversationID := result.ConversationID
if conversationID == 0 {
if c, err := s.m.pg.CreateConversation(db.CredentialAgentKey, "凭据整理 · "+firstLine(item.Title, 42), nil); err == nil {
conversationID = c.ID
result.ConversationID = c.ID
_, _ = s.m.pg.AppendConvActivity(c.ID, db.Activity{Worker: db.CredentialAgentKey, Kind: "user", Summary: "收到凭据上报,开始整理", Detail: item.Content})
}
}
needsExtract := result.ForceExtract || result.SourceHash != db.CredentialSourceHash(item.Content) || result.Method == "" || (result.Error != "" && len(result.Accounts) == 0) needsExtract := result.ForceExtract || result.SourceHash != db.CredentialSourceHash(item.Content) || result.Method == "" || (result.Error != "" && len(result.Accounts) == 0)
result.State = "ready" result.State = "ready"
result.ForceExtract = false result.ForceExtract = false
@@ -185,8 +197,20 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
if err := s.m.pg.FinishCredentialJob(finish, item, result); err != nil { if err := s.m.pg.FinishCredentialJob(finish, item, result); err != nil {
log.Printf("[credentials] save record #%d: %v", item.ID, err) log.Printf("[credentials] save record #%d: %v", item.ID, err)
} }
if conversationID > 0 {
detail, _ := json.Marshal(result)
kind, summary := "result", "凭据整理完成"
isError := result.State == "error"
if isError {
kind, summary = "text", "凭据整理失败:"+result.Error
}
_, _ = s.m.pg.AppendConvActivity(conversationID, db.Activity{Worker: db.CredentialAgentKey, Kind: kind, IsError: isError, Summary: summary, Detail: string(detail), InputTokens: intPtr(result.InputTokens), OutputTokens: intPtr(result.OutputTokens)})
_ = s.m.pg.TouchConversation(conversationID)
}
} }
func intPtr(v int) *int { return &v }
var credentialURLs = regexp.MustCompile(`https?://[^\s<>"',;))]+`) var credentialURLs = regexp.MustCompile(`https?://[^\s<>"',;))]+`)
var credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`) var credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`)
+19 -17
View File
@@ -325,15 +325,16 @@ type FindingDTO struct {
ReportStale bool `json:"report_stale"` ReportStale bool `json:"report_stale"`
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"` TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
ID string `json:"id"` ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"` VulnClass string `json:"vulnclass"`
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
Severity string `json:"severity"` // critical | high | medium | low Severity string `json:"severity"` // critical | high | medium | low
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
Summary string `json:"summary"` Summary string `json:"summary"`
Evidence string `json:"evidence"` Evidence string `json:"evidence"`
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空 Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
IntentID string `json:"intent_id,omitempty"` IntentID string `json:"intent_id,omitempty"`
ParamID string `json:"param_id,omitempty"` ParamID string `json:"param_id,omitempty"`
@@ -464,14 +465,15 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
FindingID: i64s(f.ID), FindingID: i64s(f.ID),
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion, TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings, ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
VulnClass: f.VulnClass, VulnClass: f.VulnClass,
Name: f.Name, Name: f.Name,
Severity: f.Severity, Severity: f.Severity,
Status: status, Status: status,
Summary: f.Summary, Summary: f.Summary,
Evidence: f.Evidence, Evidence: f.Evidence,
Report: f.Report, Report: f.Report,
TS: rfc3339(f.CreatedAt), ReportConversationID: f.ReportConversationID,
TS: rfc3339(f.CreatedAt),
} }
if f.ExportCaseID > 0 { if f.ExportCaseID > 0 {
d.ID = "case:" + i64s(f.ExportCaseID) d.ID = "case:" + i64s(f.ExportCaseID)
+12 -2
View File
@@ -58,20 +58,30 @@ func (s *Server) generateFindingReport(w http.ResponseWriter, r *http.Request) {
} }
} }
message := manualFindingReportMessage(f, taskID) message := manualFindingReportMessage(f, taskID)
s.StartTriggeredRun( conversation, err := s.m.pg.CreateConversation("reporter", fmt.Sprintf("漏洞报告 · finding#%d", f.ID), nil)
if err != nil {
writeErr(w, http.StatusInternalServerError, "创建报告对话失败: "+err.Error())
return
}
if err := s.m.pg.SetFindingReportConversationID(f.ID, conversation.ID); err != nil {
writeErr(w, http.StatusInternalServerError, "关联报告对话失败: "+err.Error())
return
}
s.StartTriggeredRunWithConversation(
"reporter", "reporter",
fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID), fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID),
message, message,
taskID, taskID,
false, false,
taskDesc, taskDesc,
taskGoal, taskGoal, conversation.ID,
) )
writeJSON(w, http.StatusAccepted, map[string]any{ writeJSON(w, http.StatusAccepted, map[string]any{
"queued": true, "queued": true,
"agent_key": "reporter", "agent_key": "reporter",
"finding_id": f.ID, "finding_id": f.ID,
"finding_node_id": *f.NodeID, "finding_node_id": *f.NodeID,
"conversation_id": conversation.ID,
}) })
} }
+11
View File
@@ -11,6 +11,7 @@ import (
"github.com/Autumn-27/artex/agent" "github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/permission" "github.com/Autumn-27/norma/permission"
actool "github.com/Autumn-27/norma/tool" actool "github.com/Autumn-27/norma/tool"
) )
@@ -474,6 +475,16 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil { if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil {
return actool.Errorf(err.Error()), nil return actool.Errorf(err.Error()), nil
} }
if f, err := s.m.pg.GetFinding(fid); err == nil && f != nil && f.TaskID != nil {
if err := s.archiveFindingScreenshots(*f.TaskID, a.Report); err != nil {
return actool.Errorf("归档报告截图失败: " + err.Error()), nil
}
}
if conversationID := intercept.ConvIDFromContext(ctx); conversationID > 0 {
if err := s.m.pg.SetFindingReportConversationID(fid, conversationID); err != nil {
return actool.Errorf("关联报告对话失败: " + err.Error()), nil
}
}
} }
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion) n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
if err != nil { if err != nil {
+15 -5
View File
@@ -125,9 +125,10 @@ type Server struct {
// Cold task archives run through one persistent FIFO worker. The buffered wake // Cold task archives run through one persistent FIFO worker. The buffered wake
// channel coalesces enqueue bursts; the database remains the source of truth. // channel coalesces enqueue bursts; the database remains the source of truth.
archiveWake chan struct{} archiveWake chan struct{}
archiveWG sync.WaitGroup archiveWG sync.WaitGroup
side *sideQuestionState credentialWake chan struct{}
side *sideQuestionState
} }
// provEntry is a cached provider + its config for one LLM profile id. // provEntry is a cached provider + its config for one LLM profile id.
@@ -161,7 +162,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profChatAgents: map[int64]*agent.ChatAgent{}, profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)} taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1), credentialWake: make(chan struct{}, 1)}
s.initSideQuestions() s.initSideQuestions()
if m.pg != nil { if m.pg != nil {
if err := s.basicAuth.load(m.pg); err != nil { if err := s.basicAuth.load(m.pg); err != nil {
@@ -282,6 +283,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
go s.reconcileConcurrency() go s.reconcileConcurrency()
s.startTaskArchiveWorker() s.startTaskArchiveWorker()
if m.pg != nil { if m.pg != nil {
agent.CredentialRecorded = func() {
select {
case s.credentialWake <- struct{}{}:
default:
}
}
s.seedCredentialProcessor() s.seedCredentialProcessor()
go s.runCredentialProcessor() go s.runCredentialProcessor()
} }
@@ -2325,7 +2332,10 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
stamp := now.Format("20060102-150405") stamp := now.Format("20060102-150405")
setDownload := func(contentType, filename string) { setDownload := func(contentType, filename string) {
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) // Keep an ASCII fallback for older clients and provide the real UTF-8
// name through RFC 5987. Browsers otherwise commonly decode Chinese
// names using the platform legacy code page.
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, sanitizeFilename(filename), url.PathEscape(filename)))
} }
switch format { switch format {
+2 -73
View File
@@ -1,17 +1,8 @@
package server package server
// Container-level resource metrics. These deliberately read cgroups directly:
// generic host libraries often report the host's capacity from inside Docker.
import ( import (
"encoding/json" "encoding/json"
"os"
"net/http" "net/http"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time" "time"
) )
@@ -23,6 +14,7 @@ type metricValue struct {
type systemMetricsResponse struct { type systemMetricsResponse struct {
Scope string `json:"scope"` Scope string `json:"scope"`
Supported bool `json:"supported"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
CPU metricValue `json:"cpu"` CPU metricValue `json:"cpu"`
Memory metricValue `json:"memory"` Memory metricValue `json:"memory"`
@@ -32,71 +24,8 @@ type systemMetricsResponse struct {
} }
func (s *Server) systemMetrics(w http.ResponseWriter, r *http.Request) { func (s *Server) systemMetrics(w http.ResponseWriter, r *http.Request) {
cpuUse, cpuLimit, ok := cgroupCPU() out := systemMetricsSnapshot(s)
cpuPct := 0.0
if ok {
now := time.Now()
s.metricsMu.Lock()
if !s.lastCPUAt.IsZero() && cpuUse >= s.lastCPUUse {
elapsed := now.Sub(s.lastCPUAt).Seconds()
if elapsed > 0 {
cpuPct = float64(cpuUse-s.lastCPUUse) / 1e6 / elapsed / cpuLimit * 100
}
}
s.lastCPUUse, s.lastCPUAt = cpuUse, now
s.metricsMu.Unlock()
}
memUsed, memTotal, memOK := cgroupMemory()
root := diskMetric("/")
data := diskMetric(s.dataDir)
if data.TotalBytes == 0 { data = root }
containerMetrics := ok || memOK
scope := "process"
if containerMetrics {
scope = "container"
}
out := systemMetricsResponse{Scope: scope, Timestamp: time.Now().UTC(), Container: containerMetrics,
CPU: metricValue{Percent: clampPercent(cpuPct)},
Memory: metricValue{UsedBytes: memUsed, TotalBytes: memTotal, Percent: percent(memUsed, memTotal)}, Disk: root, DataDisk: data}
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out) _ = json.NewEncoder(w).Encode(out)
} }
func cgroupCPU() (uint64, float64, bool) {
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/cpu"} {
b, err := os.ReadFile(filepath.Join(dir, "cpu.stat")); if err != nil { continue }
var use uint64
for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) == 2 && f[0] == "usage_usec" { use, _ = strconv.ParseUint(f[1], 10, 64) } }
limit := float64(runtime.NumCPU())
if q, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil { f := strings.Fields(string(q)); if len(f) == 2 && f[0] != "max" { quota, _ := strconv.ParseFloat(f[0], 64); period, _ := strconv.ParseFloat(f[1], 64); if period > 0 { limit = quota / period } } }
if use > 0 { return use, limit, true }
}
return 0, 0, false
}
func cgroupMemory() (uint64, uint64, bool) {
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/memory"} {
cur, e1 := readUint(filepath.Join(dir, "memory.current")); max, e2 := readUint(filepath.Join(dir, "memory.max"))
if e1 == nil && e2 == nil { return cur, max, max > 0 }
if e1 == nil {
if b, err := os.ReadFile(filepath.Join(dir, "memory.max")); err == nil && strings.TrimSpace(string(b)) == "max" {
return cur, hostMemoryTotal(), true
}
}
cur, e1 = readUint(filepath.Join(dir, "memory.usage_in_bytes")); max, e2 = readUint(filepath.Join(dir, "memory.limit_in_bytes")); if e1 == nil && e2 == nil && max < ^uint64(0)/2 { return cur, max, true }
}
return 0, 0, false
}
func hostMemoryTotal() uint64 {
b, err := os.ReadFile("/proc/meminfo"); if err != nil { return 0 }
for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) >= 2 && f[0] == "MemTotal:" { n, _ := strconv.ParseUint(f[1], 10, 64); return n * 1024 } }
return 0
}
func readUint(path string) (uint64, error) { b, err := os.ReadFile(path); if err != nil { return 0, err }; return strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) }
func diskMetric(path string) metricValue { var st syscall.Statfs_t; if path == "" || syscall.Statfs(path, &st) != nil { return metricValue{} }; total := st.Blocks * uint64(st.Bsize); free := st.Bavail * uint64(st.Bsize); used := total - free; return metricValue{UsedBytes: used, TotalBytes: total, Percent: percent(used, total)} }
func percent(used, total uint64) float64 { if total == 0 { return 0 }; return clampPercent(float64(used) / float64(total) * 100) }
func clampPercent(v float64) float64 { if v < 0 { return 0 }; if v > 100 { return 100 }; return v }
+62
View File
@@ -0,0 +1,62 @@
//go:build linux
package server
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
func systemMetricsSnapshot(s *Server) systemMetricsResponse {
use, limit, cpuOK := cgroupCPU()
cpuPct := 0.0
if cpuOK {
now := time.Now()
s.metricsMu.Lock()
if !s.lastCPUAt.IsZero() && use >= s.lastCPUUse && limit > 0 {
elapsed := now.Sub(s.lastCPUAt).Seconds()
if elapsed > 0 { cpuPct = float64(use-s.lastCPUUse) / 1e6 / elapsed / limit * 100 }
}
s.lastCPUUse, s.lastCPUAt = use, now
s.metricsMu.Unlock()
}
memUsed, memTotal, memOK := cgroupMemory()
root, data := diskMetric("/"), diskMetric(s.dataDir)
if data.TotalBytes == 0 { data = root }
return systemMetricsResponse{Scope: "container", Supported: true, Timestamp: time.Now().UTC(), Container: cpuOK || memOK,
CPU: metricValue{Percent: clampPercent(cpuPct)}, Memory: metricValue{UsedBytes: memUsed, TotalBytes: memTotal, Percent: percent(memUsed, memTotal)}, Disk: root, DataDisk: data}
}
func cgroupCPU() (uint64, float64, bool) {
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/cpu"} {
b, err := os.ReadFile(filepath.Join(dir, "cpu.stat")); if err != nil { continue }
var use uint64
for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) == 2 && f[0] == "usage_usec" { use, _ = strconv.ParseUint(f[1], 10, 64) } }
limit := float64(runtime.NumCPU())
if q, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil { f := strings.Fields(string(q)); if len(f) == 2 && f[0] != "max" { quota, _ := strconv.ParseFloat(f[0], 64); period, _ := strconv.ParseFloat(f[1], 64); if period > 0 { limit = quota / period } } }
if use > 0 { return use, limit, true }
}
return 0, 0, false
}
func cgroupMemory() (uint64, uint64, bool) {
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/memory"} {
cur, ce := readUint(filepath.Join(dir, "memory.current")); max, me := readUint(filepath.Join(dir, "memory.max"))
if ce == nil && me == nil { return cur, max, max > 0 }
if ce == nil { if b, err := os.ReadFile(filepath.Join(dir, "memory.max")); err == nil && strings.TrimSpace(string(b)) == "max" { return cur, hostMemoryTotal(), true } }
cur, ce = readUint(filepath.Join(dir, "memory.usage_in_bytes")); max, me = readUint(filepath.Join(dir, "memory.limit_in_bytes"))
if ce == nil && me == nil && max < ^uint64(0)/2 { return cur, max, true }
}
return 0, 0, false
}
func hostMemoryTotal() uint64 { b, err := os.ReadFile("/proc/meminfo"); if err != nil { return 0 }; for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) >= 2 && f[0] == "MemTotal:" { n, _ := strconv.ParseUint(f[1], 10, 64); return n * 1024 } }; return 0 }
func readUint(path string) (uint64, error) { b, err := os.ReadFile(path); if err != nil { return 0, err }; return strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) }
func diskMetric(path string) metricValue { var st syscall.Statfs_t; if path == "" || syscall.Statfs(path, &st) != nil { return metricValue{} }; total, free := st.Blocks*uint64(st.Bsize), st.Bavail*uint64(st.Bsize); used := total-free; return metricValue{UsedBytes: used, TotalBytes: total, Percent: percent(used, total)} }
func percent(used, total uint64) float64 { if total == 0 { return 0 }; return clampPercent(float64(used)/float64(total)*100) }
func clampPercent(v float64) float64 { if v < 0 { return 0 }; if v > 100 { return 100 }; return v }
+7
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"encoding/json" "encoding/json"
"net/http/httptest" "net/http/httptest"
"runtime"
"testing" "testing"
) )
@@ -21,6 +22,12 @@ func TestSystemMetricsReturnsContainerAndDiskValues(t *testing.T) {
if got.Scope == "" || got.Timestamp.IsZero() { if got.Scope == "" || got.Timestamp.IsZero() {
t.Fatalf("unexpected response metadata: %+v", got) t.Fatalf("unexpected response metadata: %+v", got)
} }
if runtime.GOOS != "linux" {
if got.Supported || got.Scope != "unsupported" {
t.Fatalf("unsupported platform returned metrics: %+v", got)
}
return
}
if got.Disk.TotalBytes == 0 || got.DataDisk.TotalBytes == 0 { if got.Disk.TotalBytes == 0 || got.DataDisk.TotalBytes == 0 {
t.Fatalf("disk metrics missing: %+v", got) t.Fatalf("disk metrics missing: %+v", got)
} }
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package server
import "time"
func systemMetricsSnapshot(_ *Server) systemMetricsResponse {
return systemMetricsResponse{Scope: "unsupported", Supported: false, Timestamp: time.Now().UTC()}
}
+62
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"bytes" "bytes"
"fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -309,6 +310,67 @@ func (s *Server) findSessionPreview(rawPath string) (string, bool) {
return newest, newest != "" return newest, newest != ""
} }
// archiveFindingScreenshots materializes report-referenced screenshots in the
// task workspace. Browser runs use per-session working directories, while
// reports intentionally use the portable findings/screenshots/ path.
func (s *Server) archiveFindingScreenshots(taskID int64, markdown string) error {
if taskID <= 0 {
return nil
}
for _, match := range screenshotMarkdownLink.FindAllStringSubmatch(markdown, -1) {
raw := strings.TrimSpace(match[2])
rel := filepath.Clean(strings.TrimPrefix(raw, "/"))
const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator)
if !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) {
continue
}
target := filepath.Join(s.m.dir, "tasks", fmt.Sprint(taskID), rel)
if info, err := os.Stat(target); err == nil && !info.IsDir() {
continue
}
source, ok := s.resolveSessionScreenshot(rel)
if !ok {
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
in, err := os.Open(source)
if err != nil {
return err
}
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err == nil {
_, err = io.Copy(out, in)
}
_ = in.Close()
_ = out.Close()
if err != nil {
return err
}
}
return nil
}
func (s *Server) resolveSessionScreenshot(rel string) (string, bool) {
matches, err := filepath.Glob(filepath.Join(filepath.Clean(s.m.dir), "sessions", "*", rel))
if err != nil || len(matches) == 0 {
return "", false
}
var newest string
var newestTime int64
for _, candidate := range matches {
info, statErr := os.Stat(candidate)
if statErr != nil || info.IsDir() {
continue
}
if newest == "" || info.ModTime().UnixNano() > newestTime {
newest, newestTime = candidate, info.ModTime().UnixNano()
}
}
return newest, newest != ""
}
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more). // POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path")) dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
+242
View File
@@ -11,6 +11,13 @@
"@antv/g6": "^5.1.1", "@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5", "@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -966,6 +973,149 @@
"node": ">=14.21.3" "node": ">=14.21.3"
} }
}, },
"node_modules/@codemirror/autocomplete": {
"version": "6.20.4",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz",
"integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.15",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@cspotcode/source-map-support": { "node_modules/@cspotcode/source-map-support": {
"version": "0.8.1", "version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -1885,6 +2035,80 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lezer/common": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.4.tgz",
"integrity": "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==",
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -5172,6 +5396,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -10259,6 +10489,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"license": "MIT"
},
"node_modules/style-to-js": { "node_modules/style-to-js": {
"version": "1.1.21", "version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -10928,6 +11164,12 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
+7
View File
@@ -23,6 +23,13 @@
"@antv/g6": "^5.1.1", "@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5", "@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0", "@base-ui/react": "^1.6.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.15",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
+43 -37
View File
@@ -9,13 +9,13 @@ import {
ArrowUpRightIcon, ArrowUpRightIcon,
BugIcon, BugIcon,
ClockIcon, ClockIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon, NetworkIcon,
ShieldCheckIcon, ShieldCheckIcon,
TargetIcon, TargetIcon,
ZapIcon, ZapIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
} from "lucide-react"; } from "lucide-react";
import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts"; import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts";
@@ -73,15 +73,6 @@ function fmtTokens(n: number): string {
return String(n); return String(n);
} }
function fmtBytes(n: number): string {
if (!n) return "—";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = n;
let i = 0;
while (value >= 1024 && i < units.length - 1) { value /= 1024; i++; }
return `${value.toFixed(value >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
}
const ASSET_TYPE_LABELS: Record<string, string> = { const ASSET_TYPE_LABELS: Record<string, string> = {
root_domain: "根域名", root_domain: "根域名",
ip: "IP", ip: "IP",
@@ -114,6 +105,35 @@ function statusBg(code: number): string {
return "bg-red-400"; return "bg-red-400";
} }
function SystemResourceStatus({ metrics }: { metrics: SystemMetrics | null }) {
if (!metrics) return null;
if (!metrics.supported) {
return <div className="ml-auto text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
}
const items = [
[CpuIcon, "CPU", metrics.cpu.percent],
[MemoryStickIcon, "内存", metrics.memory.percent],
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
] as const;
return (
<div className="ml-auto flex min-w-0 items-center justify-end gap-3 text-xs text-muted-foreground" role="status" aria-live="polite">
<span className="shrink-0 font-medium">容器资源</span>
{items.map(([Icon, label, value]) => (
<div key={label} className="w-24 min-w-0" title={`${label} ${value.toFixed(1)}%`}>
<div className="mb-1 flex items-center justify-between gap-1 text-[10px]">
<span className="inline-flex items-center gap-1"><Icon className="size-3" aria-hidden="true" />{label}</span>
<span className="tabular-nums">{value.toFixed(1)}%</span>
</div>
<Progress
value={value}
className="h-2 bg-muted [&_[data-slot=progress-indicator]]:bg-slate-500 dark:[&_[data-slot=progress-indicator]]:bg-slate-400"
/>
</div>
))}
</div>
);
}
// ── sub-components ──────────────────────────────────────────────────────────── // ── sub-components ────────────────────────────────────────────────────────────
function LiveDot({ className }: { className?: string }) { function LiveDot({ className }: { className?: string }) {
@@ -164,7 +184,14 @@ export default function DashboardPage() {
React.useEffect(() => { React.useEffect(() => {
let alive = true; let alive = true;
const load = async () => { try { const m = await api.systemMetrics(); if (alive) setSystemMetrics(m); } catch { /* retry */ } }; const load = async () => {
try {
const next = await api.systemMetrics();
if (alive) setSystemMetrics(next);
} catch {
/* retry on the next interval */
}
};
void load(); void load();
const timer = setInterval(load, 5000); const timer = setInterval(load, 5000);
return () => { alive = false; clearInterval(timer); }; return () => { alive = false; clearInterval(timer); };
@@ -517,10 +544,13 @@ export default function DashboardPage() {
<div className="flex flex-1 flex-col gap-4 pb-6"> <div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */} {/* ── Header ── */}
<div> <div>
<div className="flex items-center gap-6">
<div> <div>
<h1 className="text-lg font-semibold tracking-tight">总览</h1> <h1 className="text-lg font-semibold tracking-tight">总览</h1>
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p> <p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
</div> </div>
<SystemResourceStatus metrics={systemMetrics} />
</div>
</div> </div>
{/* ── Row 1: 5 stat cards ── */} {/* ── Row 1: 5 stat cards ── */}
@@ -1151,30 +1181,6 @@ export default function DashboardPage() {
<Card className="p-4"> <Card className="p-4">
<SectionTitle icon={ShieldCheckIcon}>系统状态</SectionTitle> <SectionTitle icon={ShieldCheckIcon}>系统状态</SectionTitle>
{systemMetrics && (
<div className="mb-3 rounded-lg border bg-muted/20 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-medium">容器资源</span>
<span className="text-[9px] text-muted-foreground">每 5 秒刷新</span>
</div>
<div className="grid grid-cols-3 gap-2">
{([
[CpuIcon, "CPU", systemMetrics.cpu],
[MemoryStickIcon, "内存", systemMetrics.memory],
[HardDriveIcon, "磁盘", systemMetrics.data_disk],
] as const).map(([Icon, label, metric]) => (
<div key={label} className="min-w-0">
<div className="mb-1 flex items-center gap-1 text-[9px] text-muted-foreground"><Icon className="size-3" />{label}</div>
<Progress value={metric.percent} className="h-1.5" />
<div className="mt-1 truncate text-[9px] tabular-nums">{metric.percent.toFixed(1)}%</div>
{label !== "CPU" && <div className="truncate text-[9px] text-muted-foreground">{fmtBytes(metric.used_bytes)} / {fmtBytes(metric.total_bytes)}</div>}
</div>
))}
</div>
<div className="mt-2 text-[9px] text-muted-foreground">监控范围:{systemMetrics.scope === "container" ? "ARTEX 容器" : "当前进程(非容器运行)"}</div>
</div>
)}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2"> <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-[10px] text-muted-foreground">LLM 配置</div> <div className="text-[10px] text-muted-foreground">LLM 配置</div>
@@ -72,6 +72,7 @@ function FindingDetailInner() {
const [tab, setTab] = React.useState("overview"); const [tab, setTab] = React.useState("overview");
const [reportGenerating, setReportGenerating] = React.useState(false); const [reportGenerating, setReportGenerating] = React.useState(false);
const [autoChatOpen, setAutoChatOpen] = React.useState(false); const [autoChatOpen, setAutoChatOpen] = React.useState(false);
const [reportChatOpen, setReportChatOpen] = React.useState(false);
const title = finding?.name || finding?.vulnclass || "未分类"; const title = finding?.name || finding?.vulnclass || "未分类";
const load = React.useCallback(() => { const load = React.useCallback(() => {
@@ -190,6 +191,12 @@ function FindingDetailInner() {
<BotIcon data-icon="inline-start" /> <BotIcon data-icon="inline-start" />
询问 Auto Agent 询问 Auto Agent
</Button> </Button>
{!finding.inherited ? (
<Button variant="outline" size="sm" onClick={() => setReportChatOpen(true)} title="打开当前漏洞报告撰写对话">
<FileTextIcon data-icon="inline-start" />
调整报告
</Button>
) : null}
{finding.inherited && finding.source_task_id && ( {finding.inherited && finding.source_task_id && (
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge> <Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
)} )}
@@ -410,6 +417,17 @@ function FindingDetailInner() {
open={autoChatOpen} open={autoChatOpen}
onOpenChange={setAutoChatOpen} onOpenChange={setAutoChatOpen}
/> />
<FindingAutoChat
findingId={finding.id}
findingTitle={title}
agentKey="reporter"
agentLabel="报告撰写 Agent"
promptPlaceholder="描述要如何调整漏洞报告…"
modal
initialConversationId={finding.report_conversation_id}
open={reportChatOpen}
onOpenChange={setReportChatOpen}
/>
</> </>
); );
} }
@@ -923,7 +923,7 @@ export default function FindingsPage() {
})} })}
</div> </div>
<div className="flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1"> <div className="scrollbar-none flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1">
<InputGroup className="w-72 shrink-0"> <InputGroup className="w-72 shrink-0">
<InputGroupInput <InputGroupInput
type="search" type="search"
File diff suppressed because it is too large. Load diff
+25 -3
View File
@@ -65,6 +65,28 @@ function originLabel(row: InterceptApprovalRow) {
return "—"; return "—";
} }
// Background task runs carry a pool label such as "work#2 · #2770", while
// conversation-backed runs carry the agent key (for example "reporter").
// Present a stable, human-readable agent label in both cases.
function agentLabel(row: InterceptApprovalRow) {
const key = (row.conv_agent_key || "").trim().toLowerCase();
const labels: Record<string, string> = {
auto: "自动 Agent",
mainagent: "主 Agent",
planner: "规划 Agent",
worker: "执行 Worker",
reporter: "报告编写",
retester: "复测 Agent",
pentest: "渗透测试 Agent",
};
if (key && labels[key]) return labels[key];
const raw = (row.agent_name || "").trim();
if (/^work#\d+(?:\s|$)/i.test(raw)) return "执行 Worker";
const rawKey = raw.toLowerCase();
if (labels[rawKey]) return labels[rawKey];
return raw || "未知 Agent";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) { function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row); const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
@@ -454,7 +476,7 @@ export function ApprovalDetail({
<SideQuestionButton side={side} /> <SideQuestionButton side={side} />
</div> </div>
<CodeBlock <CodeBlock
label={`${current.agent_name || current.conv_agent_key || "Agent"} · 工具请求`} label={`${agentLabel(current)} · 工具请求`}
text={JSON.stringify(row.tool_input ?? {}, null, 2)} text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/> />
{command ? ( {command ? (
@@ -726,8 +748,8 @@ function ApprovalTable({
<span className="truncate" title={originLabel(row)}> <span className="truncate" title={originLabel(row)}>
<ApprovalOrigin row={row} /> <ApprovalOrigin row={row} />
</span> </span>
<span className="truncate text-muted-foreground text-xs"> <span className="truncate text-muted-foreground text-xs" title={agentLabel(row)}>
{row.agent_name || row.conv_agent_key} Agent:{agentLabel(row)}
</span> </span>
</div> </div>
</TableCell> </TableCell>
+50 -13
View File
@@ -22,11 +22,21 @@ function mergeActivities(oldItems: Activity[], incoming: Activity[]) {
export function FindingAutoChat({ export function FindingAutoChat({
findingId, findingId,
findingTitle, findingTitle,
agentKey = "auto",
agentLabel = "Auto Agent",
promptPlaceholder = "询问这个漏洞…",
modal = false,
initialConversationId,
open, open,
onOpenChange, onOpenChange,
}: { }: {
findingId: string; findingId: string;
findingTitle: string; findingTitle: string;
agentKey?: string;
agentLabel?: string;
promptPlaceholder?: string;
modal?: boolean;
initialConversationId?: number;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
@@ -41,8 +51,9 @@ export function FindingAutoChat({
const [historyOpen, setHistoryOpen] = React.useState(false); const [historyOpen, setHistoryOpen] = React.useState(false);
const [history, setHistory] = React.useState<Conversation[]>([]); const [history, setHistory] = React.useState<Conversation[]>([]);
const cursor = React.useRef(0); const cursor = React.useRef(0);
const chatViewportRef = React.useRef<HTMLDivElement | null>(null);
const safeTitle = findingTitle.replace(/[\[\]\r\n]/g, " ").trim().slice(0, 100); const safeTitle = findingTitle.replace(/[\[\]\r\n]/g, " ").trim().slice(0, 100);
const storageKey = `artex.finding.auto-conversation.${findingId}`; const storageKey = `artex.finding.${agentKey}-conversation.${findingId}`;
const createConversation = React.useCallback(async () => { const createConversation = React.useCallback(async () => {
setManualEmpty(false); setManualEmpty(false);
@@ -50,7 +61,7 @@ export function FindingAutoChat({
setRunning(false); setRunning(false);
setLoading(true); setLoading(true);
try { try {
const created = await api.createConversation("auto", `漏洞 #${findingId} · ${safeTitle}`); const created = await api.createConversation(agentKey, `漏洞 #${findingId} · ${safeTitle}`);
window.localStorage.setItem(storageKey, String(created.id)); window.localStorage.setItem(storageKey, String(created.id));
setConversation(created); setConversation(created);
} catch (error) { } catch (error) {
@@ -58,15 +69,19 @@ export function FindingAutoChat({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [findingId, safeTitle, storageKey]); }, [agentKey, findingId, safeTitle, storageKey]);
React.useEffect(() => { React.useEffect(() => {
if (!open || conversation || manualEmpty) return; if (!open || conversation || manualEmpty) return;
const storedID = Number(window.localStorage.getItem(storageKey)); if (initialConversationId && initialConversationId > 0) {
setConversation({ id: initialConversationId, agent_key: agentKey, title: `漏洞 #${findingId} · ${safeTitle}`, created_at: "", updated_at: "" });
return;
}
const storedID = agentKey === "auto" ? Number(window.localStorage.getItem(storageKey)) : 0;
if (Number.isSafeInteger(storedID) && storedID > 0) { if (Number.isSafeInteger(storedID) && storedID > 0) {
setConversation({ setConversation({
id: storedID, id: storedID,
agent_key: "auto", agent_key: agentKey,
title: `漏洞 #${findingId} · ${safeTitle}`, title: `漏洞 #${findingId} · ${safeTitle}`,
created_at: "", created_at: "",
updated_at: "", updated_at: "",
@@ -74,7 +89,7 @@ export function FindingAutoChat({
return; return;
} }
void createConversation(); void createConversation();
}, [conversation, createConversation, manualEmpty, open, safeTitle, storageKey]); }, [conversation, createConversation, initialConversationId, manualEmpty, open, safeTitle, storageKey]);
React.useEffect(() => { React.useEffect(() => {
if (!historyOpen && !running) return; if (!historyOpen && !running) return;
@@ -85,7 +100,7 @@ export function FindingAutoChat({
const items = await api.conversations(); const items = await api.conversations();
if (!live) return; if (!live) return;
const prefix = `漏洞 #${findingId} ·`; const prefix = `漏洞 #${findingId} ·`;
setHistory(items.filter((item) => item.agent_key === "auto" && item.title.startsWith(prefix))); setHistory(items.filter((item) => item.agent_key === agentKey && item.title.startsWith(prefix)));
} catch { } catch {
if (live && historyOpen) setHistory([]); if (live && historyOpen) setHistory([]);
} finally { } finally {
@@ -97,7 +112,7 @@ export function FindingAutoChat({
live = false; live = false;
clearTimeout(timer); clearTimeout(timer);
}; };
}, [findingId, historyOpen, running]); }, [agentKey, findingId, historyOpen, running]);
React.useEffect(() => { React.useEffect(() => {
if (!open || !conversation) return; if (!open || !conversation) return;
@@ -122,6 +137,18 @@ export function FindingAutoChat({
}; };
}, [conversation, open, storageKey]); }, [conversation, open, storageKey]);
React.useEffect(() => {
if (!open || !conversation || loading || !messages.length) return;
const viewport = chatViewportRef.current;
if (!viewport) return;
const key = `artex.conversation-scroll.${conversation.id}`;
const saved = window.sessionStorage.getItem(key);
requestAnimationFrame(() => {
if (saved != null) viewport.scrollTop = Number(saved) || 0;
else viewport.scrollTop = viewport.scrollHeight;
});
}, [conversation, loading, messages.length, open]);
React.useEffect(() => { React.useEffect(() => {
if (!open || !conversation || !running) return; if (!open || !conversation || !running) return;
let live = true; let live = true;
@@ -203,13 +230,17 @@ export function FindingAutoChat({
if (!open) return null; if (!open) return null;
return ( return (
<div className="pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6"> <div className={modal
<section className="pointer-events-auto flex min-w-0 h-[calc(100svh-6rem)] w-[min(520px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"> ? "pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4"
: "pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6"}>
<section className={modal
? "pointer-events-auto flex min-w-0 h-[min(760px,calc(100svh-2rem))] w-[min(760px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
: "pointer-events-auto flex min-w-0 h-[calc(100svh-6rem)] w-[min(520px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"}>
<header className="flex items-center gap-2 border-b p-3"> <header className="flex items-center gap-2 border-b p-3">
<BotIcon className="size-4 text-primary" /> <BotIcon className="size-4 text-primary" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-medium"> <p className="flex items-center gap-2 font-medium">
Auto Agent {agentLabel}
{running && <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-blue-600 text-xs"><span className="size-1.5 animate-pulse rounded-full bg-blue-500" />运行中</span>} {running && <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-blue-600 text-xs"><span className="size-1.5 animate-pulse rounded-full bg-blue-500" />运行中</span>}
</p> </p>
<p className="truncate text-muted-foreground text-xs">漏洞 #{findingId} · {safeTitle}</p> <p className="truncate text-muted-foreground text-xs">漏洞 #{findingId} · {safeTitle}</p>
@@ -253,7 +284,13 @@ export function FindingAutoChat({
<XIcon /> <XIcon />
</Button> </Button>
</header> </header>
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-3"> <div
ref={chatViewportRef}
onScroll={(event) => {
if (conversation) window.sessionStorage.setItem(`artex.conversation-scroll.${conversation.id}`, String(event.currentTarget.scrollTop));
}}
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-3"
>
<div className="w-full min-w-0 max-w-full"> <div className="w-full min-w-0 max-w-full">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm"> <div className="flex items-center justify-center py-10 text-muted-foreground text-sm">
@@ -274,7 +311,7 @@ export function FindingAutoChat({
inputGroup inputGroup
onValueChange={setInput} onValueChange={setInput}
disabled={!conversation || loading || sending || running} disabled={!conversation || loading || sending || running}
placeholder="询问这个漏洞…" placeholder={promptPlaceholder}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault(); event.preventDefault();
+3 -28
View File
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, copyText } from "@/lib/utils"; import { cn, copyText } from "@/lib/utils";
import { PacketCodeEditor } from "@/components/packet-code-editor";
function statusTone(status: number) { function statusTone(status: number) {
if (status >= 500) return "text-red-500"; if (status >= 500) return "text-red-500";
@@ -308,34 +309,8 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent> <TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */} <div className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-hidden bg-background">
<div <PacketCodeEditor value={value} readOnly wrapLines={wrapLines} />
role="textbox"
aria-label="HTTP 报文代码"
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onCopy={(event) => {
const text = selectedPacketText(event.currentTarget, value, lines);
if (text === null) return;
event.preventDefault();
event.clipboardData.setData("text/plain", text);
}}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
event.preventDefault();
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.selectNodeContents(event.currentTarget);
selection.removeAllRanges();
selection.addRange(range);
}}
>
<PacketHighlightContext.Provider value={query}>
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
</PacketHighlightContext.Provider>
</div> </div>
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1"> <div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" /> <SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
+31 -4
View File
@@ -18,6 +18,8 @@ function AuthenticatedImage({
const [captureTimestamp, setCaptureTimestamp] = React.useState<string | null>(null); const [captureTimestamp, setCaptureTimestamp] = React.useState<string | null>(null);
const [zoomed, setZoomed] = React.useState(false); const [zoomed, setZoomed] = React.useState(false);
const [zoomScale, setZoomScale] = React.useState(1); const [zoomScale, setZoomScale] = React.useState(1);
const [pan, setPan] = React.useState({ x: 0, y: 0 });
const dragRef = React.useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
const imageSrc = typeof src === "string" ? src : undefined; const imageSrc = typeof src === "string" ? src : undefined;
React.useEffect(() => { React.useEffect(() => {
@@ -29,6 +31,7 @@ function AuthenticatedImage({
setCaptureTimestamp(null); setCaptureTimestamp(null);
setZoomed(false); setZoomed(false);
setZoomScale(1); setZoomScale(1);
setPan({ x: 0, y: 0 });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
@@ -75,31 +78,55 @@ function AuthenticatedImage({
onClick={() => renderedSrc && setZoomed(true)} onClick={() => renderedSrc && setZoomed(true)}
aria-label="放大查看报告截图" aria-label="放大查看报告截图"
> >
<img {...props} src={renderedSrc} alt={alt || "报告截图,点击放大"} /> <img {...props} src={renderedSrc} alt={alt || "报告截图,点击放大"} draggable={false} onDragStart={(event) => event.preventDefault()} />
{timestamp} {timestamp}
</button> </button>
<Dialog <Dialog
open={zoomed} open={zoomed}
onOpenChange={(next) => { onOpenChange={(next) => {
setZoomed(next); setZoomed(next);
if (!next) setZoomScale(1); if (!next) {
setZoomScale(1);
setPan({ x: 0, y: 0 });
}
}} }}
> >
<DialogContent <DialogContent
showCloseButton showCloseButton
className="flex max-h-[96dvh] max-w-[96vw] items-center justify-center overflow-hidden bg-black/85 p-3 backdrop-blur-sm sm:max-w-[96vw]" className="flex max-h-[96dvh] max-w-[96vw] items-center justify-center overflow-hidden bg-black/85 p-3 backdrop-blur-sm sm:max-w-[96vw]"
> >
<div className="relative flex max-h-[90dvh] max-w-full items-center justify-center overflow-auto"> <div
<div className="relative inline-block origin-center transition-transform duration-150" style={{ transform: `scale(${zoomScale})` }}> className="relative flex h-[90dvh] w-[90vw] max-w-full items-center justify-center overflow-hidden"
onPointerDown={(event) => {
if (zoomScale <= 1 || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
setPan({ x: drag.panX + event.clientX - drag.x, y: drag.panY + event.clientY - drag.y });
}}
onPointerUp={(event) => {
dragRef.current = null;
event.currentTarget.releasePointerCapture?.(event.pointerId);
}}
onPointerCancel={() => { dragRef.current = null; }}
style={{ cursor: zoomScale > 1 ? (dragRef.current ? "grabbing" : "grab") : "default" }}
>
<div className="relative inline-block origin-center" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoomScale})` }}>
<img <img
src={renderedSrc} src={renderedSrc}
alt={alt || "报告截图"} alt={alt || "报告截图"}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="max-h-[90dvh] max-w-[90vw] rounded-md object-contain" className="max-h-[90dvh] max-w-[90vw] rounded-md object-contain"
/> />
{timestamp} {timestamp}
</div> </div>
</div> </div>
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1 backdrop-blur-sm"> <div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
<span className="px-2 text-xs font-medium text-white tabular-nums">{Math.round(zoomScale * 100)}%</span>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
+123
View File
@@ -0,0 +1,123 @@
"use client";
import * as React from "react";
import { EditorState } from "@codemirror/state";
import { drawSelection, EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const packetHighlight = HighlightStyle.define([
{ tag: tags.keyword, color: "#d95656", fontWeight: "700" },
{ tag: tags.string, color: "#0284c7" },
{ tag: tags.variableName, color: "#286dcc", fontWeight: "650" },
{ tag: tags.typeName, color: "#6b7280" },
{ tag: tags.number, color: "#c026d3" },
{ tag: tags.bool, color: "#e11d48" },
]);
const httpLanguage = StreamLanguage.define({
startState: () => ({ line: 0 }),
token(stream, state) {
if (stream.sol()) state.line += 1;
if (stream.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE)\b/)) return "keyword";
if (stream.match(/^HTTP\/\d(?:\.\d)?\b/)) return "typeName";
if (stream.match(/^[A-Za-z][A-Za-z0-9-]*(?=:)/)) return "variableName";
if (stream.match(/^https?:\/\/\S+/)) return "string";
if (stream.match(/^(?:\d{3})(?:\s+[^\n]*)?/)) return "number";
if (stream.match(/^\b(?:true|false|null)\b/)) return "bool";
stream.next();
return null;
},
});
const packetTheme = EditorView.theme(
{
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
".cm-scroller": {
overflow: "auto",
fontFamily: "var(--font-mono)",
fontSize: "0.75rem",
fontWeight: "500",
lineHeight: "1.65rem",
},
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
".cm-gutters": {
backgroundColor: "transparent",
color: "var(--muted-foreground)",
border: "0",
borderRight: "1px solid var(--border)",
},
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent" },
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
".cm-cursor": { borderLeftColor: "#222" },
},
{ dark: false },
);
export function PacketCodeEditor({
value,
onChange,
readOnly = false,
wrapLines = true,
ariaLabel = "HTTP 报文代码",
}: {
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
wrapLines?: boolean;
ariaLabel?: string;
}) {
const host = React.useRef<HTMLDivElement>(null);
const viewRef = React.useRef<EditorView | null>(null);
const valueRef = React.useRef(value);
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
React.useEffect(() => {
if (!host.current) return;
const state = EditorState.create({
doc: value,
extensions: [
lineNumbers(),
drawSelection(),
highlightActiveLine(),
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
httpLanguage,
syntaxHighlighting(defaultHighlightStyle),
syntaxHighlighting(packetHighlight),
packetTheme,
...(wrapLines ? [EditorView.lineWrapping] : []),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
EditorView.contentAttributes.of({ role: "textbox", "aria-label": ariaLabel, "aria-multiline": "true" }),
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
const next = update.state.doc.toString();
valueRef.current = next;
onChangeRef.current?.(next);
}),
],
});
const view = new EditorView({ state, parent: host.current });
viewRef.current = view;
return () => {
viewRef.current = null;
view.destroy();
};
}, [ariaLabel, readOnly, wrapLines]);
React.useEffect(() => {
const view = viewRef.current;
if (!view || valueRef.current === value) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
valueRef.current = value;
}, [value]);
return <div ref={host} className="h-full min-h-0 w-full" />;
}
@@ -6,8 +6,7 @@ import { WrapTextIcon } from "lucide-react";
import { LiveClock } from "@/app/(main)/_components/live-clock"; import { LiveClock } from "@/app/(main)/_components/live-clock";
import { CopyButton } from "@/components/copy-button"; import { CopyButton } from "@/components/copy-button";
import { HttpPacketSyntax } from "@/components/http-code-block"; import { PacketCodeEditor } from "@/components/packet-code-editor";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function RepeaterTextPane({ export function RepeaterTextPane({
@@ -16,8 +15,6 @@ export function RepeaterTextPane({
value, value,
onChange, onChange,
disabled, disabled,
placeholder,
syntaxHighlight = false,
toolbar, toolbar,
panelClassName, panelClassName,
showClock = false, showClock = false,
@@ -33,11 +30,8 @@ export function RepeaterTextPane({
panelClassName?: string; panelClassName?: string;
showClock?: boolean; showClock?: boolean;
}) { }) {
const gutter = React.useRef<HTMLPreElement>(null);
const syntax = React.useRef<HTMLDivElement>(null);
const [wrapLines, setWrapLines] = React.useState(true); const [wrapLines, setWrapLines] = React.useState(true);
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]); const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
return ( return (
<section <section
className={cn( className={cn(
@@ -65,54 +59,13 @@ export function RepeaterTextPane({
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} /> <CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
</div> </div>
<div className="group relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring"> <div className="group relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
<div <PacketCodeEditor
aria-hidden="true"
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
>
<pre ref={gutter} className="select-none px-2 py-2 font-mono text-xs leading-5">
{numbers}
</pre>
</div>
<Textarea
id={id}
value={value} value={value}
onChange={(event) => onChange?.(event.target.value)} onChange={onChange}
onScroll={(event) => { readOnly={!onChange || disabled}
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`; wrapLines={wrapLines}
if (syntax.current) { ariaLabel={label}
syntax.current.scrollTop = event.currentTarget.scrollTop;
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
}
}}
disabled={disabled}
readOnly={!onChange}
placeholder={placeholder}
wrap={wrapLines ? "soft" : "off"}
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className={
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs" +
(syntaxHighlight
? " bg-transparent text-transparent caret-foreground selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
: "")
}
/> />
{syntaxHighlight ? (
<div
ref={syntax}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 left-12 z-20 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
>
<HttpPacketSyntax
raw={value}
lineNumbers={false}
wrapLines={wrapLines}
emptyFallback=""
lineClassName="leading-5"
/>
</div>
) : null}
</div> </div>
</section> </section>
); );
@@ -112,7 +112,7 @@ export function RepeaterWorkspace() {
</div> </div>
</div> </div>
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1"> <div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"> <div className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{workspace.tabs.map((tab) => { {workspace.tabs.map((tab) => {
const selected = tab.id === workspace.activeTabId; const selected = tab.id === workspace.activeTabId;
return ( return (
+1 -1
View File
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className className
)} )}
{...props} {...props}
+22 -3
View File
@@ -119,6 +119,24 @@ import type {
SystemMetrics, SystemMetrics,
} from "@/lib/types"; } from "@/lib/types";
/**
* Read a download name from Content-Disposition. RFC 5987's filename* is
* preferred because it preserves UTF-8 names; older filename remains the
* fallback for clients that do not understand it.
*/
export function downloadFilename(contentDisposition: string, fallback: string): string {
const encoded = contentDisposition.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]+)/i)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded.replace(/^"|"$/g, ""));
} catch {
// Fall through to the legacy token when a malformed header is received.
}
}
const legacy = contentDisposition.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;\s]+))/i);
return (legacy?.[1] ?? legacy?.[2] ?? fallback).trim() || fallback;
}
function getToken(): string | null { function getToken(): string | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
return localStorage.getItem("artex_token"); return localStorage.getItem("artex_token");
@@ -534,6 +552,7 @@ export const api = {
}, },
workspaceDownload: async (path: string) => { workspaceDownload: async (path: string) => {
let blob: Blob; let blob: Blob;
let contentDisposition = "";
if (MOCK) { if (MOCK) {
blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" }); blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" });
} else { } else {
@@ -542,12 +561,13 @@ export const api = {
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) }, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
}); });
if (!r.ok) throw new Error(`下载失败: ${r.status}`); if (!r.ok) throw new Error(`下载失败: ${r.status}`);
contentDisposition = r.headers.get("Content-Disposition") ?? "";
blob = await r.blob(); blob = await r.blob();
} }
const objUrl = URL.createObjectURL(blob); const objUrl = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = objUrl; a.href = objUrl;
a.download = path.split("/").pop() || "download"; a.download = downloadFilename(contentDisposition, path.split("/").pop() || "download");
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
@@ -703,8 +723,7 @@ export const api = {
const blob = await r.blob(); const blob = await r.blob();
// 文件名优先取后端 Content-Disposition,取不到则用默认名。 // 文件名优先取后端 Content-Disposition,取不到则用默认名。
const disp = r.headers.get("Content-Disposition") ?? ""; const disp = r.headers.get("Content-Disposition") ?? "";
const m = disp.match(/filename="?([^"]+)"?/); const filename = downloadFilename(disp, `findings-export`);
const filename = m?.[1] ?? `findings-export`;
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
+3 -1
View File
@@ -529,6 +529,7 @@ export interface Finding {
summary: string; summary: string;
evidence: string; evidence: string;
report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空 report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空
report_conversation_id?: number;
intent_id?: string; intent_id?: string;
param_id?: string; param_id?: string;
task_id?: string; task_id?: string;
@@ -1338,7 +1339,8 @@ export interface SystemMetricValue {
} }
export interface SystemMetrics { export interface SystemMetrics {
scope: "container" | string; scope: "container" | "unsupported" | string;
supported: boolean;
timestamp: string; timestamp: string;
container_metrics: boolean; container_metrics: boolean;
cpu: SystemMetricValue; cpu: SystemMetricValue;