Merge branch 'fix7'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
51db3afd0b
35 files changed
+764
-125
No files matched your search
@@ -10,7 +10,7 @@ import { type FrozenPlan, planImport } from "./services/organize.ts";
|
||||
import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts";
|
||||
|
||||
// 本模块领域的能力处理器(规格一律取 contracts):
|
||||
// inventory.query / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply
|
||||
// inventory.query / downloader.list / download.plan / download.submit / organize.plan / organize.execute / policy.preview / policy.apply
|
||||
// 授权(grant/approval)、幂等键与操作账本由注册中心统一处理(modules/agent/capability-registry.ts);
|
||||
// 这里的处理器只调本模块服务,并保证自身幂等(崩溃后同幂等键重跑用于对账)。
|
||||
|
||||
@@ -159,6 +159,57 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
||||
} as never;
|
||||
});
|
||||
|
||||
// FIX-7:已配置的下载器与媒体服务器(只读本地记录:启用、最近连通状态、管理的任务数;不含地址与凭证)
|
||||
registry.register(capabilitySpec("downloader.list"), async (input) => {
|
||||
const db = deps().ctx.db;
|
||||
const [downloaders, counts, servers] = await Promise.all([
|
||||
db
|
||||
.selectFrom("downloader_instance")
|
||||
.select(["id", "name", "kind", "enabled", "health_status", "last_seen_at", "version_info"])
|
||||
.$if(!input.includeDisabled, (qb) => qb.where("enabled", "=", true))
|
||||
.orderBy("name")
|
||||
.execute(),
|
||||
sql<{ instance_id: string; total: number; active: number; failed: number }>`
|
||||
select instance_id, count(*)::int as total,
|
||||
count(*) filter (where status in ('queued', 'submitting', 'downloading', 'paused', 'stalled', 'importing'))::int as active,
|
||||
count(*) filter (where status in ('failed', 'missing'))::int as failed
|
||||
from download_task
|
||||
where managed and removed_at is null
|
||||
group by instance_id`.execute(db),
|
||||
db
|
||||
.selectFrom("media_server_instance")
|
||||
.select(["id", "name", "kind", "enabled", "health_status", "last_seen_at"])
|
||||
.$if(!input.includeDisabled, (qb) => qb.where("enabled", "=", true))
|
||||
.orderBy("name")
|
||||
.execute(),
|
||||
]);
|
||||
const iso = (d: Date | null) => (d ? d.toISOString() : null);
|
||||
return {
|
||||
downloaders: downloaders.map((r) => {
|
||||
const c = counts.rows.find((x) => x.instance_id === r.id);
|
||||
const v = (r.version_info ?? {}) as { app?: unknown };
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
enabled: r.enabled,
|
||||
healthStatus: r.health_status as never,
|
||||
lastSeenAt: iso(r.last_seen_at),
|
||||
version: typeof v.app === "string" ? v.app : null,
|
||||
tasks: { total: c?.total ?? 0, active: c?.active ?? 0, failed: c?.failed ?? 0 },
|
||||
};
|
||||
}),
|
||||
mediaServers: servers.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
kind: r.kind,
|
||||
enabled: r.enabled,
|
||||
healthStatus: r.health_status as never,
|
||||
lastSeenAt: iso(r.last_seen_at),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// AG-3:下载任务详情(按 id 或 hash);关联计划 / 资源 / 业务任务 / 整理记录 / 待处理事项
|
||||
registry.register(capabilitySpec("download.inspect"), async (input) => {
|
||||
const db = deps().ctx.db;
|
||||
|
||||
@@ -60,6 +60,7 @@ export const INVESTIGATE_CAPABILITIES = [
|
||||
"inventory.query",
|
||||
"task.inspect",
|
||||
"download.inspect",
|
||||
"downloader.list",
|
||||
"decision.list",
|
||||
"subscription.list",
|
||||
"identity.resolve",
|
||||
|
||||
@@ -375,7 +375,8 @@ export async function annotate(
|
||||
fileKind,
|
||||
status,
|
||||
escape: r.escape,
|
||||
asset: a?.link ?? null,
|
||||
// is_primary 是"资产的主位置",字幕等附属文件自成资产时也为 true;「主文件」只认视频扩展名(FIX-7)
|
||||
asset: a ? { ...a.link, isPrimary: a.link.isPrimary && fileKind === "video" } : null,
|
||||
download: dl ? { taskId: dl.taskId, status: dl.status, progress: dl.progress, hnr: dl.hnr } : null,
|
||||
media: a?.media ?? null,
|
||||
dirSummary: null,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { resolveBusiness } from "../../src/modules/auth-settings/engine-business
|
||||
import { createE2E, type E2E, pgAvailable, runEvents, runRow, seedBase, seedMovieTarget, waitFor } from "./harness.ts";
|
||||
|
||||
// E15 Agent 质量(AG-3,来自 AGENT-REAL 真实上游验证):
|
||||
// - 只读摘要能力 subscription.list / decision.list / download.inspect(按 id 或 hash);task.inspect 收到下载任务 ID
|
||||
// - 只读摘要能力 subscription.list / decision.list / download.inspect(按 id 或 hash)/ downloader.list;task.inspect 收到下载任务 ID
|
||||
// 时明确报错并指向 download.inspect;
|
||||
// - download.plan 的 requiresApproval 按授权范围判定(覆盖本作品的持续授权 → false;范围外 / 没有 → true);
|
||||
// - 超预算:run = budget_exceeded,任务页(引擎 run 的 business.outcome)显示超出预算而不是完成;
|
||||
@@ -23,10 +23,12 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => {
|
||||
let e: E2E;
|
||||
let target: Awaited<ReturnType<typeof seedMovieTarget>>;
|
||||
let downloadTaskId: string;
|
||||
let instanceId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
e = await createE2E("e15", { fakeScripts: scripts });
|
||||
const base = await seedBase(e);
|
||||
instanceId = base.instanceId;
|
||||
target = await seedMovieTarget(e, base.siteId);
|
||||
downloadTaskId = (
|
||||
await e.db
|
||||
@@ -64,6 +66,7 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => {
|
||||
text: "按 hash 查下载。",
|
||||
toolCalls: [{ capability: "download.inspect", input: { hash: HASH.toUpperCase() } }],
|
||||
},
|
||||
{ text: "看下载器。", toolCalls: [{ capability: "downloader.list", input: {} }] },
|
||||
// 常见误用:把下载任务 ID 当业务任务 ID
|
||||
{ text: "看任务时间线。", toolCalls: [{ capability: "task.inspect", input: { taskId: downloadTaskId } }] },
|
||||
],
|
||||
@@ -124,6 +127,14 @@ describe.skipIf(!hasPg)("E15 Agent 质量(fake 模型 + 真实能力)", () => {
|
||||
decisions: [{ kind: "download_failed" }],
|
||||
otherMatches: [],
|
||||
});
|
||||
// FIX-7:下载器是否接入由 downloader.list 回答(site.list 不含下载器),任务按实例计数,不含地址与凭证
|
||||
expect(byCap("downloader.list")).toMatchObject({ isError: false });
|
||||
expect(byCap("downloader.list").json).toMatchObject({
|
||||
// 种下的失败任务在假 qB 里不存在,同步后标 removed(见上面 download.inspect),已移除的不计入
|
||||
downloaders: [{ id: instanceId, kind: "qbittorrent", enabled: true, tasks: { total: 0, active: 0, failed: 0 } }],
|
||||
mediaServers: [],
|
||||
});
|
||||
expect(byCap("downloader.list").text).not.toMatch(/baseUrl|base_url|password|secret/i);
|
||||
expect(byCap("task.inspect")).toMatchObject({ isError: true });
|
||||
expect(byCap("task.inspect").text).toContain("download.inspect");
|
||||
|
||||
|
||||
@@ -45,8 +45,8 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
|
||||
return r.body.data.id as string;
|
||||
};
|
||||
|
||||
it("启动断言:39 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(39);
|
||||
it("启动断言:40 个能力全部注册,并在能力目录里可见", async () => {
|
||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(40);
|
||||
const r = await e.api("GET", "/capabilities");
|
||||
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
|
||||
e.booted.ctx.capabilities
|
||||
|
||||
@@ -78,6 +78,29 @@ describe.skipIf(skip)("files 浏览与守卫", () => {
|
||||
expect(dir.dirSummary).toMatchObject({ videoCount: 2, registeredCount: 1 });
|
||||
});
|
||||
|
||||
it("主文件只认视频:整理登记成独立资产的字幕(is_primary = true)不标主文件(FIX-7)", async () => {
|
||||
await mkdir(join(h.lib, "Sub (2014)"));
|
||||
await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.mkv"), "video");
|
||||
await writeFile(join(h.lib, "Sub (2014)", "Sub (2014) - 1080p.chi.zh-cn.srt"), "1\n");
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "Sub", year: 2014 });
|
||||
const v = await seedAsset(h.db, h, { entityId: e, rel: "Sub (2014)/Sub (2014) - 1080p.mkv" });
|
||||
const srt = await seedAsset(h.db, h, {
|
||||
entityId: e,
|
||||
rel: "Sub (2014)/Sub (2014) - 1080p.chi.zh-cn.srt",
|
||||
kind: "subtitle",
|
||||
});
|
||||
// 与 organize.ts 一致:附属文件的资产位置也是该资产的主位置,并挂在视频位置下
|
||||
await h.db
|
||||
.updateTable("asset_location")
|
||||
.set({ is_primary: true, sidecar_of_location_id: v.locationId })
|
||||
.where("id", "=", srt.locationId)
|
||||
.execute();
|
||||
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Sub (2014)" })}`);
|
||||
const byName = Object.fromEntries(r.body.data.entries.map((x: { name: string }) => [x.name, x]));
|
||||
expect(byName["Sub (2014) - 1080p.mkv"].asset).toMatchObject({ isPrimary: true });
|
||||
expect(byName["Sub (2014) - 1080p.chi.zh-cn.srt"].asset).toMatchObject({ isPrimary: false });
|
||||
});
|
||||
|
||||
it("下载根:纳管下载中的内容标 downloading", async () => {
|
||||
const r = await h.api("GET", `/files?${q({ root: h.dlRoot })}`);
|
||||
const e = r.body.data.entries.find((x: { name: string }) => x.name === "Show.S01.1080p");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BUILTIN_AGENT_CHECKS, renderGoal } from "../../src/modules/schedules/agent-checks.ts";
|
||||
|
||||
// FIX-7:界面上看到的 {{now}} / {{openDecisions}} 是模板本身;执行时必须全部替换成实际值。
|
||||
|
||||
describe("renderGoal(巡检目标模板)", () => {
|
||||
const now = new Date("2026-09-28T18:53:50.468Z");
|
||||
const facts = { openDecisions: 3, stalledDownloads: 1, activeSubscriptions: 5 };
|
||||
|
||||
it("内置巡检的模板执行时不留占位符", () => {
|
||||
for (const c of BUILTIN_AGENT_CHECKS) {
|
||||
const goal = renderGoal(c.config.goalTemplate, now, facts);
|
||||
expect(goal).not.toMatch(/\{\{/);
|
||||
}
|
||||
const pending = BUILTIN_AGENT_CHECKS.find((c) => c.key === "check.pending");
|
||||
expect(renderGoal(pending?.config.goalTemplate ?? "", now, facts)).toContain(
|
||||
"巡检待处理(2026-09-28T18:53:50.468Z):有 3 条未关闭的待处理事项",
|
||||
);
|
||||
});
|
||||
|
||||
it("容许空格;不认识的占位符原样保留", () => {
|
||||
expect(renderGoal("{{ date }} 待处理 {{openDecisions}} 条 {{other}}", now, facts)).toBe(
|
||||
"2026-09-28 待处理 3 条 {{other}}",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,8 @@ const TiB = 1024 ** 4;
|
||||
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
||||
|
||||
describe("站点能力规格", () => {
|
||||
it("进入首批能力目录(17 + 3 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 39);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(39);
|
||||
it("进入首批能力目录(17 + 4 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 40);类别 / 风险 / 重放按约定", () => {
|
||||
expect(CAPABILITY_IDS).toHaveLength(40);
|
||||
for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id);
|
||||
expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
||||
expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
runningText,
|
||||
toolSummary,
|
||||
} from "./conversation-model.ts";
|
||||
import { collectNames, humanizeReport } from "./report-humanize.ts";
|
||||
import { ToolCallIo } from "./tool-call-card-io.tsx";
|
||||
import type { AgentStreamState, StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
@@ -439,6 +440,7 @@ export function AgentConversation({
|
||||
userMessages = [],
|
||||
audit,
|
||||
after,
|
||||
knownNames,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
live: AgentStreamState;
|
||||
@@ -446,6 +448,8 @@ export function AgentConversation({
|
||||
audit?: InvocationAudit[];
|
||||
/** 流末尾追加的内容(其它审批卡、实际结果行) */
|
||||
after?: ReactNode;
|
||||
/** 工具结果之外已知的「ID → 名字」(子会话),汇报兜底换名用 */
|
||||
knownNames?: [string, string][];
|
||||
}) {
|
||||
const active = isRunActive(run.status) && !live.finished;
|
||||
const waiting = run.status === "waiting" || run.status === "running" || run.status === "queued";
|
||||
@@ -460,6 +464,7 @@ export function AgentConversation({
|
||||
const lastKey = live.items.at(-1)?.key;
|
||||
const tail = live.items.at(-1);
|
||||
const blocks = buildConversation(live.items, userMessages);
|
||||
const names = collectNames(live.items, knownNames);
|
||||
const anim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-base ease-pop motion-reduce:animate-none";
|
||||
|
||||
const lastBlock = blocks.at(-1);
|
||||
@@ -501,18 +506,21 @@ export function AgentConversation({
|
||||
) : null}
|
||||
{p.body.map((t) => {
|
||||
const streaming = streamingText && t.key === lastKey;
|
||||
// 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息
|
||||
const human = streaming ? null : humanizeReport(t.text, names);
|
||||
return (
|
||||
<div key={t.key} className={cn("w-full min-w-0", freshKeys.has(t.key) && anim)}>
|
||||
<StreamingResponse
|
||||
className="max-w-[72ch]"
|
||||
status={streaming ? "streaming" : "complete"}
|
||||
copyText={t.text}
|
||||
copyText={human?.text ?? t.text}
|
||||
announce={false}
|
||||
showActions={false}
|
||||
>
|
||||
{/* 流式中按原文显示(半截 Markdown 会闪),结束后按安全子集渲染 */}
|
||||
{streaming ? t.text : <Markdown text={t.text} />}
|
||||
{human ? <Markdown text={human.text} /> : t.text}
|
||||
</StreamingResponse>
|
||||
{human?.replaced.length ? <ReplacedIds replaced={human.replaced} /> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -556,6 +564,32 @@ export function AgentConversation({
|
||||
);
|
||||
}
|
||||
|
||||
/** 汇报里被换掉的原文(ID、能力、原因码),收在技术信息里 */
|
||||
function ReplacedIds({ replaced }: { replaced: { from: string; to: string }[] }) {
|
||||
const uniq = [...new Map(replaced.map((r) => [r.from, r] as const)).values()];
|
||||
return (
|
||||
<TechInfo
|
||||
title="正文原文"
|
||||
className="mt-1"
|
||||
items={[
|
||||
{
|
||||
label: "换掉的标识",
|
||||
value: (
|
||||
<ul className="space-y-0.5">
|
||||
{uniq.map((r) => (
|
||||
<li key={r.from}>
|
||||
{r.from} → {r.to || "(省略)"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
wide: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 实际结果 ──
|
||||
|
||||
/** 实际结果的一句话(服务端从下载计划 / 下载任务 / 整理记录汇总,不采信模型文本) */
|
||||
@@ -592,10 +626,13 @@ export function ResultLine({
|
||||
run,
|
||||
report,
|
||||
live,
|
||||
subagents = 0,
|
||||
}: {
|
||||
run: AgentRunView;
|
||||
report: GoalReport | null;
|
||||
live: AgentStreamState;
|
||||
/** 实际派出的子会话数(会话树里的直接子会话,不含被拒绝的派发) */
|
||||
subagents?: number;
|
||||
}) {
|
||||
const u = { ...run.usage, ...(live.usage ?? {}) };
|
||||
const durationMs =
|
||||
@@ -623,6 +660,7 @@ export function ResultLine({
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-meta text-muted-foreground">
|
||||
{report ? null : <LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />}
|
||||
<span className={cn("text-foreground/85", bad && "text-warning")}>实际结果:{resultText(report)}</span>
|
||||
{subagents ? <span className="tabular-nums">· 派出 {subagents} 个子会话</span> : null}
|
||||
{durationMs ? <span className="tabular-nums">· {durationLabel(durationMs)}</span> : null}
|
||||
{u.costUsd ? <span className="tabular-nums">· 约 {formatUsd(u.costUsd)}</span> : null}
|
||||
</p>
|
||||
@@ -643,7 +681,7 @@ export function ResultLine({
|
||||
items={[
|
||||
{
|
||||
label: "说明",
|
||||
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务和整理记录。",
|
||||
value: "上面的正文是智能体的说法,未经核查;实际结果取自下载任务、整理记录和会话树。",
|
||||
wide: true,
|
||||
mono: false,
|
||||
},
|
||||
|
||||
@@ -155,4 +155,16 @@ describe("shortGoalTitle(会话短标题)", () => {
|
||||
expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`);
|
||||
expect(shortGoalTitle(" ")).toBe("未命名会话");
|
||||
});
|
||||
it("ISO 时间与待处理类型码转人话(FIX-7)", () => {
|
||||
const iso = "2026-09-28T18:53:50.468Z";
|
||||
const d = new Date(iso);
|
||||
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
expect(shortGoalTitle(`巡检待处理(${iso}):有 1 条未关闭的待处理事项。逐条查看`)).toBe(
|
||||
`巡检待处理(${d.getMonth() + 1}月${d.getDate()}日 ${hm}):有 1 条未关闭的待处理事项`,
|
||||
);
|
||||
const t = shortGoalTitle(
|
||||
"调查待处理事项 728960d1-b892-4ae7-8de3-baf61f8abbd7(download_failed):下载异常:Dune.Part.Two。摘要",
|
||||
);
|
||||
expect(t).toBe("调查待处理事项(下载失败):下载异常:Dune.Part.Two");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { stepProgress } from "./conversation-model.ts";
|
||||
import type { ApprovalView, OptionsView } from "./use-agent-stream.ts";
|
||||
|
||||
// 自动化优先的会话交互(AG-2):步骤清单(agent.plan)、方案选择(agent.propose_options)、步骤处的审批卡。
|
||||
@@ -54,11 +55,10 @@ export function RunPlan({
|
||||
fill?: boolean;
|
||||
}) {
|
||||
if (!plan?.steps.length) return null;
|
||||
const done = plan.steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const current = plan.steps.find((s) => s.status === "running") ?? plan.steps.find((s) => s.status === "pending");
|
||||
const failed = plan.steps.some((s) => s.status === "failed");
|
||||
const progress = stepProgress(plan.steps);
|
||||
const failed = progress.failed > 0;
|
||||
if (compact) {
|
||||
const pct = Math.round((done / plan.steps.length) * 100);
|
||||
const pct = progress.pct;
|
||||
return (
|
||||
<section aria-label="步骤进度" className="rounded-xl border border-border/70 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-meta">
|
||||
@@ -67,11 +67,9 @@ export function RunPlan({
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
|
||||
/>
|
||||
<span className="shrink-0 text-foreground tabular-nums">
|
||||
步骤 {done}/{plan.steps.length}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{current ? `当前:${current.title}` : failed ? "有步骤失败" : "全部完成"}
|
||||
步骤 {progress.done}/{progress.total}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
|
||||
<div
|
||||
@@ -89,6 +87,7 @@ export function RunPlan({
|
||||
<TodoList
|
||||
title="步骤"
|
||||
items={planTodoItems(plan.steps)}
|
||||
count={progress.count}
|
||||
defaultOpen
|
||||
collapseOnComplete={!fill && !active}
|
||||
maxHeightClassName={fill ? "max-h-none" : "max-h-56"}
|
||||
|
||||
@@ -39,7 +39,9 @@ import {
|
||||
runPlanIds,
|
||||
} from "./agent-conversation.tsx";
|
||||
import { RunPlan } from "./agent-interaction.tsx";
|
||||
import { shortGoalTitle } from "./agent-store.ts";
|
||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||
import { stepProgress } from "./conversation-model.ts";
|
||||
import {
|
||||
NEW_RUN_COMMANDS,
|
||||
PROPOSE_FIRST,
|
||||
@@ -154,9 +156,8 @@ export function RunOverviewBar({
|
||||
const [open, setOpen] = useState(false);
|
||||
const r = snapshot.run;
|
||||
const steps = live.plan?.steps ?? [];
|
||||
const done = steps.filter((s) => s.status === "done" || s.status === "skipped").length;
|
||||
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
|
||||
const failed = steps.find((s) => s.status === "failed");
|
||||
const progress = stepProgress(steps);
|
||||
const failed = progress.failedStep;
|
||||
const family = (snapshot.children?.length ?? 0) > 0 || !!snapshot.parent;
|
||||
if (!steps.length && !family && !isRunActive(status)) return null;
|
||||
const u = { ...r.usage, ...(live.usage ?? {}) };
|
||||
@@ -177,11 +178,9 @@ export function RunOverviewBar({
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", failed && "text-destructive")}
|
||||
/>
|
||||
<span className="shrink-0 text-foreground tabular-nums">
|
||||
步骤 {done}/{steps.length}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{failed ? `失败:${failed.title}` : current ? `当前:${current.title}` : "全部完成"}
|
||||
步骤 {progress.done}/{progress.total}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{progress.status}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
@@ -197,7 +196,7 @@ export function RunOverviewBar({
|
||||
"h-full rounded-full transition-[width] duration-layout ease-standard motion-reduce:transition-none",
|
||||
failed ? "bg-destructive" : "bg-foreground/60",
|
||||
)}
|
||||
style={{ width: `${Math.round((done / steps.length) * 100)}%` }}
|
||||
style={{ width: `${progress.pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -384,6 +383,12 @@ export function AgentRunPanel({
|
||||
<AgentConversation
|
||||
run={r}
|
||||
live={live}
|
||||
knownNames={[
|
||||
...(run.data.children ?? []).map((c) => [c.id, shortGoalTitle(c.goal, 24)] as [string, string]),
|
||||
...(run.data.parent
|
||||
? [[run.data.parent.id, shortGoalTitle(run.data.parent.goal, 24)] as [string, string]]
|
||||
: []),
|
||||
]}
|
||||
audit={audit.data}
|
||||
userMessages={messages.map((m) => ({ at: m.createdAt, text: m.text, pending: m.status === "pending" }))}
|
||||
after={
|
||||
@@ -393,7 +398,14 @@ export function AgentRunPanel({
|
||||
.map((a) => (
|
||||
<ApprovalItem key={a.id} approval={a} />
|
||||
))}
|
||||
{finished ? <ResultLine run={r} report={report} live={live} /> : null}
|
||||
{finished ? (
|
||||
<ResultLine
|
||||
run={r}
|
||||
report={report}
|
||||
live={live}
|
||||
subagents={(run.data.children ?? []).filter((c) => c.parentRunId === r.id).length}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
||||
import { CAPABILITY_CATALOG, type KnownCapabilityId, reasonText } from "@umt/contracts";
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
|
||||
@@ -159,6 +159,16 @@ const capabilityTitle = (id: string): string | null =>
|
||||
|
||||
const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
||||
const CAP_RE = /\b[a-z][a-z_]*(?:\.[a-z_]+)+\b/g;
|
||||
const ISO_RE = /\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?/g;
|
||||
const CODE_RE = /\b[a-z]+(?:_[a-z]+)+\b/g;
|
||||
|
||||
/** 目标里的 ISO 时间(巡检模板的 {{now}})→「9月28日 18:53」,按浏览器时区 */
|
||||
function shortTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话标题:从目标生成短标题(DEAI-2)。去掉上下文前缀、UUID、JSON 参数,能力 ID 换成中文名,
|
||||
@@ -170,10 +180,14 @@ export function shortGoalTitle(goal: string, max = 40): string {
|
||||
t = t.replace(UUID_RE, "");
|
||||
t = t.replace(/\b[0-9a-f]{8}(?:-[0-9a-f]{0,4})*…?/gi, ""); // 截断的短 ID
|
||||
t = t.replace(CAP_RE, (id) => capabilityTitle(id) ?? id);
|
||||
t = t.replace(ISO_RE, shortTime);
|
||||
// 待处理类型、原因码(download_failed)换成人话
|
||||
t = t.replace(CODE_RE, (c) => reasonText(c)?.text ?? c);
|
||||
t = t.replace(/[((]\s*([^()()]*[\p{Script=Han}][^()()]*)\s*[))]/gu, "($1)");
|
||||
t = t.split(/[\n。!?!?]/)[0] ?? "";
|
||||
t = t
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、)])/gu, "")
|
||||
.replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、()])/gu, "")
|
||||
.replace(/[,,::;;、\s]*(?:参数|args?)?[::]?\s*$/i, "")
|
||||
.trim();
|
||||
if (!t) return "未命名会话";
|
||||
|
||||
@@ -62,14 +62,15 @@ const joinNouns = (xs: string[]) =>
|
||||
|
||||
/**
|
||||
* 「查了 1 个站点、2 个下载任务和媒体库,搜了 3 次资源」:按能力元数据的 verb / noun 计数,
|
||||
* 同一动作的对象合并,动作按第一次出现排序;被拒绝和隐藏的调用不计入。超过 4 种动作只写前 3 种。
|
||||
* 同一动作的对象合并,动作按第一次出现排序;被拒绝、失败和隐藏的调用不计入(失败的在进度行另计「N 项失败」,
|
||||
* 否则 3 次被预算拒绝的派发 + 2 次成功会写成「派出了 5 个子会话」)。超过 4 种动作只写前 3 种。
|
||||
*/
|
||||
export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "status" | "outputSummary">[]): string {
|
||||
const groups: Group[] = [];
|
||||
for (const c of calls) {
|
||||
const a = activityOf(c.capabilityId);
|
||||
if (a.hidden) continue;
|
||||
if (c.status === "denied" || authDenial(c)) continue;
|
||||
if (c.status === "denied" || c.status === "failed" || authDenial(c)) continue;
|
||||
let g = groups.find((x) => x.verb === a.verb);
|
||||
if (!g) {
|
||||
g = { verb: a.verb, nouns: [] };
|
||||
@@ -85,6 +86,34 @@ export function activitySummary(calls: Pick<ToolCallView, "capabilityId" | "stat
|
||||
return groups.map(text).join(",");
|
||||
}
|
||||
|
||||
// ── 步骤进度 ──
|
||||
|
||||
type StepLike = { status: "pending" | "running" | "done" | "skipped" | "failed"; title: string };
|
||||
|
||||
/**
|
||||
* 步骤进度(概况条与展开的步骤清单共用一个口径):分子只数完成的步骤,跳过的单独计。
|
||||
* 「4/6 · 跳过 2」;全部结束且有跳过时状态写「完成 4 · 跳过 2」,不再写「全部完成」。
|
||||
* pct 按已结束(完成 + 跳过 + 失败)算进度条宽度。
|
||||
*/
|
||||
export function stepProgress(steps: readonly StepLike[]) {
|
||||
const done = steps.filter((s) => s.status === "done").length;
|
||||
const skipped = steps.filter((s) => s.status === "skipped").length;
|
||||
const failedStep = steps.find((s) => s.status === "failed") ?? null;
|
||||
const failed = steps.filter((s) => s.status === "failed").length;
|
||||
const total = steps.length;
|
||||
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending") ?? null;
|
||||
const count = `${done}/${total}${skipped ? ` · 跳过 ${skipped}` : ""}`;
|
||||
const status = failedStep
|
||||
? `失败:${failedStep.title}`
|
||||
: current
|
||||
? `当前:${current.title}`
|
||||
: skipped
|
||||
? `完成 ${done} · 跳过 ${skipped}`
|
||||
: "全部完成";
|
||||
const pct = total ? Math.round(((done + skipped + failed) / total) * 100) : 0;
|
||||
return { done, skipped, failed, total, current, failedStep, count, status, pct };
|
||||
}
|
||||
|
||||
/** 正在进行的那一步:「正在检测站点」 */
|
||||
export function runningText(call: Pick<ToolCallView, "capabilityId">): string {
|
||||
const a = activityOf(call.capabilityId);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { activitySummary, stepProgress } from "./conversation-model.ts";
|
||||
import { collectNames, humanizeReport } from "./report-humanize.ts";
|
||||
import type { StreamItem, ToolCallView } from "./use-agent-stream.ts";
|
||||
|
||||
// FIX-7:旧会话汇报的展示层兜底 + 进度口径。样本取自用户库会话 82338768 / a855fb03 的最终汇报(只读核对)。
|
||||
|
||||
const ENTITY = "74beab51-45e5-4177-832d-199beb8c75fa";
|
||||
const SITE = "2ff30e4b-4578-4338-bece-047ef9f6e9cc";
|
||||
const VOLUME = "be751d0b-152c-4f72-8182-9797452fbe68";
|
||||
|
||||
const call = (capabilityId: string, outputSummary: string | null, status: ToolCallView["status"] = "succeeded") =>
|
||||
({
|
||||
kind: "tool",
|
||||
key: `x${Math.random()}`,
|
||||
call: {
|
||||
toolCallId: `c${Math.random()}`,
|
||||
capabilityId,
|
||||
status,
|
||||
inputSummary: "{}",
|
||||
outputSummary,
|
||||
reason: null,
|
||||
startedAt: "2026-09-28T17:00:00.000Z",
|
||||
endedAt: "2026-09-28T17:00:01.000Z",
|
||||
},
|
||||
}) as unknown as StreamItem;
|
||||
|
||||
describe("humanizeReport(汇报兜底)", () => {
|
||||
const names = collectNames([
|
||||
call(
|
||||
"catalog.lookup",
|
||||
`{"local":[{"entity":{"id":"${ENTITY}","createdAt":"2026-09-28T16:35:21.283Z","kind":"movie","title":"沙丘2","year":2024`,
|
||||
),
|
||||
call("site.list", `{"items":[{"id":"${SITE}","key":"audience","name":"观众(mock)","enabled":true}]}`),
|
||||
]);
|
||||
|
||||
it("从工具结果收集 ID → 名字,截断的 JSON 也能配对", () => {
|
||||
expect(names.get(ENTITY)).toBe("沙丘2");
|
||||
expect(names.get(SITE)).toBe("观众(mock)");
|
||||
});
|
||||
|
||||
it("UUID 换名字;认不出的缩成「…」;原文进 replaced", () => {
|
||||
const r = humanizeReport(`目录定位:实体 ID \`${ENTITY}\`,缓存卷 \`instance:${VOLUME}\` 使用率未知`, names);
|
||||
expect(r.text).toBe("目录定位:实体 ID 「沙丘2」,缓存卷 … 使用率未知");
|
||||
expect(r.replaced.map((x) => x.from)).toEqual([ENTITY, `instance:${VOLUME}`]);
|
||||
});
|
||||
|
||||
it("名字已写在前面时不重复", () => {
|
||||
const r = humanizeReport(`站点「观众(mock)」\`${SITE}\` 登录失效`, names);
|
||||
expect(r.text).toBe("站点「观众(mock)」 登录失效");
|
||||
});
|
||||
|
||||
it("能力 ID(带参数)换成能力标题,原因码换成人话", () => {
|
||||
const r = humanizeReport(
|
||||
"`site.health_check` 被拒;`download.inspect` 无法调用(返回 `invalid_input`);`download.plan(slot 37746be7, release 0bd93cb0)` 返回 plan=null,拒绝原因 `cache:volume_state_unknown`",
|
||||
names,
|
||||
);
|
||||
expect(r.text).toBe(
|
||||
"检测站点 被拒;查看下载任务 无法调用(返回 参数不对);生成下载计划 返回 plan=null,拒绝原因 下载盘用量未知",
|
||||
);
|
||||
expect(r.text).not.toMatch(/site\.health_check|invalid_input|volume_state_unknown/);
|
||||
expect(humanizeReport("被规则拒绝(unauthorized),未重试", names).text).toBe("被规则拒绝(需要你批准),未重试");
|
||||
});
|
||||
|
||||
it("截断的短 ID 缩成「…」;不是 ID 的 8 位十六进制与普通英文不动", () => {
|
||||
const r = humanizeReport(
|
||||
`仅 2 条候选——\`e6d79af3-…\` 720p;哈希 deadbeef;${ENTITY.slice(0, 8)} 已找到;Dune Part Two`,
|
||||
names,
|
||||
);
|
||||
expect(r.text).toBe("仅 2 条候选——… 720p;哈希 deadbeef;「沙丘2」 已找到;Dune Part Two");
|
||||
});
|
||||
|
||||
it("代码块与链接地址原样保留;不认识的码不动", () => {
|
||||
const text = `看[详情](/console/library/${ENTITY})\n\`\`\`\n${ENTITY}\n\`\`\`\nsome_new_code`;
|
||||
const r = humanizeReport(text, names);
|
||||
expect(r.text).toBe(text);
|
||||
expect(r.replaced).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activitySummary(系统事实计数)", () => {
|
||||
it("失败的派发不计入「派出了 N 个子会话」", () => {
|
||||
const calls = [
|
||||
...Array.from({ length: 3 }, () => ({
|
||||
capabilityId: "agent.spawn",
|
||||
status: "failed" as const,
|
||||
outputSummary: '{"error":{"code":"budget","message":"超过剩余预算"}}',
|
||||
})),
|
||||
...Array.from({ length: 2 }, () => ({
|
||||
capabilityId: "agent.spawn",
|
||||
status: "succeeded" as const,
|
||||
outputSummary: "{}",
|
||||
})),
|
||||
];
|
||||
expect(activitySummary(calls)).toBe("派出了 2 个子会话");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepProgress(步骤口径)", () => {
|
||||
const s = (status: "pending" | "running" | "done" | "skipped" | "failed", title = "步骤") => ({ status, title });
|
||||
it("跳过的单独计:4 完成 + 2 跳过不再写「6/6 全部完成」", () => {
|
||||
const p = stepProgress([s("done"), s("done"), s("done"), s("done"), s("skipped"), s("skipped")]);
|
||||
expect(p.count).toBe("4/6 · 跳过 2");
|
||||
expect(p.status).toBe("完成 4 · 跳过 2");
|
||||
expect(p.pct).toBe(100);
|
||||
});
|
||||
it("没有跳过时照旧", () => {
|
||||
const p = stepProgress([s("done"), s("running", "核对下载任务"), s("pending")]);
|
||||
expect(p.count).toBe("1/3");
|
||||
expect(p.status).toBe("当前:核对下载任务");
|
||||
expect(stepProgress([s("done"), s("done")]).status).toBe("全部完成");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { CAPABILITY_CATALOG, CAPABILITY_IDS, reasonText } from "@umt/contracts";
|
||||
import type { StreamItem } from "./use-agent-stream.ts";
|
||||
|
||||
// 智能体汇报的展示层兜底(FIX-7):旧会话或不守汇报格式的模型会在正文里写 UUID、能力 ID(site.health_check)、
|
||||
// 原因码(invalid_input、cache:volume_state_unknown)。展示时把它们换成名称 / 人话,历史数据不改;
|
||||
// 换掉的原文交给「技术信息」。能认出名字的 UUID 换成名字,认不出的缩成「…」。
|
||||
|
||||
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const CAP_IDS = [...CAPABILITY_IDS].sort((a, b) => b.length - a.length).map(escapeRe);
|
||||
|
||||
/** 能力 ID(可带调用参数 `(…)`) */
|
||||
const CAP_RE = new RegExp(`(?<![\\w.])(${CAP_IDS.join("|")})(?![\\w])(?:\\([^)\\n]*\\))?`, "g");
|
||||
/** 完整 UUID,前面可带 `instance:` 这类前缀 */
|
||||
const UUID_RE = new RegExp(`(?:\\b[a-z_]+:)?\\b(${UUID})\\b`, "gi");
|
||||
/** 截断的 ID:`e6d79af3-…`、`0bd93cb0-…` */
|
||||
const SHORT_TRUNC_RE = /\b([0-9a-f]{8})(?:-[0-9a-f]{0,4})*-?…/gi;
|
||||
/** 裸 8 位十六进制:只有是已知 UUID 的前缀时才当 ID */
|
||||
const SHORT_RE = /(?<![\w-])([0-9a-f]{8})(?![\w-])/gi;
|
||||
/** 原因码:带下划线或冒号的小写码;单词码只认授权类(unauthorized / forbidden),其余单词可能是正文 */
|
||||
const CODE_RE =
|
||||
/(?<![\w.:/-])([a-z]+(?:_[a-z0-9]+)+(?::[a-z_]+)?|[a-z]+:[a-z]+(?:_[a-z0-9]+)+|unauthorized|forbidden)(?![\w-])/g;
|
||||
|
||||
export type NameIndex = Map<string, string>;
|
||||
|
||||
export type HumanizeResult = {
|
||||
text: string;
|
||||
/** 被替换的原文 → 显示的文字 */
|
||||
replaced: { from: string; to: string }[];
|
||||
};
|
||||
|
||||
const capTitle = (id: string) => (CAPABILITY_CATALOG as Record<string, { title?: string }>)[id]?.title ?? id;
|
||||
|
||||
function lookupShort(prefix: string, names: NameIndex, seen: Set<string>): { name: string | null; known: boolean } {
|
||||
const p = prefix.toLowerCase();
|
||||
for (const [id, name] of names) if (id.startsWith(p)) return { name, known: true };
|
||||
for (const id of seen) if (id.startsWith(p)) return { name: null, known: true };
|
||||
return { name: null, known: false };
|
||||
}
|
||||
|
||||
/** 一段不含代码块 / 行内代码的文字 */
|
||||
function humanizePlain(
|
||||
text: string,
|
||||
names: NameIndex,
|
||||
seen: Set<string>,
|
||||
replaced: HumanizeResult["replaced"],
|
||||
/** 前文(判断名字是否已经写在前面) */
|
||||
prior = "",
|
||||
): string {
|
||||
const note = (from: string, to: string) => {
|
||||
replaced.push({ from, to });
|
||||
return to;
|
||||
};
|
||||
// 名字已经写在前面(「站点「观众」2ff30e4b-…」)时,ID 直接去掉
|
||||
const nameOrDrop = (from: string, name: string, before: string) =>
|
||||
note(from, (prior + before).slice(-name.length - 4).includes(name) ? "" : `「${name}」`);
|
||||
|
||||
let out = text.replace(CAP_RE, (m, id: string) => note(m, capTitle(id)));
|
||||
out = out.replace(UUID_RE, (m, id: string, offset: number, all: string) => {
|
||||
const name = names.get(id.toLowerCase());
|
||||
return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…");
|
||||
});
|
||||
out = out.replace(SHORT_TRUNC_RE, (m, p: string, offset: number, all: string) => {
|
||||
const { name } = lookupShort(p, names, seen);
|
||||
return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…");
|
||||
});
|
||||
out = out.replace(SHORT_RE, (m, p: string, offset: number, all: string) => {
|
||||
const { name, known } = lookupShort(p, names, seen);
|
||||
if (!known) return m;
|
||||
return name ? nameOrDrop(m, name, all.slice(0, offset)) : note(m, "…");
|
||||
});
|
||||
out = out.replace(CODE_RE, (m, code: string) => {
|
||||
const r = reasonText(code);
|
||||
return r ? note(m, r.text) : m;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 纯函数(单测覆盖):汇报正文里的 UUID / 能力 ID / 原因码 → 名称 / 人话。
|
||||
* 代码块原样保留;行内代码里的内容被替换时去掉反引号,没有可换的保持原样;链接地址不动。
|
||||
*/
|
||||
export function humanizeReport(text: string, names: NameIndex = new Map()): HumanizeResult {
|
||||
const replaced: HumanizeResult["replaced"] = [];
|
||||
const seen = new Set<string>([...text.matchAll(new RegExp(UUID, "gi"))].map((m) => m[0].toLowerCase()));
|
||||
for (const id of names.keys()) seen.add(id);
|
||||
const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`|\]\([^)\s]*\))/g);
|
||||
const out: string[] = [];
|
||||
parts.forEach((part, i) => {
|
||||
const prior = out.join("");
|
||||
if (i % 2 === 0) out.push(humanizePlain(part, names, seen, replaced, prior));
|
||||
else if (part.startsWith("```") || part.startsWith("](")) out.push(part);
|
||||
else {
|
||||
const n = replaced.length;
|
||||
const h = humanizePlain(part.slice(1, -1), names, seen, replaced, prior);
|
||||
out.push(replaced.length > n ? h : part);
|
||||
}
|
||||
});
|
||||
const cleaned = out
|
||||
.join("")
|
||||
// 换掉 ID 后留下的空括号、重复省略号
|
||||
.replace(/[((]\s*[))]/g, "")
|
||||
.replace(/…(?:\s*[、,,]\s*…)+/g, "…");
|
||||
return { text: cleaned, replaced };
|
||||
}
|
||||
|
||||
const NAME_KEYS = "title|name|label|displayName";
|
||||
/** `"id":"<uuid>" … "title":"…"`(同一层对象里、下一个 id 之前) */
|
||||
const PAIR_RE = new RegExp(
|
||||
`"(?:id|runId|accountId)"\\s*:\\s*"(${UUID})"(?:(?!"(?:id|runId)"\\s*:)[^{}])*?"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`,
|
||||
"gi",
|
||||
);
|
||||
/** `"title":"…" … "id":"<uuid>"`(名字在前) */
|
||||
const PAIR_REV_RE = new RegExp(
|
||||
`"(?:${NAME_KEYS})"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"[^{}]*?"id"\\s*:\\s*"(${UUID})"`,
|
||||
"gi",
|
||||
);
|
||||
|
||||
const unescapeJson = (s: string) => {
|
||||
try {
|
||||
return JSON.parse(`"${s}"`) as string;
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从会话里的工具输入输出收集「ID → 名字」(作品、站点、订阅、待处理事项、资源…),
|
||||
* 截断的 JSON 也能按字面配对。extra(子会话等)优先。
|
||||
*/
|
||||
export function collectNames(items: StreamItem[], extra: [string, string][] = []): NameIndex {
|
||||
const names: NameIndex = new Map();
|
||||
const add = (id: string, name: string) => {
|
||||
const k = id.toLowerCase();
|
||||
const v = unescapeJson(name).trim();
|
||||
if (v && !names.has(k) && v.length <= 80) names.set(k, v);
|
||||
};
|
||||
for (const [id, name] of extra) add(id, name);
|
||||
for (const it of items) {
|
||||
if (it.kind !== "tool") continue;
|
||||
for (const raw of [it.call.outputSummary, it.call.inputSummary]) {
|
||||
if (!raw) continue;
|
||||
for (const m of raw.matchAll(PAIR_RE)) if (m[1] && m[2]) add(m[1], m[2]);
|
||||
for (const m of raw.matchAll(PAIR_REV_RE)) if (m[1] && m[2]) add(m[2], m[1]);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
@@ -99,6 +99,8 @@ export interface TodoListProps {
|
||||
maxHeightClassName?: string;
|
||||
emptyText?: ReactNode;
|
||||
className?: string;
|
||||
/** 表头计数(缺省「完成数/总数」);智能体步骤清单传「4/6 · 跳过 2」,与概况条同一口径 */
|
||||
count?: string;
|
||||
}
|
||||
|
||||
export function TodoList({
|
||||
@@ -109,6 +111,7 @@ export function TodoList({
|
||||
maxHeightClassName = "max-h-64",
|
||||
emptyText = "还没有步骤",
|
||||
className,
|
||||
count,
|
||||
}: TodoListProps) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
@@ -142,9 +145,7 @@ export function TodoList({
|
||||
<span className="sr-only">
|
||||
已完成 {done} / 共 {items.length}
|
||||
</span>
|
||||
<span aria-hidden>
|
||||
{done}/{items.length}
|
||||
</span>
|
||||
<span aria-hidden>{count ?? `${done}/${items.length}`}</span>
|
||||
</span>
|
||||
<ChevronDown aria-hidden className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
@@ -61,7 +61,7 @@ export function DirTree({
|
||||
<nav aria-label="登记目录" className={cn("space-y-4", className)}>
|
||||
{groups.map((g) => (
|
||||
<section key={g.title} aria-label={g.title}>
|
||||
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
|
||||
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground/75 uppercase">
|
||||
{g.title}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -515,9 +515,7 @@ export function FileList({
|
||||
<Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell>
|
||||
<Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell>
|
||||
<Cell className="relative grid place-items-center">
|
||||
{onAction && !e.escape ? (
|
||||
<HoverOps e={e} focused={focused} onAction={onAction} opState={opState} />
|
||||
) : null}
|
||||
{onAction && !e.escape ? <HoverOps e={e} onAction={onAction} opState={opState} /> : null}
|
||||
{menuBtn}
|
||||
</Cell>
|
||||
</div>
|
||||
@@ -561,15 +559,13 @@ export const DRAG_MIME = "application/x-umt-files";
|
||||
const DESKTOP_COLS =
|
||||
"grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_6.5rem_32px]";
|
||||
|
||||
/** 行内 hover / 聚焦时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */
|
||||
/** 行内 hover 时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */
|
||||
function HoverOps({
|
||||
e,
|
||||
focused,
|
||||
onAction,
|
||||
opState,
|
||||
}: {
|
||||
e: FileEntry;
|
||||
focused: boolean;
|
||||
onAction: (e: FileEntry, op: MenuKey) => void;
|
||||
opState?: (e: FileEntry, op: MenuKey) => string | null;
|
||||
}) {
|
||||
@@ -577,9 +573,10 @@ function HoverOps({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-1 rounded-full bg-background/95 py-0.5 pl-1 opacity-0 shadow-sm ring-1 ring-border backdrop-blur transition-opacity duration-micro",
|
||||
// 只在鼠标悬浮时盖在大小/时间列上;选中/键盘聚焦的行不常驻(否则大小和时间一直被挡),
|
||||
// 键盘操作走右键菜单与快捷键。
|
||||
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-1 rounded-full bg-background/95 py-0.5 pl-1 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
|
||||
"group-hover/row:pointer-events-auto group-hover/row:opacity-100",
|
||||
focused && "pointer-events-auto opacity-100",
|
||||
)}
|
||||
>
|
||||
{ops.map((op) => {
|
||||
|
||||
@@ -254,7 +254,9 @@ export function EpisodeVersionList({
|
||||
v={lead}
|
||||
aside={
|
||||
versions.length > 1 ? (
|
||||
<span className="text-meta whitespace-nowrap text-muted-foreground">{versions.length} 个版本</span>
|
||||
<span className="text-meta whitespace-nowrap text-muted-foreground">
|
||||
库里有 {versions.length} 个版本
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { agentRunLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
||||
import { IntervalSelect } from "./interval-select.tsx";
|
||||
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
|
||||
import { CHECK_TRIGGER_LABEL, formatUsd, goalTemplateParts, RUN_STATUS } from "./labels.ts";
|
||||
|
||||
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
|
||||
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
|
||||
@@ -43,6 +43,27 @@ export type DefaultUpstream = {
|
||||
const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) =>
|
||||
models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n");
|
||||
|
||||
/** 目标模板:占位符显示成名称标签(执行时替换成实际值),不露 `{{now}}` */
|
||||
function GoalTemplate({ template }: { template: string }) {
|
||||
return (
|
||||
<>
|
||||
{goalTemplateParts(template).map((p, i) =>
|
||||
"text" in p ? (
|
||||
<span key={i}>{p.text}</span>
|
||||
) : (
|
||||
<span
|
||||
key={i}
|
||||
title="执行时替换成实际值"
|
||||
className="mx-0.5 rounded-tight bg-foreground/[0.07] px-1 text-muted-foreground"
|
||||
>
|
||||
{p.label}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
@@ -151,8 +172,8 @@ export function AgentCheckCard({
|
||||
<TechInfo title="详情" columns={1}>
|
||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||
<Field label="目标">
|
||||
<span className="line-clamp-4 break-words" title={c.goalTemplate}>
|
||||
{c.goalTemplate ?? "—"}
|
||||
<span className="line-clamp-4 break-words">
|
||||
{c.goalTemplate ? <GoalTemplate template={c.goalTemplate} /> : "—"}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="能力">
|
||||
|
||||
@@ -25,13 +25,15 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { CHECK_TRIGGER_LABEL, splitInterval, UNIT_MS } from "./labels.ts";
|
||||
import { CHECK_TRIGGER_LABEL, GOAL_PLACEHOLDER_LABEL, splitInterval, UNIT_MS } from "./labels.ts";
|
||||
|
||||
/** 巡检只开放只读 / 预览 / 提案类能力 */
|
||||
const READONLY_CAPS = Object.values(CAPABILITY_CATALOG).filter((c) =>
|
||||
(["read", "preview", "propose"] as string[]).includes(c.kind),
|
||||
);
|
||||
const PLACEHOLDERS = "{{now}} {{date}} {{openDecisions}} {{stalledDownloads}} {{activeSubscriptions}}";
|
||||
const PLACEHOLDERS = Object.entries(GOAL_PLACEHOLDER_LABEL)
|
||||
.map(([k, v]) => `{{${k}}} ${v}`)
|
||||
.join("、");
|
||||
|
||||
type Unit = "m" | "h";
|
||||
type Draft = {
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeSchedule, formatUsd, INTERVAL_PRESETS, planPath, scheduleStreamPath } from "./labels.ts";
|
||||
import {
|
||||
describeSchedule,
|
||||
formatUsd,
|
||||
goalTemplateParts,
|
||||
INTERVAL_PRESETS,
|
||||
planPath,
|
||||
scheduleStreamPath,
|
||||
} from "./labels.ts";
|
||||
|
||||
describe("计划页标签", () => {
|
||||
it("巡检目标模板:占位符显示成名称(FIX-7)", () => {
|
||||
expect(goalTemplateParts("巡检待处理({{now}}):有 {{ openDecisions }} 条,{{unknown}}")).toEqual([
|
||||
{ text: "巡检待处理(" },
|
||||
{ placeholder: "now", label: "当前时间" },
|
||||
{ text: "):有 " },
|
||||
{ placeholder: "openDecisions", label: "待处理事项数" },
|
||||
{ text: " 条,{{unknown}}" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("planPath:节锚点与 ?job= 高亮", () => {
|
||||
expect(planPath()).toBe("/console/schedules");
|
||||
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
|
||||
|
||||
@@ -129,3 +129,28 @@ export function formatClock(iso: string | null | undefined): string {
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** 巡检目标模板的占位符(服务端 renderGoal 执行时替换);界面上显示成名称,不露 `{{now}}` */
|
||||
export const GOAL_PLACEHOLDER_LABEL: Record<string, string> = {
|
||||
now: "当前时间",
|
||||
date: "今天日期",
|
||||
openDecisions: "待处理事项数",
|
||||
stalledDownloads: "异常下载数",
|
||||
activeSubscriptions: "追踪中的订阅数",
|
||||
};
|
||||
|
||||
/** 目标模板拆成文字与占位符两段(纯函数,单测覆盖);不认识的占位符原样当文字 */
|
||||
export function goalTemplateParts(template: string): ({ text: string } | { placeholder: string; label: string })[] {
|
||||
const out: ({ text: string } | { placeholder: string; label: string })[] = [];
|
||||
let last = 0;
|
||||
for (const m of template.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) {
|
||||
const key = m[1] ?? "";
|
||||
const label = GOAL_PLACEHOLDER_LABEL[key];
|
||||
if (!label) continue;
|
||||
if (m.index > last) out.push({ text: template.slice(last, m.index) });
|
||||
out.push({ placeholder: key, label });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < template.length) out.push({ text: template.slice(last) });
|
||||
return out;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { DownloadDialog } from "./download-dialog.tsx";
|
||||
import { absTime, categoryLabel, countText, ORIGIN_LABEL, relTime, snatchedOf } from "./release-fields.ts";
|
||||
@@ -28,13 +29,21 @@ const DECISION_LABEL = {
|
||||
deferred: { label: "延后:已有计划在途", tone: "warning" },
|
||||
} as const;
|
||||
|
||||
/** 没关联作品时按识别状态说一句(原码不上屏) */
|
||||
const UNLINKED_TEXT: Record<string, string> = {
|
||||
unresolved: "还没识别作品。",
|
||||
ambiguous: "有多个可能的作品,没有自动关联。",
|
||||
unidentifiable: "没识别出作品。",
|
||||
rejected: "识别结果已被排除。",
|
||||
};
|
||||
|
||||
/** 本地库存 + 候选理由(来自作品详情:Slot / CandidateEvaluation),不从标题猜 */
|
||||
function LocalInventory({ release, entry }: { release: WorkbenchRelease; entry: InventoryEntry | undefined }) {
|
||||
const detail = useEntityDetail(release.entity?.id);
|
||||
if (!release.entity) {
|
||||
return (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
未关联本地作品({release.resolutionStatus})。
|
||||
{UNLINKED_TEXT[release.resolutionStatus] ?? "未关联本地作品。"}
|
||||
<Link to="/console/identity" className="ml-1 underline underline-offset-4">
|
||||
去指定作品
|
||||
</Link>
|
||||
@@ -123,6 +132,7 @@ export function ReleaseDetailSheet({
|
||||
const refresh = useRefreshReleaseDetail();
|
||||
const entity = useEntityDetail(release?.entity?.id);
|
||||
const [dl, setDl] = useState(false);
|
||||
const techOpen = useUiPrefs().techInfoExpanded;
|
||||
const r = detail.data?.release ?? release;
|
||||
|
||||
// 按需抓详情页:打开抽屉时没有缓存的详情页扩展内容就抓一次(服务端缓存 24 小时,同一资源本次会话只自动抓一次)
|
||||
@@ -296,7 +306,8 @@ export function ReleaseDetailSheet({
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
<JsonView label="解析结果" value={r.parsed} />
|
||||
{/* 原始解析 JSON 属于技术信息:默认收起,跟随设置 → 界面「技术信息默认展开」 */}
|
||||
<JsonView label="解析结果" value={r.parsed} defaultOpen={techOpen} />
|
||||
</>
|
||||
)}
|
||||
{r ? <DownloadDialog release={r} slots={entity.data?.slots ?? []} open={dl} onOpenChange={setDl} /> : null}
|
||||
|
||||
@@ -192,7 +192,7 @@ function readLastLoc(): Loc | null {
|
||||
const hnrPathsOf = (list: FileEntry[]) =>
|
||||
list.filter((e) => e.download && (e.download.hnr !== "satisfied" || e.status === "downloading")).map((e) => e.path);
|
||||
|
||||
/** 折叠后的左栏:44px 图标条(登记目录 + 回收站) */
|
||||
/** 折叠后的左栏:52px 圆角卡片里的图标条(登记目录 + 回收站),展开钮与主面板工具栏第一行对齐 */
|
||||
function TreeRail({
|
||||
roots,
|
||||
current,
|
||||
@@ -207,7 +207,7 @@ function TreeRail({
|
||||
trashCount: string;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label="登记目录" className="flex flex-col items-center gap-1 py-2">
|
||||
<nav aria-label="登记目录" className="flex flex-col items-center gap-1 py-2.5">
|
||||
<Button variant="ghost" size="icon-sm" aria-label="展开目录树" title="展开目录树" onClick={onExpand}>
|
||||
<PanelLeftOpen />
|
||||
</Button>
|
||||
@@ -883,61 +883,34 @@ export default function FilesPage() {
|
||||
<>
|
||||
{/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}
|
||||
<h1 className="sr-only">文件管理</h1>
|
||||
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0", mobile && "flex-col p-3")}>
|
||||
{/* 桌面左栏:贴着侧边栏的整高目录树(可折叠、可拖宽,宽度记在本机) */}
|
||||
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0 gap-3 p-3", mobile && "flex-col")}>
|
||||
{/* 桌面左栏:与主面板同款的圆角卡片(同背景、边框、圆角),上下与主面板对齐;可折叠、可拖宽,宽度记在本机 */}
|
||||
{!mobile ? (
|
||||
<aside
|
||||
className="pg-chrome pg-chrome-side relative flex shrink-0 flex-col border-r"
|
||||
style={{ width: treeOpen ? treeW : 44 }}
|
||||
className="relative flex shrink-0"
|
||||
style={{ width: treeOpen ? treeW : 52 }}
|
||||
aria-label="登记目录与目录树"
|
||||
>
|
||||
{treeOpen ? (
|
||||
<>
|
||||
{/* 左侧留出全站侧栏折叠钮的位置 */}
|
||||
<div className="flex h-11 shrink-0 items-center border-b border-[var(--shell-border)] pr-2 pl-6">
|
||||
<span className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
登记目录
|
||||
</span>
|
||||
{filesMockEnabled() ? (
|
||||
<StatusBadge tone="warning" className="ml-auto">
|
||||
演示数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tree}</div>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整目录树宽度"
|
||||
title="拖动调整宽度,双击恢复默认"
|
||||
aria-valuenow={treeW}
|
||||
aria-valuemin={TREE_W_MIN}
|
||||
aria-valuemax={TREE_W_MAX}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||
}}
|
||||
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = treeW;
|
||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||
const upH = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", upH);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", upH);
|
||||
}}
|
||||
className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div aria-hidden className="h-11 shrink-0 border-b border-[var(--shell-border)]" />
|
||||
<div className="pg-slab flex min-h-0 w-full flex-col overflow-hidden rounded-2xl">
|
||||
{treeOpen ? (
|
||||
<>
|
||||
{/* 头部一行与主面板工具栏第一行等高(同 px-3 py-2.5 + 按钮高度) */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-3 py-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="折叠目录树"
|
||||
title="折叠目录树"
|
||||
onClick={() => setTreeOpenStored(false)}
|
||||
>
|
||||
<PanelLeftClose />
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">目录</span>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">{tree}</div>
|
||||
</>
|
||||
) : (
|
||||
<TreeRail
|
||||
roots={rootList}
|
||||
current={rootId}
|
||||
@@ -945,16 +918,45 @@ export default function FilesPage() {
|
||||
onExpand={() => setTreeOpenStored(true)}
|
||||
trashCount={trashCount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
{treeOpen ? (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整目录树宽度"
|
||||
title="拖动调整宽度,双击恢复默认"
|
||||
aria-valuenow={treeW}
|
||||
aria-valuemin={TREE_W_MIN}
|
||||
aria-valuemax={TREE_W_MAX}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||
}}
|
||||
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = treeW;
|
||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||
const upH = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", upH);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", upH);
|
||||
}}
|
||||
// 放在两张卡片之间的缝里
|
||||
className="absolute inset-y-3 -right-2.5 z-10 w-2 cursor-col-resize rounded-full hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
className={cn(
|
||||
"pg-slab relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
|
||||
!mobile && "m-3",
|
||||
)}
|
||||
className="pg-slab relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
|
||||
aria-label="目录内容"
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
@@ -1001,15 +1003,6 @@ export default function FilesPage() {
|
||||
) : (
|
||||
<div className="space-y-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={treeOpen ? "折叠目录树" : "展开目录树"}
|
||||
title={treeOpen ? "折叠目录树" : "展开目录树"}
|
||||
onClick={() => setTreeOpenStored(!treeOpen)}
|
||||
>
|
||||
{treeOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -513,8 +513,8 @@ function SlotsPanel({ detail, units }: { detail: LibraryDetail; units: ContentUn
|
||||
<EmptyState
|
||||
plain
|
||||
icon={CalendarClock}
|
||||
title="还没有收录版本"
|
||||
description="新建订阅或收录版本后,按目标补齐和升级。"
|
||||
title="没设收录要求"
|
||||
description="库里已有的文件照常显示。新建订阅或收录版本后,按要求补齐和升级。"
|
||||
action={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={consolePath("subscriptions")}>去订阅</Link>
|
||||
|
||||
@@ -193,4 +193,5 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
|
||||
- **文件管理(FM-2,用户 2026-09-29)**:左侧目录树再往左靠,页面主体做大(文件管理页用宽布局);调研除视频播放外可集成的基础预览,先出调研结论。→ 已做(分支 fm2):文件管理宽布局(目录树贴侧栏、可拖宽/折叠、手机抽屉);首批预览(文本/NFO 编码探测、字幕前 200 条、.torrent、图片尺寸);调研见 `docs/design/file-preview-research.md`。
|
||||
- **Worker 分道(WK-1,用户 2026-09-29 讨论后认可,排在本轮测试之后)**:不再拆进程;IO Worker 内分三条道——文件(整理/硬链接/ffprobe/校验,并发 2)、网络(站点/RSS/TMDB/搜索,3–4,单站限流另计)、维护(清理/回填/巡检,1,空闲时跑),并发可在设置调。拆进程的触发条件:心跳事件循环延迟在整理期间持续 >200ms,或整理需要贴近存储部署到另一台机器。
|
||||
- **v0.3.0(2026-09-29 打包)VER-5 只读核对问题 → FIX-7**:中——智能体旧会话汇报直出 UUID/能力名/原因码(新提示词只管新会话,展示层要兜底);进度行"步骤 6/6 全部完成"与展开"已完成 4/6(跳过 2)"不一致;"派出了 5 个子会话"实际 2 个(汇报数字要以系统事实为准);巡检目标原样显示 `{{now}}`、`{{openDecisions}}` 占位符;汇报称"下载器不在系统内"而 qB 已配置(查能力返回)。低——种子详情"未关联本地作品(unidentifiable)"直出码、"解析结果"JSON 默认展开;srt 被标"主文件 是";版本行悬浮按钮盖住大小/时间;作品页"还没有收录版本"与"4 个版本"易混;会话标题直出 ISO 时间与 download_failed。LIBV-1 回填已对用户库执行(5 个资产)。
|
||||
- 状态(分支 fix7):根因与改法——① 汇报兜底:`components/agent/report-humanize.ts` 展示时把 UUID(按本会话工具结果与子会话配名字,认不出缩成"…")、能力 ID(换能力标题)、原因码(`reasons.ts` 补了能力错误、下载盘判定、站点登录、待处理类型,`cache:` 这类前缀按后半段查)换成人话,原文进「正文原文」折叠区,历史不改;② 概况条把跳过算进分子,步骤清单只数完成——统一为 `stepProgress`:「步骤 4/6 · 完成 4 · 跳过 2」;③ "派出了 5 个子会话"是进度行把 3 次被预算拒绝的 `agent.spawn` 也算进去(产品计数错):失败的调用不计入动作计数,实际结果行加「派出 N 个子会话」(会话树),汇报格式加"数量以工具返回为准";④ 巡检执行时占位符已正确替换(补单测),界面显示的是模板本身:改成名称标签,会话标题里的 ISO 时间转「9月28日 18:53」,`download_failed` 等码转人话;⑤ 该会话运行时 qB 尚未添加(会话 12:06,qB 12:09 建),但根因是智能体根本没有查下载器的能力(`site.list` 不含下载器),新增只读能力 `downloader.list`(下载器与媒体服务器、连通状态、管理的任务数);⑥ 种子详情按识别状态说人话、解析结果 JSON 跟随技术信息默认收起;"主文件"只认视频(整理把字幕登记成独立资产时其位置 is_primary 为真,文件列表接口按扩展名收紧);悬浮操作只在鼠标悬浮时出现,选中行不再常驻遮挡;作品页空收录版本写「没设收录要求」,版本行写「库里有 N 个版本」。追加:文件管理目录树改成与主面板同款圆角卡片(头部「目录」+ 折叠钮与工具栏第一行对齐,拖宽与折叠记忆保留)。截图 `apps/web/test/screenshots/fix7/`。
|
||||
- **导航与站点类型(IA-2,用户 2026-09-29)**:「任务」归入系统组;站点、订阅、规则合为一组(组名「获取」,用户原话"都算做规则",组名可再改);独立的 RSS 页取消——RSS 属于站点,在站点详情里管理;"站点"增加类型,PT 站之外的下载来源(公开 BT、自定义 RSS 源,以后 Torznab/Jackett/Prowlarr 等)都作为一种站点类型,各类型声明自己支持的获取方式(搜索 / RSS / 详情 / 登录)。
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
| 分组 | 能力 |
|
||||
|---|---|
|
||||
| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`task.inspect`、`site.list`、`site.user_data` |
|
||||
| 查询(read) | `catalog.lookup`、`release.search`、`release.inspect`、`inventory.query`、`subscription.list`、`decision.list`、`download.inspect`、`downloader.list`、`task.inspect`、`site.list`、`site.user_data` |
|
||||
| 计划(preview / propose) | `identity.resolve`、`download.plan`、`organize.plan`、`policy.preview` |
|
||||
| 执行(execute) | `download.submit`、`download.recheck`、`organize.execute`、`task.retry`、`task.cancel`、`policy.apply`、`site.health_check`、`site.refresh_login` |
|
||||
| 原站浏览器 | `browser.snapshot`、`browser.navigate`、`browser.interact`、`browser.download`、`browser.screenshot`、`browser.click_at`、`browser.type_at`、`browser.scroll`、`browser.wait`、`browser.solve_challenge`(见 [browser.md](browser.md)) |
|
||||
@@ -23,6 +23,7 @@ AG-3 补的只读摘要(类别 read,scope `read`,都在后台调查 / 巡检白
|
||||
- `subscription.list {status?, mediaKind?, query?, groupBy=status|mediaKind|none, limit≤200}`:订阅分组计数 + 条目(作品、模式、状态、优先级、版本目标总数 / 已满足数、上次 / 下次搜索)。查单个作品的文件与版本目标仍用 `inventory.query`。
|
||||
- `decision.list {status?, kind?, limit≤100}`:待处理事项摘要,缺省只列 open / investigating / awaiting_user,按严重度排序;每条带可选处理 `options`。只读,处理仍由用户在待处理中心选。
|
||||
- `download.inspect {downloadTaskId? | hash?}`:按下载任务 ID 或种子 hash(v1 / v2,大小写均可)读下载任务、关联计划、资源、业务任务(提交下载 / 核验入库,ID 可交给 `task.inspect` / `task.retry`)、整理记录与待处理事项;同 hash 在多个下载器实例时其余 ID 放 `otherMatches`。
|
||||
- `downloader.list {includeDisabled?}`(FIX-7):已配置的下载器与媒体服务器——名称、类型、启用、最近连通状态(`health_status` / `last_seen_at`)、版本,下载器附本工具管理的任务数(总数 / 进行中 / 失败)。只读本地记录,不连下载器,不含地址与凭证。此前智能体只能看 `site.list`(不含下载器),会把"没看到"汇报成"下载器不在系统内"。
|
||||
- `task.inspect` 只接受业务任务(`task` 表)ID;传入下载任务 ID 时返回 `invalid_input`:"这是下载任务 ID…用 download.inspect 查看"。
|
||||
|
||||
检索词(`catalog.lookup` / `release.search` / `inventory.query` / `subscription.list` 的 `query`)去掉首尾空白后不能为空、不能是单个英文字母 / 数字 / 符号、不能只有通配符与标点(`invalid_input`:"检索词太短…");中文单字片名允许。`catalog.lookup.externalId.externalId` 一律是字符串。
|
||||
|
||||
@@ -57,6 +57,7 @@ export function interactionRules(i: NonNullable<SystemPromptInput["interaction"]
|
||||
export const REPORT_FORMAT: readonly string[] = [
|
||||
"第一句写结论:做成了什么、没做成什么、为什么。",
|
||||
"对象用名字:作品名、站点名、资源标题、文件名;不写函数名、工具名、字段名、参数、UUID 或 hash。",
|
||||
"数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。",
|
||||
"不超过 10 行;不复述过程,不写下一步打算,除非需要用户做什么。",
|
||||
"只有列举多个资源(3 个以上的资源、任务或文件)时才用表格,其余用短句或列表。",
|
||||
"可以用 Markdown 列表、表格和粗体,不写标题,不写 HTML。",
|
||||
@@ -84,6 +85,7 @@ export function buildSystemPrompt({
|
||||
'检索词要有意义:写作品名、别名或关键词;不要用空串、单个字母或通配符(如 query:"a"、"*")探查全表,想盘点就用对应的列表能力(inventory.query 不带 query、subscription.list、decision.list)。',
|
||||
'参数按工具 schema 的类型填写:ID 类参数一律是字符串(如 externalId:"693134",不是数字 693134);不要传 schema 里没有的字段。',
|
||||
"业务任务 ID 用 task.inspect,下载任务 ID 或种子 hash 用 download.inspect,两者不要混用。",
|
||||
"下载器、媒体服务器有没有接入、是否正常,用 downloader.list 查;站点列表里没有下载器不代表没配置。",
|
||||
"思考、步骤说明和汇报都用中文;引用文件名、原站标题时保持原文。",
|
||||
"工具结果里出现【预算提醒】时立刻停止探查,直接用中文写小结;被预算或中止打断时,汇报只写中文小结(已完成什么、证据、还缺什么),不要复述中间过程或下一步打算。",
|
||||
];
|
||||
|
||||
@@ -27,6 +27,7 @@ describe("汇报格式(AGUI-3)", () => {
|
||||
"## 汇报格式
|
||||
- 第一句写结论:做成了什么、没做成什么、为什么。
|
||||
- 对象用名字:作品名、站点名、资源标题、文件名;不写函数名、工具名、字段名、参数、UUID 或 hash。
|
||||
- 数量以工具返回为准:派出几个子会话、提交几个下载,只数成功的调用,失败或被拒绝的不算。
|
||||
- 不超过 10 行;不复述过程,不写下一步打算,除非需要用户做什么。
|
||||
- 只有列举多个资源(3 个以上的资源、任务或文件)时才用表格,其余用短句或列表。
|
||||
- 可以用 Markdown 列表、表格和粗体,不写标题,不写 HTML。
|
||||
@@ -40,7 +41,7 @@ describe("汇报格式(AGUI-3)", () => {
|
||||
expect(p).toContain("写出查到的对象名称和关键数值");
|
||||
// 旧的"最后用简短中文汇报…可以用 Markdown(标题…)"已换成汇报格式一节
|
||||
expect(p).not.toContain("最后用简短中文汇报");
|
||||
expect(REPORT_FORMAT.length).toBe(5);
|
||||
expect(REPORT_FORMAT.length).toBe(6);
|
||||
});
|
||||
|
||||
it("子会话 / 视觉子流程汇报给父会话:不加用户汇报格式", () => {
|
||||
|
||||
@@ -320,6 +320,37 @@ export const DownloadInspectOutput = z.object({
|
||||
otherMatches: z.array(Id),
|
||||
});
|
||||
|
||||
/** downloader.list(FIX-7):已配置的下载器与媒体服务器(只读本地记录,不含地址和凭证) */
|
||||
export const DownloaderListInput = z.object({
|
||||
includeDisabled: z.boolean().default(true),
|
||||
});
|
||||
const InstanceHealth = z.enum(["unknown", "healthy", "degraded", "offline"]);
|
||||
export const DownloaderListOutput = z.object({
|
||||
downloaders: z.array(
|
||||
z.object({
|
||||
id: Id,
|
||||
name: z.string(),
|
||||
kind: z.string(),
|
||||
enabled: z.boolean(),
|
||||
healthStatus: InstanceHealth,
|
||||
lastSeenAt: z.string().nullable(),
|
||||
version: z.string().nullable(),
|
||||
/** 本工具管理、未移除的下载任务 */
|
||||
tasks: z.object({ total: NonNegInt, active: NonNegInt, failed: NonNegInt }),
|
||||
}),
|
||||
),
|
||||
mediaServers: z.array(
|
||||
z.object({
|
||||
id: Id,
|
||||
name: z.string(),
|
||||
kind: z.string(),
|
||||
enabled: z.boolean(),
|
||||
healthStatus: InstanceHealth,
|
||||
lastSeenAt: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
// ── 子代理(W7-B) ──
|
||||
|
||||
/** 子会话预算:缺省按父会话剩余预算的一半划拨;超过剩余预算直接拒绝 */
|
||||
@@ -842,6 +873,7 @@ export const CAPABILITY_IO = {
|
||||
"subscription.list": { input: SubscriptionListInput, output: SubscriptionListOutput },
|
||||
"decision.list": { input: DecisionListInput, output: DecisionListOutput },
|
||||
"download.inspect": { input: DownloadInspectInput, output: DownloadInspectOutput },
|
||||
"downloader.list": { input: DownloaderListInput, output: DownloaderListOutput },
|
||||
"agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput },
|
||||
"agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput },
|
||||
"site.list": { input: SiteListInput, output: SiteListOutput },
|
||||
@@ -918,6 +950,8 @@ export type DecisionListInput = z.infer<typeof DecisionListInput>;
|
||||
export type DecisionListOutput = z.infer<typeof DecisionListOutput>;
|
||||
export type DownloadInspectInput = z.infer<typeof DownloadInspectInput>;
|
||||
export type DownloadInspectOutput = z.infer<typeof DownloadInspectOutput>;
|
||||
export type DownloaderListInput = z.infer<typeof DownloaderListInput>;
|
||||
export type DownloaderListOutput = z.infer<typeof DownloaderListOutput>;
|
||||
export type AgentSpawnInput = z.infer<typeof AgentSpawnInput>;
|
||||
export type AgentSpawnOutput = z.infer<typeof AgentSpawnOutput>;
|
||||
export type AgentAwaitInput = z.infer<typeof AgentAwaitInput>;
|
||||
|
||||
@@ -69,6 +69,7 @@ export const CAPABILITY_IDS = [
|
||||
"subscription.list",
|
||||
"decision.list",
|
||||
"download.inspect",
|
||||
"downloader.list",
|
||||
"agent.spawn",
|
||||
"agent.await",
|
||||
"site.list",
|
||||
@@ -329,6 +330,18 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// FIX-7:智能体原先只能从站点列表推断下载器是否接入(site.list 不含下载器),汇报成"下载器不在系统内"
|
||||
"downloader.list": d(
|
||||
"downloader.list",
|
||||
"查看下载器",
|
||||
"列出已配置的下载器和媒体服务器:名称、类型、是否启用、最近一次连通状态与时间、本工具管理的下载任务数(按进行中 / 失败计)。只读本地记录,不连下载器;问下载器或媒体服务器是否接入、是否正常时先用它,某个下载任务用 download.inspect",
|
||||
"read",
|
||||
"DownloaderList",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// 子代理(设置 pi.subagents 开启后才提供给模型):类别 propose——只建有界子会话,不产生外部副作用,
|
||||
// "先给方案"的会话也能派生(子会话同样只给方案);授权不高于父会话,由服务端校验
|
||||
"agent.spawn": d(
|
||||
@@ -612,6 +625,7 @@ export const CAPABILITY_ACTIVITY: Record<KnownCapabilityId, CapabilityActivity>
|
||||
"subscription.list": act("查", "订阅"),
|
||||
"decision.list": act("查", "待处理事项"),
|
||||
"download.inspect": act("查", "下载任务", "个"),
|
||||
"downloader.list": act("查", "下载器"),
|
||||
"agent.spawn": act("派出", "子会话", "个"),
|
||||
"agent.await": act("等", "子会话"),
|
||||
"site.list": act("查", "站点"),
|
||||
|
||||
@@ -48,6 +48,35 @@ const REASONS: Record<string, ReasonText> = {
|
||||
not_imported_kind: { text: "这类文件不入库" },
|
||||
target_root_not_managed: { text: "目标目录不是本工具管理的媒体库目录", action: "在设置 → 媒体库与目录里检查" },
|
||||
download_removed: { text: "下载任务已从下载器删除" },
|
||||
// FIX-7:智能体汇报与工具结果里常见的码(能力错误、下载计划的空间判定、站点登录、待处理类型)
|
||||
invalid_input: { text: "参数不对" },
|
||||
invalid_output: { text: "返回结果格式不对" },
|
||||
unauthorized: { text: "需要你批准" },
|
||||
forbidden: { text: "没有权限" },
|
||||
not_found: { text: "没找到" },
|
||||
precondition: { text: "前置条件不满足" },
|
||||
internal: { text: "内部错误" },
|
||||
volume_state_unknown: { text: "下载盘用量未知", action: "在下载面板检查下载盘" },
|
||||
hard_threshold_reached: { text: "下载盘已到暂停线" },
|
||||
projected_over_threshold: { text: "下完会超过下载盘暂停线" },
|
||||
awaiting_resume_watermark: { text: "等下载盘降到恢复线" },
|
||||
request_size_unknown: { text: "资源大小未知" },
|
||||
inflight_size_unknown: { text: "在下的任务大小未知" },
|
||||
budget_exceeded: { text: "超出下载额度" },
|
||||
budget_unknown_size: { text: "资源大小未知,无法核对下载额度" },
|
||||
no_downloader_instance: { text: "没有可用的下载器", action: "在设置 → 下载器里添加" },
|
||||
download_save_path_unmapped: { text: "下载目录没有路径映射", action: "在设置 → 下载器里添加路径映射" },
|
||||
site_out_of_scope: { text: "站点不在这次的范围内" },
|
||||
slot_changed_or_reserved: { text: "收录版本已变化或被占用" },
|
||||
auth_missing: { text: "站点没有配置 Cookie", action: "在站点账号里填写" },
|
||||
auth_expired: { text: "站点登录已过期", action: "刷新站点登录" },
|
||||
auth_invalid: { text: "站点登录无效", action: "检查站点账号" },
|
||||
source_unreachable: { text: "站点连不上" },
|
||||
download_failed: { text: "下载失败" },
|
||||
download_stalled: { text: "下载无进展" },
|
||||
space_insufficient: { text: "空间不足" },
|
||||
unidentifiable: { text: "没识别出作品" },
|
||||
user_deleted: { text: "已删除" },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,7 +98,8 @@ export function reasonText(raw: string | null | undefined): ReasonText | null {
|
||||
...(parts[0]?.action ? { action: parts[0].action } : {}),
|
||||
};
|
||||
}
|
||||
return REASONS[s] ?? REASONS[head] ?? null;
|
||||
// 带分类前缀的码(`cache:volume_state_unknown`、`deferred:no_downloader_instance`)按冒号后的码查
|
||||
return REASONS[s] ?? REASONS[head] ?? (rest ? (REASONS[rest.trim()] ?? null) : null);
|
||||
}
|
||||
|
||||
/** 只要一句话:"没识别出作品。指定作品后重新整理";不认识的码返回 fallback */
|
||||
|
||||
@@ -18,6 +18,15 @@ describe("reasonText(DEAI-2 原因码人话化)", () => {
|
||||
for (const c of ["blocked_by_conflict", "main_failed", "not_imported_kind"]) expect(reasonText(c)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("智能体汇报里的码(FIX-7):能力错误、带分类前缀的空间判定、站点登录", () => {
|
||||
expect(reasonText("invalid_input")?.text).toBe("参数不对");
|
||||
expect(reasonText("cache:volume_state_unknown")?.text).toBe("下载盘用量未知");
|
||||
expect(reasonText("deferred:no_downloader_instance")?.text).toBe("没有可用的下载器");
|
||||
expect(reasonText("auth_missing")?.text).toContain("Cookie");
|
||||
expect(reasonText("unidentifiable")?.text).toBe("没识别出作品");
|
||||
expect(reasonText("cache:some_new_code")).toBeNull();
|
||||
});
|
||||
|
||||
it("不认识的码返回 null;句子兜底不把码原样给人看", () => {
|
||||
expect(reasonText("some_new_code")).toBeNull();
|
||||
expect(reasonSentence("some_new_code")).toBe("处理失败");
|
||||
|
||||
Reference in new issue
Block a user