Merge branch 'deai2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	apps/server/src/modules/acquisition-organize/services/organize.ts
#	apps/web/app/routes/console/sites.$siteId.tsx
This commit is contained in:
root committed 2026-09-29 21:26:16 +08:00
commit 12bc4664b6
247 files changed
+2658 -2148

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ class MemStore {
}
// 验收 S7:生产构建里 ?mock=1 不得把演示数据当真实运行展示
describe("Agent 演示开关", () => {
describe("智能体演示开关", () => {
it("只在开发构建或后端 provider=fake 时允许", () => {
expect(agentMockAllowed({ dev: true })).toBe(true);
expect(agentMockAllowed({ dev: false, provider: "fake" })).toBe(true);
+2 -2
View File
@@ -155,9 +155,9 @@ export const DOWNLOAD_STATUS_TONE: Record<DownloadStatus, "success" | "warning"
export const PAUSE_REASON_LABEL = {
user: "用户暂停",
threshold: "阈值暂停",
threshold: "下载盘满暂停",
policy: "策略暂停",
agent: "Agent 暂停",
agent: "智能体暂停",
} as const;
export const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
+1 -1
View File
@@ -106,7 +106,7 @@ export function useFetchFeedNow() {
export function feedGapHint(f: FeedSource, now = Date.now()): string | null {
if (f.possibleGap) {
const at = new Date(f.possibleGap.detectedAt).toLocaleString("zh-CN", { hour12: false });
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "Feed 未关联站点,无法自动补漏"})`;
return `疑似缺口(${at} 检测,${f.possibleGap.backfillQueued ? "已排补漏" : "RSS 未关联站点,无法自动补漏"})`;
}
if (!f.enabled || !f.lastSuccessAt) return null;
const gapMs = now - new Date(f.lastSuccessAt).getTime();
+1 -1
View File
@@ -64,7 +64,7 @@ export function inventoryFlags(
});
}
if (releaseResolution && releaseResolution === "resolved" && flags.length === 0) {
flags.push({ key: "tracked", label: "已识别,无版本目标", tone: "neutral" });
flags.push({ key: "tracked", label: "已识别,无收录版本", tone: "neutral" });
}
return flags;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export const RECORD_STATUS_TONE: Record<OrganizationStatus, "success" | "warning
cancelled: "neutral",
};
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "Agent" };
export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自动", manual: "手动", agent: "智能体" };
/** dryRun 生成的整理计划冻结为 artifact(kind=plan),GET /artifacts/{id} 读取逐文件预览 */
export function useArtifact(id: string | null | undefined) {
+4 -4
View File
@@ -99,14 +99,14 @@ export function useRuleSuppressions() {
export type RuleRefs = {
/** 规则组 id → 引用它的订阅(params.ruleGroupIds 或私有组) */
subscriptions: Map<string, { id: string; title: string }[]>;
/** 规则组 id → 引用它的版本目标 */
/** 规则组 id → 引用它的收录版本 */
slots: Map<string, { id: string; name: string }[]>;
};
const SubPage = cursorPage(Subscription);
const SlotPage = cursorPage(AcquisitionSlot);
/** 引用关系:列表接口不带引用计数,前端从订阅与版本目标反查(各取前 200 条) */
/** 引用关系:列表接口不带引用计数,前端从订阅与收录版本反查(各取前 200 条) */
export function useRuleRefs() {
return useQuery({
queryKey: ruleKeys.refs,
@@ -150,7 +150,7 @@ function useInvalidate() {
const qc = useQueryClient();
return () => {
void qc.invalidateQueries({ queryKey: ruleKeys.all });
// 旧偏好标签页与订阅/版本目标的规则组显示同源
// 旧偏好标签页与订阅/收录版本的规则组显示同源
void qc.invalidateQueries({ queryKey: ["preferences"] });
};
}
@@ -175,7 +175,7 @@ export function useUpdateRuleGroup() {
});
}
/** 删除:标记删除并写抑制记录;被版本目标/订阅引用时 409 */
/** 删除:标记删除并写抑制记录;被收录版本/订阅引用时 409 */
export function useDeleteRuleGroup() {
const done = useInvalidate();
return useMutation({
+1 -1
View File
@@ -105,7 +105,7 @@ export function useSiteRequestStats(siteId: string | undefined) {
export function accessSummary(s: SiteRequestStats): string {
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
const parts = [
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
`抓取每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
];
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
+2 -2
View File
@@ -77,7 +77,7 @@ export function useUpdateSubscription() {
api.patch<unknown>(`/subscriptions/${id}`, body).then((d) => parseOrThrow(Subscription, d, "/subscriptions")),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: subscriptionKeys.all });
// best_version 写到版本目标的升级策略
// best_version 写到收录版本的升级策略
void qc.invalidateQueries({ queryKey: ["slots"] });
},
});
@@ -246,7 +246,7 @@ export function useFillSlot() {
});
}
/** 版本目标排序:第一个成为主版本 */
/** 收录版本排序:第一个成为主版本 */
export function useSetSlotOrder() {
const done = useInvalidateSubs();
return useMutation({
+19 -8
View File
@@ -11,7 +11,8 @@ import {
type TaskStatus,
} from "@umt/contracts";
import { organizationActionLabel, organizationLabel } from "~/lib/labels.ts";
import { PLAN_STATUS_LABEL } from "./downloads.ts";
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
import { PAUSE_REASON_LABEL, PLAN_STATUS_LABEL } from "./downloads.ts";
import { apiParsed } from "./parse.ts";
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
@@ -23,7 +24,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
const DecisionPage = cursorPage(Decision);
const RESOLUTION_LABEL = {
unresolved: "待识别",
unresolved: "未识别",
resolved: "已识别",
ambiguous: "有歧义,待确认",
unidentifiable: "无法识别",
@@ -173,7 +174,7 @@ export function buildTimeline(input: {
stage: "plan",
at: plan.createdAt,
title: `创建下载计划(${plan.purpose === "fill" ? "补缺" : plan.purpose === "upgrade" ? "升级" : "换源"})`,
detail: plan.statusReason,
detail: humanizeReason(plan.statusReason),
tone: "neutral",
});
if (plan.updatedAt !== plan.createdAt)
@@ -182,7 +183,7 @@ export function buildTimeline(input: {
stage: "plan",
at: plan.updatedAt,
title: `计划状态:${PLAN_STATUS_LABEL[plan.status]}`,
detail: plan.statusReason,
detail: humanizeReason(plan.statusReason),
tone: plan.status === "failed" ? "destructive" : plan.status === "expired" ? "warning" : "neutral",
});
}
@@ -199,7 +200,7 @@ export function buildTimeline(input: {
key: "dl-pause",
stage: "download",
at: task.updatedAt,
title: task.pauseReason === "threshold" ? "缓存达到硬阈值,已暂停" : `已暂停(${task.pauseReason})`,
title: task.pauseReason === "threshold" ? "下载盘超过暂停线,已暂停" : PAUSE_REASON_LABEL[task.pauseReason],
tone: "warning",
ongoing: task.status === "paused",
});
@@ -239,7 +240,15 @@ export function buildTimeline(input: {
stage: "organization",
at: r.startedAt ?? r.createdAt,
title: `整理 ${organizationActionLabel(r.action)}:${organizationLabel(r.status).label}`,
detail: `${c.succeeded}/${c.total} 文件成功${c.failed ? `,${c.failed} 失败` : ""}${c.skipped ? `,${c.skipped} 跳过` : ""}${r.reason ? ` · ${r.reason.message}` : ""}`,
detail:
[
c.total ? `${c.succeeded}/${c.total} 个文件成功` : null,
c.failed ? `${c.failed} 个失败` : null,
c.skipped ? `${c.skipped} 个跳过` : null,
r.reason ? reasonLine(r.reason) : null,
]
.filter(Boolean)
.join(" · ") || null,
tone: RECORD_TONE[r.status],
href: `/console/organize/${r.id}`,
ongoing: r.status === "running" || r.status === "queued",
@@ -260,7 +269,7 @@ export function buildTimeline(input: {
key: `agent-${d.id}`,
stage: "agent",
at: d.updatedAt,
title: "Agent 已介入调查",
title: "智能体已介入调查",
tone: "neutral",
href: `/console/agent/${d.agentRunId}`,
});
@@ -275,7 +284,9 @@ export function buildTimeline(input: {
at: t.startedAt ?? t.createdAt,
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${TASK_STATUS_TEXT[t.status] ?? t.status}`,
detail:
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, humanizeReason(t.waitReason), humanizeReason(err)]
.filter(Boolean)
.join(" · ") || null,
tone: TASK_STATUS_TONE[t.status],
href: `/console/tasks/${encodeURIComponent(t.id)}`,
ongoing: ACTIVE_TASK.includes(t.status),
@@ -32,6 +32,8 @@ import {
useDecideApproval,
useIsAdminSession,
} from "~/api/agent.ts";
import { DOWNLOAD_STATUS_LABEL, PLAN_STATUS_LABEL } from "~/api/downloads.ts";
import { RECORD_STATUS_LABEL } from "~/api/records.ts";
import { ApprovalCard, type ApprovalCardStatus } from "~/components/agentui/approval-card.tsx";
import { type CitationItem, CitationList } from "~/components/agentui/citations.tsx";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
@@ -51,8 +53,11 @@ import { StreamingCursor, StreamingResponse } from "~/components/agentui/streami
import { ToolApproval } from "~/components/agentui/tool-approval.tsx";
import { ToolResult, ToolResultOutput, type ToolResultStatus } from "~/components/agentui/tool-result.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { ToolCallIo } from "./tool-call-card-io.tsx";
@@ -103,16 +108,13 @@ function CapabilityBadges({ id, extra }: { id: string; extra?: ReactNode }) {
const m = capabilityMeta(id);
return (
<>
{/* 只标需要注意的:会改动数据的动作和中高风险;只读、scope、幂等进技术信息 */}
{m ? (
<>
<StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge>
<StatusBadge>scope · {m.scope}</StatusBadge>
<RiskBadge level={m.riskLevel} />
{m.idempotent ? <StatusBadge>幂等</StatusBadge> : null}
{m.kind !== "read" ? <StatusBadge>{KIND_LABEL[m.kind] ?? m.kind}</StatusBadge> : null}
{m.riskLevel !== "low" ? <RiskBadge level={m.riskLevel} /> : null}
</>
) : (
<StatusBadge>未登记能力</StatusBadge>
)}
) : null}
{extra}
</>
);
@@ -197,20 +199,21 @@ export function ToolCallCard({
<ToolApproval
status="rejected"
tool={call.capabilityId}
title={`服务端拒绝了「${meta?.title ?? call.capabilityId}」`}
title={`未执行:${meta?.title ?? "这个操作"}`}
badges={
<CapabilityBadges
id={call.capabilityId}
extra={<StatusBadge tone="destructive">{authReason ? "授权校验未通过" : "白名单拦截"}</StatusBadge>}
extra={<StatusBadge tone="destructive">{authReason ? "需要你批准" : "本次没有开放"}</StatusBadge>}
/>
}
reason={authReason ?? call.reason ?? "未授权"}
explanation={
authReason
? '有副作用的能力需要有效的持续授权或单次审批,由服务端在执行前核对,模型声称"用户已同意"无效。控制台发起的会话里,需审批的动作会在对应步骤出审批卡;也可以发布收录策略生成持续授权。'
: "这个能力不在本次运行的白名单内,或已超出调用预算,调用没有执行。"
? "会改动数据的操作要你批准:在对应步骤的审批卡上批准,或在设置 → 智能体里给它持续授权。"
: "这次会话没有开放这个操作,或已用完调用次数。"
}
parameters={[
{ id: "cap", label: "能力", value: call.capabilityId },
{ id: "call", label: "调用 ID", value: call.toolCallId },
...(audit?.idempotencyKey ? [{ id: "idem", label: "幂等键", value: audit.idempotencyKey }] : []),
{ id: "at", label: "时间", value: formatDateTime(call.startedAt) },
@@ -222,9 +225,8 @@ export function ToolCallCard({
return (
<ToolResult
status={status}
tool={call.capabilityId}
tool={meta ? null : call.capabilityId}
title={meta?.title ?? call.capabilityId}
meta={durationText(call.startedAt, call.endedAt) ?? undefined}
badges={
<CapabilityBadges
id={call.capabilityId}
@@ -244,24 +246,39 @@ export function ToolCallCard({
) : call.status === "running" ? (
<p className="text-meta text-muted-foreground">等待结果…</p>
) : null}
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">调用 ID</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
<dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ??
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读能力不记操作账本")}
</dd>
{audit?.grantId || audit?.approvalId ? (
<>
<dt className="text-muted-foreground">授权依据</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
</dd>
</>
) : null}
</dl>
<ToolCallIo call={call} />
<TechInfo>
<dl className="grid grid-cols-[minmax(0,6rem)_minmax(0,1fr)] gap-x-3 gap-y-1 text-meta">
<dt className="text-muted-foreground">能力</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{call.capabilityId}
{meta
? ` · ${KIND_LABEL[meta.kind] ?? meta.kind} · scope ${meta.scope}${meta.idempotent ? " · 幂等" : ""}`
: ""}
</dd>
{durationText(call.startedAt, call.endedAt) ? (
<>
<dt className="text-muted-foreground">耗时</dt>
<dd className="min-w-0 text-foreground/80">{durationText(call.startedAt, call.endedAt)}</dd>
</>
) : null}
<dt className="text-muted-foreground">调用 ID</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">{call.toolCallId}</dd>
<dt className="text-muted-foreground">幂等键</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit?.idempotencyKey ??
(meta && meta.kind !== "read" ? "服务端已生成,审计里没有取到" : "只读操作不记录")}
</dd>
{audit?.grantId || audit?.approvalId ? (
<>
<dt className="text-muted-foreground">授权依据</dt>
<dd className="min-w-0 font-mono break-all text-foreground/80">
{audit.grantId ? `持续授权 ${audit.grantId.slice(0, 8)}` : `单次审批 ${audit.approvalId?.slice(0, 8)}`}
</dd>
</>
) : null}
</dl>
<ToolCallIo call={call} />
</TechInfo>
</ToolResult>
);
}
@@ -652,7 +669,7 @@ export function AgentConversation({
at: m.at,
role: "user",
foldable: false,
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待 Agent 读取" : undefined} animateIn />,
node: <UserMessage text={m.text} at={m.at} note={m.pending ? "已送达,等待智能体读取" : undefined} animateIn />,
});
entries.sort((a, b) => a.at.localeCompare(b.at));
const blocks = groupConversation(entries);
@@ -678,13 +695,13 @@ export function AgentConversation({
b.kind === "single" ? (
<div key={b.entry.key}>{b.entry.node}</div>
) : (
<Message key={b.key} from="assistant" aria-label="Agent 回合">
<Message key={b.key} from="assistant" aria-label="智能体回合">
<MessageAvatar>
<Bot />
</MessageAvatar>
<MessageContent className="gap-2">
<MessageHeader>
<span className="font-medium text-foreground/80">Agent</span>
<span className="font-medium text-foreground/80">智能体</span>
<time dateTime={b.at}>{clockText(b.at)}</time>
</MessageHeader>
{b.parts.map((p) =>
@@ -773,35 +790,60 @@ export function BudgetBar({
? (run.finishedAt ? new Date(run.finishedAt).getTime() : Date.now()) - new Date(run.startedAt).getTime()
: undefined);
return (
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2 sm:grid-cols-4")}>
<Meter
label="工具调用"
used={toolCalls}
max={run.budget.maxToolCalls}
format={(n) => String(n)}
exceeded={exceeded === "tool_calls"}
/>
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
exceeded={exceeded === "tokens"}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n}s`}
exceeded={exceeded === "duration"}
/>
<Meter
label="费用(估算)"
used={u.costUsd}
max={run.budget.maxCostUsd}
format={(n) => `$${n.toFixed(3)}`}
exceeded={exceeded === "cost"}
/>
<div className="grid gap-2">
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
<Meter
label="费用(估算)"
used={u.costUsd}
max={run.budget.maxCostUsd}
format={(n) => formatUsd(n)}
exceeded={exceeded === "cost"}
/>
<Meter
label="时长"
used={durationMs === undefined ? undefined : Math.round(durationMs / 1000)}
max={run.budget.maxDurationSec}
format={(n) => `${n} 秒`}
exceeded={exceeded === "duration"}
/>
</div>
{/* 调用次数与 token 超限时直接显示,否则收进技术信息 */}
{exceeded === "tool_calls" || exceeded === "tokens" ? (
<div className={cn("grid gap-x-4 gap-y-2", stacked ? "grid-cols-1 gap-y-2.5" : "grid-cols-2")}>
<Meter
label="工具调用"
used={toolCalls}
max={run.budget.maxToolCalls}
format={(n) => String(n)}
exceeded={exceeded === "tool_calls"}
/>
<Meter
label="token"
used={u.totalTokens}
max={run.budget.maxTokens}
format={(n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))}
exceeded={exceeded === "tokens"}
/>
</div>
) : (
<TechInfo
items={[
{
label: "工具调用",
value: `${toolCalls}${run.budget.maxToolCalls ? ` / ${run.budget.maxToolCalls}` : ""}`,
},
{
label: "token",
value:
u.totalTokens === undefined
? null
: `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
},
{ label: "模型", value: run.model ? `${run.provider ? `${run.provider}/` : ""}${run.model}` : null },
{ label: "会话 ID", value: run.id, wide: true },
]}
/>
)}
</div>
);
}
@@ -947,17 +989,25 @@ export function FinalReport({
...report.downloads.map((d) => ({
id: `d${d.downloadTaskId}`,
icon: <Download />,
title: d.name ?? `下载任务 ${d.downloadTaskId.slice(0, 8)}`,
title: d.name ?? "下载任务",
domain: "下载任务",
meta: <StatusBadge tone={PLAN_STATUS_TONE(d.status)}>{d.status}</StatusBadge>,
meta: (
<StatusBadge tone={PLAN_STATUS_TONE(d.status)}>
{DOWNLOAD_STATUS_LABEL[d.status as keyof typeof DOWNLOAD_STATUS_LABEL] ?? d.status}
</StatusBadge>
),
to: `/console/downloads/${d.downloadTaskId}`,
})),
...report.organization.map((o) => ({
id: `o${o.recordId}`,
icon: <FolderInput />,
title: o.targetPath ?? `整理记录 ${o.recordId.slice(0, 8)}`,
title: o.targetPath ?? "整理记录",
domain: "整理记录",
meta: <StatusBadge tone={PLAN_STATUS_TONE(o.status)}>{o.status}</StatusBadge>,
meta: (
<StatusBadge tone={PLAN_STATUS_TONE(o.status)}>
{RECORD_STATUS_LABEL[o.status as keyof typeof RECORD_STATUS_LABEL] ?? o.status}
</StatusBadge>
),
to: `/console/organize/${o.recordId}`,
})),
]
@@ -976,7 +1026,6 @@ export function FinalReport({
{said ? <Markdown text={said} /> : "(没有文字汇报)"}
</StreamingResponse>
</LongText>
<p className="mt-2 text-meta text-muted-foreground">仅供参考,不作为完成依据。</p>
</div>
<div className="min-w-0 rounded-xl border border-border/70 bg-foreground/[0.02] p-3">
<p className="mb-1.5 flex items-center gap-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
@@ -985,19 +1034,19 @@ export function FinalReport({
) : (
<LoaderCircle aria-hidden className="size-3.5 animate-spin motion-reduce:animate-none" />
)}
系统核实的结果
实际结果
</p>
<div className="flex flex-wrap gap-1.5">
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
<StatusBadge tone={RUN_STATUS_TONE[run.status]}>运行{RUN_STATUS_LABEL[run.status]}</StatusBadge>
<StatusBadge>工具成功 {count("succeeded")}</StatusBadge>
{count("failed") ? <StatusBadge tone="destructive">失败 {count("failed")}</StatusBadge> : null}
{denied ? <StatusBadge tone="destructive">被拒绝 {denied}</StatusBadge> : null}
</div>
{state || count("failed") || denied ? (
<div className="flex flex-wrap gap-1.5">
{state ? <StatusBadge tone={state.tone}>{state.label}</StatusBadge> : null}
{count("failed") ? <StatusBadge tone="destructive">{count("failed")} 步失败</StatusBadge> : null}
{denied ? <StatusBadge tone="destructive">{denied} 步未执行</StatusBadge> : null}
</div>
) : null}
{report ? (
<>
<p className="mt-2 text-caption break-words text-foreground">{report.summary}</p>
<dl className="mt-2 grid grid-cols-4 gap-2 text-center">
<dl className="mt-2 flex flex-wrap gap-2 text-center">
{(
[
["已提交", report.submitted],
@@ -1005,20 +1054,26 @@ export function FinalReport({
["进行中", report.inProgress],
["失败", report.failed],
] as const
).map(([k, v]) => (
<div key={k} className="flex flex-col-reverse rounded-lg border border-border/60 px-1 py-1.5">
<dt className="text-meta text-muted-foreground">{k}</dt>
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
</div>
))}
)
.filter(([, v]) => v > 0)
.map(([k, v]) => (
<div
key={k}
className="flex min-w-16 flex-col-reverse rounded-lg border border-border/60 px-2 py-1.5"
>
<dt className="text-meta text-muted-foreground">{k}</dt>
<dd className="text-lead font-semibold text-foreground tabular-nums">{v}</dd>
</div>
))}
</dl>
{report.plans.length ? (
<ul className="mt-2 space-y-1 text-meta">
{report.plans.map((p) => (
<li key={p.planId} className="flex min-w-0 items-center gap-2">
<span className="text-muted-foreground">下载计划</span>
<code className="min-w-0 truncate font-mono text-foreground/80">{p.planId.slice(0, 8)}</code>
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>{p.status}</StatusBadge>
<StatusBadge tone={PLAN_STATUS_TONE(p.status)}>
{PLAN_STATUS_LABEL[p.status as keyof typeof PLAN_STATUS_LABEL] ?? p.status}
</StatusBadge>
</li>
))}
</ul>
@@ -1037,18 +1092,17 @@ export function FinalReport({
}))}
/>
) : null}
<p className="mt-2 text-meta text-muted-foreground">
取自下载计划、下载任务和整理记录(生成于 {formatDateTime(report.generatedAt)}
)。「已提交」指已进入下载器,整理成功才算入库。
</p>
</>
) : (
<p className="mt-2 text-meta text-muted-foreground">服务端汇报还没有生成,以任务和整理记录的实际状态为准。</p>
)}
{run.error ? (
<p className="mt-2 text-caption break-words text-destructive">
{String((run.error as { message?: unknown }).message ?? JSON.stringify(run.error))}
</p>
<>
<p className="mt-2 text-caption break-words text-destructive">
{humanizeReason(String((run.error as { message?: unknown }).message ?? "")) || "会话出错"}
</p>
<TechInfo className="mt-1" items={[{ label: "原始错误", value: JSON.stringify(run.error), wide: true }]} />
</>
) : null}
</div>
</section>
+11 -14
View File
@@ -25,8 +25,8 @@ import {
setAgentDrawerOpen,
setAgentDrawerRun,
setAgentDrawerTab,
shortGoalTitle,
showAgentRunInDrawer,
splitContextGoal,
useAgentDrawer,
} from "./agent-store.ts";
import { AgentRunOpenContext } from "./agent-subagent-tree.tsx";
@@ -72,7 +72,7 @@ function DrawerTabs({ tab }: { tab: AgentDrawerTab }) {
return (
<div
role="tablist"
aria-label="Agent 视图"
aria-label="智能体视图"
className="mt-2 inline-flex w-fit gap-1 rounded-full border border-border/70 p-0.5"
>
{TABS.map((t) => (
@@ -108,7 +108,7 @@ function HistoryRunView({ runId }: { runId: string }) {
返回列表
</Button>
<p className="min-w-0 flex-1 truncate text-caption text-muted-foreground" title={run.data?.run.goal}>
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</p>
<Button asChild variant="ghost" size="xs" className="shrink-0 text-muted-foreground">
<Link to={`/console/agent/${runId}${mock ? "?mock=1" : ""}`} onClick={() => setAgentDrawerOpen(false)}>
@@ -161,13 +161,10 @@ export function AgentDrawer() {
<SheetHeader className="gap-1 border-b border-[var(--shell-border)] px-5 pt-4 pb-3 pr-24">
<SheetTitle className="flex items-center gap-2 text-lead">
<Bot className="h-4 w-4" />
Agent
智能体
</SheetTitle>
<SheetDescription className={cn("break-words", history && "sr-only")}>
{context?.label ? `当前对象:${context.label}` : "交代目标,Agent 在授权范围内执行并汇报。"}
<span className="ml-1 hidden text-muted-foreground/75 sm:inline">
({agentShortcutLabel()} 开关,Esc 关闭)
</span>
<SheetDescription className={cn("break-words", (history || !context?.label) && "sr-only")}>
{context?.label ? `当前对象:${context.label}` : `${agentShortcutLabel()} 打开或收起`}
</SheetDescription>
{context && !history ? <ContextChips context={context} /> : null}
<DrawerTabs tab={tab} />
@@ -281,16 +278,16 @@ function AgentMiniDock({ runId }: { runId: string }) {
: null;
return (
<section
aria-label="Agent 小窗"
aria-label="智能体小窗"
className="pg-float fixed right-4 bottom-4 z-40 w-80 max-w-[calc(100vw-2rem)] rounded-2xl p-3 animate-in fade-in-0 slide-in-from-bottom-2 duration-base ease-pop motion-reduce:animate-none"
>
<div className="flex items-center gap-2">
<Bot aria-hidden className="size-4 shrink-0 text-muted-foreground" />
{status ? <RunStatusBadge status={status} /> : null}
<span className="min-w-0 flex-1 truncate text-meta text-muted-foreground">
{run.data ? splitContextGoal(run.data.run.goal).text : ""}
{run.data ? shortGoalTitle(run.data.run.goal) : ""}
</span>
<Button variant="ghost" size="icon-xs" aria-label="展开 Agent" title="展开" onClick={restoreAgentDrawer}>
<Button variant="ghost" size="icon-xs" aria-label="展开智能体" title="展开" onClick={restoreAgentDrawer}>
<Maximize2 />
</Button>
<Button
@@ -325,8 +322,8 @@ export function AgentDrawerTrigger({ className }: { className?: string }) {
<Button
variant="ghost"
size="icon-sm"
aria-label="打开 Agent"
title={`Agent(${agentShortcutLabel()})`}
aria-label="打开智能体"
title={`智能体(${agentShortcutLabel()})`}
className={className}
onClick={() =>
minimized && runId ? restoreAgentDrawer() : openAgentDrawer(contextFromPath(pathname), lastAgentDrawerTab())
@@ -11,7 +11,7 @@ import {
toolSummary,
} from "./agent-conversation.tsx";
import { matchesHistory, rangeStart } from "./agent-session-list.tsx";
import { contextFromPath, contextPrefix, splitContextGoal } from "./agent-store.ts";
import { contextFromPath, contextPrefix, shortGoalTitle, splitContextGoal } from "./agent-store.ts";
import { foldEvents, type StreamItem, type ToolCallView } from "./use-agent-stream.ts";
const call = (p: Partial<ToolCallView>): ToolCallView => ({
@@ -185,3 +185,20 @@ describe("会话历史筛选", () => {
expect(rangeStart("", now)).toBeNull();
});
});
describe("shortGoalTitle(会话短标题)", () => {
it("去 UUID、JSON 参数,能力 ID 换中文名,取第一句", () => {
const t = shortGoalTitle(
'在浏览器会话 16be7338-1111-4222-8333-944445555666 上直接调用一次 browser.solve_challenge,参数:{"sessionId":"x","mode":"auto"}',
);
expect(t).not.toMatch(/16be7338|\{|sessionId/);
expect(t.startsWith("在浏览器会话上直接调用一次")).toBe(true);
});
it("去上下文前缀,超长截断", () => {
expect(shortGoalTitle("[上下文 作品:沙丘] 把沙丘:第二部的 4K 收进来。然后再看看别的")).toBe(
"把沙丘:第二部的 4K 收进来",
);
expect(shortGoalTitle("啊".repeat(60))).toBe(`${"啊".repeat(40)}…`);
expect(shortGoalTitle(" ")).toBe("未命名会话");
});
});
@@ -107,7 +107,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
const optionId = picked === NONE ? null : picked;
if (!picked) return;
if (optionId === null && !reason.trim()) {
toast.error("写一句不要的原因,Agent 按它调整");
toast.error("写一句不要的原因,智能体按它调整");
return;
}
choose.mutate(
@@ -115,7 +115,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
{
onSuccess: () => {
setSent({ optionId, reason: reason.trim() });
toast.success(optionId ? "已选定,Agent 继续执行" : "已退回,Agent 调整方案");
toast.success(optionId ? "已选定,智能体继续执行" : "已退回,智能体调整方案");
},
onError: (e) => toast.error(errorMessage(e)),
},
@@ -129,8 +129,8 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
: `都不要${resolved.reason ? `:${resolved.reason}` : ""}`
: sent
? sent.optionId
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待 Agent 继续`
: `都不要:${sent.reason},等待 Agent 调整`
? `已选择:${view.options.find((o) => o.id === sent.optionId)?.title ?? sent.optionId},等待智能体继续`
: `都不要:${sent.reason},等待智能体调整`
: !waiting
? "会话已不在等待这个选择"
: null;
@@ -232,7 +232,7 @@ export function OptionsCard({ runId, view, waiting }: { runId: string; view: Opt
</Button>
<span className="text-meta text-muted-foreground">
{formatDateTime(view.expiresAt)} 前没选,
{view.timeoutAction === "recommended" ? "自动选推荐项" : "Agent 停止"}。
{view.timeoutAction === "recommended" ? "自动选推荐项" : "智能体停止"}。
</span>
</div>
)}
@@ -285,7 +285,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
...(s === "rejected" && reason.trim() ? { reason: reason.trim() } : {}),
},
{
onSuccess: () => toast.success(s === "approved" ? "已批准,Agent 继续执行" : "已拒绝,Agent 按理由调整"),
onSuccess: () => toast.success(s === "approved" ? "已批准,智能体继续执行" : "已拒绝,智能体按理由调整"),
onError: (e) => toast.error(errorMessage(e)),
},
);
@@ -332,7 +332,7 @@ export function InlineApproval({ view, stepTitle }: { view: ApprovalView; stepTi
value={reason}
maxLength={2000}
aria-label="拒绝理由"
placeholder="拒绝时写理由或建议(可选),Agent 按它调整"
placeholder="拒绝时写理由或建议(可选),智能体按它调整"
onChange={(e) => setReason(e.target.value)}
/>
) : null}
@@ -25,6 +25,7 @@ import { PromptInput } from "~/components/agentui/prompt-input.tsx";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Label } from "~/components/ui/label.tsx";
import { Switch } from "~/components/ui/switch.tsx";
@@ -114,9 +115,8 @@ export function GoalComposer({
/>
<p className="px-1 text-meta text-muted-foreground" aria-live="polite">
{proposeFirst
? "Agent 先调查并给出方案,你选定后才执行。"
: "Agent 列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}{" "}
Enter 发送,Shift+Enter 换行。
? "智能体先调查并给出方案,你选定后才执行。"
: "智能体列出步骤后直接执行,需要审批的动作会停在对应步骤等你批准。"}
</p>
{suggestions?.length ? (
<div className="pt-2">
@@ -143,10 +143,6 @@ export function GoalComposer({
);
}
function shortTokens(n: number | undefined) {
return n === undefined ? null : n >= 1000 ? `${(n / 1000).toFixed(1)}k token` : `${n} token`;
}
/** 状态 + 来源 + 时间 + 模型 */
function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunStatus }) {
return (
@@ -163,7 +159,6 @@ function RunMeta({ run, status }: { run: RunSnapshot["run"]; status: AgentRunSta
</div>
<p className="text-meta break-words text-muted-foreground">
{TRIGGER_LABEL[run.trigger]} · {formatDateTime(run.createdAt)}
{run.model ? ` · ${run.provider ?? ""}/${run.model}` : ""}
</p>
</div>
);
@@ -194,8 +189,7 @@ function RunOverviewBar({
const current = steps.find((s) => s.status === "running") ?? steps.find((s) => s.status === "pending");
const failed = steps.some((s) => s.status === "failed");
const u = { ...r.usage, ...(live.usage ?? {}) };
const calls = Math.max(r.toolCallCount, live.items.filter((i) => i.kind === "tool").length);
const usage = [`${calls} 次调用`, shortTokens(u.totalTokens)].filter(Boolean).join(" · ");
const usage = u.costUsd ? `费用约 ${formatUsd(u.costUsd)}` : "";
return (
<section aria-label="会话概况" className={cn("shrink-0 rounded-xl border border-border/70", className)}>
<button
@@ -240,7 +234,7 @@ function RunOverviewBar({
<RunPlan plan={live.plan} active={active} fill />
<SubagentTree snapshot={snapshot} className="p-2.5" />
<div>
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
<p className="mb-2 text-meta text-muted-foreground">用量</p>
<BudgetBar run={r} live={live} />
</div>
</div>
@@ -400,7 +394,7 @@ export function AgentRunPanel({
viewportClassName="pr-1"
contentClassName={column}
busy={streaming}
label="Agent 会话记录"
label="智能体会话记录"
>
<AgentConversation
run={r}
@@ -462,8 +456,8 @@ export function AgentRunPanel({
<RunMeta run={r} status={shownStatus} />
<RunPlan plan={live.plan} active={streaming || r.status === "waiting"} fill />
<SubagentTree snapshot={run.data} className="p-2.5" />
<section aria-label="预算与用量" className="mt-auto rounded-xl border border-border/70 p-3">
<p className="mb-2 text-meta text-muted-foreground">预算与用量</p>
<section aria-label="用量" className="mt-auto rounded-xl border border-border/70 p-3">
<p className="mb-2 text-meta text-muted-foreground">用量</p>
<BudgetBar run={r} live={live} stacked />
</section>
</aside>
@@ -4,13 +4,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "re
import { Link } from "react-router";
import { type AgentRunView, RUN_STATUS_LABEL, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
// 会话历史列表:会话页左栏与抽屉「历史」Tab 共用。状态、来源在服务端筛选(游标翻页),
// 搜索与时间在已加载的条目里筛(按创建时间倒序,早于时间范围的页不再加载)。
@@ -57,16 +58,9 @@ export function matchesHistory(
return run.goal.toLowerCase().includes(q) || run.id.startsWith(q);
}
/** 列表副行:只写费用(token 与调用次数在会话的技术信息里) */
export function usageText(r: Pick<AgentRunView, "toolCallCount" | "usage">) {
const parts = [`${r.toolCallCount} 次调用`];
if (r.usage.totalTokens !== undefined)
parts.push(
r.usage.totalTokens >= 1000
? `${(r.usage.totalTokens / 1000).toFixed(1)}k token`
: `${r.usage.totalTokens} token`,
);
if (r.usage.costUsd !== undefined) parts.push(`$${r.usage.costUsd.toFixed(3)}`);
return parts.join(" · ");
return r.usage.costUsd ? `费用约 ${formatUsd(r.usage.costUsd)}` : "";
}
const ALL = "__all__";
@@ -115,7 +109,7 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
<>
<div className="flex min-w-0 items-center gap-2">
<p className="min-w-0 flex-1 truncate text-caption text-foreground" title={goal.text}>
{goal.text}
{shortGoalTitle(r.goal)}
</p>
{children}
</div>
@@ -130,10 +124,11 @@ function RunRow({ r, current, children }: { r: AgentRunView; current: boolean; c
{relTime(r.createdAt)}
</time>
</div>
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
{goal.context ? `${goal.context.split(" · ")[0]} · ` : ""}
{usageText(r)}
</p>
{goal.context || usageText(r) ? (
<p className="mt-0.5 truncate text-meta text-muted-foreground tabular-nums">
{[goal.context?.split(" · ")[0], usageText(r)].filter(Boolean).join(" · ")}
</p>
) : null}
</>
);
}
@@ -196,7 +191,7 @@ export function AgentSessionList({
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
return (
<nav aria-label="Agent 会话列表" className={cn("flex min-h-0 flex-col", className)}>
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5">
<Input
type="search"
@@ -1,3 +1,4 @@
import { CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
import { useSyncExternalStore } from "react";
// Agent 侧栏抽屉的全局开关与上下文:任意页面调用 openAgentDrawer({ entityId, ... }) 即可带着当前对象打开。
@@ -192,3 +193,29 @@ export function splitContextGoal(goal: string): { context: string | null; text:
const m = /^\[上下文 ([^\]]*)\]\s*/.exec(goal);
return m ? { context: m[1] ?? null, text: goal.slice(m[0].length) } : { context: null, text: goal };
}
const capabilityTitle = (id: string): string | null =>
(CAPABILITY_CATALOG as Record<string, { title?: string }>)[id as KnownCapabilityId]?.title ?? 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;
/**
* 会话标题:从目标生成短标题(DEAI-2)。去掉上下文前缀、UUID、JSON 参数,能力 ID 换成中文名,
* 取第一句,超过 40 字截断。完整目标仍在会话里第一条消息。
*/
export function shortGoalTitle(goal: string, max = 40): string {
let t = splitContextGoal(goal).text;
t = t.replace(/\s*[{[]\s*["{[][\s\S]*$/, ""); // JSON 参数到结尾
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.split(/[\n。!?!?]/)[0] ?? "";
t = t
.replace(/\s+/g, " ")
.replace(/(?<=[\p{Script=Han}(])\s+(?=[\p{Script=Han},。:;、)])/gu, "")
.replace(/[,,::;;、\s]*(?:参数|args?)?[::]?\s*$/i, "")
.trim();
if (!t) return "未命名会话";
return t.length > max ? `${t.slice(0, max)}…` : t;
}
@@ -5,7 +5,7 @@ import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { splitContextGoal } from "./agent-store.ts";
import { shortGoalTitle } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
@@ -70,7 +70,7 @@ function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<str
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
)}
<div className="min-w-0 flex-1">
<RunLink id={node.id}>{splitContextGoal(node.goal).text}</RunLink>
<RunLink id={node.id}>{shortGoalTitle(node.goal)}</RunLink>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
<RunStatusBadge status={node.status} />
<span className="text-meta text-muted-foreground tabular-nums">
@@ -116,7 +116,7 @@ export function SubagentTree({ snapshot, className }: { snapshot: RunSnapshot; c
<CornerLeftUp aria-hidden className="size-3.5" />
父会话
</p>
<RunLink id={parent.id}>{splitContextGoal(parent.goal).text}</RunLink>
<RunLink id={parent.id}>{shortGoalTitle(parent.goal)}</RunLink>
<p className="text-meta text-muted-foreground">
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
{budgetText(run.budget as Record<string, number | undefined>)}
+2 -2
View File
@@ -48,7 +48,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
for (const c of chunks(text)) push(60, { type: "text_delta", delta: c });
};
const steps: AgentPlanStep[] = [
{ id: "s1", title: "查本地库存和版本目标", status: "running", note: null },
{ id: "s1", title: "查本地库存和收录版本", status: "running", note: null },
{ id: "s2", title: "在站点搜 4K 候选", status: "pending", note: null },
{ id: "s3", title: "选资源并生成下载计划", status: "pending", note: null },
{ id: "s4", title: "提交下载", status: "pending", note: null },
@@ -63,7 +63,7 @@ function script(goal: string, opts: AgentSessionOptions): Step[] {
};
push(0, { type: "run_started", goal, resumed: false });
push(100, { type: "plan_updated", steps: steps.map((x) => ({ ...x })), revision: ++revision });
say("先确认本地库存与版本目标,再去站点找候选。");
say("先确认本地库存与收录版本,再去站点找候选。");
push(400, {
type: "tool_call_start",
toolCallId: "t1",
+3 -3
View File
@@ -42,15 +42,15 @@ const RULES: Rule[] = [
},
{
match: /^\/console\/identity/,
goals: ["识别待识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
goals: ["识别未识别的文件", "找出外部身份可能错误的作品", "补全缺少元数据的作品"],
},
{
match: /^\/console\/subscriptions/,
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的版本目标是否合理"],
goals: ["帮我新建一个订阅:", "哪些订阅很久没有新收录了", "检查订阅的收录版本是否合理"],
},
{
match: /^\/console\/sources/,
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 Feed 的抓取状态", "最近一天各来源抓到了什么"],
goals: ["哪些来源不新鲜了,原因是什么", "检查所有 RSS 的抓取状态", "最近一天各来源抓到了什么"],
},
{
match: /^\/console\/engine/,
+1 -1
View File
@@ -18,7 +18,7 @@ export interface MessageProps extends ComponentPropsWithRef<"article"> {
animateIn?: boolean;
}
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "Agent 消息", system: "系统消息" };
const FROM_LABEL: Record<MessageFrom, string> = { user: "用户消息", assistant: "智能体消息", system: "系统消息" };
export function Message({ from, animateIn = false, className, children, ...props }: MessageProps) {
return (
@@ -32,11 +32,11 @@ describe("原站视图坐标换算", () => {
describe("当前控制文案", () => {
const now = Date.parse("2026-09-29T10:00:00Z");
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「Agent」", () => {
it("自动化且没有最近动作 → 「自动化」;两分钟内有自动化动作 → 「智能体」", () => {
expect(controllerLabel({ controller: "automation", lastAutomationAt: null }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation" }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:50:00Z" }, now)).toBe("自动化");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe("Agent");
expect(controllerLabel({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe("智能体");
expect(agentActive({ controller: "automation", lastAutomationAt: "2026-09-29T09:59:10Z" }, now)).toBe(true);
});
it("人工接管 / 等待处理不看自动化时间", () => {
+2 -2
View File
@@ -41,7 +41,7 @@ export function controllerLabel(
s: { controller: BrowserController; lastAutomationAt?: string | null },
now = Date.now(),
): string {
return agentActive(s, now) ? "Agent" : CONTROLLER_LABEL[s.controller];
return agentActive(s, now) ? "智能体" : CONTROLLER_LABEL[s.controller];
}
const WAITING_TEXT: Record<string, string> = {
@@ -51,7 +51,7 @@ const WAITING_TEXT: Record<string, string> = {
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "接管超时未交还",
agent_request: "Agent 请求人工处理",
agent_request: "智能体请求人工处理",
human_released_waiting: "交还时选择继续等待",
};
@@ -229,7 +229,7 @@ function DesktopView({
control.release.mutate(s.id, {
onSuccess: () => {
refreshRole();
toast.success("已交还,Agent 会重新读取页面");
toast.success("已交还,智能体会重新读取页面");
},
onError: (e) => toast.error(errorMessage(e)),
})
@@ -276,8 +276,8 @@ function DesktopView({
{mine
? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。"
: agentActive(s)
? "只读观看,Agent 正在操作。需要自己点或输入时先接管,Agent 会暂停。"
: "只读观看,目前没有 Agent 在操作。需要自己点或输入时先接管。"}
? "只读观看,智能体正在操作。需要自己点或输入时先接管,智能体会暂停。"
: "只读观看,目前没有智能体在操作。需要自己点或输入时先接管。"}
</p>
<div className="pg-slab relative min-w-0 overflow-hidden rounded-xl">
@@ -174,7 +174,7 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
disabled={busy}
onClick={() =>
control.release.mutate(s.id, {
onSuccess: () => toast.success("已交还,Agent 会重新读取页面"),
onSuccess: () => toast.success("已交还,智能体会重新读取页面"),
onError: (e) => toast.error(errorMessage(e)),
})
}
@@ -7,7 +7,7 @@ import { AGENT_LAUNCHER_ATTR } from "~/components/agent/agent-drawer.tsx";
import {
agentShortcutLabel,
isAgentShortcut,
splitContextGoal,
shortGoalTitle,
toggleAgentDrawer,
useAgentDrawer,
} from "~/components/agent/agent-store.ts";
@@ -24,7 +24,7 @@ export function useActiveAgentRuns() {
const latest = active[0];
return {
count: active.length,
summary: latest ? `${RUN_STATUS_LABEL[latest.status]} · ${splitContextGoal(latest.goal).text}` : null,
summary: latest ? `${RUN_STATUS_LABEL[latest.status]} · ${shortGoalTitle(latest.goal)}` : null,
};
}, [runs.data]);
}
@@ -128,7 +128,7 @@ function useHideOnScroll(): boolean {
/**
* 右下角悬浮按钮 + 全局快捷键。挂在 console layout 里一次。
* - Agent 会话页本身不显示(页面就是 Agent);最小化小窗在右下角时让位给小窗。
* - 抽屉打开时变成"收起",停在抽屉左侧(窄屏抽屉占满全宽,用抽屉自带的关闭钮,FAB 隐藏)。
* - 只在没有侧栏的窄屏显示(xl 起侧栏顶部就是入口);抽屉打开时隐藏,用抽屉自带的关闭钮。
*/
export function AgentLauncher() {
useAgentHotkey();
@@ -145,7 +145,7 @@ export function AgentLauncher() {
const onAgentPage = /^\/console\/agent(\/|$)/.test(pathname);
if (!host || (onAgentPage && !open) || (minimized && runId && !open)) return null;
const label = open ? "收起 Agent" : count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent";
const label = open ? "收起智能体" : count ? `打开智能体(${count} 个会话进行中)` : "打开智能体";
return createPortal(
<button
type="button"
@@ -154,14 +154,15 @@ export function AgentLauncher() {
aria-label={label}
aria-keyshortcuts="Control+J Meta+J"
aria-expanded={open}
title={`${open ? "收起" : "打开"} Agent(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
title={`${open ? "收起" : "打开"}智能体(${shortcut})${summary && !open ? `\n${summary}` : ""}`}
style={{ bottom: `calc(${BASE_BOTTOM + lift}px + env(safe-area-inset-bottom))` }}
className={cn(
"pg-float prism-ring group pointer-events-auto fixed right-4 z-40 flex h-14 min-w-14 cursor-pointer items-center justify-center gap-3 rounded-full text-foreground outline-none select-none",
"transition-[transform,bottom,right] duration-layout ease-spring active:scale-[0.96] focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none",
"animate-in fade-in-0 zoom-in-95 duration-base motion-reduce:animate-none",
"md:px-2 md:pr-5",
open && "z-[60] hidden md:right-[calc(36rem+1rem)] md:flex",
// 桌面(xl 起)侧栏已有入口,抽屉打开时用抽屉自己的关闭钮:这两种情况都不显示悬浮按钮
open ? "hidden" : "xl:hidden",
!open && scrolledAway && "translate-x-[calc(100%+2rem)] focus-visible:translate-x-0",
)}
>
@@ -171,7 +172,7 @@ export function AgentLauncher() {
</span>
<span className="hidden min-w-0 flex-col items-start text-left md:flex">
<span className="flex items-center gap-2 text-control leading-5 font-semibold">
{open ? "收起" : "Agent"}
{open ? "收起" : "智能体"}
{count && !open ? <PulseDot /> : null}
{count && !open ? <CountBadge count={count} /> : null}
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium text-muted-foreground">
@@ -179,7 +180,7 @@ export function AgentLauncher() {
</kbd>
</span>
<span className="max-w-56 truncate text-meta leading-4 text-muted-foreground">
{open ? "Esc 关闭" : (summary ?? "交代目标,边做边汇报")}
{open ? "Esc 关闭" : (summary ?? "")}
</span>
</span>
</button>,
@@ -196,7 +197,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
onNavigate?.();
toggleAgentDrawer(pathname);
};
const title = `Agent(${shortcut})${summary ? `\n${summary}` : ""}`;
const title = `智能体(${shortcut})${summary ? `\n${summary}` : ""}`;
if (collapsed) {
return (
@@ -205,7 +206,7 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
{...{ [AGENT_LAUNCHER_ATTR]: "" }}
onClick={activate}
title={title}
aria-label={count ? `打开 Agent(${count} 个会话进行中)` : "打开 Agent"}
aria-label={count ? `打开智能体(${count} 个会话进行中)` : "打开智能体"}
aria-keyshortcuts="Control+J Meta+J"
className="prism-ring relative mb-2 grid h-10 w-10 shrink-0 cursor-pointer place-items-center rounded-xl text-foreground outline-none transition-transform duration-micro active:scale-[0.95] focus-visible:ring-2 focus-visible:ring-ring"
>
@@ -228,12 +229,10 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2 text-sm font-semibold">
Agent
智能体
{count ? <PulseDot /> : null}
</span>
<span className="truncate text-meta text-muted-foreground">
{count ? `${count} 个会话进行中` : "交代目标,边做边汇报"}
</span>
{count ? <span className="truncate text-meta text-muted-foreground">{count} 个会话进行中</span> : null}
</span>
{count ? (
<CountBadge count={count} />
@@ -250,8 +249,8 @@ export function AgentNavEntry({ collapsed = false, onNavigate }: { collapsed?: b
export function AgentShortcutsHelp() {
const shortcut = agentShortcutLabel();
const rows: [string, string][] = [
[shortcut, "打开 / 收起 Agent,附带当前页面"],
["Esc", "关闭 Agent"],
[shortcut, "打开 / 收起智能体,附带当前页面"],
["Esc", "关闭智能体"],
["Enter", "发送目标;Shift+Enter 换行"],
];
return (
@@ -6,15 +6,15 @@ import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./dat
type Row = { id: string; status: string; name: string };
const rows: Row[] = [
{ id: "1", status: "待识别", name: "a.mkv" },
{ id: "1", status: "未识别", name: "a.mkv" },
{ id: "2", status: "已登记", name: "b.mkv" },
{ id: "3", status: "待识别", name: "c.mkv" },
{ id: "3", status: "未识别", name: "c.mkv" },
];
describe("DataTable groupBy:分组逻辑", () => {
it("按首次出现顺序分组,组内保持原顺序", () => {
expect(groupRows(rows, (r) => r.status).map((g) => [g.key, g.rows.map((r) => r.id)])).toEqual([
["待识别", ["1", "3"]],
["未识别", ["1", "3"]],
["已登记", ["2"]],
]);
});
@@ -24,7 +24,7 @@ describe("DataTable groupBy:分组逻辑", () => {
(r) => r.status,
(a, b) => a.localeCompare(b, "zh-CN"),
);
expect(g.map((x) => x.key)).toEqual(["待识别", "已登记"].sort((a, b) => a.localeCompare(b, "zh-CN")));
expect(g.map((x) => x.key)).toEqual(["未识别", "已登记"].sort((a, b) => a.localeCompare(b, "zh-CN")));
});
it("折叠切换返回新集合", () => {
const a = new Set<string>();
@@ -72,8 +72,8 @@ describe("DataTable groupBy:渲染", () => {
}),
);
expect(html.match(/aria-expanded="true"/g)).toHaveLength(2);
expect(html).toContain("待识别");
expect(html).toMatch(/待识别<\/span><span[^>]*>2<\/span>/);
expect(html).toContain("未识别");
expect(html).toMatch(/未识别<\/span><span[^>]*>2<\/span>/);
expect(html).toMatch(/已登记<\/span><span[^>]*>1<\/span>/);
for (const n of ["a.mkv", "b.mkv", "c.mkv"]) expect(html).toContain(n);
});
+4 -4
View File
@@ -19,10 +19,10 @@ describe("控制台导航", () => {
it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => {
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]);
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
["首页", "任务", "计划", "Agent 会话"],
["站点", "RSS 订阅"],
["首页", "任务", "计划", "智能体会话"],
["站点", "RSS"],
["订阅", "规则"],
["下载面板", "整理记录", "文件管理", "媒体概览"],
["下载面板", "整理记录", "文件管理", "媒体库"],
["设置", "审计", "运行状态"],
]);
// IA-3 逐项清单 14 项 + FE-6 计划页
@@ -44,7 +44,7 @@ describe("控制台导航", () => {
it("按最长前缀匹配当前项", () => {
expect(findNavItem("/console")?.label).toBe("首页");
expect(findNavItem("/console/")?.label).toBe("首页");
expect(findNavItem("/console/library/abc")?.label).toBe("媒体概览");
expect(findNavItem("/console/library/abc")?.label).toBe("媒体库");
expect(findNavItem("/console/tasks/r1")?.label).toBe("任务");
expect(findNavItem("/console/schedules")?.label).toBe("计划");
expect(findNavItem("/console/downloads")?.label).toBe("下载面板");
+13 -13
View File
@@ -46,25 +46,25 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console",
label: "首页",
icon: House,
description: "正在进行、需要处理、计划、最近结果与运行状态",
description: "在忙什么、要我处理什么、最近入库了什么",
},
{
to: "/console/tasks",
label: "任务",
icon: ListTodo,
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里",
description: "最近处理了什么、有没有失败",
},
{
to: "/console/schedules",
label: "计划",
icon: CalendarCog,
description: "定时任务的开关、间隔和下次运行:自动任务与 Agent 巡检",
description: "智能体巡检与自动任务的开关和间隔",
},
{
to: "/console/agent",
label: "Agent 会话",
label: "智能体会话",
icon: Bot,
description: "交代目标,Agent 在授权内执行并汇报",
description: "给智能体一个目标,看它做了什么",
},
],
},
@@ -75,9 +75,9 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/sites",
label: "站点",
icon: Globe,
description: "站点、凭证、账号数据与健康;资源、快照与 Feed",
description: "站点账号、资源与 RSS",
},
{ to: "/console/sources", label: "RSS 订阅", icon: Rss, description: "全部 Feed 与抓取状态" },
{ to: "/console/sources", label: "RSS", icon: Rss, description: "全部 RSS 与抓取状态" },
],
},
{
@@ -90,7 +90,7 @@ export const NAV_GROUPS: NavGroup[] = [
{
title: "整理",
items: [
{ to: "/console/downloads", label: "下载面板", icon: Download, description: "下载任务、下载计划与缓存水位" },
{ to: "/console/downloads", label: "下载面板", icon: Download, description: "下载任务、下载计划与下载盘" },
{
to: "/console/organize",
label: "整理记录",
@@ -101,13 +101,13 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/files",
label: "文件管理",
icon: FolderTree,
description: "浏览登记目录,改名、移动、删除与整理",
description: "浏览下载目录和媒体库,改名、移动、删除与整理",
},
{
to: "/console/library",
label: "媒体概览",
label: "媒体库",
icon: Library,
description: "作品、季 / 集、版本目标、外部身份与待识别资源",
description: "作品、季与集、未识别资源",
},
],
},
@@ -118,7 +118,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/settings",
label: "设置",
icon: Settings,
description: "下载器、媒体服务器、库根、站点账号、Agent 上游与自动化",
description: "下载器、媒体服务器、媒体库目录、站点账号、模型服务与自动化",
},
{
to: "/console/audit",
@@ -130,7 +130,7 @@ export const NAV_GROUPS: NavGroup[] = [
to: "/console/status",
label: "运行状态",
icon: Gauge,
description: "进程心跳、事件循环、数据库、磁盘与挂载根",
description: "服务是否正常、磁盘与数据库",
},
],
},
@@ -0,0 +1,53 @@
import { ChevronDown } from "lucide-react";
import { type ReactNode, useId, useState } from "react";
import { CHEVRON_CLASS, Disclosure } from "~/components/agentui/disclosure.tsx";
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
import { useUiPrefs } from "~/lib/ui-prefs.ts";
import { cn } from "~/lib/utils.ts";
/**
* 技术信息折叠区:ID、hash、trace、token 数、耗时、模型 ID、原始字段、内部状态码都放这里,默认收起
* (设置 → 界面可改为默认展开)。值为空的项不显示;全部为空时整块不渲染。
*/
export function TechInfo({
items,
children,
title = "技术信息",
className,
columns = 2,
}: {
items?: (KvItem | null | false | undefined)[];
children?: ReactNode;
title?: string;
className?: string;
columns?: 1 | 2;
}) {
const prefs = useUiPrefs();
const [override, setOverride] = useState<boolean | null>(null);
const open = override ?? prefs.techInfoExpanded;
const id = useId();
const rows = (items ?? []).filter(
(it): it is KvItem => !!it && it.value !== null && it.value !== undefined && it.value !== "",
);
if (!rows.length && !children) return null;
return (
<div className={cn("min-w-0", className)} data-slot="tech-info">
<button
type="button"
aria-expanded={open}
aria-controls={id}
onClick={() => setOverride(!open)}
className="inline-flex items-center gap-1 rounded-md py-1 text-meta text-muted-foreground transition-colors duration-micro hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
>
{title}
<ChevronDown className={cn("size-3.5", CHEVRON_CLASS, open && "rotate-180")} aria-hidden />
</button>
<Disclosure id={id} open={open}>
<div className="grid gap-3 pt-2 pb-1">
{rows.length ? <KvList items={rows.map((r) => ({ mono: true, ...r }))} columns={columns} /> : null}
{children}
</div>
</Disclosure>
</div>
);
}
@@ -30,10 +30,10 @@ export const TASK_KIND_LABEL: Record<TaskKind, string> = {
submitDownload: "提交下载",
verifyAndImport: "核验入库",
refreshMetadata: "刷新元数据",
reconcileLibrary: "资产对账",
reconcileLibrary: "文件核对",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待处理事项",
agentRun: "Agent 运行",
agentRun: "智能体运行",
};
export const TASK_STATUS: Record<
@@ -54,15 +54,15 @@ export const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
export const SUBJECT_LABEL: Record<string, string> = {
download_task: "下载任务",
acquisition_plan: "下载计划",
acquisition_slot: "版本目标",
acquisition_slot: "收录版本",
organization_record: "整理记录",
organize_request: "整理请求",
media_entity: "作品",
media_asset: "资产",
media_asset: "文件",
source_release: "资源",
feed_source: "Feed",
feed_source: "RSS",
site: "站点",
library_root: "库根",
library_root: "媒体库目录",
cache_volume: "缓存卷",
};
@@ -9,11 +9,11 @@ import { Timeline } from "~/components/console/timeline.tsx";
// 阶段计数与首页/侧栏同口径;每条事件都有文字说明,不只靠颜色。
export const STAGE_LABEL: Record<TimelineStage, string> = {
observation: "观察",
observation: "发现",
plan: "计划",
download: "下载",
organization: "整理",
agent: "Agent",
agent: "智能体",
engine: "任务",
};
@@ -26,12 +26,11 @@ export function BusinessTimeline({ events, loading }: { events: TimelineEvent[];
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-1.5">
{ORDER.map((s) => {
const n = counts.get(s) ?? 0;
{ORDER.filter((s) => counts.get(s)).map((s) => {
const bad = events.some((e) => e.stage === s && e.tone === "destructive");
return (
<StatusBadge key={s} tone={bad ? "destructive" : n ? "success" : "neutral"}>
{STAGE_LABEL[s]} {n ? `· ${n}` : "· 无"}
<StatusBadge key={s} tone={bad ? "destructive" : "neutral"}>
{STAGE_LABEL[s]} · {counts.get(s)}
</StatusBadge>
);
})}
@@ -8,6 +8,7 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
@@ -37,10 +38,11 @@ function VolumeBar({ v }: { v: Volume }) {
<span className="truncate">{volumeLabel(v)}</span>
</span>
<span className="text-meta text-muted-foreground tabular-nums">
{pct === null ? "使用率未知" : `${pct.toFixed(1)}%`}
{v.usedBytes !== null && v.totalBytes !== null
? ` · ${formatBytes(v.usedBytes, 1)} / ${formatBytes(v.totalBytes, 1)}`
: ""}
? `已用 ${formatBytes(v.usedBytes, 0)} / ${formatBytes(v.totalBytes, 1)}`
: pct === null
? "用量未知"
: `已用 ${Math.round(pct)}%`}
</span>
</div>
<div className="relative mt-1.5 h-1.5 rounded-full bg-foreground/[0.08]">
@@ -57,28 +59,32 @@ function VolumeBar({ v }: { v: Volume }) {
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
title={`硬阈值 ${v.hardThresholdPercent}%`}
title={`暂停线 ${v.hardThresholdPercent}%`}
/>
<span
aria-hidden
className="absolute -top-1 h-3.5 w-px bg-muted-foreground"
style={{ left: `${v.resumePercent}%` }}
title={`恢复水位 ${v.resumePercent}%`}
title={`恢复线 ${v.resumePercent}%`}
/>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-meta text-muted-foreground">
<span>
硬阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}%
</span>
<span>在途 {formatBytes(v.pendingBytes, 1)}</span>
<span>预留 {formatBytes(v.reservedBytes, 1)}</span>
<span>可清理 {formatBytes(v.cleanableBytes, 1)}</span>
<span>保留锁定 {formatBytes(v.retentionLockedBytes, 1)}</span>
</div>
<TechInfo
title="详情"
className="mt-1"
items={[
{ label: "暂停线 / 恢复线", value: `${v.hardThresholdPercent}% / ${v.resumePercent}%`, mono: false },
{ label: "下载中还要占用", value: formatBytes(v.pendingBytes, 1), mono: false },
{ label: "已预留", value: formatBytes(v.reservedBytes, 1), mono: false },
{ label: "可清理", value: formatBytes(v.cleanableBytes, 1), mono: false },
{ label: "做种保留中", value: formatBytes(v.retentionLockedBytes, 1), mono: false },
]}
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{pct === null ? <StatusBadge tone="warning">读不到磁盘状态,已暂停新提交</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">已达硬阈值,停止新提交</StatusBadge> : null}
{v.pausedByThreshold ? <StatusBadge tone="warning">阈值暂停 {v.pausedByThreshold} 个任务</StatusBadge> : null}
{tripped ? <StatusBadge tone="destructive">超过暂停线,停止新下载</StatusBadge> : null}
{v.pausedByThreshold ? (
<StatusBadge tone="warning">因下载盘暂停 {v.pausedByThreshold} 个任务</StatusBadge>
) : null}
</div>
{v.notes.map((n) => (
<p key={n} className="mt-1 text-meta text-muted-foreground">
@@ -97,7 +103,7 @@ export function CacheVolumes() {
return (
<section className="pg-slab mb-6 rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">缓存水位</h2>
<h2 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">下载盘</h2>
<div className="flex flex-wrap gap-2">
<Button asChild variant="ghost" size="sm">
<Link to={settingsPath("automation", "cache")}>
@@ -113,7 +119,7 @@ export function CacheVolumes() {
</Button>
}
title="按规则清理下载缓存?"
description="只清理已入库、且满足保留期与 H&R 的纳管任务,其余不动。"
description="只清理已入库、且满足保留期与 H&R 的本工具任务,其余不动。"
confirmLabel="清理"
onConfirm={() =>
cleanup.mutateAsync(undefined).then(
@@ -1,6 +1,7 @@
import type { DownloadTask } from "@umt/contracts";
import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import {
DOWNLOAD_STATUS_LABEL,
@@ -17,11 +18,13 @@ import { DetailSection } from "~/components/console/detail-sheet.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 { OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
import { Button } from "~/components/ui/button.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { planReasonText } from "~/lib/labels.ts";
import { humanizeReason, reasonLine } from "~/lib/reasons.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { BusinessTimeline } from "./business-timeline.tsx";
@@ -47,12 +50,11 @@ export function DownloadStatusBadges({ t }: { t: DownloadTask }) {
<span className="flex flex-wrap gap-1">
<StatusBadge tone={DOWNLOAD_STATUS_TONE[t.status]}>{DOWNLOAD_STATUS_LABEL[t.status]}</StatusBadge>
{t.pauseReason === "threshold" ? (
<StatusBadge tone="warning">阈值暂停</StatusBadge>
<StatusBadge tone="warning">下载盘满,已暂停</StatusBadge>
) : t.pauseReason ? (
<StatusBadge>{PAUSE_REASON_LABEL[t.pauseReason]}</StatusBadge>
) : null}
{!t.managed ? <StatusBadge>未纳管</StatusBadge> : null}
{t.origin === "adopted" ? <StatusBadge>接管</StatusBadge> : null}
{!t.managed ? <StatusBadge>外部任务</StatusBadge> : null}
</span>
);
}
@@ -69,14 +71,14 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" |
variant="outline"
size={size}
disabled={act.isPending}
title={threshold ? "阈值暂停,缓存回落到恢复水位后自动恢复" : undefined}
title={threshold ? "下载盘超过暂停线,降到恢复线后自动恢复" : undefined}
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
act.mutate(
{ id: t.id, action: canResume ? "resume" : "pause" },
{
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。阈值回落时不会自动恢复"),
onSuccess: () => toast.success(canResume ? "已恢复" : "已暂停。下载盘回落时不会自动恢复"),
onError: (err) => toast.error(errorMessage(err)),
},
);
@@ -95,6 +97,11 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
const trace = useDownloadTrace(task, plan.data);
const [organizing, setOrganizing] = useState(false);
const canOrganize = ["completed", "importing", "imported"].includes(task.status);
// 下载完成但最近一次整理失败:状态行只写"下载完成"会误导,单独说明入库失败
const lastRecord = [...(trace.records.data?.items ?? [])].sort((a, b) =>
(b.startedAt ?? b.createdAt).localeCompare(a.startedAt ?? a.createdAt),
)[0];
const importFailed = task.status === "completed" && lastRecord?.status === "failed" ? lastRecord : null;
return (
<div className="space-y-5">
@@ -123,7 +130,8 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
})
}
>
<Bot />问 Agent
<Bot />
问智能体
</Button>
</div>
</div>
@@ -131,7 +139,15 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{task.pauseReason === "threshold" ? (
<p className="flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
缓存卷达到硬阈值,任务已暂停,水位回落到恢复线后自动恢复。手动恢复和 Agent 都不能绕过阈值。
下载盘超过暂停线,任务已暂停;降到恢复线以下后自动恢复。
</p>
) : null}
{importFailed ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
入库失败:{reasonLine(importFailed.reason)}。
<Link to="/console/inbox" className="ml-1 underline underline-offset-4">
去待处理
</Link>
</p>
) : null}
{task.noProgressSince ? (
@@ -150,17 +166,26 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
{ label: "最近进展", value: relTime(task.lastProgressAt) },
{ label: "最近同步", value: relTime(task.lastSyncedAt) },
{ label: "标签", value: task.tags.join(", ") },
{ label: "分类", value: task.category },
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
{ label: "内容路径", value: task.contentPath, mono: true, wide: true },
{ label: "torrent hash", value: task.torrentHash, mono: true, wide: true },
]}
/>
<TechInfo
className="mt-3"
items={[
{ label: "最近同步", value: relTime(task.lastSyncedAt), mono: false },
{ label: "标签", value: task.tags.join(", ") },
{ label: "分类", value: task.category },
{ label: "下载器状态", value: task.rawState },
{ label: "内容路径", value: task.contentPath, wide: true },
{ label: "torrent hash", value: task.torrentHash, wide: true },
{ label: "任务 ID", value: task.id, wide: true },
]}
>
{task.lastError ? <JsonView label="下载器错误原文" value={task.lastError} tone="destructive" /> : null}
</TechInfo>
</DetailSection>
<DetailSection title="业务时间线">
<DetailSection title="经过">
<BusinessTimeline events={trace.events} loading={trace.loading} />
</DetailSection>
@@ -174,7 +199,9 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
{ label: "说明", value: planReasonText(plan.data.statusReason), wide: true },
]}
/>
<JsonView className="mt-2" label="评估依据" value={plan.data.evaluation} />
<TechInfo className="mt-2">
<JsonView label="评估依据" value={plan.data.evaluation} />
</TechInfo>
</DetailSection>
) : null}
@@ -194,7 +221,12 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
</DetailSection>
) : null}
{task.lastError ? <JsonView label="下载器错误" value={task.lastError} tone="destructive" defaultOpen /> : null}
{task.lastError ? (
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-caption text-destructive">
下载器报错:
{humanizeReason(String((task.lastError as { message?: unknown }).message ?? "")) || "原文见技术信息"}
</p>
) : null}
{organizing ? (
<OrganizeWizard
+15 -10
View File
@@ -186,7 +186,12 @@ export function MkdirDialog({
const SUGGEST_REASON: Record<string, string> = {
identity_uncertain: "识别不可信,没有推荐名",
};
const TEMPLATE_SOURCE: Record<string, string> = { entity: "作品覆盖", root: "库根", global: "全局", override: "覆写" };
const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "媒体库目录",
global: "全局",
override: "覆写",
};
function suggestReasonText(r: string | null): string {
if (!r) return "没有推荐名";
@@ -434,7 +439,7 @@ export function RenameDialog({
))}
</ul>
{pv.assetLocations > 0 ? (
<Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
<Notice tone="neutral">同步 {pv.assetLocations} 条文件位置,写一条整理记录。</Notice>
) : null}
{pv.bindings.length > 0 ? (
<Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
@@ -582,7 +587,7 @@ export function TransferDialog({
}}
className="max-h-72 md:max-h-96"
/>
<p className="text-meta text-muted-foreground">只能选可写库根下的目录。</p>
<p className="text-meta text-muted-foreground">只能选可写媒体库目录下的目录。</p>
</div>
<div className="min-w-0 space-y-3">
{target && targetRoot ? (
@@ -616,7 +621,7 @@ export function TransferDialog({
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
) : null}
{plan.pairs.some((p) => p.registered) ? (
<StatusBadge>含已登记资产,执行后同步位置</StatusBadge>
<StatusBadge>含已入库文件,执行后同步位置</StatusBadge>
) : null}
</div>
{hnrBlocked ? (
@@ -777,7 +782,7 @@ export function DeleteDialog({
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
<DialogDescription>已检查做种任务、已登记资产和路径绑定。下载器里的任务不受影响。</DialogDescription>
<DialogDescription>已检查做种任务、已入库文件和路径绑定。下载器里的任务不受影响。</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-3 overflow-y-auto">
{check.isPending ? (
@@ -824,7 +829,7 @@ export function DeleteDialog({
</p>
))}
{c.assets.length ? (
<p>已登记资产 {c.assets.length} 个(删除后退役,已满足的版本目标退回待处理)</p>
<p>已入库文件 {c.assets.length} 个(删除后从媒体库移除,已满足的收录版本退回待处理)</p>
) : null}
{c.bindings.map((bd) => (
<p key={bd.bindingId}>
@@ -843,9 +848,9 @@ export function DeleteDialog({
<label className="flex items-start gap-2.5 text-caption">
<input type="checkbox" className="mt-1" checked={retire} onChange={(e) => setRetire(e.target.checked)} />
<span>
同时退役资产
同时从媒体库移除
<span className="block text-meta text-muted-foreground">
资产位置标为已移除,资产退役;已满足的版本目标退回待处理。
这些文件从媒体库记录中移除;已满足的收录版本退回待处理。
</span>
</span>
</label>
@@ -1042,9 +1047,9 @@ export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[];
<p key={s.ref} className="text-meta text-warning">
{s.ref}:
{s.reason === "not_registered"
? "没有已登记资产"
? "没有已入库文件"
: s.reason === "no_entity"
? "不属于任何已登记作品"
? "不属于媒体库里的作品"
: s.reason === "not_found"
? "作品不存在"
: s.reason}
+1 -1
View File
@@ -29,7 +29,7 @@ export function DirPicker({
onSelect={onChange}
/>
) : (
<p className="px-2 py-3 text-caption text-muted-foreground">没有可写的库根。在设置里把库根设为纳管后再选。</p>
<p className="px-2 py-3 text-caption text-muted-foreground">没有可写入的媒体库目录。先在设置里添加。</p>
)}
</div>
);
+3 -9
View File
@@ -2,7 +2,7 @@ import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, Tr
import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { healthLabel } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { cn } from "~/lib/utils.ts";
import { groupRoots, joinRel, segments } from "./model.ts";
type Loc = { root: string; path: string };
@@ -140,19 +140,13 @@ function RootNode({
{h.tone !== "neutral" ? <TriangleAlert className="h-3 w-3" /> : null}
{h.label}
</span>
) : (
<span className="inline-flex items-center gap-1 text-success">
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
正常
</span>
)}
) : null}
{root.pendingCount ? (
<span className="shrink-0 text-warning">
待识别 {root.pendingCount}
未识别 {root.pendingCount}
{root.pendingPartial ? "+" : ""}
</span>
) : null}
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable && root.kind === "library" ? <span>只读</span> : null}
</span>
</span>
@@ -78,7 +78,7 @@ function RecordsSection({ entityId }: { entityId: string }) {
);
}
/** 选中单项的详情抽屉:文件信息、作品/版本目标/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
export function FileDetail({
root,
entry,
@@ -123,7 +123,7 @@ export function FileDetail({
value:
e.type === "dir"
? e.dirSummary
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已登记`
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已入库`
: "—"
: formatBytes(e.size, 2),
},
@@ -148,7 +148,7 @@ export function FileDetail({
</DetailSection>
{e.asset ? (
<DetailSection title="已登记资产">
<DetailSection title="已入库文件">
<KvList
items={[
{
@@ -165,16 +165,16 @@ export function FileDetail({
},
{ label: "季集", value: e.asset.contentUnit ?? "整部" },
{
label: "版本目标",
label: "收录版本",
value: e.asset.slotId ? (
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
{e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
</Link>
) : (
"未满足任何版本目标"
"未满足任何收录版本"
),
},
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
{ label: "主文件", value: e.asset.isPrimary ? "是" : "否" },
]}
/>
</DetailSection>
+1 -1
View File
@@ -707,7 +707,7 @@ function doMove(body: {
const tRoot = rootOf(body.target.root);
const target = find(body.target.root, body.target.path).node;
if (!target.dir || !tRoot.writable)
throw new ApiRequestError("bad_request", "not_writable:目标必须是可写库根下的目录");
throw new ApiRequestError("bad_request", "not_writable:目标必须是可写媒体库目录下的目录");
const pairs = body.items.map((it) => {
const { node } = find(it.root, it.path);
const name = node.name;
+2 -2
View File
@@ -89,7 +89,7 @@ const base = {
describe("状态与禁用原因", () => {
it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => {
expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已登记");
expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已入库");
expect(
entryStatusView({
...base,
@@ -228,7 +228,7 @@ describe("v2 菜单与副行", () => {
type: "dir" as const,
dirSummary: { videoCount: 13, registeredCount: 0, partial: false },
})?.text,
).toBe("13 视频 · 0 已登记");
).toBe("13 个视频 · 未入库");
// 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析
expect(entrySubline({ ...video, parsedHint: null })).toBeNull();
});
+13 -10
View File
@@ -19,20 +19,20 @@ export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "esca
const s: FileStatus = e.status;
switch (s) {
case "temp":
return { label: "临时文件", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
return { label: "未完成", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
case "downloading":
return {
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
tone: "neutral",
};
case "registered":
return { label: "已登记", tone: "success" };
return { label: "已入库", tone: "success" };
case "seeding":
return e.download?.hnr === "unsatisfied"
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
: { label: "做种中", tone: "neutral" };
case "unidentified":
return { label: "待识别", tone: "warning" };
return { label: "未识别", tone: "warning" };
default:
return null;
}
@@ -41,10 +41,10 @@ export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "esca
export const FILTER_CHIPS = [
{ value: "", label: "全部" },
{ value: "video", label: "视频" },
{ value: "unidentified", label: "待识别" },
{ value: "registered", label: "已登记" },
{ value: "unidentified", label: "未识别" },
{ value: "registered", label: "已入库" },
{ value: "downloading", label: "下载中" },
{ value: "temp", label: "临时文件" },
{ value: "temp", label: "未完成下载" },
] as const;
export const SORT_OPTIONS = [
@@ -81,7 +81,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
case "rename":
if (!rootWritable) return "只读目录,不能改名";
if (seeding) return "做种中,H&R 满足前不能改名";
if (e.status === "temp") return "临时文件不能改名";
if (e.status === "temp") return "未完成的文件不能改名";
return null;
case "move":
if (!rootWritable) return "只读目录,不能移出。可以复制";
@@ -93,7 +93,7 @@ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot,
return null;
case "organize":
case "identify":
if (e.status === "temp") return "临时文件不能整理";
if (e.status === "temp") return "未完成的文件不能整理";
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null;
case "scrape":
@@ -309,7 +309,7 @@ export function deleteBlockText(b: string | null): { text: string; soft: boolean
case "is_root":
return { text: "不能删除登记目录本身", soft: false };
case "needs_retire":
return { text: "含已登记资产,勾选「同时退役资产」后才能删除", soft: true };
return { text: "含已入库文件,勾选「同时从媒体库移除」后才能删除", soft: true };
case "needs_unbind":
return { text: "是作品或季的绑定目录,勾选「解除路径绑定」后才能删除", soft: true };
case "root_unavailable":
@@ -449,7 +449,10 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
if (e.type === "dir" && e.dirSummary) {
const s = e.dirSummary;
if (!s.videoCount && !s.registeredCount) return null;
return { text: `${s.videoCount}${s.partial ? "+" : ""} 视频 · ${s.registeredCount} 已登记`, tone: "meta" };
return {
text: `${s.videoCount}${s.partial ? "+" : ""} 个视频${s.registeredCount ? ` · ${s.registeredCount} 个已入库` : s.videoCount ? " · 未入库" : ""}`,
tone: "meta",
};
}
if (e.parsedHint) return { text: e.parsedHint, tone: "hint" };
return null;
@@ -383,7 +383,7 @@ export function MergeDialog({
<DialogHeader>
<DialogTitle>合并作品</DialogTitle>
<DialogDescription>
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、资产和历史记录一并转过去。合并错了可以再修订。
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品,订阅、文件和历史记录一并转过去。合并错了可以再修订。
</DialogDescription>
</DialogHeader>
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
@@ -392,7 +392,7 @@ export function MergeDialog({
id="merge-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="重复建档"
placeholder="重复添加"
/>
</FormField>
<DialogFooter>
@@ -68,7 +68,7 @@ function FieldRow({ k, s, enrichmentId }: { k: EnrichFieldName; s: Suggestion; e
{k !== "posterUrl" ? <p className="text-sm break-words text-foreground">{String(v.value)}</p> : null}
<div className="flex flex-wrap items-center gap-1.5">
<Chip>{EVIDENCE_LEVEL[v.evidenceLevel] ?? v.evidenceLevel}</Chip>
{v.verbatim ? null : <Chip>Agent 归纳</Chip>}
{v.verbatim ? null : <Chip>智能体归纳</Chip>}
<SourceLink url={v.sourceUrl} />
</div>
</div>
@@ -142,7 +142,7 @@ export function EnrichSection({ item }: { item: IdentityPoolItem }) {
{out.notice}
{" · "}
{out.cached ? "7 天内的缓存结果" : `搜索 ${out.usage.searchCalls} 次,读取网页 ${out.usage.fetchCalls} 次`}
{s ? ` · ${s.extractor === "model" ? `Agent 抽取${s.model ? `(${s.model})` : ""}` : "规则抽取"}` : ""}
{s ? ` · ${s.extractor === "model" ? `智能体抽取${s.model ? `(${s.model})` : ""}` : "规则抽取"}` : ""}
{` · 今日 ${out.budget.usedToday}/${out.budget.dailyLimit}`}
</p>
{out.error ? <p className="text-caption text-warning">{out.error}</p> : null}
@@ -23,7 +23,7 @@ const ACTION: Record<IdentityRevision["action"], string> = {
locked: "加人工锁",
unlocked: "解除人工锁",
};
const ACTOR: Record<string, string> = { user: "用户", agent: "Agent", system: "系统", api_key: "API Key" };
const ACTOR: Record<string, string> = { user: "用户", agent: "智能体", system: "系统", api_key: "API Key" };
export const identityRef = (i: Pick<ExternalIdentity, "provider" | "entityType" | "externalId">) =>
`${i.provider}:${i.entityType}:${i.externalId}`;
@@ -76,7 +76,7 @@ function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-meta text-muted-foreground">本地未建档</span>
<span className="text-meta text-muted-foreground">不在媒体库</span>
)}
</div>
{c.evidence.length ? (
@@ -148,7 +148,7 @@ export function PoolTab() {
selectedKey={openId}
empty={{
icon: SearchX,
title: filtered ? "没有符合条件的待识别资源" : "没有待识别资源",
title: filtered ? "没有符合条件的未识别资源" : "没有未识别资源",
description: "未命中订阅、证据不足或冲突的资源会出现在这里;新增相近别名时自动重试,也可手动指定。",
}}
hasMore={pool.hasNextPage}
@@ -249,7 +249,7 @@ export function ExternalTab() {
empty={{
icon: Fingerprint,
title: "没有外部身份",
description: "没有外部身份的作品也能建档入库。",
description: "没有外部编号的作品也能入库。",
}}
hasMore={list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
+11 -11
View File
@@ -34,7 +34,7 @@ export const MEDIA_KIND: Record<MediaKind, string> = {
};
export const ENTITY_STATUS: Record<MediaEntityStatus, ToneLabel> = {
provisional: { label: "临时建档", tone: "warning" },
provisional: { label: "临时作品", tone: "warning" },
active: { label: "正常", tone: "success" },
merged: { label: "已合并", tone: "neutral" },
archived: { label: "已归档", tone: "neutral" },
@@ -132,11 +132,11 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
};
export const DECISION_KIND: Record<DecisionKind, string> = {
identity_ambiguous: "身份歧义",
identity_unresolved: "待识别",
identity_ambiguous: "没识别出作品",
identity_unresolved: "未识别",
download_stalled: "下载无进展",
download_failed: "下载失败",
path_conflict: "路径冲突",
path_conflict: "整理冲突",
space_insufficient: "空间不足",
approval_required: "需要审批",
metadata_conflict: "元数据冲突",
@@ -146,7 +146,7 @@ export const DECISION_KIND: Record<DecisionKind, string> = {
export const DECISION_STATUS: Record<DecisionStatus, ToneLabel> = {
open: { label: "待处理", tone: "warning" },
investigating: { label: "Agent 调查中", tone: "neutral" },
investigating: { label: "智能体调查中", tone: "neutral" },
awaiting_user: { label: "等你决定", tone: "warning" },
resolved: { label: "已处理", tone: "success" },
dismissed: { label: "已忽略", tone: "neutral" },
@@ -168,7 +168,7 @@ export const POOL_REASON: Record<IdentityCandidateReason, ToneLabel> = {
};
export const POOL_STATUS: Record<string, ToneLabel> = {
pending: { label: "待识别", tone: "warning" },
pending: { label: "未识别", tone: "warning" },
resolved: { label: "已识别", tone: "success" },
dismissed: { label: "已忽略", tone: "neutral" },
archived: { label: "已归档", tone: "neutral" },
@@ -178,13 +178,13 @@ export const PREF_KIND: Record<PreferenceKind, { label: string; hint: string }>
soft: { label: "软偏好", hint: "影响排序,不排除" },
hard: { label: "硬规则", hint: "命中即排除" },
version: { label: "版本偏好", hint: "同一作品内选哪种版本" },
target: { label: "版本目标", hint: "为作品新建一个版本目标" },
target: { label: "收录版本", hint: "为作品新建一个收录版本" },
};
export const PREF_ORIGIN: Record<PreferenceOrigin, string> = {
manual: "手动",
conversation: "对话确认",
agent_inferred: "Agent 推断",
agent_inferred: "智能体推断",
};
export const PREF_STATUS: Record<PreferenceStatus, ToneLabel> = {
@@ -219,12 +219,12 @@ export const SUB_STATUS: Record<SubscriptionStatus, ToneLabel> = {
completed: { label: "已完成", tone: "neutral" },
cancelled: { label: "已取消", tone: "neutral" },
};
export const SUB_ORIGIN: Record<string, string> = { user: "用户", agent: "Agent", policy: "策略", import: "导入" };
export const SUB_ORIGIN: Record<string, string> = { user: "用户", agent: "智能体", policy: "策略", import: "导入" };
export const METADATA_SOURCE: Record<string, string> = {
tmdb: "TMDB",
user: "人工",
agent: "Agent",
agent: "智能体",
nfo: "NFO",
site: "站点",
import: "导入",
@@ -316,7 +316,7 @@ export const opts = <K extends string>(rec: Record<K, string | { label: string }
return { value, label: typeof v === "string" ? v : v.label };
});
/** 版本目标 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
/** 收录版本 / 候选的原因串(如 `blocked:no_main_video`、`deferred:slot_busy;inflight:<id>`)→ 中文;不认识的原样显示 */
const SLOT_REASON: Record<string, string> = {
"blocked:no_main_video": "没有可入库的主视频",
"deferred:slot_busy": "已有计划在途,等它结束再评估",
@@ -108,7 +108,7 @@ export function PreferenceFormDialog({
<DialogTitle>{tag ? "编辑偏好" : "添加偏好"}</DialogTitle>
<DialogDescription>
{tag
? "修改作用范围或规则会留下旧语义的抑制记录,Agent 不会按旧证据加回。"
? "修改作用范围或规则会留下旧语义的抑制记录,智能体不会按旧证据加回。"
: "手动添加等同对话确认。之前取消过的偏好,重新添加会解除抑制。"}
</DialogDescription>
</DialogHeader>
@@ -39,7 +39,7 @@ export function SlotCard({
{slot.requireDistinctAsset ? (
<Chip>
<Link2 className="h-3 w-3" />
需不同物理资产{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
需独立文件{slot.distinctGroup ? ` · ${slot.distinctGroup}` : ""}
</Chip>
) : null}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">r{slot.revision}</span>
@@ -72,7 +72,7 @@ export function SlotCard({
<dd className="text-caption text-foreground">{summarizeCompletion(slot.completion)}</dd>
</div>
<div className="min-w-0 sm:col-span-2">
<dt className="mb-1 text-meta text-muted-foreground">当前资产</dt>
<dt className="mb-1 text-meta text-muted-foreground">当前文件</dt>
<dd className="text-caption break-words text-foreground">{currentAsset ?? "无"}</dd>
</div>
</dl>
@@ -353,8 +353,8 @@ function RulesSummary({ rules, valid }: { rules: AcquisitionPolicyRules | undefi
return (
<div className="space-y-2 text-caption">
<div>
<span className="text-muted-foreground">默认版本目标 </span>
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有版本目标时不自动创建)" />
<span className="text-muted-foreground">默认收录版本 </span>
<ChipList items={(rules.defaultSlots ?? []).map(tplLine)} empty="无(订阅没有收录版本时不自动创建)" />
</div>
<div>
<span className="text-muted-foreground">新作品收录 </span>
@@ -718,7 +718,7 @@ function PolicyEditorSheet({
</DetailSection>
<DetailSection
title="默认版本目标模板"
title="默认收录版本模板"
actions={
<Button
variant="ghost"
@@ -730,7 +730,7 @@ function PolicyEditorSheet({
</Button>
}
>
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有版本目标时,按这些模板创建。</p>
<p className="mb-2 text-meta text-muted-foreground">订阅的作品没有收录版本时,按这些模板创建。</p>
<TemplateList list={d.defaultSlots} onChange={(v) => set("defaultSlots", v)} errors={errors} />
</DetailSection>
@@ -768,7 +768,7 @@ function PolicyEditorSheet({
</FormField>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">新作品的版本目标模板</p>
<p className="text-meta text-muted-foreground">新作品的收录版本模板</p>
<Button
variant="ghost"
size="xs"
@@ -51,10 +51,10 @@ export function SlotsPanel({
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">每个版本目标单独判定和升级。这里只改规则。</p>
<p className="text-meta text-muted-foreground">每个收录版本单独判定和升级。这里只改规则。</p>
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
<Plus />
新建版本目标
新建收录版本
</Button>
</div>
{q.isPending ? (
@@ -65,7 +65,7 @@ export function SlotsPanel({
<EmptyState
plain
icon={Target}
title="还没有版本目标"
title="还没有收录版本"
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
className="py-8"
/>
@@ -140,7 +140,7 @@ function SlotEditorSheet({
const errs: Record<string, string> = {};
if (!meta.success) for (const i of meta.error.issues) errs[String(i.path[0])] ??= i.message;
if (!rules.ok) Object.assign(errs, rules.errors);
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "要求不同物理资产时须填写分组";
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "要求独立文件时须填写分组";
setErrors(errs);
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
const common = {
@@ -178,7 +178,7 @@ function SlotEditorSheet({
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"}
title={slot ? `编辑收录版本 ${slot.name}` : "新建收录版本"}
description={
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
}
@@ -225,10 +225,10 @@ function SlotEditorSheet({
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">要求不同物理资产</p>
<p className="text-caption font-medium text-foreground">要求独立文件</p>
<p className="text-meta text-muted-foreground">同一个文件不能同时满足两个目标</p>
</div>
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
<Switch checked={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求独立文件" />
</div>
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
</DetailSheet>
@@ -0,0 +1,157 @@
import type { SearchWork } from "@umt/contracts";
import { Check, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearch } from "~/api/search.ts";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { cn } from "~/lib/utils.ts";
import { MEDIA_KIND } from "./labels.ts";
/** 选中的作品:本地作品给 mediaEntityId,只在 TMDB 上的给 tmdb */
export type AssignedWork =
| { mediaEntityId: string; title: string }
| { tmdb: { type: "movie" | "tv"; id: string }; title: string };
const workTitle = (w: SearchWork) => `${w.title}${w.year ? ` (${w.year})` : ""}`;
/**
* 「指定作品」:搜本地媒体库与 TMDB(复用全局搜索 /search?scope=media),选一个交给 onAssign。
* onAssign 返回 Promise:执行中按钮禁用,resolve 后关闭,reject 时保持打开(错误由调用方提示)。
*/
export function WorkAssignDialog({
open,
onOpenChange,
initialQuery = "",
subject,
onAssign,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
initialQuery?: string;
/** 在说明里点名要指定的是什么(文件名 / 资源标题) */
subject?: string | null;
onAssign: (w: AssignedWork) => Promise<unknown>;
}) {
const [q, setQ] = useState(initialQuery);
const [debounced, setDebounced] = useState(initialQuery);
const [picked, setPicked] = useState<SearchWork | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
const t = setTimeout(() => setDebounced(q), 300);
return () => clearTimeout(t);
}, [q]);
useEffect(() => {
if (open) {
setQ(initialQuery);
setDebounced(initialQuery);
setPicked(null);
}
}, [open, initialQuery]);
const search = useSearch(debounced, "media", 12, open);
const works = search.data?.media?.works ?? [];
const tmdbOff = search.data?.media && !search.data.media.tmdb.configured;
const confirm = async () => {
if (!picked) return;
const target: AssignedWork | null = picked.localEntityId
? { mediaEntityId: picked.localEntityId, title: workTitle(picked) }
: picked.tmdb
? { tmdb: { type: picked.tmdb.entityType, id: picked.tmdb.externalId }, title: workTitle(picked) }
: null;
if (!target) return;
setPending(true);
try {
await onAssign(target);
onOpenChange(false);
} catch {
// 调用方已提示,保持打开
} finally {
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => (pending ? undefined : onOpenChange(o))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>指定作品</DialogTitle>
<DialogDescription className="break-all">
{subject ? `「${subject}」属于哪部作品?` : "选这个资源属于哪部作品。"}
选中后加人工锁,自动识别不再改它。
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-2">
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => {
setQ(e.target.value);
setPicked(null);
}}
placeholder="作品名,中英文都行"
className="pl-9"
aria-label="搜索作品"
/>
</div>
{debounced.trim() ? (
<div className="max-h-72 overflow-y-auto rounded-lg border border-border">
{search.isPending ? (
<LoadingState rows={3} className="p-2" />
) : search.isError ? (
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
) : works.length === 0 ? (
<p className="p-3 text-meta text-muted-foreground">
没有找到{tmdbOff ? "本地作品;TMDB 未配置,只搜了媒体库" : "匹配的作品"}
</p>
) : (
<ul>
{works.map((w) => {
const active = picked?.key === w.key;
return (
<li key={w.key}>
<button
type="button"
onClick={() => setPicked(w)}
aria-pressed={active}
className={cn(
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
active && "bg-foreground/[0.07]",
)}
>
{active ? <Check className="h-4 w-4 shrink-0 text-success" /> : null}
<span className="min-w-0 flex-1 truncate text-foreground">{workTitle(w)}</span>
<span className="shrink-0 text-meta text-muted-foreground">
{MEDIA_KIND[w.kind]} · {w.localEntityId ? "在媒体库" : "TMDB"}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
取消
</Button>
<Button onClick={() => void confirm()} disabled={!picked || pending}>
{pending ? "处理中…" : "指定"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NamingKind, NamingOverride } from "@umt/contracts";
// 命名模板表单的纯函数:保存错误拆分、覆盖请求体、来源文案。
export const NAMING_KINDS: NamingKind[] = ["movie", "tv", "variety"];
export const SOURCE_LABEL = { global: "全局", root: "库根", entity: "作品" } as const;
export const SOURCE_LABEL = { global: "全局", root: "媒体库目录", entity: "作品" } as const;
/** 422 消息 "movie: 1:3 …; tv: …" → 按类型拆开,行列号改写成中文 */
export function splitNamingErrors(message: string): Partial<Record<NamingKind, string>> | null {
@@ -444,7 +444,6 @@ function VariablePalette({
>
<span className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-caption text-foreground">{v.name}</span>
{v.ours ? <span className="text-micro text-muted-foreground">MP 无</span> : null}
{v.example ? (
<span className="ml-auto max-w-full truncate font-mono text-meta text-muted-foreground">
{v.example}
@@ -358,7 +358,7 @@ export const FILE_KIND: Record<string, string> = {
export const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "库根",
root: "媒体库目录",
global: "全局",
override: "保留原文件名",
};
@@ -194,11 +194,11 @@ function SlotSelect({
const items = slots.data?.items ?? [];
return (
<select
aria-label="版本目标"
aria-label="收录版本"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={!id}
title={entity && !id ? "新建的作品还没有版本目标" : undefined}
title={entity && !id ? "新建的作品还没有收录版本" : undefined}
className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
>
<option value="">自动</option>
@@ -368,7 +368,7 @@ export function StepFiles(p: StepFilesProps) {
<th className="w-[15rem] px-2 py-2 font-medium">作品</th>
<th className="w-16 px-2 py-2 font-medium">季</th>
<th className="w-24 px-2 py-2 font-medium">集</th>
<th className="w-40 px-2 py-2 font-medium">版本目标</th>
<th className="w-40 px-2 py-2 font-medium">收录版本</th>
<th className="w-20 px-2 py-2">
<span className="sr-only">操作</span>
</th>
@@ -144,7 +144,7 @@ export function StepOptions({
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
hint="已绑定的作品沿用原目录,否则按作品类型选媒体库目录"
/>
{libRoots.map((r) => (
<Choice
@@ -186,7 +186,7 @@ export function StepOptions({
title="按命名模板"
hint={
<>
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
模板按 作品覆盖 → 媒体库目录 → 全局 取第一个,预览里显示实际来源。
<Link to="/console/settings#library/naming" className="ml-1 underline underline-offset-2">
去设置
</Link>
@@ -35,7 +35,7 @@ const ERROR_TEXT: Record<string, string> = {
/** 服务端原因码(message 以 code: 开头)→ 文案 */
const REASON_PREFIX: Record<string, string> = {
library_root_offline: "目标库根离线",
library_root_offline: "目标媒体库目录离线",
root_unavailable: "目录离线",
not_writable: "目标不可写",
path_escape: "路径越界",
@@ -187,7 +187,7 @@ export function RuleGroupEditor({
</Button>
}
title={`删除「${group.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
description={`${SUPPRESS_NOTE}被收录版本或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
@@ -403,7 +403,7 @@ function Revisions({ id }: { id: string }) {
<span className="font-medium text-foreground">{REV_ACTION[action] ?? action}</span>
{r.revision ? <span className="text-muted-foreground tabular-nums">r{r.revision}</span> : null}
<span className="text-muted-foreground">
{r.actorType === "agent" ? "Agent" : r.actorType === "api_key" ? "API Key" : "用户"}
{r.actorType === "agent" ? "智能体" : r.actorType === "api_key" ? "API Key" : "用户"}
</span>
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{formatDateTime(r.at)}</span>
</div>
+12 -12
View File
@@ -22,7 +22,7 @@ function useToggle() {
toggle: async (g: RuleGroup) => {
try {
await update.mutateAsync({ id: g.id, enabled: !g.enabled, expectedRevision: g.revision });
toast.success(g.enabled ? "已停用,Agent 不会自动加回" : "已启用");
toast.success(g.enabled ? "已停用,智能体不会自动加回" : "已启用");
} catch (err) {
toast.error(errorMessage(err));
}
@@ -36,9 +36,9 @@ function RefsCell({ g, refs }: { g: RuleGroup; refs: RuleRefs | undefined }) {
const parts: string[] = [];
if (g.defaultOrder !== null) parts.push("默认链");
if (subs.length) parts.push(`${subs.length} 个订阅`);
if (slots.length) parts.push(`${slots.length} 个版本目标`);
if (slots.length) parts.push(`${slots.length} 个收录版本`);
if (!parts.length) return <span className="text-meta text-muted-foreground">{refs ? "未引用" : "—"}</span>;
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `版本目标:${s.name}`)].join("\n");
const title = [...subs.map((s) => `订阅:${s.title}`), ...slots.map((s) => `收录版本:${s.name}`)].join("\n");
return (
<span className="text-meta text-foreground" title={title || undefined}>
{parts.join(" · ")}
@@ -101,11 +101,11 @@ export function RuleGroupTable({
},
{ key: "refs", header: "引用", cell: (g) => <RefsCell g={g} refs={refs} />, hideBelow: "lg" },
{
key: "rev",
header: "修订",
key: "updated",
header: "最后修改",
cell: (g) => (
<span className="text-meta text-muted-foreground tabular-nums" title={`更新于 ${relTime(g.updatedAt)}`}>
r{g.revision}
<span className="text-meta whitespace-nowrap text-muted-foreground" title={`r${g.revision}`}>
{relTime(g.updatedAt)}
</span>
),
hideBelow: "md",
@@ -140,7 +140,7 @@ export function RuleGroupTable({
empty={{
icon: ListChecks,
title: filtered ? "没有符合条件的规则组" : "还没有规则组",
description: filtered ? undefined : "新建一个规则组,或在 Agent 会话里说明长期偏好。",
description: filtered ? undefined : "新建一个规则组,或在智能体会话里说明长期偏好。",
action: filtered ? undefined : (
<Button size="sm" onClick={onCreate}>
新建规则组
@@ -214,13 +214,13 @@ function RuleTag({ g, onOpen }: { g: RuleGroup; onOpen: () => void }) {
</Button>
}
title={`删除「${g.name}」?`}
description={`${SUPPRESS_NOTE}被版本目标或订阅引用时不能删除。`}
description={`${SUPPRESS_NOTE}被收录版本或订阅引用时不能删除。`}
confirmLabel="删除"
destructive
onConfirm={async () => {
try {
await del.mutateAsync(g.id);
toast.success("已删除,Agent 不会自动加回");
toast.success("已删除,智能体不会自动加回");
} catch (err) {
toast.error(errorMessage(err, "删除失败"));
throw err;
@@ -237,7 +237,7 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
return (
<Panel
title="偏好"
description="单条规则,多由对话或 Agent 提出。停用或删除后 Agent 不会自动加回。"
description="单条规则,多由对话或智能体提出。停用或删除后智能体不会自动加回。"
actions={<span className="text-meta text-muted-foreground tabular-nums">{rows.length} 条</span>}
>
{rows.length === 0 ? (
@@ -245,7 +245,7 @@ export function RuleTags({ rows, onOpen }: { rows: RuleGroup[]; onOpen: (g: Rule
plain
icon={Tags}
title="还没有偏好"
description="在 Agent 会话里说明长期偏好,会出现在这里。"
description="在智能体会话里说明长期偏好,会出现在这里。"
className="py-6"
/>
) : (
+5 -5
View File
@@ -5,17 +5,17 @@ import { exprToString } from "./expr.ts";
// 规则页文案:类型 / 来源 / 作用范围 / 默认链 / 抑制原因
export const RULE_KIND: Record<RuleGroupKind, { label: string; hint: string }> = {
acquisition: { label: "订阅", hint: "订阅与版本目标挑资源时用" },
upgrade: { label: "洗版", hint: "版本目标开了升级时额外叠加" },
search: { label: "搜索", hint: "手动搜索与 Agent 搜索资源时用" },
acquisition: { label: "订阅", hint: "订阅与收录版本挑资源时用" },
upgrade: { label: "洗版", hint: "收录版本开了升级时额外叠加" },
search: { label: "搜索", hint: "手动搜索与智能体搜索资源时用" },
};
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,Agent 不会自动加回;重新启用或手动新建同样的规则后解除。";
export const SUPPRESS_NOTE = "停用或删除会留下抑制记录,智能体不会自动加回;重新启用或手动新建同样的规则后解除。";
export const RULE_SOURCE: Record<RuleGroupSource, string> = {
manual: "手动",
chat: "对话",
agent: "Agent",
agent: "智能体",
};
export const DEFAULT_CHAIN_LABEL: Record<RuleGroupKind, string> = {
+16 -11
View File
@@ -339,21 +339,26 @@ export function AtomsTab() {
onRowClick={(a) => setDialog({ open: true, atom: a })}
empty={{ icon: FlaskConical, title: "还没有自定义条件", description: "内置关键字覆盖不到时再新建。" }}
/>
<Panel title="内置关键字" description="对齐 MoviePilot;判定依据按解析字段优先">
<Panel title="内置关键字">
{keywords.isPending ? (
<LoadingState rows={3} />
) : keywords.isError ? (
<ErrorState plain error={keywords.error} onRetry={() => keywords.refetch()} />
) : (
<ul className="grid gap-x-6 gap-y-2 sm:grid-cols-2">
{builtins.map((k) => (
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
<span className="min-w-0 text-muted-foreground">
{k.supported ? k.description || k.name : "不支持,规则串里出现会报错"}
</span>
{builtins
.filter((k) => k.supported)
.map((k) => (
<li key={k.id} className="flex min-w-0 items-baseline gap-2 text-caption">
<span className="w-16 shrink-0 font-mono font-medium text-foreground">{k.id}</span>
<span className="min-w-0 text-muted-foreground">{k.description || k.name}</span>
</li>
))}
{builtins.some((k) => !k.supported) ? (
<li className="text-meta text-muted-foreground sm:col-span-2">
另有 {builtins.filter((k) => !k.supported).length} 个关键字暂不支持,写进规则串会报错
</li>
))}
) : null}
</ul>
)}
</Panel>
@@ -385,7 +390,7 @@ export function DefaultsTab({ groups }: { groups: RuleGroup[] | undefined }) {
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-caption text-muted-foreground">
订阅和版本目标没指定规则组时用这里的链。链上的组按顺序串联,任一组拒绝即拒绝,优先级取最后一个带层的组。
订阅和收录版本没指定规则组时用这里的链。按顺序检查,任一组不通过就不下载。
</p>
<div className="flex items-center gap-2">
{dirty ? <StatusBadge tone="warning">未保存</StatusBadge> : null}
@@ -514,7 +519,7 @@ export function SuppressionsTab() {
return (
<Panel title="抑制记录" description={sups.data ? `${active} 条生效中` : undefined}>
<p className="mb-3 text-caption text-muted-foreground">
停用、删除,或改掉对话 / Agent 提出的规则,都会留下抑制记录。Agent
停用、删除,或改掉对话 / 智能体提出的规则,都会留下抑制记录。智能体
再提出同样的规则会被直接拒绝,解除后才能再提。
</p>
{sups.isPending ? (
@@ -551,7 +556,7 @@ export function SuppressionsTab() {
</Button>
}
title="解除这条抑制?"
description="解除后 Agent 可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
description="解除后智能体可以再次提出这条规则,仍需审批。规则组本身不会恢复。"
confirmLabel="解除"
onConfirm={async () => {
try {
@@ -7,6 +7,7 @@ import { useDeleteSchedule } from "~/api/schedules.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.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 { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -16,7 +17,8 @@ import { NextRun, useJobActions } from "./auto-task-list.tsx";
import { IntervalSelect } from "./interval-select.tsx";
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
// 计划页「Agent 巡检」:到点按目标模板发起一次 Agent 会话(kind=agent_check),一个巡检一张卡片。
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
/** 卡片上能力白名单最多列几个,其余折成"+N" */
const MAX_CAPS = 6;
@@ -113,103 +115,31 @@ export function AgentCheckCard({
/>
</header>
<div className="rounded-tight border border-border/70 px-2.5 py-2">
<p className="line-clamp-3 text-caption text-foreground" title={c.goalTemplate}>
{c.goalTemplate ?? "—"}
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-meta text-muted-foreground">
<IntervalSelect
label={`${job.name}间隔`}
schedule={job.schedule}
disabled={!job.available}
pending={a.update.isPending}
onSave={a.setSchedule}
/>
<span className="inline-flex items-center gap-1">
下次 <NextRun job={job} now={now} />
</span>
</div>
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
<Field label="能力">
{allow.length ? (
<span className="flex flex-wrap gap-1">
{allow.slice(0, MAX_CAPS).map((id) => (
<span
key={id}
title={id}
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
>
{capTitle(id)}
</span>
))}
{allow.length > MAX_CAPS ? (
<span
className="px-1 text-meta text-muted-foreground"
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
>
+{allow.length - MAX_CAPS}
</span>
) : null}
</span>
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
{last ? (
job.lastStatus === "ok" ? (
<span>上次成功</span>
) : (
"全部只读能力"
)}
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="上游">
{stats?.lastModel ? (
<span className="break-words" title={modelsTitle(stats.models)}>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
) : null}
</span>
) : upstream ? (
<span
className="break-words"
title={
upstream.modelFromCatalog
? "上游没有指定默认模型,运行时使用模型目录里的第一个"
: "还没有运行记录,按默认上游显示"
}
>
默认上游 {upstream.name} · {upstream.model ?? "模型未指定"}
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
</span>
) : (
<span className="text-muted-foreground">未配置 Agent 上游</span>
)}
</Field>
<Field label="触发条件">
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
</Field>
<Field label="间隔">
<IntervalSelect
label={`${job.name}间隔`}
schedule={job.schedule}
disabled={!job.available}
pending={a.update.isPending}
onSave={a.setSchedule}
/>
</Field>
<Field label="下次">
<span className="text-meta">
<NextRun job={job} now={now} />
</span>
</Field>
<Field label="每次费用">
{stats?.count ? (
<span className="tabular-nums">
上次 {formatUsd(stats.lastCostUsd)}
<span className="text-muted-foreground">
{" "}
· 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</span>
</span>
) : (
<span className="text-muted-foreground">还没有会话</span>
)}
</Field>
<Field label="最近结果">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
</span>
</Field>
</dl>
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
)
) : (
<span>还没运行过</span>
)}
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
{stats?.count ? <span className="tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
</p>
{job.lastStatus === "failed" && job.lastError ? (
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
@@ -218,33 +148,96 @@ export function AgentCheckCard({
</details>
) : null}
{runs.length ? (
<section aria-label="最近会话" className="grid min-w-0 gap-1 border-t border-border/70 pt-2.5">
<h4 className="text-meta text-muted-foreground">最近会话</h4>
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
{runs.slice(0, 3).map((r) => {
const s = agentRunLabel(r.status);
return (
<li key={r.id} className="min-w-0">
<Link
to={consolePath("agent", r.id)}
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
<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>
</Field>
<Field label="能力">
{allow.length ? (
<span className="flex flex-wrap gap-1">
{allow.slice(0, MAX_CAPS).map((id) => (
<span
key={id}
title={id}
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
{plainSummary(r.summary ?? r.goal)}
</span>
<span className="shrink-0 tabular-nums">{formatUsd(r.costUsd)}</span>
<span className="shrink-0" title={r.createdAt}>
{formatRelative(r.finishedAt ?? r.createdAt)}
</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
{capTitle(id)}
</span>
))}
{allow.length > MAX_CAPS ? (
<span
className="px-1 text-meta text-muted-foreground"
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
>
+{allow.length - MAX_CAPS}
</span>
) : null}
</span>
) : (
"全部只读能力"
)}
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
</Field>
<Field label="触发条件">
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="模型">
{stats?.lastModel ? (
<span className="break-words" title={modelsTitle(stats.models)}>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
) : null}
</span>
) : upstream ? (
<span className="break-words">
{upstream.name} · {upstream.model ?? "模型未指定"}
</span>
) : (
<span className="text-muted-foreground">未配置模型服务</span>
)}
</Field>
{stats?.count ? (
<Field label="费用">
<span className="tabular-nums">
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</span>
</Field>
) : null}
</dl>
{runs.length ? (
<section aria-label="最近会话" className="grid min-w-0 gap-1">
<h4 className="text-meta text-muted-foreground">最近会话</h4>
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
{runs.slice(0, 3).map((r) => {
const st = agentRunLabel(r.status);
return (
<li key={r.id} className="min-w-0">
<Link
to={consolePath("agent", r.id)}
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
>
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
{plainSummary(r.summary ?? r.goal)}
</span>
<span className="shrink-0" title={r.createdAt}>
{formatRelative(r.finishedAt ?? r.createdAt)}
</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
</TechInfo>
<footer className="flex flex-wrap items-center gap-1.5">
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
@@ -283,7 +276,7 @@ export function AgentCheckCard({
</Button>
}
title={`删除「${job.name}」?`}
description="运行记录一并删除,已产生的 Agent 会话保留。"
description="运行记录一并删除,已产生的智能体会话保留。"
confirmLabel="删除巡检"
destructive
onConfirm={() =>
@@ -142,7 +142,7 @@ export function AgentCheckDialog({
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>{job ? "编辑巡检" : "新建巡检"}</DialogTitle>
<DialogDescription>到点按目标发起一次 Agent 会话,只在白名单和预算内执行。</DialogDescription>
<DialogDescription>到点按目标发起一次智能体会话,只在白名单和预算内执行。</DialogDescription>
</DialogHeader>
<form
className="grid gap-1"
@@ -1,6 +1,6 @@
import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts";
import { History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
import { useEffect, useRef } from "react";
import { ChevronRight, History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
@@ -9,11 +9,12 @@ import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { readCollapseState, writeCollapseState } from "~/lib/ui-prefs.ts";
import { cn } from "~/lib/utils.ts";
import { IntervalSelect } from "./interval-select.tsx";
import { countdown, formatMs, GROUP_LABEL, RUN_STATUS, scheduleStreamPath, TASK_GROUPS } from "./labels.ts";
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组的紧凑列表。
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组折叠的紧凑列表;维护类一组叫「数据清理」。
/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */
export function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
@@ -121,9 +122,22 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
/>
</div>
<div className="col-start-1 row-start-3 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto">
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
{job.lastDurationMs !== null ? <span className="tabular-nums">{formatMs(job.lastDurationMs)}</span> : null}
{last ? (
job.lastStatus === "ok" ? (
<span>成功</span>
) : (
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
)
) : (
<span>未运行</span>
)}
{job.lastRunAt ? (
<span
title={`${job.lastRunAt}${job.lastDurationMs !== null ? ` · 用时 ${formatMs(job.lastDurationMs)}` : ""}`}
>
{formatRelative(job.lastRunAt)}
</span>
) : null}
</div>
<div className="col-start-2 row-start-3 flex min-w-0 items-center justify-end gap-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto lg:justify-start">
<span className="shrink-0 lg:hidden">下次</span>
@@ -178,6 +192,25 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
);
}
const COLLAPSE_KEY = "umt.schedules.groups";
/** 组摘要:"6 个任务 · 下次 3 分后 · 1 个失败" */
function groupSummary(list: ScheduledJob[], now: number): string {
const next = list
.filter((j) => j.enabled && j.available && j.nextRunAt)
.map((j) => j.nextRunAt as string)
.sort()[0];
const failed = list.filter((j) => j.lastStatus === "failed").length;
const running = list.filter((j) => j.runningSince).length;
return [
`${list.length} 个任务`,
running ? `${running} 个运行中` : next ? `下次 ${countdown(next, now)}` : null,
failed ? `${failed} 个失败` : null,
]
.filter(Boolean)
.join(" · ");
}
export function AutoTaskList({
jobs,
now,
@@ -189,37 +222,73 @@ export function AutoTaskList({
}) {
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
for (const j of jobs) {
// 未接入调度的占位任务(如备份)不列
if (!j.available && !j.enabled && j.group === "maintain") continue;
const list = byGroup.get(j.group);
if (list) list.push(j);
else byGroup.set(j.group, [j]);
}
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length);
// 折叠:有失败 / 运行中 / 被高亮的组默认展开,其余收起;点过的按用户的来并记在本机
const [saved, setSaved] = useState<Record<string, boolean>>(() => readCollapseState(COLLAPSE_KEY) ?? {});
const expanded = (g: ScheduleGroup, list: ScheduledJob[]) =>
list.some((j) => j.id === highlightJob) ||
(saved[g] ?? list.some((j) => j.lastStatus === "failed" || !!j.runningSince));
const toggle = (g: ScheduleGroup, v: boolean) =>
setSaved((cur) => {
const next = { ...cur, [g]: v };
writeCollapseState(COLLAPSE_KEY, next);
return next;
});
const anyOpen = groups.some((g) => expanded(g, byGroup.get(g) ?? []));
return (
<div className="pg-slab overflow-hidden rounded-xl">
<div
aria-hidden
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
>
<span>名称</span>
<span>开关</span>
<span>间隔</span>
<span>上次结果</span>
<span>下次运行</span>
<span className="text-right">操作</span>
</div>
{anyOpen ? (
<div
aria-hidden
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
>
<span>名称</span>
<span>开关</span>
<span>间隔</span>
<span>上次结果</span>
<span>下次运行</span>
<span className="text-right">操作</span>
</div>
) : null}
{groups.map((g) => {
const list = byGroup.get(g) ?? [];
const open = expanded(g, list);
const failed = list.some((j) => j.lastStatus === "failed");
return (
<section key={g} aria-label={GROUP_LABEL[g]}>
<h3 className="flex items-baseline gap-2 border-b border-border/70 bg-foreground/[0.03] px-4 py-1.5 text-meta font-medium text-muted-foreground">
{GROUP_LABEL[g]}
<span className="tabular-nums">{list.length}</span>
<section key={g} aria-label={GROUP_LABEL[g]} className="border-b border-border/70 last:border-b-0">
<h3>
<button
type="button"
aria-expanded={open}
onClick={() => toggle(g, !open)}
className="flex w-full cursor-pointer flex-wrap items-baseline gap-x-3 gap-y-0.5 bg-foreground/[0.03] px-4 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.05]"
>
<ChevronRight
aria-hidden
className={cn(
"size-3.5 shrink-0 self-center text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<span className="text-body font-medium text-foreground">{GROUP_LABEL[g]}</span>
<span className={cn("text-meta tabular-nums", failed ? "text-destructive" : "text-muted-foreground")}>
{groupSummary(list, now)}
</span>
</button>
</h3>
<ul className="divide-y divide-border/70 border-b border-border/70 last:border-b-0">
{list.map((j) => (
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
))}
</ul>
{open ? (
<ul className="divide-y divide-border/70 border-t border-border/70">
{list.map((j) => (
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
))}
</ul>
) : null}
</section>
);
})}
@@ -12,10 +12,11 @@ describe("计划页标签", () => {
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
});
it("formatUsd:小额保留 4 位", () => {
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
expect(formatUsd(undefined)).toBe("—");
expect(formatUsd(0)).toBe("$0");
expect(formatUsd(0.0031258)).toBe("$0.0031");
expect(formatUsd(0.0031258)).toBe("< $0.01");
expect(formatUsd(0.0191)).toBe("$0.02");
expect(formatUsd(1.234)).toBe("$1.23");
});
+6 -5
View File
@@ -10,7 +10,7 @@ import type { StatusTone } from "~/components/console/status-badge.tsx";
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
/** 计划页「自动任务」一节的分组(Agent 巡检单独一节) */
/** 计划页「自动任务」一节的分组(智能体巡检单独一节,放在最上面);维护类合成一组「数据清理」 */
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
/** 计划页两节的锚点:/console/schedules#auto、#checks */
@@ -28,8 +28,8 @@ export const GROUP_LABEL: Record<ScheduleGroup, string> = {
collect: "采集",
download: "下载",
organize: "整理",
maintain: "维护",
agent: "Agent 巡检",
maintain: "数据清理",
agent: "智能体巡检",
};
export const RUN_STATUS: Record<ScheduleRunStatus, { label: string; tone: StatusTone }> = {
@@ -70,11 +70,12 @@ export const INTERVAL_PRESETS: number[] = [
7 * 24 * 3600_000,
];
/** 美元费用:"$0.0031" / "$0.12";空值 "—" */
/** 美元费用:两位小数,不足一分写 "< $0.01";空值 "—" */
export function formatUsd(v: number | null | undefined): string {
if (v === null || v === undefined) return "—";
if (v === 0) return "$0";
return `$${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`;
if (v < 0.01) return "< $0.01";
return `$${v.toFixed(2)}`;
}
/** 毫秒 → 最大整除单位 */
@@ -52,7 +52,7 @@ export function RunsSheet({ job, onClose }: { job: ScheduledJob | null; onClose:
to={consolePath("agent", r.agentRunId)}
className="w-fit text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
查看 Agent 会话{r.agentStatus ? `(${agentRunLabel(r.agentStatus).label})` : ""}
查看智能体会话{r.agentStatus ? `(${agentRunLabel(r.agentStatus).label})` : ""}
</Link>
) : null}
</li>
@@ -1,12 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
import { AlertTriangle, Bot, CalendarClock, Plus } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useLocation } from "react-router";
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
import { useScheduleBoard } from "~/api/schedules.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { Button } from "~/components/ui/button.tsx";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
@@ -16,8 +15,9 @@ import { AutoTaskList } from "./auto-task-list.tsx";
import { formatUsd } from "./labels.ts";
import { RunsSheet } from "./runs-sheet.tsx";
// 计划页(FE-6,/console/schedules):「自动任务」= 程序按固定周期执行的定时任务(紧凑列表);
// 「Agent 巡检」= 到点发起 Agent 会话的定时任务(卡片)。自动任务的运行记录跳任务页按该定时任务筛选。
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
/** 每秒一跳的当前时间(倒计时用) */
export function useNow(ms = 1000) {
@@ -69,7 +69,17 @@ export function planStats(items: ScheduledJob[]) {
};
}
function SectionHead({ id, title, hint, actions }: { id: string; title: string; hint: string; actions?: ReactNode }) {
function SectionHead({
id,
title,
hint,
actions,
}: {
id: string;
title: string;
hint: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
<div className="min-w-0">
@@ -107,7 +117,6 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
}
: null;
const s = planStats(items);
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
// 首页计数链到 #auto / #checks:数据到了再滚过去
const loaded = !!data;
@@ -117,59 +126,8 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
if (id === "auto" || id === "checks") document.getElementById(id)?.scrollIntoView({ block: "start" });
}, [loaded, hash, highlightJob]);
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "smooth" });
return (
<>
<div className="mb-4 grid gap-3 lg:grid-cols-2">
<div className="grid gap-1.5">
<p className="px-1 text-meta font-medium text-muted-foreground">自动任务</p>
<div className="grid grid-cols-3 gap-2">
<StatTile
label="已启用"
icon={CircleCheck}
value={val(s.auto.enabled)}
hint={board.isPending ? undefined : `共 ${s.auto.total} 项`}
onClick={() => jump("auto")}
/>
<StatTile
label="上次失败"
icon={XCircle}
tone={s.auto.failed > 0 ? "destructive" : "neutral"}
value={val(s.auto.failed)}
onClick={() => jump("auto")}
/>
<StatTile label="运行中" icon={LoaderCircle} value={val(s.auto.running)} onClick={() => jump("auto")} />
</div>
</div>
<div className="grid gap-1.5">
<p className="px-1 text-meta font-medium text-muted-foreground">Agent 巡检</p>
<div className="grid grid-cols-3 gap-2">
<StatTile
label="已启用"
icon={Bot}
value={val(s.checks.enabled)}
hint={board.isPending ? undefined : `共 ${s.checks.total} 项`}
onClick={() => jump("checks")}
/>
<StatTile
label="上次失败"
icon={XCircle}
tone={s.checks.failed > 0 ? "destructive" : "neutral"}
value={val(s.checks.failed)}
onClick={() => jump("checks")}
/>
<StatTile
label="近 7 天费用"
icon={Coins}
value={data ? formatUsd(s.checks.weekCost) : "…"}
hint={data ? `${s.checks.weekRuns} 次会话` : undefined}
onClick={() => jump("checks")}
/>
</div>
</div>
</div>
{data && !data.schedulerOnline ? (
<p
role="status"
@@ -186,24 +144,21 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
<ErrorState error={board.error} onRetry={() => board.refetch()} />
) : (
<div className="grid gap-8">
<section aria-labelledby="auto">
<SectionHead
id="auto"
title="自动任务"
hint="程序按固定周期执行,不调用模型。间隔可直接改,改过的可恢复默认。"
/>
{autoJobs.length === 0 ? (
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
) : (
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
)}
</section>
<section aria-labelledby="checks">
<SectionHead
id="checks"
title="Agent 巡检"
hint="到点按目标发起一次 Agent 会话,只在能力白名单和预算内执行;触发条件不满足时跳过,不调用模型。"
title="智能体巡检"
hint={
s.checks.total
? [
`${s.checks.enabled} 个启用`,
s.checks.failed ? `${s.checks.failed} 个上次失败` : null,
s.checks.weekRuns ? `近 7 天 ${s.checks.weekRuns} 次,${formatUsd(s.checks.weekCost)}` : null,
]
.filter(Boolean)
.join(" · ")
: "到点让智能体按目标检查并汇报。"
}
actions={
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
<Plus />
@@ -212,7 +167,7 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
}
/>
{checks.length === 0 ? (
<EmptyState icon={Bot} title="还没有巡检" description="新建一个巡检,到点由 Agent 按目标检查并汇报。" />
<EmptyState icon={Bot} title="还没有巡检" description="新建一个巡检,到点由智能体按目标检查并汇报。" />
) : (
<div className="grid items-start gap-4 md:grid-cols-2 2xl:grid-cols-3">
{checks.map((j) => (
@@ -229,6 +184,24 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
</div>
)}
</section>
<section aria-labelledby="auto">
<SectionHead
id="auto"
title="自动任务"
hint={[
`${s.auto.enabled} 个启用`,
s.auto.running ? `${s.auto.running} 个运行中` : null,
s.auto.failed ? `${s.auto.failed} 个上次失败` : null,
]
.filter(Boolean)
.join(" · ")}
/>
{autoJobs.length === 0 ? (
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
) : (
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
)}
</section>
</div>
)}
@@ -1,5 +1,5 @@
import { type SearchAction, type SearchWork, tmdbImageUrl } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Search } from "lucide-react";
import { CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Search } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { useSearch } from "~/api/search.ts";
@@ -111,7 +111,6 @@ function PaletteBody({ initial }: { initial: string }) {
查看「<span className="font-medium">{term}</span>」的全部结果
</span>
),
meta: "作品卡上可以选站点搜资源",
hint: "打开",
run: () => go(searchPagePath(term)),
});
@@ -254,10 +253,6 @@ function PaletteBody({ initial }: { initial: string }) {
<div className="hidden items-center gap-4 border-t border-border/70 px-4 py-2 text-meta text-muted-foreground sm:flex">
<span>↑↓ 选择</span>
<span>↵ 打开</span>
<span className="ml-auto inline-flex items-center gap-1">
<ArrowRight className="h-3 w-3" />
搜资源在作品详情页
</span>
</div>
</div>
);
@@ -412,7 +412,7 @@ function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => voi
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
委托 Agent
委托智能体
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
@@ -23,7 +23,7 @@ import { formatBytes } from "~/lib/utils.ts";
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
const STEPS = [
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
"生成下载计划,预留收录版本和预算;每个收录版本同时只有一个在途计划",
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
"提交前复核免费、做种等易变字段",
@@ -109,11 +109,11 @@ export function SearchDownloadDialog({
{!entityId ? (
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在「身份与元数据」指定。</p>
) : slots.isPending ? (
<p className="text-meta text-muted-foreground">正在读取版本目标…</p>
<p className="text-meta text-muted-foreground">正在读取收录版本…</p>
) : slotList.length ? (
<div className="flex flex-wrap items-center gap-2">
<FilterSelect
label="版本目标"
label="收录版本"
value={slotId}
onChange={setSlotId}
allLabel="自动匹配"
@@ -124,7 +124,7 @@ export function SearchDownloadDialog({
/>
</div>
) : (
<p className="text-meta text-muted-foreground">这个作品没有版本目标。先在订阅里新建。</p>
<p className="text-meta text-muted-foreground">这个作品没有收录版本。先在订阅里新建。</p>
)}
{release.entityMatch === "alias" ? (
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
@@ -159,7 +159,7 @@ export function SearchDownloadDialog({
}}
>
<Bot />
委托 Agent
委托智能体
</Button>
<Button onClick={go} disabled={plan.isPending || !!done || !entityId}>
<Download />
@@ -15,7 +15,7 @@ export function slotSummary(h: LocalMediaHit): string {
if (h.versionCount) parts.push(`${h.versionCount} 个版本`);
if (h.downloadingCount) parts.push(`${h.downloadingCount} 个下载中`);
const gaps = c.empty + c.attention + c.upgrade_pending;
if (gaps) parts.push(`${gaps} 个版本目标未满足`);
if (gaps) parts.push(`${gaps} 个收录版本未满足`);
return parts.join(" · ") || "未订阅";
}
@@ -82,14 +82,14 @@ describe("站点选择", () => {
});
describe("库内状态文案", () => {
it("未收录 / 已建档 / 已收录 / 正在下载;版本目标满足与缺失", () => {
expect(inventoryBadge(inv({})).label).toBe("未收录");
expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("已建档 · 未入库");
expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已收录 · 2 个版本");
it("未收录 / 已建档 / 已收录 / 正在下载;收录版本满足与缺失", () => {
expect(inventoryBadge(inv({})).label).toBe("不在媒体库");
expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("在媒体库 · 未下载");
expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已入库 · 2 个版本");
expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个");
expect(
slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })),
).toBe("版本目标满足 1/2 · 缺「2160p」");
).toBe("收录版本满足 1/2 · 缺「2160p」");
expect(slotLine(inv({}))).toBeNull();
});
+5 -5
View File
@@ -33,20 +33,20 @@ export function releaseSearchPath(w: { title: string; ref: string }, siteIds: st
export function inventoryBadge(inv: WorkInventory): { label: string; tone: StatusTone } {
switch (inv.state) {
case "not_collected":
return { label: "未收录", tone: "neutral" };
return { label: "不在媒体库", tone: "neutral" };
case "downloading":
return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" };
case "owned":
return { label: `已收录 · ${inv.versionCount} 个版本`, tone: "success" };
return { label: `已入库 · ${inv.versionCount} 个版本`, tone: "success" };
default:
return { label: "已建档 · 未入库", tone: "neutral" };
return { label: "在媒体库 · 未下载", tone: "neutral" };
}
}
/** 版本目标满足情况一行:满足 a/b · 缺「x」「y」 */
/** 收录版本满足情况一行:满足 a/b · 缺「x」「y」 */
export function slotLine(inv: WorkInventory): string | null {
if (inv.state === "not_collected" || inv.slotTotal === 0) return null;
const parts = [`版本目标满足 ${inv.slotSatisfied}/${inv.slotTotal}`];
const parts = [`收录版本满足 ${inv.slotSatisfied}/${inv.slotTotal}`];
if (inv.missing.length) parts.push(`缺${inv.missing.map((m) => `「${m.name}」`).join("")}`);
return parts.join(" · ");
}
@@ -196,7 +196,7 @@ export function PasskeyBlock({ disabled }: { disabled: boolean }) {
<SettingsBlock
section="account"
anchor="passkeys"
title="Passkey"
title="通行密钥(Passkey)"
description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。"
>
{body}
@@ -50,17 +50,17 @@ const RISK: Record<RiskLevel, { label: string; tone: StatusTone }> = {
const AUTH: Record<CapabilityAuthState, { label: string; tone: StatusTone; hint: string }> = {
read_only: { label: "无需授权", tone: "neutral", hint: "只读或预览,不落变更,Key 有对应 scope 即可调用" },
granted: { label: "有持续授权", tone: "success", hint: "Agent、MCP、API Key 调用按持续授权放行" },
granted: { label: "有持续授权", tone: "success", hint: "智能体、MCP、API Key 调用按持续授权放行" },
needs_approval: {
label: "需审批",
tone: "warning",
hint: "没有覆盖它的持续授权,Agent、MCP、API Key 调用需要单次审批",
hint: "没有覆盖它的持续授权,智能体、MCP、API Key 调用需要单次审批",
},
agent_only: { label: "仅 Agent 会话", tone: "neutral", hint: "只在 Agent 会话内可用,不经 MCP 暴露" },
agent_only: { label: "仅智能体会话", tone: "neutral", hint: "只在智能体会话内可用,不经 MCP 暴露" },
};
export const CALLER_LABEL: Record<CapabilityCaller, string> = {
agent: "内置 Agent",
agent: "内置智能体",
mcp: "MCP",
rest: "REST",
other: "其他",
@@ -86,7 +86,7 @@ function McpServerBlock() {
section="agent"
anchor="mcp"
title="MCP 服务端"
description="外部 Agent 用 API Key 经 MCP 调用同一套能力。授权、审计、幂等与控制台一致,有副作用的工具没有持续授权或单次审批时被拒绝。"
description="外部程序用 API Key 经 MCP 调用和智能体同一套能力,授权与审计规则相同。"
>
{status.isPending ? (
<LoadingState rows={3} />
@@ -318,13 +318,13 @@ function CapabilitiesBlock() {
section="agent"
anchor="capabilities"
title="能力清单"
description="Agent、MCP、REST 共用这些能力。调用数与失败率为最近 7 天,含被拒绝的调用。"
description="智能体、MCP、REST 共用这些能力。调用数与失败率为最近 7 天,含被拒绝的调用。"
plain
>
{automationOff ? (
<p className="mb-3 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning">
{tools.data?.readOnlyReconcile ? "只读对账模式" : "自动化总开关已关闭"}
:执行类能力对 Agent、MCP、API Key 一律拒绝,持续授权暂不生效。
:执行类能力对智能体、MCP、API Key 一律拒绝,持续授权暂不生效。
</p>
) : null}
<DataTable
@@ -14,7 +14,7 @@ export const WEBSEARCH_CARD: CardSpec = {
anchor: "websearch",
title: "网页搜索",
description:
"开启后 Agent 可以用 web.search 搜索站外公开信息,用 web.fetch 读取搜索结果里的网页。结果和正文按不可信数据交给模型,web.fetch 不能读内网地址和结果以外的链接。",
"开启后智能体可以用 web.search 搜索站外公开信息,用 web.fetch 读取搜索结果里的网页。结果和正文按不可信数据交给模型,web.fetch 不能读内网地址和结果以外的链接。",
fields: [
{
name: "agent.webSearch.provider",
@@ -63,7 +63,7 @@ export const WEBSEARCH_CARD: CardSpec = {
name: "agent.webSearch.maxResults",
label: "每次条数",
kind: "number",
hint: "1–10,Agent 可以要更少",
hint: "1–10,智能体可以要更少",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
+51 -46
View File
@@ -1,7 +1,7 @@
import { AgentToolsBlock } from "./agent-tools.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
import type { FieldValue } from "./resource-section.tsx";
import type { FieldSpec, FieldValue } from "./resource-section.tsx";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
@@ -85,7 +85,7 @@ const BUDGET_CARD: CardSpec = {
const INVESTIGATE_CARD: CardSpec = {
anchor: "investigate",
title: "自动调查",
description: "待处理事项(识别有歧义、下载失败等)创建时,自动发起一次有界调查。",
description: "待处理事项(没识别出作品、下载失败等)创建时,自动发起一次有界调查。",
fields: [
{ name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true },
{
@@ -140,53 +140,58 @@ const INVESTIGATE_CARD: CardSpec = {
}),
};
const DEV_ONLY_FIELDS = new Set(["agent.sessionDir", "agent.fakeScript"]);
const ADVANCED_CARD: CardSpec = {
anchor: "advanced",
title: "高级",
fields: [
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
kind: "number",
hint: "超时后按「仍在等待」汇报结束",
},
{
name: "agent.optionsTimeoutSec",
label: "方案选择等待(秒)",
kind: "number",
hint: "Agent 给出方案后等你选择的最长时间",
},
{
name: "agent.optionsTimeoutAction",
label: "超时未选",
kind: "select",
options: [
{ value: "recommended", label: "自动选推荐项" },
{ value: "stop", label: "不选,Agent 收尾" },
],
},
{
name: "agent.approvalTimeoutSec",
label: "会话内审批有效期(秒)",
kind: "number",
hint: "步骤处的审批卡过期未批准,按拒绝处理",
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用 DATA_DIR/agent-sessions",
hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改",
},
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: "只对 fake 上游生效,留空用内置演示脚本;格式见 docs/runbook/agent.md",
},
],
// 会话目录与 fake 脚本是开发调试项,生产构建不显示(DEAI-2)
fields: (
[
{ name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" },
{
name: "agent.goalWaitHours",
label: "目标最长等待(小时)",
kind: "number",
hint: "超时后按「仍在等待」汇报结束",
},
{
name: "agent.optionsTimeoutSec",
label: "方案选择等待(秒)",
kind: "number",
hint: "智能体给出方案后等你选择的最长时间",
},
{
name: "agent.optionsTimeoutAction",
label: "超时未选",
kind: "select",
options: [
{ value: "recommended", label: "自动选推荐项" },
{ value: "stop", label: "不选,智能体收尾" },
],
},
{
name: "agent.approvalTimeoutSec",
label: "会话内审批有效期(秒)",
kind: "number",
hint: "步骤处的审批卡过期未批准,按拒绝处理",
},
{
name: "agent.sessionDir",
label: "会话目录",
kind: "text",
wide: true,
placeholder: "留空用 DATA_DIR/agent-sessions",
hint: "绝对路径,api 与 worker 都要可读写;有运行中的会话时不要改",
},
{
name: "agent.fakeScript",
label: "fake 脚本(JSON)",
kind: "textarea",
hint: "只对 fake 模型服务生效,留空用内置演示脚本;格式见 docs/runbook/agent.md",
},
] as FieldSpec[]
).filter((f) => import.meta.env.DEV || !DEV_ONLY_FIELDS.has(f.name)),
fromSettings: (s) => ({
"agent.goalPollSec": n(s.agent.goalPollSec),
"agent.goalWaitHours": n(s.agent.goalWaitHours),
@@ -25,10 +25,10 @@ import { SettingsBlock } from "./settings-block.tsx";
// ─── API Key ───
const SCOPES: { value: ApiScope; label: string; hint: string }[] = [
{ value: "read", label: "read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "organize.execute", hint: "发起整理" },
{ value: "admin", label: "admin", hint: "全部管理权限,含改设置" },
{ value: "read", label: "只读 · read", hint: "只读查询全部数据" },
{ value: "organize.read", label: "读整理记录 · organize.read", hint: "只读整理记录,供外部增量同步" },
{ value: "organize.execute", label: "执行整理 · organize.execute", hint: "发起整理" },
{ value: "admin", label: "管理员 · admin", hint: "全部管理权限,含改设置" },
];
function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; onClose: () => void }) {
@@ -200,7 +200,7 @@ export function ApiKeysSection() {
checked={on}
onChange={() => setScopes((xs) => (on ? xs.filter((x) => x !== s.value) : [...xs, s.value]))}
/>
<code className="text-caption">{s.label}</code>
<span className="text-caption">{s.label}</span>
<span className="text-meta text-muted-foreground">{s.hint}</span>
</label>
);
@@ -250,7 +250,7 @@ function ScopeLegend() {
{SCOPES.map((s) => (
<div key={s.value} className="contents">
<dt>
<code className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</code>
<span className="rounded-tight bg-foreground/[0.06] px-1.5 py-0.5">{s.label}</span>
</dt>
<dd className="text-muted-foreground">{s.hint}</dd>
</div>
+73 -26
View File
@@ -1,12 +1,13 @@
import { Pause, Play, ShieldAlert } from "lucide-react";
import type { ReactNode } from "react";
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useLocation } from "react-router";
import { toast } from "sonner";
import { useUpdateSettings } from "~/api/settings.ts";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { type FormValues, num } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
import { anchorId, SettingsBlock } from "./settings-block.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
@@ -15,12 +16,12 @@ const GiB = 1024 ** 3;
const CACHE_CARD: CardSpec = {
anchor: "cache",
title: "缓存水位",
description: "缓存卷使用率到硬阈值时停止新增下载并暂停纳管任务;降到恢复水位以下只恢复因阈值暂停的任务。",
title: "下载盘",
description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。",
fields: [
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
{ name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
{ name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
],
fromSettings: (s) => ({
@@ -75,7 +76,7 @@ const RETENTION_CARD: CardSpec = {
kind: "number",
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
},
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
],
fromSettings: (s) => ({
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
@@ -115,7 +116,7 @@ const STORAGE_BUDGET_CARD: CardSpec = {
gbField("pgDataWarnGb", "数据库告警线", "不高于数据库预算"),
gbField("imagesGb", "图片缓存容量"),
gbField("backupsGb", "备份目录预算"),
gbField("sessionsEvidenceGb", "Agent 会话与截图证据预算"),
gbField("sessionsEvidenceGb", "智能体会话与截图证据预算"),
],
fromSettings: (s) => Object.fromEntries(BUDGET_KEYS.map((k) => [`storage.budgets.${k}`, n(s.storage.budgets[k])])),
toPatch: (v) => ({ storage: { budgets: pick(v, "storage.budgets", BUDGET_KEYS) } }),
@@ -136,17 +137,17 @@ const STORAGE_RETENTION_CARD: CardSpec = {
title: "流水数据保留",
description: "到期由定时清理任务分批删除。单位天,心跳为小时。",
fields: [
dayField("retentionDays", "observation", "Feed 观察"),
dayField("retentionDays", "observation", "RSS 抓取记录"),
dayField("retentionDays", "sseEvent", "实时事件"),
dayField("retentionDays", "heartbeatHours", "进程心跳(小时)"),
dayField("retentionDays", "taskRun", "任务运行记录"),
dayField("retentionDays", "agentEvent", "Agent 会话事件", "只删已结束的会话"),
dayField("retentionDays", "agentEvent", "智能体会话事件", "只删已结束的会话"),
dayField("retentionDays", "audit", "审计日志", "能力调用审计除外"),
dayField("retentionDays", "capabilityStats", "能力调用统计"),
dayField(
"retentionDays",
"identityPoolArchive",
"待识别池归档",
"未识别资源归档",
"未命中的条目这么久没有新证据就归档,仍可手动指定",
),
],
@@ -211,6 +212,59 @@ function ToggleRow({
);
}
/** 高级:只读对账、存储预算、流水保留、缓存有效期;默认收起,从 #automation/advanced 进来时展开 */
function Advanced({ children }: { children: ReactNode }) {
const location = useLocation();
const [open, setOpen] = useState(() => location.hash.endsWith("/advanced"));
useEffect(() => {
if (location.hash.endsWith("/advanced")) setOpen(true);
}, [location.hash]);
return (
<section id={anchorId("automation", "advanced")} className="scroll-mt-20">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1.5 text-lead font-semibold text-foreground"
>
高级
<ChevronDown
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
/>
</button>
<p className="mt-1 text-caption text-muted-foreground">只读对账、存储预算、数据保留和缓存有效期。一般不用改。</p>
{open ? <div className="mt-6 grid grid-cols-1 gap-8">{children}</div> : null}
</section>
);
}
function ReadOnlySwitch() {
const update = useUpdateSettings();
return (
<SettingsGate>
{(s) => (
<SettingsBlock section="automation" anchor="readonly" title="只读对账">
<ToggleRow
id="tg-readonly"
icon={<ShieldAlert className="h-4 w-4" />}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) =>
update.mutateAsync({ automation: { readOnlyReconcile: v } }).then(
() => toast.success(v ? "已开启只读对账" : "已关闭只读对账"),
(err) => toast.error(errorMessage(err, "切换失败")),
)
}
/>
</SettingsBlock>
)}
</SettingsGate>
);
}
function Switches() {
const update = useUpdateSettings();
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
@@ -236,16 +290,6 @@ function Switches() {
disabled={update.isPending}
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
/>
<ToggleRow
id="tg-readonly"
icon={<ShieldAlert className="h-4 w-4" />}
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
checked={s.automation.readOnlyReconcile}
disabled={update.isPending}
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
/>
</div>
</SettingsBlock>
)}
@@ -263,9 +307,12 @@ export function AutomationSection() {
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
<Advanced>
<ReadOnlySwitch />
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
</Advanced>
</>
)}
</SettingsGate>
@@ -97,7 +97,7 @@ export function BrowserProvidersBlock() {
section="sites"
anchor="browser"
title="浏览器服务"
description="原站视图和 Agent 打开站点网页用的 camofox-browser。部署自带的一台由环境变量登记(camofox-env)。"
description="打开站点网页用(原站视图、智能体)。随部署提供的一台显示为内置。"
plain
>
<ResourceSection<BrowserProviderView>
@@ -297,9 +297,8 @@ export function LibraryRootsTable() {
<ResourceSection<LibraryRoot>
hooks={{ list, create, update, remove } as never}
config={{
noun: "库根",
description:
"登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。",
noun: "媒体库目录",
description: "保存时会在目录里放一个标记文件,用来判断目录是否挂载;目录不存在或不可写时无法保存。",
deleteHint: "只删除登记,磁盘上的文件不动。",
columns: [
{
@@ -326,7 +325,7 @@ export function LibraryRootsTable() {
},
{ key: "health", header: "在线", cell: (r) => <Health status={r.healthStatus} at={r.lastHealthAt} /> },
],
rowActions: (r) => <TestButton label="检查哨兵" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
rowActions: (r) => <TestButton label="检查目录" pending={test.isPending} run={() => test.mutateAsync(r.id)} />,
fields: [
{ name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" },
{ name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND },
@@ -0,0 +1,28 @@
import { Switch } from "~/components/ui/switch.tsx";
import { setUiPref, useUiPrefs } from "~/lib/ui-prefs.ts";
import { SettingsBlock } from "./settings-block.tsx";
// 界面:本机显示偏好(存浏览器,不进 settings 表)
export function InterfaceSection() {
const prefs = useUiPrefs();
return (
<SettingsBlock section="interface" anchor="display" title="显示">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground" id="ui-techinfo-label">
默认展开技术信息
</p>
<p className="mt-1 text-caption text-muted-foreground">
ID、hash、token 数、耗时和原始错误码平时收在「技术信息」里。开启后各页默认展开。
</p>
</div>
<Switch
aria-labelledby="ui-techinfo-label"
checked={prefs.techInfoExpanded}
onCheckedChange={(v) => setUiPref("techInfoExpanded", v)}
/>
</div>
</SettingsBlock>
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ const ORGANIZE_CARD: CardSpec = {
export function LibrarySection() {
return (
<div className="grid grid-cols-1 gap-8">
<SettingsBlock section="library" anchor="roots" title="库根" plain>
<SettingsBlock section="library" anchor="roots" title="媒体库目录" plain>
<LibraryRootsTable />
</SettingsBlock>
<SettingsGate>{(s) => <SettingsCard section="library" spec={ORGANIZE_CARD} settings={s} />}</SettingsGate>
@@ -211,21 +211,20 @@ function ImageCacheStatus() {
const IDENTITY_CARD: CardSpec = {
anchor: "identity",
title: "资源识别",
description:
"缺省只在资源命中订阅或本地作品别名、新作品收录规则前置条件通过、你手动识别或搜索时查 TMDB;其余进待识别池,不进待处理。",
description: "默认只在资源命中订阅、作品别名或新作品规则,或你手动识别和搜索时查 TMDB;其余放进未识别,不进待处理。",
fields: [
{
name: "identity.eagerTmdbLookup",
label: "每条新资源都查 TMDB",
kind: "switch",
hint: "旧行为。每条资源约 1–2 次搜索 + 命中时 1 次详情(识别缓存命中为 0);每小时 100 条的 RSS 约每天 2400–7200 次请求,站点独有的短剧 / 综艺多半查不到",
hint: "每条新资源都查一次 TMDB。RSS 量大时每天可能上千次请求,站点独有的短剧和综艺多半查不到",
wide: true,
},
{
name: "identity.autoEnrich",
label: "TMDB 查不到时自动补全元数据",
kind: "switch",
hint: "资源命中订阅或新作品收录规则、TMDB 却没有结果时,自动搜索网页出一份补全建议(每条资源一次,不建作品)。每次约 1–2 次网页搜索、最多读 3 个网页、1 次 Agent 上游调用",
hint: "资源命中订阅或新作品规则、TMDB 却查不到时,搜索网页出一份补全建议(每条资源一次,不建作品)。每次最多读 3 个网页,调用一次模型服务",
wide: true,
},
{
+5 -5
View File
@@ -120,7 +120,7 @@ function RootNaming() {
<SettingsBlock
section="library"
anchor="naming-roots"
title="库根命名覆盖"
title="媒体库目录命名覆盖"
description="按类型覆盖全局模板;留空的类型沿用全局。"
>
{roots.isPending ? (
@@ -128,12 +128,12 @@ function RootNaming() {
) : roots.isError ? (
<ErrorState plain error={roots.error} onRetry={() => roots.refetch()} />
) : !root ? (
<p className="text-caption text-muted-foreground">还没有库根。先在上方「库根」添加。</p>
<p className="text-caption text-muted-foreground">还没有媒体库目录。先在上方「媒体库目录」添加。</p>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Select value={root.id} onValueChange={setRootId}>
<SelectTrigger size="sm" aria-label="库根" className="max-w-full min-w-48">
<SelectTrigger size="sm" aria-label="媒体库目录" className="max-w-full min-w-48">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
@@ -177,7 +177,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
try {
await save.mutateAsync({ id: root.id, naming: body });
setDraft({ ...(body ?? {}) });
toast.success(body ? "库根命名覆盖已保存" : "已清除库根命名覆盖");
toast.success(body ? "媒体库目录命名覆盖已保存" : "已清除媒体库目录命名覆盖");
} catch (err) {
const split = splitNamingErrors(errorMessage(err));
if (split) setErrors(split);
@@ -190,7 +190,7 @@ function RootNamingEditor({ root }: { root: LibraryRoot }) {
<div className="flex flex-wrap items-center gap-2">
{KINDS.map((k) => (
<StatusBadge key={k} tone={saved[k] ? "success" : "neutral"}>
{NAMING_KIND_LABEL[k]}:{saved[k] ? "库根覆盖" : "沿用全局"}
{NAMING_KIND_LABEL[k]}:{saved[k] ? "媒体库目录覆盖" : "沿用全局"}
</StatusBadge>
))}
</div>
@@ -104,7 +104,7 @@ export function PathMappingsPanel({
<p className="truncate">{m.remotePrefix}</p>
<p className="truncate text-muted-foreground">→ {m.localPrefix}</p>
{rootName(m.libraryRootId) ? (
<p className="font-sans text-muted-foreground">库根:{rootName(m.libraryRootId)}</p>
<p className="font-sans text-muted-foreground">媒体库目录:{rootName(m.libraryRootId)}</p>
) : null}
</div>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
@@ -175,7 +175,7 @@ export function PathMappingsPanel({
disabled={create.isPending}
/>
<FieldControl
spec={{ name: "libraryRootId", label: "关联库根", kind: "select", options: rootOpts }}
spec={{ name: "libraryRootId", label: "关联媒体库目录", kind: "select", options: rootOpts }}
value={v.libraryRootId}
onChange={(x) => setV((s) => ({ ...s, libraryRootId: x }))}
disabled={create.isPending}
+1 -1
View File
@@ -22,7 +22,7 @@ const SOURCE_TEXT: Record<string, string> = { "stored credential": "已存凭证
/** 上游返回的多是英文原文:先给一句怎么办,原文留作排查 */
const ERROR_HINT: Record<string, string> = {
network: "连不上上游,检查 Base URL、端口和网络",
network: "连不上模型服务,检查 Base URL、端口和网络",
auth: "凭证被拒,检查 API Key 或重新订阅登录",
model: "模型不可用,换一个模型 id",
};
@@ -49,13 +49,13 @@ export function PiUpstreamsBlock() {
<SettingsBlock
section="agent"
anchor="upstream"
title="上游"
description="没指定上游的运行用默认上游。密钥和登录凭证保存后不回显。"
title="模型服务"
description="没指定模型服务的运行用默认模型服务。密钥和登录凭证保存后不回显。"
plain
actions={
<Button size="sm" onClick={() => setWizard(true)}>
<Plus />
新建上游
新建模型服务
</Button>
}
>
@@ -66,7 +66,7 @@ export function PiUpstreamsBlock() {
error={list.error}
onRetry={() => list.refetch()}
onRowClick={(r) => setOpenId(r.id)}
empty={{ title: "还没有上游" }}
empty={{ title: "还没有模型服务" }}
columns={[
{
key: "name",
@@ -181,7 +181,7 @@ function UpstreamDrawer({
const makeDefault = async () => {
const model = v.model.trim();
if (!model) {
setErrors((e) => ({ ...e, defaultModel: "设为默认上游前先选默认模型" }));
setErrors((e) => ({ ...e, defaultModel: "设为默认模型服务前先选默认模型" }));
return;
}
try {
@@ -189,7 +189,7 @@ function UpstreamDrawer({
id: upstream.id,
body: { makeDefault: true, ...(model !== (upstream.defaultModel ?? "") ? { defaultModel: model } : {}) },
});
toast.success(`${upstream.name}已设为默认上游`);
toast.success(`${upstream.name}已设为默认模型服务`);
} catch (err) {
toast.error(errorMessage(err, "设置失败"));
}
@@ -204,7 +204,7 @@ function UpstreamDrawer({
description={
<span className="flex flex-wrap items-center gap-2">
<UpstreamStatusBadge u={upstream} />
{upstream.isDefault ? <StatusBadge tone="success">默认上游</StatusBadge> : null}
{upstream.isDefault ? <StatusBadge tone="success">默认模型服务</StatusBadge> : null}
<span>
{provider?.name ?? upstream.provider} · {AUTH_LABEL[upstream.authKind]}
</span>
@@ -402,11 +402,13 @@ function UpstreamDrawer({
<ConfirmDialog
open={deleting}
onOpenChange={setDeleting}
title={`删除上游「${upstream.name}」?`}
title={`删除模型服务「${upstream.name}」?`}
description={
upstream.isDefault ? "它是默认上游,删除后没有默认上游;凭证一并删除,无法恢复。" : "凭证一并删除,无法恢复。"
upstream.isDefault
? "它是默认模型服务,删除后没有默认模型服务;凭证一并删除,无法恢复。"
: "凭证一并删除,无法恢复。"
}
confirmLabel="删除上游"
confirmLabel="删除模型服务"
destructive
onConfirm={async () => {
try {
@@ -60,9 +60,9 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
const title =
step === "provider"
? "新建 Agent 上游"
? "新建模型服务"
: step === "config"
? `新建上游 · ${provider?.name ?? ""}`
? `新建模型服务 · ${provider?.name ?? ""}`
: (created?.name ?? "");
const description =
step === "provider"
@@ -70,8 +70,8 @@ export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: ()
: step === "config"
? "第 2 步,共 3 步:凭证与模型"
: created?.authKind === "oauth"
? "第 3 步,共 3 步:登录并测试。上游已创建。"
: "第 3 步,共 3 步:测试。上游已创建。";
? "第 3 步,共 3 步:登录并测试。模型服务已创建。"
: "第 3 步,共 3 步:测试。模型服务已创建。";
return (
<DetailSheet open={open} onOpenChange={(o) => !o && close()} title={title} description={description} width="lg">
@@ -413,7 +413,7 @@ function VerifyStep({
const finish = async () => {
if (makeDefault && !model.trim()) {
setModelError("设为默认上游前先选默认模型");
setModelError("设为默认模型服务前先选默认模型");
return;
}
const body = {
@@ -422,7 +422,7 @@ function VerifyStep({
};
try {
if (Object.keys(body).length) await update.mutateAsync({ id: upstream.id, body });
if (makeDefault && !upstream.isDefault) toast.success(`${upstream.name}已设为默认上游`);
if (makeDefault && !upstream.isDefault) toast.success(`${upstream.name}已设为默认模型服务`);
onDone();
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
@@ -476,7 +476,7 @@ function VerifyStep({
onCheckedChange={setMakeDefault}
disabled={upstream.isDefault}
/>
{upstream.isDefault ? "已是默认上游" : "设为默认上游"}
{upstream.isDefault ? "已是默认模型服务" : "设为默认模型服务"}
</label>
<Button onClick={finish} disabled={update.isPending}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
+15 -1
View File
@@ -1,11 +1,24 @@
import { SETTINGS_SECTIONS, type SettingsSectionDef, type SettingsSectionId } from "@umt/contracts";
import { Bot, FolderTree, KeyRound, type LucideIcon, Server, Shield, Tags, Tv, UserCog, Zap } from "lucide-react";
import {
Bot,
FolderTree,
KeyRound,
type LucideIcon,
Monitor,
Server,
Shield,
Tags,
Tv,
UserCog,
Zap,
} from "lucide-react";
import type { ReactNode } from "react";
import { AccountSection } from "./account.tsx";
import { AgentSection } from "./agent.tsx";
import { ApiKeysSection } from "./api-keys.tsx";
import { AutomationSection } from "./automation.tsx";
import { DownloadersSection, MediaServersSection } from "./instances.tsx";
import { InterfaceSection } from "./interface.tsx";
import { LibrarySection } from "./library.tsx";
import { MetadataSection } from "./metadata.tsx";
import { SitesSection } from "./sites.tsx";
@@ -21,6 +34,7 @@ const UI: Record<SettingsSectionId, { icon: LucideIcon; render: () => ReactNode
metadata: { icon: Tags, render: () => <MetadataSection /> },
agent: { icon: Bot, render: () => <AgentSection /> },
"api-keys": { icon: KeyRound, render: () => <ApiKeysSection /> },
interface: { icon: Monitor, render: () => <InterfaceSection /> },
account: { icon: Shield, render: () => <AccountSection /> },
};
+4 -4
View File
@@ -23,15 +23,15 @@ const DEFAULTS_CARD: CardSpec = {
{ name: "sites.defaultRateLimit.concurrency", label: "并发", kind: "number", hint: "同一站点同时进行的请求" },
{
name: "sites.defaultSnapshotIntervalSec",
label: "快照周期(分钟)",
label: "抓取周期(分钟)",
kind: "number",
hint: "站点开了定时快照但没设周期时使用;5–10080",
hint: "站点开了定时抓取但没设周期时使用;5–10080",
},
{
name: "sites.defaultSnapshotMaxPages",
label: "快照页数",
label: "抓取页数",
kind: "number",
hint: "定时和手动快照最多抓几页列表;站点定义更少时按站点;1–20",
hint: "定时和手动抓取最多抓几页列表;站点定义更少时按站点;1–20",
},
{
name: "sites.defaultHealthCheckIntervalSec",
@@ -239,7 +239,7 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
}
}}
>
<h2 className="text-lead font-semibold text-foreground">Passkey</h2>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey(RSS 与下载链接用)</h2>
<p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "已保存;再次保存会替换。" : "还没有保存。"}
</p>
@@ -258,7 +258,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
onClick={() => pick(CUSTOM)}
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
>
自定义 RSS 站点:只抓 Feed,不登录
自定义 RSS 站点:只抓 RSS,不登录
</button>
</section>
</>
@@ -335,7 +335,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
</>
) : (
<p className="text-caption text-muted-foreground">
不需要登录凭证。添加后在站点详情的"快照与 Feed"里新建 Feed。
不需要登录凭证。添加后在站点详情的"抓取与 RSS"里新建 RSS。
</p>
)}
</form>
+9 -28
View File
@@ -27,7 +27,6 @@ import { cn } from "~/lib/utils.ts";
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
import {
ACCOUNT_OVERALL,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
@@ -36,7 +35,6 @@ import {
loginFailureText,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} from "./site-fields.ts";
@@ -141,11 +139,11 @@ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog:
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
{item(
<Camera />,
"抓取快照",
"抓取最新资源",
() =>
snap.mutate(
{ id: site.id },
{ onSuccess: () => toast.success("快照已排队"), onError: (e) => toast.error(errorMessage(e)) },
{ onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) },
),
{ disabled: !site.enabled },
)}
@@ -197,21 +195,8 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
function AccountBadges({ site }: { site: SiteListItem }) {
const st = ACCOUNT_OVERALL[overallStatus(site)];
const acc = primaryAccount(site.accounts);
const hasPasskey = site.accounts?.some((a) => a.hasPasskey || a.authKind === "passkey");
return (
<>
<StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>
{acc && acc.authKind !== "passkey" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{AUTH_KIND_LABEL[acc.authKind]}
</span>
) : null}
{hasPasskey ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">Passkey</span>
) : null}
</>
);
// 正常不标;登录方式不做徽章(DEAI-2)
return st.tone === "success" ? null : <StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>;
}
function Stat({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
@@ -307,26 +292,22 @@ export function SiteCard({ site, checks, onDialog }: Props) {
<dd className="text-foreground">{relTime(acc?.lastLoginAt)}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">健康检测</dt>
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={hmsg ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">快照</dt>
<dt className="text-meta text-muted-foreground">资源更新</dt>
<dd className={cn("text-foreground", stale && "text-warning")}>
{!site.snapshotEnabled
? "未开启"
: site.lastSnapshotAt
? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}`
: "还没有快照"}
: "还没抓取过"}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">限流</dt>
<dd className="truncate text-foreground">{rateLimitText(site.rateLimit)}</dd>
</div>
</dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
@@ -441,7 +422,7 @@ export function SiteTable({
},
{
key: "health",
header: "健康检测",
header: "状态",
hideBelow: "md",
cell: (s) => {
const h = healthLine(s.lastHealth, s.lastHealthAt);
@@ -455,7 +436,7 @@ export function SiteTable({
},
{
key: "snapshot",
header: "快照",
header: "资源更新",
hideBelow: "lg",
cell: (s) => (
<span className={s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground"}>
@@ -380,7 +380,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
open
onOpenChange={(o) => !o && !busy && onClose()}
title={`编辑 · ${site.name}`}
description="限流和快照周期留空时用「设置 → 站点默认」"
description="限流和抓取周期留空时用「设置 → 站点默认」"
footer={
<>
<Button variant="outline" onClick={onClose} disabled={busy}>
@@ -434,11 +434,11 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
disabled={busy}
/>
<label htmlFor="se-snap" className="text-caption text-foreground">
定时抓取快照
定时抓取
</label>
</div>
{v.snapshotEnabled ? (
<FormField label="快照周期(分钟)" htmlFor="se-int" error={errors.intervalMin} hint="留空用站点默认">
<FormField label="抓取周期(分钟)" htmlFor="se-int" error={errors.intervalMin} hint="留空用站点默认">
<Input
id="se-int"
inputMode="numeric"
@@ -508,7 +508,7 @@ export function DeleteSiteDialog({
<ul className="list-disc space-y-1 pl-5 text-caption text-muted-foreground">
<li>站点、{site.accounts?.length ?? 0} 个账号和全部凭证删除</li>
<li>已抓到的资源保留,标为"站点已关闭",不再产生新候选</li>
<li>该站 Feed 停用;已有下载任务按原策略继续</li>
<li>该站 RSS 停用;已有下载任务按原策略继续</li>
<li>同一站点以后可以重新添加</li>
</ul>
</ConfirmDialog>
@@ -216,7 +216,7 @@ export function SiteFeedDialog({
<p className="text-caption text-muted-foreground">
{account ? `账号 ${account.label}` : "该站没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30
分钟抓一次,可在 Feed 详情里改
分钟抓一次,可在 RSS 详情里改
</p>
</form>
)}
@@ -279,7 +279,7 @@ function FirstFetch({
</p>
) : timedOut ? (
<p className="rounded-lg border border-border px-3 py-2 text-muted-foreground">
已保存。抓取还在排队(站点限流),结果稍后显示在 Feed 列表里。
已保存。抓取还在排队(站点限流),结果稍后显示在 RSS 列表里。
</p>
) : (
<p className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground">
+1 -1
View File
@@ -121,7 +121,7 @@ const HEALTH_STATUS_TEXT: Record<string, string> = {
export function healthLine(h: SiteLastHealth | null, at: string | null): { text: string; tone: StatusTone } {
if (!h) return { text: "未检测", tone: "neutral" };
const parts = [HEALTH_STATUS_TEXT[h.status] ?? h.status];
if (h.latencyMs !== null) parts.push(`${h.latencyMs} ms`);
// 延迟毫秒数不上屏(DEAI-2),需要时看站点详情
parts.push(relTime(at));
const tone: StatusTone = h.ok ? "success" : h.status.startsWith("auth_") ? "destructive" : "warning";
return { text: parts.join(" · "), tone };
@@ -99,9 +99,9 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
),
},
{
label: "快照",
label: "抓取",
value: site.snapshotEnabled
? `${site.lastSnapshotAt ? relTime(site.lastSnapshotAt) : "还没有快照"} · ${site.snapshotIntervalSec ? `每 ${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认周期"}`
? `${site.lastSnapshotAt ? relTime(site.lastSnapshotAt) : "还没抓取过"} · ${site.snapshotIntervalSec ? `每 ${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认周期"}`
: "未开启",
},
{ label: "限流", value: rateLimitText(site.rateLimit) },
@@ -48,6 +48,16 @@ export function HistoryChart({
return [...m.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)));
}, [series, metric]);
// 同名序列(容器重建后 pid 相同)加序号区分,图例不再重复拼接
const labels = new Map<string, string>();
const seen = new Map<string, number>();
for (const s of series) {
const base = seriesLabel(s);
const n = (seen.get(base) ?? 0) + 1;
seen.set(base, n);
labels.set(s.instanceId, n > 1 ? `${base}(${n})` : base);
}
if (rows.length === 0) return <EmptyState plain title="这段时间没有采样" className="py-8" />;
return (
@@ -80,13 +90,19 @@ export function HistoryChart({
}}
cursor={{ stroke: "var(--color-muted-foreground)", strokeDasharray: "3 3" }}
/>
{series.length > 1 ? <Legend iconType="plainline" wrapperStyle={{ fontSize: 12 }} /> : null}
{series.length > 1 ? (
<Legend
iconType="plainline"
wrapperStyle={{ fontSize: 12, paddingTop: 4 }}
formatter={(v) => <span className="mr-3 text-muted-foreground">{String(v)}</span>}
/>
) : null}
{series.map((s, i) => (
<Line
key={s.instanceId}
type="monotone"
dataKey={s.instanceId}
name={seriesLabel(s)}
name={labels.get(s.instanceId) ?? seriesLabel(s)}
stroke="var(--color-foreground)"
strokeOpacity={OPACITY[i % OPACITY.length]}
strokeDasharray={DASH[i % DASH.length]}
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff. Show more