299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
export type AiRequestInspectionStatus = 'prepared' | 'succeeded' | 'failed';
|
|
|
|
export interface AiRequestInspectionRecord {
|
|
id: string;
|
|
title: string;
|
|
category: 'preview' | 'generation' | 'review' | 'render';
|
|
method: string;
|
|
path: string;
|
|
status: AiRequestInspectionStatus;
|
|
submitted_at: string;
|
|
finished_at: string | null;
|
|
request_id: string | null;
|
|
request_body: unknown;
|
|
execution_evidence: unknown;
|
|
error_message: string | null;
|
|
}
|
|
|
|
type InspectionListener = (records: AiRequestInspectionRecord[]) => void;
|
|
|
|
const STORAGE_KEY = 'ai_content_ai_request_inspections_v1';
|
|
const MAX_RECORDS = 30;
|
|
const listeners = new Set<InspectionListener>();
|
|
let records: AiRequestInspectionRecord[] = readStoredRecords();
|
|
|
|
const AI_REQUEST_PATTERNS = [
|
|
/\/provider-lab\/runs$/,
|
|
/\/prompt-storyboard(?:\/preview)?$/,
|
|
/\/original\/(?:idea|outline|chapters|self-check)$/,
|
|
/\/production\/prompts\/.+\/preview$/,
|
|
/\/production\/contracts\/.+\/ai-review$/,
|
|
/\/projects\/.+\/reviews\/text$/,
|
|
/\/assets\/.+\/review$/,
|
|
/\/admin\/novel-creation-wizard\/proposals\/jobs$/,
|
|
/\/novel-generation\/.+\/(?:ip-bible|chapters\/.+\/run|chapters\/batch)\/jobs$/,
|
|
/\/admin\/hit-analyses(?:$|\/.+\/(?:analyze|patterns)$)/,
|
|
/(?:^|[\/_-])(?:generate|regenerate|retry|extract|optimize|ai-review|preflight|render|compose|synthesize|test|review-visual|quality-check|cost-estimate|request-preview|plan-request-preview|prepare|auto-bind)(?:[\/?_-]|$)/,
|
|
];
|
|
|
|
export function isInspectableAiRequest(path: string, method: string) {
|
|
if (method === 'HEAD') return false;
|
|
if (method === 'GET' && !/\/(?:preflight|cost-estimate)(?:[/?]|$)/.test(path)) return false;
|
|
return AI_REQUEST_PATTERNS.some((pattern) => pattern.test(path));
|
|
}
|
|
|
|
export function beginAiRequestInspection(input: {
|
|
path: string;
|
|
method: string;
|
|
body: unknown;
|
|
}) {
|
|
const record: AiRequestInspectionRecord = {
|
|
id: createInspectionId(),
|
|
title: requestTitle(input.path),
|
|
category: requestCategory(input.path),
|
|
method: input.method,
|
|
path: input.path,
|
|
status: 'prepared',
|
|
submitted_at: new Date().toISOString(),
|
|
finished_at: null,
|
|
request_id: null,
|
|
request_body: normalizeInspectorValue(buildSubmittedParameters(input.path, input.body)),
|
|
execution_evidence: null,
|
|
error_message: null
|
|
};
|
|
|
|
records = [record, ...records].slice(0, MAX_RECORDS);
|
|
publish();
|
|
return record.id;
|
|
}
|
|
|
|
export function completeAiRequestInspection(
|
|
id: string | null,
|
|
input: { requestId?: string | null; responseData?: unknown }
|
|
) {
|
|
if (!id) return;
|
|
const record = records.find((item) => item.id === id);
|
|
const extractedEvidence = extractExecutionEvidence(input.responseData);
|
|
updateInspection(id, {
|
|
status: 'succeeded',
|
|
finished_at: new Date().toISOString(),
|
|
request_id: input.requestId ?? null,
|
|
execution_evidence: extractedEvidence ?? (
|
|
record?.category === 'preview' ? normalizeInspectorValue(input.responseData) : null
|
|
),
|
|
error_message: null
|
|
});
|
|
}
|
|
|
|
export function failAiRequestInspection(id: string | null, error: unknown) {
|
|
if (!id) return;
|
|
updateInspection(id, {
|
|
status: 'failed',
|
|
finished_at: new Date().toISOString(),
|
|
error_message: error instanceof Error ? error.message : String(error ?? '未知错误')
|
|
});
|
|
}
|
|
|
|
export function subscribeAiRequestInspections(listener: InspectionListener) {
|
|
listeners.add(listener);
|
|
listener([...records]);
|
|
return () => listeners.delete(listener);
|
|
}
|
|
|
|
export function clearAiRequestInspections() {
|
|
records = [];
|
|
publish();
|
|
}
|
|
|
|
function updateInspection(id: string, patch: Partial<AiRequestInspectionRecord>) {
|
|
records = records.map((record) => record.id === id ? { ...record, ...patch } : record);
|
|
publish();
|
|
}
|
|
|
|
function publish() {
|
|
writeStoredRecords(records);
|
|
const snapshot = [...records];
|
|
for (const listener of listeners) listener(snapshot);
|
|
}
|
|
|
|
function requestCategory(path: string): AiRequestInspectionRecord['category'] {
|
|
if (/preview|preflight/.test(path)) return 'preview';
|
|
if (/review|optimize/.test(path)) return 'review';
|
|
if (/render|compose|synthesize/.test(path)) return 'render';
|
|
return 'generation';
|
|
}
|
|
|
|
function requestTitle(path: string) {
|
|
const rules: Array<[RegExp, string]> = [
|
|
[/provider-lab\/runs$/, 'API 快测'],
|
|
[/admin\/providers\/.+\/test$/, '管理端 Provider 实测'],
|
|
[/novel-creation-wizard\/proposals\/jobs$/, '小说方案生成'],
|
|
[/novel-generation\/.+\/ip-bible\/jobs$/, '小说 IP 圣经生成'],
|
|
[/novel-generation\/.+\/chapters\/.+\/jobs$/, '小说章节生成'],
|
|
[/admin\/hit-analyses(?:$|\/.+\/(?:analyze|patterns)$)/, '爆款案例 AI 分析'],
|
|
[/projects\/.+\/reviews\/text$|assets\/.+\/review$/, '内容 AI 审核'],
|
|
[/prompt-storyboard\/preview$/, '提示词分镜预览'],
|
|
[/prompt-storyboard$/, '提示词生成分镜'],
|
|
[/production\/prompts\/(.+)\/preview$/, 'S+ 阶段 Prompt 预览'],
|
|
[/production\/contracts\/.+\/ai-review$/, 'S+ 场景剧本 AI 审核'],
|
|
[/request-preview|plan-request-preview/i, 'AI 请求预览'],
|
|
[/anchor-video-test/i, '角色元素视频测试'],
|
|
[/review-visual/i, '角色图片视觉质检'],
|
|
[/quality-check/i, '视频质量检查'],
|
|
[/preflight/i, '视频生成预检'],
|
|
[/cost-estimate/i, '生成成本估算'],
|
|
[/auto-bind/i, '角色音色自动绑定'],
|
|
[/character.*(?:image|anchor).*generate|images.*generate/i, '角色资产生成'],
|
|
[/keyframe.*generate|generate.*keyframe/i, '关键帧生成'],
|
|
[/video.*generate|generate.*video/i, '视频生成'],
|
|
[/audio.*generate|voice.*generate|tts/i, '语音生成'],
|
|
[/subtitle.*generate/i, '字幕生成'],
|
|
[/music.*generate/i, '音乐生成'],
|
|
[/story.*generate/i, '故事圣经生成'],
|
|
[/episode.*generate/i, '分集计划生成'],
|
|
[/script.*generate/i, '剧本生成'],
|
|
[/storyboard.*generate/i, '分镜生成'],
|
|
[/render|compose/i, '成片合成'],
|
|
[/review|optimize/i, 'AI 审核优化']
|
|
];
|
|
|
|
return rules.find(([pattern]) => pattern.test(path))?.[1] ?? 'AI 请求';
|
|
}
|
|
|
|
function buildSubmittedParameters(path: string, body: unknown) {
|
|
const queryIndex = path.indexOf('?');
|
|
const query = queryIndex >= 0 ? path.slice(queryIndex + 1) : '';
|
|
const queryParams: Record<string, string | string[]> = {};
|
|
|
|
if (query) {
|
|
const search = new URLSearchParams(query);
|
|
for (const key of new Set(search.keys())) {
|
|
const values = search.getAll(key);
|
|
queryParams[key] = values.length > 1 ? values : values[0] ?? '';
|
|
}
|
|
}
|
|
|
|
return {
|
|
path_params: path.slice(0, queryIndex >= 0 ? queryIndex : undefined),
|
|
query_params: queryParams,
|
|
body: body ?? null
|
|
};
|
|
}
|
|
|
|
function normalizeInspectorValue(value: unknown, depth = 0, seen = new WeakSet<object>()): unknown {
|
|
if (depth > 12) return '[超过展示深度]';
|
|
if (value === null || value === undefined) return value ?? null;
|
|
if (typeof value === 'bigint') return value.toString();
|
|
if (typeof value === 'string') return summarizeBinaryString(value);
|
|
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
|
|
if (typeof File !== 'undefined' && value instanceof File) {
|
|
return {
|
|
file_name: value.name,
|
|
mime_type: value.type || 'application/octet-stream',
|
|
size_bytes: value.size,
|
|
last_modified: value.lastModified
|
|
};
|
|
}
|
|
if (typeof Blob !== 'undefined' && value instanceof Blob) {
|
|
return { mime_type: value.type || 'application/octet-stream', size_bytes: value.size };
|
|
}
|
|
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, item] of value.entries()) {
|
|
const normalized = normalizeInspectorValue(parseFormValue(key, item), depth + 1, seen);
|
|
const current = result[key];
|
|
result[key] = current === undefined
|
|
? normalized
|
|
: Array.isArray(current) ? [...current, normalized] : [current, normalized];
|
|
}
|
|
return result;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => normalizeInspectorValue(item, depth + 1, seen));
|
|
}
|
|
if (typeof value === 'object') {
|
|
if (seen.has(value)) return '[循环引用]';
|
|
seen.add(value);
|
|
const result: Record<string, unknown> = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
result[key] = isSecretKey(key)
|
|
? '[已隐藏敏感字段]'
|
|
: normalizeInspectorValue(item, depth + 1, seen);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
return String(value);
|
|
}
|
|
|
|
function parseFormValue(key: string, value: FormDataEntryValue) {
|
|
if (typeof value !== 'string') return value;
|
|
if (!/(?:_json|params|input)$/i.test(key)) return value;
|
|
try {
|
|
return JSON.parse(value) as unknown;
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function summarizeBinaryString(value: string) {
|
|
if (/^data:[^;]+;base64,/i.test(value)) {
|
|
const commaIndex = value.indexOf(',');
|
|
return `[Base64 二进制已省略,共 ${Math.max(0, value.length - commaIndex - 1)} 字符]`;
|
|
}
|
|
if (value.length > 10_000 && /^[A-Za-z0-9+/=\r\n]+$/.test(value)) {
|
|
return `[疑似 Base64 二进制已省略,共 ${value.length} 字符]`;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function isSecretKey(key: string) {
|
|
return /(?:password|authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|secret)/i.test(key);
|
|
}
|
|
|
|
function extractExecutionEvidence(value: unknown) {
|
|
if (!value || typeof value !== 'object') return null;
|
|
const evidenceKeys = /^(?:task|tasks|input_json|request_preview|request_params|request_params_override|generation_plan|generation_plans|provider|provider_log|route_decision|router_decision|compiled_request|final_request|cost_estimate|estimated_cost)$/i;
|
|
const evidence: Record<string, unknown> = {};
|
|
|
|
function visit(node: unknown, depth: number, prefix: string) {
|
|
if (!node || typeof node !== 'object' || depth > 5) return;
|
|
if (Array.isArray(node)) {
|
|
node.forEach((item, index) => visit(item, depth + 1, `${prefix}[${index}]`));
|
|
return;
|
|
}
|
|
for (const [key, item] of Object.entries(node)) {
|
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
if (evidenceKeys.test(key)) evidence[path] = normalizeInspectorValue(item);
|
|
if (!evidenceKeys.test(key)) visit(item, depth + 1, path);
|
|
}
|
|
}
|
|
|
|
visit(value, 0, '');
|
|
return Object.keys(evidence).length > 0 ? evidence : null;
|
|
}
|
|
|
|
function createInspectionId() {
|
|
return `ai_req_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function readStoredRecords(): AiRequestInspectionRecord[] {
|
|
if (typeof window === 'undefined') return [];
|
|
try {
|
|
const parsed = JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) || '[]') as unknown;
|
|
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECORDS) as AiRequestInspectionRecord[] : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function writeStoredRecords(nextRecords: AiRequestInspectionRecord[]) {
|
|
if (typeof window === 'undefined') return;
|
|
try {
|
|
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(nextRecords));
|
|
} catch {
|
|
// Inspection history is diagnostic only; generation must not fail if storage is full.
|
|
}
|
|
}
|