Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import type { LogEntry } from "@umt/contracts";
|
|
import { Bot, ExternalLink, History } from "lucide-react";
|
|
import type { ReactNode } from "react";
|
|
import { Link } from "react-router";
|
|
import { useEngineRun } from "~/api/engine.ts";
|
|
import { type AgentContext, openAgentDrawer } from "~/components/agent/agent-store.ts";
|
|
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
|
import { JsonView } from "~/components/console/json-view.tsx";
|
|
import { KvList } from "~/components/console/kv-list.tsx";
|
|
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
|
import { TechInfo } from "~/components/console/tech-info.tsx";
|
|
import { runTitle } from "~/components/tasks/labels.ts";
|
|
import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx";
|
|
import { Button } from "~/components/ui/button.tsx";
|
|
import { actorTypeLabel, formatDuration } from "~/lib/labels.ts";
|
|
import { formatDateTime } from "~/lib/utils.ts";
|
|
import {
|
|
actorLabel,
|
|
agentGoalFor,
|
|
LOG_TYPE_LABEL,
|
|
logBadge,
|
|
logsPath,
|
|
logTitle,
|
|
objectLogsPath,
|
|
objectText,
|
|
TARGET_LABEL,
|
|
} from "./labels.ts";
|
|
|
|
// 日志详情抽屉:任务复用原任务详情(业务摘要、时间线、引擎步骤、取消 / 重跑);定时任务运行、后台调查、审计各自一组字段。
|
|
// 底部「问智能体」带上这条日志打开智能体抽屉。
|
|
|
|
/** 选中的日志 → 智能体上下文(单条时带上能识别的对象 id,服务端据此取数) */
|
|
export function agentContextFor(entries: LogEntry[]): AgentContext {
|
|
const ctx: AgentContext = {
|
|
label: entries.length === 1 ? `日志:${logTitle(entries[0]!)}` : `日志:${entries.length} 条`,
|
|
goal: agentGoalFor(entries),
|
|
};
|
|
if (entries.length === 1) {
|
|
const e = entries[0]!;
|
|
if (e.source === "engine_run") ctx.taskId = e.refId;
|
|
const o = e.object;
|
|
if (o?.id) {
|
|
if (o.type === "download_task") ctx.downloadTaskId = o.id;
|
|
else if (o.type === "organization_record") ctx.recordId = o.id;
|
|
else if (o.type === "media_entity") ctx.entityId = o.id;
|
|
else if (o.type === "site") ctx.siteId = o.id;
|
|
}
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
export function AskAgentButton({ entries, size = "sm" }: { entries: LogEntry[]; size?: "sm" | "xs" }) {
|
|
return (
|
|
<Button
|
|
variant="outline"
|
|
size={size}
|
|
disabled={!entries.length}
|
|
onClick={() => openAgentDrawer(agentContextFor(entries))}
|
|
>
|
|
<Bot />
|
|
问智能体
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function LinkRow({ links }: { links: { to: string; label: string; icon?: ReactNode }[] }) {
|
|
return (
|
|
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
|
{links.map((l) => (
|
|
<li key={l.to}>
|
|
<Link
|
|
to={l.to}
|
|
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
|
>
|
|
{l.icon ?? <ExternalLink className="h-3.5 w-3.5" />}
|
|
{l.label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function ScheduleRunBody({ e }: { e: LogEntry }) {
|
|
const x = e.extra as {
|
|
jobId?: string;
|
|
jobKind?: string;
|
|
trigger?: string;
|
|
agentRunId?: string | null;
|
|
summary?: string | null;
|
|
error?: string | null;
|
|
};
|
|
const jobId = x.jobId ?? e.object?.id ?? "";
|
|
return (
|
|
<>
|
|
<DetailSection title="概况">
|
|
<KvList
|
|
items={[
|
|
{ label: "定时任务", value: e.title, wide: true },
|
|
{ label: "类型", value: x.jobKind === "agent_check" ? "智能体巡检" : "自动任务" },
|
|
{ label: "触发", value: actorLabel(e) },
|
|
{ label: "开始", value: formatDateTime(e.at) },
|
|
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
|
|
{ label: "摘要", value: x.summary, wide: true },
|
|
]}
|
|
/>
|
|
{x.error ? <p className="mt-3 text-caption break-words text-destructive">{x.error}</p> : null}
|
|
</DetailSection>
|
|
<DetailSection title="相关">
|
|
<LinkRow
|
|
links={[
|
|
...(jobId
|
|
? [
|
|
{ to: logsPath({ tab: "schedules", job: jobId }), label: "定时任务设置" },
|
|
{
|
|
to: objectLogsPath("scheduled_job", jobId),
|
|
label: "这个定时任务的全部运行",
|
|
icon: <History className="h-3.5 w-3.5" />,
|
|
},
|
|
]
|
|
: []),
|
|
...(x.agentRunId ? [{ to: `/console/agent/${encodeURIComponent(x.agentRunId)}`, label: "巡检会话" }] : []),
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
<TechInfo>
|
|
<KvList
|
|
items={[
|
|
{ label: "运行记录 ID", value: e.refId, mono: true },
|
|
{ label: "定时任务 ID", value: jobId, mono: true },
|
|
]}
|
|
/>
|
|
</TechInfo>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AgentRunBody({ e }: { e: LogEntry }) {
|
|
const x = e.extra as { toolCalls?: number; spawnReason?: string | null; model?: string | null };
|
|
return (
|
|
<>
|
|
<DetailSection title="概况">
|
|
<KvList
|
|
items={[
|
|
{ label: "目标", value: <span className="whitespace-pre-wrap">{e.title}</span>, wide: true },
|
|
{ label: "开始", value: formatDateTime(e.at) },
|
|
{ label: "耗时", value: e.durationMs == null ? "—" : formatDuration(e.durationMs / 1000) },
|
|
{ label: "结果", value: e.message, wide: true },
|
|
{ label: "工具调用", value: x.toolCalls ?? 0 },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
<DetailSection title="相关">
|
|
<LinkRow links={[{ to: `/console/agent/${encodeURIComponent(e.refId)}`, label: "打开会话" }]} />
|
|
</DetailSection>
|
|
<TechInfo>
|
|
<KvList
|
|
items={[
|
|
{ label: "会话 ID", value: e.refId, mono: true },
|
|
{ label: "模型", value: x.model, mono: true },
|
|
]}
|
|
/>
|
|
</TechInfo>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AuditBody({ e }: { e: LogEntry }) {
|
|
const x = e.extra as { action?: string; traceId?: string | null; payload?: unknown };
|
|
const o = e.object;
|
|
return (
|
|
<>
|
|
<DetailSection title="事件">
|
|
<KvList
|
|
items={[
|
|
{
|
|
label: "操作者",
|
|
value: e.actor ? `${actorTypeLabel(e.actor.type)}${e.actor.id ? ` · ${e.actor.id}` : ""}` : "—",
|
|
wide: true,
|
|
},
|
|
{ label: "对象", value: o ? (TARGET_LABEL[o.type] ?? o.type) : "—" },
|
|
{ label: "摘要", value: e.message },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
{o?.id ? (
|
|
<DetailSection title="相关">
|
|
<LinkRow
|
|
links={[
|
|
{
|
|
to: objectLogsPath(o.type, o.id),
|
|
label: "同一对象的全部日志",
|
|
icon: <History className="h-3.5 w-3.5" />,
|
|
},
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
) : null}
|
|
<JsonView label="负载" value={x.payload} />
|
|
<TechInfo>
|
|
<KvList
|
|
items={[
|
|
{ label: "动作", value: x.action, mono: true },
|
|
{ label: "对象类型", value: o?.type, mono: true },
|
|
{ label: "对象 ID", value: o?.id, mono: true },
|
|
{ label: "Trace", value: x.traceId, mono: true },
|
|
{ label: "事件 ID", value: e.refId, mono: true },
|
|
]}
|
|
/>
|
|
</TechInfo>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 任务:按引擎运行 id 取详情(列表里没有这条也能打开,旧链接 ?run= 用) */
|
|
function RunSheet({
|
|
runId,
|
|
entry,
|
|
onOpenRun,
|
|
onClose,
|
|
}: {
|
|
runId: string;
|
|
entry: LogEntry | null;
|
|
onOpenRun: (id: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const q = useEngineRun(runId);
|
|
const r = q.data;
|
|
return (
|
|
<DetailSheet
|
|
open
|
|
onOpenChange={(o) => !o && onClose()}
|
|
width="lg"
|
|
title={r ? runTitle(r) : entry ? logTitle(entry) : "任务"}
|
|
description={r ? <TaskStatusLine r={r} /> : undefined}
|
|
footer={
|
|
r ? (
|
|
<>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link to={`/console/tasks/${encodeURIComponent(r.id)}`}>
|
|
<ExternalLink />
|
|
独立页面
|
|
</Link>
|
|
</Button>
|
|
{entry ? <AskAgentButton entries={[entry]} /> : null}
|
|
<RunActions r={r} onOpenRun={onOpenRun} />
|
|
</>
|
|
) : null
|
|
}
|
|
>
|
|
{q.isPending ? (
|
|
<LoadingState rows={6} />
|
|
) : q.isError ? (
|
|
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
|
|
) : r ? (
|
|
<TaskDetailBody r={r} onOpenRun={onOpenRun} />
|
|
) : null}
|
|
</DetailSheet>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 日志详情抽屉。open = 日志 id(<source>:<原始 id>);任务类只要 id 就能打开,其余要在已加载的列表里找到这条。
|
|
*/
|
|
export function LogDetailSheet({
|
|
logId,
|
|
entry,
|
|
onOpenLog,
|
|
onClose,
|
|
}: {
|
|
logId: string | null;
|
|
entry: LogEntry | null;
|
|
onOpenLog: (id: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
if (!logId) return null;
|
|
const sep = logId.indexOf(":");
|
|
const source = logId.slice(0, sep);
|
|
const refId = logId.slice(sep + 1);
|
|
if (source === "engine_run")
|
|
return <RunSheet runId={refId} entry={entry} onOpenRun={(id) => onOpenLog(`engine_run:${id}`)} onClose={onClose} />;
|
|
const badge = entry ? logBadge(entry) : null;
|
|
return (
|
|
<DetailSheet
|
|
open
|
|
onOpenChange={(o) => !o && onClose()}
|
|
title={entry ? logTitle(entry) : "日志"}
|
|
description={
|
|
entry ? (
|
|
<span className="flex flex-wrap items-center gap-2">
|
|
{badge ? <StatusBadge tone={badge.tone}>{badge.label}</StatusBadge> : null}
|
|
<span>{LOG_TYPE_LABEL[entry.type]}</span>
|
|
<span>· {formatDateTime(entry.at)}</span>
|
|
{entry.object ? <span>· {objectText(entry.object)}</span> : null}
|
|
</span>
|
|
) : undefined
|
|
}
|
|
footer={entry ? <AskAgentButton entries={[entry]} /> : null}
|
|
>
|
|
{!entry ? (
|
|
<EmptyState plain title="这条日志不在当前列表里" description="换个筛选或时间范围再找。" className="py-6" />
|
|
) : entry.source === "schedule_run" ? (
|
|
<ScheduleRunBody e={entry} />
|
|
) : entry.source === "agent_run" ? (
|
|
<AgentRunBody e={entry} />
|
|
) : (
|
|
<AuditBody e={entry} />
|
|
)}
|
|
</DetailSheet>
|
|
);
|
|
}
|