feat: expand novel IP and production workflows
This commit is contained in:
@@ -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_json、Generation 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user