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