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,304 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import {
clearAiRequestInspections,
subscribeAiRequestInspections,
type AiRequestInspectionRecord
} from './ai-request-inspector';
const open = ref(false);
const records = ref<AiRequestInspectionRecord[]>([]);
const selectedId = ref<string | null>(null);
const copyState = ref('');
let unsubscribe: (() => void) | null = null;
const selected = computed(() =>
records.value.find((record) => record.id === selectedId.value) ?? records.value[0] ?? null
);
const pendingCount = computed(() => records.value.filter((record) => record.status === 'prepared').length);
onMounted(() => {
unsubscribe = subscribeAiRequestInspections((nextRecords) => {
records.value = nextRecords;
if (!selectedId.value || !nextRecords.some((record) => record.id === selectedId.value)) {
selectedId.value = nextRecords[0]?.id ?? null;
}
});
});
onBeforeUnmount(() => unsubscribe?.());
function formatJson(value: unknown) {
return JSON.stringify(value ?? null, null, 2);
}
function formatTime(value: string | null) {
if (!value) return '处理中';
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(new Date(value));
}
function statusLabel(status: AiRequestInspectionRecord['status']) {
if (status === 'succeeded') return '已返回';
if (status === 'failed') return '失败';
return '已提交';
}
function categoryLabel(category: AiRequestInspectionRecord['category']) {
return { preview: '预检', generation: '生成', review: '审核', render: '合成' }[category];
}
async function copySelected() {
if (!selected.value) return;
await navigator.clipboard.writeText(formatJson(selected.value));
copyState.value = '已复制';
window.setTimeout(() => { copyState.value = ''; }, 1500);
}
function clearHistory() {
clearAiRequestInspections();
selectedId.value = null;
}
</script>
<template>
<button
type="button"
class="ai-request-trigger"
:class="{ 'has-pending': pendingCount > 0 }"
aria-label="查看AI请求参数"
@click="open = true"
>
<span class="trigger-mark">AI</span>
<span>请求参数</span>
<strong>{{ records.length }}</strong>
</button>
<Teleport to="body">
<div v-if="open" class="ai-request-backdrop" @click.self="open = false">
<aside class="ai-request-drawer" role="dialog" aria-modal="true" aria-label="AI请求参数">
<header class="drawer-header">
<div>
<span>AI Request Inspector</span>
<h2>提交参数</h2>
<p>显示页面提交明文及接口返回的最终执行证据不展示密钥和二进制正文</p>
</div>
<button type="button" class="drawer-close" aria-label="关闭" @click="open = false">×</button>
</header>
<div v-if="records.length" class="drawer-layout">
<nav class="request-history" aria-label="AI请求历史">
<button
v-for="record in records"
:key="record.id"
type="button"
:class="{ active: selected?.id === record.id }"
@click="selectedId = record.id"
>
<span class="history-line">
<strong>{{ record.title }}</strong>
<i :class="`is-${record.status}`">{{ statusLabel(record.status) }}</i>
</span>
<small>{{ categoryLabel(record.category) }} · {{ formatTime(record.submitted_at) }}</small>
</button>
</nav>
<section v-if="selected" class="request-detail">
<div class="request-toolbar">
<div>
<span>{{ categoryLabel(selected.category) }}</span>
<h3>{{ selected.title }}</h3>
</div>
<div class="toolbar-actions">
<button type="button" @click="copySelected">{{ copyState || '复制全部' }}</button>
<button type="button" @click="clearHistory">清空</button>
</div>
</div>
<dl class="request-meta">
<div><dt>状态</dt><dd :class="`is-${selected.status}`">{{ statusLabel(selected.status) }}</dd></div>
<div><dt>方法</dt><dd>{{ selected.method }}</dd></div>
<div><dt>接口</dt><dd>{{ selected.path }}</dd></div>
<div><dt>Request ID</dt><dd>{{ selected.request_id || '等待接口返回' }}</dd></div>
</dl>
<details open>
<summary>页面提交参数</summary>
<pre>{{ formatJson(selected.request_body) }}</pre>
</details>
<details open>
<summary>最终执行证据</summary>
<p v-if="!selected.execution_evidence" class="empty-copy">
当前接口尚未返回 task.input_jsonGeneration Plan Provider 路由信息
</p>
<pre v-else>{{ formatJson(selected.execution_evidence) }}</pre>
</details>
<details v-if="selected.error_message" open class="error-detail">
<summary>失败原因</summary>
<pre>{{ selected.error_message }}</pre>
</details>
</section>
</div>
<div v-else class="drawer-empty">
<strong>还没有 AI 请求记录</strong>
<p>执行生成AI审核预检或合成后请求会自动出现在这里</p>
</div>
</aside>
</div>
</Teleport>
</template>
<style scoped>
.ai-request-trigger {
position: fixed;
z-index: 70;
right: 0;
top: 42%;
display: grid;
gap: 4px;
justify-items: center;
width: 72px;
padding: 12px 8px;
border: 1px solid #c6d0da;
border-right: 0;
border-radius: 6px 0 0 6px;
background: #ffffff;
color: #17212b;
box-shadow: 0 8px 22px rgba(15, 30, 44, 0.14);
cursor: pointer;
}
.ai-request-trigger:hover { background: #f4f7f9; }
.ai-request-trigger.has-pending { border-color: #2b6f6a; }
.ai-request-trigger > span:not(.trigger-mark) { font-size: 12px; }
.ai-request-trigger strong { font-size: 11px; color: #587080; }
.trigger-mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: #153d3a;
color: #fff;
font-size: 12px;
font-weight: 800;
}
.ai-request-backdrop {
position: fixed;
inset: 0;
z-index: 120;
background: rgba(12, 20, 27, 0.42);
}
.ai-request-drawer {
position: absolute;
inset: 0 0 0 auto;
width: min(860px, 94vw);
height: 100%;
overflow: hidden;
background: #f5f7f8;
border-left: 1px solid #cfd7dd;
box-shadow: -14px 0 40px rgba(10, 25, 36, 0.2);
color: #17212b;
}
.drawer-header {
min-height: 112px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 22px 24px;
border-bottom: 1px solid #d8dfe4;
background: #fff;
}
.drawer-header span { color: #2b6f6a; font-size: 12px; font-weight: 700; text-transform: uppercase; }
.drawer-header h2 { margin: 4px 0; font-size: 24px; letter-spacing: 0; }
.drawer-header p { margin: 0; color: #65747f; font-size: 13px; line-height: 1.6; }
.drawer-close {
width: 38px;
height: 38px;
border: 1px solid #cfd7dd;
border-radius: 4px;
background: #fff;
color: #31434f;
font-size: 25px;
line-height: 1;
cursor: pointer;
}
.drawer-layout { display: grid; grid-template-columns: 220px minmax(0, 1fr); height: calc(100% - 112px); }
.request-history { overflow-y: auto; border-right: 1px solid #d8dfe4; background: #eef2f4; }
.request-history button {
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #d8dfe4;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.request-history button:hover { background: #e3eaed; }
.request-history button.active { background: #fff; box-shadow: inset 3px 0 #2b6f6a; }
.history-line { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
.history-line strong { font-size: 13px; line-height: 1.45; }
.history-line i { font-style: normal; white-space: nowrap; font-size: 10px; font-weight: 700; }
.history-line i.is-prepared { color: #9a6200; }
.history-line i.is-succeeded { color: #1c6b48; }
.history-line i.is-failed { color: #a93434; }
.request-history small { display: block; margin-top: 6px; color: #70808b; font-size: 11px; }
.request-detail { overflow-y: auto; padding: 20px 22px 48px; }
.request-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.request-toolbar span { color: #2b6f6a; font-size: 11px; font-weight: 700; }
.request-toolbar h3 { margin: 3px 0 0; font-size: 20px; letter-spacing: 0; }
.toolbar-actions { display: flex; gap: 8px; }
.toolbar-actions button {
padding: 8px 11px;
border: 1px solid #c6d0d7;
border-radius: 4px;
background: #fff;
color: #263844;
cursor: pointer;
}
.request-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 18px 0; border: 1px solid #d7dee3; background: #d7dee3; }
.request-meta div { min-width: 0; padding: 10px 12px; background: #fff; }
.request-meta dt { color: #71808a; font-size: 11px; }
.request-meta dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 12px; font-weight: 650; }
.request-meta dd.is-prepared { color: #9a6200; }
.request-meta dd.is-succeeded { color: #1c6b48; }
.request-meta dd.is-failed { color: #a93434; }
details { margin-top: 12px; border: 1px solid #d7dee3; border-radius: 5px; background: #fff; }
summary { padding: 12px 14px; cursor: pointer; font-size: 13px; font-weight: 750; }
pre { margin: 0; padding: 15px; overflow: auto; border-top: 1px solid #e2e7ea; background: #111b22; color: #d9e6ec; font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.empty-copy { margin: 0; padding: 14px; border-top: 1px solid #e2e7ea; color: #687985; font-size: 13px; }
.error-detail { border-color: #e0baba; }
.error-detail pre { background: #351c1c; color: #ffd8d8; }
.drawer-empty { display: grid; place-content: center; height: calc(100% - 112px); padding: 30px; text-align: center; }
.drawer-empty strong { font-size: 18px; }
.drawer-empty p { color: #687985; }
@media (max-width: 720px) {
.ai-request-trigger { top: auto; bottom: 92px; width: 62px; }
.ai-request-drawer { width: 100vw; }
.drawer-layout { grid-template-columns: 1fr; grid-template-rows: 150px minmax(0, 1fr); }
.request-history { display: flex; overflow-x: auto; overflow-y: hidden; border-right: 0; border-bottom: 1px solid #d8dfe4; }
.request-history button { min-width: 190px; border-right: 1px solid #d8dfe4; }
.request-meta { grid-template-columns: 1fr; }
.drawer-header { padding: 18px; }
.drawer-header p { display: none; }
.request-detail { padding: 16px 14px 40px; }
}
</style>
@@ -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.
}
}