mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-29 09:33:50 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b72683aa | ||
|
|
69eab0c033 | ||
|
|
8f9d981bdc |
No files matched your search
@@ -90,20 +90,11 @@ const ReporterDefaultPrompt = `你是一个授权渗透测试系统里的**漏
|
||||
4. **写报告**:综合以上,写一份结构化 Markdown 报告(见下方模板)。
|
||||
5. **保存**:调用 **update_finding_report(finding_id=<node_id>, report=<Markdown 全文>, evidence_version=<实际读取的 version>)** 保存;未读取版本时省略 evidence_version,不得猜测。这是你的最终产物——不写进去等于没做。
|
||||
|
||||
━━ 报告步骤截图(仅在系统设置开启时)━━
|
||||
- 当系统设置开启报告步骤截图时,你会自动获得 browser MCP;此时对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser_navigate 后调用 browser_take_screenshot。
|
||||
- 只访问漏洞证据中出现的 URL,不扩大测试范围。把截图保存到当前工作目录的 findings/screenshots/ 下,命名 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片链接:。
|
||||
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
|
||||
|
||||
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
|
||||
- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。
|
||||
- ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。
|
||||
- ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。
|
||||
- ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。
|
||||
- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。
|
||||
- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。
|
||||
- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。
|
||||
- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。
|
||||
- ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。
|
||||
- ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。
|
||||
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
|
||||
|
||||
@@ -96,13 +96,6 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
continue
|
||||
}
|
||||
for _, t := range ts {
|
||||
// Playwright exposes the screenshot capability as a separate tool. Keep
|
||||
// it opt-in so a normal browser MCP session does not unexpectedly send
|
||||
// image payloads to the model or consume extra tokens.
|
||||
if m.Name == browserMCPName && isBrowserScreenshotTool(t.Name()) &&
|
||||
(agentKey != "reporter" || !pg.GetBool(settingBrowserScreenshot, false)) {
|
||||
continue
|
||||
}
|
||||
extra = append(extra, t)
|
||||
allNames = append(allNames, t.Name())
|
||||
serverTools[m.Name] = append(serverTools[m.Name], t.Name())
|
||||
@@ -187,11 +180,6 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
|
||||
}
|
||||
}
|
||||
|
||||
func isBrowserScreenshotTool(name string) bool {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
return strings.Contains(name, "screenshot") || strings.Contains(name, "take_screenshot")
|
||||
}
|
||||
|
||||
func idSet(ids []int64) map[int64]bool {
|
||||
m := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
|
||||
+4
-5
@@ -264,7 +264,6 @@ type Manager struct {
|
||||
// Settings keys the UI toggles at runtime.
|
||||
const (
|
||||
settingTrafficCapture = "traffic_capture"
|
||||
settingBrowserScreenshot = "browser_screenshot_enabled"
|
||||
settingAgentTrafficBinding = "agent_traffic_binding"
|
||||
settingWebSearchOn = "web_search_enabled"
|
||||
settingWebSearchBackend = "web_search_backend"
|
||||
@@ -274,11 +273,11 @@ const (
|
||||
// settingGlobalProxy is the global egress proxy for all target traffic
|
||||
// (http/https/socks5). Empty = direct. Distinct from web_search_proxy (which
|
||||
// only routes the search backend) and the per-profile LLM proxy.
|
||||
settingGlobalProxy = "global_proxy"
|
||||
settingGlobalProxy = "global_proxy"
|
||||
settingShowEgressIP = "show_egress_ip"
|
||||
settingWorkers = "workers"
|
||||
settingWorkerPools = "worker_pools"
|
||||
settingLLMRecord = "llm_record"
|
||||
settingWorkers = "workers"
|
||||
settingWorkerPools = "worker_pools"
|
||||
settingLLMRecord = "llm_record"
|
||||
// LLM 轮询(故障转移)。默认关闭——开启后走「全局激活配置」的 agent 在当前配置
|
||||
// 不可用(余额不足/key 失效/限流/服务异常)时自动切到下一个配置。
|
||||
// settingLLMPoolBindFallback 仅在轮询开启时有意义:默认关闭,即 agent/任务显式
|
||||
|
||||
+20
-61
@@ -199,12 +199,6 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
if err != nil || t == "" {
|
||||
return "", false
|
||||
}
|
||||
if key == "reporter" && m.pg.GetBool(settingBrowserScreenshot, false) {
|
||||
t += `
|
||||
|
||||
【报告步骤截图已开启】
|
||||
报告撰写时,针对每个复现步骤中真实出现且仍可访问的 HTTP 页面,使用 browser MCP 的 browser_navigate 和 browser_take_screenshot 截取页面证据。只导航到漏洞证据中出现的目标 URL,不要自行扩大测试范围。将截图保存到当前工作目录下的 findings/screenshots/ 目录,文件名使用 finding-<finding_id>-step-<n>.png;确认文件存在后,在对应步骤中加入 Markdown 图片:。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
|
||||
@@ -778,7 +772,6 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/workspace/mkdir", s.wsMkdir)
|
||||
mux.HandleFunc("DELETE /api/workspace/delete", s.wsDelete)
|
||||
mux.HandleFunc("GET /api/workspace/download", s.wsDownload)
|
||||
mux.HandleFunc("GET /api/workspace/preview", s.wsPreview)
|
||||
mux.HandleFunc("POST /api/workspace/upload", s.wsUpload)
|
||||
mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList)
|
||||
mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd)
|
||||
@@ -3449,27 +3442,26 @@ func (s *Server) settingsPayload() map[string]any {
|
||||
concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI
|
||||
}
|
||||
return map[string]any{
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"browser_screenshot_enabled": s.m.pg.GetBool(settingBrowserScreenshot, false),
|
||||
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
|
||||
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
|
||||
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
|
||||
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
|
||||
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
|
||||
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
|
||||
"llm_record": s.m.LLMRecordEnabled(),
|
||||
"web_search_enabled": on,
|
||||
"web_search_backend": backend,
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
|
||||
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
||||
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
|
||||
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
|
||||
"traffic_capture": s.m.TrafficEnabled(),
|
||||
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
|
||||
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
|
||||
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
|
||||
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
|
||||
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
|
||||
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
|
||||
"llm_record": s.m.LLMRecordEnabled(),
|
||||
"web_search_enabled": on,
|
||||
"web_search_backend": backend,
|
||||
"brave_key_set": strings.TrimSpace(braveKey) != "",
|
||||
"tavily_key_set": strings.TrimSpace(tavilyKey) != "",
|
||||
"web_search_proxy": proxy, // 独立出口代理(http/https/socks5),空=直连
|
||||
"global_proxy": s.m.GlobalProxy(), // 全局出口代理(http/https/socks5),所有目标流量走它,空=直连
|
||||
"show_egress_ip": s.m.pg.GetBool(settingShowEgressIP, false),
|
||||
"python_interpreter": strings.TrimSpace(pyStored), // 用户/自动设的值(空=用运行时检测)
|
||||
"workers": s.m.Workers(), // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
"worker_pools": s.m.WorkerPools(), // 非空时按池顺序绑定 worker 会话
|
||||
"task_concurrency_enabled": concOn, // 任务并发上限开关(默认关)
|
||||
"task_concurrency_limit": concLimit, // 同时运行任务上限(开启后默认5)
|
||||
// LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时
|
||||
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
|
||||
"llm_pool_enabled": s.m.LLMPoolEnabled(),
|
||||
@@ -3528,7 +3520,6 @@ func (s *Server) pgDetectPython(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TrafficCapture *bool `json:"traffic_capture"`
|
||||
BrowserScreenshot *bool `json:"browser_screenshot_enabled"`
|
||||
AgentTrafficBinding *bool `json:"agent_traffic_binding"`
|
||||
TaskIntelligence *bool `json:"task_intelligence_enabled"`
|
||||
TaskAutoTitle *bool `json:"task_auto_title_enabled"`
|
||||
@@ -3724,16 +3715,6 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.BrowserScreenshot != nil {
|
||||
if err := s.setReporterBrowserScreenshotVisibility(*req.BrowserScreenshot); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetBool(settingBrowserScreenshot, *req.BrowserScreenshot); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.TrafficCapture != nil {
|
||||
if err := s.m.SetTrafficEnabled(*req.TrafficCapture); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
@@ -3785,28 +3766,6 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, s.settingsPayload())
|
||||
}
|
||||
|
||||
// setReporterBrowserScreenshotVisibility keeps the report writer's browser MCP
|
||||
// permission in sync with the screenshot setting. Other agents retain their
|
||||
// explicit MCP permissions.
|
||||
func (s *Server) setReporterBrowserScreenshotVisibility(enabled bool) error {
|
||||
reporter, err := s.m.pg.GetAgentByKey("reporter")
|
||||
if err != nil || reporter == nil {
|
||||
return err
|
||||
}
|
||||
servers, err := s.m.pg.ListMCP()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, server := range servers {
|
||||
if server.Name == browserMCPName {
|
||||
return s.m.pg.ToggleVisibility(reporter.ID, "mcp", server.ID, enabled)
|
||||
}
|
||||
}
|
||||
// The built-in browser MCP may be deleted by an administrator. Keep the
|
||||
// setting writable so it takes effect automatically if the server returns.
|
||||
return nil
|
||||
}
|
||||
|
||||
// testWebSearch runs a real "test" search with the given (or currently saved)
|
||||
// backend/proxy/key to verify the config can actually reach a search backend —
|
||||
// mirroring testLLM. Backend/proxy come from the request (so the form's unsaved
|
||||
|
||||
@@ -225,38 +225,6 @@ func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, abs)
|
||||
}
|
||||
|
||||
// GET /api/workspace/preview?path=<rel> — serve an image inline for report
|
||||
// Markdown. Unlike download, this uses an inline content disposition so a
|
||||
// report can render a saved browser screenshot with a normal Markdown image URL.
|
||||
func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) {
|
||||
rawPath := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||
abs, ok := s.wsResolve(rawPath)
|
||||
if filepath.IsAbs(rawPath) {
|
||||
base := filepath.Clean(s.m.dir)
|
||||
candidate := filepath.Clean(rawPath)
|
||||
ok = candidate == base || strings.HasPrefix(candidate, base+string(os.PathSeparator))
|
||||
if ok {
|
||||
abs = candidate
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
writeErr(w, 400, "非法路径")
|
||||
return
|
||||
}
|
||||
fi, err := os.Stat(abs)
|
||||
if err != nil || fi.IsDir() {
|
||||
writeErr(w, 404, "文件不存在")
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(abs))
|
||||
if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" && ext != ".gif" {
|
||||
writeErr(w, 415, "仅支持图片预览")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(filepath.Base(abs))+"\"")
|
||||
http.ServeFile(w, r, abs)
|
||||
}
|
||||
|
||||
// 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"))
|
||||
|
||||
@@ -75,12 +75,6 @@ export interface FindingEdit {
|
||||
|
||||
export type FindingReport = { status: "loading" | "done" | "error"; text: string };
|
||||
|
||||
function isStructuredReproductionReport(report: string) {
|
||||
return (
|
||||
/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report)
|
||||
);
|
||||
}
|
||||
|
||||
// Exploration node ids are only unique inside a task. Prefer the persisted
|
||||
// finding id and otherwise namespace the node id by task so editing one group
|
||||
// cannot update a similarly-named node in another expanded group.
|
||||
@@ -452,8 +446,8 @@ export function FindingsTable({
|
||||
{f.evidence}
|
||||
</pre>
|
||||
|
||||
{/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */}
|
||||
{f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
|
||||
@@ -9,8 +9,8 @@ import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingAgentChatDrawer } from "@/components/finding-agent-chat-drawer";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingReproductionPanel } from "@/components/finding-reproduction-panel";
|
||||
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
@@ -45,12 +45,6 @@ function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
function usesStructuredReproduction(report: string) {
|
||||
return (
|
||||
/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im.test(report) && /^\s*(?:###\s*)?步骤\s*\d+[.、::))]/im.test(report)
|
||||
);
|
||||
}
|
||||
|
||||
// FieldRow is one label/value line in the right-hand status panel.
|
||||
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -135,7 +129,6 @@ function FindingDetailInner() {
|
||||
}
|
||||
|
||||
const title = finding.name || finding.vulnclass || "未分类";
|
||||
const structuredReproduction = usesStructuredReproduction(finding.report || "");
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
@@ -168,6 +161,7 @@ function FindingDetailInner() {
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
<FindingAgentChatDrawer finding={finding} />
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">概览</TabsTrigger>
|
||||
@@ -190,65 +184,47 @@ function FindingDetailInner() {
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{structuredReproduction ? (
|
||||
<>
|
||||
<FindingReproductionPanel finding={finding} contextTask={contextTaskId || undefined} />
|
||||
<FindingRetestPanel
|
||||
key={`retest:${id}`}
|
||||
findingId={id}
|
||||
readOnly={finding.inherited}
|
||||
onCompleted={load}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.evidence ? (
|
||||
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">(无证据)</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={`traffic:${id}`}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={load}
|
||||
/>
|
||||
<FindingRetestPanel
|
||||
key={`retest:${id}`}
|
||||
findingId={id}
|
||||
readOnly={finding.inherited}
|
||||
onCompleted={load}
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report_stale ? (
|
||||
<Alert>
|
||||
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{finding.report ? (
|
||||
<Markdown text={finding.report} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
<FindingRetestPanel key={`retest:${id}`} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.evidence ? (
|
||||
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">(无证据)</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={`traffic:${id}`}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={load}
|
||||
/>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report_stale ? (
|
||||
<Alert>
|
||||
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{finding.report ? (
|
||||
<Markdown text={finding.report} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右栏:状态区 */}
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
CameraIcon,
|
||||
CpuIcon,
|
||||
FlaskConicalIcon,
|
||||
KeyboardIcon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,7 +29,6 @@ export default function SystemSettingsPage() {
|
||||
const [chatAutoTitleEnabled, setChatAutoTitleEnabled] = React.useState(false);
|
||||
const [savingIntelligence, setSavingIntelligence] = React.useState(false);
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
const [browserScreenshot, setBrowserScreenshot] = React.useState(false);
|
||||
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
|
||||
const [webSearch, setWebSearch] = React.useState(false);
|
||||
const [backend, setBackend] = React.useState("ddgs");
|
||||
@@ -95,7 +86,6 @@ export default function SystemSettingsPage() {
|
||||
setTaskAutoTitleEnabled(!!s.task_auto_title_enabled);
|
||||
setChatAutoTitleEnabled(!!s.chat_auto_title_enabled);
|
||||
setTrafficCapture(!!s.traffic_capture);
|
||||
setBrowserScreenshot(!!s.browser_screenshot_enabled);
|
||||
setAgentTrafficBinding(!!s.agent_traffic_binding);
|
||||
setWebSearch(!!s.web_search_enabled);
|
||||
setBackend(s.web_search_backend || "ddgs");
|
||||
@@ -223,22 +213,6 @@ export default function SystemSettingsPage() {
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleBrowserScreenshot = (v: boolean) => {
|
||||
setBrowserScreenshot(v);
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ browser_screenshot_enabled: v })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(v ? "已开启浏览器截图工具" : "已关闭浏览器截图工具");
|
||||
})
|
||||
.catch((e) => {
|
||||
setBrowserScreenshot(!v);
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleInjectPlanner = (v: boolean) => {
|
||||
setInjectPlanner(v); // optimistic
|
||||
api
|
||||
@@ -288,7 +262,9 @@ export default function SystemSettingsPage() {
|
||||
const toggleAutoTitle = (key: "task_auto_title_enabled" | "chat_auto_title_enabled", value: boolean) => {
|
||||
const setter = key === "task_auto_title_enabled" ? setTaskAutoTitleEnabled : setChatAutoTitleEnabled;
|
||||
const patch: Partial<Settings> =
|
||||
key === "task_auto_title_enabled" ? { task_auto_title_enabled: value } : { chat_auto_title_enabled: value };
|
||||
key === "task_auto_title_enabled"
|
||||
? { task_auto_title_enabled: value }
|
||||
: { chat_auto_title_enabled: value };
|
||||
setter(value);
|
||||
api
|
||||
.setSettings(patch)
|
||||
@@ -521,31 +497,6 @@ export default function SystemSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CameraIcon className="size-4" />
|
||||
浏览器截图
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
开启后,报告撰写 Agent 会自动获得 browser
|
||||
MCP,并在复现步骤对应的页面上保存截图,报告中直接显示对应步骤截图。 截图会增加 Token
|
||||
消耗;关闭后不会自动给报告撰写 Agent 授权截图能力,已有历史报告保留原截图。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="browser-screenshot" className="text-sm font-normal text-muted-foreground">
|
||||
{browserScreenshot ? "已开启 · 报告撰写 Agent 自动截图" : "已关闭 · 报告不自动截图"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="browser-screenshot"
|
||||
checked={browserScreenshot}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={toggleBrowserScreenshot}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -559,8 +510,9 @@ export default function SystemSettingsPage() {
|
||||
开启<b>流量捕获</b>时,它作为记录代理的<b>上游</b>(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入
|
||||
Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
|
||||
<br />
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl 可用,python
|
||||
urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持——curl
|
||||
可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM
|
||||
亲自拨号,工具无感知、稳定生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
@@ -585,7 +537,12 @@ export default function SystemSettingsPage() {
|
||||
保存
|
||||
</Button>
|
||||
{/* 测表单里当前的值,不要求先保存。 */}
|
||||
<Button type="button" variant="outline" onClick={testEgress} disabled={!loaded || egressTesting}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testEgress}
|
||||
disabled={!loaded || egressTesting}
|
||||
>
|
||||
{egressTesting ? "测试中…" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -610,13 +567,15 @@ export default function SystemSettingsPage() {
|
||||
</div>
|
||||
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */}
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换 <code>ifconfig.me</code>
|
||||
),它返回"这个请求从哪个 IP 来"。 ① 是不走代理时的 IP,② 是经全局代理出网时的
|
||||
IP。改完不用先保存,直接测。
|
||||
点<b>测试</b>会访问 IP 回显服务(默认 <code>api.ipify.org</code>,失败自动换{" "}
|
||||
<code>ifconfig.me</code>),它返回"这个请求从哪个 IP 来"。
|
||||
① 是不走代理时的 IP,② 是经全局代理出网时的 IP。改完不用先保存,直接测。
|
||||
</p>
|
||||
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
|
||||
<details className="text-xs">
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">自定义 IP 探测服务(可选)</summary>
|
||||
<summary className="text-muted-foreground cursor-pointer select-none">
|
||||
自定义 IP 探测服务(可选)
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
@@ -628,9 +587,10 @@ export default function SystemSettingsPage() {
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground leading-relaxed">
|
||||
自定义 IP 探测服务需返回纯文本 IP 或 <code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口, 则可替换为内网 IP 探测服务,此时 ① 和 ②
|
||||
的 IP 不同,就说明流量走了内网代理。
|
||||
自定义 IP 探测服务需返回纯文本 IP 或{" "}
|
||||
<code>{'{"ip":"1.2.3.4"}'}</code> 格式的 JSON。
|
||||
若内网环境下无法访问公网,或本机和内网代理共用同一个公网出口,
|
||||
则可替换为内网 IP 探测服务,此时 ① 和 ② 的 IP 不同,就说明流量走了内网代理。
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
@@ -643,7 +603,11 @@ export default function SystemSettingsPage() {
|
||||
<div key={c.key} className="flex flex-wrap items-center gap-x-2">
|
||||
<span
|
||||
className={
|
||||
c.skipped ? "text-muted-foreground" : c.result.ok ? "text-emerald-600" : "text-destructive"
|
||||
c.skipped
|
||||
? "text-muted-foreground"
|
||||
: c.result.ok
|
||||
? "text-emerald-600"
|
||||
: "text-destructive"
|
||||
}
|
||||
>
|
||||
{c.skipped ? "—" : c.result.ok ? "✓" : "✗"}
|
||||
@@ -665,12 +629,18 @@ export default function SystemSettingsPage() {
|
||||
<span className="text-muted-foreground">{c.skip_reason}</span>
|
||||
) : c.result.ok ? (
|
||||
<span className="font-mono">
|
||||
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">{c.result.exit_ip}</span> ·{" "}
|
||||
<span title="外界看到的出口 IP(探测站点回显的客户端地址)">
|
||||
{c.result.exit_ip}
|
||||
</span>{" "}
|
||||
·{" "}
|
||||
<span title="从 ARTEX 发出请求到拿到响应的完整往返耗时;经代理时包含代理那一跳">
|
||||
{c.result.latency_ms}ms
|
||||
</span>
|
||||
{c.result.via && c.result.via !== "direct" && !viaCapture && (
|
||||
<span className="text-muted-foreground"> · 经 {c.result.via_label ?? c.result.via}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· 经 {c.result.via_label ?? c.result.via}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
@@ -763,9 +733,7 @@ export default function SystemSettingsPage() {
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>自动标题</CardTitle>
|
||||
<CardDescription>
|
||||
为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。
|
||||
</CardDescription>
|
||||
<CardDescription>为 Agent 创建的任务和对话首条消息生成简短标题。两个开关独立生效,默认关闭。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Field orientation="horizontal">
|
||||
@@ -783,9 +751,7 @@ export default function SystemSettingsPage() {
|
||||
<Field orientation="horizontal" className="mt-5">
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor="chat-auto-title">会话标题</FieldLabel>
|
||||
<FieldDescription>
|
||||
对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。
|
||||
</FieldDescription>
|
||||
<FieldDescription>对话首条消息后自动生成会话标题;先显示消息截断文本,失败时保留截断标题。</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id="chat-auto-title"
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpIcon, Loader2Icon, MessageCircleQuestionIcon, SquareIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Transcript } from "@/components/transcript";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api, openSSE } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { Activity, Finding } from "@/lib/types";
|
||||
|
||||
import {
|
||||
buildFindingAgentContextMessage,
|
||||
findingAgentUnavailableReason,
|
||||
findingDisplayTitle,
|
||||
} from "./finding-agent-chat";
|
||||
|
||||
const MAIN_HISTORY_LIMIT = 200;
|
||||
|
||||
type FindingAgentChatDrawerProps = {
|
||||
finding: Finding;
|
||||
};
|
||||
|
||||
function mainActivity(activity: Activity, segment: number): boolean {
|
||||
if (activity.worker !== "mainagent") return false;
|
||||
return activity.main_seg == null || activity.main_seg === segment;
|
||||
}
|
||||
|
||||
function mergeActivities(current: Activity[], incoming: Activity[], segment: number): Activity[] {
|
||||
const bySeq = new Map<number, Activity>();
|
||||
for (const item of current) bySeq.set(item.seq, item);
|
||||
for (const item of incoming) {
|
||||
if (mainActivity(item, segment)) bySeq.set(item.seq, item);
|
||||
}
|
||||
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
||||
}
|
||||
|
||||
function LoadingActivity() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
正在加载主 Agent 对话…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyActivity({ available }: { available: boolean }) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{available ? "还没有对话。发送问题后,主 Agent 的回复会显示在这里。" : "暂无可显示的活动。"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) {
|
||||
const taskId = finding.task_id ?? "";
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [activities, setActivities] = React.useState<Activity[]>([]);
|
||||
const [input, setInput] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [stopping, setStopping] = React.useState(false);
|
||||
const sendMode = useChatSendMode();
|
||||
const [taskAvailable, setTaskAvailable] = React.useState<boolean | null>(null);
|
||||
const [unavailableReason, setUnavailableReason] = React.useState("");
|
||||
const eventSourceRef = React.useRef<EventSource | null>(null);
|
||||
const snapshotRef = React.useRef(0);
|
||||
const aliveRef = React.useRef(false);
|
||||
const requestGenerationRef = React.useRef(0);
|
||||
const sentContextRef = React.useRef(false);
|
||||
const currentSegmentRef = React.useRef(0);
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const atBottomRef = React.useRef(true);
|
||||
const findingIdRef = React.useRef(finding.id);
|
||||
findingIdRef.current = finding.id;
|
||||
|
||||
const findingId = finding.id;
|
||||
const inherited = Boolean(finding.inherited);
|
||||
const canAsk = Boolean(taskId && !inherited);
|
||||
|
||||
const closeStream = React.useCallback(() => {
|
||||
eventSourceRef.current?.close();
|
||||
eventSourceRef.current = null;
|
||||
}, []);
|
||||
|
||||
const refreshStatus = React.useCallback(
|
||||
(generation = requestGenerationRef.current) => {
|
||||
if (!taskId || !canAsk) return Promise.resolve();
|
||||
return api
|
||||
.chatStatus(taskId)
|
||||
.then(({ running: nextRunning }) => {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation) setRunning(nextRunning);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[canAsk, taskId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const generation = ++requestGenerationRef.current;
|
||||
const isCurrent = () =>
|
||||
aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId;
|
||||
|
||||
closeStream();
|
||||
aliveRef.current = open;
|
||||
if (!open) return;
|
||||
|
||||
sentContextRef.current = false;
|
||||
setInput("");
|
||||
setActivities([]);
|
||||
setTaskAvailable(null);
|
||||
setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited }));
|
||||
setRunning(false);
|
||||
setLoading(false);
|
||||
setSending(false);
|
||||
setStopping(false);
|
||||
snapshotRef.current = 0;
|
||||
currentSegmentRef.current = 0;
|
||||
atBottomRef.current = true;
|
||||
|
||||
if (!canAsk) {
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
closeStream();
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (MOCK) {
|
||||
api
|
||||
.activity(taskId)
|
||||
.then(({ items, cursor }) => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(true);
|
||||
setActivities(mergeActivities([], items, currentSegmentRef.current));
|
||||
snapshotRef.current = cursor;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
api
|
||||
.task(taskId)
|
||||
.then(() => api.mainSessions(taskId))
|
||||
.then((sessions) => {
|
||||
if (!isCurrent()) return null;
|
||||
const currentSegment = sessions.current ?? 0;
|
||||
currentSegmentRef.current = currentSegment;
|
||||
return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({
|
||||
currentSegment,
|
||||
history,
|
||||
}));
|
||||
})
|
||||
.then((result) => {
|
||||
if (!result || !isCurrent()) return;
|
||||
const { currentSegment, history } = result;
|
||||
setTaskAvailable(true);
|
||||
setActivities((current) => mergeActivities(current, history.items, currentSegment));
|
||||
snapshotRef.current = history.snapshotCursor;
|
||||
|
||||
closeStream();
|
||||
const stream = openSSE(
|
||||
`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`,
|
||||
);
|
||||
if (!isCurrent()) {
|
||||
stream.close();
|
||||
return;
|
||||
}
|
||||
eventSourceRef.current = stream;
|
||||
stream.onmessage = (event) => {
|
||||
if (!isCurrent()) return;
|
||||
try {
|
||||
const activity = JSON.parse(event.data) as Activity;
|
||||
setActivities((current) => mergeActivities(current, [activity], currentSegment));
|
||||
} catch {
|
||||
// Ignore malformed frames; the next history refresh still repairs the view.
|
||||
}
|
||||
};
|
||||
stream.onerror = () => undefined; // EventSource reconnects automatically.
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCurrent()) return;
|
||||
setTaskAvailable(false);
|
||||
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
|
||||
})
|
||||
.finally(() => {
|
||||
if (isCurrent()) setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
void refreshStatus(generation);
|
||||
const statusTimer = window.setInterval(() => void refreshStatus(generation), 2000);
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
window.clearInterval(statusTimer);
|
||||
closeStream();
|
||||
};
|
||||
}, [canAsk, closeStream, findingId, inherited, open, refreshStatus, taskId]);
|
||||
|
||||
const viewport = React.useCallback(
|
||||
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (!vp) return;
|
||||
const onScroll = () => {
|
||||
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
|
||||
};
|
||||
vp.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => vp.removeEventListener("scroll", onScroll);
|
||||
}, [open, viewport]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
const vp = viewport();
|
||||
if (vp) {
|
||||
vp.scrollTop = vp.scrollHeight;
|
||||
atBottomRef.current = true;
|
||||
}
|
||||
}, [open, viewport]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
}, [activities, open, running, viewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || typeof ResizeObserver === "undefined") return;
|
||||
const el = contentRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
const vp = viewport();
|
||||
if (vp) vp.scrollTop = vp.scrollHeight;
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [open, viewport]);
|
||||
|
||||
const send = React.useCallback(async () => {
|
||||
const message = input.trim();
|
||||
if (!message || sending || running || !taskId || taskAvailable !== true || !canAsk) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setSending(true);
|
||||
const payload = sentContextRef.current
|
||||
? message
|
||||
: `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`;
|
||||
try {
|
||||
const result = await api.chat(payload, taskId);
|
||||
if (!aliveRef.current || requestGenerationRef.current !== generation || findingIdRef.current !== findingId)
|
||||
return;
|
||||
if (MOCK) {
|
||||
const ts = new Date().toISOString();
|
||||
const reply = result.reply?.trim() || "(demo)主 Agent 暂无回复。";
|
||||
setActivities((current) =>
|
||||
mergeActivities(
|
||||
current,
|
||||
[
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 1,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "user",
|
||||
summary: message,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
{
|
||||
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 2,
|
||||
worker: "mainagent",
|
||||
ts,
|
||||
kind: "text",
|
||||
summary: reply,
|
||||
main_seg: currentSegmentRef.current,
|
||||
},
|
||||
],
|
||||
currentSegmentRef.current,
|
||||
),
|
||||
);
|
||||
}
|
||||
sentContextRef.current = true;
|
||||
setInput("");
|
||||
if (!MOCK) void refreshStatus(generation);
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [canAsk, finding, findingId, input, refreshStatus, running, sending, taskAvailable, taskId]);
|
||||
|
||||
const stop = React.useCallback(async () => {
|
||||
if (!taskId || stopping) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.stopChat(taskId);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
await refreshStatus(generation);
|
||||
}
|
||||
} catch (error) {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
}, [findingId, refreshStatus, stopping, taskId]);
|
||||
|
||||
const title = findingDisplayTitle(finding);
|
||||
const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim());
|
||||
let footerStatus = "Enter 发送,Shift+Enter 换行。";
|
||||
if (sending) footerStatus = "正在发送…";
|
||||
else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)} aria-label="问 Agent">
|
||||
<MessageCircleQuestionIcon />问 Agent
|
||||
</Button>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
|
||||
<SheetHeader className="border-b pr-12">
|
||||
<SheetTitle className="truncate">问 Agent · {title}</SheetTitle>
|
||||
<SheetDescription className="flex min-w-0 items-center gap-1">
|
||||
所属任务:
|
||||
{taskId ? (
|
||||
<Link className="truncate text-primary hover:underline" href={`/function/tasks/detail?id=${taskId}`}>
|
||||
{finding.task_description ?? `#${taskId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<span>无</span>
|
||||
)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{unavailableReason ? (
|
||||
<Alert variant="destructive" className="m-4 mb-0">
|
||||
<AlertDescription>{unavailableReason}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div ref={contentRef} className="min-h-full p-4">
|
||||
{loading ? <LoadingActivity /> : null}
|
||||
{!loading && activities.length ? (
|
||||
<Transcript
|
||||
activity={activities}
|
||||
live={running}
|
||||
taskId={taskId}
|
||||
chat
|
||||
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail)}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !activities.length ? <EmptyActivity available={taskAvailable === true} /> : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="border-t bg-background p-3">
|
||||
<div className="flex w-full items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (!shouldSubmitOnKey(event, sendMode)) return;
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
disabled={!canAsk || taskAvailable !== true || sending || running}
|
||||
placeholder={running ? "主 Agent 正在运行…" : "描述复现现象或继续提问…"}
|
||||
aria-label="向漏洞所属任务的主 Agent 提问"
|
||||
className="max-h-32 min-h-10 resize-none"
|
||||
/>
|
||||
{running ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => void stop()}
|
||||
disabled={stopping}
|
||||
title="停止当前执行"
|
||||
aria-label="停止当前执行"
|
||||
>
|
||||
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
onClick={() => void send()}
|
||||
disabled={!canSend}
|
||||
title="发送消息"
|
||||
aria-label="发送消息"
|
||||
>
|
||||
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="w-full text-[11px] text-muted-foreground">{footerStatus}</p>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mentionToken } from "../lib/chat-mentions";
|
||||
import type { Finding } from "../lib/types";
|
||||
|
||||
type FindingContextFields = Finding & {
|
||||
url?: string;
|
||||
endpoint?: string;
|
||||
entry_point?: string;
|
||||
};
|
||||
|
||||
export function canAskFindingAgent(finding: Pick<Finding, "task_id" | "inherited"> | null | undefined): boolean {
|
||||
return Boolean(finding?.task_id && !finding.inherited);
|
||||
}
|
||||
|
||||
export function findingDisplayTitle(finding: Pick<Finding, "id" | "name" | "vulnclass">): string {
|
||||
if (finding.name?.trim()) return finding.name;
|
||||
if (finding.vulnclass?.trim()) return finding.vulnclass;
|
||||
return `漏洞 #${finding.id}`;
|
||||
}
|
||||
|
||||
export function findingAgentUnavailableReason(
|
||||
finding: Pick<Finding, "task_id" | "inherited"> | null | undefined,
|
||||
): string {
|
||||
if (!finding?.task_id) return "无所属任务,无法连接到主 Agent。";
|
||||
if (finding.inherited) return "继承漏洞只读,请到源任务提问。";
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(value: string, max: number): string {
|
||||
const compact = value.replace(/\s+/g, " ").trim();
|
||||
return compact.length > max ? `${compact.slice(0, max)}…` : compact;
|
||||
}
|
||||
|
||||
/**
|
||||
* The finding mention is included once, in the first turn, so later user turns
|
||||
* stay short while the task's main Agent still has an unambiguous finding anchor.
|
||||
*/
|
||||
export function buildFindingAgentContextMessage(finding: Finding): string {
|
||||
const extended = finding as FindingContextFields;
|
||||
const numericId = Number(finding.id);
|
||||
const mention = mentionToken({
|
||||
kind: "finding",
|
||||
id: Number.isFinite(numericId) ? numericId : 0,
|
||||
label: truncate(findingDisplayTitle(finding), 100),
|
||||
description: truncate(finding.summary || "", 200),
|
||||
});
|
||||
const lines = [
|
||||
"用户正在漏洞详情页复现并提问。",
|
||||
mention,
|
||||
`严重等级:${finding.severity};漏洞类型:${finding.vulnclass || "未分类"}。`,
|
||||
];
|
||||
const summary = truncate(finding.summary || "", 300);
|
||||
if (summary) lines.push(`摘要:${summary}`);
|
||||
const entryPoint = extended.url || extended.entry_point || extended.endpoint;
|
||||
if (entryPoint) lines.push(`入口点:${truncate(entryPoint, 240)}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, LoaderCircleIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { headersFromReqHead, requestFromEvidence } from "@/components/repeater/repeater-http";
|
||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingTrafficBinding, FindingTrafficDetail } from "@/lib/types";
|
||||
|
||||
type ReproductionStep = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function extractSection(report: string) {
|
||||
const match = report.match(/^##\s+(?:复现步骤|测试过程|复现过程)\s*$/im);
|
||||
if (!match || match.index === undefined) return "";
|
||||
const start = match.index + match[0].length;
|
||||
const tail = report.slice(start);
|
||||
const end = tail.search(/^##\s+/m);
|
||||
return (end >= 0 ? tail.slice(0, end) : tail).trim();
|
||||
}
|
||||
|
||||
function withoutPacket(text: string) {
|
||||
return text.replace(/```(?:http|bash|shell|sh|json|text)?\s*\n[\s\S]*?```/gi, "").trim();
|
||||
}
|
||||
|
||||
function parseSteps(report: string, fallback: string): ReproductionStep[] {
|
||||
const section = extractSection(report);
|
||||
if (!section) {
|
||||
return [
|
||||
{
|
||||
number: 1,
|
||||
title: "执行验证并确认结果",
|
||||
body: fallback.trim() || "暂无结构化复现步骤,查看下方原始报告。",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const markers = [...section.matchAll(/^\s*(?:###\s*)?(?:步骤\s*)?(\d+)[.、::))]\s*(.+)$/gim)];
|
||||
if (markers.length === 0) {
|
||||
return [{ number: 1, title: "复现过程", body: withoutPacket(section) }];
|
||||
}
|
||||
|
||||
return markers.map((marker, index) => {
|
||||
const start = marker.index ?? 0;
|
||||
const end = markers[index + 1]?.index ?? section.length;
|
||||
const raw = section.slice(start, end).trim();
|
||||
const firstLine = marker[2].trim();
|
||||
const rest = raw.slice(marker[0].length).trim();
|
||||
return {
|
||||
number: Number(marker[1]),
|
||||
title: firstLine,
|
||||
body: withoutPacket(rest),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function reportSection(report: string, headings: string[]) {
|
||||
const headingPattern = headings.join("|");
|
||||
const match = report.match(new RegExp(`^##\\s+(?:${headingPattern})\\s*$`, "im"));
|
||||
if (!match || match.index === undefined) return "";
|
||||
const tail = report.slice(match.index + match[0].length);
|
||||
const end = tail.search(/^##\s+/m);
|
||||
return (end >= 0 ? tail.slice(0, end) : tail).trim();
|
||||
}
|
||||
|
||||
function packetText(detail: FindingTrafficDetail, side: "request" | "response") {
|
||||
const head = side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head;
|
||||
const body = detail[side].content;
|
||||
return [head, body].filter(Boolean).join("\n\n").trim();
|
||||
}
|
||||
|
||||
function PacketDisclosure({
|
||||
findingId,
|
||||
bindingId,
|
||||
contextTask,
|
||||
}: {
|
||||
findingId: string;
|
||||
bindingId?: string;
|
||||
contextTask?: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [repeaterBusy, setRepeaterBusy] = React.useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !bindingId || detail) return;
|
||||
let active = true;
|
||||
setError("");
|
||||
api
|
||||
.findingTrafficDetail(findingId, bindingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) setDetail(value);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [bindingId, contextTask, detail, findingId, open]);
|
||||
|
||||
if (!bindingId) {
|
||||
return <p className="mt-3 border-t pt-3 text-xs text-muted-foreground">此步骤暂无已绑定的真实流量证据。</p>;
|
||||
}
|
||||
|
||||
async function sendToRepeater() {
|
||||
if (!detail) return;
|
||||
setRepeaterBusy(true);
|
||||
try {
|
||||
if (detail.request.binary) toast.info("请求体为二进制,已留空;完整正文请先下载");
|
||||
else if (detail.request.truncated) toast.info("请求体为预览片段,完整正文请先下载");
|
||||
seedFromEvidence(
|
||||
requestFromEvidence(
|
||||
{
|
||||
method: detail.binding.snapshot.method || "GET",
|
||||
url: detail.binding.snapshot.url || "",
|
||||
headersText: headersFromReqHead(detail.binding.snapshot.req_head),
|
||||
body: detail.request.binary ? "" : detail.request.content || "",
|
||||
},
|
||||
detail.binding.snapshot.req_head,
|
||||
),
|
||||
{ findingId, bindingId: detail.binding.id },
|
||||
);
|
||||
router.push("/function/repeater");
|
||||
} catch (e) {
|
||||
toast.error(`送入请求重发失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
setRepeaterBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
{open ? "收起数据包" : "查看数据包"}
|
||||
</Button>
|
||||
{open && detail ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={repeaterBusy}
|
||||
onClick={() => void sendToRepeater()}
|
||||
>
|
||||
{repeaterBusy ? "准备中…" : "送入请求重发"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 p-3">
|
||||
{error ? <p className="text-destructive text-xs">数据包加载失败:{error}</p> : null}
|
||||
{!error && bindingId && !detail ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-xs">
|
||||
<LoaderCircleIcon className="size-3.5 animate-spin" /> 正在读取已保存的真实流量…
|
||||
</div>
|
||||
) : null}
|
||||
{detail ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="secondary">{detail.binding.snapshot.method}</Badge>
|
||||
<Badge variant="outline">{detail.binding.snapshot.status}</Badge>
|
||||
<span className="break-all text-muted-foreground">{detail.binding.snapshot.url}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] text-muted-foreground">请求 Request</p>
|
||||
<HttpCodeBlock raw={packetText(detail, "request")} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] text-muted-foreground">响应 Response</p>
|
||||
<HttpCodeBlock raw={packetText(detail, "response")} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingReproductionPanel({ finding, contextTask }: { finding: Finding; contextTask?: string }) {
|
||||
const steps = React.useMemo(
|
||||
() => parseSteps(finding.report ?? "", finding.evidence || finding.summary),
|
||||
[finding.evidence, finding.report, finding.summary],
|
||||
);
|
||||
const [traffic, setTraffic] = React.useState<FindingTrafficBinding[] | undefined>(undefined);
|
||||
const overview = reportSection(finding.report ?? "", ["概述", "影响与危害", "受影响范围"]);
|
||||
const outcome = reportSection(finding.report ?? "", ["最终结果"]);
|
||||
const rootCause = reportSection(finding.report ?? "", ["根因分析"]);
|
||||
const remediation = reportSection(finding.report ?? "", ["修复建议"]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
api
|
||||
.findingTraffic(finding.id, contextTask)
|
||||
.then((value) => {
|
||||
if (active) setTraffic(value.bindings);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setTraffic([]);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [contextTask, finding.id]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>复现步骤</CardTitle>
|
||||
<CardDescription>步骤同时展示,数据包默认隐藏;需要核对原始请求时再按步骤展开。</CardDescription>
|
||||
</div>
|
||||
<Badge variant={steps.length > 0 ? "secondary" : "outline"}>
|
||||
{steps.length} 个步骤 · {traffic?.length ?? finding.traffic_count ?? 0} 个数据包
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{overview ? (
|
||||
<div className="rounded-lg bg-muted/30 px-3 py-2.5 text-sm">
|
||||
<Markdown text={overview} />
|
||||
</div>
|
||||
) : null}
|
||||
{steps.map((step, index) => {
|
||||
const binding = traffic?.find((item) => item.position === index) ?? traffic?.[index];
|
||||
return (
|
||||
<div key={`${step.number}-${step.title}`} className="relative rounded-lg border bg-background p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="outline" className="shrink-0 font-medium">
|
||||
步骤 {step.number}
|
||||
</Badge>
|
||||
<h3 className="min-w-0 break-words font-semibold text-sm leading-5">{step.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{step.body ? <Markdown text={step.body} /> : null}
|
||||
<PacketDisclosure findingId={finding.id} bindingId={binding?.id} contextTask={contextTask} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{outcome || rootCause || remediation ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
{outcome ? <Markdown text={`## 最终结果\n\n${outcome}`} /> : null}
|
||||
{rootCause ? <Markdown text={`## 根因分析\n\n${rootCause}`} /> : null}
|
||||
{remediation ? <Markdown text={`## 修复建议\n\n${remediation}`} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -18,21 +18,6 @@ const components: Components = {
|
||||
a: ({ node, ...p }) => (
|
||||
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...p} />
|
||||
),
|
||||
img: ({ node, src, alt, ...p }) => {
|
||||
const resolvedSrc =
|
||||
typeof src === "string" && src.startsWith("workspace://")
|
||||
? `/api/workspace/preview?path=${encodeURIComponent(src.slice("workspace://".length))}`
|
||||
: src;
|
||||
return (
|
||||
<img
|
||||
className="my-2 max-h-[520px] max-w-full rounded-md border object-contain"
|
||||
src={resolvedSrc}
|
||||
alt={alt || "报告截图"}
|
||||
loading="lazy"
|
||||
{...p}
|
||||
/>
|
||||
);
|
||||
},
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
blockquote: ({ node, ...p }) => (
|
||||
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
|
||||
@@ -57,7 +42,9 @@ const components: Components = {
|
||||
<table className="w-full border-collapse text-xs" {...p} />
|
||||
</div>
|
||||
),
|
||||
th: ({ node, ...p }) => <th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />,
|
||||
th: ({ node, ...p }) => (
|
||||
<th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />
|
||||
),
|
||||
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { createRequire } = require("node:module");
|
||||
const test = require("node:test");
|
||||
|
||||
// Keep the tests runnable on Node 20, which does not load TypeScript natively.
|
||||
const requireFromTest = createRequire(__filename);
|
||||
requireFromTest("ts-node").register({
|
||||
transpileOnly: true,
|
||||
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
|
||||
});
|
||||
|
||||
const {
|
||||
buildFindingAgentContextMessage,
|
||||
canAskFindingAgent,
|
||||
findingAgentUnavailableReason,
|
||||
} = requireFromTest("../components/finding-agent-chat.ts");
|
||||
|
||||
const finding = (overrides = {}) => ({
|
||||
id: "42",
|
||||
vulnclass: "SQL 注入",
|
||||
name: "搜索参数注入",
|
||||
severity: "high",
|
||||
status: "confirmed",
|
||||
summary: "搜索参数可控制数据库查询。",
|
||||
evidence: "large evidence omitted",
|
||||
ts: "2026-10-27T00:00:00Z",
|
||||
task_id: "task-7",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("finding without task cannot ask its Agent", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ task_id: undefined })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ task_id: undefined })), /无所属任务/);
|
||||
});
|
||||
|
||||
test("task-owned finding can ask and context contains a finding mention", () => {
|
||||
assert.equal(canAskFindingAgent(finding()), true);
|
||||
const context = buildFindingAgentContextMessage(finding());
|
||||
assert.match(context, /用户正在漏洞详情页复现并提问/);
|
||||
assert.match(context, /@\[漏洞#42 搜索参数注入\]/);
|
||||
assert.match(context, /SQL 注入/);
|
||||
});
|
||||
|
||||
test("inherited finding is read-only", () => {
|
||||
assert.equal(canAskFindingAgent(finding({ inherited: true, source_task_id: "source-1" })), false);
|
||||
assert.match(findingAgentUnavailableReason(finding({ inherited: true })), /继承漏洞只读/);
|
||||
});
|
||||
@@ -970,8 +970,6 @@ export interface TrafficHost {
|
||||
// ---- App settings (runtime toggles) ----
|
||||
export interface Settings {
|
||||
traffic_capture: boolean;
|
||||
/** 是否向可见 Agent 暴露浏览器 MCP 的截图工具。默认关闭。 */
|
||||
browser_screenshot_enabled?: boolean;
|
||||
credential_agent_enabled: boolean;
|
||||
credential_traffic_binding: boolean;
|
||||
task_intelligence_enabled: boolean; // 默认关闭;关闭不注入情报工具或提示
|
||||
@@ -993,7 +991,7 @@ export interface Settings {
|
||||
// 全局出口代理(http/https/socks5,可带 user:pass),所有目标流量走它。开启流量捕获时作为
|
||||
// MITM 上游;关闭捕获时直接注入 agent 的 bash/WebFetch。空=直连。
|
||||
global_proxy?: string;
|
||||
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||
show_egress_ip?: boolean; // 在顶栏显示直连 / 全局代理出口 IP;默认关闭
|
||||
python_interpreter?: string; // 自定义脚本工具的 python 解释器路径(空=运行时检测)
|
||||
workers?: number; // 并发工作 agent 数(默认3);对之后启动的任务生效
|
||||
worker_pools: WorkerPool[];
|
||||
|
||||
Reference in new issue
Block a user