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:
1 parent
34e8751b45
commit
1023642ef9
30 files changed
+703
-271
No files matched your search
@@ -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" : ""}`)}
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user