feat: add parameter preprocessing audit trail

This commit is contained in:
2026-05-12 13:54:51 +08:00
parent 9ea83be718
commit b9c9f457e9
17 changed files with 2323 additions and 28 deletions
+192 -5
View File
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Popover as AntPopover } from 'antd';
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, SlidersHorizontal, Trash2, UserRound } from 'lucide-react';
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayTaskParamPreprocessingLog, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
import type { ConsoleData } from '../app-state';
import { EntityTable } from '../components/EntityTable';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
import { listTaskParamPreprocessing } from '../api';
const tabs = [
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
@@ -27,6 +28,7 @@ export function WorkspacePage(props: {
message: string;
section: WorkspaceSection;
state: LoadState;
token: string;
taskQuery: WorkspaceTaskQuery;
taskTotal: number;
transactionQuery: WorkspaceTransactionQuery;
@@ -48,7 +50,7 @@ export function WorkspacePage(props: {
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
{props.section === 'billing' && <BillingPanel walletAccounts={props.data.walletAccounts} />}
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} token={props.token} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
{props.section === 'transactions' && <ConsumptionPanel data={props.data} query={props.transactionQuery} total={props.transactionTotal} onQueryChange={props.onTransactionQueryChange} />}
</div>
</div>
@@ -545,6 +547,7 @@ function ApiKeyPanel(props: {
function TaskPanel(props: {
data: ConsoleData;
query: WorkspaceTaskQuery;
token: string;
total: number;
onQueryChange: (value: WorkspaceTaskQuery) => void;
}) {
@@ -675,6 +678,7 @@ function TaskPanel(props: {
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead>Token</TableHead>
@@ -684,7 +688,7 @@ function TaskPanel(props: {
<TableHead> JSON</TableHead>
</TableRow>
{tasks.map((task) => (
<TaskRecord key={task.id} task={task} onCopyRequestId={copyTaskRequestId} onOpenJson={setJsonTask} />
<TaskRecord key={task.id} task={task} token={props.token} onCopyRequestId={copyTaskRequestId} onOpenJson={setJsonTask} />
))}
</Table>
) : (
@@ -759,7 +763,7 @@ function TaskPanel(props: {
);
}
function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayTask) => Promise<void>; onOpenJson: (task: GatewayTask) => void }) {
function TaskRecord(props: { task: GatewayTask; token: string; onCopyRequestId: (task: GatewayTask) => Promise<void>; onOpenJson: (task: GatewayTask) => void }) {
const usage = props.task.usage ?? {};
const tokenUsage = formatTokenUsage(usage);
const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-';
@@ -801,6 +805,9 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT
<TableCell className="taskRecordAttemptCell">
<TaskAttemptChain task={props.task} />
</TableCell>
<TableCell className="taskRecordParamCell">
<TaskParamConversionCell task={props.task} token={props.token} />
</TableCell>
<TableCell>{props.task.modelType || '-'}</TableCell>
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
@@ -817,6 +824,108 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT
);
}
type TaskParamConversionSummary = {
changed: boolean;
changeCount: number;
actions: string[];
paths: string[];
capabilityPaths: string[];
};
function TaskParamConversionCell(props: { task: GatewayTask; token: string }) {
const summary = taskParamConversionSummary(props.task);
const [open, setOpen] = useState(false);
const [logs, setLogs] = useState<GatewayTaskParamPreprocessingLog[] | null>(null);
const [loadState, setLoadState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [error, setError] = useState('');
useEffect(() => {
if (!open || !summary.changed || logs || loadState === 'loading' || !props.token) return;
let cancelled = false;
setLoadState('loading');
setError('');
listTaskParamPreprocessing(props.token, props.task.id)
.then((response) => {
if (cancelled) return;
setLogs(response.items ?? []);
setLoadState('ready');
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : '参数转换明细加载失败');
setLoadState('error');
});
return () => {
cancelled = true;
};
}, [loadState, logs, open, props.task.id, props.token, summary.changed]);
if (!summary.changed) {
return <span className="taskParamConversionEmpty"></span>;
}
return (
<AntPopover
align={{ offset: [0, 8] }}
content={<TaskParamConversionPopover error={error} loadState={loadState} logs={logs} summary={summary} />}
overlayClassName="taskParamConversionAntPopover"
placement="bottomLeft"
trigger={['hover', 'focus']}
onOpenChange={setOpen}
>
<button className="taskParamConversionTrigger" type="button" aria-label={`参数转换 ${summary.changeCount}`}>
<SlidersHorizontal size={14} />
<span>{summary.changeCount || '有'} </span>
</button>
</AntPopover>
);
}
function TaskParamConversionPopover(props: {
error: string;
loadState: 'idle' | 'loading' | 'ready' | 'error';
logs: GatewayTaskParamPreprocessingLog[] | null;
summary: TaskParamConversionSummary;
}) {
const logs = props.logs ?? [];
return (
<span className="taskParamConversionPopover" role="tooltip">
<span className="taskParamConversionPopoverHeader">
<strong></strong>
<small>{taskParamSummaryText(props.summary)}</small>
</span>
{props.loadState === 'loading' && <span className="taskParamConversionState">...</span>}
{props.loadState === 'error' && <span className="taskParamConversionState error">{props.error || '参数转换明细加载失败'}</span>}
{props.loadState === 'ready' && logs.length === 0 && <span className="taskParamConversionState"></span>}
{logs.map((log) => (
<span key={log.id} className="taskParamConversionLog">
<span className="taskParamConversionLogHeader">
<strong>{taskParamLogTitle(log)}</strong>
<Badge variant={log.changed ? 'secondary' : 'outline'}>{log.changeCount} </Badge>
</span>
{(log.changes ?? []).slice(0, 8).map((change, index) => (
<span key={`${log.id}-change-${index}`} className="taskParamConversionChange">
<span className="taskParamConversionChangeTop">
<Badge variant="outline">{taskParamActionLabel(objectString(change, 'action'))}</Badge>
<strong>{objectString(change, 'path') || '-'}</strong>
</span>
<span>{objectString(change, 'reason') || '按模型能力配置调整参数。'}</span>
{objectString(change, 'capabilityPath') && <small>{objectString(change, 'capabilityPath')}</small>}
<code>{taskParamChangePreview(change)}</code>
</span>
))}
{(log.changes?.length ?? 0) > 8 && <small> {(log.changes?.length ?? 0) - 8} </small>}
</span>
))}
{props.loadState !== 'ready' && props.summary.capabilityPaths.length > 0 && (
<span className="taskParamConversionSummaryPaths">
{props.summary.capabilityPaths.slice(0, 4).map((path) => <code key={path}>{path}</code>)}
</span>
)}
</span>
);
}
function TaskAttemptChain(props: { task: GatewayTask }) {
const attempts = props.task.attempts ?? [];
if (!attempts.length) return <span>-</span>;
@@ -999,6 +1108,73 @@ function taskAttemptTraceReasonLabel(reason: string) {
return labels[reason] ?? reason;
}
function taskParamConversionSummary(task: GatewayTask): TaskParamConversionSummary {
const summary: TaskParamConversionSummary = {
changed: false,
changeCount: 0,
actions: [],
paths: [],
capabilityPaths: [],
};
mergeTaskParamSummary(summary, metadataObject(task.metrics, 'parameterPreprocessingSummary'));
for (const attempt of task.attempts ?? []) {
mergeTaskParamSummary(summary, metadataObject(attempt.metrics, 'parameterPreprocessingSummary'));
}
return summary;
}
function mergeTaskParamSummary(target: TaskParamConversionSummary, raw: Record<string, unknown>) {
if (!Object.keys(raw).length) return;
target.changed = target.changed || raw.changed === true;
const changeCount = metadataNumber(raw, 'changeCount');
if (changeCount) target.changeCount += Math.max(0, Math.trunc(changeCount));
for (const action of metadataStringList(raw, 'actions')) appendUniqueText(target.actions, action);
for (const path of metadataStringList(raw, 'paths')) appendUniqueText(target.paths, path);
for (const path of metadataStringList(raw, 'capabilityPaths')) appendUniqueText(target.capabilityPaths, path);
}
function taskParamSummaryText(summary: TaskParamConversionSummary) {
const actionText = summary.actions.map(taskParamActionLabel).join('、');
const parts = [
`${summary.changeCount || summary.paths.length || 1} 项转换`,
actionText ? `动作 ${actionText}` : '',
summary.capabilityPaths.length ? `涉及 ${summary.capabilityPaths.length} 项能力配置` : '',
].filter(Boolean);
return parts.join(' · ');
}
function taskParamLogTitle(log: GatewayTaskParamPreprocessingLog) {
const parts = [
log.attemptNo ? `#${log.attemptNo}` : '',
log.modelType || '',
log.clientId || '',
].filter(Boolean);
return parts.join(' · ') || '预处理记录';
}
function taskParamActionLabel(action: string) {
if (action === 'remove') return '移除';
if (action === 'adjust') return '调整';
if (action === 'set') return '补齐';
return action || '转换';
}
function taskParamChangePreview(change: Record<string, unknown>) {
const before = previewCompactValue(change.before);
const after = previewCompactValue(change.after);
const action = objectString(change, 'action');
if (action === 'remove') return `原值 ${before}`;
if (action === 'set') return `新值 ${after}`;
return `原值 ${before} -> 新值 ${after}`;
}
function previewCompactValue(value: unknown) {
if (value === undefined || value === null || value === '') return '-';
const text = typeof value === 'string' ? value : JSON.stringify(value);
if (!text) return '-';
return text.length > 150 ? `${text.slice(0, 150)}...` : text;
}
function formatCellValue(value: unknown) {
if (value === undefined || value === null || value === '') return '-';
return String(value);
@@ -1039,6 +1215,12 @@ function metadataString(metadata: Record<string, unknown> | undefined, key: stri
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function metadataStringList(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === 'string' && item.trim() !== '').map((item) => item.trim());
}
function metadataNumber(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
if (value === undefined || value === null || value === '') return null;
@@ -1057,6 +1239,11 @@ function objectString(value: Record<string, unknown>, key: string) {
return typeof next === 'string' && next.trim() ? next.trim() : '';
}
function appendUniqueText(values: string[], value: string) {
if (!value || values.includes(value)) return;
values.push(value);
}
function transactionChargeAmount(transaction: GatewayWalletTransaction) {
return metadataNumber(transaction.metadata, 'finalChargeAmount') ?? transaction.amount;
}