Merge branch 'fix8'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-30 00:10:57 +08:00
commit 9ef2e8554c
39 files changed
+1175 -232

No files matched your search

@@ -44,6 +44,7 @@ import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { humanizeReason } from "~/lib/reasons.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { InlineApproval, OptionsCard } from "./agent-interaction.tsx";
import { splitContextGoal } from "./agent-store.ts";
@@ -691,7 +692,7 @@ export function ResultLine({
value:
u.totalTokens === undefined
? null
: `${u.totalTokens.toLocaleString("zh-CN")}${run.budget.maxTokens ? ` / ${run.budget.maxTokens.toLocaleString("zh-CN")}` : ""}`,
: `${formatTokens(u.totalTokens)}${run.budget.maxTokens ? ` / ${formatTokens(run.budget.maxTokens)}` : ""}`,
},
{
label: "工具调用",
@@ -29,6 +29,7 @@ import { formatUsd } from "~/components/schedules/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import {
AgentConversation,
@@ -216,7 +217,7 @@ export function RunOverviewBar({
? `约 ${formatUsd(u.costUsd)}${r.budget.maxCostUsd ? ` / 上限 ${formatUsd(r.budget.maxCostUsd)}` : ""}`
: null,
},
{ label: "token", value: u.totalTokens !== undefined ? u.totalTokens.toLocaleString("zh-CN") : null },
{ label: "token", value: u.totalTokens !== undefined ? formatTokens(u.totalTokens) : null },
{
label: "工具调用",
value: `${r.toolCallCount}${r.budget.maxToolCalls ? ` / ${r.budget.maxToolCalls}` : ""}`,
@@ -3,6 +3,7 @@ import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
import { createContext, type ReactNode, useContext, useState } from "react";
import { Link } from "react-router";
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
import { RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle } from "./agent-store.ts";
@@ -10,7 +11,7 @@ import { shortGoalTitle } from "./agent-store.ts";
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
const tokens = (n: number | undefined) => formatTokens(n);
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
@@ -0,0 +1,142 @@
import { Undo2, X } from "lucide-react";
import { useState } from "react";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { SECRET_CLEAR, secretView } from "~/lib/secret.ts";
import { cn } from "~/lib/utils.ts";
/**
* 只写凭证输入框。已保存时框里显示掩码占位(「已设置 ••••••••」,服务端给了末 4 位就带上),
* 聚焦后直接输入即替换;留空保存 = 保持原值;「清除」确认后把值设为 SECRET_CLEAR,保存时才生效。
* 值约定见 ~/lib/secret.ts。明文只在用户输入时存在于这个表单里,从不由服务端回填。
*/
export function SecretInput({
id,
value,
onChange,
isSet,
mask,
clearable = false,
label = "凭证",
placeholder,
multiline = false,
disabled,
invalid,
className,
}: {
id?: string;
value: string;
onChange: (v: string) => void;
/** 服务端已保存(secretRef / hasCredential 等) */
isSet: boolean;
/** 服务端给的掩码 hint(如 credentialMask),只取末 4 位显示 */
mask?: string | null;
/** 允许清除(必填凭证不给) */
clearable?: boolean;
/** 确认弹窗里的名字:"API Key"、"密码" */
label?: string;
placeholder?: string;
/** Cookie 这类长串用多行 */
multiline?: boolean;
disabled?: boolean;
invalid?: boolean;
className?: string;
}) {
const [confirming, setConfirming] = useState(false);
const view = secretView(value, { isSet, mask, clearable });
if (view.state === "cleared") {
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<Input
id={id}
readOnly
value=""
placeholder="保存后清除"
aria-label={`${label}:保存后清除`}
className="min-w-0 flex-1 border-dashed placeholder:text-destructive"
disabled={disabled}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => onChange("")} disabled={disabled}>
<Undo2 />
撤销
</Button>
</div>
);
}
const shownPlaceholder = view.state === "set" ? view.placeholder : placeholder;
const common = {
id,
value,
disabled,
// 没有外部 <label htmlFor> 时用名字作无障碍标签
"aria-label": id ? undefined : label,
"aria-invalid": invalid || undefined,
placeholder: shownPlaceholder,
// 掩码占位要像"有值",不能像空框
className: cn("min-w-0 flex-1", view.state === "set" && "placeholder:text-foreground"),
};
return (
<div className={cn("flex min-w-0 items-start gap-2", className)}>
{multiline ? (
<Textarea
{...common}
rows={3}
className={cn(common.className, "font-mono text-caption")}
autoComplete="off"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
) : (
<Input
{...common}
type="password"
autoComplete="new-password"
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
/>
)}
{view.state === "set" && view.canClear ? (
<>
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0 hover:text-destructive"
onClick={() => setConfirming(true)}
disabled={disabled}
>
<X />
清除
</Button>
<ConfirmDialog
open={confirming}
onOpenChange={setConfirming}
title={`清除已保存的${label}?`}
description="保存后生效,清除后无法找回,用到它的功能要重新填写。"
confirmLabel="清除"
destructive
onConfirm={() => onChange(SECRET_CLEAR)}
/>
</>
) : null}
{view.state === "typing" && view.replacing ? (
<Button
type="button"
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => onChange("")}
disabled={disabled}
aria-label={`不替换${label}`}
>
<Undo2 />
撤销
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,32 @@
import type * as React from "react";
import { Input } from "~/components/ui/input.tsx";
import { cn } from "~/lib/utils.ts";
/**
* 带固定单位后缀的数字输入(如 token 数以 K 为单位:输入 128 表示 128K)。
* 值是字符串,允许小数;换算与校验由调用方做(token 见 ~/lib/tokens.ts)。
*/
export function UnitInput({
unit,
className,
inputClassName,
...props
}: Omit<React.ComponentProps<"input">, "type"> & { unit: string; inputClassName?: string }) {
return (
<div className={cn("relative min-w-0", className)}>
<Input
type="text"
inputMode="decimal"
autoComplete="off"
{...props}
className={cn("pr-9 tabular-nums", inputClassName)}
/>
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-caption text-muted-foreground"
>
{unit}
</span>
</div>
);
}
@@ -12,6 +12,7 @@ import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { agentRunLabel, formatRelative } from "~/lib/labels.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
import { NextRun, useJobActions } from "./auto-task-list.tsx";
import { IntervalSelect } from "./interval-select.tsx";
@@ -105,7 +106,7 @@ export function AgentCheckCard({
const budgetParts = [
budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null,
budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null,
budget.maxTokens ? `${budget.maxTokens.toLocaleString("zh-CN")} tokens` : null,
budget.maxTokens ? `${formatTokens(budget.maxTokens)} token` : null,
budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null,
].filter(Boolean);
+6 -6
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.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";
@@ -138,13 +139,12 @@ function SessionBlock() {
只存在这个浏览器,有值时请求会带上它。{hasToken ? "已设置。" : "未设置。"}
</p>
<div className="flex max-w-md flex-wrap items-center gap-2">
<Input
type="password"
autoComplete="off"
aria-label="本机 Token"
placeholder={hasToken ? "输入新值替换" : "粘贴 API Key"}
<SecretInput
label="本机 Token"
isSet={hasToken}
placeholder="粘贴 API Key"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
onChange={setTokenValue}
className="min-w-0 flex-1"
/>
<Button
@@ -1,10 +1,10 @@
import type { GlobalSettings } from "@umt/contracts";
import { LoaderCircle } from "lucide-react";
import { toast } from "sonner";
import { useTestWebSearch, useUpdateSettings } from "~/api/settings.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { useTestWebSearch } from "~/api/settings.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { secretPatch } from "~/lib/secret.ts";
import { num, str } from "./resource-section.tsx";
import { type CardSpec, n } from "./settings-card.tsx";
@@ -28,8 +28,9 @@ export const WEBSEARCH_CARD: CardSpec = {
{
name: "agent.webSearch.apiKey",
label: "API Key",
kind: "password",
hint: "Brave Search API 的 Subscription Token;留空不修改",
kind: "secret",
secret: { set: (v) => v["agent.webSearch.apiKey:set"] === true, clearable: true },
hint: "Brave Search API 的 Subscription Token",
visible: (v) => v["agent.webSearch.provider"] !== "none",
},
{
@@ -79,6 +80,7 @@ export const WEBSEARCH_CARD: CardSpec = {
return {
"agent.webSearch.provider": w.provider,
"agent.webSearch.apiKey": "",
"agent.webSearch.apiKey:set": !!w.apiKeySecretRef,
"agent.webSearch.country": w.country,
"agent.webSearch.searchLang": w.searchLang ?? "",
"agent.webSearch.safesearch": w.safesearch,
@@ -87,7 +89,7 @@ export const WEBSEARCH_CARD: CardSpec = {
};
},
toPatch: (v) => {
const key = str(v["agent.webSearch.apiKey"]);
const key = secretPatch(v["agent.webSearch.apiKey"]);
const safe = str(v["agent.webSearch.safesearch"]);
return {
agent: {
@@ -98,7 +100,7 @@ export const WEBSEARCH_CARD: CardSpec = {
safesearch: safe === "off" || safe === "strict" ? safe : "moderate",
maxResults: num(v["agent.webSearch.maxResults"]),
requestsPerMinute: num(v["agent.webSearch.requestsPerMinute"]),
...(key ? { apiKey: key } : {}),
...(key !== undefined ? { apiKey: key } : {}),
},
},
};
@@ -106,54 +108,31 @@ export const WEBSEARCH_CARD: CardSpec = {
extra: (s) => <WebSearchKeyStatus settings={s} />,
};
/** 已保存 Key 时可以测试;Key 的设置与清除在上面的输入框里 */
function WebSearchKeyStatus({ settings }: { settings: GlobalSettings }) {
const update = useUpdateSettings();
const test = useTestWebSearch();
const w = settings.agent.webSearch;
if (w.provider === "none") return null;
const saved = !!w.apiKeySecretRef;
if (w.provider === "none" || !w.apiKeySecretRef) return null;
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
{saved ? (
<>
<Button
type="button"
variant="outline"
size="xs"
disabled={test.isPending}
onClick={async () => {
try {
const r = await test.mutateAsync({});
if (r.ok) toast.success(`搜索可用:${r.count} 条结果,用时 ${r.durationMs} ms`);
else toast.error(r.error?.message ?? "搜索失败");
} catch (err) {
toast.error(errorMessage(err, "测试失败"));
}
}}
>
{test.isPending ? <LoaderCircle className="animate-spin" /> : null}
测试搜索
</Button>
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ agent: { webSearch: { apiKey: null } } });
toast.success("搜索服务 API Key 已清除");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除
</Button>
</>
) : null}
<Button
type="button"
variant="outline"
size="xs"
disabled={test.isPending}
onClick={async () => {
try {
const r = await test.mutateAsync({});
if (r.ok) toast.success(`搜索可用:${r.count} 条结果,用时 ${r.durationMs} ms`);
else toast.error(r.error?.message ?? "搜索失败");
} catch (err) {
toast.error(errorMessage(err, "测试失败"));
}
}}
>
{test.isPending ? <LoaderCircle className="animate-spin" /> : null}
测试搜索
</Button>
{test.data?.ok ? (
<span className="text-meta text-muted-foreground tabular-nums">
「{test.data.query}」{test.data.count} 条 · {test.data.durationMs} ms
+16 -8
View File
@@ -1,3 +1,4 @@
import { kToTokens, tokensToK } from "~/lib/tokens.ts";
import { AgentToolsBlock } from "./agent-tools.tsx";
import { WEBSEARCH_CARD } from "./agent-websearch.tsx";
import { PiUpstreamsBlock } from "./pi-upstreams.tsx";
@@ -16,9 +17,10 @@ const RUNTIME_CARD: CardSpec = {
fields: [
{
name: "pi.contextBudget",
label: "上下文预算(token)",
kind: "number",
hint: "4096–2000000,超过模型窗口时按窗口算",
label: "上下文预算",
kind: "tokens",
tokens: { min: 4000, max: 2_000_000, required: true },
hint: "4K–2000K,超过模型窗口时按窗口算",
},
{ name: "pi.subagentsEnabled", label: "允许子会话", kind: "switch" },
{ name: "pi.compaction.enabled", label: "上下文压缩", kind: "switch" },
@@ -31,14 +33,14 @@ const RUNTIME_CARD: CardSpec = {
},
],
fromSettings: (s) => ({
"pi.contextBudget": n(s.pi.contextBudget),
"pi.contextBudget": tokensToK(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"]),
contextBudget: kToTokens(str(v["pi.contextBudget"])),
subagentsEnabled: v["pi.subagentsEnabled"] === true,
compaction: {
enabled: v["pi.compaction.enabled"] === true,
@@ -57,7 +59,13 @@ const BUDGET_CARD: CardSpec = {
{ 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.maxTokensPerRun",
label: "单次 token 上限",
kind: "tokens",
tokens: { min: 1 },
hint: "单位 K,留空不限",
},
{ name: "agent.maxCostUsdPerRun", label: "单次费用上限(USD)", kind: "number", hint: "留空不限" },
],
fromSettings: (s) => ({
@@ -65,7 +73,7 @@ const BUDGET_CARD: CardSpec = {
"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.maxTokensPerRun": tokensToK(s.agent.maxTokensPerRun),
"agent.maxCostUsdPerRun": n(s.agent.maxCostUsdPerRun),
}),
toPatch: (v) => ({
@@ -76,7 +84,7 @@ const BUDGET_CARD: CardSpec = {
agent: {
maxToolCalls: num(v["agent.maxToolCalls"]),
maxDurationSec: num(v["agent.maxDurationSec"]),
maxTokensPerRun: nullNum(v["agent.maxTokensPerRun"]),
maxTokensPerRun: kToTokens(str(v["agent.maxTokensPerRun"])) ?? null,
maxCostUsdPerRun: nullNum(v["agent.maxCostUsdPerRun"]),
},
}),
@@ -13,6 +13,7 @@ import {
useUpdateBrowserProvider,
} from "~/api/browser.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { secretPatch } from "~/lib/secret.ts";
import { Health, TestButton } from "./instances.tsx";
import { type FormValues, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
@@ -174,14 +175,20 @@ export function BrowserProvidersBlock() {
{
name: "accessKey",
label: "访问凭据",
kind: "password",
kind: "secret",
secret: { set: (v) => v["accessKey:set"] === true, clearable: true },
hint: "camofox 的 CAMOFOX_ACCESS_KEY;没设就留空",
editHint: "留空不修改",
},
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", baseUrl: "", accessKey: "", enabled: true },
fromRow: (r) => ({ name: r.name, baseUrl: r.baseUrl ?? "", accessKey: "", enabled: r.enabled }),
fromRow: (r) => ({
name: r.name,
baseUrl: r.baseUrl ?? "",
accessKey: "",
"accessKey:set": r.hasAccessKey,
enabled: r.enabled,
}),
toBody: (v: FormValues, mode) =>
mode === "create"
? {
@@ -190,7 +197,7 @@ export function BrowserProvidersBlock() {
accessKey: optStr(v.accessKey),
enabled: v.enabled === true,
}
: { baseUrl: str(v.baseUrl), accessKey: optStr(v.accessKey), enabled: v.enabled === true },
: { baseUrl: str(v.baseUrl), accessKey: secretPatch(v.accessKey), enabled: v.enabled === true },
createSchema: BrowserProviderCreate,
updateSchema: BrowserProviderUpdate,
}}
+22 -8
View File
@@ -16,6 +16,7 @@ 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 { secretPatch } from "~/lib/secret.ts";
import { PathMappingsPanel } from "./path-mappings.tsx";
import { type FormValues, nullStr, optStr, ResourceSection, str } from "./resource-section.tsx";
import { SettingsBlock } from "./settings-block.tsx";
@@ -65,7 +66,8 @@ export function TestButton({
);
}
const credentialHint = { editHint: "留空不修改" };
/** 下载器:已存凭证只对保存时的认证方式有效(改了认证方式就要重新填) */
const dlSecretSet = (v: FormValues) => v["secret:set"] === true && v["secret:authKind"] === v.authKind;
const AUTH_KINDS = [
{ value: "password", label: "用户名和密码" },
@@ -145,17 +147,17 @@ function DownloadersTable() {
{
name: "password",
label: "密码",
kind: "password",
kind: "secret",
secret: { set: dlSecretSet, clearable: true },
visible: (v) => v.authKind === "password",
...credentialHint,
},
{
name: "apiKey",
label: "API Key",
kind: "password",
kind: "secret",
secret: { set: dlSecretSet },
placeholder: "qbt_…",
visible: (v) => v.authKind === "api_key",
...credentialHint,
},
{ name: "managedTag", label: "管理标签", kind: "text", placeholder: "umt", hint: "只接管带此标签的任务" },
{ name: "cacheVolumeKey", label: "缓存卷标识", kind: "text", placeholder: "cache-1" },
@@ -179,6 +181,8 @@ function DownloadersTable() {
username: r.username ?? "",
password: "",
apiKey: "",
"secret:set": !!r.secretRef,
"secret:authKind": authKindOf(r),
managedTag: r.managedTag,
cacheVolumeKey: r.cacheVolumeKey ?? "",
enabled: r.enabled,
@@ -190,7 +194,11 @@ function DownloadersTable() {
authKind: str(v.authKind),
username: v.authKind === "password" ? nullStr(v.username) : null,
credential:
v.authKind === "password" ? optStr(v.password) : v.authKind === "api_key" ? optStr(v.apiKey) : undefined,
v.authKind === "password"
? secretPatch(v.password)
: v.authKind === "api_key"
? secretPatch(v.apiKey)
: undefined,
managedTag: optStr(v.managedTag),
cacheVolumeKey: nullStr(v.cacheVolumeKey),
enabled: v.enabled === true,
@@ -252,7 +260,12 @@ function MediaServersTable() {
},
{ name: "baseUrl", label: "地址", kind: "url", required: true, placeholder: "http://192.168.1.10:8096" },
{ name: "userId", label: "用户 ID", kind: "text", hint: "可选,按该用户的可见范围查询" },
{ name: "credential", label: "API Key", kind: "password", ...credentialHint },
{
name: "credential",
label: "API Key",
kind: "secret",
secret: { set: (v) => v["secret:set"] === true, clearable: true },
},
{ name: "enabled", label: "启用", kind: "switch" },
],
initial: { name: "", kind: "emby", baseUrl: "", userId: "", credential: "", enabled: true },
@@ -262,6 +275,7 @@ function MediaServersTable() {
baseUrl: r.baseUrl,
userId: r.userId ?? "",
credential: "",
"secret:set": !!r.secretRef,
enabled: r.enabled,
}),
toBody: (v) => ({
@@ -269,7 +283,7 @@ function MediaServersTable() {
kind: str(v.kind),
baseUrl: str(v.baseUrl),
userId: nullStr(v.userId),
credential: optStr(v.credential),
credential: secretPatch(v.credential),
enabled: v.enabled === true,
}),
createSchema: MediaServerInstanceCreate,
+16 -41
View File
@@ -1,10 +1,9 @@
import { DEFAULT_SETTINGS } from "@umt/contracts";
import { toast } from "sonner";
import { useClearImageCache, useClearTmdbCache, useImageCache, useTmdbCache } from "~/api/cache.ts";
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 { secretPatch } from "~/lib/secret.ts";
import { str } from "./resource-section.tsx";
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
@@ -34,8 +33,9 @@ const TMDB_CARD: CardSpec = {
{
name: "metadata.tmdbApiKey",
label: "API Key",
kind: "password",
hint: "v3 API Key 或 v4 Read Access Token;留空不修改",
kind: "secret",
secret: { set: (v) => v["metadata.tmdbApiKey:set"] === true, clearable: true },
hint: "v3 API Key 或 v4 Read Access Token",
wide: true,
},
{
@@ -64,49 +64,24 @@ const TMDB_CARD: CardSpec = {
],
fromSettings: (s) => ({
"metadata.tmdbApiKey": "",
"metadata.tmdbApiKey:set": !!s.metadata.tmdbApiKeySecretRef,
"metadata.tmdbBaseUrl": s.metadata.tmdbBaseUrl ?? "",
"metadata.languages": (s.metadata.languages ?? DEFAULT_LANGUAGES).join(", "),
"metadata.includeSpecials": s.metadata.includeSpecials !== false,
}),
toPatch: (v) => ({
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
includeSpecials: v["metadata.includeSpecials"] === true,
...(str(v["metadata.tmdbApiKey"]) ? { tmdbApiKey: str(v["metadata.tmdbApiKey"]) } : {}),
},
}),
extra: (s) => <TmdbKeyStatus saved={!!s.metadata.tmdbApiKeySecretRef} />,
toPatch: (v) => {
const key = secretPatch(v["metadata.tmdbApiKey"]);
return {
metadata: {
tmdbBaseUrl: str(v["metadata.tmdbBaseUrl"]) || null,
languages: parseLanguages(str(v["metadata.languages"])),
includeSpecials: v["metadata.includeSpecials"] === true,
...(key !== undefined ? { tmdbApiKey: key } : {}),
},
};
},
};
function TmdbKeyStatus({ saved }: { saved: boolean }) {
const update = useUpdateSettings();
return (
<div className="mb-2 flex flex-wrap items-center gap-2">
<StatusBadge tone={saved ? "success" : "neutral"}>{saved ? "API Key 已保存" : "未保存 API Key"}</StatusBadge>
{saved ? (
<Button
type="button"
variant="ghost"
size="xs"
className="hover:text-destructive"
disabled={update.isPending}
onClick={async () => {
try {
await update.mutateAsync({ metadata: { tmdbApiKey: null } });
toast.success("TMDB API Key 已清除");
} catch (err) {
toast.error(errorMessage(err, "清除失败"));
}
}}
>
清除
</Button>
) : null}
</div>
);
}
// TMDB 缓存(对照 MP:META_CACHE_EXPIRE / CONF.tmdb / EMPTY_RESULT_CACHE_TTL)
const TMDB_CACHE_CARD: CardSpec = {
anchor: "tmdb-cache",
@@ -14,6 +14,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { formatRelative } from "~/lib/labels.ts";
import { formatTokens } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
// Agent 上游的界面标签(术语见 docs/design/glossary.md:上游配置叫"Agent 上游")
@@ -93,8 +94,7 @@ export const listedFromInfo = (models: PiModelInfo[]): PiListedModel[] =>
const TAG = "shrink-0 rounded-tight border border-border/60 px-1 text-meta text-muted-foreground";
const fmtContext = (n: number | null) =>
!n ? null : n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`;
const fmtContext = (n: number | null) => (!n ? null : formatTokens(n));
/** 模型 id:可搜索的下拉(显示 id 与视觉 / 工具标记),也可手填(目录滞后于上游时) */
export function ModelInput({
@@ -19,6 +19,7 @@ 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 { maskedDisplay, secretPatch } from "~/lib/secret.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import {
@@ -138,7 +139,7 @@ function UpstreamDrawer({
v.model !== (upstream.defaultModel ?? "") ||
v.thinking !== upstream.thinkingLevel ||
v.baseUrl !== (upstream.baseUrl ?? "") ||
!!v.apiKey ||
v.apiKey !== "" ||
v.enabled !== upstream.enabled ||
envFields.some((f) => (v.env[f.name] ?? "") !== (upstream.extra.env?.[f.name] ?? ""));
@@ -148,19 +149,20 @@ function UpstreamDrawer({
? upstream.authKind === "oauth"
? "登录后可获取"
: "先配置 API Key"
: v.apiKey.trim() || v.baseUrl !== (upstream.baseUrl ?? "")
: v.apiKey !== "" || v.baseUrl !== (upstream.baseUrl ?? "")
? "先保存 API Key / Base URL 再获取"
: null;
const save = async (e: React.FormEvent) => {
e.preventDefault();
const env = Object.fromEntries(Object.entries(v.env).filter(([, x]) => x.trim()));
const apiKey = secretPatch(v.apiKey);
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() } : {}),
...(apiKey !== undefined ? { apiKey } : {}),
enabled: v.enabled,
...(envFields.length ? { extra: { ...upstream.extra, env } } : {}),
});
@@ -239,7 +241,7 @@ function UpstreamDrawer({
{upstream.credentialMask ? (
<>
<dt className="text-muted-foreground">API Key</dt>
<dd className="font-mono">{upstream.credentialMask}</dd>
<dd className="font-mono">{maskedDisplay(upstream.credentialMask)}</dd>
</>
) : null}
{upstream.account ? (
@@ -372,10 +374,10 @@ function UpstreamDrawer({
<FieldControl
spec={{
name: "apiKey",
label: upstream.hasCredential ? "替换 API Key" : "API Key",
kind: "password",
label: "API Key",
kind: "secret",
secret: { set: () => upstream.hasCredential, mask: () => upstream.credentialMask, clearable: true },
wide: true,
hint: upstream.hasCredential ? "留空不修改" : undefined,
}}
value={v.apiKey}
error={errors.apiKey}
+19 -5
View File
@@ -23,6 +23,7 @@ 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 { kToTokens, type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
import { PiOAuthLogin } from "./pi-oauth.tsx";
import {
@@ -38,6 +39,9 @@ import {
import { PiTestPanel } from "./pi-test.tsx";
import { FieldControl } from "./resource-section.tsx";
/** 自定义模型的上下文窗口(与 PiUpstreamExtra.contextWindow 一致) */
const CONTEXT_WINDOW_RANGE: TokenRange = { min: 1000, max: 10_000_000 };
// 新建 Agent 上游:① 选 provider → ② 填凭证与模型并创建 → ③ 登录 / 测试,可设为默认
type Step = "provider" | "config" | "verify";
@@ -209,7 +213,7 @@ function ConfigStep({
const extra = {
...(Object.keys(envVals).length ? { env: envVals } : {}),
...(custom ? { api: api as never } : {}),
...(custom && contextWindow.trim() ? { contextWindow: Number(contextWindow) } : {}),
...(custom && contextWindow.trim() ? { contextWindow: kToTokens(contextWindow) } : {}),
};
const parsed = PiUpstreamCreate.safeParse({
name: name.trim(),
@@ -225,7 +229,9 @@ function ConfigStep({
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 以上的整数";
const cwError = custom ? tokenInputError(contextWindow, CONTEXT_WINDOW_RANGE) : null;
if (cwError) fe.contextWindow = cwError;
else if (fe["extra.contextWindow"]) fe.contextWindow = `填 ${tokenRangeText(CONTEXT_WINDOW_RANGE)}`;
if (!parsed.success || Object.keys(fe).length) {
setErrors(fe);
return;
@@ -277,10 +283,11 @@ function ConfigStep({
spec={{
name: "apiKey",
label: "API Key",
kind: "password",
kind: "secret",
secret: { set: () => false },
required: !custom,
wide: true,
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示前后 4 位",
hint: custom ? "端点不要求鉴权时可留空" : "只写入,保存后只显示末 4 位",
}}
value={apiKey}
error={errors.apiKey}
@@ -319,7 +326,14 @@ function ConfigStep({
disabled={pending}
/>
<FieldControl
spec={{ name: "contextWindow", label: "上下文窗口(token)", kind: "number", placeholder: "128000" }}
spec={{
name: "contextWindow",
label: "上下文窗口",
kind: "tokens",
tokens: CONTEXT_WINDOW_RANGE,
placeholder: "128",
hint: "单位 K,如 128 表示 128K;留空按 128K",
}}
value={contextWindow}
error={errors.contextWindow}
onChange={(v) => setContextWindow(String(v))}
@@ -8,6 +8,8 @@ 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 { SecretInput } from "~/components/console/secret-input.tsx";
import { UnitInput } from "~/components/console/unit-input.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
@@ -15,10 +17,13 @@ import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { secretHint } from "~/lib/secret.ts";
import { type TokenRange, tokenInputError, tokenRangeText } from "~/lib/tokens.ts";
import { cn } from "~/lib/utils.ts";
// 设置页里"实例类"资源的通用段落:列表 + 新建/编辑抽屉 + 删除 + 行内动作。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写,编辑时留空表示不变。
// 表单用 contracts 的 Create/Update schema 做 zod 校验;凭证字段只写(kind=secret,值约定见 ~/lib/secret.ts):
// 留空不变、SECRET_CLEAR 清除;token 数字段(kind=tokens)以 K 输入,toBody 里用 kToTokens 换回原始值。
export type FieldValue = string | boolean;
export type FormValues = Record<string, FieldValue>;
@@ -26,7 +31,7 @@ export type FormValues = Record<string, FieldValue>;
export type FieldSpec = {
name: string;
label: string;
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea";
kind: "text" | "url" | "password" | "number" | "select" | "switch" | "textarea" | "tokens" | "secret";
options?: { value: string; label: string }[];
placeholder?: string;
hint?: string;
@@ -38,8 +43,37 @@ export type FieldSpec = {
/** 条件显示 */
visible?: (v: FormValues) => boolean;
wide?: boolean;
/** kind=tokens:以 K 为单位输入(表单值是 K 字符串),范围按原始 token 数 */
tokens?: TokenRange;
/** kind=secret:只写凭证。set/mask 从表单值(fromRow / fromSettings 放进去的隐藏键)或闭包取 */
secret?: {
set: (v: FormValues) => boolean;
mask?: (v: FormValues) => string | null | undefined;
clearable?: boolean;
multiline?: boolean;
};
};
/** kind=tokens 字段先在本地校验(错误用 K 表述),通过后再走 zod */
export function localFieldErrors(fields: FieldSpec[], values: FormValues): Record<string, string> {
const out: Record<string, string> = {};
for (const f of fields) {
if (f.visible && !f.visible(values)) continue;
if (f.kind === "tokens") {
const e = tokenInputError(str(values[f.name]), { ...f.tokens, required: f.tokens?.required ?? f.required });
if (e) out[f.name] = e;
}
}
return out;
}
/** 字段说明:凭证字段已设置时补"留空不修改",token 字段缺省给范围 */
export function fieldHint(spec: FieldSpec, value: FieldValue | undefined, values: FormValues, hint?: string) {
if (spec.kind === "secret") return secretHint(hint, value, spec.secret?.set(values) ?? false);
if (spec.kind === "tokens" && hint === undefined && spec.tokens) return tokenRangeText(spec.tokens) ?? undefined;
return hint;
}
export type ResourceConfig<Row extends { id: string }> = {
/** 单数名,用于标题与提示:"下载器实例" */
noun: string;
@@ -186,6 +220,12 @@ function ResourceFormDrawer<Row extends { id: string }>({
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const local = localFieldErrors(config.fields, values);
if (Object.keys(local).length) {
setErrors(local);
revealFirstError();
return;
}
const body = config.toBody(values, mode);
const parsed = (mode === "create" ? config.createSchema : config.updateSchema).safeParse(body);
if (!parsed.success) {
@@ -240,6 +280,7 @@ function ResourceFormDrawer<Row extends { id: string }>({
onChange={(v) => set(f.name, v)}
disabled={pending || (mode === "edit" && !!f.createOnly)}
hint={mode === "edit" && f.editHint ? f.editHint : f.hint}
values={values}
/>
))}
</form>
@@ -255,6 +296,7 @@ export function FieldControl({
onChange,
disabled,
hint,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
@@ -262,8 +304,11 @@ export function FieldControl({
onChange: (v: FieldValue) => void;
disabled?: boolean;
hint?: string;
/** 整个表单的值(凭证字段据此判断是否已设置);单独使用时可省略 */
values?: FormValues;
}) {
const id = `f-${spec.name}`;
const all = values ?? {};
const wide = spec.wide || spec.kind === "textarea";
if (spec.kind === "switch") {
return (
@@ -281,11 +326,19 @@ export function FieldControl({
label={spec.label}
htmlFor={id}
error={error}
hint={hint}
hint={fieldHint(spec, value, all, hint)}
required={spec.required}
className={wide ? "sm:col-span-2" : undefined}
>
<FieldInput id={id} spec={spec} value={value} error={error} onChange={onChange} disabled={disabled} />
<FieldInput
id={id}
spec={spec}
value={value}
values={all}
error={error}
onChange={onChange}
disabled={disabled}
/>
</FormField>
);
}
@@ -299,6 +352,7 @@ export function FieldInput({
onChange,
disabled,
className,
values,
}: {
id: string;
spec: FieldSpec;
@@ -307,7 +361,41 @@ export function FieldInput({
onChange: (v: FieldValue) => void;
disabled?: boolean;
className?: string;
values?: FormValues;
}) {
if (spec.kind === "secret") {
const all = values ?? {};
return (
<SecretInput
id={id}
value={typeof value === "string" ? value : ""}
onChange={onChange}
isSet={spec.secret?.set(all) ?? false}
mask={spec.secret?.mask?.(all)}
clearable={spec.secret?.clearable}
multiline={spec.secret?.multiline}
label={spec.label}
placeholder={spec.placeholder}
disabled={disabled}
invalid={!!error}
className={className}
/>
);
}
if (spec.kind === "tokens") {
return (
<UnitInput
id={id}
unit="K"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={spec.placeholder}
disabled={disabled}
aria-invalid={!!error}
className={className}
/>
);
}
if (spec.kind === "switch") {
return (
<Switch
@@ -15,7 +15,14 @@ import { Label } from "~/components/ui/label.tsx";
import { errorMessage } from "~/lib/api.ts";
import { revealFirstError } from "~/lib/form-errors.ts";
import { cn } from "~/lib/utils.ts";
import { FieldInput, type FieldSpec, type FieldValue, type FormValues } from "./resource-section.tsx";
import {
FieldInput,
type FieldSpec,
type FieldValue,
type FormValues,
fieldHint,
localFieldErrors,
} from "./resource-section.tsx";
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
// 全局设置的一节:一组字段,独立保存(PATCH 对应 key);
@@ -49,6 +56,12 @@ export function SettingsCard({
const save = async (e: React.FormEvent) => {
e.preventDefault();
const local = localFieldErrors(spec.fields, values);
if (Object.keys(local).length) {
setErrors(local);
revealFirstError();
return;
}
const patch = spec.toPatch(values, settings);
const parsed = GlobalSettingsSchema.safeParse(mergeSettings(settings, patch));
if (!parsed.success) {
@@ -89,6 +102,7 @@ export function SettingsCard({
key={f.name}
spec={f}
value={values[f.name]}
values={values}
error={errors[f.name]}
disabled={update.isPending}
onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))}
@@ -124,7 +138,7 @@ export function SettingsCard({
function controlWidth(spec: FieldSpec): string {
if (spec.kind === "switch") return "";
if (spec.wide || spec.kind === "textarea") return "w-full";
if (spec.kind === "number") return "w-full sm:w-40";
if (spec.kind === "number" || spec.kind === "tokens") return "w-full sm:w-40";
if (spec.kind === "select") return "w-full sm:max-w-xs";
return "w-full sm:max-w-md";
}
@@ -139,15 +153,19 @@ export function SettingsField({
error,
onChange,
disabled,
values,
}: {
spec: FieldSpec;
value: FieldValue | undefined;
error?: string;
onChange: (v: FieldValue) => void;
disabled?: boolean;
/** 整个表单的值(凭证字段据此判断是否已设置) */
values?: FormValues;
}) {
const id = `f-${spec.name}`;
const isSwitch = spec.kind === "switch";
const hint = fieldHint(spec, value, values ?? {}, spec.hint);
return (
<div
className={cn(
@@ -160,13 +178,14 @@ export function SettingsField({
{spec.label}
{spec.required ? <span className="text-destructive">*</span> : null}
</Label>
{spec.hint ? <p className="mt-1 text-meta text-muted-foreground">{spec.hint}</p> : null}
{hint ? <p className="mt-1 text-meta text-muted-foreground">{hint}</p> : null}
</div>
<div className={cn("min-w-0", isSwitch && "pt-0.5 md:justify-self-start")}>
<FieldInput
id={id}
spec={spec}
value={value}
values={values}
error={error}
onChange={onChange}
disabled={disabled}
@@ -1,12 +1,24 @@
import type { SiteCredentialsBody } from "@umt/contracts";
import type { SiteAccount, SiteCredentialsBody } from "@umt/contracts";
import { FormField } from "~/components/console/form-field.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { secretHint, secretPatch } from "~/lib/secret.ts";
import { cn } from "~/lib/utils.ts";
import { AUTH_KIND_LABEL, type LoginKind } from "./site-fields.ts";
// 凭证三选一(账号密码 / Cookie / API Key)。只写不回显;添加站点向导与"更新凭证"对话框共用。
// 更新已有账号且类型不变时,已存的密码 / Cookie / API Key / TOTP 显示掩码占位,留空 = 保持原值(值约定见 ~/lib/secret.ts)。
/** 已存账号里与表单相关的非密信息 */
export type SavedCredential = { kind: LoginKind; hasTotp: boolean };
/** 已有账号 → 表单初值:同类型时带上登录名与 UA(非密),凭证留空表示保持 */
export function draftFromAccount(kind: LoginKind, acc: SiteAccount | null | undefined): CredentialDraft {
const d = emptyCredential(kind);
if (!acc || acc.authKind !== kind) return d;
return { ...d, username: acc.loginName ?? "", userAgent: acc.userAgent ?? "" };
}
export type CredentialDraft = {
kind: LoginKind;
@@ -34,20 +46,25 @@ export const emptyCredential = (kind: LoginKind): CredentialDraft => ({
export function credentialBody(
d: CredentialDraft,
label: string,
saved?: SavedCredential | null,
): { body: SiteCredentialsBody; errors: null } | { body: null; errors: Record<string, string> } {
const ua = d.userAgent.trim() || undefined;
// 同类型已存:密码 / Cookie / API Key 可以留空保持
const keep = saved?.kind === d.kind;
if (d.kind === "password") {
const errors: Record<string, string> = {};
if (!d.username.trim()) errors.username = "填写登录名";
if (!d.password) errors.password = "填写密码";
const password = secretPatch(d.password);
if (!password && !keep) errors.password = "填写密码";
if (Object.keys(errors).length) return { body: null, errors };
const totp = secretPatch(d.totpSecret);
return {
body: {
kind: "password",
label,
username: d.username.trim(),
password: d.password,
totpSecret: d.totpSecret.trim() || undefined,
...(password ? { password } : {}),
...(totp !== undefined ? { totpSecret: totp } : {}),
saveCookieOnly: d.saveCookieOnly || undefined,
userAgent: ua,
},
@@ -55,11 +72,13 @@ export function credentialBody(
};
}
if (d.kind === "cookie") {
if (!d.cookie.trim()) return { body: null, errors: { cookie: "粘贴 Cookie" } };
return { body: { kind: "cookie", label, cookie: d.cookie.trim(), userAgent: ua }, errors: null };
const cookie = secretPatch(d.cookie);
if (!cookie && !keep) return { body: null, errors: { cookie: "粘贴 Cookie" } };
return { body: { kind: "cookie", label, ...(cookie ? { cookie } : {}), userAgent: ua }, errors: null };
}
if (!d.apiKey.trim()) return { body: null, errors: { apiKey: "填写 API Key" } };
return { body: { kind: "api_key", label, apiKey: d.apiKey.trim() }, errors: null };
const apiKey = secretPatch(d.apiKey);
if (!apiKey && !keep) return { body: null, errors: { apiKey: "填写 API Key" } };
return { body: { kind: "api_key", label, ...(apiKey ? { apiKey } : {}) }, errors: null };
}
export function CredentialFields({
@@ -69,6 +88,7 @@ export function CredentialFields({
errors,
disabled,
idPrefix = "cred",
saved,
}: {
value: CredentialDraft;
onChange: (v: CredentialDraft) => void;
@@ -76,8 +96,12 @@ export function CredentialFields({
errors: Record<string, string>;
disabled?: boolean;
idPrefix?: string;
/** 更新已有账号时传:同类型的已存凭证显示掩码,留空保持 */
saved?: SavedCredential | null;
}) {
const set = <K extends keyof CredentialDraft>(k: K, v: CredentialDraft[K]) => onChange({ ...value, [k]: v });
const isSet = saved?.kind === value.kind;
const keepHint = (h?: string) => (isSet ? (h ? `${h};留空不修改` : "留空不修改") : h);
const id = (s: string) => `${idPrefix}-${s}`;
return (
<div className="grid gap-x-4 gap-y-1">
@@ -121,14 +145,14 @@ export function CredentialFields({
disabled={disabled}
/>
</FormField>
<FormField label="密码" htmlFor={id("pass")} required error={errors.password}>
<Input
<FormField label="密码" htmlFor={id("pass")} required error={errors.password} hint={keepHint()}>
<SecretInput
id={id("pass")}
type="password"
autoComplete="new-password"
label="密码"
value={value.password}
aria-invalid={!!errors.password}
onChange={(e) => set("password", e.target.value)}
isSet={isSet}
invalid={!!errors.password}
onChange={(v) => set("password", v)}
disabled={disabled}
/>
</FormField>
@@ -136,14 +160,19 @@ export function CredentialFields({
<FormField
label="TOTP 密钥"
htmlFor={id("totp")}
hint="站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码"
hint={secretHint(
"站点开了二次验证时填 base32 密钥,登录时自动生成 6 位码",
value.totpSecret,
isSet && !!saved?.hasTotp,
)}
>
<Input
<SecretInput
id={id("totp")}
type="password"
autoComplete="off"
label="TOTP 密钥"
value={value.totpSecret}
onChange={(e) => set("totpSecret", e.target.value)}
isSet={isSet && !!saved?.hasTotp}
clearable
onChange={(v) => set("totpSecret", v)}
disabled={disabled}
/>
</FormField>
@@ -168,27 +197,34 @@ export function CredentialFields({
htmlFor={id("cookie")}
required
error={errors.cookie}
hint="浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie"
hint={keepHint("浏览器登录站点后,从开发者工具复制请求头里的整段 Cookie")}
>
<Textarea
<SecretInput
id={id("cookie")}
rows={3}
className="font-mono text-caption"
label="Cookie"
multiline
value={value.cookie}
aria-invalid={!!errors.cookie}
onChange={(e) => set("cookie", e.target.value)}
isSet={isSet}
invalid={!!errors.cookie}
onChange={(v) => set("cookie", v)}
disabled={disabled}
/>
</FormField>
) : (
<FormField label="API Key" htmlFor={id("apikey")} required error={errors.apiKey} hint="在站点控制台生成">
<Input
<FormField
label="API Key"
htmlFor={id("apikey")}
required
error={errors.apiKey}
hint={keepHint("在站点控制台生成")}
>
<SecretInput
id={id("apikey")}
type="password"
autoComplete="off"
label="API Key"
value={value.apiKey}
aria-invalid={!!errors.apiKey}
onChange={(e) => set("apiKey", e.target.value)}
isSet={isSet}
invalid={!!errors.apiKey}
onChange={(v) => set("apiKey", v)}
disabled={disabled}
/>
</FormField>
@@ -8,9 +8,9 @@ import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { SecretInput } from "~/components/console/secret-input.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
@@ -226,7 +226,7 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
onSubmit={async (e) => {
e.preventDefault();
if (!v.trim()) {
setErr("填写 Passkey");
setErr(has ? "填写新的 Passkey 再保存" : "填写 Passkey");
return;
}
setErr(null);
@@ -241,16 +241,17 @@ function PasskeyForm({ site, label, has }: { site: SiteListItem; label: string;
>
<h2 className="text-lead font-semibold text-foreground">站点 Passkey(RSS 与下载链接用)</h2>
<p className="mb-3 text-caption text-muted-foreground">
用于 RSS 和下载链接,不参与登录。{has ? "已保存;再次保存会替换。" : "还没有保存。"}
用于 RSS 和下载链接,不参与登录。{has ? "填写新值保存会替换。" : "还没有保存。"}
</p>
<div className="flex flex-wrap items-start gap-2">
<FormField label="Passkey" htmlFor="pk" error={err} className="min-w-0 flex-1">
<Input
<SecretInput
id="pk"
type="password"
autoComplete="off"
label="Passkey"
value={v}
onChange={(e) => setV(e.target.value)}
isSet={has}
invalid={!!err}
onChange={setV}
disabled={put.isPending}
/>
</FormField>
+14 -3
View File
@@ -28,7 +28,13 @@ 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 { CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import {
CredentialFields,
credentialBody,
draftFromAccount,
emptyCredential,
type SavedCredential,
} from "./credential-fields.tsx";
import {
AUTH_KIND_LABEL,
bytes,
@@ -229,7 +235,11 @@ export function CredentialDialog({
(current && current.authKind !== "passkey" && kinds.includes(current.authKind as LoginKind)
? (current.authKind as LoginKind)
: (kinds[0] ?? "cookie"));
const [draft, setDraft] = useState(() => emptyCredential(initialKind));
const [draft, setDraft] = useState(() => draftFromAccount(initialKind, current));
const saved: SavedCredential | null =
current && current.authKind !== "passkey" && current.secretRef
? { kind: current.authKind as LoginKind, hasTotp: current.hasTotp }
: null;
const [errors, setErrors] = useState<Record<string, string>>({});
const [result, setResult] = useState<SiteLoginResult | null>(null);
const put = usePutCredentials();
@@ -239,7 +249,7 @@ export function CredentialDialog({
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const b = credentialBody(draft, current?.label ?? site.key);
const b = credentialBody(draft, current?.label ?? site.key, saved);
if (!b.body) {
setErrors(b.errors);
return;
@@ -283,6 +293,7 @@ export function CredentialDialog({
errors={errors}
disabled={busy}
idPrefix="upd"
saved={saved}
/>
</form>
{result && !result.ok ? (