Compare commits

...
Author SHA1 Message Date
ji ruo 3af131272c fix: 修复报告对话绑定与滚动位置 2026-10-30 13:01:24 +08:00
RuoJi6 07c0c8ee35 Merge pull request #95 from RuoJi6/codex/backfill-report-conversation-binding
修复历史报告撰写对话回溯绑定
2026-10-29 21:36:50 -07:00
ji ruo 3f2c44be91 fix: 回溯绑定历史报告撰写对话 2026-10-30 12:34:04 +08:00
RuoJi6 57424249ce Merge pull request #94 from RuoJi6/codex/fix-report-conversation-binding
修复工具触发报告对话绑定
2026-10-29 21:29:23 -07:00
ji ruo f48f4ec8e2 fix: 绑定工具触发的报告撰写对话 2026-10-30 12:27:50 +08:00
ji ruo aac79d72e9 Merge pull request #93 after conflict resolution 2026-10-30 12:21:02 +08:00
RuoJi6 a55189a649 Merge pull request #91 from RuoJi6/codex/task-list-column-settings
feat: add task list column visibility settings
2026-10-29 21:20:28 -07:00
ji ruo 9745e7d019 feat: 复用漏洞报告撰写对话 2026-10-30 12:19:43 +08:00
ji ruo af2f9e3560 fix: 归档报告截图并支持弹窗调整 2026-10-30 12:07:10 +08:00
ji ruo 8828d01eae fix: 将报告调整对话改为弹窗 2026-10-30 12:04:39 +08:00
ji ruo 53840faf7f fix: 归档报告截图并支持对话调整 2026-10-30 12:03:21 +08:00
ji ruo 3575b9091e feat: add task list column visibility settings 2026-10-30 11:25:36 +08:00
RuoJi6 4c5b4e9e79 Merge pull request #90 from RuoJi6/codex/fix-hidden-scrollbars
修复筛选与请求重发标签栏滚动条显示
2026-10-29 18:21:58 -07:00
RuoJi6 bffa2b55a8 Merge pull request #89 from RuoJi6/codex/fix-repeater-request-undo
修复 Repeater 请求编辑器撤销,并显示审批调用 Agent
2026-10-29 18:21:50 -07:00
ji ruo 21f68a6a60 feat: event-driven credential processing conversations 2026-10-30 09:18:31 +08:00
ji ruo 524f8bc632 fix: 隐藏筛选与请求标签滚动条 2026-10-30 09:11:05 +08:00
ji ruo 255e11aca6 feat(web): show agent source in approval records 2026-10-30 09:01:57 +08:00
ji ruo 6ad055d24f fix(web): enable repeater request undo 2026-10-30 02:12:36 +08:00
RuoJi6 571b6116e0 fix: place resource status on dashboard header
* fix: place resource status on dashboard header

* fix: show resource metrics as progress bars

* fix: preview unsupported resource layout

* fix: disable non-Linux resource preview
2026-10-29 10:59:32 -07:00
23 changed files with 1498 additions and 396 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.
var IntelligenceEnabled func() bool
var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error)
var CredentialRecorded func()
func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() }
func intelligenceSchema() map[string]any {
+17 -16
View File
@@ -39,22 +39,23 @@ type CredentialTraffic struct {
Accounts []int `json:"accounts"`
}
type CredentialAnalysis struct {
ForceExtract bool `json:"force_extract,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"`
State string `json:"state"`
Lease string `json:"lease,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
SourceHash string `json:"source_hash,omitempty"`
Method string `json:"method,omitempty"`
Accounts []CredentialAccount `json:"accounts"`
Traffic []CredentialTraffic `json:"traffic"`
TrafficState string `json:"traffic_state"`
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
Model string `json:"model,omitempty"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
ConversationID int64 `json:"conversation_id,omitempty"`
ForceExtract bool `json:"force_extract,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"`
State string `json:"state"`
Lease string `json:"lease,omitempty"`
LeaseUntil time.Time `json:"lease_until,omitempty"`
SourceHash string `json:"source_hash,omitempty"`
Method string `json:"method,omitempty"`
Accounts []CredentialAccount `json:"accounts"`
Traffic []CredentialTraffic `json:"traffic"`
TrafficState string `json:"traffic_state"`
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
Model string `json:"model,omitempty"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
}
func CredentialSourceHash(content string) string {
+62 -18
View File
@@ -23,20 +23,21 @@ type DBFinding struct {
ReportEvidenceVersion int64
TrafficBindings []FindingTrafficBinding // populated only for export
ID int64
TaskID *int64
NodeID *int64
VulnClass string
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
Severity string
Summary string
Evidence string
Worker string
AssetIDs []int64
Status string
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
ID int64
TaskID *int64
NodeID *int64
VulnClass string
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
Severity string
Summary string
Evidence string
Worker string
AssetIDs []int64
Status string
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
CreatedAt time.Time
TaskDescription string // populated via LEFT JOIN on tasks
}
// 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
// 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,
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,
(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.
func scanFindings(rows interface {
@@ -661,13 +662,14 @@ func (d *DB) FindingStats() (*FindingStats, error) {
func (d *DB) GetFinding(id int64) (*DBFinding, error) {
f := &DBFinding{}
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
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.id = $1`, id).Scan(
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
&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 {
return nil, nil
}
@@ -675,9 +677,51 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
return nil, err
}
_ = 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
}
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
// originating exploration node (kind='finding'), so it disappears from the findings
// 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',
-- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。
report TEXT NOT NULL DEFAULT '',
report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL,
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 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_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_time ON findings(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 {
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
}
// 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
// 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) {
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 {
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)
s.queueMu.Lock()
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.queueMu.Unlock()
}
+25 -1
View File
@@ -2,6 +2,7 @@ package server
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
@@ -41,13 +42,16 @@ func (s *Server) seedCredentialProcessor() {
_ = s.m.pg.SetBool(flag, true)
}
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()
for {
select {
case <-s.ctx.Done():
return
case <-ticker.C:
case <-s.credentialWake:
}
if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) {
continue
@@ -93,6 +97,14 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
}
}()
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)
result.State = "ready"
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 {
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 credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`)
+19 -17
View File
@@ -325,15 +325,16 @@ type FindingDTO struct {
ReportStale bool `json:"report_stale"`
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"`
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
Severity string `json:"severity"` // critical | high | medium | low
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
Summary string `json:"summary"`
Evidence string `json:"evidence"`
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
ID string `json:"id"`
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
VulnClass string `json:"vulnclass"`
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
Severity string `json:"severity"` // critical | high | medium | low
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
Summary string `json:"summary"`
Evidence string `json:"evidence"`
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
IntentID string `json:"intent_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),
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
VulnClass: f.VulnClass,
Name: f.Name,
Severity: f.Severity,
Status: status,
Summary: f.Summary,
Evidence: f.Evidence,
Report: f.Report,
TS: rfc3339(f.CreatedAt),
VulnClass: f.VulnClass,
Name: f.Name,
Severity: f.Severity,
Status: status,
Summary: f.Summary,
Evidence: f.Evidence,
Report: f.Report,
ReportConversationID: f.ReportConversationID,
TS: rfc3339(f.CreatedAt),
}
if f.ExportCaseID > 0 {
d.ID = "case:" + i64s(f.ExportCaseID)
+12 -2
View File
@@ -58,20 +58,30 @@ func (s *Server) generateFindingReport(w http.ResponseWriter, r *http.Request) {
}
}
message := manualFindingReportMessage(f, taskID)
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",
fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID),
message,
taskID,
false,
taskDesc,
taskGoal,
taskGoal, conversation.ID,
)
writeJSON(w, http.StatusAccepted, map[string]any{
"queued": true,
"agent_key": "reporter",
"finding_id": f.ID,
"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/db"
"github.com/Autumn-27/artex/intercept"
"github.com/Autumn-27/norma/permission"
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 {
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)
if err != nil {
+11 -4
View File
@@ -125,9 +125,10 @@ type Server struct {
// Cold task archives run through one persistent FIFO worker. The buffered wake
// channel coalesces enqueue bursts; the database remains the source of truth.
archiveWake chan struct{}
archiveWG sync.WaitGroup
side *sideQuestionState
archiveWake chan struct{}
archiveWG sync.WaitGroup
credentialWake chan struct{}
side *sideQuestionState
}
// 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{},
profChatAgents: map[int64]*agent.ChatAgent{},
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()
if m.pg != 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()
s.startTaskArchiveWorker()
if m.pg != nil {
agent.CredentialRecorded = func() {
select {
case s.credentialWake <- struct{}{}:
default:
}
}
s.seedCredentialProcessor()
go s.runCredentialProcessor()
}
+62
View File
@@ -2,6 +2,7 @@ package server
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
@@ -309,6 +310,67 @@ func (s *Server) findSessionPreview(rawPath string) (string, bool) {
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).
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
@@ -6,11 +6,9 @@ import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { CpuIcon, HardDriveIcon, MemoryStickIcon } from "lucide-react";
import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { SystemMetrics } from "@/lib/types";
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
import { LiveClock } from "./live-clock";
@@ -78,7 +76,6 @@ function MainContentInner({ children }: { children: ReactNode }) {
</div>
<div className="min-w-0 flex-1">
<EgressIpNavbar />
<SystemResourceNavbar />
</div>
<div className="flex shrink-0 items-center gap-2">
<LiveClock />
@@ -98,42 +95,3 @@ function MainContentInner({ children }: { children: ReactNode }) {
</>
);
}
function SystemResourceNavbar() {
const [metrics, setMetrics] = useState<SystemMetrics | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const next = await api.systemMetrics();
if (alive) setMetrics(next);
} catch {
/* Keep the status bar quiet while the API is unavailable. */
}
};
void load();
const timer = setInterval(load, 5000);
return () => { alive = false; clearInterval(timer); };
}, []);
if (!metrics) return null;
if (!metrics.supported) {
return <div className="flex items-center justify-center text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
}
const items = [
[CpuIcon, "CPU", metrics.cpu.percent],
[MemoryStickIcon, "内存", metrics.memory.percent],
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
] as const;
return (
<div className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground" role="status" aria-live="polite">
<span>容器资源</span>
{items.map(([Icon, label, value]) => (
<span key={label} className="inline-flex items-center gap-1 tabular-nums" title={`${label} ${value.toFixed(1)}%`}>
<Icon className="size-3" aria-hidden="true" /> {label} {value.toFixed(1)}%
</span>
))}
</div>
);
}
+52
View File
@@ -9,6 +9,9 @@ import {
ArrowUpRightIcon,
BugIcon,
ClockIcon,
CpuIcon,
HardDriveIcon,
MemoryStickIcon,
NetworkIcon,
ShieldCheckIcon,
TargetIcon,
@@ -38,6 +41,7 @@ import type {
Tool,
TrafficExchange,
UsageStats,
SystemMetrics,
} from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -101,6 +105,35 @@ function statusBg(code: number): string {
return "bg-red-400";
}
function SystemResourceStatus({ metrics }: { metrics: SystemMetrics | null }) {
if (!metrics) return null;
if (!metrics.supported) {
return <div className="ml-auto text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
}
const items = [
[CpuIcon, "CPU", metrics.cpu.percent],
[MemoryStickIcon, "内存", metrics.memory.percent],
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
] as const;
return (
<div className="ml-auto flex min-w-0 items-center justify-end gap-3 text-xs text-muted-foreground" role="status" aria-live="polite">
<span className="shrink-0 font-medium">容器资源</span>
{items.map(([Icon, label, value]) => (
<div key={label} className="w-24 min-w-0" title={`${label} ${value.toFixed(1)}%`}>
<div className="mb-1 flex items-center justify-between gap-1 text-[10px]">
<span className="inline-flex items-center gap-1"><Icon className="size-3" aria-hidden="true" />{label}</span>
<span className="tabular-nums">{value.toFixed(1)}%</span>
</div>
<Progress
value={value}
className="h-2 bg-muted [&_[data-slot=progress-indicator]]:bg-slate-500 dark:[&_[data-slot=progress-indicator]]:bg-slate-400"
/>
</div>
))}
</div>
);
}
// ── sub-components ────────────────────────────────────────────────────────────
function LiveDot({ className }: { className?: string }) {
@@ -147,6 +180,22 @@ export default function DashboardPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
const [tools, setTools] = React.useState<Tool[]>([]);
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
const [systemMetrics, setSystemMetrics] = React.useState<SystemMetrics | null>(null);
React.useEffect(() => {
let alive = true;
const load = async () => {
try {
const next = await api.systemMetrics();
if (alive) setSystemMetrics(next);
} catch {
/* retry on the next interval */
}
};
void load();
const timer = setInterval(load, 5000);
return () => { alive = false; clearInterval(timer); };
}, []);
// The task list is the most expensive dashboard source. Poll it independently
// so a large history cannot hold back every other dashboard panel.
@@ -495,10 +544,13 @@ export default function DashboardPage() {
<div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */}
<div>
<div className="flex items-center gap-6">
<div>
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
</div>
<SystemResourceStatus metrics={systemMetrics} />
</div>
</div>
{/* ── Row 1: 5 stat cards ── */}
@@ -72,6 +72,7 @@ function FindingDetailInner() {
const [tab, setTab] = React.useState("overview");
const [reportGenerating, setReportGenerating] = React.useState(false);
const [autoChatOpen, setAutoChatOpen] = React.useState(false);
const [reportChatOpen, setReportChatOpen] = React.useState(false);
const title = finding?.name || finding?.vulnclass || "未分类";
const load = React.useCallback(() => {
@@ -190,6 +191,12 @@ function FindingDetailInner() {
<BotIcon data-icon="inline-start" />
询问 Auto Agent
</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 && (
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
)}
@@ -410,6 +417,17 @@ function FindingDetailInner() {
open={autoChatOpen}
onOpenChange={setAutoChatOpen}
/>
<FindingAutoChat
findingId={finding.id}
findingTitle={title}
agentKey="reporter"
agentLabel="报告撰写 Agent"
promptPlaceholder="描述要如何调整漏洞报告…"
modal
initialConversationId={finding.report_conversation_id}
open={reportChatOpen}
onOpenChange={setReportChatOpen}
/>
</>
);
}
@@ -923,7 +923,7 @@ export default function FindingsPage() {
})}
</div>
<div className="flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1">
<div className="scrollbar-none flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1">
<InputGroup className="w-72 shrink-0">
<InputGroupInput
type="search"
File diff suppressed because it is too large. Load diff
+25 -3
View File
@@ -65,6 +65,28 @@ function originLabel(row: InterceptApprovalRow) {
return "—";
}
// Background task runs carry a pool label such as "work#2 · #2770", while
// conversation-backed runs carry the agent key (for example "reporter").
// Present a stable, human-readable agent label in both cases.
function agentLabel(row: InterceptApprovalRow) {
const key = (row.conv_agent_key || "").trim().toLowerCase();
const labels: Record<string, string> = {
auto: "自动 Agent",
mainagent: "主 Agent",
planner: "规划 Agent",
worker: "执行 Worker",
reporter: "报告编写",
retester: "复测 Agent",
pentest: "渗透测试 Agent",
};
if (key && labels[key]) return labels[key];
const raw = (row.agent_name || "").trim();
if (/^work#\d+(?:\s|$)/i.test(raw)) return "执行 Worker";
const rawKey = raw.toLowerCase();
if (labels[rawKey]) return labels[rawKey];
return raw || "未知 Agent";
}
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
const [locating, setLocating] = React.useState(false);
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
@@ -454,7 +476,7 @@ export function ApprovalDetail({
<SideQuestionButton side={side} />
</div>
<CodeBlock
label={`${current.agent_name || current.conv_agent_key || "Agent"} · 工具请求`}
label={`${agentLabel(current)} · 工具请求`}
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
/>
{command ? (
@@ -726,8 +748,8 @@ function ApprovalTable({
<span className="truncate" title={originLabel(row)}>
<ApprovalOrigin row={row} />
</span>
<span className="truncate text-muted-foreground text-xs">
{row.agent_name || row.conv_agent_key}
<span className="truncate text-muted-foreground text-xs" title={agentLabel(row)}>
Agent:{agentLabel(row)}
</span>
</div>
</TableCell>
+50 -13
View File
@@ -22,11 +22,21 @@ function mergeActivities(oldItems: Activity[], incoming: Activity[]) {
export function FindingAutoChat({
findingId,
findingTitle,
agentKey = "auto",
agentLabel = "Auto Agent",
promptPlaceholder = "询问这个漏洞…",
modal = false,
initialConversationId,
open,
onOpenChange,
}: {
findingId: string;
findingTitle: string;
agentKey?: string;
agentLabel?: string;
promptPlaceholder?: string;
modal?: boolean;
initialConversationId?: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -41,8 +51,9 @@ export function FindingAutoChat({
const [historyOpen, setHistoryOpen] = React.useState(false);
const [history, setHistory] = React.useState<Conversation[]>([]);
const cursor = React.useRef(0);
const chatViewportRef = React.useRef<HTMLDivElement | null>(null);
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 () => {
setManualEmpty(false);
@@ -50,7 +61,7 @@ export function FindingAutoChat({
setRunning(false);
setLoading(true);
try {
const created = await api.createConversation("auto", `漏洞 #${findingId} · ${safeTitle}`);
const created = await api.createConversation(agentKey, `漏洞 #${findingId} · ${safeTitle}`);
window.localStorage.setItem(storageKey, String(created.id));
setConversation(created);
} catch (error) {
@@ -58,15 +69,19 @@ export function FindingAutoChat({
} finally {
setLoading(false);
}
}, [findingId, safeTitle, storageKey]);
}, [agentKey, findingId, safeTitle, storageKey]);
React.useEffect(() => {
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) {
setConversation({
id: storedID,
agent_key: "auto",
agent_key: agentKey,
title: `漏洞 #${findingId} · ${safeTitle}`,
created_at: "",
updated_at: "",
@@ -74,7 +89,7 @@ export function FindingAutoChat({
return;
}
void createConversation();
}, [conversation, createConversation, manualEmpty, open, safeTitle, storageKey]);
}, [conversation, createConversation, initialConversationId, manualEmpty, open, safeTitle, storageKey]);
React.useEffect(() => {
if (!historyOpen && !running) return;
@@ -85,7 +100,7 @@ export function FindingAutoChat({
const items = await api.conversations();
if (!live) return;
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 {
if (live && historyOpen) setHistory([]);
} finally {
@@ -97,7 +112,7 @@ export function FindingAutoChat({
live = false;
clearTimeout(timer);
};
}, [findingId, historyOpen, running]);
}, [agentKey, findingId, historyOpen, running]);
React.useEffect(() => {
if (!open || !conversation) return;
@@ -122,6 +137,18 @@ export function FindingAutoChat({
};
}, [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(() => {
if (!open || !conversation || !running) return;
let live = true;
@@ -203,13 +230,17 @@ export function FindingAutoChat({
if (!open) return null;
return (
<div className="pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6">
<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">
<div className={modal
? "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">
<BotIcon className="size-4 text-primary" />
<div className="min-w-0 flex-1">
<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>}
</p>
<p className="truncate text-muted-foreground text-xs">漏洞 #{findingId} · {safeTitle}</p>
@@ -253,7 +284,13 @@ export function FindingAutoChat({
<XIcon />
</Button>
</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">
{loading ? (
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm">
@@ -274,7 +311,7 @@ export function FindingAutoChat({
inputGroup
onValueChange={setInput}
disabled={!conversation || loading || sending || running}
placeholder="询问这个漏洞…"
placeholder={promptPlaceholder}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
+29 -20
View File
@@ -2,14 +2,8 @@
import * as React from "react";
import { EditorState } from "@codemirror/state";
import {
drawSelection,
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
} from "@codemirror/view";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { drawSelection, EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
@@ -37,17 +31,31 @@ const httpLanguage = StreamLanguage.define({
},
});
const packetTheme = EditorView.theme({
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
".cm-scroller": { overflow: "auto", fontFamily: "var(--font-mono)", fontSize: "0.75rem", fontWeight: "500", lineHeight: "1.65rem" },
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
".cm-gutters": { backgroundColor: "transparent", color: "var(--muted-foreground)", border: "0", borderRight: "1px solid var(--border)" },
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent" },
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
".cm-cursor": { borderLeftColor: "#222" },
}, { dark: false });
const packetTheme = EditorView.theme(
{
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
".cm-scroller": {
overflow: "auto",
fontFamily: "var(--font-mono)",
fontSize: "0.75rem",
fontWeight: "500",
lineHeight: "1.65rem",
},
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
".cm-gutters": {
backgroundColor: "transparent",
color: "var(--muted-foreground)",
border: "0",
borderRight: "1px solid var(--border)",
},
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
".cm-activeLine": { backgroundColor: "transparent" },
".cm-activeLineGutter": { backgroundColor: "transparent" },
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
".cm-cursor": { borderLeftColor: "#222" },
},
{ dark: false },
);
export function PacketCodeEditor({
value,
@@ -76,7 +84,8 @@ export function PacketCodeEditor({
lineNumbers(),
drawSelection(),
highlightActiveLine(),
keymap.of([...defaultKeymap, indentWithTab]),
history(),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
httpLanguage,
syntaxHighlighting(defaultHighlightStyle),
syntaxHighlighting(packetHighlight),
@@ -112,7 +112,7 @@ export function RepeaterWorkspace() {
</div>
</div>
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<div className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{workspace.tabs.map((tab) => {
const selected = tab.id === workspace.activeTabId;
return (
+1 -1
View File
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
+1
View File
@@ -529,6 +529,7 @@ export interface Finding {
summary: string;
evidence: string;
report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空
report_conversation_id?: number;
intent_id?: string;
param_id?: string;
task_id?: string;