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
+1
View File
@@ -942,6 +942,7 @@ export function App() {
message={coreMessage}
section={workspaceSection}
state={coreState}
token={token}
taskQuery={workspaceTaskQuery}
taskTotal={taskTotal}
transactionQuery={workspaceTransactionQuery}
+8
View File
@@ -16,6 +16,7 @@ import type {
GatewayTenantUpsertRequest,
GatewayNetworkProxyConfig,
GatewayTask,
GatewayTaskParamPreprocessingLog,
GatewayUser,
GatewayUserUpsertRequest,
GatewayWalletTransaction,
@@ -660,6 +661,13 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
return request<GatewayTask>(`/api/workspace/tasks/${taskId}`, { token });
}
export async function listTaskParamPreprocessing(
token: string,
taskId: string,
): Promise<ListResponse<GatewayTaskParamPreprocessingLog>> {
return request<ListResponse<GatewayTaskParamPreprocessingLog>>(`/api/workspace/tasks/${taskId}/param-preprocessing`, { token });
}
export async function pollTaskUntilSettled(
token: string,
task: GatewayTask,
+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;
}
+114 -2
View File
@@ -261,8 +261,8 @@ strong {
}
.taskRecordTable .shTableRow {
grid-template-columns: minmax(190px, 0.9fr) minmax(220px, 1fr) minmax(94px, 0.4fr) minmax(280px, 1.45fr) minmax(104px, 0.42fr) minmax(126px, 0.55fr) minmax(150px, 0.66fr) minmax(154px, 0.62fr) minmax(82px, 0.36fr) minmax(98px, 0.42fr) minmax(150px, 0.66fr) minmax(130px, 0.54fr);
min-width: 1778px;
grid-template-columns: minmax(190px, 0.9fr) minmax(220px, 1fr) minmax(94px, 0.4fr) minmax(280px, 1.45fr) minmax(104px, 0.42fr) minmax(118px, 0.44fr) minmax(126px, 0.55fr) minmax(150px, 0.66fr) minmax(154px, 0.62fr) minmax(82px, 0.36fr) minmax(98px, 0.42fr) minmax(150px, 0.66fr) minmax(130px, 0.54fr);
min-width: 1904px;
align-items: start;
}
@@ -359,6 +359,11 @@ strong {
white-space: normal;
}
.taskRecordParamCell {
overflow: visible;
white-space: normal;
}
.taskRecordAttemptCount {
display: inline-flex;
align-items: center;
@@ -437,6 +442,113 @@ strong {
overflow-wrap: anywhere;
}
.taskParamConversionEmpty {
color: var(--text-soft);
font-size: var(--font-size-xs);
}
.taskParamConversionTrigger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 1.5rem;
padding: 0;
border: 0;
background: transparent;
color: var(--text-strong);
cursor: default;
font: inherit;
font-weight: var(--font-weight-medium);
}
.taskParamConversionTrigger svg {
color: var(--primary);
}
.taskParamConversionAntPopover {
z-index: 1200;
}
.taskParamConversionPopover {
display: grid;
width: min(42rem, calc(100vw - 2rem));
max-height: min(34rem, calc(100vh - 7rem));
overflow-y: auto;
gap: 0.65rem;
}
.taskParamConversionPopoverHeader,
.taskParamConversionLogHeader,
.taskParamConversionChangeTop {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.taskParamConversionPopoverHeader strong,
.taskParamConversionLogHeader strong,
.taskParamConversionChangeTop strong {
min-width: 0;
color: var(--text-strong);
font-weight: var(--font-weight-semibold);
overflow-wrap: anywhere;
}
.taskParamConversionPopoverHeader small,
.taskParamConversionLog small,
.taskParamConversionChange small {
color: var(--text-soft);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.taskParamConversionState {
color: var(--text-soft);
font-size: var(--font-size-sm);
}
.taskParamConversionState.error {
color: var(--destructive);
}
.taskParamConversionLog {
display: grid;
min-width: 0;
gap: 0.5rem;
padding-top: 0.65rem;
border-top: 1px solid var(--border);
}
.taskParamConversionChange {
display: grid;
gap: 0.28rem;
padding: 0.5rem;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
color: var(--text-normal);
font-size: var(--font-size-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.taskParamConversionChange code,
.taskParamConversionSummaryPaths code {
color: var(--text-soft);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
overflow-wrap: anywhere;
white-space: normal;
}
.taskParamConversionSummaryPaths {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.taskRecordJsonButton {
width: 100%;
justify-content: flex-start;