diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx
index 5f05618..28dbe6a 100644
--- a/web/src/app/(main)/function/llm-records/page.tsx
+++ b/web/src/app/(main)/function/llm-records/page.tsx
@@ -29,21 +29,9 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { HttpPacketSyntax } from "@/components/http-code-block";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
@@ -80,7 +68,12 @@ function tryFormatJSON(s: string): string {
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef | null>(null);
- React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
+ React.useEffect(
+ () => () => {
+ if (timer.current) clearTimeout(timer.current);
+ },
+ [],
+ );
const disabled = !text;
const copy = async () => {
@@ -95,7 +88,11 @@ function CopyButton({ text }: { text: string }) {
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
- try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
+ try {
+ document.execCommand("copy");
+ } catch {
+ /* 忽略:不支持则静默 */
+ }
document.body.removeChild(ta);
}
setCopied(true);
@@ -151,9 +148,7 @@ export default function LLMRecordsPage() {
const reqText = showRaw
? detail?.raw_request && tryFormatJSON(detail.raw_request)
: detail?.request_body && tryFormatJSON(detail.request_body);
- const respText = showRaw
- ? detail?.raw_response
- : detail?.response_body && tryFormatJSON(detail.response_body);
+ const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
// Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState([]);
@@ -167,9 +162,13 @@ export default function LLMRecordsPage() {
let alive = true;
api
.settings()
- .then((s) => { if (alive) setRecEnabled(!!s.llm_record); })
+ .then((s) => {
+ if (alive) setRecEnabled(!!s.llm_record);
+ })
.catch(() => {});
- return () => { alive = false; };
+ return () => {
+ alive = false;
+ };
}, []);
const toggleRecording = async (on: boolean) => {
@@ -201,7 +200,13 @@ export default function LLMRecordsPage() {
let alive = true;
setLoading(true);
api
- .llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
+ .llmRecords({
+ model: model || undefined,
+ session: sessionQ || undefined,
+ task: pickedTask || undefined,
+ page,
+ size,
+ })
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
@@ -211,9 +216,13 @@ export default function LLMRecordsPage() {
.finally(() => alive && setLoading(false));
api
.llmTasks()
- .then((r) => { if (alive) setTasks(r.tasks ?? []); })
+ .then((r) => {
+ if (alive) setTasks(r.tasks ?? []);
+ })
.catch(() => {});
- return () => { alive = false; };
+ return () => {
+ alive = false;
+ };
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
// Delete every LLM record for the picked task, then refetch.
@@ -244,10 +253,16 @@ export default function LLMRecordsPage() {
setDetail(null);
api
.llmRecordDetail(selected.id)
- .then((d) => { if (alive) setDetail(d); })
+ .then((d) => {
+ if (alive) setDetail(d);
+ })
.catch(() => {})
- .finally(() => { if (alive) setDetailLoading(false); });
- return () => { alive = false; };
+ .finally(() => {
+ if (alive) setDetailLoading(false);
+ });
+ return () => {
+ alive = false;
+ };
}, [selected]);
const totalPages = Math.max(1, Math.ceil(total / size));
@@ -276,12 +291,7 @@ export default function LLMRecordsPage() {
className="h-8 pl-8"
/>
- setModel(e.target.value)}
- />
+ setModel(e.target.value)} />
绑定时保存的匹配片段
-
- {data.reference.excerpt}
-
+
{data.unavailable ? (
原始流量已删除或不可用,仍可查看上方保存的匹配片段。
) : (
-
-
- 请求
- 响应
-
-
+
)}
) : (
diff --git a/web/src/components/http-code-block.tsx b/web/src/components/http-code-block.tsx
index 0a24de1..6d35240 100644
--- a/web/src/components/http-code-block.tsx
+++ b/web/src/components/http-code-block.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
+import { CheckIcon, CopyIcon, SearchIcon, WrapTextIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -16,15 +16,55 @@ function statusTone(status: number) {
return "text-muted-foreground";
}
+const PACKET_COLORS = {
+ method: "font-semibold text-violet-600 dark:text-violet-400",
+ url: "text-sky-600 dark:text-sky-400",
+ dim: "text-muted-foreground",
+ headerName: "text-sky-700 dark:text-sky-300",
+ headerValue: "text-emerald-600 dark:text-emerald-400",
+ jsonKey: "text-sky-700 dark:text-sky-300",
+ jsonString: "text-amber-600 dark:text-amber-400",
+ jsonNumber: "text-fuchsia-600 dark:text-fuchsia-400",
+ jsonBoolean: "text-rose-600 dark:text-rose-400",
+ tag: "text-sky-600 dark:text-sky-400",
+};
+
+const PacketHighlightContext = React.createContext("");
+
+function HighlightedText({ text, className }: { text: string; className?: string }) {
+ const query = React.useContext(PacketHighlightContext).trim();
+ if (!query || !text) return {text};
+ const needle = query.toLowerCase();
+ const haystack = text.toLowerCase();
+ const parts: React.ReactNode[] = [];
+ let cursor = 0;
+ let key = 0;
+ for (;;) {
+ const match = haystack.indexOf(needle, cursor);
+ if (match < 0) {
+ parts.push(text.slice(cursor));
+ break;
+ }
+ if (match > cursor) parts.push(text.slice(cursor, match));
+ parts.push(
+
+ {text.slice(match, match + needle.length)}
+ ,
+ );
+ cursor = match + needle.length;
+ }
+ return {parts};
+}
+
function StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
- {response[1]}
+
{response[2]}
- {response[3]}
- {response[4]}
+
+
>
);
}
@@ -33,26 +73,26 @@ function StartLine({ line }: { line: string }) {
if (request) {
return (
<>
- {request[1]}
+
{request[2]}
- {request[3]}
+
{request[4]}
- {request[5]}
+
>
);
}
- return line;
+ return ;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
- if (separator <= 0) return line;
+ if (separator <= 0) return ;
return (
<>
- {line.slice(0, separator)}
- :
- {line.slice(separator + 1)}
+
+ :
+
>
);
}
@@ -63,18 +103,14 @@ function JsonBody({ body }: { body: string }) {
let cursor = 0;
for (const match of body.matchAll(tokens)) {
const index = match.index ?? 0;
- if (index > cursor) parts.push(body.slice(cursor, index));
- let className = "text-chart-4";
- if (match[1]) className = match[2] ? "text-primary" : "text-chart-2";
- else if (match[3]) className = "text-chart-3";
- parts.push(
-
- {match[0]}
- ,
- );
+ if (index > cursor) parts.push();
+ let className = PACKET_COLORS.jsonNumber;
+ if (match[1]) className = match[2] ? PACKET_COLORS.jsonKey : PACKET_COLORS.jsonString;
+ else if (match[3]) className = PACKET_COLORS.jsonBoolean;
+ parts.push();
cursor = index + match[0].length;
}
- if (cursor < body.length) parts.push(body.slice(cursor));
+ if (cursor < body.length) parts.push();
return parts;
}
@@ -84,15 +120,11 @@ function MarkupBody({ body }: { body: string }) {
let cursor = 0;
for (const match of body.matchAll(tags)) {
const index = match.index ?? 0;
- if (index > cursor) parts.push(body.slice(cursor, index));
- parts.push(
-
- {match[0]}
- ,
- );
+ if (index > cursor) parts.push();
+ parts.push();
cursor = index + match[0].length;
}
- if (cursor < body.length) parts.push(body.slice(cursor));
+ if (cursor < body.length) parts.push();
return parts;
}
@@ -108,7 +140,82 @@ function detectBodyFormat(body: string): BodyFormat {
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
if (format === "json") return ;
if (format === "markup") return ;
- return body;
+ return ;
+}
+
+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 lineItems = React.useMemo(() => {
+ const occurrences = new Map();
+ return lines.map((line) => {
+ const occurrence = occurrences.get(line) ?? 0;
+ occurrences.set(line, occurrence + 1);
+ return { key: `${line}\u0000${occurrence}`, line };
+ });
+ }, [value]);
+ const separator = lines.indexOf("");
+ 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 ;
+ if (index === 0) return ;
+ if (separator < 0 || index < separator) return ;
+ if (index === separator) return null;
+ return ;
+ };
+ return (
+ <>
+ {lineItems.map((item, index) => (
+
+ {lineNumbers ? (
+
+ {index + 1}
+
+ ) : null}
+
+ {renderLine(item.line, index)}
+
+
+ ))}
+ >
+ );
+}
+
+function countMatches(value: string, query: string) {
+ const needle = query.trim().toLowerCase();
+ if (!needle) return 0;
+ const haystack = value.toLowerCase();
+ let count = 0;
+ let cursor = 0;
+ while (cursor <= haystack.length - needle.length) {
+ const index = haystack.indexOf(needle, cursor);
+ if (index < 0) return count;
+ count += 1;
+ cursor = index + needle.length;
+ }
}
// Empty grid rows have no text, so native selection copying drops their newlines.
@@ -148,11 +255,10 @@ function selectedPacketText(container: HTMLElement, value: string, lines: string
export function HttpCodeBlock({ raw }: { raw: string }) {
const [wrapLines, setWrapLines] = React.useState(true);
const [copied, setCopied] = React.useState(false);
+ const [query, setQuery] = React.useState("");
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);
+ const matches = React.useMemo(() => countMatches(value, query), [value, query]);
React.useEffect(() => {
if (!copied) return;
@@ -169,15 +275,8 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
};
- const renderLine = (line: string, index: number) => {
- if (index === 0) return ;
- if (separator < 0 || index < separator) return ;
- if (index === separator) return null;
- return ;
- };
-
return (
-
+
);
diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx
index 7f1fc9c..d6f4563 100644
--- a/web/src/components/repeater/repeater-editor.tsx
+++ b/web/src/components/repeater/repeater-editor.tsx
@@ -154,6 +154,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
onChange={changeRequest}
disabled={busy}
placeholder="POST /path HTTP/1.1"
+ syntaxHighlight
/>
{response?.error ? (
@@ -166,6 +167,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) {
label="响应"
value={response ? buildRawResponse(response) : ""}
placeholder="发送后显示原始响应"
+ syntaxHighlight
/>
diff --git a/web/src/components/repeater/repeater-text-pane.tsx b/web/src/components/repeater/repeater-text-pane.tsx
index 8819074..1bd0342 100644
--- a/web/src/components/repeater/repeater-text-pane.tsx
+++ b/web/src/components/repeater/repeater-text-pane.tsx
@@ -3,6 +3,7 @@
import * as React from "react";
import { CopyButton } from "@/components/copy-button";
+import { HttpPacketSyntax } from "@/components/http-code-block";
import { Textarea } from "@/components/ui/textarea";
export function RepeaterTextPane({
@@ -12,6 +13,7 @@ export function RepeaterTextPane({
onChange,
disabled,
placeholder,
+ syntaxHighlight = false,
}: {
id: string;
label: string;
@@ -19,8 +21,10 @@ export function RepeaterTextPane({
onChange?: (value: string) => void;
disabled?: boolean;
placeholder?: string;
+ syntaxHighlight?: boolean;
}) {
const gutter = React.useRef(null);
+ const syntax = React.useRef(null);
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 (
@@ -32,7 +36,7 @@ export function RepeaterTextPane({
{lines} 行
-
+
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}
@@ -55,8 +63,28 @@ export function RepeaterTextPane({
spellCheck={false}
autoCapitalize="off"
autoCorrect="off"
- 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"
+ 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 placeholder:text-muted-foreground selection:bg-primary/20 selection:text-transparent "
+ : "")
+ }
/>
+ {syntaxHighlight ? (
+
+
+
+ ) : null}
);
diff --git a/web/src/components/traffic-evidence-viewer.tsx b/web/src/components/traffic-evidence-viewer.tsx
index f614f27..812c473 100644
--- a/web/src/components/traffic-evidence-viewer.tsx
+++ b/web/src/components/traffic-evidence-viewer.tsx
@@ -13,7 +13,6 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
@@ -114,17 +113,16 @@ export function TrafficEvidenceViewer({
{error}
) : detail ? (
-
-
- 请求 Request
- 响应 Response
-
+
{(["request", "response"] as const).map((side) => (
-
+
-
- 正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
-
+
+
{side === "request" ? "请求 Request" : "响应 Response"}
+
+ 正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
+
+
toast.error(e.message))
}
>
- 下载完整{side === "request" ? "请求" : "响应"}正文
+ 下载完整正文
) : null}
-
+
))}
-
+
) : (
)}
@@ -193,18 +191,16 @@ export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onCl
{error}
) : detail ? (
-
-
- 请求 Request
- 响应 Response
-
-
+
) : (
)}