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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user