import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts"; import { Ban, History, ListChecks, Plus, ShieldOff, Tags } from "lucide-react"; import { useMemo, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { useDeleteTag, useLiftSuppression, usePreferenceTags, useSuppressions, useTagRevisions, useUpdateTag, } from "~/api/preferences.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx"; import { MEDIA_KIND, opts, PREF_KIND, PREF_ORIGIN, PREF_STATUS } from "~/components/library/labels.ts"; import { Chip, Panel, relTime, Tone } from "~/components/library/parts.tsx"; import { PreferenceFormDialog } from "~/components/library/preferences-form.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; // 偏好标签(REQ §6.7):一个偏好一个标签;停用/删除/改语义留抑制记录,Agent 不据同一旧证据自动加回。 const SUP_REASON: Record = { deleted: "删除", "status:disabled": "停用", "status:deleted": "删除", edited: "修改语义", }; const supReason = (r: string | null) => (r ? (SUP_REASON[r] ?? r) : "未注明"); const actorName = (s: string | null) => (s ? s.replace(/^[a-z_]+:/, "") || s : "—"); /** scope 摘要:{} = 全局;mediaKinds 翻中文;其余键原样 */ function scopeSummary(scope: Record): string { const keys = Object.keys(scope); if (keys.length === 0) return "全局"; const parts: string[] = []; for (const k of keys) { const v = scope[k]; if (k === "mediaKinds" && Array.isArray(v)) parts.push(v.map((x) => MEDIA_KIND[x as keyof typeof MEDIA_KIND] ?? String(x)).join("、")); else parts.push(`${k}: ${Array.isArray(v) ? v.join("、") : typeof v === "object" ? "…" : String(v)}`); } return parts.join(" · "); } function TagCard({ tag, onOpen }: { tag: PreferenceTag; onOpen: () => void }) { const inactive = tag.status !== "active"; return ( ); } export default function PreferencesPage() { const [status, setStatus] = useState(""); const [kind, setKind] = useState(""); const tags = usePreferenceTags({ status: (status || undefined) as "active" | "disabled" | undefined, kind: (kind || undefined) as PreferenceKind | undefined, }); const deleted = usePreferenceTags({ status: "deleted" }); const allTags = usePreferenceTags({}); const sups = useSuppressions(); const [selectedId, setSelectedId] = useState(null); const [form, setForm] = useState<{ open: boolean; tag: PreferenceTag | null }>({ open: false, tag: null }); const update = useUpdateTag(); const del = useDeleteTag(); const lift = useLiftSuppression(); const tagById = useMemo(() => { const m = new Map(); for (const t of [...(allTags.data?.items ?? []), ...(deleted.data?.items ?? [])]) m.set(t.id, t); return m; }, [allTags.data, deleted.data]); const selected = selectedId ? (tagById.get(selectedId) ?? tags.data?.items.find((t) => t.id === selectedId)) : null; const toggle = async (t: PreferenceTag) => { const next = t.status === "active" ? "disabled" : "active"; try { await update.mutateAsync({ id: t.id, status: next }); toast.success(next === "active" ? "已启用,抑制已解除" : "已停用"); } catch (err) { toast.error(errorMessage(err)); } }; const history = useMemo(() => { const items: (TimelineItem & { ts: number })[] = []; for (const s of sups.data?.items ?? []) { const label = s.preferenceTagId ? (tagById.get(s.preferenceTagId)?.label ?? "未知标签") : "未关联标签"; items.push({ id: `sup-${s.id}`, at: s.createdAt, ts: Date.parse(s.createdAt), tone: "warning", title: `「${label}」加入抑制(${supReason(s.reason)})`, meta: actorName(s.suppressedBy), }); if (s.liftedAt) items.push({ id: `lift-${s.id}`, at: s.liftedAt, ts: Date.parse(s.liftedAt), tone: "success", title: `「${label}」的抑制已解除`, meta: actorName(s.liftedBy), }); } for (const t of deleted.data?.items ?? []) { if ((sups.data?.items ?? []).some((s) => s.preferenceTagId === t.id && s.reason === "deleted")) continue; items.push({ id: `del-${t.id}`, at: t.updatedAt, ts: Date.parse(t.updatedAt), tone: "neutral", title: `「${t.label}」已删除`, }); } return items.sort((a, b) => b.ts - a.ts).slice(0, 30); }, [sups.data, deleted.data, tagById]); const activeSups = (sups.data?.items ?? []).filter((s) => s.active); const filtered = !!status || !!kind; return ( <> } /> { setStatus(""); setKind(""); } : undefined } >
{tags.isPending ? (
) : tags.isError ? ( tags.refetch()} /> ) : tags.data.items.length === 0 ? ( ) : (
{tags.data.items.map((t) => ( setSelectedId(t.id)} /> ))}
)}

停用、删除或修改语义的偏好会留下抑制记录,Agent 不会按旧证据加回。重新启用、重新添加或解除抑制后恢复。

{sups.isPending ? ( ) : sups.isError ? ( sups.refetch()} /> ) : sups.data.items.length === 0 ? ( ) : (
    {sups.data.items.map((s) => ( { try { await lift.mutateAsync({ id: s.id, reason: "用户明确解除" }); toast.success("已解除抑制"); } catch (err) { toast.error(errorMessage(err)); throw err; } }} /> ))}
)}
{sups.isPending || deleted.isPending ? ( ) : history.length === 0 ? ( ) : ( )}
!o && setSelectedId(null)} title={selected?.label ?? ""} description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined} footer={ selected && selected.status !== "deleted" ? ( <> 删除} title={`删除「${selected.label}」?`} description="删除后留下抑制记录,Agent 不会按旧证据加回。" confirmLabel="删除" destructive onConfirm={async () => { try { await del.mutateAsync(selected.id); toast.success("已删除"); setSelectedId(null); } catch (err) { toast.error(errorMessage(err)); throw err; } }} /> ) : undefined } > {selected ? ( <> }, { label: "来源", value: PREF_ORIGIN[selected.origin] }, { label: "作用范围", value: scopeSummary(selected.scope) }, { label: "修订", value: `r${selected.revision}` }, { label: "创建", value: formatDateTime(selected.createdAt) }, { label: "更新", value: formatDateTime(selected.updatedAt) }, { label: "说明", value: selected.description, wide: true }, { label: "语义指纹", value: selected.fingerprint, mono: true, wide: true }, ]} /> ) : null} setForm((f) => ({ ...f, open: o }))} /> ); } const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const; const FIELD_LABEL: Record = { label: "名称", kind: "类型", scope: "作用范围", rule: "规则", description: "说明", evidence: "证据", status: "状态", }; function TagRevisions({ id }: { id: string }) { const q = useTagRevisions(id); if (q.isPending) return ; if (q.isError) return q.refetch()} />; if (q.data.length === 0) return

没有修订记录。W4 之前创建的标签没有历史。

; return (
    {q.data.map((r) => (
  • {REV_ACTION[r.action]} {r.revision ? r{r.revision} : null} {r.actorType === "api_key" ? "API Key" : "用户"} {formatDateTime(r.at)}

    {r.changedFields.length ? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}` : `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`}

  • ))}
); } function SuppressionRow({ s, label, onLift, }: { s: PreferenceSuppression; label: string | undefined; onLift: () => Promise; }) { return (
  • {label ?? "未关联标签"} {supReason(s.reason)} {s.active ? 抑制中 : 已解除}

    {actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)} {s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}

    {s.active ? ( 解除抑制 } title="解除这条抑制?" description="解除后,Agent 可按证据再次提出这个偏好,仍需授权。标签不会恢复。" confirmLabel="解除" onConfirm={onLift} /> ) : null}
  • ); }