feat(agent): 用对话起草巡检(schedule.draft_check + 起草巡检 Skill + 计划页入口与对话卡片)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8eb8a68295
commit
7c4ad901d9
14 files changed
+408
-7
No files matched your search
@@ -9,6 +9,9 @@
|
|||||||
- 站点列表模式:操作按钮改成图标,数值列不换行,窄屏隐藏次要列;公开 BT 站的表只列出口、资源搜索、资源更新;公开站卡片不再显示 RSS。
|
- 站点列表模式:操作按钮改成图标,数值列不换行,窄屏隐藏次要列;公开 BT 站的表只列出口、资源搜索、资源更新;公开站卡片不再显示 RSS。
|
||||||
- 智能体页会话分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话。
|
- 智能体页会话分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- 用对话起草巡检:计划页「用对话起草」,一句话描述要定期检查什么、多久一次,智能体建一个未启用的巡检(目标、间隔、只读能力白名单、触发条件),对话里给「检查并启用」卡片,你在计划页检查、修改后启用。新能力 schedule.draft_check 只在用户发起的对话里可用,定时巡检里不能再造巡检。
|
||||||
|
|
||||||
## [0.3.19] - 2026-10-03
|
## [0.3.19] - 2026-10-03
|
||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ export function createAuthorizer(deps: {
|
|||||||
}
|
}
|
||||||
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
|
// metadata.enrich 只存补全建议(不写作品表);次数由每日预算约束,应用建议另走控制台会话
|
||||||
if (d.id === "metadata.enrich") return { allowed: true };
|
if (d.id === "metadata.enrich") return { allowed: true };
|
||||||
|
// schedule.draft_check 只建未启用的巡检(不会自己运行,用户检查后启用);只放行用户发起的对话,
|
||||||
|
// 定时巡检 / 事件触发的会话不能再造巡检
|
||||||
|
if (d.id === "schedule.draft_check") {
|
||||||
|
if (!runId || (ctx.source !== "agent" && ctx.source !== "task"))
|
||||||
|
return { allowed: false, reason: "只有智能体对话可以起草巡检" };
|
||||||
|
const r = await db.selectFrom("agent_run").select("trigger").where("id", "=", runId).executeTakeFirst();
|
||||||
|
return r?.trigger === "user" ? { allowed: true } : { allowed: false, reason: "只在用户发起的对话里起草巡检" };
|
||||||
|
}
|
||||||
// 先看方案再执行:用户选定方案前,Agent 的执行类调用一律拒绝
|
// 先看方案再执行:用户选定方案前,Agent 的执行类调用一律拒绝
|
||||||
if (runId && d.kind === "execute" && (ctx.source === "agent" || ctx.source === "task")) {
|
if (runId && d.kind === "execute" && (ctx.source === "agent" || ctx.source === "task")) {
|
||||||
const block = await proposeFirstBlock(db, runId);
|
const block = await proposeFirstBlock(db, runId);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
||||||
|
import { CAPABILITY_CATALOG, capabilitySpec, KnownCapabilityId } from "@umt/contracts";
|
||||||
|
import type { ServerContext } from "../types.ts";
|
||||||
|
import { createAgentCheck } from "./store.ts";
|
||||||
|
|
||||||
|
// schedule.draft_check:智能体按用户的自然语言需求起草巡检。建成「未启用」,不会自己运行;
|
||||||
|
// 用户在计划页检查、修改后再启用(授权只放行用户发起的对话,见 agent/authorize.ts)。
|
||||||
|
|
||||||
|
/** 巡检只开放只读 / 预览 / 提案类能力(与计划页表单一致),且不能再起草巡检 */
|
||||||
|
const ALLOWED = new Set(
|
||||||
|
Object.values(CAPABILITY_CATALOG)
|
||||||
|
.filter((c) => ["read", "preview", "propose"].includes(c.kind) && c.id !== "schedule.draft_check")
|
||||||
|
.map((c) => c.id as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function registerScheduleCapabilities(registry: CapabilityRegistry, ctx: ServerContext): void {
|
||||||
|
registry.register(capabilitySpec("schedule.draft_check"), async (input) => {
|
||||||
|
const asked = [...new Set(input.capabilities ?? [])];
|
||||||
|
const caps = asked.filter((c) => KnownCapabilityId.safeParse(c).success && ALLOWED.has(c));
|
||||||
|
const dropped = asked.filter((c) => !caps.includes(c));
|
||||||
|
let row: Awaited<ReturnType<typeof createAgentCheck>>;
|
||||||
|
try {
|
||||||
|
row = await createAgentCheck(
|
||||||
|
ctx.db,
|
||||||
|
{
|
||||||
|
name: input.name,
|
||||||
|
description: "智能体按对话起草",
|
||||||
|
enabled: false,
|
||||||
|
schedule: { type: "interval", everyMs: Math.round(input.everyHours * 3600_000) },
|
||||||
|
config: {
|
||||||
|
goalTemplate: input.goal,
|
||||||
|
capabilityAllowlist: caps as KnownCapabilityId[],
|
||||||
|
budget: {
|
||||||
|
...(input.maxToolCalls ? { maxToolCalls: input.maxToolCalls } : {}),
|
||||||
|
...(input.maxDurationSec ? { maxDurationSec: input.maxDurationSec } : {}),
|
||||||
|
},
|
||||||
|
triggerCondition: input.triggerCondition ?? "always",
|
||||||
|
reportAsDecision: input.reportAsDecision ?? false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
throw new CapabilityError("precondition", `巡检没建成:${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
jobId: row.id,
|
||||||
|
name: row.name,
|
||||||
|
everyHours: input.everyHours,
|
||||||
|
capabilities: caps,
|
||||||
|
dropped,
|
||||||
|
enabled: false as const,
|
||||||
|
next: "已建成未启用的巡检。请用户到「计划」页检查目标、间隔和能力,确认后打开开关启用。",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ServerContext, ServerModule } from "../types.ts";
|
import type { ServerContext, ServerModule } from "../types.ts";
|
||||||
import { builtinAgentCheckDefs } from "./agent-checks.ts";
|
import { builtinAgentCheckDefs } from "./agent-checks.ts";
|
||||||
|
import { registerScheduleCapabilities } from "./capabilities.ts";
|
||||||
import { registerScheduledJob, scheduleRegistry } from "./registry.ts";
|
import { registerScheduledJob, scheduleRegistry } from "./registry.ts";
|
||||||
import { registerScheduleRoutes } from "./routes.ts";
|
import { registerScheduleRoutes } from "./routes.ts";
|
||||||
import { Scheduler, type SchedulerOptions } from "./scheduler.ts";
|
import { Scheduler, type SchedulerOptions } from "./scheduler.ts";
|
||||||
@@ -30,6 +31,9 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
|||||||
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
|
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
|
||||||
},
|
},
|
||||||
registerTasks() {},
|
registerTasks() {},
|
||||||
|
registerCapabilities(registry, ctx) {
|
||||||
|
registerScheduleCapabilities(registry, ctx);
|
||||||
|
},
|
||||||
start(ctx: ServerContext) {
|
start(ctx: ServerContext) {
|
||||||
if (opts.builtinChecks !== false) for (const def of builtinAgentCheckDefs(ctx)) registerScheduledJob(ctx, def);
|
if (opts.builtinChecks !== false) for (const def of builtinAgentCheckDefs(ctx)) registerScheduledJob(ctx, def);
|
||||||
// 备份目前由宿主机 scripts/backup.sh(cron / 手动)执行,这里只占位,看板上显示"未配置"
|
// 备份目前由宿主机 scripts/backup.sh(cron / 手动)执行,这里只占位,看板上显示"未配置"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||||
|
import { registerScheduleCapabilities } from "../../src/modules/schedules/capabilities.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable } from "../sources-identity/harness.ts";
|
||||||
|
|
||||||
|
// schedule.draft_check:智能体按对话起草巡检 → 建成未启用的 agent_check;只读能力白名单;只放行用户发起的对话
|
||||||
|
const pg = await pgAvailable();
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: 能力输出按 schema 校验过,测试里直接取字段
|
||||||
|
type Any = any;
|
||||||
|
|
||||||
|
describe.skipIf(!pg)("起草巡检能力(真实 PG)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness("draftcheck");
|
||||||
|
const registry = createCapabilityRegistry({ logger: h.ctx.logger, db: h.db, settings: h.ctx.settings });
|
||||||
|
registerScheduleCapabilities(registry, h.ctx);
|
||||||
|
(h.ctx as { capabilities: unknown }).capabilities = registry;
|
||||||
|
}, 120_000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (trigger: "user" | "schedule") =>
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.insertInto("agent_run")
|
||||||
|
.values({ trigger, goal: "起草巡检", status: "running", mode: "execute" } as never)
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).id;
|
||||||
|
const invoke = (input: Record<string, unknown>, agentRunId: string) =>
|
||||||
|
h.ctx.capabilities.invoke("schedule.draft_check", input, {
|
||||||
|
actorId: "agent",
|
||||||
|
taskId: crypto.randomUUID(),
|
||||||
|
agentRunId,
|
||||||
|
scopeIds: [],
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
source: "agent",
|
||||||
|
}) as Promise<Any>;
|
||||||
|
const input = {
|
||||||
|
name: "失败下载日报",
|
||||||
|
goal: "检查下载器里失败或卡住的任务,有就写一段话汇报",
|
||||||
|
everyHours: 24,
|
||||||
|
capabilities: ["download.list", "downloader.list", "download.submit", "not.exists"],
|
||||||
|
triggerCondition: "stalled_downloads",
|
||||||
|
reportAsDecision: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("对话里起草:建成未启用的巡检;写入 / 不存在的能力去掉", async () => {
|
||||||
|
const out = await invoke(input, await run("user"));
|
||||||
|
expect(out).toMatchObject({
|
||||||
|
enabled: false,
|
||||||
|
capabilities: ["download.list", "downloader.list"],
|
||||||
|
dropped: ["download.submit", "not.exists"],
|
||||||
|
});
|
||||||
|
const job = await h.db
|
||||||
|
.selectFrom("scheduled_job")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", out.jobId)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(job).toMatchObject({ kind: "agent_check", enabled: false, builtin: false, name: "失败下载日报" });
|
||||||
|
expect(job.schedule).toMatchObject({ type: "interval", everyMs: 24 * 3600_000 });
|
||||||
|
expect(job.config).toMatchObject({
|
||||||
|
goalTemplate: input.goal,
|
||||||
|
capabilityAllowlist: ["download.list", "downloader.list"],
|
||||||
|
triggerCondition: "stalled_downloads",
|
||||||
|
reportAsDecision: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("定时巡检的会话里不能再起草巡检", async () => {
|
||||||
|
const before = await h.db.selectFrom("scheduled_job").select("id").execute();
|
||||||
|
await expect(invoke(input, await run("schedule"))).rejects.toThrow(/用户发起的对话/);
|
||||||
|
expect(await h.db.selectFrom("scheduled_job").select("id").execute()).toHaveLength(before.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
Brain,
|
Brain,
|
||||||
|
CalendarCog,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -38,6 +39,7 @@ import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
|||||||
import { Markdown } from "~/components/agentui/markdown.tsx";
|
import { Markdown } from "~/components/agentui/markdown.tsx";
|
||||||
import { MessageMarker, MessageTyping } from "~/components/agentui/message.tsx";
|
import { MessageMarker, MessageTyping } from "~/components/agentui/message.tsx";
|
||||||
import { StreamingResponse } from "~/components/agentui/streaming-response.tsx";
|
import { StreamingResponse } from "~/components/agentui/streaming-response.tsx";
|
||||||
|
import { consolePath } from "~/components/console/nav.ts";
|
||||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||||
import { formatUsd } from "~/components/schedules/labels.ts";
|
import { formatUsd } from "~/components/schedules/labels.ts";
|
||||||
@@ -542,6 +544,11 @@ export function AgentConversation({
|
|||||||
until={p.body[0]?.at ?? null}
|
until={p.body[0]?.at ?? null}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{p.activity
|
||||||
|
.flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : []))
|
||||||
|
.map((d) => (
|
||||||
|
<DraftCheckCard key={d.jobId} {...d} />
|
||||||
|
))}
|
||||||
{p.body.map((t) => {
|
{p.body.map((t) => {
|
||||||
const streaming = streamingText && t.key === lastKey;
|
const streaming = streamingText && t.key === lastKey;
|
||||||
// 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息
|
// 旧会话 / 不守汇报格式的模型:UUID、能力 ID、原因码换成名称和人话,原文进技术信息
|
||||||
@@ -601,6 +608,33 @@ export function AgentConversation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 起草巡检(schedule.draft_check)成功的输出:巡检 ID 与名称(输出摘要可能被截断,按字段取) */
|
||||||
|
export function draftCheckOf(call: ToolCallView): { jobId: string; name: string }[] {
|
||||||
|
if (call.capabilityId !== "schedule.draft_check" || call.status === "running" || call.status === "failed") return [];
|
||||||
|
if (isDenied(call) || !call.outputSummary) return [];
|
||||||
|
const jobId = /"jobId"\s*:\s*"([\w.-]+)"/.exec(call.outputSummary)?.[1];
|
||||||
|
const name = /"name"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(call.outputSummary)?.[1];
|
||||||
|
return jobId ? [{ jobId, name: name ?? "巡检" }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对话里起草好的巡检:未启用,去计划页检查后启用 */
|
||||||
|
function DraftCheckCard({ jobId, name }: { jobId: string; name: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-[72ch] flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 px-3 py-2.5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex items-center gap-2 text-caption font-medium text-foreground">
|
||||||
|
<CalendarCog aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
巡检草稿「{name}」<StatusBadge>未启用</StatusBadge>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-meta text-muted-foreground">到计划页检查目标、间隔和能力,确认后打开开关启用。</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<Link to={`${consolePath("schedules")}?job=${encodeURIComponent(jobId)}`}>检查并启用</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 回合开始的时间:前一条用户消息;第一轮是会话开始 */
|
/** 回合开始的时间:前一条用户消息;第一轮是会话开始 */
|
||||||
function turnSince(blocks: ConvBlock[], index: number, run: AgentRunView): string | null {
|
function turnSince(blocks: ConvBlock[], index: number, run: AgentRunView): string | null {
|
||||||
const prev = blocks[index - 1];
|
const prev = blocks[index - 1];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentRunEvent } from "@umt/contracts";
|
import type { AgentRunEvent } from "@umt/contracts";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { authDenial, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx";
|
import { authDenial, draftCheckOf, isDenied, pairAudit, runPlanIds, toolSummary } from "./agent-conversation.tsx";
|
||||||
import { matchesHistory } from "./agent-session-list.tsx";
|
import { matchesHistory } from "./agent-session-list.tsx";
|
||||||
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||||
import { isLongReport } from "./conversation-model.ts";
|
import { isLongReport } from "./conversation-model.ts";
|
||||||
@@ -168,3 +168,17 @@ describe("shortGoalTitle(会话短标题)", () => {
|
|||||||
expect(t).toBe("调查待处理事项(下载失败):下载异常:Dune.Part.Two");
|
expect(t).toBe("调查待处理事项(下载失败):下载异常:Dune.Part.Two");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("起草巡检卡片", () => {
|
||||||
|
it("成功的 schedule.draft_check 取巡检 ID 与名称;失败 / 其它能力不出卡片", () => {
|
||||||
|
const out =
|
||||||
|
'{"jobId":"check.custom.1a2b3c4d","name":"失败下载日报","everyHours":24,"capabilities":["download.list"]';
|
||||||
|
expect(
|
||||||
|
draftCheckOf(call({ capabilityId: "schedule.draft_check", status: "succeeded", outputSummary: out })),
|
||||||
|
).toEqual([{ jobId: "check.custom.1a2b3c4d", name: "失败下载日报" }]);
|
||||||
|
expect(draftCheckOf(call({ capabilityId: "schedule.draft_check", status: "failed", outputSummary: out }))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(draftCheckOf(call({ capabilityId: "site.list", status: "succeeded", outputSummary: out }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { LoaderCircle, Sparkles } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useCreateAgentRun } from "~/api/agent.ts";
|
||||||
|
import { FormField } from "~/components/console/form-field.tsx";
|
||||||
|
import { consolePath } from "~/components/console/nav.ts";
|
||||||
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog.tsx";
|
||||||
|
import { Textarea } from "~/components/ui/textarea.tsx";
|
||||||
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
|
|
||||||
|
// 用对话起草巡检:用户一句话描述需求 → 发起智能体对话(起草巡检 Skill)→ 智能体建成未启用的巡检,
|
||||||
|
// 对话里给「检查并启用」卡片,用户在计划页检查、修改后启用。
|
||||||
|
|
||||||
|
const EXAMPLES = [
|
||||||
|
"每天早上 9 点前看看有没有失败或卡住的下载,有就写成待处理",
|
||||||
|
"每隔 6 小时检查一下站点登录有没有失效",
|
||||||
|
"每周汇报一次订阅追更情况和缺集",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DraftCheckDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const create = useCreateAgentRun();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const submit = async () => {
|
||||||
|
const need = text.trim();
|
||||||
|
if (need.length < 4) return;
|
||||||
|
try {
|
||||||
|
const run = await create.mutateAsync({ goal: `帮我起草一个巡检:${need}` });
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate(consolePath("agent", run.id));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorMessage(err, "发起对话失败"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !create.isPending && onOpenChange(o)}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>用对话起草巡检</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
说说想定期检查什么、多久一次。智能体会建一个未启用的巡检,你检查后再启用。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormField label="需求" htmlFor="draft-need" required>
|
||||||
|
<Textarea
|
||||||
|
id="draft-need"
|
||||||
|
rows={4}
|
||||||
|
autoFocus
|
||||||
|
value={text}
|
||||||
|
placeholder={EXAMPLES[0]}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
|
{EXAMPLES.map((x) => (
|
||||||
|
<button
|
||||||
|
key={x}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setText(x)}
|
||||||
|
className="rounded-full border border-border/70 px-2.5 py-1 text-left text-meta text-muted-foreground transition-colors hover:border-foreground/40 hover:text-foreground"
|
||||||
|
>
|
||||||
|
{x}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={create.isPending}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={create.isPending || text.trim().length < 4}>
|
||||||
|
{create.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
|
||||||
|
开始起草
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
||||||
import { AlertTriangle, Bot, CalendarClock, Plus } from "lucide-react";
|
import { AlertTriangle, Bot, CalendarClock, Plus, Sparkles } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
|
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
|
||||||
@@ -14,6 +14,7 @@ import { useSse } from "~/lib/sse.ts";
|
|||||||
import { AgentCheckCard, type DefaultUpstream } from "./agent-check-card.tsx";
|
import { AgentCheckCard, type DefaultUpstream } from "./agent-check-card.tsx";
|
||||||
import { AgentCheckDialog } from "./agent-check-dialog.tsx";
|
import { AgentCheckDialog } from "./agent-check-dialog.tsx";
|
||||||
import { AutoTaskList } from "./auto-task-list.tsx";
|
import { AutoTaskList } from "./auto-task-list.tsx";
|
||||||
|
import { DraftCheckDialog } from "./draft-check-dialog.tsx";
|
||||||
import { formatUsd } from "./labels.ts";
|
import { formatUsd } from "./labels.ts";
|
||||||
import { RunsSheet } from "./runs-sheet.tsx";
|
import { RunsSheet } from "./runs-sheet.tsx";
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
const now = useNow();
|
const now = useNow();
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null);
|
const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null);
|
||||||
|
const [drafting, setDrafting] = useState(false);
|
||||||
const [history, setHistory] = useState<ScheduledJob | null>(null);
|
const [history, setHistory] = useState<ScheduledJob | null>(null);
|
||||||
|
|
||||||
const data = board.data;
|
const data = board.data;
|
||||||
@@ -169,10 +171,16 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
: "到点让智能体按目标检查并汇报。"
|
: "到点让智能体按目标检查并汇报。"
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
<>
|
||||||
<Plus />
|
<Button size="sm" variant="outline" onClick={() => setDrafting(true)}>
|
||||||
新建巡检
|
<Sparkles />
|
||||||
</Button>
|
用对话起草
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||||
|
<Plus />
|
||||||
|
新建巡检
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{checks.length === 0 ? (
|
{checks.length === 0 ? (
|
||||||
@@ -218,6 +226,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
{editing ? (
|
{editing ? (
|
||||||
<AgentCheckDialog key={editing.key} open job={editing.job} onOpenChange={(o) => !o && setEditing(null)} />
|
<AgentCheckDialog key={editing.key} open job={editing.job} onOpenChange={(o) => !o && setEditing(null)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{drafting ? <DraftCheckDialog open onOpenChange={setDrafting} /> : null}
|
||||||
<RunsSheet job={history} onClose={() => setHistory(null)} />
|
<RunsSheet job={history} onClose={() => setHistory(null)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
name: patrol-draft
|
||||||
|
title: 起草巡检
|
||||||
|
description: 用户用一句话描述想定期检查的事(多久一次、查什么、怎么汇报),起草成一个未启用的巡检,交给用户在计划页检查后启用。用户说「建个巡检」「每天帮我看看…」「定时检查…」时用。
|
||||||
|
triggers:
|
||||||
|
- (新建|创建|起草|添加|加|建|设置|设|定)(一?个)?.{0,6}(巡检|定时检查|定时任务)
|
||||||
|
- 每(天|日|周|晚|早|小时|隔.{1,6})(早上|晚上|上午|下午)?.{0,16}(检查|看看|看一下|巡检|汇报|提醒)
|
||||||
|
- 定期.{0,8}(检查|看看|巡检|汇报)
|
||||||
|
- 每(天|日|周|晚|早|隔|小时)
|
||||||
|
- \d+\s*(个)?(小时|分钟|天)(一次)?
|
||||||
|
capabilities:
|
||||||
|
- schedule.draft_check
|
||||||
|
- site.list
|
||||||
|
- downloader.list
|
||||||
|
- download.list
|
||||||
|
- decision.list
|
||||||
|
- subscription.list
|
||||||
|
sessions: [chat]
|
||||||
|
---
|
||||||
|
|
||||||
|
## 适用意图
|
||||||
|
|
||||||
|
- 「帮我建个巡检,每天早上看看有没有失败的下载」「每隔 6 小时检查一下站点登录」「每周汇报一次订阅追更」。
|
||||||
|
- 只负责「起草」:建成未启用的巡检,用户检查、修改后自己启用。用户只是想现在检查一次的,不走这里(那是普通巡检)。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
1. 从用户的话里拿出四件事:检查什么、多久一次、条件(比如「有失败下载时才看」)、要不要写成待处理事项。缺「多久一次」时按常识定(每天一次 = 24 小时),不追问。
|
||||||
|
2. 写巡检目标(goal):第二人称、一段话,写清要查哪些对象、异常怎么判断、汇报写什么、只读不改。需要当前数量时可以用占位符 {{openDecisions}}(未关闭待处理数)、{{stalledDownloads}}(卡住的下载数)、{{activeSubscriptions}}(进行中的订阅数)、{{date}}。
|
||||||
|
3. 选能力白名单:只挑完成目标要用的只读能力(站点看 site.list、下载器 downloader.list、下载任务 download.list、待处理 decision.list、订阅 subscription.list 等);拿不准就不填,等于全部只读能力。
|
||||||
|
4. 触发条件:只在有待处理时看选 open_decisions,只在有卡住的下载时看选 stalled_downloads,只在有进行中订阅时看选 subscriptions_active,否则 always。
|
||||||
|
5. 调用一次 schedule.draft_check(name 十个字以内,everyHours 用小时数)。不要重复调用;被拒绝时照实告诉用户。
|
||||||
|
|
||||||
|
## 需要批准
|
||||||
|
|
||||||
|
- schedule.draft_check 只建未启用的巡检,不会自己运行,不需要批准;启用、改间隔、删除都由用户在计划页做。
|
||||||
|
- 只在用户发起的对话里能用,定时巡检里不能再起草巡检。
|
||||||
|
|
||||||
|
## 汇报模板
|
||||||
|
|
||||||
|
两三行:建了什么、多久一次、请用户去计划页检查后启用。被去掉的能力也提一句:
|
||||||
|
|
||||||
|
> 已起草巡检「失败下载日报」:每天一次,检查下载器里失败或卡住的任务,有就写成一条待处理。还没启用,请到计划页检查目标和间隔,确认后打开开关。
|
||||||
|
|
||||||
|
## 不要做
|
||||||
|
|
||||||
|
- 不要替用户启用巡检,也不要说「已开始运行」。
|
||||||
|
- 不要把写入、删除、提交下载这类能力放进白名单:巡检只读。
|
||||||
|
- 不要为一次性的检查起草巡检。
|
||||||
@@ -101,6 +101,10 @@ export const INTENT_ROUTES: readonly IntentRoute[] = [
|
|||||||
{ cap: "browser.solve_challenge", note: "处理页面挑战(视觉子流程)" },
|
{ cap: "browser.solve_challenge", note: "处理页面挑战(视觉子流程)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
intent: "要建定时检查 / 巡检",
|
||||||
|
steps: [{ cap: "schedule.draft_check", note: "建成未启用的巡检,用户在计划页检查后启用" }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
intent: "改收录策略",
|
intent: "改收录策略",
|
||||||
steps: [
|
steps: [
|
||||||
@@ -148,7 +152,8 @@ export const GENERAL_CAPABILITIES: readonly KnownCapabilityId[] = [
|
|||||||
/** 需要授权(持续授权或单次审批)的能力:execute / approve / propose 类,会话交互类除外 */
|
/** 需要授权(持续授权或单次审批)的能力:execute / approve / propose 类,会话交互类除外 */
|
||||||
export function needsApproval(id: string): boolean {
|
export function needsApproval(id: string): boolean {
|
||||||
const d = CAPABILITY_CATALOG[id as KnownCapabilityId];
|
const d = CAPABILITY_CATALOG[id as KnownCapabilityId];
|
||||||
return !!d && AUTHORIZED_KINDS.includes(d.kind) && !id.startsWith("agent.");
|
// 起草巡检只建未启用的草稿,对话里直接放行(agent/authorize.ts)
|
||||||
|
return !!d && AUTHORIZED_KINDS.includes(d.kind) && !id.startsWith("agent.") && id !== "schedule.draft_check";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通用提示(参数、检索词、站点类型) */
|
/** 通用提示(参数、检索词、站点类型) */
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ const ROUTING: [string, string][] = [
|
|||||||
["qB 是不是掉线了", "patrol"],
|
["qB 是不是掉线了", "patrol"],
|
||||||
["我订的三体更新到第几集了", "subscription-check"],
|
["我订的三体更新到第几集了", "subscription-check"],
|
||||||
["下载进度怎么样了", "download-diagnose"],
|
["下载进度怎么样了", "download-diagnose"],
|
||||||
|
["帮我建个巡检,每天早上看看有没有失败的下载", "patrol-draft"],
|
||||||
|
["每隔 6 小时检查一下站点登录", "patrol-draft"],
|
||||||
];
|
];
|
||||||
|
|
||||||
describe("按意图选择", () => {
|
describe("按意图选择", () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MediaAsset } from "./assets.ts";
|
|||||||
import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from "./browser.ts";
|
import { BrowserController, BrowserDownloadVerification, BrowserSnapshot } from "./browser.ts";
|
||||||
import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from "./capability.ts";
|
import { CAPABILITY_CATALOG, type CapabilitySpec, type KnownCapabilityId } from "./capability.ts";
|
||||||
import { Id, JsonObject, NonNegInt } from "./common.ts";
|
import { Id, JsonObject, NonNegInt } from "./common.ts";
|
||||||
|
import { AgentCheckTrigger } from "./schedules.ts";
|
||||||
import { MediaEntity, MediaKind } from "./content.ts";
|
import { MediaEntity, MediaKind } from "./content.ts";
|
||||||
import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts";
|
import { AcquisitionPlan, DownloadStatus, DownloadTask, Task, TaskStatus } from "./execution.ts";
|
||||||
import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts";
|
import { EnrichmentStatus, EnrichSuggestion, EnrichUsage, ExternalEntityType, ExternalIdentity } from "./identity.ts";
|
||||||
@@ -909,6 +910,37 @@ export const WebSearchTestResult = z.object({
|
|||||||
export type WebSearchTestResult = z.infer<typeof WebSearchTestResult>;
|
export type WebSearchTestResult = z.infer<typeof WebSearchTestResult>;
|
||||||
|
|
||||||
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
|
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
|
||||||
|
// ── schedule.draft_check:按自然语言需求起草巡检(建成未启用,用户在计划页检查后启用) ──
|
||||||
|
|
||||||
|
export const ScheduleDraftCheckInput = z.object({
|
||||||
|
/** 巡检名称,10 字左右 */
|
||||||
|
name: z.string().trim().min(1).max(60),
|
||||||
|
/** 到点发给智能体的目标:写清检查什么、怎么汇报;可用占位符 {{now}} {{date}} {{openDecisions}} {{stalledDownloads}} {{activeSubscriptions}} */
|
||||||
|
goal: z.string().trim().min(4).max(4000),
|
||||||
|
/** 每隔多少小时跑一次(0.25–168) */
|
||||||
|
everyHours: z.number().min(0.25).max(168),
|
||||||
|
/** 需要的只读能力;不填 = 只读类全部 */
|
||||||
|
capabilities: z.array(z.string()).max(40).optional(),
|
||||||
|
/** 条件不满足时跳过、不唤醒模型 */
|
||||||
|
triggerCondition: AgentCheckTrigger.optional(),
|
||||||
|
maxToolCalls: z.number().int().min(1).max(100).optional(),
|
||||||
|
maxDurationSec: z.number().int().min(30).max(1800).optional(),
|
||||||
|
/** 结果写成一条待处理事项 */
|
||||||
|
reportAsDecision: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export const ScheduleDraftCheckOutput = z.object({
|
||||||
|
/** 建好的巡检(未启用) */
|
||||||
|
jobId: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
everyHours: z.number(),
|
||||||
|
capabilities: z.array(z.string()),
|
||||||
|
/** 不能用的能力(非只读或不存在)已去掉 */
|
||||||
|
dropped: z.array(z.string()),
|
||||||
|
enabled: z.literal(false),
|
||||||
|
/** 给用户的下一步 */
|
||||||
|
next: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CAPABILITY_IO = {
|
export const CAPABILITY_IO = {
|
||||||
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
|
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
|
||||||
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
|
"release.search": { input: ReleaseSearchInput, output: ReleaseSearchOutput },
|
||||||
@@ -952,6 +984,7 @@ export const CAPABILITY_IO = {
|
|||||||
"web.fetch": { input: WebFetchInput, output: WebFetchOutput },
|
"web.fetch": { input: WebFetchInput, output: WebFetchOutput },
|
||||||
"metadata.resolve_organize": { input: OrganizeMetadataInput, output: OrganizeMetadataOutput },
|
"metadata.resolve_organize": { input: OrganizeMetadataInput, output: OrganizeMetadataOutput },
|
||||||
"metadata.enrich": { input: MetadataEnrichInput, output: MetadataEnrichOutput },
|
"metadata.enrich": { input: MetadataEnrichInput, output: MetadataEnrichOutput },
|
||||||
|
"schedule.draft_check": { input: ScheduleDraftCheckInput, output: ScheduleDraftCheckOutput },
|
||||||
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
|
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
|
||||||
|
|
||||||
type CapabilityIo = typeof CAPABILITY_IO;
|
type CapabilityIo = typeof CAPABILITY_IO;
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const CAPABILITY_IDS = [
|
|||||||
"web.fetch",
|
"web.fetch",
|
||||||
"metadata.enrich",
|
"metadata.enrich",
|
||||||
"metadata.resolve_organize",
|
"metadata.resolve_organize",
|
||||||
|
"schedule.draft_check",
|
||||||
] as const;
|
] as const;
|
||||||
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
|
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
|
||||||
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
|
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
|
||||||
@@ -604,6 +605,18 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
|||||||
false,
|
false,
|
||||||
"never",
|
"never",
|
||||||
),
|
),
|
||||||
|
"schedule.draft_check": d(
|
||||||
|
"schedule.draft_check",
|
||||||
|
"起草巡检",
|
||||||
|
"按用户用自然语言描述的需求,建一个未启用的巡检(目标、间隔、能力白名单、触发条件、预算)。只在用户要新建定时检查 / 巡检时用;建好后由用户在计划页检查、修改并启用,不要替用户启用",
|
||||||
|
"propose",
|
||||||
|
"ScheduleDraftCheck",
|
||||||
|
"admin",
|
||||||
|
"low",
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
"never",
|
||||||
|
),
|
||||||
"metadata.enrich": d(
|
"metadata.enrich": d(
|
||||||
"metadata.enrich",
|
"metadata.enrich",
|
||||||
"补全元数据",
|
"补全元数据",
|
||||||
@@ -661,6 +674,7 @@ export const CAPABILITY_ACTIVITY: Record<KnownCapabilityId, CapabilityActivity>
|
|||||||
"site.health_check": act("检测", "站点", "个"),
|
"site.health_check": act("检测", "站点", "个"),
|
||||||
"site.refresh_login": act("刷新", "站点登录"),
|
"site.refresh_login": act("刷新", "站点登录"),
|
||||||
"site.user_data": act("查", "站点数据"),
|
"site.user_data": act("查", "站点数据"),
|
||||||
|
"schedule.draft_check": act("起草", "巡检", "个"),
|
||||||
"browser.snapshot": act("读", "原站页面", "次"),
|
"browser.snapshot": act("读", "原站页面", "次"),
|
||||||
"browser.navigate": act("打开", "原站页面", "次"),
|
"browser.navigate": act("打开", "原站页面", "次"),
|
||||||
"browser.interact": act("操作", "原站页面", "次"),
|
"browser.interact": act("操作", "原站页面", "次"),
|
||||||
|
|||||||
Reference in new issue
Block a user