mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-01 10:43:49 +08:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed68d9143 | ||
|
|
542d7e9ca6 | ||
|
|
35a33b20f9 | ||
|
|
74d8b1efa2 | ||
|
|
92e58d3de7 | ||
|
|
21057b1a6b | ||
|
|
136782f48e | ||
|
|
3af131272c | ||
|
|
07c0c8ee35 | ||
|
|
3f2c44be91 | ||
|
|
57424249ce | ||
|
|
f48f4ec8e2 | ||
|
|
aac79d72e9 | ||
|
|
a55189a649 | ||
|
|
9745e7d019 | ||
|
|
af2f9e3560 | ||
|
|
8828d01eae | ||
|
|
53840faf7f | ||
|
|
3575b9091e | ||
|
|
4c5b4e9e79 | ||
|
|
bffa2b55a8 | ||
|
|
21f68a6a60 |
No files matched your search
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,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
@@ -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,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"))
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+22
-3
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user