diff --git a/app/renderer/src/main/public/locales/en/webFuzzer.json b/app/renderer/src/main/public/locales/en/webFuzzer.json index bdc6dc968f..ffd5531cc6 100644 --- a/app/renderer/src/main/public/locales/en/webFuzzer.json +++ b/app/renderer/src/main/public/locales/en/webFuzzer.json @@ -80,7 +80,6 @@ "webFuzzerDemo": "WebFuzzer Brute-Force Animation Demo", "bruteForceExample": "Brute-Force Example", "AI_new_conversation": "New conversation", - "ai_auto_patch_request": "AI auto-update request", "followRedirects": "Follow Redirects", "generatePathTemplate": "Generate as Path Template", "generateRawTemplate": "Generate as Raw Template", @@ -100,7 +99,11 @@ "send_request": "Send Request", "parameter": "Parameter", "trafficAnalysisMode": "TrafficAnalysis Mode", - "trafficAnalysis": "TrafficAnalysis" + "trafficAnalysis": "TrafficAnalysis", + "aiCasualRejectAll": "Reject all", + "aiCasualAcceptAll": "Accept all", + + "aiCasualAcceptAllMetaTitle": "Setting to apply" }, "FuzzerExtraShow": { "responsesDiscarded": "Discarded [{{droppedCount}}] responses", diff --git a/app/renderer/src/main/public/locales/zh/webFuzzer.json b/app/renderer/src/main/public/locales/zh/webFuzzer.json index 4d8a1270b1..37ce6ddd37 100644 --- a/app/renderer/src/main/public/locales/zh/webFuzzer.json +++ b/app/renderer/src/main/public/locales/zh/webFuzzer.json @@ -80,7 +80,6 @@ "webFuzzerDemo": "WebFuzzer 爆破动画演示", "bruteForceExample": "爆破示例", "AI_new_conversation": "新建对话", - "ai_auto_patch_request": "AI 自动改包", "followRedirects": "跟随重定向", "generatePathTemplate": "生成为 Path 模板", "generateRawTemplate": "生成为 Raw 模板", @@ -100,7 +99,11 @@ "send_request": "发送请求", "parameter": "参数", "trafficAnalysisMode": "流量分析模式", - "trafficAnalysis": "流量分析" + "trafficAnalysis": "流量分析", + "aiCasualRejectAll": "全部放弃", + "aiCasualAcceptAll": "全部应用", + + "aiCasualAcceptAllMetaTitle": "即将应用的配置" }, "FuzzerExtraShow": { "responsesDiscarded": "已丢弃[{{droppedCount}}]个响应", diff --git a/app/renderer/src/main/src/components/AIReActChat.tsx b/app/renderer/src/main/src/components/AIReActChat.tsx index ad6834f026..d11afaeb87 100644 --- a/app/renderer/src/main/src/components/AIReActChat.tsx +++ b/app/renderer/src/main/src/components/AIReActChat.tsx @@ -104,8 +104,7 @@ const HistroryAIReActChat: FC = (props) => { }, [inViewport]) const resultRender = useMemo(() => { - // 未完成检查,不渲染任何业务 UI - if (loading) return null + if (loading && data === undefined) return null // 无模型 → 配置引导 if (data) { diff --git a/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx b/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx index 22d6a06325..8cf8a75527 100644 --- a/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx +++ b/app/renderer/src/main/src/components/withHistoryAIReActChat.tsx @@ -1,4 +1,4 @@ -import React, { createContext, memo, useCallback, useContext, useMemo, useRef } from 'react' +import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react' import { useCreation, useInViewport, useMemoizedFn, useSafeState } from 'ahooks' import { cloneDeep } from 'lodash' @@ -27,8 +27,12 @@ import { AISendParams, AISendResProps, } from '@/pages/ai-re-act/aiReActChat/AIReActChatType' -import { AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' -import { getWebFuzzerPageRequestString } from '@/pages/fuzzer/webFuzzerAiRequestApplyBridge' +import { AIAgentGrpcApi, AIInputEvent } from '@/pages/ai-re-act/hooks/grpcApi' +import { + applyHttpFuzzRequestChangeToWebFuzzerPage, + getWebFuzzerPageRequestString, + enqueueWebFuzzerCasualReplaceReview, +} from '@/pages/fuzzer/webFuzzerAiRequestApplyBridge' import { ChatIPCSendType, UseChatIPCEvents } from '@/pages/ai-re-act/hooks/type' import useChatIPC from '@/pages/ai-re-act/hooks/useChatIPC' import useGetSetState from '@/pages/pluginHub/hooks/useGetSetState' @@ -118,12 +122,49 @@ export const HistoryAIReActChatProvider = memo(function HistoryAIReActChatProvid const [setting, setSetting, getSetting] = useGetSetState(cloneDeep(AIAgentSettingDefault)) const [chats, setChats, getChats] = useGetSetState([]) const [activeChat, setActiveChat] = useSafeState() + const casualLoadingRef = useRef(false) + const initialRequestInCasualRef = useRef(null) + + const onHttpFuzzRequestChange = useMemoizedFn((data: AIAgentGrpcApi.HttpFuzzRequestChange) => { + if (!httpFuzzTabPageId) return + + // casual 问答期间:`replace` 不自动写包;入队审阅(页内合并为「问答开始前快照 vs 最新一条 raw」) + if (casualLoadingRef.current && data?.op === 'replace') { + const nextRaw = data?.request?.raw + if (nextRaw != null && String(nextRaw).trim() !== '' && initialRequestInCasualRef.current != null) { + enqueueWebFuzzerCasualReplaceReview(httpFuzzTabPageId, { + original: initialRequestInCasualRef.current ?? '', + change: data, + }) + } + return + } + + applyHttpFuzzRequestChangeToWebFuzzerPage(httpFuzzTabPageId, data) + }) const [chatIPCData, events] = useChatIPC({ cacheDataStore, + onHttpFuzzRequestChange, }) - const { execute } = chatIPCData + const { execute, casualStatus } = chatIPCData + + useEffect(() => { + if (!httpFuzzTabPageId) { + casualLoadingRef.current = false + initialRequestInCasualRef.current = null + return + } + + if (!casualLoadingRef.current && casualStatus.loading) { + initialRequestInCasualRef.current = getWebFuzzerPageRequestString(httpFuzzTabPageId) ?? '' + } else if (casualLoadingRef.current && !casualStatus.loading) { + initialRequestInCasualRef.current = null + } + + casualLoadingRef.current = casualStatus.loading + }, [casualStatus.loading, httpFuzzTabPageId]) const activeID = useCreation(() => { return activeChat?.SessionID diff --git a/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditor.tsx b/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditor.tsx index 0a39b5df2c..145445d26a 100644 --- a/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditor.tsx +++ b/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditor.tsx @@ -21,6 +21,7 @@ export const YakitDiffEditor: React.FC = memo((props) => { noWrap, leftReadOnly = false, rightReadOnly = false, + renderSideBySide = true, } = props const { t, i18n } = useI18nNamespaces(['yakitUi']) @@ -119,6 +120,7 @@ export const YakitDiffEditor: React.FC = memo((props) => { diffEditorRef.current = monaco.createDiffEditor(diffDivRef.current, { enableSplitViewResizing: false, + renderSideBySide, originalEditable: !leftReadOnly, readOnly: rightReadOnly, automaticLayout: true, @@ -185,13 +187,14 @@ export const YakitDiffEditor: React.FC = memo((props) => { useUpdateEffect(() => { if (diffEditorRef.current) { diffEditorRef.current.updateOptions({ + renderSideBySide, originalEditable: !leftReadOnly, readOnly: rightReadOnly, wordWrap: !!noWrap ? 'off' : 'on', fontSize: fontSize, }) } - }, [noWrap, leftReadOnly, rightReadOnly, fontSize]) + }, [noWrap, leftReadOnly, rightReadOnly, fontSize, renderSideBySide]) // 强制更新默认值 useUpdateEffect(() => { diff --git a/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditorType.d.ts b/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditorType.d.ts index 70ab70c041..4d8222df66 100644 --- a/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditorType.d.ts +++ b/app/renderer/src/main/src/components/yakitUI/YakitDiffEditor/YakitDiffEditorType.d.ts @@ -16,4 +16,6 @@ export interface YakitDiffEditorProps { leftReadOnly?: boolean /** 右侧是否只读 */ rightReadOnly?: boolean + /** `false` 时为上下内联 diff(非左右分栏),默认 `true` */ + renderSideBySide?: boolean } diff --git a/app/renderer/src/main/src/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline.module.scss b/app/renderer/src/main/src/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline.module.scss new file mode 100644 index 0000000000..c399bba986 --- /dev/null +++ b/app/renderer/src/main/src/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline.module.scss @@ -0,0 +1,118 @@ +.outerHost { + width: 100%; + height: 100%; + min-height: 0; + position: relative; +} + +.editorHost { + width: 100%; + height: 100%; + min-height: 0; +} + +.floatingBar { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 5px 10px; + border-radius: 6px; + background: var(--Colors-Use-Neutral-Bg-Hover); + border: 1px solid var(--Colors-Use-Neutral-Border); + box-shadow: 0 6px 28px var(--Colors-Use-Neutral-Shadow) 0 0 0 1px var(--Colors-Use-Neutral-Shadow); + font-size: 12px; + line-height: 1.35; + color: var(--Colors-Use-Neutral-Text-1-Title); + white-space: nowrap; + position: absolute; + right: 48px; + z-index: 50; + max-width: calc(100% - 24px); +} + +.floatingNav { + color: var(--Colors-Use-Neutral-Text-3-Secondary); + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + user-select: none; + min-width: 28px; + text-align: center; +} + +.btnNav { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + margin: 0; + padding: 0; + width: 20px; + height: 20px; + border: none; + border-radius: 4px; + font: inherit; + font-size: 14px; + line-height: 1; + color: var(--Colors-Use-Neutral-Text-3-Secondary); + background: transparent; +} + +.btnNav:hover:not(:disabled) { + color: var(--Colors-Use-Main-Primary); + background: var(--Colors-Use-Neutral-Bg-Hover); +} + +.btnNav:disabled { + cursor: default; + opacity: 0.4; +} + +.floatingSep { + width: 1px; + height: 14px; + background: var(--Colors-Use-Neutral-Border); + flex-shrink: 0; +} + +.btnUndo { + display: inline-flex; + align-items: center; + cursor: pointer; + margin: 0; + padding: 3px 8px; + border: none; + border-radius: 4px; + font: inherit; + color: var(--Colors-Use-Neutral-Text-3-Secondary); + background: transparent; +} + +.btnUndo:hover:not(:disabled) { + color: var(--Colors-Use-Neutral-Text-1-Title); + background: var(--Colors-Use-Neutral-Bg-Hover); +} + +.btnKeep { + display: inline-flex; + align-items: center; + cursor: pointer; + margin: 0; + padding: 4px 10px; + border: none; + border-radius: 4px; + font: inherit; + font-weight: 500; + color: var(--Colors-Use-Main-On-Primary); + background: var(--Colors-Use-Main-Primary); + box-shadow: 0 1px 0 var(--Colors-Use-Neutral-Shadow); +} + +.btnKeep:hover:not(:disabled) { + filter: brightness(1.06); +} + +.btnKeep:disabled, +.btnUndo:disabled { + cursor: default; + opacity: 0.55; +} diff --git a/app/renderer/src/main/src/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline.tsx b/app/renderer/src/main/src/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline.tsx new file mode 100644 index 0000000000..c281ce5742 --- /dev/null +++ b/app/renderer/src/main/src/components/yakitUI/YakitMonacoDiffInline/YakitMonacoDiffInline.tsx @@ -0,0 +1,307 @@ +import React, { memo, useEffect, useRef } from 'react' +import { useUpdateEffect } from 'ahooks' +import * as monacoEditor from 'monaco-editor/esm/vs/editor/editor.api' + +import { useEditorFontSize } from '@/store/editorFontSize' +import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' + +import styles from './YakitMonacoDiffInline.module.scss' + +export type YakitMonacoDiffInlineDecision = 'accept' | 'reject' | null + +/** 行级 hunk 的最小结构(兼容上层根据 `diffLines` 合并后的块) */ +export interface YakitMonacoDiffInlineHunk { + /** baseline 中该 hunk 起始行索引(0-based) */ + origStart: number + /** baseline 端被替换/删除的原行 */ + origLines: string[] + /** 提案端用于替换/插入的新行 */ + newLines: string[] +} + +export interface YakitMonacoDiffInlineProps { + /** 同一会话内稳定的 key;变化时强制重挂 Monaco */ + reuseKey: string + original: string + incoming: string + /** 与 `diffLines` 行级 hunk 对齐的审阅块(避免 Monaco getLineChanges 拆块) */ + hunks: YakitMonacoDiffInlineHunk[] + onDecision: (hunkIndex: number, v: 'accept' | 'reject') => void + language?: string +} + +/** 根据 i18n.language 选取浮条文案;不走 i18n keys,避免依赖业务命名空间 */ +function pickLabels(lng: string): { + nav: (current: number, total: number) => string + keep: string + undo: string +} { + const isEn = (lng || '').toLowerCase().startsWith('en') + return { + nav: (current, total) => `${current} / ${total}`, + keep: isEn ? 'Keep' : '保留', + undo: isEn ? 'Undo' : '撤销', + } +} + +/** 由 hunks 顺序累加推算第 i 个 hunk 在 modified 端的末行(1-based),不依赖 Monaco ILineChange */ +function modEndLineForHunk(hunks: YakitMonacoDiffInlineHunk[], i: number, modMax: number): number { + const h = hunks[i] + let prevOrigConsumed = 0 + let prevNewProduced = 0 + for (let j = 0; j < i; j++) { + prevOrigConsumed += hunks[j].origLines.length + prevNewProduced += hunks[j].newLines.length + } + const modStart1 = 1 + (h.origStart - prevOrigConsumed) + prevNewProduced + const newLen = h.newLines.length + const modEnd1 = newLen > 0 ? modStart1 + newLen - 1 : Math.max(1, modStart1 - 1) + return Math.min(Math.max(1, modEnd1), modMax) +} + +/** + * 内联 diff 审阅器:以行级 hunk 为单位在 modified 末行下方挂浮条, + * 提供「撤销 / 保留」逐块决策。文案可由调用方注入。 + */ +export const YakitMonacoDiffInline = memo(function YakitMonacoDiffInlineInner(props: YakitMonacoDiffInlineProps) { + const { reuseKey, original, incoming, hunks, onDecision, language = 'http' } = props + + const { i18n } = useI18nNamespaces([]) + const lng = i18n.language + + const { initFontSize, fontSize } = useEditorFontSize() + const editorHostRef = useRef(null) + const monaco = monacoEditor.editor + const diffEditorRef = useRef(null) + + useEffect(() => { + initFontSize() + }, [initFontSize]) + + useEffect(() => { + if (!editorHostRef.current) return + + let disposed = false + const disposables: monacoEditor.IDisposable[] = [] + let overlayEl: HTMLDivElement | null = null + const overlayBars: Array<{ + lineNumber: number + stackIndex: number + dom: HTMLDivElement + }> = [] + + const diffEditor = monaco.createDiffEditor(editorHostRef.current, { + enableSplitViewResizing: false, + renderSideBySide: false, + originalEditable: false, + readOnly: true, + automaticLayout: true, + wordWrap: 'on', + fontSize, + contextmenu: false, + }) + diffEditorRef.current = diffEditor + + const originalModel = monaco.createModel(original, language) + const modifiedModel = monaco.createModel(incoming, language) + diffEditor.setModel({ original: originalModel, modified: modifiedModel }) + + const modEditor = diffEditor.getModifiedEditor() + + const clearWidgets = () => { + overlayBars.splice(0, overlayBars.length).forEach(({ dom }) => dom.remove()) + if (overlayEl) overlayEl.remove() + overlayEl = null + } + + const mountHunkWidgets = (items: YakitMonacoDiffInlineHunk[]) => { + if (disposed) return + clearWidgets() + + if (items.length === 0) return + + overlayEl = document.createElement('div') + overlayEl.style.position = 'absolute' + overlayEl.style.inset = '0' + overlayEl.style.pointerEvents = 'none' + editorHostRef.current?.parentElement?.appendChild(overlayEl) + + const updatePositions = () => { + if (!editorHostRef.current) return + if (!overlayEl) return + if (!overlayEl.parentElement) return + + const containerRect = overlayEl.parentElement.getBoundingClientRect() + const editorDom = modEditor.getDomNode() as HTMLElement | null + const editorRect = editorDom?.getBoundingClientRect() + if (!editorRect) return + + const scrollTopOffset = editorRect.top - containerRect.top + const visibleRanges = modEditor.getVisibleRanges() + overlayBars.forEach((item) => { + const visible = visibleRanges.some( + (r) => item.lineNumber >= r.startLineNumber && item.lineNumber <= r.endLineNumber, + ) + item.dom.style.display = visible ? 'inline-flex' : 'none' + if (!visible) return + + const barHeight = item.dom.offsetHeight || 32 + const vis = modEditor.getScrolledVisiblePosition({ + lineNumber: item.lineNumber, + column: 1, + }) + if (!vis) { + item.dom.style.display = 'none' + return + } + const baseTop = scrollTopOffset + vis.top + vis.height + 2 + const stackGap = 4 + const top = baseTop + item.stackIndex * (barHeight + stackGap) + const clampedTop = Math.max(0, Math.min(top, containerRect.height - barHeight)) + item.dom.style.top = `${clampedTop}px` + }) + } + + disposables.push(modEditor.onDidLayoutChange(() => updatePositions())) + disposables.push(modEditor.onDidScrollChange(() => updatePositions())) + requestAnimationFrame(() => updatePositions()) + + const stackByLine = new Map() + const modMax = modifiedModel.getLineCount() + const labels = pickLabels(lng) + + const scrollToHunkIndex = (targetIdx: number) => { + if (targetIdx < 0 || targetIdx >= items.length) return + const max = modifiedModel.getLineCount() + const ln = modEndLineForHunk(items, targetIdx, max) + modEditor.revealLineInCenterIfOutsideViewport(ln) + requestAnimationFrame(() => updatePositions()) + } + + items.forEach((h, i) => { + const lineNumber = modEndLineForHunk(items, i, modMax) + const stackIndex = stackByLine.get(lineNumber) ?? 0 + stackByLine.set(lineNumber, stackIndex + 1) + + const bar = document.createElement('div') + bar.className = styles.floatingBar + + const prevBtn = document.createElement('button') + prevBtn.type = 'button' + prevBtn.className = styles.btnNav + prevBtn.setAttribute('aria-label', 'prev-hunk') + prevBtn.textContent = '‹' + prevBtn.disabled = items.length < 2 || i === 0 + + const nav = document.createElement('span') + nav.className = styles.floatingNav + nav.textContent = labels.nav(i + 1, items.length) + + const nextBtn = document.createElement('button') + nextBtn.type = 'button' + nextBtn.className = styles.btnNav + nextBtn.setAttribute('aria-label', 'next-hunk') + nextBtn.textContent = '›' + nextBtn.disabled = items.length < 2 || i === items.length - 1 + + const sep = document.createElement('span') + sep.className = styles.floatingSep + + const undoBtn = document.createElement('button') + undoBtn.type = 'button' + undoBtn.className = styles.btnUndo + undoBtn.textContent = labels.undo + + const keepBtn = document.createElement('button') + keepBtn.type = 'button' + keepBtn.className = styles.btnKeep + keepBtn.textContent = labels.keep + + bar.append(prevBtn, nav, nextBtn, sep, undoBtn, keepBtn) + bar.style.pointerEvents = 'auto' + bar.style.display = 'none' + + prevBtn.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + scrollToHunkIndex(i - 1) + }) + nextBtn.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + scrollToHunkIndex(i + 1) + }) + + undoBtn.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + onDecision(i, 'reject') + }) + keepBtn.addEventListener('click', (e) => { + e.preventDefault() + e.stopPropagation() + onDecision(i, 'accept') + }) + + overlayEl?.appendChild(bar) + overlayBars.push({ lineNumber, stackIndex, dom: bar }) + }) + } + + let diffMounted = false + const tryMountFromDiff = () => { + if (disposed || diffMounted) return true + const ch = diffEditor.getLineChanges() + if (ch !== null) { + diffMounted = true + mountHunkWidgets(hunks) + return true + } + return false + } + + if (!tryMountFromDiff()) { + const diffEditorAny = diffEditor as monacoEditor.editor.IStandaloneDiffEditor & { + onDidUpdateDiff?: (listener: () => void) => monacoEditor.IDisposable + } + const sub = diffEditorAny.onDidUpdateDiff?.(() => { + if (tryMountFromDiff()) { + sub?.dispose() + } + }) + if (sub) disposables.push(sub) + let frames = 0 + const poll = () => { + if (disposed) return + if (tryMountFromDiff()) return + frames++ + if (frames > 240) return + requestAnimationFrame(poll) + } + requestAnimationFrame(poll) + } + + return () => { + disposed = true + clearWidgets() + disposables.forEach((d) => d.dispose()) + diffEditor.dispose() + originalModel.dispose() + modifiedModel.dispose() + diffEditorRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reuseKey, original, incoming, hunks, language, onDecision, lng]) + + useUpdateEffect(() => { + diffEditorRef.current?.updateOptions({ fontSize }) + }, [fontSize]) + + return ( +
+
+
+ ) +}) + +YakitMonacoDiffInline.displayName = 'YakitMonacoDiffInline' diff --git a/app/renderer/src/main/src/pages/ai-agent/components/WebFuzzerAiStoreCardRightHeader.module.scss b/app/renderer/src/main/src/pages/ai-agent/components/WebFuzzerAiStoreCardRightHeader.module.scss index 5f20741de6..678d477ea5 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/WebFuzzerAiStoreCardRightHeader.module.scss +++ b/app/renderer/src/main/src/pages/ai-agent/components/WebFuzzerAiStoreCardRightHeader.module.scss @@ -4,7 +4,8 @@ span { cursor: pointer; svg { - width: 12px; + width: 14px; + height: 14px; &:hover { color: var(--Colors-Use-Main-Primary); } diff --git a/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx b/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx index f8532fcc71..6344264818 100644 --- a/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx +++ b/app/renderer/src/main/src/pages/ai-agent/components/aiYaklangCode/AIYaklangCode.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useMemo, useState } from 'react' import { WebFuzzerAiStoreCardRightHeader } from '@/pages/ai-agent/components/WebFuzzerAiStoreCardRightHeader' import { AIYaklangCodeProps } from './type' import ChatCard from '../ChatCard' @@ -7,7 +7,6 @@ import { YakitEditor } from '@/components/yakitUI/YakitEditor/YakitEditor' import ModalInfo from '../ModelInfo' import styles from './AIYaklangCode.module.scss' import { useCreation, useMemoizedFn, useThrottleEffect } from 'ahooks' -import { tryWebFuzzerAutoApplyRequestFromAiYaklangCode } from '@/pages/fuzzer/webFuzzerAiRequestApplyBridge' import { NewHTTPPacketEditor } from '@/utils/editors' import useChatIPCDispatcher from '../../useContext/ChatIPCContent/useDispatcher' import { @@ -20,16 +19,8 @@ import { } from '@/pages/ai-agent/store/ChatDataStore' export const AIYaklangCode: React.FC = React.memo((props) => { - const { - content: defContent, - autoApplyStreamId, - autoApplyChatSessionId, - listItemIndex, - nodeLabel, - modalInfo, - contentType, - referenceNode, - } = props + const { content: defContent, nodeLabel, modalInfo, contentType, referenceNode } = props + const [content, setContent] = useState(defContent) useThrottleEffect( () => { @@ -44,7 +35,15 @@ export const AIYaklangCode: React.FC = React.memo((props) => const renderCode = useMemoizedFn(() => { switch (type) { case 'http-request': - return + return ( + + ) default: // case AIStreamContentType.CODE_YAKLANG: // case AIStreamContentType.CODE_PYTHON: @@ -52,10 +51,12 @@ export const AIYaklangCode: React.FC = React.memo((props) => } }) const { chatIPCEvents } = useChatIPCDispatcher() + const webFuzzerAiStoreFuzzerPageId = useMemo((): string | undefined => { const store = chatIPCEvents.fetchChatDataStore() return store instanceof WebFuzzerAiStore ? store.fuzzerPageId : undefined }, [chatIPCEvents]) + const chatDataStoreKey = useMemo((): ChatDataStoreKey => { const store = chatIPCEvents.fetchChatDataStore() switch (store) { @@ -77,24 +78,6 @@ export const AIYaklangCode: React.FC = React.memo((props) => return chatDataStoreKey === 'WebFuzzerAiStore' }, [chatDataStoreKey]) - useEffect(() => { - if (!isWebFuzzerAiStore || !webFuzzerAiStoreFuzzerPageId) return - tryWebFuzzerAutoApplyRequestFromAiYaklangCode( - webFuzzerAiStoreFuzzerPageId, - defContent, - autoApplyStreamId, - autoApplyChatSessionId, - listItemIndex, - ) - }, [ - defContent, - isWebFuzzerAiStore, - webFuzzerAiStoreFuzzerPageId, - autoApplyStreamId, - autoApplyChatSessionId, - listItemIndex, - ]) - const titleExtra = useMemo(() => { if (!modalInfo) return null return ( diff --git a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss index 57b539499b..e0f475941c 100644 --- a/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss +++ b/app/renderer/src/main/src/pages/ai-re-act/aiReActChat/AIReActChat.module.scss @@ -88,6 +88,7 @@ .chat-container { .chat-header { padding: 12px 8px 12px 0px; + margin-left: 8px; } } } diff --git a/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx b/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx index 9a7c5abd8d..3a87e95356 100644 --- a/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx +++ b/app/renderer/src/main/src/pages/fuzzer/HTTPFuzzerPage.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, useEffect, useLayoutEffect, useMemo, useRef, useState, createRef } from 'react' +import React, { CSSProperties, memo, useEffect, useLayoutEffect, useMemo, useRef, useState, createRef } from 'react' import { Form, Result, Space, Popover, Tooltip, Divider, Descriptions } from 'antd' import { IMonacoEditor, @@ -108,10 +108,11 @@ import emiter from '@/utils/eventBus/eventBus' import { HistoryAIReActChatProvider, useHistoryAIReActChat } from '@/components/historyAIReActChat' import { WebFuzzerAiStore } from '@/pages/ai-agent/store/ChatDataStore' import { - clearWebFuzzerLastAiAutoApplySnapshot, - registerWebFuzzerPageAiAutoApplyEnabled, + applyHttpFuzzRequestChangeToWebFuzzerPage, registerWebFuzzerPageApplyRequestFromCard, + registerWebFuzzerPageCasualReplaceReview, registerWebFuzzerPageGetRequestString, + type WebFuzzerCasualReplaceReviewPayload, } from './webFuzzerAiRequestApplyBridge' import useChatIPCDispatcher from '@/pages/ai-agent/useContext/ChatIPCContent/useDispatcher' import { AIInputInnerFeatureEnum } from '@/pages/ai-agent/template/type' @@ -125,6 +126,7 @@ import { PayloadGroupNodeProps, ReadOnlyNewPayload } from '../payloadManager/new import { createRoot, Root } from 'react-dom/client' import { SolidPauseIcon, SolidPlayIcon } from '@/assets/icon/solid' import { YakitEditor } from '@/components/yakitUI/YakitEditor/YakitEditor' +import { WebFuzzerCasualReplaceReviewOverlay } from '@/pages/fuzzer/WebFuzzerCasualReplaceReviewOverlay' import blastingIdmp4 from '@/assets/blasting-id.mp4' import blastingPwdmp4 from '@/assets/blasting-pwd.mp4' import blastingCountmp4 from '@/assets/blasting-count.mp4' @@ -754,6 +756,7 @@ export interface SelectOptionProps { label: string value: string } + /*LINK - app\renderer\src\main\src\defaultConstants\HTTPFuzzerPage.ts*/ /*为避免文件相互引用造成数据问题,请将 HTTPFuzzerPage 页面的常用变量放在 app\renderer\src\main\src\defaultConstants\HTTPFuzzerPage.ts */ const HTTPFuzzerPageCore: React.FC = (props) => { @@ -787,12 +790,6 @@ const HTTPFuzzerPageCore: React.FC = (props) => { // 切换【配置】/【规则】/【热加载】/ 【Ai】高级内容显示 type const [advancedConfigShowType, setAdvancedConfigShowType] = useState('config') - const [aiAutoApplyRequest, setAiAutoApplyRequest] = useState(false) - /** 仅看勾选态:选项只在 AI 子页展示,但勾选后切到「配置/规则」时仍应自动写回,避免漏应用 */ - const aiAutoApplyWantRef = useRef(false) - useEffect(() => { - aiAutoApplyWantRef.current = aiAutoApplyRequest - }, [aiAutoApplyRequest]) const [currentFuzzerPage, setCurrentFuzzerPage] = useGetSetState(true) const [redirectedResponse, setRedirectedResponse] = useState() const [affixSearch, setAffixSearch] = useState('') @@ -844,6 +841,14 @@ const HTTPFuzzerPageCore: React.FC = (props) => { // first Node const firstNodeRef = useRef(null) + const casualReviewQueueIdRef = useRef(0) + /** 同一次 casual 问答内仅一条审阅;多次 `replace` 只刷新为「快照 vs 最新 raw」 */ + const casualReviewSessionIdRef = useRef(null) + const [casualReviewQueue, setCasualReviewQueue] = useState< + { id: string; payload: WebFuzzerCasualReplaceReviewPayload }[] + >([]) + /** casual 审阅写回后递增,驱动 WebFuzzerNewEditor 内 refreshTrigger 变化以同步 requestRef */ + const [casualEditorApplyNonce, setCasualEditorApplyNonce] = useState(0) const firstNodeSize = useSize(firstNodeRef) // second Node const secondNodeRef = useRef(null) @@ -1847,20 +1852,76 @@ const HTTPFuzzerPageCore: React.FC = (props) => { sendFuzzerSettingInfo() }) + const onCasualReplaceReviewEnqueued = useMemoizedFn((payload: WebFuzzerCasualReplaceReviewPayload) => { + /** 提案与基线完全一致时不入队,避免 mount→自动 done 闪一帧 overlay */ + const incoming = payload.change.request?.raw ?? '' + const normIncoming = String(incoming).replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const normOriginal = String(payload.original ?? '') + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + if (normOriginal === normIncoming) { + if (casualReviewSessionIdRef.current != null) { + setCasualReviewQueue([]) + casualReviewSessionIdRef.current = null + } + return + } + + if (casualReviewSessionIdRef.current == null) { + casualReviewQueueIdRef.current += 1 + casualReviewSessionIdRef.current = `r-${casualReviewQueueIdRef.current}` + } + const id = casualReviewSessionIdRef.current + setCasualReviewQueue([{ id, payload }]) + }) + + const onCasualRoundApplyMerged = useMemoizedFn((mergedRaw: string, done?: boolean) => { + const head = casualReviewQueue[0] + if (!head) return + applyHttpFuzzRequestChangeToWebFuzzerPage( + props.id, + { + ...head.payload.change, + request: { ...head.payload.change.request, raw: mergedRaw }, + }, + { skipReplaceDedup: true }, + ) + setCasualEditorApplyNonce((n) => n + 1) + if (done) { + setCasualReviewQueue([]) + casualReviewSessionIdRef.current = null + } + }) + useLayoutEffect(() => { if (!props.id) return - const unregisterApply = registerWebFuzzerPageApplyRequestFromCard(props.id, (raw) => { + const unregisterApply = registerWebFuzzerPageApplyRequestFromCard(props.id, (raw, extras) => { + if (extras?.isHttps !== undefined) { + setAdvancedConfigValue((prev) => { + if (prev.isHttps === extras.isHttps) return prev + return { + ...prev, + isHttps: extras.isHttps!, + ...(!extras.isHttps + ? { + isGmTLS: false, + randomJA3: false, + } + : {}), + } + }) + } onSetRequest(raw) refreshRequest() }) const unregisterGet = registerWebFuzzerPageGetRequestString(props.id, () => requestRef.current) - const unregisterAuto = registerWebFuzzerPageAiAutoApplyEnabled(props.id, () => aiAutoApplyWantRef.current) + const unregisterCasualReview = registerWebFuzzerPageCasualReplaceReview(props.id, onCasualReplaceReviewEnqueued) return () => { unregisterApply() unregisterGet() - unregisterAuto() + unregisterCasualReview() } - }, [props.id, onSetRequest, refreshRequest]) + }, [props.id, onSetRequest, refreshRequest, onCasualReplaceReviewEnqueued]) const onInsertYakFuzzerFun = useMemoizedFn(() => { if (webFuzzerNewEditorRef.current) onInsertYakFuzzer(webFuzzerNewEditorRef.current.reqEditor) }) @@ -2608,24 +2669,6 @@ const HTTPFuzzerPageCore: React.FC = (props) => { }) }} /> - {advancedConfigShowType === 'ai' && ( - <> - - - {t('HTTPFuzzerPage.ai_auto_patch_request')} - - { - const v = e.target.checked - setAiAutoApplyRequest(v) - if (!v && props.id) { - clearWebFuzzerLastAiAutoApplySnapshot(props.id) - } - }} - /> - - )}
@@ -2788,10 +2831,11 @@ const HTTPFuzzerPageCore: React.FC = (props) => { firstNodeStyle={{ padding: secondFull ? 0 : undefined, display: secondFull ? 'none' : '' }} {...ResizeBoxProps} firstNode={ -
+
= (props) => { } privacy={privacy} /> + {casualReviewQueue[0] ? ( + + ) : null}
} secondNode={ diff --git a/app/renderer/src/main/src/pages/fuzzer/HttpQueryAdvancedConfig/HttpQueryAdvancedConfig.tsx b/app/renderer/src/main/src/pages/fuzzer/HttpQueryAdvancedConfig/HttpQueryAdvancedConfig.tsx index 6633e3a5be..73ac4d19cf 100644 --- a/app/renderer/src/main/src/pages/fuzzer/HttpQueryAdvancedConfig/HttpQueryAdvancedConfig.tsx +++ b/app/renderer/src/main/src/pages/fuzzer/HttpQueryAdvancedConfig/HttpQueryAdvancedConfig.tsx @@ -1360,29 +1360,43 @@ export const HttpQueryAdvancedConfig: React.FC = R return <> } }) + const isAiTab = showFormContentType === 'ai' + return (
- {showFormContentType === 'ai' ? ( -
{fuzzerAiSlot}
- ) : ( -
{ - onSetValue(allFields) - }} - size="small" - labelCol={{ span: 10 }} - wrapperCol={{ span: 14 }} - style={{ overflowY: 'auto', flex: 1, minHeight: 0 }} - initialValues={{ - ...advancedConfigValue, - }} - > - {renderContent()} -
{t('YakitEmpty.end_of_list')}
-
- )} + {/* + AI 与表单同时挂载、用 display 切换,避免切走「配置/规则/…」再回 AI 时整树卸载, + Virtuoso / useRequest 重跑导致「空白 → 内容 → 空白 → 正常」的闪烁。 + */} +
+ {fuzzerAiSlot} +
+
{ + onSetValue(allFields) + }} + size="small" + labelCol={{ span: 10 }} + wrapperCol={{ span: 14 }} + style={{ + overflowY: 'auto', + flex: 1, + minHeight: 0, + ...(isAiTab ? { display: 'none' } : {}), + }} + initialValues={{ + ...advancedConfigValue, + }} + > + {renderContent()} +
{t('YakitEmpty.end_of_list')}
+
void +} + +const WebFuzzerCasualReplaceReviewOverlay = memo(function WebFuzzerCasualReplaceReviewOverlayInner( + props: WebFuzzerCasualReplaceReviewOverlayProps, +) { + const { roundKey, payload, onApplyRound } = props + const { t } = useI18nNamespaces(['webFuzzer']) + /** casual 开始时快照(会话级不变) */ + const sessionSnapshot = payload.original + const incoming = payload.change.request?.raw ?? '' + + /** Diff 左侧:已「保留」片段与未处理部分合成;流式更新右侧时不清空,避免丢进度 */ + const [reviewBaseline, setReviewBaseline] = useState(sessionSnapshot) + /** 右侧提案:流式/新 raw 同步;「撤销」某块时裁切 */ + const [draftIncoming, setDraftIncoming] = useState(incoming) + /** 与 `diff` 行级块一致,避免 Monaco getLineChanges 拆块导致「保留 1/3」只合了一小段 */ + const hunks = useMemo(() => computeCasualLineHunks(reviewBaseline, draftIncoming), [reviewBaseline, draftIncoming]) + /** 左侧或右侧任一变化后递增,强制 Monaco 重挂 */ + const [diffNonce, setDiffNonce] = useState(0) + const diffNonceRef = useRef(0) + /** 控制「全部接受」元数据预览卡片显示:仅当悬浮在按钮或卡片时显示 */ + const [showAcceptMeta, setShowAcceptMeta] = useState(false) + const acceptMetaHideTimerRef = useRef | null>(null) + /** 无差异时避免重复触发 onApplyRound(done) */ + const autoDoneRef = useRef(false) + const hunksRef = useRef(hunks) + const reviewBaselineRef = useRef(reviewBaseline) + const draftIncomingRef = useRef(draftIncoming) + const prevIncomingRef = useRef(null) + + const bumpDiffNonce = useMemoizedFn(() => { + diffNonceRef.current += 1 + setDiffNonce(diffNonceRef.current) + }) + + const handleAcceptMetaEnter = useMemoizedFn(() => { + if (acceptMetaHideTimerRef.current) { + clearTimeout(acceptMetaHideTimerRef.current) + acceptMetaHideTimerRef.current = null + } + setShowAcceptMeta(true) + }) + + const handleAcceptMetaLeave = useMemoizedFn(() => { + if (acceptMetaHideTimerRef.current) { + clearTimeout(acceptMetaHideTimerRef.current) + } + // 留出从按钮移动到卡片之间的短暂间隙,避免穿越空白区域时闪烁 + acceptMetaHideTimerRef.current = setTimeout(() => { + setShowAcceptMeta(false) + acceptMetaHideTimerRef.current = null + }, 80) + }) + + useEffect(() => { + return () => { + if (acceptMetaHideTimerRef.current) { + clearTimeout(acceptMetaHideTimerRef.current) + acceptMetaHideTimerRef.current = null + } + } + }, []) + + hunksRef.current = hunks + + useEffect(() => { + reviewBaselineRef.current = reviewBaseline + }, [reviewBaseline]) + + useEffect(() => { + draftIncomingRef.current = draftIncoming + }, [draftIncoming]) + + useEffect(() => { + autoDoneRef.current = false + reviewBaselineRef.current = sessionSnapshot + draftIncomingRef.current = incoming + setReviewBaseline(sessionSnapshot) + setDraftIncoming(incoming) + bumpDiffNonce() + prevIncomingRef.current = null + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [roundKey, sessionSnapshot, bumpDiffNonce]) + + useEffect(() => { + if (prevIncomingRef.current === null) { + prevIncomingRef.current = incoming + return + } + if (prevIncomingRef.current === incoming) return + prevIncomingRef.current = incoming + draftIncomingRef.current = incoming + bumpDiffNonce() + setDraftIncoming(incoming) + }, [incoming, bumpDiffNonce]) + + useEffect(() => { + if (hunks.length !== 0) { + autoDoneRef.current = false + return + } + if (norm(reviewBaseline) !== norm(draftIncoming)) return + if (autoDoneRef.current) return + autoDoneRef.current = true + onApplyRound(reviewBaselineRef.current, true) + }, [hunks, reviewBaseline, draftIncoming, onApplyRound]) + + const setChangeDecision = useMemoizedFn((idx: number, v: 'accept' | 'reject') => { + const hs = hunksRef.current + if (!hs[idx]) return + const rb = reviewBaselineRef.current + const di = draftIncomingRef.current + const decMap: Record = {} + hs.forEach((h, j) => { + decMap[h.id] = j === idx ? v : v === 'accept' ? 'reject' : 'accept' + }) + if (v === 'accept') { + const merged = mergeCasualLineHunks(rb, hs, decMap) + reviewBaselineRef.current = merged + setReviewBaseline(merged) + bumpDiffNonce() + onApplyRound(merged, false) + return + } + const nextDraft = mergeCasualLineHunks(rb, hs, decMap) + draftIncomingRef.current = nextDraft + setDraftIncoming(nextDraft) + bumpDiffNonce() + }) + + const handleRejectAll = useMemoizedFn(() => { + const rb = reviewBaselineRef.current + draftIncomingRef.current = rb + setDraftIncoming(rb) + bumpDiffNonce() + onApplyRound(rb, true) + }) + + const handleAcceptAll = useMemoizedFn(() => { + const hs = hunksRef.current + const rb = reviewBaselineRef.current + const decMap: Record = {} + hs.forEach((h) => { + decMap[h.id] = 'accept' + }) + const merged = mergeCasualLineHunks(rb, hs, decMap) + reviewBaselineRef.current = merged + draftIncomingRef.current = merged + setReviewBaseline(merged) + setDraftIncoming(merged) + bumpDiffNonce() + onApplyRound(merged, true) + }) + + const acceptMetaPreview = useMemo(() => { + const change = payload.change + const req = change?.request ?? {} + const { raw: _raw, ...request } = req as Record + // const preview = { + // ...change, + // request: request, + // } + // return JSON.stringify(request, null, 2) + return request + }, [payload.change]) + + return ( +
+
+ +
+ {hunks.length > 0 && ( +
+ + {t('HTTPFuzzerPage.aiCasualRejectAll')} + +
+ + {t('HTTPFuzzerPage.aiCasualAcceptAll')} + + {showAcceptMeta && ( +
+
+
{t('HTTPFuzzerPage.aiCasualAcceptAllMetaTitle')}
+ +
+ {Object.keys(acceptMetaPreview ?? {}).map((it) => { + const value = acceptMetaPreview?.[it] + return ( +
+ {it}: {value !== undefined && value !== null ? JSON.stringify(value, null, 2) : '-'} +
+ ) + })} +
+ )} +
+
+ )} +
+ ) +}) + +WebFuzzerCasualReplaceReviewOverlay.displayName = 'WebFuzzerCasualReplaceReviewOverlay' + +export { WebFuzzerCasualReplaceReviewOverlay } diff --git a/app/renderer/src/main/src/pages/fuzzer/WebFuzzerNewEditor/WebFuzzerNewEditor.tsx b/app/renderer/src/main/src/pages/fuzzer/WebFuzzerNewEditor/WebFuzzerNewEditor.tsx index b9b0a30fa2..9cbcd80321 100644 --- a/app/renderer/src/main/src/pages/fuzzer/WebFuzzerNewEditor/WebFuzzerNewEditor.tsx +++ b/app/renderer/src/main/src/pages/fuzzer/WebFuzzerNewEditor/WebFuzzerNewEditor.tsx @@ -1,21 +1,16 @@ -import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import React, { useImperativeHandle, useMemo, useState } from 'react' import { IMonacoEditor, NewHTTPPacketEditor } from '@/utils/editors' import { insertFileFuzzTag, insertTemporaryFileFuzzTag } from '../InsertFileFuzzTag' import { monacoEditorWrite } from '../fuzzerTemplates' import { OtherMenuListProps } from '@/components/yakitUI/YakitEditor/YakitEditorType' -import { execCodec } from '@/utils/encodec' import { copyAsUrl, ByteCountTag, showDictsAndSelect } from '../HTTPFuzzerPage' -import styles from './WebFuzzerNewEditor.module.scss' import { showYakitModal } from '@/components/yakitUI/YakitModal/YakitModalConfirm' import { setRemoteValue } from '@/utils/kv' import { useMemoizedFn } from 'ahooks' import { HTTPFuzzerHotPatch } from '../HTTPFuzzerHotPatch' import { yakitNotify } from '@/utils/notification' import { openExternalWebsite, openPacketNewWindow } from '@/utils/openWebsite' -import { setClipboardText } from '@/utils/clipboard' -import { setEditorContext } from '@/utils/monacoSpec/yakEditor' import { FuzzerRemoteGV } from '@/enums/fuzzer' -import { YakitTag } from '@/components/yakitUI/YakitTag/YakitTag' import { useSelectionByteCount } from '@/components/yakitUI/YakitEditor/useSelectionByteCount' import { useI18nNamespaces } from '@/i18n/useI18nNamespaces' const { ipcRenderer } = window.require('electron') @@ -23,6 +18,8 @@ const { ipcRenderer } = window.require('electron') export interface WebFuzzerNewEditorProps { ref?: any refreshTrigger: boolean + /** casual 审阅分段写回时递增,与 refreshTrigger 组合以强制请求编辑器同步 `requestRef`(避免仅 ref 更新子组件未吃到新 props) */ + casualEditorApplyNonce?: number request: string hex: boolean isHttps: boolean @@ -42,6 +39,7 @@ export const WebFuzzerNewEditor: React.FC = React.memo( React.forwardRef((props, ref) => { const { refreshTrigger, + casualEditorApplyNonce = 0, request, setRequest, isHttps, @@ -162,7 +160,7 @@ export const WebFuzzerNewEditor: React.FC = React.memo( defaultHttps={isHttps} isShowBeautifyRender={false} showDefaultExtra={false} - refreshTrigger={refreshTrigger} + refreshTrigger={`${refreshTrigger}_${casualEditorApplyNonce}`} noMinimap={true} utf8={true} originValue={request} diff --git a/app/renderer/src/main/src/pages/fuzzer/webFuzzerAiRequestApplyBridge.ts b/app/renderer/src/main/src/pages/fuzzer/webFuzzerAiRequestApplyBridge.ts index 42e101e732..073279e2a8 100644 --- a/app/renderer/src/main/src/pages/fuzzer/webFuzzerAiRequestApplyBridge.ts +++ b/app/renderer/src/main/src/pages/fuzzer/webFuzzerAiRequestApplyBridge.ts @@ -1,21 +1,55 @@ +import type { AIAgentGrpcApi } from '@/pages/ai-re-act/hooks/grpcApi' import { yakitFailed } from '@/utils/notification' -const pageApplyHandlers = new Map void>() +export type WebFuzzerApplyRequestExtras = { isHttps?: boolean } + +const pageApplyHandlers = new Map void>() const pageGetRequestHandlers = new Map string>() -const pageAiAutoApplyGetEnabled = new Map boolean>() -type WebFuzzerAiAutoApplyLast = { streamId: string | null; content: string } +const lastAppliedReplaceRequestByPage = new Map() + +/** Web Fuzzer 页内联审阅:问答开始前快照 vs AI `replace`(由 `HTTPFuzzerPageCore` 注册) */ +export type WebFuzzerCasualReplaceReviewPayload = { + original: string + change: AIAgentGrpcApi.HttpFuzzRequestChange +} + +type WebFuzzerCasualReplaceReviewHandler = (payload: WebFuzzerCasualReplaceReviewPayload) => void + +const pageCasualReplaceReviewHandlers = new Map() + +export function registerWebFuzzerPageCasualReplaceReview( + pageId: string, + handler: WebFuzzerCasualReplaceReviewHandler, +): () => void { + pageCasualReplaceReviewHandlers.set(pageId, handler) + return () => { + if (pageCasualReplaceReviewHandlers.get(pageId) === handler) { + pageCasualReplaceReviewHandlers.delete(pageId) + } + } +} -const lastWebFuzzerAiAutoApply = new Map() -const maxAutoApplyListItemIndex = new Map>() +/** 将 `replace` 交给 Web Fuzzer 页展示审阅(同一会话内多次推送时由页内合并为「快照 vs 最新 raw」单条) */ +export function enqueueWebFuzzerCasualReplaceReview( + pageId: string, + payload: WebFuzzerCasualReplaceReviewPayload, +): void { + const fn = pageCasualReplaceReviewHandlers.get(pageId) + if (fn) fn(payload) +} /** * 由 `HTTPFuzzerPageCore` 在挂载时注册,用于从 AI 代码卡「应用」将请求原文写入当前页并同步会话存储。 */ -export function registerWebFuzzerPageApplyRequestFromCard(pageId: string, handler: (raw: string) => void): () => void { +export function registerWebFuzzerPageApplyRequestFromCard( + pageId: string, + handler: (raw: string, extras?: WebFuzzerApplyRequestExtras) => void, +): () => void { pageApplyHandlers.set(pageId, handler) return () => { if (pageApplyHandlers.get(pageId) === handler) { pageApplyHandlers.delete(pageId) + lastAppliedReplaceRequestByPage.delete(pageId) } } } @@ -39,80 +73,65 @@ export function getWebFuzzerPageRequestString(pageId: string): string | null { return fn() } -/** - * 由 `HTTPFuzzerPageCore` 注册:是否开启「AI 自动改包」(勾选项)。 - */ -export function registerWebFuzzerPageAiAutoApplyEnabled(pageId: string, getEnabled: () => boolean): () => void { - pageAiAutoApplyGetEnabled.set(pageId, getEnabled) - return () => { - if (pageAiAutoApplyGetEnabled.get(pageId) === getEnabled) { - pageAiAutoApplyGetEnabled.delete(pageId) - } +/** 从 Web Fuzzer AI 代码卡将内容应用到指定页签的请求编辑器(会触发 `onSetRequest` 的会话落盘与编辑器刷新) */ +export function applyRequestContentToWebFuzzerPage(pageId: string, raw: string): void { + const fn = pageApplyHandlers.get(pageId) + if (!fn) { + yakitFailed('未找到对应的 Web Fuzzer 页,请保持该页已打开。') + return } -} - -export function clearWebFuzzerLastAiAutoApplySnapshot(pageId: string) { - lastWebFuzzerAiAutoApply.delete(pageId) - maxAutoApplyListItemIndex.delete(pageId) + fn(raw) } /** - * `AIYaklangCode` 的 `content` 更新时调用:在已勾选自动改包且内容相对上次有变化时,等同「应用」写入请求盒并落盘(无 yakit 失败弹窗) - * @param autoApplyStreamId 单条流/卡片的 `stream.id`:不同回复即使报文字符串与上一条终稿相同也应再次应用 - * @param autoApplyChatSessionId 当前 ReAct 会话,换会话时列表下标会重置 - * @param listItemIndex 在 `chats.elements` 中的下标,用于拒绝虚拟列表中更早条目重挂载时的越权覆盖 + * 由引擎 `http_fuzz_request_change` 推送:按 `op` 写入 Web Fuzzer。 + * 当前仅处理 `replace`;其它 `op` 在 `switch` 的 `default` 中预留扩展。 */ -export function tryWebFuzzerAutoApplyRequestFromAiYaklangCode( +export type ApplyHttpFuzzRequestChangeOptions = { + /** 为 true 时跳过「与上次 replace 完全相同则忽略」;用于 casual 分段保留等仍须触发写回/刷新的场景 */ + skipReplaceDedup?: boolean +} + +export function applyHttpFuzzRequestChangeToWebFuzzerPage( pageId: string, - content: string | undefined, - autoApplyStreamId?: string, - autoApplyChatSessionId?: string, - listItemIndex?: number, + data: AIAgentGrpcApi.HttpFuzzRequestChange, + options?: ApplyHttpFuzzRequestChangeOptions, ): void { - if (content === undefined) return - if (content.trim() === '') return - if (!pageAiAutoApplyGetEnabled.get(pageId)?.()) return - const sessionId = (autoApplyChatSessionId || '').trim() - if (sessionId && listItemIndex !== undefined) { - const bySess = maxAutoApplyListItemIndex.get(pageId) - const maxIdx = bySess?.get(sessionId) ?? -1 - if (listItemIndex < maxIdx) { - return - } - } - const last = lastWebFuzzerAiAutoApply.get(pageId) - const id = (autoApplyStreamId || '').trim() || null - if (last) { - if (id) { - if (last.streamId === id && last.content === content) return - } else if (last.content === content) { - // 无 stream id 时与旧版一致:全串相同时跳过 + const op = data?.op + + switch (op) { + case 'replace': { + const fn = pageApplyHandlers.get(pageId) + if (!fn) { + yakitFailed('未找到对应的 Web Fuzzer 页,请保持该页已打开。') + return + } + const raw = data?.request?.raw + if (raw == null || String(raw).trim() === '') return + const normalizedRaw = String(raw) + const isHttps = data.request.is_https + const lastApplied = lastAppliedReplaceRequestByPage.get(pageId) + if ( + !options?.skipReplaceDedup && + lastApplied && + lastApplied.raw === normalizedRaw && + lastApplied.isHttps === isHttps + ) { + return + } + + lastAppliedReplaceRequestByPage.set(pageId, { + raw: normalizedRaw, + isHttps, + }) + fn(normalizedRaw, { isHttps }) return } + default: + // 预留:非 `replace` 的 `op`(如 patch、merge 等)在此分支自行扩展; + // 需要写请求盒时可复用 `pageApplyHandlers.get(pageId)` 或抽新函数。 + break } - const apply = pageApplyHandlers.get(pageId) - if (!apply) return - if (sessionId && listItemIndex !== undefined) { - let m = maxAutoApplyListItemIndex.get(pageId) - if (!m) { - m = new Map() - maxAutoApplyListItemIndex.set(pageId, m) - } - const maxIdx = m.get(sessionId) ?? -1 - m.set(sessionId, Math.max(maxIdx, listItemIndex)) - } - lastWebFuzzerAiAutoApply.set(pageId, { streamId: id, content }) - apply(content) -} - -/** 从 Web Fuzzer AI 代码卡将内容应用到指定页签的请求编辑器(会触发 `onSetRequest` 的会话落盘与编辑器刷新) */ -export function applyRequestContentToWebFuzzerPage(pageId: string, raw: string): void { - const fn = pageApplyHandlers.get(pageId) - if (!fn) { - yakitFailed('未找到对应的 Web Fuzzer 页,请保持该页已打开。') - return - } - fn(raw) } export { WebFuzzerAiRequestCompareModalContent } from './webFuzzerAiRequestCompareModalContent' diff --git a/app/renderer/src/main/src/pages/fuzzer/webFuzzerCasualLineMerge.ts b/app/renderer/src/main/src/pages/fuzzer/webFuzzerCasualLineMerge.ts new file mode 100644 index 0000000000..e24f545b24 --- /dev/null +++ b/app/renderer/src/main/src/pages/fuzzer/webFuzzerCasualLineMerge.ts @@ -0,0 +1,119 @@ +import { diffLines } from 'diff' + +export type CasualLineHunk = { + id: string + /** 在基准文本中该变更起始行(0-based,与 `baseline.split('\n')` 对齐) */ + origStart: number + origLines: string[] + newLines: string[] +} + +function norm(s: string): string { + return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n') +} + +function partToLines(value: string): string[] { + if (!value) return [] + const endsNl = value.endsWith('\n') + const core = endsNl ? value.slice(0, -1) : value + return core.split('\n') +} + +/** 将基准与 AI 建议拆成行级变更块,供逐块引用/放弃 */ +export function computeCasualLineHunks(baseline: string, incoming: string): CasualLineHunk[] { + const base = norm(baseline) + const inc = norm(incoming) + const parts = diffLines(base, inc) + const hunks: CasualLineHunk[] = [] + let origCursor = 0 + let hid = 0 + + let i = 0 + while (i < parts.length) { + const p = parts[i] + if (!p.added && !p.removed) { + origCursor += partToLines(p.value).length + i++ + continue + } + if (p.removed) { + const origLines = partToLines(p.value) + const next = parts[i + 1] + if (next?.added) { + const newLines = partToLines(next.value) + hunks.push({ + id: `h${hid++}`, + origStart: origCursor, + origLines, + newLines, + }) + origCursor += origLines.length + i += 2 + } else { + hunks.push({ + id: `h${hid++}`, + origStart: origCursor, + origLines, + newLines: [], + }) + origCursor += origLines.length + i += 1 + } + continue + } + if (p.added) { + const newLines = partToLines(p.value) + hunks.push({ + id: `h${hid++}`, + origStart: origCursor, + origLines: [], + newLines, + }) + i++ + continue + } + i++ + } + return hunks +} + +/** 按每块的引用/放弃生成合并后的请求正文 */ +export function mergeCasualLineHunks( + baseline: string, + hunks: CasualLineHunk[], + decisions: Record, +): string { + const baseLines = norm(baseline).split('\n') + const sorted = [...hunks].sort((a, b) => a.origStart - b.origStart) + let cursor = 0 + const result: string[] = [] + + const emitBase = (until: number) => { + while (cursor < until) { + result.push(baseLines[cursor] ?? '') + cursor++ + } + } + + for (const h of sorted) { + emitBase(h.origStart) + const dec = decisions[h.id] + if (dec !== 'accept' && dec !== 'reject') { + throw new Error(`missing decision for hunk ${h.id}`) + } + if (h.origLines.length === 0) { + if (dec === 'accept') { + result.push(...h.newLines) + } + continue + } + if (dec === 'accept') { + result.push(...h.newLines) + } else { + result.push(...h.origLines) + } + cursor += h.origLines.length + } + emitBase(baseLines.length) + return result.join('\n') +} diff --git a/app/renderer/src/main/src/pages/fuzzer/webFuzzerMonacoLineMerge.ts b/app/renderer/src/main/src/pages/fuzzer/webFuzzerMonacoLineMerge.ts new file mode 100644 index 0000000000..b03e100fc4 --- /dev/null +++ b/app/renderer/src/main/src/pages/fuzzer/webFuzzerMonacoLineMerge.ts @@ -0,0 +1,80 @@ +export type MonacoLineChangeLike = { + originalStartLineNumber: number + originalEndLineNumber: number + modifiedStartLineNumber: number + modifiedEndLineNumber: number +} + +function norm(s: string): string { + return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n') +} + +function startIndexFromOriginal(lineNumber: number): number { + if (lineNumber <= 0) return 0 + return lineNumber - 1 +} + +function spanLength(startLineNumber: number, endLineNumber: number): number { + if (startLineNumber <= 0 || endLineNumber <= 0) return 0 + if (endLineNumber < startLineNumber) return 0 + return endLineNumber - startLineNumber + 1 +} + +function sliceStartIndex(lineNumber: number): number { + if (lineNumber <= 0) return 0 + return lineNumber - 1 +} + +/** 与 Monaco `getLineChanges()` 结构一致;按每块 accept/reject 合并为最终 raw */ +export function mergeByMonacoLineChanges( + baseline: string, + incoming: string, + changes: MonacoLineChangeLike[], + decisions: ('accept' | 'reject')[], +): string { + const baseLines = norm(baseline).split('\n') + const incLines = norm(incoming).split('\n') + const out: string[] = [] + let cursor = 0 + + const sortedIdx = changes + .map((_, i) => i) + .sort((ia, ib) => changes[ia].originalStartLineNumber - changes[ib].originalStartLineNumber) + + for (const ci of sortedIdx) { + const c = changes[ci] + const dec = decisions[ci] + if (dec !== 'accept' && dec !== 'reject') { + throw new Error(`mergeByMonacoLineChanges: missing decision for change #${ci}`) + } + + const start = startIndexFromOriginal(c.originalStartLineNumber) + while (cursor < start) { + out.push(baseLines[cursor] ?? '') + cursor++ + } + + const origLen = spanLength(c.originalStartLineNumber, c.originalEndLineNumber) + const modLen = spanLength(c.modifiedStartLineNumber, c.modifiedEndLineNumber) + + if (dec === 'accept') { + if (modLen > 0) { + const modStart = sliceStartIndex(c.modifiedStartLineNumber) + out.push(...incLines.slice(modStart, modStart + modLen)) + } + cursor += origLen + } else { + if (origLen > 0) { + out.push(...baseLines.slice(cursor, cursor + origLen)) + } + cursor += origLen + } + } + + while (cursor < baseLines.length) { + out.push(baseLines[cursor] ?? '') + cursor++ + } + + return out.join('\n') +} diff --git a/app/renderer/src/main/src/utils/editors.tsx b/app/renderer/src/main/src/utils/editors.tsx index 3c611d284a..45cbd0e590 100644 --- a/app/renderer/src/main/src/utils/editors.tsx +++ b/app/renderer/src/main/src/utils/editors.tsx @@ -13,7 +13,7 @@ import { EnterOutlined, FullscreenOutlined, SettingOutlined, ThunderboltFilled } import { HTTPFlowBodyByIdRequest, HTTPPacketFuzzable } from '../components/HTTPHistory' import ReactResizeDetector from 'react-resize-detector' -import { useDebounceFn, useMemoizedFn, useUpdateEffect } from 'ahooks' +import { useDebounceFn, useMemoizedFn, useUpdateEffect, useWhyDidYouUpdate } from 'ahooks' import { Buffer } from 'buffer' import { StringToUint8Array, Uint8ArrayToString } from './str' import { getRemoteValue } from '@/utils/kv'