diff --git a/server/orchestration.go b/server/orchestration.go
index e7515c4..9f62f7e 100644
--- a/server/orchestration.go
+++ b/server/orchestration.go
@@ -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 {
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)
if err != nil {
diff --git a/server/workspace.go b/server/workspace.go
index a0d1162..4e2b445 100644
--- a/server/workspace.go
+++ b/server/workspace.go
@@ -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=
— 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"))
diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx
index c74fc09..a9334e8 100644
--- a/web/src/app/(main)/function/findings/detail/page.tsx
+++ b/web/src/app/(main)/function/findings/detail/page.tsx
@@ -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() {
询问 Auto Agent
+ {!finding.inherited ? (
+
+ ) : null}
{finding.inherited && finding.source_task_id && (
来源任务 #{finding.source_task_id} · 只读
)}
@@ -410,6 +417,15 @@ function FindingDetailInner() {
open={autoChatOpen}
onOpenChange={setAutoChatOpen}
/>
+
>
);
}
diff --git a/web/src/components/finding-auto-chat.tsx b/web/src/components/finding-auto-chat.tsx
index 1c01c81..6d70788 100644
--- a/web/src/components/finding-auto-chat.tsx
+++ b/web/src/components/finding-auto-chat.tsx
@@ -22,11 +22,17 @@ function mergeActivities(oldItems: Activity[], incoming: Activity[]) {
export function FindingAutoChat({
findingId,
findingTitle,
+ agentKey = "auto",
+ agentLabel = "Auto Agent",
+ promptPlaceholder = "询问这个漏洞…",
open,
onOpenChange,
}: {
findingId: string;
findingTitle: string;
+ agentKey?: string;
+ agentLabel?: string;
+ promptPlaceholder?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -42,7 +48,7 @@ export function FindingAutoChat({
const [history, setHistory] = React.useState([]);
const cursor = React.useRef(0);
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 +56,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,7 +64,7 @@ export function FindingAutoChat({
} finally {
setLoading(false);
}
- }, [findingId, safeTitle, storageKey]);
+ }, [agentKey, findingId, safeTitle, storageKey]);
React.useEffect(() => {
if (!open || conversation || manualEmpty) return;
@@ -66,7 +72,7 @@ export function FindingAutoChat({
if (Number.isSafeInteger(storedID) && storedID > 0) {
setConversation({
id: storedID,
- agent_key: "auto",
+ agent_key: agentKey,
title: `漏洞 #${findingId} · ${safeTitle}`,
created_at: "",
updated_at: "",
@@ -85,7 +91,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 +103,7 @@ export function FindingAutoChat({
live = false;
clearTimeout(timer);
};
- }, [findingId, historyOpen, running]);
+ }, [agentKey, findingId, historyOpen, running]);
React.useEffect(() => {
if (!open || !conversation) return;
@@ -209,7 +215,7 @@ export function FindingAutoChat({
- Auto Agent
+ {agentLabel}
{running && 运行中}
漏洞 #{findingId} · {safeTitle}
@@ -274,7 +280,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();