feat(web): token 数以 K 输入显示,已存凭证显示掩码占位(FIX-8)
- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens) - 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处 - 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA - contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写 - 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
68f53390f6
commit
fa344a7184
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";
|
||||
@@ -653,7 +654,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,
|
||||
@@ -217,7 +218,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";
|
||||
@@ -84,7 +85,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 +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
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SecretInput } from "~/components/console/secret-input.tsx";
|
||||
import { MASK_DOTS, maskTail, SECRET_CLEAR, secretHint, secretPatch, secretPlaceholder, secretView } from "./secret.ts";
|
||||
|
||||
const render = (props: Partial<Parameters<typeof SecretInput>[0]>) =>
|
||||
renderToStaticMarkup(
|
||||
createElement(SecretInput, { id: "k", value: "", onChange: () => {}, isSet: false, label: "API Key", ...props }),
|
||||
);
|
||||
|
||||
describe("只写凭证的表单值约定", () => {
|
||||
it("表单值 → 请求值:空串不改、哨兵清除、其余去空白替换", () => {
|
||||
expect(secretPatch("")).toBeUndefined();
|
||||
expect(secretPatch(" ")).toBeUndefined();
|
||||
expect(secretPatch(undefined)).toBeUndefined();
|
||||
expect(secretPatch(true)).toBeUndefined();
|
||||
expect(secretPatch(SECRET_CLEAR)).toBeNull();
|
||||
expect(secretPatch(" sk-new ")).toBe("sk-new");
|
||||
});
|
||||
|
||||
it("掩码只取服务端 hint 的末 4 位,拼不出更多", () => {
|
||||
expect(maskTail("sk-l…1234")).toBe("1234");
|
||||
expect(maskTail("****")).toBeNull();
|
||||
expect(maskTail(null)).toBeNull();
|
||||
expect(secretPlaceholder("sk-l…1234")).toBe(`已设置 ${MASK_DOTS}1234`);
|
||||
expect(secretPlaceholder(null)).toBe(`已设置 ${MASK_DOTS}`);
|
||||
expect(secretPlaceholder("sk-l…1234")).not.toContain("sk-l");
|
||||
});
|
||||
|
||||
it("显示状态:空 / 已设置 / 输入中(替换)/ 待清除", () => {
|
||||
expect(secretView("", { isSet: false })).toEqual({ state: "empty" });
|
||||
expect(secretView("", { isSet: true, clearable: true })).toEqual({
|
||||
state: "set",
|
||||
placeholder: `已设置 ${MASK_DOTS}`,
|
||||
canClear: true,
|
||||
});
|
||||
expect(secretView("abc", { isSet: true })).toEqual({ state: "typing", replacing: true });
|
||||
expect(secretView("abc", { isSet: false })).toEqual({ state: "typing", replacing: false });
|
||||
expect(secretView(SECRET_CLEAR, { isSet: true })).toEqual({ state: "cleared" });
|
||||
});
|
||||
|
||||
it("说明文字随状态变", () => {
|
||||
expect(secretHint("在控制台生成", "", false)).toBe("在控制台生成");
|
||||
expect(secretHint("在控制台生成", "", true)).toBe("在控制台生成;留空不修改");
|
||||
expect(secretHint(undefined, "", true)).toBe("留空不修改");
|
||||
expect(secretHint("x", SECRET_CLEAR, true)).toBe("x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SecretInput 渲染", () => {
|
||||
it("未设置:普通密码框,用调用方占位,没有清除", () => {
|
||||
const html = render({ placeholder: "qbt_…" });
|
||||
expect(html).toContain('type="password"');
|
||||
expect(html).toContain('placeholder="qbt_…"');
|
||||
expect(html).not.toContain("清除");
|
||||
});
|
||||
|
||||
it("已设置:框里是掩码占位(不回填 value),可清除时有「清除」", () => {
|
||||
const html = render({ isSet: true, mask: "sk-l…1234", clearable: true });
|
||||
expect(html).toContain(`placeholder="已设置 ${MASK_DOTS}1234"`);
|
||||
expect(html).toContain('value=""');
|
||||
expect(html).toContain("清除");
|
||||
expect(html).not.toContain("sk-l");
|
||||
});
|
||||
|
||||
it("已设置但不可清除(必填凭证):没有「清除」", () => {
|
||||
expect(render({ isSet: true })).not.toContain("清除");
|
||||
});
|
||||
|
||||
it("输入了新值:可撤销回保持原值", () => {
|
||||
const html = render({ isSet: true, value: "new-key" });
|
||||
expect(html).toContain("撤销");
|
||||
expect(html).toContain('aria-label="不替换API Key"');
|
||||
});
|
||||
|
||||
it("待清除:只读框提示保存后清除,可撤销", () => {
|
||||
const html = render({ isSet: true, clearable: true, value: SECRET_CLEAR });
|
||||
expect(html).toContain('placeholder="保存后清除"');
|
||||
expect(html).toContain("撤销");
|
||||
expect(html).not.toContain(SECRET_CLEAR);
|
||||
});
|
||||
|
||||
it("多行(Cookie):textarea,不回填", () => {
|
||||
const html = render({ isSet: true, multiline: true });
|
||||
expect(html).toContain("<textarea");
|
||||
expect(html).toContain(`placeholder="已设置 ${MASK_DOTS}"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// 只写凭证字段(API Key、密码、Cookie、令牌)的表单值约定,前后端一致:
|
||||
// "" → 保持原值(请求里省略该字段)
|
||||
// SECRET_CLEAR → 清除(请求里传 null)
|
||||
// 其它字符串 → 替换为新值
|
||||
// 服务端从不回传明文;"已设置"只来自 secretRef / hasCredential 之类的布尔,掩码只用服务端给的 hint。
|
||||
|
||||
/** 表单里"清除"的哨兵值;不会是用户能输入的内容 */
|
||||
export const SECRET_CLEAR = "\u0000clear";
|
||||
|
||||
export const MASK_DOTS = "••••••••";
|
||||
|
||||
/** 表单值 → 请求值:undefined = 不改,null = 清除,字符串 = 替换(去掉首尾空白) */
|
||||
export function secretPatch(v: unknown): string | null | undefined {
|
||||
if (v === SECRET_CLEAR) return null;
|
||||
if (typeof v !== "string") return undefined;
|
||||
const s = v.trim();
|
||||
return s ? s : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端给的掩码(如 "sk-a…wxyz")只取末 4 位;没有或太短只显示圆点。
|
||||
* 不在前端拼任何可还原的内容。
|
||||
*/
|
||||
export function maskTail(mask: string | null | undefined): string | null {
|
||||
if (!mask) return null;
|
||||
const tail = mask.includes("…") ? mask.split("…").pop() : null;
|
||||
return tail && /^[^•*]{4}$/.test(tail) ? tail : null;
|
||||
}
|
||||
|
||||
/** 掩码显示:圆点 + 末 4 位(有的话) */
|
||||
export function maskedDisplay(mask?: string | null): string {
|
||||
return `${MASK_DOTS}${maskTail(mask) ?? ""}`;
|
||||
}
|
||||
|
||||
/** 已设置时输入框里的占位 */
|
||||
export function secretPlaceholder(mask?: string | null): string {
|
||||
return `已设置 ${maskedDisplay(mask)}`;
|
||||
}
|
||||
|
||||
export type SecretView =
|
||||
| { state: "empty" }
|
||||
| { state: "set"; placeholder: string; canClear: boolean }
|
||||
| { state: "typing"; replacing: boolean }
|
||||
| { state: "cleared" };
|
||||
|
||||
/** SecretInput 的显示状态(纯函数,便于测试) */
|
||||
export function secretView(
|
||||
value: string,
|
||||
opts: { isSet: boolean; mask?: string | null; clearable?: boolean },
|
||||
): SecretView {
|
||||
if (value === SECRET_CLEAR) return { state: "cleared" };
|
||||
if (value !== "") return { state: "typing", replacing: opts.isSet };
|
||||
if (opts.isSet) return { state: "set", placeholder: secretPlaceholder(opts.mask), canClear: !!opts.clearable };
|
||||
return { state: "empty" };
|
||||
}
|
||||
|
||||
/** 字段说明:已设置时补一句"留空不修改";待清除时框里已写明,说明保持原样 */
|
||||
export function secretHint(hint: string | undefined, value: unknown, isSet: boolean): string | undefined {
|
||||
if (value === SECRET_CLEAR) return hint;
|
||||
if (!isSet) return hint;
|
||||
return hint ? `${hint};留空不修改` : "留空不修改";
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatTokens, kToTokens, tokenInputError, tokenRangeText, tokensToK } from "./tokens.ts";
|
||||
|
||||
describe("token 以 K 为单位的换算", () => {
|
||||
it("原始值 → 输入框 K 值:整千、小数、空", () => {
|
||||
expect(tokensToK(128_000)).toBe("128");
|
||||
expect(tokensToK(1_500)).toBe("1.5");
|
||||
expect(tokensToK(4_096)).toBe("4.096");
|
||||
expect(tokensToK(2_000_000)).toBe("2000");
|
||||
expect(tokensToK(null)).toBe("");
|
||||
expect(tokensToK(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("K 值 → 原始值:允许小数与尾随 K,空串 undefined,非法 NaN", () => {
|
||||
expect(kToTokens("128")).toBe(128_000);
|
||||
expect(kToTokens(" 1.5 ")).toBe(1_500);
|
||||
expect(kToTokens("200k")).toBe(200_000);
|
||||
expect(kToTokens(".5")).toBe(500);
|
||||
expect(kToTokens("0.0005")).toBe(1);
|
||||
expect(kToTokens("")).toBeUndefined();
|
||||
expect(kToTokens(" ")).toBeUndefined();
|
||||
expect(kToTokens("abc")).toBeNaN();
|
||||
expect(kToTokens("-1")).toBeNaN();
|
||||
expect(kToTokens("1e3")).toBeNaN();
|
||||
});
|
||||
|
||||
it("往返不丢精度", () => {
|
||||
for (const n of [4_000, 4_096, 128_000, 131_072, 1_000_000, 2_000_000, 1]) expect(kToTokens(tokensToK(n))).toBe(n);
|
||||
});
|
||||
|
||||
it("校验:范围、必填、非法输入,错误用 K 表述", () => {
|
||||
const r = { min: 4_000, max: 2_000_000, required: true };
|
||||
expect(tokenRangeText(r)).toBe("4K–2000K");
|
||||
expect(tokenInputError("128", r)).toBeNull();
|
||||
expect(tokenInputError("4", r)).toBeNull();
|
||||
expect(tokenInputError("3.9", r)).toBe("填 4K–2000K");
|
||||
expect(tokenInputError("2001", r)).toBe("填 4K–2000K");
|
||||
expect(tokenInputError("", r)).toBe("填一个数,单位 K");
|
||||
expect(tokenInputError("12x", r)).toMatch(/只填数字/);
|
||||
// 可选:空串通过;0 不行
|
||||
expect(tokenInputError("", { min: 1 })).toBeNull();
|
||||
expect(tokenInputError("0", { min: 1 })).toBe("填 0.001K 以上");
|
||||
expect(tokenInputError("0", {})).toBe("填大于 0 的数");
|
||||
});
|
||||
|
||||
it("列表 / 详情显示:K / M 简写", () => {
|
||||
expect(formatTokens(512)).toBe("512");
|
||||
expect(formatTokens(1_500)).toBe("1.5K");
|
||||
expect(formatTokens(12_345)).toBe("12.3K");
|
||||
expect(formatTokens(128_000)).toBe("128K");
|
||||
expect(formatTokens(200_000)).toBe("200K");
|
||||
expect(formatTokens(999_960)).toBe("1M");
|
||||
expect(formatTokens(1_000_000)).toBe("1M");
|
||||
expect(formatTokens(1_048_576)).toBe("1.05M");
|
||||
expect(formatTokens(undefined)).toBe("—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// token 数在界面上以 K(千)为单位输入与显示;存储与 API 仍是原始 token 数,只在前端换算。
|
||||
// 1K = 1000 token(模型目录的上下文窗口也是按 1000 计,如 128K = 128000)。
|
||||
|
||||
export const TOKENS_PER_K = 1000;
|
||||
|
||||
/** 去掉小数末尾的 0:1.50 → "1.5",2.00 → "2" */
|
||||
const trim = (n: number, digits: number) => String(Number(n.toFixed(digits)));
|
||||
|
||||
/** 列表 / 详情里的 token 数:<1000 原样,千级 "128K",百万级 "1.5M" */
|
||||
export function formatTokens(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return "—";
|
||||
const abs = Math.abs(n);
|
||||
if (abs < TOKENS_PER_K) return String(Math.round(n));
|
||||
if (abs < 1_000_000) {
|
||||
const k = n / TOKENS_PER_K;
|
||||
// 999.95K 以上四舍五入会变成 1000K,直接进位到 M
|
||||
if (Math.abs(Number(k.toFixed(1))) < 1000) return `${trim(k, Math.abs(k) < 10 ? 2 : 1)}K`;
|
||||
}
|
||||
return `${trim(n / 1_000_000, 2)}M`;
|
||||
}
|
||||
|
||||
/** 原始 token 数 → 输入框里的 K 值字符串(null → 空串);最多 3 位小数,正好换回原值 */
|
||||
export function tokensToK(n: number | null | undefined): string {
|
||||
if (n === null || n === undefined || !Number.isFinite(n)) return "";
|
||||
return trim(n / TOKENS_PER_K, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入框里的 K 值 → 原始 token 数。
|
||||
* 空串 → undefined;不是数字 → NaN(交给校验报错);其余四舍五入到整数 token。
|
||||
*/
|
||||
export function kToTokens(input: string): number | undefined {
|
||||
const s = input.trim().replace(/[kK]$/, "").trim();
|
||||
if (s === "") return undefined;
|
||||
if (!/^\d+(\.\d+)?$|^\.\d+$/.test(s)) return Number.NaN;
|
||||
return Math.round(Number(s) * TOKENS_PER_K);
|
||||
}
|
||||
|
||||
export type TokenRange = { min?: number; max?: number; required?: boolean };
|
||||
|
||||
/** 范围说明:{min:4000,max:2000000} → "4K–2000K" */
|
||||
export function tokenRangeText(r: TokenRange): string | null {
|
||||
if (r.min !== undefined && r.max !== undefined) return `${tokensToK(r.min)}K–${tokensToK(r.max)}K`;
|
||||
if (r.min !== undefined) return `${tokensToK(r.min)}K 以上`;
|
||||
if (r.max !== undefined) return `不超过 ${tokensToK(r.max)}K`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 校验 K 值输入;通过返回 null,否则返回一句错误 */
|
||||
export function tokenInputError(input: string, r: TokenRange = {}): string | null {
|
||||
const t = kToTokens(input);
|
||||
if (t === undefined) return r.required ? "填一个数,单位 K" : null;
|
||||
const range = tokenRangeText(r);
|
||||
if (Number.isNaN(t)) return "只填数字,单位 K,如 128 或 1.5";
|
||||
if (t <= 0 || (r.min !== undefined && t < r.min) || (r.max !== undefined && t > r.max))
|
||||
return range ? `填 ${range}` : "填大于 0 的数";
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user