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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:32:11 +08:00
1 parent 34e8751b45
commit 1023642ef9
30 files changed
+703 -271

No files matched your search

+7 -9
View File
@@ -4,7 +4,6 @@ import { useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { type AgentRunView, agentMockEnabled, RUN_STATUS_LABEL, useAgentRuns, useCreateAgentRun } from "~/api/agent.ts";
import { isMissingEndpoint } from "~/api/parse.ts";
import { RunStatusBadge } from "~/components/agent/agent-conversation.tsx";
import { GoalComposer } from "~/components/agent/agent-run-panel.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
@@ -47,7 +46,6 @@ export default function AgentPage() {
const create = useCreateAgentRun();
const navigate = useNavigate();
const mock = agentMockEnabled();
const missing = !mock && isMissingEndpoint(runs.error);
const rows = runs.data?.pages.flatMap((p) => p.items);
return (
@@ -56,12 +54,12 @@ export default function AgentPage() {
title="Agent 会话"
description="交代目标,Agent 在授权范围内边执行边汇报;只要方案时,它只调查不执行。"
/>
{missing ? (
{mock ? (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-warning/40 bg-warning/10 px-4 py-3 text-caption text-warning">
<FlaskConical className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">Agent 会话端点还没上线(W2-D 实现中)。可以先用 mock 数据预览交互。</span>
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=1")}>
用 mock 预览
<span className="min-w-0 flex-1">演示模式:会话是本地假数据,不连后端,不会产生任何动作。</span>
<Button variant="outline" size="sm" onClick={() => window.location.assign("/console/agent?mock=0")}>
退出演示
</Button>
</div>
) : null}
@@ -89,14 +87,14 @@ export default function AgentPage() {
</FilterBar>
<DataTable
columns={COLUMNS}
rows={missing ? [] : rows}
rows={rows}
rowKey={(r) => r.id}
loading={runs.isPending}
error={missing ? undefined : runs.error}
error={runs.error}
onRetry={() => void runs.refetch()}
empty={{
icon: Bot,
title: missing ? "会话端点未就绪" : "还没有会话",
title: "还没有会话",
description: "例如:“把某部电影的 1080p 收进来”,或在任意页面右上角打开 Agent 侧栏。",
}}
onRowClick={(r) => navigate(`/console/agent/${r.id}${mock ? "?mock=1" : ""}`)}
+10
View File
@@ -166,6 +166,16 @@ function ExternalTab() {
</span>
),
},
{
key: "entity",
header: "作品",
hideBelow: "sm",
cell: (r) => (
<span className="line-clamp-1 text-caption text-foreground">
{r.mediaEntityTitle ?? <span className="text-muted-foreground">未关联</span>}
</span>
),
},
{ key: "status", header: "状态", cell: (r) => <Tone v={IDENTITY_STATUS[r.status]} /> },
{
key: "level",
@@ -7,6 +7,7 @@ import {
useLiftSuppression,
usePreferenceTags,
useSuppressions,
useTagRevisions,
useUpdateTag,
} from "~/api/preferences.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
@@ -315,6 +316,9 @@ export default function PreferencesPage() {
<DetailSection title="证据">
<JsonView value={Object.keys(selected.evidence).length ? selected.evidence : null} />
</DetailSection>
<DetailSection title="修订历史">
<TagRevisions id={selected.id} />
</DetailSection>
</>
) : null}
</DetailSheet>
@@ -324,6 +328,44 @@ export default function PreferencesPage() {
);
}
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,
@@ -124,7 +124,17 @@ const PI_CARD: CardSpec = {
title: "Pi 上游与模型",
description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。",
fields: [
{ name: "pi.provider", label: "Provider", kind: "text", required: true, placeholder: "anthropic" },
{
name: "pi.provider",
label: "Provider",
kind: "select",
required: true,
options: [
{ value: "anthropic", label: "anthropic" },
{ value: "openai-compatible", label: "openai-compatible(baseUrl 带 /v1)" },
{ value: "fake", label: "fake(脚本化假模型,不联网,演示用)" },
],
},
{ name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" },
{ name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true },
{ name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true },
@@ -216,11 +226,58 @@ const AGENT_BUDGET_CARD: CardSpec = {
}),
};
const AGENT_RUNTIME_CARD: CardSpec = {
title: "Agent 运行",
description: "单次运行预算、后台自动调查;provider 选 fake 时按下面的脚本回放工具调用(留空用内置演示脚本)。",
fields: [
{ name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1 – 500" },
{ name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" },
{ name: "agent.autoInvestigate", label: "待处理事项自动调查", kind: "switch" },
{
name: "agent.investigateMaxToolCalls",
label: "自动调查工具调用上限",
kind: "number",
visible: (v) => v["agent.autoInvestigate"] === true,
},
{ name: "agent.goalPollSec", label: "目标落定轮询(秒)", kind: "number" },
{ name: "agent.goalWaitHours", label: "目标最长等待(小时)", kind: "number" },
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: '{"match":"收进来","steps":[{"toolCalls":[{"capability":"inventory.query","input":{"query":"沙丘2"}}]}],"finalText":"…"},格式见 docs/runbook/agent.md',
},
],
fromSettings: (s) => ({
"agent.maxToolCalls": n(s.agent.maxToolCalls),
"agent.maxDurationSec": n(s.agent.maxDurationSec),
"agent.autoInvestigate": s.agent.autoInvestigate,
"agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls),
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
"agent.fakeScript": s.agent.fakeScript ?? "",
}),
toPatch: (v) => ({
agent: {
maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]),
autoInvestigate: v["agent.autoInvestigate"] === true,
investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]),
goalPollSec: num(v["agent.goalPollSec"]),
goalWaitHours: num(v["agent.goalWaitHours"]),
fakeScript: str(v["agent.fakeScript"]).trim() || null,
},
}),
};
export function PiTab() {
return useSettingsGate((s) => (
<div className="grid gap-4 xl:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
<SettingsCard spec={PI_CARD} settings={s} />
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
<div className="grid content-start gap-4">
<SettingsCard spec={AGENT_BUDGET_CARD} settings={s} />
<SettingsCard spec={AGENT_RUNTIME_CARD} settings={s} />
</div>
</div>
));
}
+37 -5
View File
@@ -9,7 +9,7 @@ import {
type SubscriptionStatus,
} from "@umt/contracts";
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
import { useId, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useEntitySearch } from "~/api/library.ts";
@@ -115,8 +115,20 @@ export default function SubscriptionsPage() {
// ── 订阅列表 ──
function useDebounced(v: string, ms = 300) {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
function EntityTitle({ sub }: { sub: Subscription }) {
const q = useEntityTitle(sub.mediaEntityId);
// 列表响应已带 mediaEntityTitle;没有时(旧数据/详情)再按 ID 取
const q = useEntityTitle(sub.mediaEntityTitle ? null : sub.mediaEntityId);
if (sub.mediaEntityId && sub.mediaEntityTitle)
return <span className="min-w-0 break-all text-foreground">{sub.mediaEntityTitle}</span>;
if (!sub.mediaEntityId)
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
@@ -137,7 +149,15 @@ function EntityTitle({ sub }: { sub: Subscription }) {
function SubscriptionsTab() {
const [status, setStatus] = useState("");
const filters: SubscriptionFilters = { status: (status || undefined) as SubscriptionFilters["status"] };
const [kind, setKind] = useState("");
const [search, setSearch] = useState("");
const dq = useDebounced(search.trim());
const filters: SubscriptionFilters = {
status: (status || undefined) as SubscriptionFilters["status"],
mediaKind: (kind || undefined) as SubscriptionFilters["mediaKind"],
q: dq || undefined,
};
const filtered = !!status || !!kind || !!search;
const q = useSubscriptions(filters);
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
const [openId, setOpenId] = useState<string | null>(null);
@@ -186,8 +206,20 @@ function SubscriptionsTab() {
return (
<>
<FilterBar onReset={status ? () => setStatus("") : undefined}>
<FilterBar
search={{ value: search, onChange: setSearch, placeholder: "按作品标题 / 别名 / 检索词搜索" }}
onReset={
filtered
? () => {
setStatus("");
setKind("");
setSearch("");
}
: undefined
}
>
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(SUB_KIND_LABEL)} />
</FilterBar>
<DataTable
columns={columns}
@@ -203,7 +235,7 @@ function SubscriptionsTab() {
loadingMore={q.isFetchingNextPage}
empty={{
icon: CalendarClock,
title: status ? "没有符合条件的订阅" : "还没有订阅",
title: filtered ? "没有符合条件的订阅" : "还没有订阅",
description: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
}}
/>
+5
View File
@@ -18,6 +18,7 @@ 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 { BusinessTasks } from "~/components/downloads/business-tasks.tsx";
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
import {
DownloadDetail,
@@ -171,6 +172,7 @@ export default function TasksPage() {
<TabsList>
<TabsTrigger value="downloads">下载任务</TabsTrigger>
<TabsTrigger value="plans">下载计划</TabsTrigger>
<TabsTrigger value="tasks">业务任务</TabsTrigger>
</TabsList>
<TabsContent value="downloads">
<FilterBar
@@ -246,6 +248,9 @@ export default function TasksPage() {
}}
/>
</TabsContent>
<TabsContent value="tasks">
<BusinessTasks />
</TabsContent>
</Tabs>
<DetailSheet