Compare commits

..
Author SHA1 Message Date
ji ruo e8572a18d2 feat: add structured finding reproduction screenshots 2026-10-28 18:08:12 +08:00
18 changed files with 653 additions and 486 deletions

No files matched your search

+9
View File
@@ -90,11 +90,20 @@ 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 图片链接:![步骤 <n> 截图](workspace://<截图绝对路径>)。
- 截图失败时如实说明;系统设置关闭或 browser MCP 不可用时,不要伪造图片链接,也不要把文字描述冒充截图。
━━ 报告结构(Markdown,按需裁剪,但证据/复现/修复必须有)━━
- ` + "`## 概述`" + `:一句话说清是什么漏洞、在哪、能造成什么。
- ` + "`## 影响与危害`" + `:结合业务讲清最坏后果(数据泄露/接管/RCE/横向…),给出**严重等级**判断及理由。
- ` + "`## 受影响范围`" + `:受影响的资产/接口/参数/版本。
- ` + "`## 复现步骤`" + `:**可照做复现**的分步操作(请求/命令/参数),能贴 PoC 就贴。
- 复现步骤必须使用明确的编号标题:` + "`### 步骤 1:准备环境`" + `、` + "`### 步骤 2:构造请求`" + `……;每一步都要写清操作、输入、预期结果、实际结果和证据来源。不要只写“步骤一”或把多步合并成一段。
- 每个步骤只描述本次漏洞真实执行过的动作;有真实 HTTP 流量时,在对应步骤的代码块中保留完整请求或响应,并说明它是请求、响应还是命令输出。没有证据的内容标记为“待补充/未验证”。
- 数据包只能来自 get_finding_traffic 返回的、已经绑定到本漏洞的真实流量;报告中自行写出的代码块不能冒充绑定数据包。没有绑定流量时明确写“暂无已绑定流量证据”,不要编造请求/响应。
- 最新报告将概述、影响、复现步骤、最终结果、根因分析和修复建议合并在同一份报告中;不要再额外生成一份脱离步骤的“详细报告”。
- ` + "`## 证据`" + `:证明漏洞真实存在的关键请求/响应片段、命令输出、回显、截图说明——用代码块贴原文。
- ` + "`## PoC`" + `:可直接运行/复用的利用代码或 payload(利用脚本、请求报文、命令行、payload 串),**通常以代码块给出完整代码**,并简述如何运行;无独立利用代码时说明"复现步骤即为 PoC"。
- ` + "`## 根因分析`" + `:为什么会有这个漏洞(缺校验/危险函数/配置错误…)。
+12
View File
@@ -96,6 +96,13 @@ 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())
@@ -180,6 +187,11 @@ 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 {
+5 -4
View File
@@ -264,6 +264,7 @@ 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"
@@ -273,11 +274,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/任务显式
+61 -20
View File
@@ -199,6 +199,12 @@ 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 图片:![步骤 <n> 截图](workspace://<截图绝对路径>)。截图失败时如实写明,不要用占位图或把截图描述冒充图片。`
}
return t, true
}
// Wire DB-stored wrap-up (settlement) prompts. Empty column → built-in default.
@@ -772,6 +778,7 @@ 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)
@@ -3442,26 +3449,27 @@ func (s *Server) settingsPayload() map[string]any {
concLimit = defaultConcurrencyLimit // 关闭时也回显一个合理默认值给 UI
}
return map[string]any{
"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)
"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)
// LLM 轮询(故障转移)。默认关;开启后走全局激活配置的 agent 在当前配置不可用时
// 自动切到下一个配置。bind_fallback 仅在轮询开启时有意义(默认关)。
"llm_pool_enabled": s.m.LLMPoolEnabled(),
@@ -3520,6 +3528,7 @@ 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"`
@@ -3715,6 +3724,16 @@ 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())
@@ -3766,6 +3785,28 @@ 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
+32
View File
@@ -225,6 +225,38 @@ 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"))
@@ -1,36 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function formatClock(value: Date) {
const pad = (part: number) => String(part).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
}
export function LiveClock({ className }: { className?: string }) {
const [now, setNow] = React.useState<Date | null>(null);
React.useEffect(() => {
const update = () => setNow(new Date());
update();
const timer = window.setInterval(update, 1000);
return () => window.clearInterval(timer);
}, []);
return (
<time
role="timer"
className={cn(
"hidden rounded-md border bg-muted/40 px-2 py-1 font-mono text-[11px] text-muted-foreground tabular-nums sm:inline-flex",
className,
)}
dateTime={now?.toISOString()}
aria-label="当前时间"
title="当前本地时间"
>
{now ? formatClock(now) : "---- -- -- --:--:--"}
</time>
);
}
@@ -10,13 +10,12 @@ import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
import { LiveClock } from "./live-clock";
import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
import { SearchDialog } from "./sidebar/search-dialog";
import { ThemeSwitcher } from "./sidebar/theme-switcher";
import { UpdateBadge } from "./update-badge";
import { EgressIpNavbar, EgressIpProvider } from "./egress-ip-banner";
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
function isFullBleed(pathname: string) {
@@ -78,7 +77,6 @@ function MainContentInner({ children }: { children: ReactNode }) {
<EgressIpNavbar />
</div>
<div className="flex shrink-0 items-center gap-2">
<LiveClock />
{version && (
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
)}
@@ -75,6 +75,12 @@ 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.
@@ -446,8 +452,8 @@ export function FindingsTable({
{f.evidence}
</pre>
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
{f.finding_id && (
{/* 旧版详细报告保留;新版合并报告在详情页按概览/步骤/根因/修复展示。 */}
{f.finding_id && !isStructuredReproductionReport(reports[rowKey]?.text ?? "") && (
<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">
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { CopyButton } from "@/components/copy-button";
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";
@@ -44,6 +45,12 @@ 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 (
@@ -128,6 +135,7 @@ 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">
@@ -182,47 +190,65 @@ function FindingDetailInner() {
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</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>
{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>
</>
)}
</div>
{/* 右栏:状态区 */}
+72 -38
View File
@@ -2,7 +2,15 @@
import * as React from "react";
import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
import {
CameraIcon,
CpuIcon,
FlaskConicalIcon,
KeyboardIcon,
RadioTowerIcon,
SearchIcon,
ShieldAlertIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -29,6 +37,7 @@ 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");
@@ -86,6 +95,7 @@ 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");
@@ -213,6 +223,22 @@ 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
@@ -262,9 +288,7 @@ 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)
@@ -497,6 +521,31 @@ 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">
@@ -510,9 +559,8 @@ 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">
@@ -537,12 +585,7 @@ 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>
@@ -567,15 +610,13 @@ 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"
@@ -587,10 +628,9 @@ 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>
@@ -603,11 +643,7 @@ 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 ? "✓" : "✗"}
@@ -629,18 +665,12 @@ 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>
) : (
@@ -733,7 +763,9 @@ 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">
@@ -751,7 +783,9 @@ 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,279 @@
"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>
);
}
+26 -65
View File
@@ -111,70 +111,6 @@ function HighlightedBody({ body, format }: { body: string; format: BodyFormat })
return body;
}
/**
* Render a packet with the same syntax rules everywhere in the UI.
*
* `lineNumbers` is disabled by the repeater editor because it already owns a
* synchronised gutter. Keeping the tokenisation here prevents packet previews
* and editable packet panes from drifting apart visually.
*/
export function HttpPacketSyntax({
raw,
wrapLines = true,
lineNumbers = true,
emptyFallback = "(空)",
lineClassName = "leading-relaxed",
}: {
raw: string;
wrapLines?: boolean;
lineNumbers?: boolean;
emptyFallback?: string;
lineClassName?: string;
}) {
const value = raw || emptyFallback;
const lines = value.replaceAll("\r\n", "\n").split("\n");
const separator = lines.indexOf("");
// LLM records and a few provider diagnostics contain a standalone JSON
// payload instead of an HTTP head. Treat that as a body too, so those
// packet views use the same token colours as captured HTTP traffic.
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : value;
const bodyFormat = detectBodyFormat(body);
const renderLine = (line: string, index: number) => {
if (separator < 0 && bodyFormat !== "plain") return <HighlightedBody body={line} format={bodyFormat} />;
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<>
{lines.map((line, index) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className={cn("grid min-w-0", lineClassName, lineNumbers && "grid-cols-[1.5rem_minmax(0,1fr)]")}
>
{lineNumbers ? (
<span className="sticky left-0 self-stretch border-r bg-muted/20 px-1 text-right text-muted-foreground/60">
{index + 1}
</span>
) : null}
<code
className={cn(
"min-h-[1lh] min-w-0 [tab-size:4]",
lineNumbers ? "pr-16 pl-1.5" : "",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
</>
);
}
// Empty grid rows have no text, so native selection copying drops their newlines.
// Map the selected DOM boundaries back to the raw packet instead.
function selectedPacketText(container: HTMLElement, value: string, lines: string[]): string | null {
@@ -214,6 +150,9 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
const [copied, setCopied] = React.useState(false);
const value = raw || "(空)";
const lines = value.replaceAll("\r\n", "\n").split("\n");
const separator = lines.indexOf("");
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
const bodyFormat = detectBodyFormat(body);
React.useEffect(() => {
if (!copied) return;
@@ -230,6 +169,13 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
};
const renderLine = (line: string, index: number) => {
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
@@ -288,7 +234,22 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
selection.addRange(range);
}}
>
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
{lines.map((line, index) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
>
<code
className={cn(
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
</div>
</div>
);
+16 -3
View File
@@ -18,6 +18,21 @@ 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} />
@@ -42,9 +57,7 @@ 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} />,
};
+13 -78
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { SendIcon, Settings2 } from "lucide-react";
import { SendIcon } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
@@ -32,40 +32,14 @@ import { newId, type RepeaterTab } from "./repeater-state";
import { useRepeaterStore } from "./repeater-store";
import { RepeaterTextPane } from "./repeater-text-pane";
function prettyResponse(response: NonNullable<RepeaterTab["response"]>) {
const raw = buildRawResponse(response);
const separator = raw.indexOf("\n\n");
if (separator < 0) return raw;
const body = raw.slice(separator + 2);
try {
return `${raw.slice(0, separator)}\n\n${JSON.stringify(JSON.parse(body), null, 2)}`;
} catch {
return raw;
}
}
function responseStatusClass(status: number) {
if (status >= 500) return "border-rose-300 text-rose-600";
if (status >= 400) return "border-amber-300 text-amber-600";
if (status >= 300) return "border-sky-300 text-sky-600";
return "border-emerald-300 text-emerald-600";
}
function responseTextFor(response: RepeaterTab["response"], mode: "raw" | "pretty") {
if (!response) return "";
return mode === "pretty" ? prettyResponse(response) : buildRawResponse(response);
}
export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
const { useProxy, insecure, response } = tab;
const updateTab = useRepeaterStore((state) => state.updateTab);
const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined);
const [targetOpen, setTargetOpen] = React.useState(false);
const [targetDraft, setTargetDraft] = React.useState("");
const [responseMode, setResponseMode] = React.useState<"raw" | "pretty">("pretty");
const targetOrigin = tab.targetOrigin ?? (tab.rawRequest === undefined ? connectionOrigin(tab.url) : "");
const requestText = buildRawRequest(tab);
const responseText = responseTextFor(response, responseMode);
function changeRequest(rawRequest: string) {
try {
const { httpVersion: _version, ...parts } = parseRawRequest(rawRequest, targetOrigin);
@@ -122,15 +96,11 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
}
return (
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 border-b px-1 pb-2">
<span className="text-muted-foreground text-xs">目标</span>
<code
className="min-w-0 max-w-[min(60vw,44rem)] truncate rounded bg-muted px-2 py-1 font-mono text-xs"
title={targetOrigin || tab.url}
>
{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
</code>
<section aria-label="请求编辑器" className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs" title={targetOrigin || tab.url}>
发送目标:{targetOrigin || connectionOrigin(tab.url) || "按 Host 请求头(默认 HTTPS)"}
</span>
<Button
variant="outline"
size="sm"
@@ -140,15 +110,14 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
setTargetOpen(true);
}}
>
<Settings2 data-icon="inline-start" />
目标设置
修改目标
</Button>
<Button className="ml-auto" disabled={busy} onClick={() => void send()}>
<Button disabled={busy} onClick={() => void send()}>
<SendIcon data-icon="inline-start" />
{busy ? "发送中…" : "发送"}
</Button>
</div>
<FieldSet className="flex-row flex-wrap items-center gap-3 border-b px-1 pb-2 text-xs">
<FieldSet className="flex-row flex-wrap items-center gap-4 text-sm">
<FieldLegend className="sr-only">发送选项</FieldLegend>
<FieldLabel className="flex items-center gap-2" htmlFor="repeater-use-proxy">
<Checkbox
@@ -169,19 +138,15 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
跳过 TLS 校验
</FieldLabel>
{response ? (
<div className="ml-auto flex items-center gap-2 text-muted-foreground text-xs">
{response.status ? (
<Badge variant="outline" className={responseStatusClass(response.status)}>
{response.status} {response.status_text}
</Badge>
) : null}
<div className="flex items-center gap-2 text-muted-foreground text-xs">
{response.status ? <Badge variant="secondary">{response.status_text || response.status}</Badge> : null}
<span>{response.elapsed_ms} ms</span>
<span>{response.body_bytes.toLocaleString()} 字节</span>
{response.body_truncated ? <Badge variant="outline">正文已截断</Badge> : null}
</div>
) : null}
</FieldSet>
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-2 overflow-hidden lg:auto-rows-auto lg:grid-cols-2">
<div className="grid min-h-0 flex-1 auto-rows-[minmax(300px,1fr)] gap-3 overflow-y-auto xl:auto-rows-auto xl:grid-cols-2">
<RepeaterTextPane
id="repeater-request"
label="请求"
@@ -189,12 +154,6 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
onChange={changeRequest}
disabled={busy}
placeholder="POST /path HTTP/1.1"
syntaxHighlight
panelClassName="bg-background"
showClock
toolbar={
<span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Raw</span>
}
/>
<div className="flex min-h-[300px] min-w-0 flex-col gap-2 xl:min-h-0">
{response?.error ? (
@@ -205,32 +164,8 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
<RepeaterTextPane
id="repeater-response"
label="响应"
value={responseText}
value={response ? buildRawResponse(response) : ""}
placeholder="发送后显示原始响应"
syntaxHighlight
panelClassName="bg-background"
toolbar={
<div className="ml-auto flex items-center gap-0.5 rounded border bg-muted/50 p-0.5">
<Button
type="button"
variant={responseMode === "raw" ? "secondary" : "ghost"}
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setResponseMode("raw")}
>
Raw
</Button>
<Button
type="button"
variant={responseMode === "pretty" ? "secondary" : "ghost"}
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => setResponseMode("pretty")}
>
Pretty
</Button>
</div>
}
/>
</div>
</div>
@@ -32,11 +32,9 @@ type Rename = { kind: "tab" | "group"; id: string; name: string };
export function RepeaterNavigator({
workspace,
onRename,
className,
}: {
workspace: RepeaterWorkspace;
onRename: (value: Rename) => void;
className?: string;
}) {
const store = useRepeaterStore();
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
@@ -79,10 +77,7 @@ export function RepeaterNavigator({
return (
<nav
aria-label="请求重发分组和标签"
className={cn(
"flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0",
className,
)}
className="flex h-[28rem] min-h-0 w-full shrink-0 flex-col gap-3 overflow-y-auto rounded-md border p-3 lg:h-[calc(100dvh-14rem)] lg:min-h-[480px] lg:w-80 xl:h-auto xl:min-h-0"
>
<div className="flex shrink-0 flex-col gap-2">
<Input
@@ -2,13 +2,8 @@
import * as React from "react";
import { WrapTextIcon } from "lucide-react";
import { LiveClock } from "@/app/(main)/_components/live-clock";
import { CopyButton } from "@/components/copy-button";
import { HttpPacketSyntax } from "@/components/http-code-block";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export function RepeaterTextPane({
id,
@@ -17,10 +12,6 @@ export function RepeaterTextPane({
onChange,
disabled,
placeholder,
syntaxHighlight = false,
toolbar,
panelClassName,
showClock = false,
}: {
id: string;
label: string;
@@ -28,43 +19,20 @@ export function RepeaterTextPane({
onChange?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
syntaxHighlight?: boolean;
toolbar?: React.ReactNode;
panelClassName?: string;
showClock?: boolean;
}) {
const gutter = React.useRef<HTMLPreElement>(null);
const syntax = React.useRef<HTMLDivElement>(null);
const [wrapLines, setWrapLines] = React.useState(true);
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
return (
<section
className={cn(
"flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 rounded-md border bg-background p-2 xl:min-h-0",
panelClassName,
)}
>
<div className="flex min-h-[280px] min-w-0 flex-1 flex-col gap-2 xl:min-h-0">
<div className="flex items-center gap-2">
<label htmlFor={id} className="font-medium text-xs">
{label}
</label>
<span className="text-muted-foreground text-xs tabular-nums">{lines} 行</span>
{showClock ? <LiveClock className="ml-auto" /> : null}
<button
type="button"
className="inline-flex h-6 items-center gap-1 rounded border px-1.5 text-[10px] text-muted-foreground hover:bg-muted"
onClick={() => setWrapLines((value) => !value)}
title={wrapLines ? "切换为不换行" : "切换为自动换行"}
aria-label={wrapLines ? "切换为不换行" : "切换为自动换行"}
>
<WrapTextIcon className="size-3" />
{wrapLines ? "换行" : "不换行"}
</button>
{toolbar}
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
<CopyButton text={value} label={`复制${label}`} className="ml-auto" />
</div>
<div className="relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
<div className="flex min-h-0 flex-1 overflow-hidden rounded-lg border border-input focus-within:border-ring">
<div
aria-hidden="true"
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
@@ -79,41 +47,17 @@ export function RepeaterTextPane({
onChange={(event) => onChange?.(event.target.value)}
onScroll={(event) => {
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
if (syntax.current) {
syntax.current.scrollTop = event.currentTarget.scrollTop;
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
}
}}
disabled={disabled}
readOnly={!onChange}
placeholder={placeholder}
wrap={wrapLines ? "soft" : "off"}
wrap="off"
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
className={
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs" +
(syntaxHighlight
? " bg-transparent text-transparent caret-foreground selection:bg-primary/20 selection:text-transparent placeholder:text-muted-foreground"
: "")
}
className="field-sizing-fixed h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs"
/>
{syntaxHighlight ? (
<div
ref={syntax}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 left-12 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
>
<HttpPacketSyntax
raw={value}
lineNumbers={false}
wrapLines={wrapLines}
emptyFallback=""
lineClassName="leading-5"
/>
</div>
) : null}
</div>
</section>
</div>
);
}
@@ -2,7 +2,7 @@
import * as React from "react";
import { Library, Plus, X } from "lucide-react";
import { FolderPlus } from "lucide-react";
import {
AlertDialog,
@@ -27,39 +27,16 @@ import {
import { Field, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { RepeaterEditor } from "./repeater-editor";
import { RepeaterNavigator } from "./repeater-navigator";
import { useRepeaterStore } from "./repeater-store";
const METHOD_COLORS: Record<string, string> = {
GET: "text-sky-600 dark:text-sky-400",
POST: "text-emerald-600 dark:text-emerald-400",
PUT: "text-amber-600 dark:text-amber-400",
PATCH: "text-amber-600 dark:text-amber-400",
DELETE: "text-rose-600 dark:text-rose-400",
};
function methodColor(method: string) {
return METHOD_COLORS[method.toUpperCase()] ?? "text-muted-foreground";
}
function statusColor(status: number) {
if (status >= 500) return "text-rose-600 dark:text-rose-400";
if (status >= 400) return "text-amber-600 dark:text-amber-400";
if (status >= 300) return "text-sky-600 dark:text-sky-400";
if (status >= 200) return "text-emerald-600 dark:text-emerald-400";
return "text-muted-foreground";
}
export function RepeaterWorkspace() {
const store = useRepeaterStore();
const { workspace, hydrate } = store;
const [rename, setRename] = React.useState<{ kind: "tab" | "group"; id: string; name: string } | null>(null);
const [libraryOpen, setLibraryOpen] = React.useState(false);
React.useEffect(() => {
hydrate();
}, [hydrate]);
@@ -75,20 +52,16 @@ export function RepeaterWorkspace() {
}
return (
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-2 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
<div className="flex flex-wrap items-center gap-2 border-b pb-2">
<div className="min-w-0">
<h1 className="font-semibold text-lg tracking-tight">请求重发</h1>
<p className="text-muted-foreground text-xs">Repeater · 草稿与历史响应保存在此浏览器</p>
<div className="flex min-h-[calc(100dvh-7rem)] min-w-0 flex-col gap-4 xl:h-[calc(100dvh-8rem)] xl:min-h-[600px]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-semibold text-xl">请求重发</h1>
<p className="text-muted-foreground text-sm">编辑、发送并比较请求 · 草稿与近期响应保存在此浏览器</p>
</div>
<div className="ml-auto flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setLibraryOpen(true)}>
<Library data-icon="inline-start" />
请求库
</Button>
<Button size="sm" onClick={() => store.createTab()}>
<Plus data-icon="inline-start" />
新建请求
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => store.createGroup()}>
<FolderPlus data-icon="inline-start" />
新建分组
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -111,98 +84,40 @@ export function RepeaterWorkspace() {
</AlertDialog>
</div>
</div>
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{workspace.tabs.map((tab) => {
const selected = tab.id === workspace.activeTabId;
return (
<div
key={tab.id}
className={cn(
"group flex min-w-0 max-w-64 shrink-0 items-center rounded-sm border border-transparent",
selected && "border-border bg-background shadow-xs",
)}
>
<Button
variant="ghost"
size="sm"
className="min-w-0 justify-start gap-2 rounded-sm px-2 text-xs"
aria-current={selected ? "page" : undefined}
onClick={() => store.setActiveTab(tab.id)}
onDoubleClick={() => setRename({ kind: "tab", id: tab.id, name: tab.title })}
title={`${tab.title}\n${tab.url}`}
>
<span className={cn("font-mono font-semibold", methodColor(tab.method))}>{tab.method}</span>
<span className="truncate">{tab.title}</span>
{tab.response?.status ? (
<span className={cn("font-mono text-[10px]", statusColor(tab.response.status))}>
{tab.response.status}
</span>
) : null}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="mr-0.5 opacity-60 hover:opacity-100"
aria-label={`关闭请求 ${tab.title}`}
onClick={() => store.closeTab(tab.id)}
>
<X />
</Button>
<div className="flex min-h-0 flex-1 flex-col gap-4 lg:flex-row">
<RepeaterNavigator workspace={workspace} onRename={setRename} />
<main className="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
{active ? (
<>
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 flex-1 truncate font-medium text-sm" title={active.title}>
{active.title}
</h2>
<Field orientation="horizontal" className="w-auto">
<FieldLabel htmlFor="repeater-group">移入分组</FieldLabel>
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
<SelectTrigger id="repeater-group" className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workspace.groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
);
})}
</div>
<Button variant="ghost" size="icon-sm" aria-label="新建请求" onClick={() => store.createTab()}>
<Plus />
</Button>
<RepeaterEditor key={active.id} tab={active} />
</>
) : (
<Button onClick={() => store.createTab()}>新建标签</Button>
)}
</main>
</div>
<main className="flex min-h-0 min-w-0 flex-1 flex-col rounded-md border bg-muted/10 p-2">
{active ? (
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 px-1">
<span className="truncate font-medium text-sm" title={active.title}>
{active.title}
</span>
<Field orientation="horizontal" className="ml-auto w-auto gap-2">
<FieldLabel htmlFor="repeater-group" className="text-muted-foreground text-xs">
分组
</FieldLabel>
<Select value={active.groupId} onValueChange={(id) => store.setTabGroup(active.id, id)}>
<SelectTrigger id="repeater-group" className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workspace.groups.map((group) => (
<SelectItem key={group.id} value={group.id}>
{group.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</div>
<RepeaterEditor key={active.id} tab={active} />
</div>
) : (
<Button onClick={() => store.createTab()}>新建请求</Button>
)}
</main>
<Sheet open={libraryOpen} onOpenChange={setLibraryOpen}>
<SheetContent side="left" className="w-[min(92vw,380px)] max-w-none p-3">
<SheetHeader className="px-1 pb-1">
<SheetTitle>请求库</SheetTitle>
<SheetDescription>搜索、分组和恢复请求历史。</SheetDescription>
</SheetHeader>
<RepeaterNavigator
workspace={workspace}
onRename={setRename}
className="h-full min-h-0 w-full rounded-md border-0 p-1 lg:h-full lg:min-h-0 lg:w-full xl:h-full"
/>
</SheetContent>
</Sheet>
<Dialog
open={rename !== null}
onOpenChange={(open) => {
+3 -1
View File
@@ -970,6 +970,8 @@ 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; // 默认关闭;关闭不注入情报工具或提示
@@ -991,7 +993,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[];