Files
UHD_Media_Tool/apps/web/app/routes/console/preferences.tsx
T
rootandClaude Opus 5.5 1023642ef9 feat(web): W4-A wire console to real endpoints
- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local
  mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status;
  stream auto-follow only while the user stays near the bottom (abort stays reachable)
- settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript)
- download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of
  time-window engine run matching and path matching; new business-task tab with cancel/retry
- workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons
- preference tag revision history; subscription search by title/kind; identity/alias/subscription
  lists show entity titles; slot cards show current asset summary; policy preview sample titles
- DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary /
  OrganizePlanArtifactContent taken from contracts
- docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 21:32:11 +08:00

407 lines
16 KiB
TypeScript

import type { PreferenceKind, PreferenceSuppression, PreferenceTag } from "@umt/contracts";
import { Ban, History, Plus, ShieldOff, Tags } from "lucide-react";
import { useMemo, useState } from "react";
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 { 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<string, string> = {
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, unknown>): 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 (
<button
type="button"
onClick={onOpen}
className={cn(
"min-w-0 cursor-pointer rounded-xl border border-border/80 bg-foreground/[0.02] p-3 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
inactive && "opacity-70",
)}
>
<div className="flex items-start justify-between gap-2">
<span className={cn("min-w-0 font-medium break-words text-foreground", inactive && "line-through")}>
{tag.label}
</span>
<Tone v={PREF_STATUS[tag.status]} className="shrink-0" />
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Chip>{PREF_KIND[tag.kind].label}</Chip>
<Chip>{scopeSummary(tag.scope)}</Chip>
<Chip>{PREF_ORIGIN[tag.origin]}</Chip>
</div>
{tag.description ? (
<p className="mt-2 line-clamp-2 text-meta break-words text-muted-foreground">{tag.description}</p>
) : null}
<p className="mt-2 text-micro text-muted-foreground tabular-nums">
r{tag.revision} · 更新于 {relTime(tag.updatedAt)}
</p>
</button>
);
}
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<string | null>(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<string, PreferenceTag>();
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<TimelineItem[]>(() => {
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 (
<>
<PageHeader
title="偏好标签"
description="一个偏好一个标签。软偏好影响排序,硬规则直接排除;手动取消的偏好不会被 Agent 自动加回。"
actions={
<Button onClick={() => setForm({ open: true, tag: null })}>
<Plus />
添加偏好
</Button>
}
/>
<FilterBar
onReset={
filtered
? () => {
setStatus("");
setKind("");
}
: undefined
}
>
<FilterSelect
label="状态"
value={status}
onChange={setStatus}
allLabel="未删除"
options={[
{ value: "active", label: "生效中" },
{ value: "disabled", label: "已停用" },
]}
/>
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(PREF_KIND)} />
</FilterBar>
<div className="space-y-6">
{tags.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={3} />
</div>
) : tags.isError ? (
<ErrorState error={tags.error} onRetry={() => tags.refetch()} />
) : tags.data.items.length === 0 ? (
<EmptyState
icon={Tags}
title={filtered ? "没有符合条件的偏好" : "还没有偏好标签"}
description="偏好可以在这里添加,也可以在 Agent 会话里说「以后都这样」形成长期偏好。"
/>
) : (
<Panel title="偏好" description={`${tags.data.items.length} 个标签,点开看规则与证据`}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{tags.data.items.map((t) => (
<TagCard key={t.id} tag={t} onOpen={() => setSelectedId(t.id)} />
))}
</div>
</Panel>
)}
<Panel title="抑制记录" description={`${activeSups.length} 条生效中`} bodyClassName="space-y-3">
<div className="flex gap-2 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-foreground">
<ShieldOff className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p>
手动停用、删除或修改语义的偏好会留下抑制记录,Agent
不会根据同一旧证据自动加回;只有你重新启用、重新添加或在这里明确解除后才会恢复。
</p>
</div>
{sups.isPending ? (
<LoadingState rows={2} />
) : sups.isError ? (
<ErrorState plain error={sups.error} onRetry={() => sups.refetch()} />
) : sups.data.items.length === 0 ? (
<EmptyState plain icon={Ban} title="没有抑制记录" className="py-8" />
) : (
<ul className="divide-y divide-border/60">
{sups.data.items.map((s) => (
<SuppressionRow
key={s.id}
s={s}
label={s.preferenceTagId ? tagById.get(s.preferenceTagId)?.label : undefined}
onLift={async () => {
try {
await lift.mutateAsync({ id: s.id, reason: "用户明确解除" });
toast.success("已解除抑制");
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
/>
))}
</ul>
)}
</Panel>
<Panel title="历史" description="停用、删除、解除抑制的记录(新 → 旧)">
{sups.isPending || deleted.isPending ? (
<LoadingState rows={2} />
) : history.length === 0 ? (
<EmptyState plain icon={History} title="还没有变更历史" className="py-8" />
) : (
<Timeline items={history} />
)}
</Panel>
</div>
<DetailSheet
open={!!selected}
onOpenChange={(o) => !o && setSelectedId(null)}
title={selected?.label ?? ""}
description={selected ? `${PREF_KIND[selected.kind].label} · ${PREF_KIND[selected.kind].hint}` : undefined}
footer={
selected && selected.status !== "deleted" ? (
<>
<ConfirmDialog
trigger={<Button variant="destructive">删除</Button>}
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;
}
}}
/>
<Button variant="outline" onClick={() => toggle(selected)} disabled={update.isPending}>
{selected.status === "active" ? "停用" : "重新启用"}
</Button>
<Button onClick={() => setForm({ open: true, tag: selected })}>编辑</Button>
</>
) : undefined
}
>
{selected ? (
<>
<KvList
items={[
{ label: "状态", value: <Tone v={PREF_STATUS[selected.status]} /> },
{ 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 },
]}
/>
<DetailSection title="规则">
<JsonView value={selected.rule} />
</DetailSection>
<DetailSection title="作用范围">
<JsonView value={selected.scope} />
</DetailSection>
<DetailSection title="证据">
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
</DetailSection>
<DetailSection title="修订历史">
<TagRevisions id={selected.id} />
</DetailSection>
</>
) : null}
</DetailSheet>
<PreferenceFormDialog open={form.open} tag={form.tag} onOpenChange={(o) => setForm((f) => ({ ...f, open: o }))} />
</>
);
}
const REV_ACTION = { created: "创建", updated: "修改", deleted: "删除" } as const;
const FIELD_LABEL: Record<string, string> = {
label: "名称",
kind: "类型",
scope: "作用范围",
rule: "规则",
description: "说明",
evidence: "证据",
status: "状态",
};
function TagRevisions({ id }: { id: string }) {
const q = useTagRevisions(id);
if (q.isPending) return <LoadingState rows={2} />;
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
if (q.data.length === 0)
return <p className="text-caption text-muted-foreground">没有修订记录(W4 之前创建的标签不带历史)。</p>;
return (
<ul className="space-y-2">
{q.data.map((r) => (
<li key={r.id} className="rounded-lg border border-border/70 px-3 py-2 text-caption">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-foreground">{REV_ACTION[r.action]}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">{r.actorType === "api_key" ? "API Key" : "用户"}</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
<p className="mt-1 text-meta text-muted-foreground">
{r.changedFields.length
? `改动:${r.changedFields.map((f) => FIELD_LABEL[f] ?? f).join("、")}`
: `名称:${r.snapshot.label} · 状态:${r.snapshot.status}`}
</p>
</li>
))}
</ul>
);
}
function SuppressionRow({
s,
label,
onLift,
}: {
s: PreferenceSuppression;
label: string | undefined;
onLift: () => Promise<void>;
}) {
return (
<li className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 font-medium break-words text-foreground">{label ?? "未关联标签"}</span>
<Chip>{supReason(s.reason)}</Chip>
{s.active ? <StatusBadge tone="warning">抑制中</StatusBadge> : <StatusBadge>已解除</StatusBadge>}
</div>
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums">
{actorName(s.suppressedBy)} · {formatDateTime(s.createdAt)}
{s.liftedAt ? ` · ${actorName(s.liftedBy)} 于 ${formatDateTime(s.liftedAt)} 解除` : ""}
</p>
</div>
{s.active ? (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm">
解除抑制
</Button>
}
title="解除这条抑制?"
description="解除后,Agent 可以再次根据证据提出同一偏好(仍需走正常授权)。标签本身不会自动恢复。"
confirmLabel="解除"
onConfirm={onLift}
/>
) : null}
</li>
);
}