feat: expand novel IP and production workflows

This commit is contained in:
www
2026-09-18 08:14:05 +02:00
parent b2ae4600b4
commit d9c81a3ac0
235 changed files with 117971 additions and 2721 deletions
@@ -0,0 +1,298 @@
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.
}
}