Merge pull request #91 from RuoJi6/codex/task-list-column-settings

feat: add task list column visibility settings
This commit is contained in:
RuoJi6 authored and GitHub committed 2026-10-29 21:20:28 -07:00
commit a55189a649
5 files changed
+1188 -266

No files matched your search

+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 {
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 {
+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"))
@@ -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,16 @@ function FindingDetailInner() {
open={autoChatOpen}
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({
findingId,
findingTitle,
agentKey = "auto",
agentLabel = "Auto Agent",
promptPlaceholder = "询问这个漏洞…",
modal = false,
open,
onOpenChange,
}: {
findingId: string;
findingTitle: string;
agentKey?: string;
agentLabel?: string;
promptPlaceholder?: string;
modal?: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
@@ -42,7 +50,7 @@ export function FindingAutoChat({
const [history, setHistory] = React.useState<Conversation[]>([]);
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 +58,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 +66,7 @@ export function FindingAutoChat({
} finally {
setLoading(false);
}
}, [findingId, safeTitle, storageKey]);
}, [agentKey, findingId, safeTitle, storageKey]);
React.useEffect(() => {
if (!open || conversation || manualEmpty) return;
@@ -66,7 +74,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 +93,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 +105,7 @@ export function FindingAutoChat({
live = false;
clearTimeout(timer);
};
}, [findingId, historyOpen, running]);
}, [agentKey, findingId, historyOpen, running]);
React.useEffect(() => {
if (!open || !conversation) return;
@@ -203,13 +211,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>
@@ -274,7 +286,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();