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:
commit
12bc4664b6
247 files changed
+2658
-2148
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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("人工接管 / 等待处理不看自动化时间", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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("下载面板");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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> = {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 /> },
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
Reference in new issue
Block a user