diff --git a/apps/web/app/api/instances.ts b/apps/web/app/api/instances.ts index 33bf11fd..ceb5f052 100644 --- a/apps/web/app/api/instances.ts +++ b/apps/web/app/api/instances.ts @@ -16,8 +16,11 @@ import type { PathMappingUpdate, Site, SiteAccount, + SiteAccountUpdate, + SiteCreate, SiteCredentialsPut, SiteCredentialsResult, + SiteUpdate, } from "@umt/contracts"; import { apiData } from "~/lib/api.ts"; import { type QueryArea, qk } from "~/lib/query-keys.ts"; @@ -115,6 +118,52 @@ export function usePutSiteCredentials() { apiData("/sites/{id}/credentials", { method: "PUT", pathParams: { id: siteId }, body }), onSuccess: () => { qc.invalidateQueries({ queryKey: qk.all("site-accounts") }); + qc.invalidateQueries({ queryKey: qk.all("sites") }); }, }); } + +// ─── 站点登记与参数(设置"站点与账号";浏览在站点工作台) ─── + +function useInvalidateSites() { + const qc = useQueryClient(); + return () => { + // sites.ts 用 ["sites", …],这里的列表用 qk.list("sites"),两者同前缀 + qc.invalidateQueries({ queryKey: qk.all("sites") }); + qc.invalidateQueries({ queryKey: qk.all("site-accounts") }); + }; +} + +export function useCreateSite() { + const inv = useInvalidateSites(); + return useMutation({ + mutationFn: (body: SiteCreate) => apiData("/sites", { method: "POST", body }), + onSuccess: inv, + }); +} + +export function useUpdateSite() { + const inv = useInvalidateSites(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: SiteUpdate }) => + apiData("/sites/{id}", { method: "PATCH", pathParams: { id }, body }), + onSuccess: inv, + }); +} + +export function useUpdateSiteAccount() { + const inv = useInvalidateSites(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: SiteAccountUpdate }) => + apiData("/site-accounts/{id}", { method: "PATCH", pathParams: { id }, body }), + onSuccess: inv, + }); +} + +export function useRemoveSiteAccount() { + const inv = useInvalidateSites(); + return useMutation({ + mutationFn: (id: string) => apiData("/site-accounts/{id}", { method: "DELETE", pathParams: { id } }), + onSuccess: inv, + }); +} diff --git a/apps/web/app/api/pi.ts b/apps/web/app/api/pi.ts new file mode 100644 index 00000000..a04d526c --- /dev/null +++ b/apps/web/app/api/pi.ts @@ -0,0 +1,125 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + PiOAuthCompleteRequest, + PiOAuthCompleteResult, + PiOAuthStartRequest, + PiOAuthStartResult, + PiProvidersResponse, + PiUpstream, + PiUpstreamCreate, + PiUpstreamList, + PiUpstreamModels, + PiUpstreamTestRequest, + PiUpstreamTestResult, + PiUpstreamUpdate, +} from "@umt/contracts"; +import { apiData, TTL_MINUTE } from "~/lib/api.ts"; +import { qk } from "~/lib/query-keys.ts"; + +// Agent 上游(W7-A 的 /pi/*):能力表、上游 CRUD、测试、模型表、订阅登录。 +// 写操作后刷新上游列表与全局设置(默认上游存在 settings.pi.defaultUpstreamId)。 + +export const piKeys = { + all: ["pi"] as const, + providers: (withModels: boolean) => ["pi", "providers", withModels] as const, + upstreams: ["pi", "upstreams"] as const, + models: (id: string) => ["pi", "models", id] as const, +}; + +/** 能力表;withModels = 带静态模型表(约 370 KB,只在新建向导里要) */ +export function usePiProviders(withModels = false, enabled = true) { + return useQuery({ + queryKey: piKeys.providers(withModels), + enabled, + staleTime: 30 * TTL_MINUTE, + queryFn: ({ signal }) => + apiData("/pi/providers", { params: withModels ? {} : { models: "false" }, signal }), + }); +} + +export function usePiUpstreams() { + return useQuery({ + queryKey: piKeys.upstreams, + queryFn: ({ signal }) => apiData("/pi/upstreams", { signal }), + }); +} + +export function usePiUpstreamModels(id: string | null) { + return useQuery({ + queryKey: piKeys.models(id ?? ""), + enabled: !!id, + staleTime: 5 * TTL_MINUTE, + queryFn: ({ signal }) => + apiData("/pi/upstreams/{id}/models", { pathParams: { id: id ?? "" }, signal }), + }); +} + +function useInvalidatePi() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: piKeys.upstreams }); + qc.invalidateQueries({ queryKey: qk.settings }); + }; +} + +export function useCreatePiUpstream() { + const inv = useInvalidatePi(); + return useMutation({ + mutationFn: (body: PiUpstreamCreate) => apiData("/pi/upstreams", { method: "POST", body }), + onSuccess: inv, + }); +} + +export function useUpdatePiUpstream() { + const inv = useInvalidatePi(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: PiUpstreamUpdate }) => + apiData("/pi/upstreams/{id}", { method: "PATCH", pathParams: { id }, body }), + onSuccess: inv, + }); +} + +export function useRemovePiUpstream() { + const inv = useInvalidatePi(); + return useMutation({ + mutationFn: (id: string) => apiData("/pi/upstreams/{id}", { method: "DELETE", pathParams: { id } }), + onSuccess: inv, + }); +} + +export function useTestPiUpstream() { + const inv = useInvalidatePi(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: PiUpstreamTestRequest }) => + apiData("/pi/upstreams/{id}/test", { method: "POST", pathParams: { id }, body }), + onSettled: inv, + }); +} + +export function useStartPiOAuth() { + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: PiOAuthStartRequest }) => + apiData("/pi/upstreams/{id}/oauth/start", { method: "POST", pathParams: { id }, body }), + }); +} + +/** 完成 / 轮询一次登录(device 模式前端反复调用直到终态) */ +export function completePiOAuth(id: string, body: PiOAuthCompleteRequest, signal?: AbortSignal) { + return apiData("/pi/upstreams/{id}/oauth/complete", { + method: "POST", + pathParams: { id }, + body, + signal, + }); +} + +export function useLogoutPiOAuth() { + const inv = useInvalidatePi(); + return useMutation({ + mutationFn: (id: string) => + apiData("/pi/upstreams/{id}/oauth/logout", { method: "POST", pathParams: { id }, body: {} }), + onSuccess: inv, + }); +} + +export { useInvalidatePi }; diff --git a/apps/web/app/components/settings/account.tsx b/apps/web/app/components/settings/account.tsx new file mode 100644 index 00000000..70c56c65 --- /dev/null +++ b/apps/web/app/components/settings/account.tsx @@ -0,0 +1,331 @@ +import { PasswordChangeRequest, TotpCode } from "@umt/contracts"; +import { LoaderCircle, LogOut } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { z } from "zod"; +import { useChangePassword, useLogout, useSession, useTotpAction, useTotpStatus } from "~/api/auth.ts"; +import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx"; +import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/api.ts"; +import { formatDateTime } from "~/lib/utils.ts"; +import { anchorId, SettingsBlock } from "./settings-block.tsx"; + +// 账户与安全:改密、二次验证(TOTP)、当前会话与本机 Token、快捷键 + +const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, { + message: "两次输入的新密码不一致", + path: ["confirm"], +}); + +const EMPTY_PWD = { currentPassword: "", newPassword: "", confirm: "" }; + +function PasswordBlock({ disabled }: { disabled: boolean }) { + const change = useChangePassword(); + const [form, setForm] = useState(EMPTY_PWD); + const [errors, setErrors] = useState>({}); + const dirty = Object.values(form).some(Boolean); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const parsed = PasswordForm.safeParse(form); + if (!parsed.success) { + setErrors(zodFieldErrors(parsed.error.issues)); + return; + } + setErrors({}); + try { + const r = await change.mutateAsync({ currentPassword: form.currentPassword, newPassword: form.newPassword }); + toast.success(r.revokedSessions ? `密码已修改,注销了其它 ${r.revokedSessions} 个会话` : "密码已修改"); + setForm(EMPTY_PWD); + } catch (err) { + const msg = errorMessage(err); + if (/旧密码|当前密码|password/i.test(msg)) setErrors({ currentPassword: "当前密码不正确" }); + else toast.error(msg); + } + }; + const set = (k: keyof typeof form) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [k]: e.target.value })); + const off = disabled || change.isPending; + + return ( + +
+ + + + + + + + + +
+ +
+
+
+ ); +} + +function TotpBlock({ disabled }: { disabled: boolean }) { + const status = useTotpStatus(!disabled); + const action = useTotpAction(); + const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null); + const [code, setCode] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const enabled = status.data?.enabled === true; + + const reset = () => { + setSetup(null); + setCode(""); + setPassword(""); + setError(null); + }; + + const begin = async () => { + setError(null); + try { + const r = await action.mutateAsync({ action: "begin" }); + if (r.secret && r.otpauthUri) setSetup({ secret: r.secret, uri: r.otpauthUri }); + } catch (err) { + setError(errorMessage(err)); + } + }; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!TotpCode.safeParse(code).success) { + setError("验证码为 6 位数字"); + return; + } + if (enabled && !password) { + setError("输入当前密码"); + return; + } + setError(null); + try { + if (enabled) { + await action.mutateAsync({ action: "disable", password, code }); + toast.success("二次验证已关闭"); + } else { + await action.mutateAsync({ action: "enable", code }); + toast.success("二次验证已开启"); + } + reset(); + } catch (err) { + setError(errorMessage(err)); + } + }; + + const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim(); + + return ( + {enabled ? "已开启" : "未开启"} + ) + } + > + {disabled ? ( +

当前用 API Key 访问,登录会话才能修改。

+ ) : !enabled && !setup ? ( +
+ {status.data?.pending ? ( +

有一个未确认的密钥,重新生成会作废它。

+ ) : null} + {error ?

{error}

: null} + +
+ ) : ( +
+ {setup ? ( +
+

+ 在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。 +

+ {grouped} + + {setup.uri} + +
+ ) : null} + {enabled ? ( + + setPassword(e.target.value)} + /> + + ) : null} + + setCode(e.target.value.replace(/\D/g, ""))} + aria-invalid={!!error} + /> + +
+ + {setup ? ( + + ) : null} +
+
+ )} +
+ ); +} + +function SessionBlock() { + const session = useSession(); + const logout = useLogout(); + const [hasToken, setHasToken] = useState(() => !!getToken()); + const [token, setTokenValue] = useState(""); + const s = session.data; + + const doLogout = async () => { + try { + await logout.mutateAsync(); + } catch { + // 会话已失效也照样回登录页 + } + window.location.assign(LOGIN_PATH); + }; + + return ( + +
+
身份
+
+ {s?.actor?.name ?? "—"} · {s?.actor?.type === "api_key" ? "API Key" : "管理员"} +
+
当前会话
+
{s?.expiresAt ? `到 ${formatDateTime(s.expiresAt)} 失效` : "未使用登录会话"}
+
其它会话
+
修改密码时全部注销
+
+
+

本机 Token

+

+ 只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"} +

+
+ setTokenValue(e.target.value)} + className="min-w-0 flex-1" + /> + + {hasToken ? ( + + ) : null} +
+
+
+ +
+
+ ); +} + +export function AccountSection() { + const session = useSession(); + const cookie = !!session.data?.expiresAt; + return ( +
+ + + + {/* 快捷键说明自带标题,只包卡片与锚点 */} +
+ +
+
+ ); +} diff --git a/apps/web/app/components/settings/agent.tsx b/apps/web/app/components/settings/agent.tsx new file mode 100644 index 00000000..42822334 --- /dev/null +++ b/apps/web/app/components/settings/agent.tsx @@ -0,0 +1,199 @@ +import { PiUpstreamsBlock } from "./pi-upstreams.tsx"; +import type { FieldValue } from "./resource-section.tsx"; +import { num, str } from "./resource-section.tsx"; +import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; + +// Agent 分区:上游(pi_upstream)+ 上下文与子代理(pi)+ 预算(budget.agent* 与单次上限)+ 自动调查 + 高级 + +/** 可清空的数字:空串 → null */ +const nullNum = (v: FieldValue | undefined) => (str(v) === "" ? null : Number(str(v))); + +const RUNTIME_CARD: CardSpec = { + anchor: "runtime", + title: "上下文与子代理", + fields: [ + { + name: "pi.contextBudget", + label: "上下文预算(token)", + kind: "number", + hint: "4096–2000000,超过模型窗口时按窗口算", + }, + { name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" }, + { name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" }, + { + name: "pi.compaction.thresholdRatio", + label: "压缩触发比例", + kind: "number", + hint: "0.3–0.95,用到预算的这个比例时压缩", + visible: (v) => v["pi.compaction.enabled"] === true, + }, + ], + fromSettings: (s) => ({ + "pi.contextBudget": n(s.pi.contextBudget), + "pi.subagentsEnabled": s.pi.subagentsEnabled, + "pi.compaction.enabled": s.pi.compaction.enabled, + "pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio), + }), + toPatch: (v) => ({ + pi: { + contextBudget: num(v["pi.contextBudget"]), + subagentsEnabled: v["pi.subagentsEnabled"] === true, + compaction: { + enabled: v["pi.compaction.enabled"] === true, + thresholdRatio: num(v["pi.compaction.thresholdRatio"]), + }, + }, + }), +}; + +const BUDGET_CARD: CardSpec = { + anchor: "budget", + title: "预算", + description: "超出每日预算的运行停在「超预算」,等你放行。", + fields: [ + { name: "budget.agentMaxRunsPerDay", label: "每日运行次数", kind: "number" }, + { name: "budget.agentMaxCostUsdPerDay", label: "每日费用(USD)", kind: "number" }, + { name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1–500" }, + { name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" }, + { name: "agent.maxTokensPerRun", label: "单次 token 上限", kind: "number", hint: "留空不限" }, + { name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" }, + ], + fromSettings: (s) => ({ + "budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay), + "budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay), + "agent.maxToolCalls": n(s.agent.maxToolCalls), + "agent.maxDurationSec": n(s.agent.maxDurationSec), + "agent.maxTokensPerRun": n(s.agent.maxTokensPerRun), + "agent.maxCostUsdPerRun": n(s.agent.maxCostUsdPerRun), + }), + toPatch: (v) => ({ + budget: { + agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]), + agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]), + }, + agent: { + maxToolCalls: num(v["agent.maxToolCalls"]), + maxDurationSec: num(v["agent.maxDurationSec"]), + maxTokensPerRun: nullNum(v["agent.maxTokensPerRun"]), + maxCostUsdPerRun: nullNum(v["agent.maxCostUsdPerRun"]), + }, + }), +}; + +const INVESTIGATE_CARD: CardSpec = { + anchor: "investigate", + title: "自动调查", + description: "待处理事项(识别有歧义、下载失败等)创建时,自动发起一次有界调查。", + fields: [ + { name: "agent.autoInvestigate", label: "自动调查", kind: "switch", wide: true }, + { + name: "agent.autoInvestigateMode", + label: "模式", + kind: "select", + options: [ + { value: "investigate", label: "只读调查,结论交还给你" }, + { value: "recover", label: "授权内恢复(重试任务、重新校验、确认识别)" }, + ], + hint: "恢复只在已发布策略或持续授权覆盖时执行,换源、删除、清理始终排除;没有授权时按只读调查", + wide: true, + visible: (v) => v["agent.autoInvestigate"] === true, + }, + { + name: "agent.investigateMaxToolCalls", + label: "工具调用上限", + kind: "number", + hint: "1–100", + visible: (v) => v["agent.autoInvestigate"] === true, + }, + { + name: "agent.investigateMaxAttempts", + label: "同一根因最多恢复次数", + kind: "number", + hint: "1–10,用完后只交还给你", + visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover", + }, + { + name: "agent.recoverVerifySec", + label: "恢复后核实时长(秒)", + kind: "number", + hint: "等下载或任务状态变化的最长时间", + visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover", + }, + ], + fromSettings: (s) => ({ + "agent.autoInvestigate": s.agent.autoInvestigate, + "agent.autoInvestigateMode": s.agent.autoInvestigateMode, + "agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls), + "agent.investigateMaxAttempts": n(s.agent.investigateMaxAttempts), + "agent.recoverVerifySec": n(s.agent.recoverVerifySec), + }), + toPatch: (v) => ({ + agent: { + autoInvestigate: v["agent.autoInvestigate"] === true, + autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate", + investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]), + investigateMaxAttempts: num(v["agent.investigateMaxAttempts"]), + recoverVerifySec: num(v["agent.recoverVerifySec"]), + }, + }), +}; + +const ADVANCED_CARD: CardSpec = { + anchor: "advanced", + title: "高级", + fields: [ + { name: "agent.goalPollSec", label: "目标轮询间隔(秒)", kind: "number", hint: "等下载、入库结束时的检查间隔" }, + { + name: "agent.goalWaitHours", + 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", + }, + ], + fromSettings: (s) => ({ + "agent.goalPollSec": n(s.agent.goalPollSec), + "agent.goalWaitHours": n(s.agent.goalWaitHours), + "agent.sessionDir": s.agent.sessionDir ?? "", + "agent.fakeScript": s.agent.fakeScript ?? "", + }), + toPatch: (v) => ({ + agent: { + goalPollSec: num(v["agent.goalPollSec"]), + goalWaitHours: num(v["agent.goalWaitHours"]), + sessionDir: str(v["agent.sessionDir"]) || null, + fakeScript: str(v["agent.fakeScript"]) || null, + }, + }), +}; + +export function AgentSection() { + return ( +
+ + + {(s) => ( + <> + + + + + + )} + +
+ ); +} diff --git a/apps/web/app/routes/console/settings/keys-tab.tsx b/apps/web/app/components/settings/api-keys.tsx similarity index 52% rename from apps/web/app/routes/console/settings/keys-tab.tsx rename to apps/web/app/components/settings/api-keys.tsx index b8721650..c02fedf6 100644 --- a/apps/web/app/routes/console/settings/keys-tab.tsx +++ b/apps/web/app/components/settings/api-keys.tsx @@ -1,15 +1,11 @@ -import { type ApiKey, ApiKeyCreate, type ApiScope, SiteCredentialsPut } from "@umt/contracts"; -import { Check, Copy, KeyRound, LoaderCircle, Plus } from "lucide-react"; +import { type ApiKey, ApiKeyCreate, type ApiScope } from "@umt/contracts"; +import { Check, Copy, LoaderCircle, Plus } from "lucide-react"; import { useState } from "react"; -import { Link } from "react-router"; import { toast } from "sonner"; import { useApiKeys, useCreateApiKey, useRevokeApiKey } from "~/api/api-keys.ts"; -import { usePutSiteCredentials, useSiteAccounts, useSiteOptions } from "~/api/instances.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { DataTable } from "~/components/console/data-table.tsx"; -import { DetailSection } from "~/components/console/detail-sheet.tsx"; import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; -import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { @@ -24,177 +20,15 @@ import { Input } from "~/components/ui/input.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatRelative } from "~/lib/labels.ts"; import { cn, formatDateTime } from "~/lib/utils.ts"; -import { FieldControl, type FormValues, str } from "./resource-section.tsx"; - -// ─── 站点账号凭证 ─── - -const AUTH_KINDS = [ - { value: "cookie", label: "Cookie" }, - { value: "passkey", label: "Passkey" }, - { value: "api_key", label: "API Key" }, - { value: "password", label: "账号密码" }, -]; - -export function SiteCredentialsSection() { - const sites = useSiteOptions(); - const accounts = useSiteAccounts(); - const put = usePutSiteCredentials(); - const [v, setV] = useState({ - siteId: "", - label: "default", - authKind: "cookie", - username: "", - credential: "", - }); - const [errors, setErrors] = useState>({}); - const siteOpts = (sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name })); - const siteName = (id: string) => sites.data?.items.find((s) => s.id === id)?.name ?? id.slice(0, 8); - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - const fe: Record = {}; - if (!str(v.siteId)) fe.siteId = "请选择站点"; - const parsed = SiteCredentialsPut.safeParse({ - label: str(v.label) || "default", - authKind: str(v.authKind), - credential: str(v.credential), - username: v.authKind === "password" ? str(v.username) || undefined : undefined, - }); - if (!parsed.success) Object.assign(fe, zodFieldErrors(parsed.error.issues)); - if (fe.credential) fe.credential = "请填写凭证"; - if (Object.keys(fe).length || !parsed.success) { - setErrors(fe); - return; - } - setErrors({}); - try { - const r = await put.mutateAsync({ siteId: str(v.siteId), body: parsed.data }); - toast.success(r.created ? "已添加站点账号" : "已替换站点凭证"); - setV((s) => ({ ...s, credential: "" })); - } catch (err) { - toast.error(errorMessage(err, "保存失败")); - } - }; - - return ( -
-
-

- 按「站点 + 标签」新建或替换账号凭证。凭证加密保存,提交后不会回显;站点本身在 - - PT 站点工作台 - - 登记。 -

- setV((s) => ({ ...s, siteId: x }))} - disabled={put.isPending} - /> - setV((s) => ({ ...s, label: x }))} - disabled={put.isPending} - /> - setV((s) => ({ ...s, authKind: x }))} - disabled={put.isPending} - /> - {v.authKind === "password" ? ( - setV((s) => ({ ...s, username: x }))} - disabled={put.isPending} - /> - ) : null} - setV((s) => ({ ...s, credential: x }))} - disabled={put.isPending} - /> -
- -
- - - r.id} - loading={accounts.isPending} - error={accounts.error} - onRetry={() => accounts.refetch()} - empty={{ title: "还没有站点账号" }} - columns={[ - { - key: "site", - header: "站点 / 标签", - cell: (r) => ( -
-

{siteName(r.siteId)}

-

{r.label}

-
- ), - }, - { - key: "kind", - header: "类型", - cell: (r) => AUTH_KINDS.find((k) => k.value === r.authKind)?.label ?? r.authKind, - }, - { - key: "status", - header: "状态", - cell: (r) => ( - - {!r.enabled ? "停用" : r.secretRef ? "已录入" : "缺凭证"} - - ), - }, - ]} - /> -
-
- ); -} +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 }) { @@ -204,7 +38,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on API Key 已创建 - 明文只显示这一次,关闭后无法再查看。请现在复制保存。 + 明文只显示这一次,关闭后无法再查看。
{plaintext} @@ -218,7 +52,7 @@ function CreatedKeyDialog({ plaintext, onClose }: { plaintext: string | null; on setCopied(true); toast.success("已复制"); } catch { - toast.error("复制失败,请手动选中复制"); + toast.error("复制失败,手动选中复制"); } }} > @@ -249,8 +83,8 @@ export function ApiKeysSection() { const parsed = ApiKeyCreate.safeParse({ name: name.trim(), scopes }); if (!parsed.success) { const fe = zodFieldErrors(parsed.error.issues); - if (fe.name) fe.name = "请填写名称(80 字以内)"; - if (fe.scopes) fe.scopes = "至少选择一个权限范围"; + if (fe.name) fe.name = "名称必填,80 字以内"; + if (fe.scopes) fe.scopes = "至少选一个权限范围"; setErrors(fe); return; } @@ -267,16 +101,20 @@ export function ApiKeysSection() { }; return ( -
-
-

- 给外部程序(媒体服务器插件、脚本)用的密钥,按权限范围授权。控制台本身走登录会话,不需要 Key。 -

+ setOpen(true)}> 创建 API Key -
+ } + > + r.id} @@ -388,7 +226,7 @@ export function ApiKeysSection() { open={!!revoking} onOpenChange={(o) => !o && setRevoking(null)} title={`撤销「${revoking?.name ?? ""}」?`} - description="撤销后使用这个 Key 的程序会立即失去访问权限,无法恢复。" + description="使用这个 Key 的程序立即失去访问权限,无法恢复。" confirmLabel="撤销" destructive onConfirm={async () => { @@ -402,6 +240,21 @@ export function ApiKeysSection() { } }} /> -
+ + ); +} + +function ScopeLegend() { + return ( +
+ {SCOPES.map((s) => ( +
+
+ {s.label} +
+
{s.hint}
+
+ ))} +
); } diff --git a/apps/web/app/components/settings/automation.tsx b/apps/web/app/components/settings/automation.tsx new file mode 100644 index 00000000..71489589 --- /dev/null +++ b/apps/web/app/components/settings/automation.tsx @@ -0,0 +1,189 @@ +import { Pause, Play, ShieldAlert } from "lucide-react"; +import type { ReactNode } from "react"; +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 { num } from "./resource-section.tsx"; +import { SettingsBlock } from "./settings-block.tsx"; +import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx"; + +// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数 + +const GiB = 1024 ** 3; + +const CACHE_CARD: CardSpec = { + anchor: "cache", + 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.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" }, + ], + fromSettings: (s) => ({ + "cache.hardThresholdPercent": n(s.cache.hardThresholdPercent), + "cache.resumePercent": n(s.cache.resumePercent), + "cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent), + "cache.checkIntervalSec": n(s.cache.checkIntervalSec), + }), + toPatch: (v) => ({ + cache: { + hardThresholdPercent: num(v["cache.hardThresholdPercent"]), + resumePercent: num(v["cache.resumePercent"]), + cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]), + checkIntervalSec: num(v["cache.checkIntervalSec"]), + }, + }), +}; + +const BUDGET_CARD: CardSpec = { + anchor: "budget", + title: "每日预算", + description: "任一项到上限后不再建新的下载计划;前两项按自然日计。", + fields: [ + { name: "budget.maxNewTitlesPerDay", label: "每日新作品数", kind: "number" }, + { name: "budget.maxDownloadGiBPerDay", label: "每日下载量(GiB)", kind: "number" }, + { name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1–100" }, + ], + fromSettings: (s) => ({ + "budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay), + "budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100), + "budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads), + }), + toPatch: (v) => { + const gib = num(v["budget.maxDownloadGiBPerDay"]); + return { + budget: { + maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]), + maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB), + maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]), + }, + }; + }, +}; + +const RETENTION_CARD: CardSpec = { + anchor: "retention", + title: "保留", + fields: [ + { + name: "retention.seedRetentionDays", + label: "源文件保留天数", + kind: "number", + hint: "复制入库后下载源在缓存里至少留这么久;0–365", + }, + { name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" }, + ], + fromSettings: (s) => ({ + "retention.seedRetentionDays": n(s.retention.seedRetentionDays), + "retention.logRetentionDays": n(s.retention.logRetentionDays), + }), + toPatch: (v) => ({ + retention: { + seedRetentionDays: num(v["retention.seedRetentionDays"]), + logRetentionDays: num(v["retention.logRetentionDays"]), + }, + }), +}; + +function ToggleRow({ + id, + icon, + title, + description, + checked, + onChange, + disabled, + tone, +}: { + id: string; + icon: ReactNode; + title: string; + description: string; + checked: boolean; + onChange: (v: boolean) => void; + disabled: boolean; + tone: "success" | "warning"; +}) { + return ( +
+ + {icon} + + + + {description} + + +
+ ); +} + +function Switches() { + const update = useUpdateSettings(); + const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => { + try { + await update.mutateAsync({ automation: patch }); + toast.success(msg); + } catch (err) { + toast.error(errorMessage(err, "切换失败")); + } + }; + return ( + + {(s) => ( + +
+ : } + tone={s.automation.enabled ? "success" : "warning"} + title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`} + description="关闭后自动收录、下载、整理全部停止;查询与手动操作不受影响。" + checked={s.automation.enabled} + disabled={update.isPending} + onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")} + /> + } + tone={s.automation.readOnlyReconcile ? "warning" : "success"} + title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`} + description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。" + checked={s.automation.readOnlyReconcile} + disabled={update.isPending} + onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账" : "已退出只读对账")} + /> +
+
+ )} +
+ ); +} + +export function AutomationSection() { + return ( +
+ + + {(s) => ( + <> + + + + + )} + +
+ ); +} diff --git a/apps/web/app/routes/console/settings/instances-tab.tsx b/apps/web/app/components/settings/instances.tsx similarity index 60% rename from apps/web/app/routes/console/settings/instances-tab.tsx rename to apps/web/app/components/settings/instances.tsx index ff2c47ae..824fd48d 100644 --- a/apps/web/app/routes/console/settings/instances-tab.tsx +++ b/apps/web/app/components/settings/instances.tsx @@ -8,20 +8,19 @@ import { type MediaServerInstance, MediaServerInstanceCreate, MediaServerInstanceUpdate, - type PathMapping, - PathMappingCreate, - PathMappingUpdate, } from "@umt/contracts"; import { PlugZap } from "lucide-react"; import { toast } from "sonner"; -import { downloaders, libraryRoots, mediaServers, pathMappings } from "~/api/instances.ts"; +import { downloaders, libraryRoots, mediaServers } from "~/api/instances.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatRelative, healthLabel } from "~/lib/labels.ts"; -import { type FormValues, nullStr, num, optStr, ResourceSection, str } from "./resource-section.tsx"; +import { PathMappingsPanel } from "./path-mappings.tsx"; +import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx"; +import { SettingsBlock } from "./settings-block.tsx"; -function Health({ status, at }: { status: string; at?: string | null }) { +export function Health({ status, at }: { status: string; at?: string | null }) { const h = healthLabel(status); return (
@@ -36,7 +35,7 @@ function Enabled({ on }: { on: boolean }) { } /** 行内"测试连接"按钮:结果 toast,health 写回后列表自动刷新 */ -function TestButton({ +export function TestButton({ run, pending, label = "测试连接", @@ -66,9 +65,17 @@ function TestButton({ ); } -const credentialHint = { editHint: "留空表示不修改已保存的凭证" }; +const credentialHint = { editHint: "留空不修改" }; export function DownloadersSection() { + return ( + + + + ); +} + +function DownloadersTable() { const list = downloaders.useList(); const create = downloaders.useCreate(); const update = downloaders.useUpdate(); @@ -80,8 +87,9 @@ export function DownloadersSection() { config={{ noun: "下载器实例", description: - "qBittorrent WebUI。系统只管理带管理标签的任务;共享同一缓存卷的多个实例填相同的缓存卷标识以合并水位。", + "只接管带管理标签的任务。多个实例共用一块缓存盘时填相同的缓存卷标识,水位合并计算。点行编辑实例和它的路径映射。", emptyHint: "添加 qBittorrent 后才能提交下载。", + drawerExtra: (r) => , columns: [ { key: "name", @@ -105,7 +113,7 @@ export function DownloadersSection() { ], rowActions: (r) => test.mutateAsync(r.id)} />, fields: [ - { name: "name", label: "名称", kind: "text", required: true, placeholder: "家里的 qB" }, + { name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:家里的 qB" }, { name: "baseUrl", label: "WebUI 地址", @@ -113,7 +121,7 @@ export function DownloadersSection() { required: true, placeholder: "http://192.168.1.10:8080", }, - { name: "username", label: "用户名", kind: "text", hint: "留空则把凭证当 WebUI API Key 使用" }, + { name: "username", label: "用户名", kind: "text", hint: "留空时凭证按 WebUI API Key 使用" }, { name: "credential", label: "密码 / API Key", kind: "password", ...credentialHint }, { name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" }, { name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" }, @@ -155,6 +163,14 @@ export function DownloadersSection() { } export function MediaServersSection() { + return ( + + + + ); +} + +function MediaServersTable() { const list = mediaServers.useList(); const create = mediaServers.useCreate(); const update = mediaServers.useUpdate(); @@ -165,7 +181,8 @@ export function MediaServersSection() { hooks={{ list, create, update, remove } as never} config={{ noun: "媒体服务器", - description: "Emby / Jellyfin 只用于查询有无、季集与版本,查不到不等于缺失。", + description: "只用来查询作品有无、季集和版本;查不到不等于缺失。点行编辑实例和它的路径映射。", + drawerExtra: (r) => , columns: [ { key: "name", @@ -194,7 +211,7 @@ export function MediaServersSection() { ], }, { name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" }, - { name: "userId", label: "用户 ID", kind: "text", hint: "可选;按该用户视角查询" }, + { name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" }, { name: "credential", label: "API Key", kind: "password", ...credentialHint }, { name: "enabled", label: "启用", kind: "switch" }, ], @@ -230,7 +247,7 @@ const MEDIA_KIND = [ { value: "other", label: "其它" }, ]; -export function LibraryRootsSection() { +export function LibraryRootsTable() { const list = libraryRoots.useList(); const create = libraryRoots.useCreate(); const update = libraryRoots.useUpdate(); @@ -241,9 +258,8 @@ export function LibraryRootsSection() { hooks={{ list, create, update, remove } as never} config={{ noun: "库根", - description: - "登记或修改路径时会在目录里写入哨兵文件 .uhd-root,用来判断库根是否在线;目录不存在或不可写会被拒绝。", - deleteHint: "只删除登记,不会删除磁盘上的任何文件。", + description: "登记或改路径时会在目录里写哨兵文件 .uhd-root,据此判断库根是否在线;目录不存在或不可写时无法保存。", + deleteHint: "只删除登记,磁盘上的文件不动。", columns: [ { key: "name", @@ -271,7 +287,7 @@ export function LibraryRootsSection() { ], rowActions: (r) => test.mutateAsync(r.id)} />, fields: [ - { name: "name", label: "名称", kind: "text", required: true, placeholder: "电影库" }, + { name: "name", label: "名称", kind: "text", required: true, placeholder: "例如:电影库" }, { name: "mediaKind", label: "内容类型", kind: "select", options: MEDIA_KIND }, { name: "localPath", @@ -279,7 +295,7 @@ export function LibraryRootsSection() { kind: "text", required: true, placeholder: "/media/movies", - hint: "执行进程可访问的绝对路径", + hint: "服务进程能访问的绝对路径", wide: true, }, { @@ -287,8 +303,8 @@ export function LibraryRootsSection() { label: "管理方式", kind: "select", options: [ - { value: "register_only", label: "只登记(不自动维护)" }, - { value: "managed", label: "自动维护(补齐/升级)" }, + { value: "register_only", label: "只登记" }, + { value: "managed", label: "自动维护(补齐、升级)" }, ], }, { name: "enabled", label: "启用", kind: "switch" }, @@ -324,150 +340,3 @@ export function LibraryRootsSection() { /> ); } - -export function PathMappingsSection() { - const list = pathMappings.useList(); - const create = pathMappings.useCreate(); - const update = pathMappings.useUpdate(); - const remove = pathMappings.useRemove(); - const verify = pathMappings.useVerify(); - const dls = downloaders.useList(); - const mss = mediaServers.useList(); - const roots = libraryRoots.useList(); - const dlOpts = (dls.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })); - const msOpts = (mss.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })); - const rootOpts = [ - { value: "none", label: "不关联" }, - ...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })), - ]; - const nameOf = (r: PathMapping) => - r.viewKind === "downloader" - ? (dls.data?.items.find((d) => d.id === r.downloaderInstanceId)?.name ?? "下载器") - : (mss.data?.items.find((d) => d.id === r.mediaServerInstanceId)?.name ?? "媒体服务器"); - - return ( - - hooks={{ list, create, update, remove } as never} - config={{ - noun: "路径映射", - description: "把下载器或媒体服务器看到的路径前缀换成本进程可访问的路径。前缀越长越优先,同长按优先级。", - columns: [ - { - key: "map", - header: "映射", - cell: (r) => ( -
-

{r.remotePrefix}

-

→ {r.localPrefix}

-
- ), - }, - { - key: "view", - header: "视图", - hideBelow: "md", - cell: (r) => ( - - {r.viewKind === "downloader" ? "下载器" : "媒体服务器"} · {nameOf(r)} - - ), - }, - { key: "prio", header: "优先级", hideBelow: "lg", cell: (r) => r.priority }, - { key: "verify", header: "校验", cell: (r) => }, - ], - rowActions: (r) => ( - { - const m = await verify.mutateAsync(r.id); - return { ok: m.verifyStatus === "ok", message: String(m.verifyDetail?.message ?? "本地前缀不可达") }; - }} - /> - ), - fields: [ - { - name: "viewKind", - label: "路径视图", - kind: "select", - options: [ - { value: "downloader", label: "下载器看到的路径" }, - { value: "media_server", label: "媒体服务器看到的路径" }, - ], - }, - { - name: "downloaderInstanceId", - label: "下载器实例", - kind: "select", - options: dlOpts, - placeholder: dlOpts.length ? "请选择" : "先添加下载器实例", - visible: (v) => v.viewKind === "downloader", - }, - { - name: "mediaServerInstanceId", - label: "媒体服务器", - kind: "select", - options: msOpts, - placeholder: msOpts.length ? "请选择" : "先添加媒体服务器", - visible: (v) => v.viewKind === "media_server", - }, - { - name: "remotePrefix", - label: "对方路径前缀", - kind: "text", - required: true, - placeholder: "/downloads", - wide: true, - }, - { - name: "localPrefix", - label: "本地路径前缀", - kind: "text", - required: true, - placeholder: "/mnt/cache/downloads", - wide: true, - }, - { name: "libraryRootId", label: "关联库根", kind: "select", options: rootOpts }, - { name: "priority", label: "优先级", kind: "number", hint: "数字越大越优先" }, - { name: "enabled", label: "启用", kind: "switch" }, - ], - initial: { - viewKind: "downloader", - downloaderInstanceId: "", - mediaServerInstanceId: "", - remotePrefix: "", - localPrefix: "", - libraryRootId: "none", - priority: "0", - enabled: true, - }, - fromRow: (r) => ({ - viewKind: r.viewKind, - downloaderInstanceId: r.downloaderInstanceId ?? "", - mediaServerInstanceId: r.mediaServerInstanceId ?? "", - remotePrefix: r.remotePrefix, - localPrefix: r.localPrefix, - libraryRootId: r.libraryRootId ?? "none", - priority: String(r.priority), - enabled: r.enabled, - }), - toBody: (v) => { - const dl = v.viewKind === "downloader"; - const root = str(v.libraryRootId); - return { - viewKind: str(v.viewKind), - downloaderInstanceId: dl ? nullStr(v.downloaderInstanceId) : null, - mediaServerInstanceId: dl ? null : nullStr(v.mediaServerInstanceId), - remotePrefix: str(v.remotePrefix), - localPrefix: str(v.localPrefix), - libraryRootId: root && root !== "none" ? root : null, - priority: num(v.priority) ?? 0, - enabled: v.enabled === true, - }; - }, - createSchema: PathMappingCreate, - updateSchema: PathMappingUpdate, - }} - /> - ); -} diff --git a/apps/web/app/components/settings/library.tsx b/apps/web/app/components/settings/library.tsx new file mode 100644 index 00000000..5dd47d19 --- /dev/null +++ b/apps/web/app/components/settings/library.tsx @@ -0,0 +1,50 @@ +import { LibraryRootsTable } from "./instances.tsx"; +import { str } from "./resource-section.tsx"; +import { SettingsBlock } from "./settings-block.tsx"; +import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx"; + +// 媒体库与目录:库根(含哨兵状态)+ 默认整理方式 + +const ORGANIZE_CARD: CardSpec = { + anchor: "organize", + title: "整理方式", + description: "复制入库时源文件留在缓存继续做种,保留天数在自动化分区设置。", + fields: [ + { + name: "organize.defaultMode", + label: "默认整理方式", + kind: "select", + options: [ + { value: "copy", label: "复制(源文件继续做种)" }, + { value: "move", label: "移动(移走源文件)" }, + ], + }, + { + name: "organize.allowCrossVolumeMove", + label: "允许跨卷移动", + kind: "switch", + hint: "跨卷时按复制 → 核验 → 删源执行", + }, + ], + fromSettings: (s) => ({ + "organize.defaultMode": s.organize.defaultMode, + "organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove, + }), + toPatch: (v) => ({ + organize: { + defaultMode: str(v["organize.defaultMode"]) as "copy" | "move", + allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true, + }, + }), +}; + +export function LibrarySection() { + return ( +
+ + + + {(s) => } +
+ ); +} diff --git a/apps/web/app/components/settings/metadata.tsx b/apps/web/app/components/settings/metadata.tsx new file mode 100644 index 00000000..c7eb8b7f --- /dev/null +++ b/apps/web/app/components/settings/metadata.tsx @@ -0,0 +1,72 @@ +import { toast } from "sonner"; +import { useUpdateSettings } from "~/api/settings.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { str } from "./resource-section.tsx"; +import { type CardSpec, SettingsCard, SettingsGate } from "./settings-card.tsx"; + +// 元数据:TMDB API Key(只写,存 secret)与 Base URL + +const TMDB_CARD: CardSpec = { + anchor: "tmdb", + title: "TMDB", + description: "这里没填时使用环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。", + fields: [ + { + name: "metadata.tmdbApiKey", + label: "API Key", + kind: "password", + hint: "v3 API Key 或 v4 Read Access Token;留空不修改", + wide: true, + }, + { + name: "metadata.tmdbBaseUrl", + label: "Base URL", + kind: "url", + placeholder: "https://api.themoviedb.org", + hint: "自建反代时填写;留空用官方地址", + wide: true, + }, + ], + fromSettings: (s) => ({ "metadata.tmdbApiKey": "", "metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "" }), + toPatch: (v) => ({ + metadata: { + tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null, + ...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}), + }, + }), + extra: (s) => , +}; + +function TmdbKeyStatus({ saved }: { saved: boolean }) { + const update = useUpdateSettings(); + return ( +
+ {saved ? "API Key 已保存" : "未保存 API Key"} + {saved ? ( + + ) : null} +
+ ); +} + +export function MetadataSection() { + return {(s) => }; +} diff --git a/apps/web/app/components/settings/path-mappings.tsx b/apps/web/app/components/settings/path-mappings.tsx new file mode 100644 index 00000000..ee4e6ac7 --- /dev/null +++ b/apps/web/app/components/settings/path-mappings.tsx @@ -0,0 +1,229 @@ +import { type PathMapping, PathMappingCreate } from "@umt/contracts"; +import { LoaderCircle, Plus, ShieldCheck, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { libraryRoots, pathMappings } from "~/api/instances.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { DetailSection } from "~/components/console/detail-sheet.tsx"; +import { zodFieldErrors } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { healthLabel } from "~/lib/labels.ts"; +import { FieldControl, type FormValues, num, str } from "./resource-section.tsx"; + +// 实例抽屉里的路径映射:只列本实例的映射,可新增、校验、删除。 +// 对方看到的路径前缀 → 本进程可访问的前缀;前缀越长越优先,同长按优先级。 + +const EMPTY: FormValues = { remotePrefix: "", localPrefix: "", libraryRootId: "none", priority: "0" }; + +export function PathMappingsPanel({ + viewKind, + instanceId, +}: { + viewKind: "downloader" | "media_server"; + instanceId: string; +}) { + const list = pathMappings.useList(); + const create = pathMappings.useCreate(); + const remove = pathMappings.useRemove(); + const verify = pathMappings.useVerify(); + const roots = libraryRoots.useList(); + const [adding, setAdding] = useState(false); + const [v, setV] = useState(EMPTY); + const [errors, setErrors] = useState>({}); + const [deleting, setDeleting] = useState(null); + + const rows = (list.data?.items ?? []).filter((m) => + viewKind === "downloader" ? m.downloaderInstanceId === instanceId : m.mediaServerInstanceId === instanceId, + ); + const rootOpts = [ + { value: "none", label: "不关联" }, + ...(roots.data?.items ?? []).map((d) => ({ value: d.id, label: d.name })), + ]; + const rootName = (id: string | null) => roots.data?.items.find((r) => r.id === id)?.name; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const root = str(v.libraryRootId); + const parsed = PathMappingCreate.safeParse({ + viewKind, + downloaderInstanceId: viewKind === "downloader" ? instanceId : null, + mediaServerInstanceId: viewKind === "media_server" ? instanceId : null, + remotePrefix: str(v.remotePrefix), + localPrefix: str(v.localPrefix), + libraryRootId: root && root !== "none" ? root : null, + priority: num(v.priority) ?? 0, + enabled: true, + }); + if (!parsed.success) { + const fe = zodFieldErrors(parsed.error.issues); + if (fe.localPrefix) fe.localPrefix = "填以 / 开头的绝对路径"; + if (fe.remotePrefix) fe.remotePrefix = "必填"; + setErrors(fe); + return; + } + setErrors({}); + try { + await create.mutateAsync(parsed.data as never); + toast.success("路径映射已添加"); + setV(EMPTY); + setAdding(false); + } catch (err) { + toast.error(errorMessage(err, "添加失败")); + } + }; + + return ( + setAdding(true)}> + + 添加映射 + + ) + } + > +

+ 把{viewKind === "downloader" ? "下载器" : "媒体服务器"} + 看到的路径换成本服务能访问的路径。前缀越长越优先,同长比优先级。 +

+ {list.isPending ? ( +

加载中…

+ ) : rows.length === 0 ? ( +

还没有映射,路径两边一致时不用加。

+ ) : ( +
    + {rows.map((m) => { + const h = healthLabel(m.verifyStatus); + return ( +
  • +
    +

    {m.remotePrefix}

    +

    → {m.localPrefix}

    + {rootName(m.libraryRootId) ? ( +

    库根:{rootName(m.libraryRootId)}

    + ) : null} +
    + {h.label} + + +
  • + ); + })} +
+ )} + {adding ? ( +
+ setV((s) => ({ ...s, remotePrefix: x }))} + disabled={create.isPending} + /> + setV((s) => ({ ...s, localPrefix: x }))} + disabled={create.isPending} + /> + setV((s) => ({ ...s, libraryRootId: x }))} + disabled={create.isPending} + /> + setV((s) => ({ ...s, priority: x }))} + disabled={create.isPending} + /> +
+ + +
+ + ) : null} + !o && setDeleting(null)} + title="删除路径映射?" + description="依赖这条映射的路径换算会失效,磁盘文件不动。" + confirmLabel="删除" + destructive + onConfirm={async () => { + if (!deleting) return; + try { + await remove.mutateAsync(deleting.id); + toast.success("路径映射已删除"); + } catch (err) { + toast.error(errorMessage(err, "删除失败")); + throw err; + } + }} + /> +
+ ); +} diff --git a/apps/web/app/components/settings/pi-oauth.tsx b/apps/web/app/components/settings/pi-oauth.tsx new file mode 100644 index 00000000..1f6d8b17 --- /dev/null +++ b/apps/web/app/components/settings/pi-oauth.tsx @@ -0,0 +1,307 @@ +import type { PiOAuthMode, PiOAuthStartResult, PiOAuthState, PiProviderInfo, PiUpstream } from "@umt/contracts"; +import { Check, Copy, ExternalLink, LoaderCircle } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { completePiOAuth, useInvalidatePi, useStartPiOAuth } from "~/api/pi.ts"; +import { FormField } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; + +// 订阅登录:start → browser 粘回地址栏 URL / device 前端轮询 complete → 终态。 +// 登录会话只在服务端内存里,15 分钟过期;失败、过期、被取消都可以重新开始。 + +const MODE_LABEL: Record = { device: "验证码", browser: "浏览器授权" }; + +const END_LABEL: Record< + Exclude, + { label: string; tone: "success" | "destructive" | "warning" } +> = { + succeeded: { label: "已登录", tone: "success" }, + failed: { label: "登录失败", tone: "destructive" }, + expired: { label: "登录会话已过期", tone: "warning" }, + cancelled: { label: "登录已取消", tone: "warning" }, +}; + +function CopyButton({ text, label }: { text: string; label: string }) { + const [done, setDone] = useState(false); + return ( + + ); +} + +function useCountdown(expiresAt: string | null) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!expiresAt) return; + const t = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(t); + }, [expiresAt]); + if (!expiresAt) return null; + const left = Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 1000)); + return left; +} + +export function PiOAuthLogin({ + upstream, + provider, + onSucceeded, +}: { + upstream: PiUpstream; + provider: PiProviderInfo | undefined; + onSucceeded?: () => void; +}) { + const start = useStartPiOAuth(); + const invalidate = useInvalidatePi(); + const modes = provider?.oauth?.modes ?? ["browser"]; + const [mode, setMode] = useState(modes[0] ?? "browser"); + const [session, setSession] = useState(null); + const [state, setState] = useState(null); + const [message, setMessage] = useState(null); + const [progress, setProgress] = useState([]); + const [pasted, setPasted] = useState(""); + const [submitting, setSubmitting] = useState(false); + const abortRef = useRef(null); + const left = useCountdown(state === "pending" ? (session?.expiresAt ?? null) : null); + const termsNote = provider?.oauth?.termsNote ?? provider?.termsNote ?? null; + + useEffect(() => () => abortRef.current?.abort(), []); + + const finish = (s: PiOAuthState, msg: string | null) => { + setState(s); + setMessage(msg); + invalidate(); + if (s === "succeeded") { + toast.success(`${upstream.name}已登录`); + onSucceeded?.(); + } + }; + + // device:反复调 complete(每次服务端最多等 25 秒)直到终态或本地判定过期 + const poll = async (s: PiOAuthStartResult) => { + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + while (!ac.signal.aborted) { + if (Date.now() > new Date(s.expiresAt).getTime()) { + finish("expired", "超过有效期仍未完成授权。"); + return; + } + try { + const r = await completePiOAuth(upstream.id, { sessionId: s.sessionId, waitMs: 25_000 }, ac.signal); + setProgress(r.progress); + if (r.state !== "pending") { + finish(r.state, r.message); + return; + } + } catch (err) { + if (ac.signal.aborted) return; + finish("failed", errorMessage(err, "轮询登录状态失败")); + return; + } + } + }; + + const begin = async () => { + abortRef.current?.abort(); + setMessage(null); + setProgress([]); + setPasted(""); + try { + const r = await start.mutateAsync({ id: upstream.id, body: { mode } }); + setSession(r); + setState(r.state); + setMessage(r.message); + if (r.state === "pending" && r.mode === "device") void poll(r); + } catch (err) { + setSession(null); + setState(null); + toast.error(errorMessage(err, "无法开始登录")); + } + }; + + const submitPasted = async (e: React.FormEvent) => { + e.preventDefault(); + if (!session || !pasted.trim()) return; + setSubmitting(true); + try { + const r = await completePiOAuth(upstream.id, { + sessionId: session.sessionId, + pastedUrlOrCode: pasted.trim(), + waitMs: 25_000, + }); + setProgress(r.progress); + if (r.state === "pending") setMessage("还在处理,稍后再提交一次。"); + else finish(r.state, r.message); + } catch (err) { + toast.error(errorMessage(err, "提交失败")); + } finally { + setSubmitting(false); + } + }; + + const pending = state === "pending" && !!session; + const ended = state && state !== "pending" ? END_LABEL[state] : null; + const mmss = left === null ? "" : `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`; + + return ( +
+ {termsNote ? ( +

+ {termsNote} +

+ ) : null} + + {!pending ? ( +
+ {modes.length > 1 ? ( +
+ 登录方式 +
+ {modes.map((m) => ( + + ))} +
+
+ ) : null} + {ended ? ( +
+ {ended.label} + {message ? {message} : null} +
+ ) : null} + {state !== "succeeded" ? ( +
+ +
+ ) : null} +
+ ) : session.mode === "device" ? ( +
+

+ 在任意设备打开验证地址,输入下面的验证码。授权后这里自动完成。 +

+
+ + {session.userCode} + + {session.userCode ? : null} +
+ {session.verificationUrl ? ( + + {session.verificationUrl} + + + ) : null} + {session.instructions ?

{session.instructions}

: null} +

+ + 等待授权 · 剩余 {mmss} + {progress.length ? ` · ${progress[progress.length - 1]}` : ""} +

+
+ +
+
+ ) : ( +
+

+ 在自己的浏览器打开授权链接。授权后会跳到 localhost 地址,页面打不开是正常的,把地址栏里的完整 URL 粘到下面。 +

+ {session.authorizeUrl ? ( + + ) : null} + + setPasted(e.target.value)} + placeholder="http://localhost:…/callback?code=…&state=…" + autoComplete="off" + disabled={submitting} + /> + + {message ?

{message}

: null} +
+ + +
+
+ )} +
+ ); +} diff --git a/apps/web/app/components/settings/pi-shared.tsx b/apps/web/app/components/settings/pi-shared.tsx new file mode 100644 index 00000000..578adc5f --- /dev/null +++ b/apps/web/app/components/settings/pi-shared.tsx @@ -0,0 +1,108 @@ +import type { PiAuthKind, PiProviderGroup, PiProviderInfo, PiThinkingLevel, PiUpstream } from "@umt/contracts"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { Input } from "~/components/ui/input.tsx"; + +// Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游") + +export const GROUP_LABEL: Record = { + api_key: "API Key", + subscription: "订阅登录", + cloud: "云凭据", + custom: "兼容端点", + test: "测试", +}; +export const GROUP_ORDER: PiProviderGroup[] = ["api_key", "subscription", "cloud", "custom", "test"]; + +export const AUTH_LABEL: Record = { + api_key: "API Key", + oauth: "订阅登录", + none: "不需要凭据", +}; + +export const THINKING_LABEL: Record = { + off: "关闭", + minimal: "最低", + low: "低", + medium: "中", + high: "高", + xhigh: "很高", + max: "最高", +}; +export const THINKING_OPTIONS = (Object.keys(THINKING_LABEL) as PiThinkingLevel[]).map((v) => ({ + value: v, + label: `${THINKING_LABEL[v]}(${v})`, +})); + +export const API_OPTIONS = [ + { value: "openai-completions", label: "OpenAI Chat Completions" }, + { value: "openai-responses", label: "OpenAI Responses" }, + { value: "anthropic-messages", label: "Anthropic Messages" }, + { value: "google-generative-ai", label: "Google Generative AI" }, +]; + +export function upstreamStatus(u: Pick): { + label: string; + tone: StatusTone; +} { + if (!u.enabled) return { label: "已停用", tone: "neutral" }; + switch (u.status) { + case "ok": + return { label: "可用", tone: "success" }; + case "auth_required": + return { label: u.authKind === "oauth" ? "待登录" : "缺 API Key", tone: "warning" }; + case "expired": + return { label: "登录已失效", tone: "destructive" }; + case "error": + return { label: "出错", tone: "destructive" }; + } +} + +export function UpstreamStatusBadge({ u }: { u: Pick }) { + const s = upstreamStatus(u); + return {s.label}; +} + +export const providerName = (providers: PiProviderInfo[] | undefined, id: string) => + providers?.find((p) => p.id === id)?.name ?? id; + +/** 模型 id:可从列表挑,也可手填(目录滞后于上游时) */ +export function ModelInput({ + id, + value, + onChange, + models, + disabled, + invalid, + placeholder, +}: { + id: string; + value: string; + onChange: (v: string) => void; + models: { id: string; name: string }[]; + disabled?: boolean; + invalid?: boolean; + placeholder?: string; +}) { + const listId = `${id}-models`; + return ( + <> + onChange(e.target.value)} + disabled={disabled} + aria-invalid={invalid} + autoComplete="off" + placeholder={placeholder ?? (models.length ? `从 ${models.length} 个模型中选,或手填 id` : "模型 id")} + /> + + {models.map((m) => ( + + ))} + + + ); +} diff --git a/apps/web/app/components/settings/pi-test.tsx b/apps/web/app/components/settings/pi-test.tsx new file mode 100644 index 00000000..fabc5492 --- /dev/null +++ b/apps/web/app/components/settings/pi-test.tsx @@ -0,0 +1,72 @@ +import type { PiUpstream, PiUpstreamTestResult } from "@umt/contracts"; +import { LoaderCircle, PlugZap, Send } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useTestPiUpstream } from "~/api/pi.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; + +// 上游测试:默认只验凭据与模型(不产生费用);"发一次最小补全"真实调用一次模型 + +const ERROR_KIND: Record = { + auth: "凭据被拒", + oauth: "登录失效", + model: "模型不可用", + network: "网络错误", + config: "配置错误", + other: "失败", +}; + +export function PiTestPanel({ upstream, model }: { upstream: PiUpstream; model?: string | null }) { + const test = useTestPiUpstream(); + const [result, setResult] = useState(null); + const [running, setRunning] = useState<"auth" | "completion" | null>(null); + + const run = async (mode: "auth" | "completion") => { + setRunning(mode); + try { + const r = await test.mutateAsync({ id: upstream.id, body: { mode, ...(model ? { model } : {}) } }); + setResult(r); + if (r.ok) toast.success(mode === "auth" ? "凭据可用" : "补全成功"); + else toast.error(r.message ?? "测试失败"); + } catch (err) { + setResult(null); + toast.error(errorMessage(err, "测试失败")); + } finally { + setRunning(null); + } + }; + + return ( +
+
+ + +
+

+ 「测试」只检查凭据和模型,不产生费用;最小补全会真实调用一次模型(最多 16 个 token)。 +

+ {result ? ( +
+ + {result.ok ? "通过" : ERROR_KIND[result.errorKind ?? "other"]} + + + {result.latencyMs} ms{result.model ? ` · ${result.model}` : ""} + {result.source ? ` · ${result.source}` : ""} + + {!result.ok && result.message ? ( + {result.message} + ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/components/settings/pi-upstreams.tsx b/apps/web/app/components/settings/pi-upstreams.tsx new file mode 100644 index 00000000..d7815e80 --- /dev/null +++ b/apps/web/app/components/settings/pi-upstreams.tsx @@ -0,0 +1,397 @@ +import { type PiProviderInfo, type PiThinkingLevel, type PiUpstream, PiUpstreamUpdate } from "@umt/contracts"; +import { LoaderCircle, LogOut, Plus, Star, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + useLogoutPiOAuth, + usePiProviders, + usePiUpstreamModels, + usePiUpstreams, + useRemovePiUpstream, + useUpdatePiUpstream, +} from "~/api/pi.ts"; +import { useSettings } from "~/api/settings.ts"; +import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; +import { DataTable } from "~/components/console/data-table.tsx"; +import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { formatRelative } from "~/lib/labels.ts"; +import { formatDateTime } from "~/lib/utils.ts"; +import { PiOAuthLogin } from "./pi-oauth.tsx"; +import { + AUTH_LABEL, + ModelInput, + providerName, + THINKING_LABEL, + THINKING_OPTIONS, + UpstreamStatusBadge, +} from "./pi-shared.tsx"; +import { PiTestPanel } from "./pi-test.tsx"; +import { PiUpstreamWizard } from "./pi-wizard.tsx"; +import { FieldControl } from "./resource-section.tsx"; +import { SettingsBlock } from "./settings-block.tsx"; + +// Agent 上游列表 + 编辑抽屉(登录、测试、设默认、删除) + +export function PiUpstreamsBlock() { + const list = usePiUpstreams(); + const providers = usePiProviders(false); + const settings = useSettings(); + const [wizard, setWizard] = useState(false); + const [openId, setOpenId] = useState(null); + const open = list.data?.items.find((u) => u.id === openId) ?? null; + const pv = providers.data?.providers; + const legacy = settings.data?.pi.provider; + + return ( + setWizard(true)}> + + 新建上游 + + } + > + r.id} + loading={list.isPending} + error={list.error} + onRetry={() => list.refetch()} + onRowClick={(r) => setOpenId(r.id)} + empty={{ + title: "还没有上游", + description: legacy ? `没有上游时按旧设置运行(provider:${legacy})。` : undefined, + }} + columns={[ + { + key: "name", + header: "名称", + cell: (r) => ( +
+

+ {r.name} + {r.isDefault ? 默认 : null} +

+

{providerName(pv, r.provider)}

+
+ ), + }, + { key: "auth", header: "认证", hideBelow: "md", cell: (r) => AUTH_LABEL[r.authKind] }, + { + key: "model", + header: "模型", + hideBelow: "sm", + cell: (r) => {r.defaultModel ?? "provider 默认"}, + }, + { key: "status", header: "状态", cell: (r) => }, + ]} + /> + p.id === open?.provider)} + onClose={() => setOpenId(null)} + /> + setWizard(false)} /> +
+ ); +} + +function UpstreamDrawer({ + upstream, + provider, + onClose, +}: { + upstream: PiUpstream | null; + provider: PiProviderInfo | undefined; + onClose: () => void; +}) { + const update = useUpdatePiUpstream(); + const remove = useRemovePiUpstream(); + const logout = useLogoutPiOAuth(); + const models = usePiUpstreamModels(upstream?.id ?? null); + const [deleting, setDeleting] = useState(false); + const [v, setV] = useState(() => ({ + name: upstream?.name ?? "", + model: upstream?.defaultModel ?? "", + thinking: (upstream?.thinkingLevel ?? "medium") as PiThinkingLevel, + baseUrl: upstream?.baseUrl ?? "", + apiKey: "", + enabled: upstream?.enabled ?? true, + env: { ...(upstream?.extra.env ?? {}) } as Record, + })); + const [errors, setErrors] = useState>({}); + if (!upstream) return null; + + const custom = upstream.provider === "openai-compatible"; + const showBaseUrl = (provider?.baseUrlOverridable ?? custom) && upstream.authKind !== "oauth"; + const showThinking = provider?.supportsThinking || custom; + const envFields = provider?.envFields ?? []; + const dirty = + v.name !== upstream.name || + v.model !== (upstream.defaultModel ?? "") || + v.thinking !== upstream.thinkingLevel || + v.baseUrl !== (upstream.baseUrl ?? "") || + !!v.apiKey || + v.enabled !== upstream.enabled || + envFields.some((f) => (v.env[f.name] ?? "") !== (upstream.extra.env?.[f.name] ?? "")); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + const env = Object.fromEntries(Object.entries(v.env).filter(([, x]) => x.trim())); + const parsed = PiUpstreamUpdate.safeParse({ + name: v.name.trim(), + defaultModel: v.model.trim() || null, + ...(showThinking ? { thinkingLevel: v.thinking } : {}), + ...(showBaseUrl ? { baseUrl: v.baseUrl.trim() || null } : {}), + ...(v.apiKey.trim() ? { apiKey: v.apiKey.trim() } : {}), + enabled: v.enabled, + ...(envFields.length ? { extra: { ...upstream.extra, env } } : {}), + }); + if (!parsed.success) { + setErrors(zodFieldErrors(parsed.error.issues)); + return; + } + setErrors({}); + try { + await update.mutateAsync({ id: upstream.id, body: parsed.data }); + setV((s) => ({ ...s, apiKey: "" })); + toast.success(`${v.name.trim()}已保存`); + } catch (err) { + toast.error(errorMessage(err, "保存失败")); + } + }; + + const makeDefault = async () => { + try { + await update.mutateAsync({ id: upstream.id, body: { makeDefault: true } }); + toast.success(`${upstream.name}已设为默认上游`); + } catch (err) { + toast.error(errorMessage(err, "设置失败")); + } + }; + + const pending = update.isPending; + return ( + !o && onClose()} + title={upstream.name} + description={ + + + {upstream.isDefault ? 默认上游 : null} + + {provider?.name ?? upstream.provider} · {AUTH_LABEL[upstream.authKind]} + + + } + width="lg" + footer={ + <> + + {upstream.isDefault ? null : ( + + )} + + + } + > +
+ {upstream.credentialMask ? ( + <> +
API Key
+
{upstream.credentialMask}
+ + ) : null} + {upstream.account ? ( + <> +
账号
+
{upstream.account}
+ + ) : null} + {upstream.oauthExpiresAt ? ( + <> +
令牌到期
+
{formatDateTime(upstream.oauthExpiresAt)}(到期前自动刷新)
+ + ) : null} +
最近验证
+
{upstream.lastVerifiedAt ? formatRelative(upstream.lastVerifiedAt) : "未验证"}
+ {upstream.lastError ? ( + <> +
最近错误
+
{upstream.lastError}
+ + ) : null} +
+ + {upstream.authKind === "oauth" ? ( + { + try { + await logout.mutateAsync(upstream.id); + toast.success("已退出登录"); + } catch (err) { + toast.error(errorMessage(err, "退出失败")); + } + }} + > + + 退出登录 + + ) : null + } + > + {upstream.hasCredential && upstream.status === "ok" ? ( +

已登录。退出只删除本地令牌,不会撤销提供方侧的授权。

+ ) : ( + + )} +
+ ) : null} + + +
+ setV((s) => ({ ...s, name: String(x) }))} + disabled={pending} + /> + setV((s) => ({ ...s, enabled: x === true }))} + disabled={pending} + /> + + setV((s) => ({ ...s, model: x }))} + models={models.data?.items ?? []} + disabled={pending} + /> + + {showThinking ? ( + setV((s) => ({ ...s, thinking: x as PiThinkingLevel }))} + disabled={pending} + /> + ) : null} + {showBaseUrl ? ( + setV((s) => ({ ...s, baseUrl: String(x) }))} + disabled={pending} + /> + ) : null} + {upstream.authKind === "api_key" ? ( + setV((s) => ({ ...s, apiKey: String(x) }))} + disabled={pending} + /> + ) : null} + {envFields.map((f) => ( + setV((s) => ({ ...s, env: { ...s.env, [f.name]: String(x) } }))} + disabled={pending} + /> + ))} + +
+ + + + + + { + try { + await remove.mutateAsync(upstream.id); + toast.success(`${upstream.name}已删除`); + onClose(); + } catch (err) { + toast.error(errorMessage(err, "删除失败")); + throw err; + } + }} + /> +
+ ); +} diff --git a/apps/web/app/components/settings/pi-wizard.tsx b/apps/web/app/components/settings/pi-wizard.tsx new file mode 100644 index 00000000..376ed10a --- /dev/null +++ b/apps/web/app/components/settings/pi-wizard.tsx @@ -0,0 +1,410 @@ +import { + type PiAuthKind, + type PiProviderInfo, + type PiThinkingLevel, + type PiUpstream, + PiUpstreamCreate, +} from "@umt/contracts"; +import { ChevronLeft, LoaderCircle, Search } from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { useCreatePiUpstream, usePiProviders, usePiUpstreamModels, useUpdatePiUpstream } from "~/api/pi.ts"; +import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; +import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; +import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { Input } from "~/components/ui/input.tsx"; +import { Switch } from "~/components/ui/switch.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; +import { PiOAuthLogin } from "./pi-oauth.tsx"; +import { API_OPTIONS, AUTH_LABEL, GROUP_LABEL, GROUP_ORDER, ModelInput, THINKING_OPTIONS } from "./pi-shared.tsx"; +import { PiTestPanel } from "./pi-test.tsx"; +import { FieldControl } from "./resource-section.tsx"; + +// 新建 Agent 上游:① 选 provider → ② 填凭据与模型并创建 → ③ 登录 / 测试,可设为默认 + +type Step = "provider" | "config" | "verify"; + +export function PiUpstreamWizard({ open, onClose }: { open: boolean; onClose: () => void }) { + const [step, setStep] = useState("provider"); + const [provider, setProvider] = useState(null); + const [created, setCreated] = useState(null); + + const close = () => { + onClose(); + // 关闭动画后再复位,避免闪回第一步 + setTimeout(() => { + setStep("provider"); + setProvider(null); + setCreated(null); + }, 200); + }; + + const title = + step === "provider" + ? "新建 Agent 上游" + : step === "config" + ? `新建上游 · ${provider?.name ?? ""}` + : (created?.name ?? ""); + const description = + step === "provider" + ? "第 1 步,共 3 步:选 provider" + : step === "config" + ? "第 2 步,共 3 步:凭据与模型" + : created?.authKind === "oauth" + ? "第 3 步,共 3 步:登录并测试。上游已创建。" + : "第 3 步,共 3 步:测试。上游已创建。"; + + return ( + !o && close()} title={title} description={description} width="lg"> + {step === "provider" ? ( + { + setProvider(p); + setStep("config"); + }} + /> + ) : step === "config" && provider ? ( + setStep("provider")} + onCreated={(u) => { + setCreated(u); + setStep("verify"); + }} + /> + ) : created ? ( + + ) : null} + + ); +} + +function ProviderPicker({ onPick }: { onPick: (p: PiProviderInfo) => void }) { + const q = usePiProviders(false); + const [filter, setFilter] = useState(""); + if (q.isPending) return ; + if (q.isError) return q.refetch()} />; + const f = filter.trim().toLowerCase(); + const list = q.data.providers.filter((p) => !f || p.name.toLowerCase().includes(f) || p.id.includes(f)); + + return ( +
+
+ + setFilter(e.target.value)} + className="pl-9" + /> +
+ {GROUP_ORDER.map((g) => { + const items = list.filter((p) => p.group === g); + if (!items.length) return null; + return ( + +
    + {items.map((p) => ( +
  • + +
  • + ))} +
+
+ ); + })} + {list.length === 0 ?

没有匹配的 provider。

: null} +
+ ); +} + +function ConfigStep({ + provider, + onBack, + onCreated, +}: { + provider: PiProviderInfo; + onBack: () => void; + onCreated: (u: PiUpstream) => void; +}) { + const full = usePiProviders(true); + const create = useCreatePiUpstream(); + const models = useMemo( + () => full.data?.providers.find((p) => p.id === provider.id)?.models ?? [], + [full.data, provider.id], + ); + const custom = provider.id === "openai-compatible"; + const [authKind, setAuthKind] = useState(provider.authKinds[0] ?? "none"); + const [name, setName] = useState(provider.name); + const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [model, setModel] = useState(""); + const [thinking, setThinking] = useState("medium"); + const [api, setApi] = useState("openai-completions"); + const [contextWindow, setContextWindow] = useState(""); + const [env, setEnv] = useState>({}); + const [errors, setErrors] = useState>({}); + const showBaseUrl = provider.baseUrlOverridable && authKind !== "oauth"; + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + const envVals = Object.fromEntries(Object.entries(env).filter(([, v]) => v.trim())); + const extra = { + ...(Object.keys(envVals).length ? { env: envVals } : {}), + ...(custom ? { api: api as never } : {}), + ...(custom && contextWindow.trim() ? { contextWindow: Number(contextWindow) } : {}), + }; + const parsed = PiUpstreamCreate.safeParse({ + name: name.trim(), + provider: provider.id, + authKind, + ...(authKind === "api_key" && apiKey.trim() ? { apiKey: apiKey.trim() } : {}), + baseUrl: showBaseUrl && baseUrl.trim() ? baseUrl.trim() : null, + defaultModel: model.trim() || null, + ...(provider.supportsThinking || custom ? { thinkingLevel: thinking } : {}), + extra, + }); + const fe: Record = parsed.success ? {} : zodFieldErrors(parsed.error.issues); + for (const f of provider.envFields) if (f.required && !env[f.name]?.trim()) fe[`env.${f.name}`] = `${f.label}必填`; + if (authKind === "api_key" && !apiKey.trim() && !custom) fe.apiKey = "填写 API Key"; + if (fe.name) fe.name = "名称必填,64 字以内"; + if (fe["extra.contextWindow"]) fe.contextWindow = "填 1024 以上的整数"; + if (!parsed.success || Object.keys(fe).length) { + setErrors(fe); + return; + } + setErrors({}); + try { + const u = await create.mutateAsync(parsed.data); + toast.success(`${u.name}已创建`); + onCreated(u); + } catch (err) { + toast.error(errorMessage(err, "创建失败")); + } + }; + + const pending = create.isPending; + return ( +
+
+ + {provider.note && !custom ?

{provider.note}

: null} +
+ + setName(e.target.value)} disabled={pending} /> + + {provider.authKinds.length > 1 ? ( + ({ value: k, label: AUTH_LABEL[k] })), + wide: true, + }} + value={authKind} + onChange={(v) => setAuthKind(v as PiAuthKind)} + disabled={pending} + /> + ) : null} + {authKind === "oauth" && provider.termsNote ? ( +

+ {provider.termsNote} +

+ ) : null} + {authKind === "api_key" ? ( + setApiKey(String(v))} + disabled={pending} + /> + ) : null} + {authKind === "none" && provider.group === "cloud" ? ( +

+ 不存密钥,运行时读服务端进程的环境凭据(AWS 凭据链 / Google ADC)。 +

+ ) : null} + {showBaseUrl ? ( + setBaseUrl(String(v))} + disabled={pending} + /> + ) : null} + {custom ? ( + <> + setApi(String(v))} + disabled={pending} + /> + setContextWindow(String(v))} + disabled={pending} + /> + + ) : null} + {provider.envFields.map((f) => ( + setEnv((s) => ({ ...s, [f.name]: String(v) }))} + disabled={pending} + /> + ))} + + + + {provider.supportsThinking || custom ? ( + setThinking(v as PiThinkingLevel)} + disabled={pending} + /> + ) : null} +
+ +
+ + ); +} + +function VerifyStep({ + upstream, + provider, + onDone, +}: { + upstream: PiUpstream; + provider: PiProviderInfo | null; + onDone: () => void; +}) { + const update = useUpdatePiUpstream(); + const models = usePiUpstreamModels(upstream.id); + const [model, setModel] = useState(upstream.defaultModel ?? ""); + const [makeDefault, setMakeDefault] = useState(upstream.isDefault); + const [loggedIn, setLoggedIn] = useState(upstream.authKind !== "oauth"); + + const finish = async () => { + const body = { + ...(model.trim() !== (upstream.defaultModel ?? "") ? { defaultModel: model.trim() || null } : {}), + ...(makeDefault && !upstream.isDefault ? { makeDefault: true } : {}), + }; + try { + if (Object.keys(body).length) await update.mutateAsync({ id: upstream.id, body }); + if (makeDefault && !upstream.isDefault) toast.success(`${upstream.name}已设为默认上游`); + onDone(); + } catch (err) { + toast.error(errorMessage(err, "保存失败")); + } + }; + + return ( +
+ {upstream.authKind === "oauth" ? ( + + setLoggedIn(true)} /> + + ) : null} + + + + + + + {loggedIn ? ( + + ) : ( +

登录后再测试。

+ )} +
+
+ + +
+
+ ); +} diff --git a/apps/web/app/routes/console/settings/resource-section.tsx b/apps/web/app/components/settings/resource-section.tsx similarity index 84% rename from apps/web/app/routes/console/settings/resource-section.tsx rename to apps/web/app/components/settings/resource-section.tsx index 729a8212..dcc47240 100644 --- a/apps/web/app/routes/console/settings/resource-section.tsx +++ b/apps/web/app/components/settings/resource-section.tsx @@ -6,23 +6,16 @@ import { toast } from "sonner"; import type { z } from "zod"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; +import { DetailSheet } from "~/components/console/detail-sheet.tsx"; import { FormField, zodFieldErrors } from "~/components/console/form-field.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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { Textarea } from "~/components/ui/textarea.tsx"; import { errorMessage } from "~/lib/api.ts"; -// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑对话框 + 删除 + 行内动作。 +// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。 // 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。 export type FieldValue = string | boolean; @@ -64,6 +57,8 @@ export type ResourceConfig = { emptyHint?: string; /** 删除确认的额外说明 */ deleteHint?: string; + /** 编辑抽屉里表单下方的附加内容(如该实例的路径映射) */ + drawerExtra?: (row: Row) => ReactNode; }; type Hooks = { @@ -91,7 +86,12 @@ export function ResourceSection({ header: 操作, align: "right", cell: (row) => ( -
+ // biome-ignore lint/a11y/noStaticElementInteractions: 只拦截冒泡,避免行内按钮触发整行打开抽屉 +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > {config.rowActions?.(row)} - - - - + + } + > +
+ {config.fields + .filter((f) => !f.visible || f.visible(values)) + .map((f) => ( + set(f.name, v)} + disabled={pending || (mode === "edit" && !!f.createOnly)} + hint={mode === "edit" && f.editHint ? f.editHint : f.hint} + /> + ))} + + {row && row !== "new" && config.drawerExtra ? config.drawerExtra(row) : null} + ); } @@ -277,7 +278,7 @@ export function FieldControl({ control = ( navigate({ hash: settingsHash(id) })}> + + + + + {SECTIONS.map((s) => ( + + + {s.label} + + ))} + + +

{section.description}

+
+ + {/* 宽屏:左侧分区导航 */} + + +
+
+

{section.label}

+

{section.description}

+
+
{section.render()}
+
+
); } diff --git a/apps/web/app/routes/console/settings/prefs-tab.tsx b/apps/web/app/routes/console/settings/prefs-tab.tsx deleted file mode 100644 index b5cd7a6f..00000000 --- a/apps/web/app/routes/console/settings/prefs-tab.tsx +++ /dev/null @@ -1,494 +0,0 @@ -import { - type GlobalSettings, - GlobalSettings as GlobalSettingsSchema, - type GlobalSettingsUpdate, - mergeSettings, -} from "@umt/contracts"; -import { LoaderCircle, Pause, Play, ShieldAlert } from "lucide-react"; -import { type ReactNode, useState } from "react"; -import { toast } from "sonner"; -import { useSettings, useUpdateSettings } from "~/api/settings.ts"; -import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; -import { zodFieldErrors } from "~/components/console/form-field.tsx"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; -import { Button } from "~/components/ui/button.tsx"; -import { Switch } from "~/components/ui/switch.tsx"; -import { errorMessage } from "~/lib/api.ts"; -import { cn } from "~/lib/utils.ts"; -import { FieldControl, type FieldSpec, type FormValues, num, str } from "./resource-section.tsx"; - -// 全局设置表单:每张卡片一组字段,保存时 PATCH 对应分组; -// 校验 = 合并现值后用 contracts GlobalSettings 整体校验(含"恢复水位 < 硬阈值"等交叉规则)。 - -type CardSpec = { - title: string; - description?: ReactNode; - fields: FieldSpec[]; - fromSettings: (s: GlobalSettings) => FormValues; - toPatch: (v: FormValues, s: GlobalSettings) => GlobalSettingsUpdate; - extra?: (s: GlobalSettings) => ReactNode; -}; - -function SettingsCard({ spec, settings }: { spec: CardSpec; settings: GlobalSettings }) { - const update = useUpdateSettings(); - const [values, setValues] = useState(() => spec.fromSettings(settings)); - const [errors, setErrors] = useState>({}); - const initial = spec.fromSettings(settings); - const dirty = Object.keys(values).some((k) => values[k] !== initial[k]); - - const save = async (e: React.FormEvent) => { - e.preventDefault(); - const patch = spec.toPatch(values, settings); - const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch)); - if (!parsed.success) { - const fe = zodFieldErrors(parsed.error.issues); - // 交叉校验(refine)落在分组上:挂到本卡第一个字段 - for (const [k, m] of Object.entries(fe)) { - if (!spec.fields.some((f) => f.name === k)) - fe[spec.fields.find((f) => k.startsWith(f.name.split(".")[0] ?? ""))?.name ?? k] ??= m; - } - setErrors(fe); - return; - } - setErrors({}); - try { - const next = await update.mutateAsync(patch); - setValues(spec.fromSettings(next)); - toast.success(`已保存${spec.title}`); - } catch (err) { - toast.error(errorMessage(err, "保存失败")); - } - }; - - return ( -
-

{spec.title}

- {spec.description ? ( -

{spec.description}

- ) : ( -
- )} -
- {spec.fields - .filter((f) => !f.visible || f.visible(values)) - .map((f) => ( - setValues((s) => ({ ...s, [f.name]: v }))} - /> - ))} -
- {spec.extra?.(settings)} -
- - {dirty ? ( - - ) : null} -
- - ); -} - -function useSettingsGate(render: (s: GlobalSettings) => ReactNode) { - const q = useSettings(); - if (q.isPending) return ; - if (q.isError) return q.refetch()} />; - return render(q.data); -} - -const n = (v: number | null | undefined) => (v === null || v === undefined ? "" : String(v)); - -// ─── Pi 上游 ─── - -const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh"].map((v) => ({ value: v, label: v })); - -const PI_CARD: CardSpec = { - title: "Pi 上游与模型", - description: "Agent 使用的模型上游。密钥只写入、加密保存,不回显。", - fields: [ - { - name: "pi.provider", - label: "Provider", - kind: "select", - required: true, - options: [ - { value: "anthropic", label: "anthropic" }, - { value: "openai-compatible", label: "openai-compatible(baseUrl 带 /v1)" }, - { value: "fake", label: "fake(脚本化假模型,不联网,演示用)" }, - ], - }, - { name: "pi.model", label: "模型", kind: "text", placeholder: "留空用 provider 默认" }, - { name: "pi.baseUrl", label: "Base URL", kind: "url", placeholder: "留空用官方地址", wide: true }, - { name: "pi.apiKey", label: "API Key", kind: "password", hint: "留空表示不修改", wide: true }, - { name: "pi.thinkingLevel", label: "思考档位", kind: "select", options: THINKING }, - { name: "pi.contextBudget", label: "上下文预算(token)", kind: "number", hint: "4096 – 2000000" }, - { name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" }, - { - name: "pi.compaction.thresholdRatio", - label: "压缩触发比例", - kind: "number", - hint: "0.3 – 0.95,达到预算的该比例时压缩", - visible: (v) => v["pi.compaction.enabled"] === true, - }, - { name: "pi.subagentsEnabled", label: "允许子代理", kind: "switch" }, - ], - fromSettings: (s) => ({ - "pi.provider": s.pi.provider, - "pi.model": s.pi.model ?? "", - "pi.baseUrl": s.pi.baseUrl ?? "", - "pi.apiKey": "", - "pi.thinkingLevel": s.pi.thinkingLevel, - "pi.contextBudget": n(s.pi.contextBudget), - "pi.compaction.enabled": s.pi.compaction.enabled, - "pi.compaction.thresholdRatio": n(s.pi.compaction.thresholdRatio), - "pi.subagentsEnabled": s.pi.subagentsEnabled, - }), - toPatch: (v) => ({ - pi: { - provider: str(v["pi.provider"]), - model: str(v["pi.model"]) || null, - baseUrl: str(v["pi.baseUrl"]) || null, - ...(str(v["pi.apiKey"]) ? { apiKey: str(v["pi.apiKey"]) } : {}), - thinkingLevel: str(v["pi.thinkingLevel"]) as GlobalSettings["pi"]["thinkingLevel"], - contextBudget: num(v["pi.contextBudget"]), - compaction: { - enabled: v["pi.compaction.enabled"] === true, - thresholdRatio: num(v["pi.compaction.thresholdRatio"]), - }, - subagentsEnabled: v["pi.subagentsEnabled"] === true, - }, - }), - extra: (s) => , -}; - -function PiKeyStatus({ set }: { set: boolean }) { - const update = useUpdateSettings(); - return ( -
- {set ? "上游密钥已保存" : "未设置上游密钥"} - {set ? ( - - ) : null} -
- ); -} - -const AGENT_BUDGET_CARD: CardSpec = { - title: "Agent 预算", - description: "超过预算的 Agent 运行会停在「超预算」状态,等你放行。", - fields: [ - { name: "budget.agentMaxRunsPerDay", label: "每日最多运行次数", kind: "number" }, - { name: "budget.agentMaxCostUsdPerDay", label: "每日费用上限(USD)", kind: "number" }, - ], - fromSettings: (s) => ({ - "budget.agentMaxRunsPerDay": n(s.budget.agentMaxRunsPerDay), - "budget.agentMaxCostUsdPerDay": n(s.budget.agentMaxCostUsdPerDay), - }), - toPatch: (v) => ({ - budget: { - agentMaxRunsPerDay: num(v["budget.agentMaxRunsPerDay"]), - agentMaxCostUsdPerDay: num(v["budget.agentMaxCostUsdPerDay"]), - }, - }), -}; - -const AGENT_RUNTIME_CARD: CardSpec = { - title: "Agent 运行", - description: - "单次运行预算、后台自动调查(只读调查或授权内恢复);provider 选 fake 时按下面的脚本回放工具调用(留空用内置演示脚本)。", - fields: [ - { name: "agent.maxToolCalls", label: "单次工具调用上限", kind: "number", hint: "1 – 500" }, - { name: "agent.maxDurationSec", label: "单次时长上限(秒)", kind: "number" }, - { name: "agent.autoInvestigate", label: "待处理事项自动调查", kind: "switch" }, - { - name: "agent.autoInvestigateMode", - label: "自动调查模式", - kind: "select", - options: [ - { value: "investigate", label: "只读调查(结论交还给你)" }, - { value: "recover", label: "授权内恢复(可重试任务 / 重新校验 / 确认识别)" }, - ], - hint: "恢复只在已发布策略或持续授权覆盖时执行,换源、删除、清理始终排除;没有覆盖的授权时自动退回只读调查", - wide: true, - visible: (v) => v["agent.autoInvestigate"] === true, - }, - { - name: "agent.investigateMaxToolCalls", - label: "自动调查工具调用上限", - kind: "number", - visible: (v) => v["agent.autoInvestigate"] === true, - }, - { - name: "agent.investigateMaxAttempts", - label: "同一根因最多自动恢复", - kind: "number", - hint: "1 – 10 次,用尽后只交还给你", - visible: (v) => v["agent.autoInvestigate"] === true && v["agent.autoInvestigateMode"] === "recover", - }, - { name: "agent.goalPollSec", label: "目标落定轮询(秒)", kind: "number" }, - { name: "agent.goalWaitHours", label: "目标最长等待(小时)", kind: "number" }, - { - name: "agent.fakeScript", - label: "fake 脚本(JSON)", - kind: "textarea", - hint: '{"match":"收进来","steps":[{"toolCalls":[{"capability":"inventory.query","input":{"query":"沙丘2"}}]}],"finalText":"…"},格式见 docs/runbook/agent.md', - }, - { - name: "agent.sessionDir", - label: "会话目录", - kind: "text", - wide: true, - placeholder: "留空用部署默认:DATA_DIR/agent-sessions(容器内 /data/agent-sessions)", - hint: "绝对路径,api 与 worker 进程都要可读写;有运行中的会话时不要改(续跑会找不到旧会话文件)", - }, - ], - fromSettings: (s) => ({ - "agent.maxToolCalls": n(s.agent.maxToolCalls), - "agent.maxDurationSec": n(s.agent.maxDurationSec), - "agent.autoInvestigate": s.agent.autoInvestigate, - "agent.autoInvestigateMode": s.agent.autoInvestigateMode, - "agent.investigateMaxToolCalls": n(s.agent.investigateMaxToolCalls), - "agent.investigateMaxAttempts": n(s.agent.investigateMaxAttempts), - "agent.goalPollSec": n(s.agent.goalPollSec), - "agent.goalWaitHours": n(s.agent.goalWaitHours), - "agent.fakeScript": s.agent.fakeScript ?? "", - "agent.sessionDir": s.agent.sessionDir ?? "", - }), - toPatch: (v) => ({ - agent: { - maxToolCalls: num(v["agent.maxToolCalls"]), - maxDurationSec: num(v["agent.maxDurationSec"]), - autoInvestigate: v["agent.autoInvestigate"] === true, - autoInvestigateMode: v["agent.autoInvestigateMode"] === "recover" ? "recover" : "investigate", - investigateMaxToolCalls: num(v["agent.investigateMaxToolCalls"]), - investigateMaxAttempts: num(v["agent.investigateMaxAttempts"]), - goalPollSec: num(v["agent.goalPollSec"]), - goalWaitHours: num(v["agent.goalWaitHours"]), - fakeScript: str(v["agent.fakeScript"]).trim() || null, - sessionDir: str(v["agent.sessionDir"]).trim() || null, - }, - }), -}; - -export function PiTab() { - return useSettingsGate((s) => ( -
- -
- - -
-
- )); -} - -// ─── 缓存 / 入库 / 保留 / 下载预算 ─── - -const GiB = 1024 ** 3; - -const CACHE_CARD: CardSpec = { - 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.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10 – 3600" }, - ], - fromSettings: (s) => ({ - "cache.hardThresholdPercent": n(s.cache.hardThresholdPercent), - "cache.resumePercent": n(s.cache.resumePercent), - "cache.cleanupTargetPercent": n(s.cache.cleanupTargetPercent), - "cache.checkIntervalSec": n(s.cache.checkIntervalSec), - }), - toPatch: (v) => ({ - cache: { - hardThresholdPercent: num(v["cache.hardThresholdPercent"]), - resumePercent: num(v["cache.resumePercent"]), - cleanupTargetPercent: num(v["cache.cleanupTargetPercent"]), - checkIntervalSec: num(v["cache.checkIntervalSec"]), - }, - }), -}; - -const ORGANIZE_CARD: CardSpec = { - title: "入库方式与保留", - description: "默认 Copy 入库,下载源在缓存里保留到做种期满;跨卷 Move 走 Copy→核验→删源。", - fields: [ - { - name: "organize.defaultMode", - label: "默认入库方式", - kind: "select", - options: [ - { value: "copy", label: "Copy(保留源文件做种)" }, - { value: "move", label: "Move(移走源文件)" }, - ], - }, - { name: "organize.allowCrossVolumeMove", label: "允许跨卷 Move", kind: "switch" }, - { - name: "retention.seedRetentionDays", - label: "源文件保留天数", - kind: "number", - hint: "Copy 入库后至少保留;0 – 365", - }, - { name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" }, - ], - fromSettings: (s) => ({ - "organize.defaultMode": s.organize.defaultMode, - "organize.allowCrossVolumeMove": s.organize.allowCrossVolumeMove, - "retention.seedRetentionDays": n(s.retention.seedRetentionDays), - "retention.logRetentionDays": n(s.retention.logRetentionDays), - }), - toPatch: (v) => ({ - organize: { - defaultMode: str(v["organize.defaultMode"]) as "copy" | "move", - allowCrossVolumeMove: v["organize.allowCrossVolumeMove"] === true, - }, - retention: { - seedRetentionDays: num(v["retention.seedRetentionDays"]), - logRetentionDays: num(v["retention.logRetentionDays"]), - }, - }), -}; - -const DOWNLOAD_BUDGET_CARD: CardSpec = { - title: "下载预算", - fields: [ - { name: "budget.maxNewTitlesPerDay", label: "每日最多新作品", kind: "number" }, - { name: "budget.maxDownloadGiBPerDay", label: "每日下载量上限(GiB)", kind: "number" }, - { name: "budget.maxConcurrentDownloads", label: "最大并发下载", kind: "number", hint: "1 – 100" }, - ], - fromSettings: (s) => ({ - "budget.maxNewTitlesPerDay": n(s.budget.maxNewTitlesPerDay), - "budget.maxDownloadGiBPerDay": n(Math.round((s.budget.maxDownloadBytesPerDay / GiB) * 100) / 100), - "budget.maxConcurrentDownloads": n(s.budget.maxConcurrentDownloads), - }), - toPatch: (v) => { - const gib = num(v["budget.maxDownloadGiBPerDay"]); - return { - budget: { - maxNewTitlesPerDay: num(v["budget.maxNewTitlesPerDay"]), - maxDownloadBytesPerDay: gib === undefined ? undefined : Math.round(gib * GiB), - maxConcurrentDownloads: num(v["budget.maxConcurrentDownloads"]), - }, - }; - }, -}; - -export function StorageTab() { - return useSettingsGate((s) => ( -
- - - -
- )); -} - -// ─── 自动化开关 ─── - -function ToggleRow({ - icon, - title, - description, - checked, - onChange, - disabled, - tone, -}: { - icon: ReactNode; - title: string; - description: string; - checked: boolean; - onChange: (v: boolean) => void; - disabled: boolean; - tone: "success" | "warning"; -}) { - return ( -
- - {icon} - - - - {description} - - -
- ); -} - -export function AutomationTab() { - const update = useUpdateSettings(); - const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => { - try { - await update.mutateAsync({ automation: patch }); - toast.success(msg); - } catch (err) { - toast.error(errorMessage(err, "切换失败")); - } - }; - return useSettingsGate((s) => ( -
- : } - tone={s.automation.enabled ? "success" : "warning"} - title={`自动化总开关 · ${s.automation.enabled ? "运行中" : "已暂停"}`} - description="关闭后所有自动收录、下载、整理停止;只读查询与手动操作仍可用。" - checked={s.automation.enabled} - disabled={update.isPending} - onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")} - /> - } - tone={s.automation.readOnlyReconcile ? "warning" : "success"} - title={`只读对账模式 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`} - description="从备份恢复后先开启:只核对库存与路径,不做任何自动写入;确认无误后关闭。" - checked={s.automation.readOnlyReconcile} - disabled={update.isPending} - onChange={(v) => set({ readOnlyReconcile: v }, v ? "已进入只读对账模式" : "已退出只读对账模式")} - /> -
- )); -} diff --git a/docs/design/console-ia.md b/docs/design/console-ia.md index 46f25349..ef556956 100644 --- a/docs/design/console-ia.md +++ b/docs/design/console-ia.md @@ -269,3 +269,22 @@ - `components/agent/agent-conversation.tsx` 仍链到 `/console/tasks/:下载任务ID`(W7-B 范围),靠 `tasks.$id.tsx` 的兜底跳转可用,改成 `/console/downloads/:id` 可省一次 404 探测。 - `components/agent/quick-goals.ts` 里的"待识别池"文案(W7-B 范围)。 - 原站视图仍是占位。 + +## 6. 设置分区已实施对照(IA-2,2026-09-28) + +路由保持 `/console/settings`(routes.ts 归 IA-1),分区用 URL hash 定位:`#<分区>` 或 `#<分区>/<节>`;旧 `?tab=` 由设置页 `clientLoader` 跳到对应 hash。 +分区定义在 `packages/contracts/src/settings.ts` 的 `SETTINGS_SECTIONS`(label、说明、锚点、字段、`legacyTabs`),测试断言 8 个存储 key 的每个字段恰好归属一个分区。组件在 `apps/web/app/components/settings/`。 + +| 分区 | 节(锚点) | 实际内容 | 与 §3.4 的差异 | +| --- | --- | --- | --- | +| 自动化 `automation` | switches / cache / budget / retention | 总开关、只读对账(切换即生效);缓存水位 4 项;每日新作品数、下载量、并发;源文件保留天数、日志保留天数 | 无 | +| 站点与账号 `sites` | sites / accounts | 站点列表(启停开关、快照周期与新鲜度、账号数、健康);抽屉改地址、定时快照、快照周期、限流三项,并录入本站凭证;从内置站点定义登记新站点;账号列表(启停、删除)+ 录入凭证抽屉 | 浏览器服务能力未放进来(无对应写接口) | +| 下载器 `downloaders` | instances | qB 实例 CRUD + 连通测试;路径映射并入实例抽屉(只列本实例的映射,可加、校验、删) | §3.4 把映射放在"媒体库与目录",按 IA-2 任务改为挂在实例抽屉;`?tab=path-mappings` → `#downloaders` | +| 媒体库与目录 `library` | roots / organize | 库根 CRUD + 哨兵检查;默认整理方式(复制/移动)、允许跨卷移动 | 没有命名模板字段,"整理默认"只有这两项 | +| 媒体服务器 `media-servers` | instances | Emby/Jellyfin CRUD + 测试;抽屉里同样挂本实例的路径映射 | 无 | +| 元数据 `metadata` | tmdb | TMDB API Key(只写,显示已保存/清除)、Base URL | 语言、刷新周期在 contracts 里没有字段,未做 | +| Agent `agent` | upstream / runtime / budget / investigate / advanced | 上游列表(名称、provider、认证、模型、状态、默认)+ 新建向导(选 provider → 凭据与模型 → 登录/测试/设默认)+ 编辑抽屉(改配置、登录/重新登录/退出、测试、设默认、删除);上下文预算、压缩、子代理;每日与单次预算;自动调查;高级(轮询、等待、会话目录、fake 脚本) | 多上游已由 W7-A 落地,§3.4 说明里"多上游等 Agent 侧需求"作废;旧 `pi.provider/model/baseUrl/apiKey` 字段不再展示 | +| API Key `api-keys` | keys | 列表、创建(明文只显示一次)、撤销、scope 说明 | 无 | +| 账户与安全 `account` | password / totp / session / shortcuts | 改密、TOTP 开启/关闭、当前会话到期与退出、本机 Token、Agent 快捷键 | 后端没有会话列表/吊销接口,只能看当前会话;改密会注销其它会话 | + +每节独立保存:全局设置类的节有"未保存"标记、保存/还原按钮,保存后 sonner 一句;实例、站点、上游类在抽屉里保存。窄屏左栏折叠为顶部下拉。 diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0428403d..4e84adc2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -289,3 +289,187 @@ export const SettingEntry = z.object({ updatedAt: Timestamp, }); export type SettingEntry = z.infer; + +// ── 设置页分区(docs/design/console-ia.md §3.4) ── +// 只是"展示分区 → 字段"的映射,不改存储 key;每个字段恰好归属一个分区(测试断言)。 +// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。 + +export const SettingsSectionId = z.enum([ + "automation", + "sites", + "downloaders", + "library", + "media-servers", + "metadata", + "agent", + "api-keys", + "account", +]); +export type SettingsSectionId = z.infer; + +export type SettingsSectionDef = { + id: SettingsSectionId; + label: string; + description: string; + /** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */ + legacyTabs: { tab: string; anchor?: string }[]; + /** 分区内锚点(节),顺序即页面顺序 */ + anchors: { id: string; label: string }[]; + /** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */ + fields: string[]; + /** 本分区管理的非 settings 对象 */ + resources: string[]; +}; + +export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ + { + id: "automation", + label: "自动化", + description: "总开关、缓存水位、每日预算、保留天数", + legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }], + anchors: [ + { id: "switches", label: "开关" }, + { id: "cache", label: "缓存水位" }, + { id: "budget", label: "每日预算" }, + { id: "retention", label: "保留" }, + ], + fields: [ + "automation.enabled", + "automation.readOnlyReconcile", + "cache.hardThresholdPercent", + "cache.resumePercent", + "cache.checkIntervalSec", + "cache.cleanupTargetPercent", + "budget.maxNewTitlesPerDay", + "budget.maxDownloadBytesPerDay", + "budget.maxConcurrentDownloads", + "retention.seedRetentionDays", + "retention.logRetentionDays", + ], + resources: [], + }, + { + id: "sites", + label: "站点与账号", + description: "站点启停、快照周期、限流、账号凭证", + legacyTabs: [{ tab: "site-credentials" }], + anchors: [ + { id: "sites", label: "站点" }, + { id: "accounts", label: "站点账号" }, + ], + fields: [], + resources: ["sites", "site-accounts"], + }, + { + id: "downloaders", + label: "下载器", + description: "qBittorrent 实例、连通测试、路径映射", + legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }], + anchors: [{ id: "instances", label: "实例" }], + fields: [], + resources: ["downloader-instances", "path-mappings"], + }, + { + id: "library", + label: "媒体库与目录", + description: "库根、哨兵状态、默认整理方式", + legacyTabs: [{ tab: "library-roots", anchor: "roots" }], + anchors: [ + { id: "roots", label: "库根" }, + { id: "organize", label: "整理方式" }, + ], + fields: ["organize.defaultMode", "organize.allowCrossVolumeMove"], + resources: ["library-roots"], + }, + { + id: "media-servers", + label: "媒体服务器", + description: "Emby / Jellyfin 实例、连通测试、路径映射", + legacyTabs: [{ tab: "media-servers" }], + anchors: [{ id: "instances", label: "实例" }], + fields: [], + resources: ["media-server-instances", "path-mappings"], + }, + { + id: "metadata", + label: "元数据", + description: "TMDB API Key 与地址", + legacyTabs: [], + anchors: [{ id: "tmdb", label: "TMDB" }], + fields: ["metadata.tmdbApiKeySecretRef", "metadata.tmdbBaseUrl"], + resources: [], + }, + { + id: "agent", + label: "Agent", + description: "Agent 上游、运行预算、自动调查", + legacyTabs: [{ tab: "pi" }], + anchors: [ + { id: "upstream", label: "上游" }, + { id: "runtime", label: "上下文与子代理" }, + { id: "budget", label: "预算" }, + { id: "investigate", label: "自动调查" }, + { id: "advanced", label: "高级" }, + ], + fields: [ + "pi.defaultUpstreamId", + "pi.provider", + "pi.baseUrl", + "pi.model", + "pi.thinkingLevel", + "pi.contextBudget", + "pi.compaction", + "pi.subagentsEnabled", + "pi.subagents", + "pi.apiKeySecretRef", + "budget.agentMaxRunsPerDay", + "budget.agentMaxCostUsdPerDay", + "agent.autoInvestigate", + "agent.autoInvestigateMode", + "agent.investigateMaxAttempts", + "agent.recoverVerifySec", + "agent.maxToolCalls", + "agent.maxDurationSec", + "agent.maxTokensPerRun", + "agent.maxCostUsdPerRun", + "agent.investigateMaxToolCalls", + "agent.goalPollSec", + "agent.goalWaitHours", + "agent.fakeScript", + "agent.sessionDir", + ], + resources: ["pi-upstreams"], + }, + { + id: "api-keys", + label: "API Key", + description: "签发给外部程序的 Key", + legacyTabs: [{ tab: "api-keys" }], + anchors: [{ id: "keys", label: "API Key" }], + fields: [], + resources: ["api-keys"], + }, + { + id: "account", + label: "账户与安全", + description: "密码、二次验证、会话、快捷键", + legacyTabs: [], + anchors: [ + { id: "password", label: "密码" }, + { id: "totp", label: "二次验证" }, + { id: "session", label: "会话" }, + { id: "shortcuts", label: "快捷键" }, + ], + fields: [], + resources: ["auth"], + }, +]; + +/** 旧 `?tab=` → 分区与锚点;不认识的返回 null */ +export function settingsSectionForLegacyTab(tab: string): { section: SettingsSectionId; anchor?: string } | null { + for (const s of SETTINGS_SECTIONS) { + const hit = s.legacyTabs.find((t) => t.tab === tab); + if (hit) return { section: s.id, anchor: hit.anchor }; + } + return null; +} diff --git a/packages/contracts/test/settings-sections.test.ts b/packages/contracts/test/settings-sections.test.ts new file mode 100644 index 00000000..de4e2e50 --- /dev/null +++ b/packages/contracts/test/settings-sections.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SETTINGS, + SETTINGS_SECTIONS, + SettingsSectionId, + settingsSectionForLegacyTab, +} from "../src/settings.ts"; + +describe("SETTINGS_SECTIONS", () => { + it("9 个分区,顺序与 SettingsSectionId 一致", () => { + expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options); + }); + + it("8 个存储 key 的每个字段恰好归属一个分区", () => { + const all = Object.entries(DEFAULT_SETTINGS).flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`)); + const owned = SETTINGS_SECTIONS.flatMap((s) => s.fields); + expect(new Set(owned).size).toBe(owned.length); + expect([...owned].sort()).toEqual([...all].sort()); + }); + + it("旧 ?tab= 值都能落到分区", () => { + for (const tab of [ + "automation", + "downloaders", + "media-servers", + "library-roots", + "path-mappings", + "site-credentials", + "api-keys", + "pi", + "storage", + ]) { + expect(settingsSectionForLegacyTab(tab), tab).not.toBeNull(); + } + expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "automation", anchor: "cache" }); + expect(settingsSectionForLegacyTab("path-mappings")).toEqual({ section: "downloaders", anchor: undefined }); + expect(settingsSectionForLegacyTab("nope")).toBeNull(); + }); +});