Files
yaklang-chrome-extension/src/features/browser-transform/BrowserTransformWorkspace.tsx
T

772 lines
50 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
AlertTriangle, ArrowDown, ArrowRight, Braces, CheckCircle2, ChevronDown, CirclePlus, Code2,
FileInput, FileKey2, FlaskConical, Link2, Play, Plus, RefreshCw, Save, Sparkles, Trash2, Unplug,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { errorMessage, request } from '@/platform/messaging/runtime';
import type {
ActiveTabInfo, BrowserPageCallable, BrowserRecordingEvent, BrowserTransformBuiltinOperation,
BrowserTransformDirection, BrowserTransformDirectionName, BrowserTransformExecution,
BrowserTransformNodeReference, BrowserTransformPipelineNode, BrowserTransformProfile,
BrowserTransformProfileInput, BrowserProfileInferenceCandidate,
} from '@/types/models';
import {
compileGuidedTransform, defaultGuidedTransform, guidedOutputDescription, parseGuidedTransform,
type GuidedTransformDraft, type GuidedTransformOutputKind,
} from './guided';
import { createBrowserTransformProfileInput } from './profile-draft';
import {
clearBrowserTransformReplayDraft,
deleteBrowserTransformReplayDrafts,
getBrowserTransformReplayDraft,
saveBrowserTransformReplayDraft,
type BrowserTransformReplayDraftFields,
type BrowserTransformReplayDraftInput,
} from './replay-draft';
import './browser-transform-workspace.css';
type RunTask = (task: () => Promise<void>, success?: string) => Promise<void>;
interface BrowserTransformWorkspaceProps {
tab?: ActiveTabInfo;
selectedEvent?: BrowserRecordingEvent;
busy: boolean;
run: RunTask;
onOpenCapture: () => void;
suggestion?: BrowserTransformSuggestionSeed;
}
export interface BrowserTransformSuggestionSeed {
revision: number;
candidate: BrowserProfileInferenceCandidate;
callable: BrowserPageCallable;
profile: BrowserTransformProfile;
sampleBody?: string;
sampleLabel?: string;
}
const BUILTINS: Array<{ value: BrowserTransformBuiltinOperation; label: string }> = [
{ value: 'value.literal', label: '固定值' },
{ value: 'json.stringify', label: 'JSON 序列化' },
{ value: 'json.parse', label: 'JSON 解析' },
{ value: 'text.toString', label: '转为文本' },
{ value: 'url.encode', label: 'URL 编码' },
{ value: 'url.decode', label: 'URL 解码' },
{ value: 'base64.encode', label: 'Base64 编码' },
{ value: 'base64.decode', label: 'Base64 解码' },
{ value: 'hex.encode', label: 'Hex 编码' },
{ value: 'hex.decode', label: 'Hex 解码' },
{ value: 'object.pick', label: '选择对象字段' },
{ value: 'object.compose', label: '组合对象' },
{ value: 'form.compose', label: '组合表单' },
];
function originOf(url?: string): string {
try { return url ? new URL(url).origin : ''; } catch { return ''; }
}
function absoluteUrl(value?: string, base?: string): string {
try { return value ? new URL(value, base).toString() : base || ''; } catch { return value || base || ''; }
}
function uid(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`;
}
type GuidedInputKind = 'body' | 'body-field' | 'text' | 'header-field' | 'query-field' | 'custom';
function splitInputPath(path: string): { kind: GuidedInputKind; field: string } {
if (path === 'body') return { kind: 'body', field: '' };
if (path === 'text') return { kind: 'text', field: '' };
if (path.startsWith('body.')) return { kind: 'body-field', field: path.slice(5) };
if (path.startsWith('headers.')) return { kind: 'header-field', field: path.slice(8) };
if (path.startsWith('query.')) return { kind: 'query-field', field: path.slice(6) };
return { kind: 'custom', field: path };
}
function joinInputPath(kind: GuidedInputKind, field: string): string {
if (kind === 'body') return 'body';
if (kind === 'text') return 'text';
if (kind === 'body-field') return `body.${field.trim()}`;
if (kind === 'header-field') return `headers.${field.trim().toLowerCase()}`;
if (kind === 'query-field') return `query.${field.trim()}`;
return field.trim();
}
function outputFieldLabel(kind: GuidedTransformOutputKind): string {
if (kind === 'json-field') return 'JSON 字段名';
if (kind === 'form-field') return '表单字段名';
if (kind === 'header') return 'Header 名称';
if (kind === 'query') return 'Query 参数名';
return '';
}
const INPUT_ROLE_LABELS: Record<BrowserPageCallable['inputSlots'][number]['role'], string> = {
data: '明文数据',
key: '密钥',
iv: 'IV',
algorithm: '算法',
options: '选项',
signature: '签名',
salt: 'Salt',
nonce: 'Nonce',
aad: '附加数据',
unknown: '页面参数',
};
function toInput(profile: BrowserTransformProfile): BrowserTransformProfileInput {
return {
id: profile.id,
name: profile.name,
enabled: profile.enabled,
target: { ...profile.target },
origin: profile.origin,
match: { methods: [...profile.match.methods], urlPattern: profile.match.urlPattern },
request: structuredClone(profile.request),
response: structuredClone(profile.response),
failMode: 'closed',
maxConcurrency: profile.maxConcurrency,
};
}
function profileFingerprint(profile?: BrowserTransformProfileInput): string {
return profile ? JSON.stringify(profile) : '';
}
function encodeUtf8(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function formatSampleBody(value: string): string {
try {
const parsed = JSON.parse(value) as unknown;
return typeof parsed === 'object' && parsed !== null ? JSON.stringify(parsed, null, 2) : value;
} catch {
return value;
}
}
function sampleHeaders(value: string): string {
try {
JSON.parse(value);
return '{"Content-Type":"application/json"}';
} catch {
return '{"Content-Type":"text/plain; charset=utf-8"}';
}
}
const DEFAULT_REPLAY_BODY = '{\n "value": "plain"\n}';
type ReplayPersistenceState = 'memory' | 'loading' | 'ready' | 'saving' | 'saved' | 'too-large' | 'error';
interface PendingReplaySave {
key: string;
fingerprint: string;
input: BrowserTransformReplayDraftInput;
revision: number;
timeout: ReturnType<typeof setTimeout>;
}
function defaultReplayFields(tab?: ActiveTabInfo, selectedEvent?: BrowserRecordingEvent): BrowserTransformReplayDraftFields {
return {
method: selectedEvent?.method || 'POST',
url: absoluteUrl(selectedEvent?.url, tab?.url),
headers: '{"Content-Type":"application/json"}',
body: DEFAULT_REPLAY_BODY,
};
}
function replayFieldsFingerprint(fields: BrowserTransformReplayDraftFields): string {
return JSON.stringify(fields);
}
function replayPersistenceLabel(state: ReplayPersistenceState): string {
if (state === 'memory') return '保存网关后自动保存';
if (state === 'loading') return '正在恢复';
if (state === 'ready') return '本机自动保存';
if (state === 'saving') return '正在保存';
if (state === 'saved') return '本机已保存';
if (state === 'too-large') return '样本过大';
return '保存失败';
}
function nodeLabel(kind: BrowserTransformPipelineNode['kind']): string {
if (kind === 'context.read') return '上下文';
if (kind === 'builtin') return '内置转换';
if (kind === 'page.call') return '页面函数';
return '输出';
}
function callableKindLabel(callable: BrowserPageCallable): string {
if (callable.kind === 'recorded-call') return '录制调用';
if (callable.kind === 'business-closure') return '业务闭包';
if (callable.kind === 'request-transaction') return '请求事务';
return '全局函数';
}
function referencesOf(node: BrowserTransformPipelineNode): BrowserTransformNodeReference[] {
if (node.kind === 'builtin') return node.inputs;
if (node.kind === 'page.call') return node.arguments;
if (node.kind === 'output.write') return [node.source];
return [];
}
export function BrowserTransformWorkspace({ tab, selectedEvent, busy, run, onOpenCapture, suggestion }: BrowserTransformWorkspaceProps) {
const [profiles, setProfiles] = useState<BrowserTransformProfile[]>([]);
const [callables, setCallables] = useState<BrowserPageCallable[]>([]);
const [selectedProfileId, setSelectedProfileId] = useState('');
const [draft, setDraft] = useState<BrowserTransformProfileInput>();
const [directionName, setDirectionName] = useState<BrowserTransformDirectionName>('request');
const [loadError, setLoadError] = useState('');
const [testMethod, setTestMethod] = useState('POST');
const [testUrl, setTestUrl] = useState('');
const [testHeaders, setTestHeaders] = useState('{"Content-Type":"application/json"}');
const [testBody, setTestBody] = useState(DEFAULT_REPLAY_BODY);
const [testSample, setTestSample] = useState<{ body: string; label: string }>();
const [testResult, setTestResult] = useState<BrowserTransformExecution>();
const [testError, setTestError] = useState('');
const [replayPersistence, setReplayPersistence] = useState<ReplayPersistenceState>('memory');
const [replayStorageError, setReplayStorageError] = useState('');
const [replayLoadedKey, setReplayLoadedKey] = useState('');
const [editorMode, setEditorMode] = useState<'guided' | 'advanced'>('guided');
const [confirmDeleteCallableId, setConfirmDeleteCallableId] = useState('');
const handledSuggestion = useRef(0);
const replayLoadRevision = useRef(0);
const replaySaveRevision = useRef(0);
const replayBaselineFingerprint = useRef('');
const replayStablePersistence = useRef<ReplayPersistenceState>('memory');
const pendingReplaySeed = useRef<{ key: string; fields: BrowserTransformReplayDraftFields } | undefined>(undefined);
const pendingReplaySave = useRef<PendingReplaySave | undefined>(undefined);
const replayProfileId = draft?.id || '';
const replayOrigin = draft?.origin || '';
const replayKey = replayProfileId ? `${replayProfileId}:${directionName}` : '';
const replayActiveKey = useRef(replayKey);
replayActiveKey.current = replayKey;
const workspaceMounted = useRef(false);
const replayFields = useMemo<BrowserTransformReplayDraftFields>(() => ({
method: testMethod,
url: testUrl,
headers: testHeaders,
body: testBody,
sample: testSample,
}), [testBody, testHeaders, testMethod, testSample, testUrl]);
const replayFingerprint = useMemo(() => replayFieldsFingerprint(replayFields), [replayFields]);
const applyReplayFields = useCallback((fields: BrowserTransformReplayDraftFields) => {
setTestMethod(fields.method);
setTestUrl(fields.url);
setTestHeaders(fields.headers);
setTestBody(fields.body);
setTestSample(fields.sample);
setTestResult(undefined);
setTestError('');
}, []);
const discardPendingReplaySave = useCallback(() => {
replaySaveRevision.current += 1;
if (pendingReplaySave.current) clearTimeout(pendingReplaySave.current.timeout);
pendingReplaySave.current = undefined;
}, []);
const flushPendingReplaySave = useCallback(() => {
const pending = pendingReplaySave.current;
if (!pending) return;
clearTimeout(pending.timeout);
pendingReplaySave.current = undefined;
replaySaveRevision.current += 1;
void saveBrowserTransformReplayDraft(pending.input).catch(() => undefined);
}, []);
const load = useCallback(async () => {
try {
const target = tab ? { tabId: tab.id, frameId: 0 } : undefined;
const [nextProfiles, nextCallables] = await Promise.all([
request('transform.profile.list', target || {}),
target ? request('callable.list', target).catch(() => []) : Promise.resolve([]),
]);
setProfiles(nextProfiles);
setCallables(nextCallables);
setLoadError('');
setSelectedProfileId((current) => nextProfiles.some((profile) => profile.id === current) ? current : nextProfiles[0]?.id || '');
} catch (error) {
setLoadError(errorMessage(error));
}
}, [tab]);
useEffect(() => {
workspaceMounted.current = true;
return () => { workspaceMounted.current = false; };
}, []);
useEffect(() => { void load(); }, [load]);
useEffect(() => {
const selected = profiles.find((profile) => profile.id === selectedProfileId);
if (selected) {
setDraft(toInput(selected));
const selectedDirection = selected.request.enabled ? selected.request : selected.response;
setEditorMode(parseGuidedTransform(selectedDirection, callables) ? 'guided' : 'advanced');
}
}, [callables, profiles, selectedProfileId]);
useEffect(() => {
const revision = ++replayLoadRevision.current;
discardPendingReplaySave();
setReplayLoadedKey('');
setReplayStorageError('');
const fallback = defaultReplayFields(tab, selectedEvent);
if (!replayProfileId) {
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
replayStablePersistence.current = 'memory';
applyReplayFields(fallback);
setReplayPersistence('memory');
return;
}
const seed = pendingReplaySeed.current?.key === replayKey ? pendingReplaySeed.current.fields : undefined;
if (seed) {
pendingReplaySeed.current = undefined;
replayBaselineFingerprint.current = '';
replayStablePersistence.current = 'ready';
applyReplayFields(seed);
setReplayPersistence('ready');
setReplayLoadedKey(replayKey);
return;
}
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
replayStablePersistence.current = 'ready';
applyReplayFields(fallback);
setReplayPersistence('loading');
void getBrowserTransformReplayDraft(replayProfileId, directionName, replayOrigin)
.then((stored) => {
if (!workspaceMounted.current || replayLoadRevision.current !== revision) return;
const fields = stored ? {
method: stored.method,
url: stored.url,
headers: stored.headers,
body: stored.body,
sample: stored.sample,
} : fallback;
replayBaselineFingerprint.current = replayFieldsFingerprint(fields);
replayStablePersistence.current = stored ? 'saved' : 'ready';
applyReplayFields(fields);
setReplayPersistence(stored ? 'saved' : 'ready');
setReplayLoadedKey(replayKey);
})
.catch((error) => {
if (!workspaceMounted.current || replayLoadRevision.current !== revision) return;
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
replayStablePersistence.current = 'error';
setReplayStorageError(`无法恢复本机回放草稿:${errorMessage(error)}`);
setReplayPersistence('error');
setReplayLoadedKey(replayKey);
});
return () => { replayLoadRevision.current += 1; };
// Replay defaults are captured only when the profile/direction changes.
// Navigation and new recording selections must not overwrite an edited draft.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [replayKey]);
useEffect(() => () => flushPendingReplaySave(), [flushPendingReplaySave, replayKey]);
useEffect(() => {
if (!replayProfileId || replayLoadedKey !== replayKey) return;
if (replayFingerprint === replayBaselineFingerprint.current) {
pendingReplaySave.current = undefined;
setReplayPersistence(replayStablePersistence.current);
return;
}
const revision = ++replaySaveRevision.current;
setReplayPersistence('saving');
setReplayStorageError('');
const input: BrowserTransformReplayDraftInput = {
...structuredClone(replayFields),
profileId: replayProfileId,
direction: directionName,
origin: replayOrigin,
};
const pending: PendingReplaySave = {
key: replayKey,
fingerprint: replayFingerprint,
input,
revision,
timeout: setTimeout(() => {
if (pendingReplaySave.current === pending) pendingReplaySave.current = undefined;
void saveBrowserTransformReplayDraft(input)
.then((result) => {
if (!workspaceMounted.current || replayActiveKey.current !== pending.key || replaySaveRevision.current !== revision) return;
replayBaselineFingerprint.current = pending.fingerprint;
replayStablePersistence.current = result.status === 'saved' ? 'saved' : 'too-large';
setReplayPersistence(replayStablePersistence.current);
})
.catch((error) => {
if (!workspaceMounted.current || replayActiveKey.current !== pending.key || replaySaveRevision.current !== revision) return;
setReplayStorageError(`无法保存本机回放草稿:${errorMessage(error)}`);
setReplayPersistence('error');
});
}, 350),
};
pendingReplaySave.current = pending;
return () => clearTimeout(pending.timeout);
}, [directionName, replayFields, replayFingerprint, replayKey, replayLoadedKey, replayOrigin, replayProfileId]);
useEffect(() => {
if (!suggestion || !tab || handledSuggestion.current >= suggestion.revision) return;
handledSuggestion.current = suggestion.revision;
replayLoadRevision.current += 1;
discardPendingReplaySave();
const sampleBody = suggestion.sampleBody ? formatSampleBody(suggestion.sampleBody) : undefined;
const fields: BrowserTransformReplayDraftFields = {
...defaultReplayFields(tab, selectedEvent),
method: suggestion.candidate.request.method || 'POST',
url: absoluteUrl(suggestion.candidate.request.url, tab.url),
headers: suggestion.sampleBody ? sampleHeaders(suggestion.sampleBody) : '{"Content-Type":"application/json"}',
body: sampleBody || DEFAULT_REPLAY_BODY,
sample: sampleBody ? { body: sampleBody, label: suggestion.sampleLabel || '录制短时样本' } : undefined,
};
const seedKey = `${suggestion.profile.id}:request`;
pendingReplaySeed.current = { key: seedKey, fields };
replayBaselineFingerprint.current = '';
replayStablePersistence.current = 'ready';
applyReplayFields(fields);
setReplayLoadedKey(seedKey);
setReplayPersistence('ready');
setReplayStorageError('');
setCallables((current) => [...current.filter((item) => item.id !== suggestion.callable.id), suggestion.callable]);
setProfiles((current) => [suggestion.profile, ...current.filter((item) => item.id !== suggestion.profile.id)]);
setSelectedProfileId(suggestion.profile.id);
setDraft(toInput(suggestion.profile));
setDirectionName('request');
setEditorMode('guided');
setTestResult(undefined);
setTestError('');
}, [applyReplayFields, discardPendingReplaySave, selectedEvent, suggestion, tab]);
const savedProfile = profiles.find((profile) => profile.id === selectedProfileId);
const dirty = Boolean(draft && profileFingerprint(draft) !== profileFingerprint(savedProfile ? toInput(savedProfile) : undefined));
const callableIds = useMemo(() => new Set(callables.map((callable) => callable.id)), [callables]);
const referencedCallableIds = useMemo(() => draft ? [draft.request, draft.response]
.flatMap((direction) => direction.enabled ? direction.nodes : [])
.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
.map((node) => node.callableId) : [], [draft]);
const bindingReady = Boolean(draft && originOf(tab?.url) === draft.origin && referencedCallableIds.every((id) => callableIds.has(id)));
const callableReferences = useMemo(() => {
const references = new Map<string, number>();
for (const profile of profiles) {
for (const node of [profile.request, profile.response].flatMap((item) => item.enabled ? item.nodes : [])) {
if (node.kind !== 'page.call') continue;
references.set(node.callableId, (references.get(node.callableId) || 0) + 1);
}
}
return references;
}, [profiles]);
const direction = draft?.[directionName];
const guide = useMemo(() => direction ? parseGuidedTransform(direction, callables) : undefined, [callables, direction]);
const guidedCallable = callables.find((callable) => callable.id === guide?.callableId);
const guidedValid = Boolean(guide && guide.callableId
&& guide.inputPaths.every((path) => path.trim())
&& (guide.outputKind === 'body' || guide.outputField.trim()));
const replayLoading = replayPersistence === 'loading';
const replayPersistenceTitle = replayStorageError
|| (replayPersistence === 'too-large'
? '当前草稿超过 256 KiB,仅保留在本次页面中;已移除旧的本机副本,避免下次恢复过期内容。'
: replayPersistence === 'memory'
? '保存明文网关后,回放输入会仅保存在当前浏览器中。'
: '仅保存在当前浏览器,不会进入明文网关导出、Bridge、Yak 引擎或 AI 上下文。');
const selectProfile = (profile: BrowserTransformProfile) => {
setSelectedProfileId(profile.id);
setDraft(toInput(profile));
setDirectionName(profile.request.enabled ? 'request' : 'response');
setEditorMode(parseGuidedTransform(profile.request.enabled ? profile.request : profile.response, callables) ? 'guided' : 'advanced');
setTestResult(undefined);
};
const create = () => {
if (!tab) return;
setSelectedProfileId('');
setDraft(createBrowserTransformProfileInput(tab, selectedEvent, callables[0]));
setDirectionName('request');
setEditorMode('guided');
setTestResult(undefined);
};
const patchDirection = (patcher: (value: BrowserTransformDirection) => BrowserTransformDirection) => {
setDraft((current) => current ? { ...current, [directionName]: patcher(current[directionName]) } : current);
setTestResult(undefined);
};
const patchNode = (id: string, patch: Partial<BrowserTransformPipelineNode>) => patchDirection((current) => ({
...current,
nodes: current.nodes.map((node) => node.id === id ? { ...node, ...patch } as BrowserTransformPipelineNode : node),
}));
const patchGuide = (next: GuidedTransformDraft) => {
const callable = callables.find((item) => item.id === next.callableId);
patchDirection(() => compileGuidedTransform(next, callable));
};
const selectGuidedCallable = (callableId: string) => {
if (!guide) return;
const callable = callables.find((item) => item.id === callableId);
const defaults = defaultGuidedTransform(callable, { outputKind: guide.outputKind, outputField: guide.outputField });
patchGuide({
...guide,
callableId,
inputPaths: defaults.inputPaths.map((path, index) => guide.inputPaths[index] || path),
});
};
const patchGuideInput = (index: number, path: string) => {
if (!guide) return;
patchGuide({ ...guide, inputPaths: guide.inputPaths.map((item, itemIndex) => itemIndex === index ? path : item) });
};
const addNode = (kind: BrowserTransformPipelineNode['kind']) => patchDirection((current) => {
const previous = current.nodes.at(-1);
const reference = previous ? { nodeId: previous.id } : { nodeId: '' };
let node: BrowserTransformPipelineNode;
if (kind === 'context.read') node = { id: uid('context'), name: '读取上下文', kind, path: 'body' };
else if (kind === 'builtin') node = { id: uid('builtin'), name: '转换数据', kind, operation: 'json.stringify', inputs: previous ? [reference] : [] };
else if (kind === 'page.call') node = { id: uid('call'), name: callables[0]?.name || '调用页面函数', kind, callableId: callables[0]?.id || '', arguments: previous ? [reference] : [] };
else node = { id: uid('output'), name: '写入输出', kind, destination: 'body', source: reference, encoding: 'auto' };
return { ...current, nodes: [...current.nodes, node] };
});
const patchReferences = (node: BrowserTransformPipelineNode, references: BrowserTransformNodeReference[]) => {
if (node.kind === 'builtin') patchNode(node.id, { inputs: references });
else if (node.kind === 'page.call') patchNode(node.id, { arguments: references });
};
const save = () => run(async () => {
if (!draft) return;
const replaySeed = structuredClone(replayFields);
const wasNew = !draft.id;
const profile = await request('transform.profile.save', draft);
if (wasNew) {
const seedKey = `${profile.id}:${directionName}`;
pendingReplaySeed.current = { key: seedKey, fields: replaySeed };
replayBaselineFingerprint.current = '';
replayStablePersistence.current = 'ready';
setReplayLoadedKey(seedKey);
setReplayPersistence('ready');
}
setProfiles((current) => [profile, ...current.filter((item) => item.id !== profile.id)]);
setSelectedProfileId(profile.id);
setDraft(toInput(profile));
}, 'Pipeline v2 配置已保存');
const remove = () => run(async () => {
if (!draft?.id) { setDraft(undefined); return; }
const removedId = draft.id;
discardPendingReplaySave();
setReplayLoadedKey('');
const remaining = await request('transform.profile.delete', { id: removedId });
// The background service also removes these keys. Repeating the local
// cleanup here serializes behind any Options-page write already in flight.
await deleteBrowserTransformReplayDrafts(removedId).catch(() => undefined);
setProfiles(remaining);
setSelectedProfileId(remaining[0]?.id || '');
setDraft(remaining[0] ? toInput(remaining[0]) : undefined);
}, '明文网关配置已删除');
const clearReplay = () => run(async () => {
if (!draft?.id) return;
const profileId = draft.id;
const key = replayKey;
replayLoadRevision.current += 1;
discardPendingReplaySave();
setReplayLoadedKey('');
await clearBrowserTransformReplayDraft(profileId, directionName);
if (replayActiveKey.current !== key) return;
const fallback = defaultReplayFields(tab, selectedEvent);
replayBaselineFingerprint.current = replayFieldsFingerprint(fallback);
replayStablePersistence.current = 'ready';
applyReplayFields(fallback);
setReplayStorageError('');
setReplayPersistence('ready');
setReplayLoadedKey(key);
}, '当前方向的本机回放草稿已清空');
const deleteCallable = (callable: BrowserPageCallable) => run(async () => {
const remaining = await request('callable.delete', { ...callable.target, callableId: callable.id });
setCallables(remaining);
setConfirmDeleteCallableId('');
setTestResult(undefined);
}, callableReferences.get(callable.id) ? '页面函数已删除,引用它的明文网关需要重新绑定' : '页面函数已删除');
const execute = async () => {
if (!draft?.id || dirty) { setTestError('请先保存当前 Pipeline'); return; }
setTestError('');
setTestResult(undefined);
try {
const parsed = JSON.parse(testHeaders) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Header 必须是 JSON 对象');
const headers = Object.entries(parsed as Record<string, unknown>).map(([name, value]) => ({ name, value: String(value) }));
setTestResult(await request('transform.execute', {
profileId: draft.id,
direction: directionName,
packet: { method: testMethod.toUpperCase(), url: testUrl, statusCode: directionName === 'response' ? 200 : undefined, headers, bodyBase64: encodeUtf8(testBody) },
}));
} catch (error) { setTestError(errorMessage(error)); }
};
return <div className="transform-workbench">
<aside className="transform-profiles">
<header><div><strong>明文网关</strong><span>{profiles.length}</span></div><Button size="icon" variant="ghost" aria-label="新建 Pipeline" title="新建 Pipeline" disabled={!tab} onClick={create}><Plus size={15} /></Button></header>
<div className="transform-profile-list">
{profiles.map((profile) => {
const ready = originOf(tab?.url) === profile.origin && [profile.request, profile.response]
.flatMap((item) => item.enabled ? item.nodes : [])
.filter((node): node is Extract<BrowserTransformPipelineNode, { kind: 'page.call' }> => node.kind === 'page.call')
.every((node) => callableIds.has(node.callableId));
return <button key={profile.id} className={selectedProfileId === profile.id ? 'is-selected' : ''} onClick={() => selectProfile(profile)}>
<span className={`transform-profile-mark ${ready ? 'is-ready' : ''}`}><FileKey2 size={14} /></span>
<span><strong>{profile.name}</strong><small>{profile.match.methods.join(' / ') || 'ANY'} · {profile.match.urlPattern}</small></span>
<i title={ready ? '页面绑定可用' : '页面函数已失效'}>{ready ? <CheckCircle2 size={13} /> : <Unplug size={13} />}</i>
</button>;
})}
{!profiles.length && <div className="transform-profile-empty"><FileKey2 size={20} /><strong>没有 Pipeline</strong><Button size="sm" variant="primary" disabled={!tab} onClick={create}><CirclePlus size={14} />新建</Button></div>}
</div>
<footer>
<details className="transform-callable-menu">
<summary className={callables.length ? 'is-ready' : ''}><i />{callables.length} 个页面函数<ChevronDown size={12} /></summary>
<div className="transform-callable-popover">
<header><div><strong>当前文档页面函数</strong><span>页面刷新或导航后自动失效</span></div><em>{callables.length}</em></header>
{!callables.length ? <div className="transform-callable-empty"><Code2 size={17} /><span>还没有可管理的页面函数</span></div> : <div className="transform-callable-list">{callables.map((callable) => {
const referenceCount = callableReferences.get(callable.id) || 0;
const confirming = confirmDeleteCallableId === callable.id;
return <section key={callable.id}>
<div className="transform-callable-row"><span><strong>{callable.name}</strong><small>{callableKindLabel(callable)} · {callable.algorithm || callable.operation}</small></span><Button size="icon" variant="ghost" aria-label={`删除 ${callable.name}`} title="删除页面函数" disabled={busy} onClick={() => setConfirmDeleteCallableId(callable.id)}><Trash2 size={13} /></Button></div>
{confirming && <div className="transform-callable-confirm"><span>{referenceCount ? `${referenceCount} 个网关节点正在引用,删除后会显示“页面函数缺失”。` : '这个页面函数将从当前文档中移除。'}</span><div><Button size="sm" variant="ghost" onClick={() => setConfirmDeleteCallableId('')}>取消</Button><Button size="sm" variant="danger" disabled={busy} onClick={() => void deleteCallable(callable)}>确认删除</Button></div></div>}
</section>;
})}</div>}
</div>
</details>
<Button size="icon" variant="ghost" aria-label="刷新页面绑定" title="刷新页面绑定" onClick={() => void load()}><RefreshCw size={14} /></Button>
</footer>
</aside>
<main className="transform-editor">
{!draft ? <div className="transform-editor-empty"><Link2 size={24} /><strong>建立明文与线上报文的转换链路</strong>{callables.length ? <Button variant="primary" onClick={create}><CirclePlus size={14} />新建 Pipeline</Button> : <Button variant="primary" onClick={onOpenCapture}><Code2 size={14} />先捕获页面函数</Button>}</div> : <>
<header className="transform-editor-head"><div><input value={draft.name} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /><span>{draft.origin} · document 生命周期</span></div><label><Switch checked={draft.enabled} onCheckedChange={(enabled) => setDraft({ ...draft, enabled })} />启用</label></header>
<div className="transform-route">
<label><span>HTTP 方法</span><input value={draft.match.methods.join(', ')} onChange={(event) => setDraft({ ...draft, match: { ...draft.match, methods: event.target.value.split(',').map((item) => item.trim().toUpperCase()).filter(Boolean) } })} /></label>
<label><span>URL 模式</span><input value={draft.match.urlPattern} onChange={(event) => setDraft({ ...draft, match: { ...draft.match, urlPattern: event.target.value } })} /></label>
<label><span>并发</span><input type="number" min={1} max={8} value={draft.maxConcurrency} onChange={(event) => setDraft({ ...draft, maxConcurrency: Number(event.target.value) })} /></label>
</div>
<div className="transform-direction-tabs">
{(['request', 'response'] as const).map((name) => <button key={name} className={directionName === name ? 'is-selected' : ''} onClick={() => setDirectionName(name)}>{name === 'request' ? '请求加密' : '响应解密'}<i className={draft[name].enabled ? 'is-enabled' : ''}>{draft[name].enabled ? `${draft[name].nodes.length} 节点` : '关闭'}</i></button>)}
</div>
{direction && <div className="transform-pipeline-editor">
<div className="transform-direction-state"><div><strong>{directionName === 'request' ? '明文 → 线上请求' : '线上响应 → 明文'}</strong><span>{editorMode === 'guided' ? '确认三个业务选择,底层 Pipeline 自动生成' : '直接编辑有序 DAG 与节点引用'}</span></div><Switch checked={direction.enabled} onCheckedChange={(enabled) => patchDirection((current) => ({ ...current, enabled }))} /></div>
<div className="transform-editor-mode" role="tablist" aria-label="Pipeline 编辑方式">
<button type="button" className={editorMode === 'guided' ? 'is-selected' : ''} onClick={() => setEditorMode('guided')}><Sparkles size={13} />引导配置</button>
<button type="button" className={editorMode === 'advanced' ? 'is-selected' : ''} onClick={() => setEditorMode('advanced')}><Code2 size={13} />高级 Pipeline</button>
</div>
{editorMode === 'guided' && (!guide ? <div className="transform-guide-empty">
<Sparkles size={20} />
<div><strong>{direction.nodes.length ? '这条 Pipeline 包含高级结构' : '选择页面函数后自动生成'}</strong><span>{direction.nodes.length ? '高级结构不会被静默改写;可继续使用高级编辑,或明确替换成三步引导流程。' : '无需添加节点、引用或内置转换。'}</span></div>
<Button size="sm" variant="primary" disabled={!callables.length} onClick={() => patchGuide(defaultGuidedTransform(callables[0]))}>{direction.nodes.length ? '替换为引导流程' : '开始配置'}</Button>
</div> : guide && <div className="transform-guide">
<div className="transform-guide-flow">
<span>逻辑明文</span><ArrowRight size={13} /><strong>{guidedCallable?.name || '选择页面函数'}</strong><ArrowRight size={13} /><span>{guidedOutputDescription(guide)}</span>
</div>
<section className="transform-guide-step">
<span className="transform-guide-step__index">1</span>
<div className="transform-guide-step__body">
<header><div><strong>明文从哪里来</strong><span>通常选择整个逻辑 Body;多参数函数会逐项显示。</span></div><FileInput size={15} /></header>
<div className="transform-guide-inputs">
{guide.inputPaths.map((path, index) => {
const source = splitInputPath(path);
const slot = guidedCallable?.inputSlots.filter((item) => !item.retained)[index];
const needsField = !['body', 'text'].includes(source.kind);
return <div key={`${guide.callableId}:${index}`}>
<label><span>{slot ? `${slot.name} · ${INPUT_ROLE_LABELS[slot.role]}` : `参数 ${index + 1}`}</span><select value={source.kind} onChange={(event) => {
const kind = event.target.value as GuidedInputKind;
const defaultField = kind === 'body-field' ? 'value' : kind === 'header-field' ? 'authorization' : kind === 'query-field' ? 'value' : kind === 'custom' ? 'body' : '';
patchGuideInput(index, joinInputPath(kind, defaultField));
}}><option value="body">整个逻辑 Body</option><option value="body-field">Body 中的字段</option><option value="text">原始 Body 文本</option><option value="header-field">Header 字段</option><option value="query-field">Query 参数</option><option value="custom">高级上下文路径</option></select></label>
{needsField && <label><span>{source.kind === 'custom' ? '上下文路径' : '字段名'}</span><input value={source.field} onChange={(event) => patchGuideInput(index, joinInputPath(source.kind, event.target.value))} placeholder={source.kind === 'custom' ? 'body.account.id' : 'password'} /></label>}
</div>;
})}
{!guide.inputPaths.length && <div className="transform-guide-note">这个页面函数不需要外部输入,将直接使用页面内保留的环境。</div>}
</div>
</div>
</section>
<section className="transform-guide-step">
<span className="transform-guide-step__index">2</span>
<div className="transform-guide-step__body">
<header><div><strong>交给哪个页面函数</strong><span>函数在当前页面文档中执行,KeyIV 与闭包值不会离开页面。</span></div><Code2 size={15} /></header>
<label className="transform-guide-callable"><span>页面能力</span><select value={guide.callableId} onChange={(event) => selectGuidedCallable(event.target.value)}><option value="">选择页面函数</option>{callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></label>
{guidedCallable && <div className="transform-guide-callable-meta"><span>{callableKindLabel(guidedCallable)}</span><strong>{guidedCallable.algorithm || guidedCallable.operation}</strong><em>{guidedCallable.inputSlots.filter((slot) => !slot.retained).length} 个明文参数</em></div>}
<details className="transform-guide-result"><summary>函数返回的是对象,需要取其中一个字段</summary><label><span>返回字段路径</span><input value={guide.resultPath || ''} onChange={(event) => patchGuide({ ...guide, resultPath: event.target.value || undefined })} placeholder="例如 encryptedData;留空使用完整返回值" /></label></details>
</div>
</section>
<section className="transform-guide-step">
<span className="transform-guide-step__index">3</span>
<div className="transform-guide-step__body">
<header><div><strong>线上请求写到哪里</strong><span>选择报文形态即可,字段组合与节点引用由插件生成。</span></div><ArrowDown size={15} /></header>
<div className="transform-guide-output">
<label><span>输出形态</span><select value={guide.outputKind} onChange={(event) => {
const outputKind = event.target.value as GuidedTransformOutputKind;
const outputField = outputKind === 'body' ? '' : guide.outputField || (outputKind === 'header' ? 'X-Sign' : outputKind === 'query' ? 'signature' : 'encryptedData');
patchGuide({ ...guide, outputKind, outputField, setFormContentType: outputKind === 'form-field' });
}}><option value="body">替换整个 Body</option><option value="json-field">写入 JSON 字段</option><option value="form-field">写入表单字段</option><option value="header">写入 Header</option><option value="query">写入 Query 参数</option></select></label>
{guide.outputKind !== 'body' && <label><span>{outputFieldLabel(guide.outputKind)}</span><input value={guide.outputField} onChange={(event) => patchGuide({ ...guide, outputField: event.target.value })} placeholder={guide.outputKind === 'form-field' ? 'encryptedData' : guide.outputKind === 'header' ? 'X-Sign' : 'signature'} /></label>}
</div>
{guide.outputKind === 'form-field' && <label className="transform-guide-content-type"><Switch checked={guide.setFormContentType} onCheckedChange={(setFormContentType) => patchGuide({ ...guide, setFormContentType })} /><span><strong>自动设置表单 Content-Type</strong><small>生成 application/x-www-form-urlencoded,无需再添加固定值和 Header 节点。</small></span></label>}
<div className={`transform-guide-ready ${guidedValid ? 'is-ready' : ''}`}><CheckCircle2 size={14} /><span>{guidedValid ? `将自动生成 ${direction.nodes.length} 个底层节点` : '补全页面函数、输入来源和输出字段后即可保存'}</span></div>
</div>
</section>
</div>)}
{editorMode === 'advanced' && <>
<div className="transform-advanced-notice"><Code2 size={14} /><span><strong>高级 Pipeline</strong>节点、引用和白名单操作会直接影响线上报文;常规加解密场景建议使用引导配置。</span></div>
<div className="transform-node-list">
{direction.nodes.map((node, index) => {
const available = direction.nodes.slice(0, index);
const references = referencesOf(node);
return <section className="transform-node" key={node.id}>
<div className="transform-node-index"><span>{index + 1}</span>{index < direction.nodes.length - 1 && <i />}</div>
<div className="transform-node-fields">
<header><em>{nodeLabel(node.kind)}</em><input value={node.name} onChange={(event) => patchNode(node.id, { name: event.target.value })} /><Button size="icon" variant="ghost" aria-label="删除节点" title="删除节点" onClick={() => patchDirection((current) => ({ ...current, nodes: current.nodes.filter((item) => item.id !== node.id) }))}><Trash2 size={13} /></Button></header>
{node.kind === 'context.read' && <label><span>上下文路径</span><input value={node.path} onChange={(event) => patchNode(node.id, { path: event.target.value })} placeholder="body.password" /></label>}
{node.kind === 'builtin' && <><label><span>白名单操作</span><select value={node.operation} onChange={(event) => patchNode(node.id, { operation: event.target.value as BrowserTransformBuiltinOperation })}>{BUILTINS.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>{node.operation === 'value.literal' && <label><span>固定值</span><input value={typeof node.options?.value === 'string' ? node.options.value : ''} onChange={(event) => patchNode(node.id, { options: { value: event.target.value } })} /></label>}{['form.compose', 'object.compose'].includes(node.operation) && <label><span>字段名 · 按输入顺序</span><input value={Array.isArray(node.options?.keys) ? node.options.keys.join(', ') : ''} placeholder="encryptedData, signature" onChange={(event) => patchNode(node.id, { options: { ...node.options, keys: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label>}{node.operation === 'object.pick' && <><label><span>读取路径</span><input value={Array.isArray(node.options?.paths) ? node.options.paths.join(', ') : ''} placeholder="account.id, profile.name" onChange={(event) => patchNode(node.id, { options: { ...node.options, paths: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label><label><span>输出字段名</span><input value={Array.isArray(node.options?.keys) ? node.options.keys.join(', ') : ''} placeholder="accountId, name" onChange={(event) => patchNode(node.id, { options: { ...node.options, keys: event.target.value.split(',').map((item) => item.trim()).filter(Boolean) } })} /></label></>}</>}
{node.kind === 'page.call' && <label><span>页面函数</span><select value={node.callableId} onChange={(event) => patchNode(node.id, { callableId: event.target.value })}><option value="">选择页面函数</option>{callables.map((callable) => <option key={callable.id} value={callable.id}>{callable.name}</option>)}</select></label>}
{(node.kind === 'page.call' || (node.kind === 'builtin' && node.operation !== 'value.literal')) && <div className="transform-node-references"><span>输入引用</span>{references.map((reference, referenceIndex) => <div key={`${node.id}:${referenceIndex}`}><select value={reference.nodeId} onChange={(event) => patchReferences(node, references.map((item, itemIndex) => itemIndex === referenceIndex ? { ...item, nodeId: event.target.value } : item))}><option value="">选择前序节点</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select><input value={reference.path || ''} onChange={(event) => patchReferences(node, references.map((item, itemIndex) => itemIndex === referenceIndex ? { ...item, path: event.target.value || undefined } : item))} placeholder="可选子路径" /><Button size="icon" variant="ghost" aria-label="删除输入引用" onClick={() => patchReferences(node, references.filter((_, itemIndex) => itemIndex !== referenceIndex))}><Trash2 size={12} /></Button></div>)}<Button size="sm" variant="ghost" onClick={() => patchReferences(node, [...references, { nodeId: available.at(-1)?.id || '' }])}><Plus size={12} />输入</Button></div>}
{node.kind === 'output.write' && <div className="transform-output-fields"><label><span>来源节点</span><select value={node.source.nodeId} onChange={(event) => patchNode(node.id, { source: { ...node.source, nodeId: event.target.value } })}><option value="">选择前序节点</option>{available.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label><label><span>子路径</span><input value={node.source.path || ''} onChange={(event) => patchNode(node.id, { source: { ...node.source, path: event.target.value || undefined } })} placeholder="可选" /></label><label><span>写入目标</span><input value={node.destination} onChange={(event) => patchNode(node.id, { destination: event.target.value })} placeholder="body.encryptedData" /></label><label><span>编码</span><select value={node.encoding} onChange={(event) => patchNode(node.id, { encoding: event.target.value as 'auto' })}><option value="auto">自动</option><option value="text">文本</option><option value="json">JSON</option><option value="base64">Base64</option></select></label></div>}
</div>
</section>;
})}
</div>
<div className="transform-node-add"><span>添加节点</span><Button size="sm" variant="ghost" onClick={() => addNode('context.read')}><FileInput size={13} />上下文</Button><Button size="sm" variant="ghost" onClick={() => addNode('builtin')}><Braces size={13} />内置转换</Button><Button size="sm" variant="ghost" onClick={() => addNode('page.call')}><Code2 size={13} />页面函数</Button><Button size="sm" variant="ghost" onClick={() => addNode('output.write')}><ArrowDown size={13} />输出</Button></div>
</>}
</div>}
<footer className="transform-editor-actions"><span className={bindingReady ? 'is-ready' : 'is-stale'}><i />{bindingReady ? '当前页面函数可用' : '页面函数缺失或文档已变化'}</span><Button size="icon" variant="ghost" aria-label="删除配置" title="删除配置" onClick={() => void remove()}><Trash2 size={14} /></Button><Button variant="primary" disabled={busy || !dirty || (editorMode === 'guided' && Boolean(direction?.enabled) && (!guide || !guidedValid))} onClick={() => void save()}><Save size={14} />保存</Button></footer>
</>}
</main>
<aside className="transform-test">
<header>
<div><FlaskConical size={15} /><span><strong>本地回放</strong><small>不发送网络请求</small></span></div>
<div className="transform-test-header-actions">
{testResult && <i className="transform-test-duration">{testResult.durationMs.toFixed(1)} ms</i>}
<span className={`transform-replay-persistence is-${replayPersistence}`} title={replayPersistenceTitle} aria-live="polite"><i />{replayPersistenceLabel(replayPersistence)}</span>
<Button size="icon" variant="ghost" disabled={!draft?.id || busy || replayLoading} aria-label="清空本机回放草稿" title="清空当前方向的本机回放草稿" onClick={() => void clearReplay()}><Trash2 size={13} /></Button>
</div>
</header>
<label><span>请求</span><div><input disabled={replayLoading} aria-label="回放 HTTP 方法" value={testMethod} onChange={(event) => { setTestMethod(event.target.value); setTestResult(undefined); }} /><input disabled={replayLoading} aria-label="回放请求 URL" value={testUrl} onChange={(event) => { setTestUrl(event.target.value); setTestResult(undefined); }} placeholder="https://example.test/api" /></div></label>
<label><span>Headers · JSON</span><textarea disabled={replayLoading} rows={4} value={testHeaders} onChange={(event) => { setTestHeaders(event.target.value); setTestResult(undefined); }} /></label>
<div className="transform-test-body"><div className="transform-test-field-label"><span>Body</span>{testSample && (testBody === testSample.body ? <em title={testSample.label}>短时样本</em> : <button type="button" disabled={replayLoading} onClick={() => { setTestBody(testSample.body); setTestResult(undefined); }}>恢复短时样本</button>)}</div><textarea disabled={replayLoading} aria-label="回放 Body" rows={8} value={testBody} onChange={(event) => { setTestBody(event.target.value); setTestResult(undefined); }} /></div>
<Button variant="primary" disabled={!draft?.id || dirty || busy || replayLoading || !bindingReady} onClick={() => void execute()}><Play size={14} />执行 Pipeline</Button>
{(loadError || replayStorageError || testError) && <div className="transform-test-error"><AlertTriangle size={14} />{loadError || replayStorageError || testError}</div>}
{testResult && <section className="transform-test-result"><header><div><CheckCircle2 size={14} /><strong>转换完成</strong></div><span>{testResult.nodeDurations.length} 节点</span></header><dl><div><dt>输出 URL</dt><dd>{testResult.url}</dd></div><div><dt>Body Base64</dt><dd>{testResult.bodyBase64.slice(0, 64)}{testResult.bodyBase64.length > 64 ? '…' : ''}</dd></div><div><dt>Headers</dt><dd>{testResult.setHeaders.length} 设置 · {testResult.removeHeaders.length} 删除</dd></div></dl><pre>{JSON.stringify(testResult.logicalOutput, null, 2)}</pre><footer>{testResult.nodeDurations.map((node) => <span key={node.nodeId}>{node.nodeId.split('-')[0]} · {node.durationMs.toFixed(1)} ms</span>)}</footer></section>}
</aside>
</div>;
}