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
@@ -8,6 +8,7 @@ import {
|
||||
import {
|
||||
Ban,
|
||||
Brain,
|
||||
CalendarCog,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -38,6 +39,7 @@ import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
|
||||
import { Markdown } from "~/components/agentui/markdown.tsx";
|
||||
import { MessageMarker, MessageTyping } from "~/components/agentui/message.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 { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { formatUsd } from "~/components/schedules/labels.ts";
|
||||
@@ -542,6 +544,11 @@ export function AgentConversation({
|
||||
until={p.body[0]?.at ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{p.activity
|
||||
.flatMap((a) => (a.kind === "tool" ? draftCheckOf(a.call) : []))
|
||||
.map((d) => (
|
||||
<DraftCheckCard key={d.jobId} {...d} />
|
||||
))}
|
||||
{p.body.map((t) => {
|
||||
const streaming = streamingText && t.key === lastKey;
|
||||
// 旧会话 / 不守汇报格式的模型: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 {
|
||||
const prev = blocks[index - 1];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentRunEvent } from "@umt/contracts";
|
||||
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 { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
|
||||
import { isLongReport } from "./conversation-model.ts";
|
||||
@@ -168,3 +168,17 @@ describe("shortGoalTitle(会话短标题)", () => {
|
||||
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 { 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 { useLocation } from "react-router";
|
||||
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 { AgentCheckDialog } from "./agent-check-dialog.tsx";
|
||||
import { AutoTaskList } from "./auto-task-list.tsx";
|
||||
import { DraftCheckDialog } from "./draft-check-dialog.tsx";
|
||||
import { formatUsd } from "./labels.ts";
|
||||
import { RunsSheet } from "./runs-sheet.tsx";
|
||||
|
||||
@@ -101,6 +102,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
const now = useNow();
|
||||
const { hash } = useLocation();
|
||||
const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null);
|
||||
const [drafting, setDrafting] = useState(false);
|
||||
const [history, setHistory] = useState<ScheduledJob | null>(null);
|
||||
|
||||
const data = board.data;
|
||||
@@ -169,10 +171,16 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
: "到点让智能体按目标检查并汇报。"
|
||||
}
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||
<Plus />
|
||||
新建巡检
|
||||
</Button>
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setDrafting(true)}>
|
||||
<Sparkles />
|
||||
用对话起草
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||
<Plus />
|
||||
新建巡检
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{checks.length === 0 ? (
|
||||
@@ -218,6 +226,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
{editing ? (
|
||||
<AgentCheckDialog key={editing.key} open job={editing.job} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
) : null}
|
||||
{drafting ? <DraftCheckDialog open onOpenChange={setDrafting} /> : null}
|
||||
<RunsSheet job={history} onClose={() => setHistory(null)} />
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user