Files

305 lines
11 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>