feat: add parameter preprocessing audit trail
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user