Compare commits

..
Author SHA1 Message Date
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
10 changed files with 1249 additions and 287 deletions

No files matched your search

+1
View File
@@ -12,6 +12,7 @@ import (
// Missing host/setting means off. Rechecked on every write for running sessions. // Missing host/setting means off. Rechecked on every write for running sessions.
var IntelligenceEnabled func() bool var IntelligenceEnabled func() bool
var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error) var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error)
var CredentialRecorded func()
func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() } func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() }
func intelligenceSchema() map[string]any { func intelligenceSchema() map[string]any {
+17 -16
View File
@@ -39,22 +39,23 @@ type CredentialTraffic struct {
Accounts []int `json:"accounts"` Accounts []int `json:"accounts"`
} }
type CredentialAnalysis struct { type CredentialAnalysis struct {
ForceExtract bool `json:"force_extract,omitempty"` ConversationID int64 `json:"conversation_id,omitempty"`
TrafficNote string `json:"traffic_note,omitempty"` ForceExtract bool `json:"force_extract,omitempty"`
State string `json:"state"` TrafficNote string `json:"traffic_note,omitempty"`
Lease string `json:"lease,omitempty"` State string `json:"state"`
LeaseUntil time.Time `json:"lease_until,omitempty"` Lease string `json:"lease,omitempty"`
SourceHash string `json:"source_hash,omitempty"` LeaseUntil time.Time `json:"lease_until,omitempty"`
Method string `json:"method,omitempty"` SourceHash string `json:"source_hash,omitempty"`
Accounts []CredentialAccount `json:"accounts"` Method string `json:"method,omitempty"`
Traffic []CredentialTraffic `json:"traffic"` Accounts []CredentialAccount `json:"accounts"`
TrafficState string `json:"traffic_state"` Traffic []CredentialTraffic `json:"traffic"`
Note string `json:"note,omitempty"` TrafficState string `json:"traffic_state"`
Error string `json:"error,omitempty"` Note string `json:"note,omitempty"`
Model string `json:"model,omitempty"` Error string `json:"error,omitempty"`
InputTokens int `json:"input_tokens"` Model string `json:"model,omitempty"`
OutputTokens int `json:"output_tokens"` InputTokens int `json:"input_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"` OutputTokens int `json:"output_tokens"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
} }
func CredentialSourceHash(content string) string { func CredentialSourceHash(content string) string {
+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).
+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`)
+5
View File
@@ -474,6 +474,11 @@ 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
}
}
} }
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 {
+11 -4
View File
@@ -125,9 +125,10 @@ type Server struct {
// Cold task archives run through one persistent FIFO worker. The buffered wake // Cold task archives run through one persistent FIFO worker. The buffered wake
// channel coalesces enqueue bursts; the database remains the source of truth. // channel coalesces enqueue bursts; the database remains the source of truth.
archiveWake chan struct{} archiveWake chan struct{}
archiveWG sync.WaitGroup archiveWG sync.WaitGroup
side *sideQuestionState credentialWake chan struct{}
side *sideQuestionState
} }
// provEntry is a cached provider + its config for one LLM profile id. // provEntry is a cached provider + its config for one LLM profile id.
@@ -161,7 +162,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{}, triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
profChatAgents: map[int64]*agent.ChatAgent{}, profChatAgents: map[int64]*agent.ChatAgent{},
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg), provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)} taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1), credentialWake: make(chan struct{}, 1)}
s.initSideQuestions() s.initSideQuestions()
if m.pg != nil { if m.pg != nil {
if err := s.basicAuth.load(m.pg); err != nil { if err := s.basicAuth.load(m.pg); err != nil {
@@ -282,6 +283,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
go s.reconcileConcurrency() go s.reconcileConcurrency()
s.startTaskArchiveWorker() s.startTaskArchiveWorker()
if m.pg != nil { if m.pg != nil {
agent.CredentialRecorded = func() {
select {
case s.credentialWake <- struct{}{}:
default:
}
}
s.seedCredentialProcessor() s.seedCredentialProcessor()
go s.runCredentialProcessor() go s.runCredentialProcessor()
} }
+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,16 @@ function FindingDetailInner() {
open={autoChatOpen} open={autoChatOpen}
onOpenChange={setAutoChatOpen} onOpenChange={setAutoChatOpen}
/> />
<FindingAutoChat
findingId={finding.id}
findingTitle={title}
agentKey="reporter"
agentLabel="报告撰写 Agent"
promptPlaceholder="描述要如何调整漏洞报告…"
modal
open={reportChatOpen}
onOpenChange={setReportChatOpen}
/>
</> </>
); );
} }
File diff suppressed because it is too large. Load diff
+22 -10
View File
@@ -22,11 +22,19 @@ function mergeActivities(oldItems: Activity[], incoming: Activity[]) {
export function FindingAutoChat({ export function FindingAutoChat({
findingId, findingId,
findingTitle, findingTitle,
agentKey = "auto",
agentLabel = "Auto Agent",
promptPlaceholder = "询问这个漏洞…",
modal = false,
open, open,
onOpenChange, onOpenChange,
}: { }: {
findingId: string; findingId: string;
findingTitle: string; findingTitle: string;
agentKey?: string;
agentLabel?: string;
promptPlaceholder?: string;
modal?: boolean;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
@@ -42,7 +50,7 @@ export function FindingAutoChat({
const [history, setHistory] = React.useState<Conversation[]>([]); const [history, setHistory] = React.useState<Conversation[]>([]);
const cursor = React.useRef(0); const cursor = React.useRef(0);
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 +58,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,7 +66,7 @@ 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;
@@ -66,7 +74,7 @@ export function FindingAutoChat({
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: "",
@@ -85,7 +93,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 +105,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;
@@ -203,13 +211,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>
@@ -274,7 +286,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();