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
18 changed files with 1394 additions and 284 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 {
+1
View File
@@ -39,6 +39,7 @@ type CredentialTraffic struct {
Accounts []int `json:"accounts"` Accounts []int `json:"accounts"`
} }
type CredentialAnalysis struct { type CredentialAnalysis struct {
ConversationID int64 `json:"conversation_id,omitempty"`
ForceExtract bool `json:"force_extract,omitempty"` ForceExtract bool `json:"force_extract,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"` TrafficNote string `json:"traffic_note,omitempty"`
State string `json:"state"` State string `json:"state"`
+46 -2
View File
@@ -35,6 +35,7 @@ type DBFinding struct {
AssetIDs []int64 AssetIDs []int64
Status string Status string
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带 Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
CreatedAt time.Time CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks TaskDescription string // populated via LEFT JOIN on tasks
} }
@@ -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`)
+2
View File
@@ -334,6 +334,7 @@ type FindingDTO struct {
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"`
@@ -471,6 +472,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
Summary: f.Summary, Summary: f.Summary,
Evidence: f.Evidence, Evidence: f.Evidence,
Report: f.Report, Report: f.Report,
ReportConversationID: f.ReportConversationID,
TS: rfc3339(f.CreatedAt), TS: rfc3339(f.CreatedAt),
} }
if f.ExportCaseID > 0 { if f.ExportCaseID > 0 {
+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 {
+12 -2
View File
@@ -127,6 +127,7 @@ type Server struct {
// 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
credentialWake chan struct{}
side *sideQuestionState side *sideQuestionState
} }
@@ -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 {
+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"))
@@ -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
+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();
+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"
+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;
+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;