diff --git a/src/upstreams/AccountPanel.i18n.tsx b/src/upstreams/AccountPanel.i18n.tsx index f386b220..cdff5a01 100644 --- a/src/upstreams/AccountPanel.i18n.tsx +++ b/src/upstreams/AccountPanel.i18n.tsx @@ -18,6 +18,7 @@ export const accountPanelText = messages( noticeTheirPage: "授权页由对方提供,页面上显示的是他们自己的客户端名称。", noticeKey: "登录会在账号中创建一把名为 thinkwatch 的 API 密钥(已存在则直接使用),额度按该密钥计入套餐。", + noticeKeyStorage: (where: string) => `得到的密钥保存在${where}的配置文件中,与其他上游一同管理。`, here: "本机", server: (name: string) => ` ${name} `, understood: "已阅读上述说明", @@ -45,7 +46,7 @@ export const accountPanelText = messages( open: "打开", deviceWaiting: (min: number) => `等待输入 · ${min} 分钟内有效`, newCode: "重新获取", - deviceWarning: "只输入这里显示的设备码", + deviceWarning: "只输入这里显示的设备码;他人提供的设备码请勿输入。", doneTitle: "已登录", reloginDone: (name: ReactNode) => <>已重新登录,上游 {name} 的凭据已更新。, signedIn: "已登录", @@ -75,6 +76,8 @@ export const accountPanelText = messages( noticeTheirPage: "The authorization page is theirs, and it names their own client.", noticeKey: "Signing in creates an API key named thinkwatch on the account, or uses the existing one. Quota is counted against that key.", + noticeKeyStorage: (where: string) => + `The key obtained by signing in is stored in the config file ${where} and managed together with the other upstreams.`, here: "on this computer", server: (name: string) => `on ${name}`, understood: "Acknowledge the notes above", @@ -103,7 +106,7 @@ export const accountPanelText = messages( open: "Open", deviceWaiting: (min: number) => `Waiting for the code · valid for ${min} min`, newCode: "Get a new code", - deviceWarning: "Enter only the device code shown here", + deviceWarning: "Enter only the device code shown here; never enter a device code provided by someone else.", doneTitle: "Signed in", reloginDone: (name: ReactNode) => <>Signed in again. The credential of the upstream {name} has been replaced., signedIn: "Signed in", diff --git a/src/upstreams/AccountPanel.tsx b/src/upstreams/AccountPanel.tsx index d3525222..2aaaa1f4 100644 --- a/src/upstreams/AccountPanel.tsx +++ b/src/upstreams/AccountPanel.tsx @@ -159,6 +159,7 @@ function Methods({ <>
  • {t.noticeTheirPage}
  • {t.noticeKey}
  • +
  • {t.noticeKeyStorage(where)}
  • )} @@ -273,8 +274,8 @@ function Way({ } /** - * 设备码登录:验证网址和设备码并排(网址能复制、能打开,码大字、能复制),下面是有效期、 - * 重新获取和那一句提醒。在这台电脑上时还能改回浏览器登录 + * 设备码登录:验证网址和设备码并排(网址能复制、能打开,码大字、能复制),下面是有效期和 + * 重新获取(在这台电脑上时还能改回浏览器登录),最后一句提醒 */ function DeviceCode({ login, params }: { login: AccountLogin; params: LoginParams }) { const t = useText(accountPanelText); @@ -317,9 +318,9 @@ function DeviceCode({ login, params }: { login: AccountLogin; params: LoginParam )} - {/* 拿着别人给的码去输,等于把自己的账号授权给对方 */} - {t.deviceWarning} + {/* 拿着别人给的码去输,等于把自己的账号授权给对方 */} +

    {t.deviceWarning}

    ); } diff --git a/src/upstreams/BillingSection.tsx b/src/upstreams/BillingSection.tsx index 418389ab..013e86e7 100644 --- a/src/upstreams/BillingSection.tsx +++ b/src/upstreams/BillingSection.tsx @@ -65,7 +65,7 @@ export function BillingSection({ ] : []; - // 一屏放下:价格表在自己的框里滚,框按剩下的高度收 + // 价格表在自己的框里滚,框按剩下的高度收;再矮就让对话框中间那一块去滚 return (
    @@ -110,13 +110,15 @@ export function BillingSection({
    {perToken && linkSheet && ( -
    - - {t.linkSheetDetail(linkSheet.multiplier, Object.keys(linkSheet.models).length)} - - {sheetProblem && {sheetProblem}} -
    - @@ -124,7 +126,7 @@ export function BillingSection({ )} {perToken && sheet && ( -
    +
    {t.usedBy}
    diff --git a/src/upstreams/ChatgptAccountSection.i18n.ts b/src/upstreams/ChatgptAccountSection.i18n.ts index 3c068068..98b635c4 100644 --- a/src/upstreams/ChatgptAccountSection.i18n.ts +++ b/src/upstreams/ChatgptAccountSection.i18n.ts @@ -16,6 +16,7 @@ export const chatgptAccountText = messages( }, name: "名称", egress: "出站方式", + egressDesc: "登录与后续请求都经此发出", direct: "直连", systemProxy: "系统代理", quota: "订阅额度", @@ -56,6 +57,7 @@ export const chatgptAccountText = messages( }, name: "Name", egress: "Outbound connection", + egressDesc: "Used for sign-in and all later requests", direct: "Direct", systemProxy: "System proxy", quota: "Usage limits", diff --git a/src/upstreams/ChatgptAccountSection.tsx b/src/upstreams/ChatgptAccountSection.tsx index 8f11ae5b..5acb1047 100644 --- a/src/upstreams/ChatgptAccountSection.tsx +++ b/src/upstreams/ChatgptAccountSection.tsx @@ -116,10 +116,9 @@ export function ChatgptAccountSection({ const list = credits?.credits ?? []; const available = list.filter((c) => c.status === AVAILABLE); - // 一屏放下:名称、出站方式、并发上限一行,额度和重置卡左右两栏 return ( -
    -
    +
    +
    set({ name: e.target.value })} /> - + {/* 账号一样限制同时进行的请求 */} - +
    {!broken && ( -
    -
    -
    + <> +
    +

    {t.quota}

    -
    -
    +
    +

    {t.credits} {credits && ` · ${t.available(available.length)}`} @@ -233,7 +232,7 @@ export function ChatgptAccountSection({ )}

    -
    + )} diff --git a/src/upstreams/ConnectionSection.i18n.ts b/src/upstreams/ConnectionSection.i18n.ts index afa00215..d23d2ca2 100644 --- a/src/upstreams/ConnectionSection.i18n.ts +++ b/src/upstreams/ConnectionSection.i18n.ts @@ -14,6 +14,7 @@ export const connectionSectionText = messages( namePlaceholder: "例如 relay-hk", namePlaceholderGateway: "例如 corp-gateway", baseUrl: "接口地址", + baseUrlDesc: "服务商提供的 Base URL,不含 /chat/completions、/messages 等接口路径。", auth: "认证方式", site: "站点", siteZai: "Z.ai(国际)", @@ -22,6 +23,7 @@ export const connectionSectionText = messages( /** 收起时那一行列出的几项之间 */ listSep: "、", headers: "请求头", + headersHint: "随每个请求发送。值中的 ${变量名} 读取系统环境变量。", authUnknown: "鉴权头(按接口协议)", renewedToken: "‹自动换发的 Access Token›", signedPerRequest: "‹每个请求单独签名›", @@ -31,6 +33,7 @@ export const connectionSectionText = messages( accessKeyId: "访问密钥 ID", secretAccessKey: "私有访问密钥", sessionToken: "会话令牌(可选)", + sessionTokenDesc: "临时凭证才有。", /** 术语表:两种语言都写 AWS profile(「配置文件」是 config.yaml) */ awsProfile: "AWS profile", profileDesc: "读取本机 AWS 凭证文件(~/.aws/credentials、~/.aws/config)中的这个 profile,只支持写有访问密钥的 profile。", @@ -56,6 +59,7 @@ export const connectionSectionText = messages( clientId: "Client ID(可选)", clientSecret: "Client Secret(可选)", accessToken: "Access Token(检测用,可选)", + accessTokenDesc: "仅用于保存前检测连接。", failed: "连接失败", authenticated: "认证通过", responded: (ms: string) => `响应 ${ms}`, @@ -74,6 +78,7 @@ export const connectionSectionText = messages( namePlaceholder: "e.g. relay-hk", namePlaceholderGateway: "e.g. corp-gateway", baseUrl: "Base URL", + baseUrlDesc: "The provider's base URL, without an endpoint path such as /chat/completions or /messages.", auth: "Authentication", site: "Site", siteZai: "Z.ai (international)", @@ -81,6 +86,7 @@ export const connectionSectionText = messages( advanced: "Advanced settings", listSep: ", ", headers: "Headers", + headersHint: "Sent with every request. ${NAME} in a value reads a system environment variable.", authUnknown: "Auth header (per protocol)", renewedToken: "‹renewed access token›", signedPerRequest: "‹signed per request›", @@ -90,6 +96,7 @@ export const connectionSectionText = messages( accessKeyId: "Access key ID", secretAccessKey: "Secret access key", sessionToken: "Session token (optional)", + sessionTokenDesc: "Only temporary credentials have one.", awsProfile: "AWS profile", profileDesc: "Reads this profile from the AWS credential files on this computer (~/.aws/credentials, ~/.aws/config). Only profiles that hold access keys are supported.", @@ -115,6 +122,7 @@ export const connectionSectionText = messages( clientId: "Client ID (optional)", clientSecret: "Client secret (optional)", accessToken: "Access token (for checking, optional)", + accessTokenDesc: "Only used to check the connection before saving.", failed: "Connection failed", authenticated: "Authenticated", responded: (ms: string) => `responded in ${ms}`, diff --git a/src/upstreams/ConnectionSection.tsx b/src/upstreams/ConnectionSection.tsx index 2f3af8d5..800023c8 100644 --- a/src/upstreams/ConnectionSection.tsx +++ b/src/upstreams/ConnectionSection.tsx @@ -2,13 +2,11 @@ import { useState, type ReactNode } from "react"; import { ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, PlugIcon } from "lucide-react"; import { Button, DISCLOSURE } from "@/ui/button"; import { Input } from "@/ui/input"; -import { Label } from "@/ui/label"; import { NativeSelect, NativeSelectOption } from "@/ui/native-select"; import { SecretInput } from "@/ui/secret-input"; import { Segmented } from "@/ui/segmented"; import { StatusLabel } from "@/ui/status-dot"; import { Switch } from "@/ui/switch"; -import { Tip } from "@/ui/tip"; import { cn } from "@/lib/utils"; import { ms } from "@/format"; import { useText } from "@/i18n"; @@ -131,15 +129,17 @@ export interface ZaiAccount { } /** - * 连接:名称和出站代理一行,地址和协议一行(协议只在有得选时出现),凭据,最后是收起的 - * 「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限)。新建和编辑是同一张表单; - * 新建时第一步选的服务类型已经定了的几项(地址、协议、要不要密钥)不再问,见 `presets.ts`。 + * 连接:名称和出站代理一行,地址和协议、区域一行(协议、区域只在有得选时出现,地址下面一句说明), + * 凭据,最后是收起的「高级设置」(请求头、转发客户端身份、代理不可用时、并发上限)。新建和 + * 编辑是同一张表单;新建时第一步选的服务类型已经定了的几项(地址、协议、要不要密钥)不再问, + * 见 `presets.ts`。 * * 认证方式那一行和 Z.ai 的站点在这一节上面(`AuthField`、`SiteField`),新建时登录账号 * 这一节整个让位给登录那一块;编辑 Z.ai / BigModel 上游时账号登录只占密钥那一栏的位置。 * - * **「检测连接」只在编辑时是一个按钮**,在对话框底部左边(`CheckButton`):新建时点「下一步」 - * 就检测,通过了结果在「模型」一步顶上,没通过时对话框底部说原因(见 `UpstreamDialog`)。 + * **「检测连接」只在编辑时有**(`check`),在这一节最后,结果整句写在它下面(`CheckButton`)。 + * 新建时点「下一步」就检测,通过了结果在「模型」一步顶上,没通过时对话框底部说原因(见 + * `UpstreamDialog`)。 */ export function ConnectionSection({ form, @@ -149,6 +149,7 @@ export function ConnectionSection({ preview, onNewProxy, account, + check, }: { form: UpstreamForm; set: (patch: Partial) => void; @@ -159,6 +160,8 @@ export function ConnectionSection({ onNewProxy: () => void; /** 编辑 Z.ai / BigModel 上游、它在能登录的那一边时有;认证方式是账号登录时显示 */ account?: ZaiAccount; + /** 「检测连接」:编辑时有,新建时点「下一步」就检测 */ + check?: { testing: boolean; test: ProviderTestResult | null; onTest: () => void }; }) { const t = useText(connectionSectionText); // 连着远程 core 时 `${变量名}` 取的是服务器上 core 进程的环境 @@ -184,66 +187,8 @@ export function ConnectionSection({ // 不签名,非标准地址也就用不着区域 const showRegion = bedrock && (region !== null || mode !== "key"); - /* - 地址、协议、区域排成一行:地址占两份,协议、区域各占一份。地址由这一格定了的(Z.ai - 按站点)不显示,剩下的各占一半 - */ - const addr: { key: string; node: ReactNode }[] = []; - if (rules.url) { - addr.push({ - key: "url", - node: ( - - - set({ - baseUrl: e.target.value, - // 新建时名称跟着地址猜,直到用户自己填了 - name: - !editing && (form.name === "" || form.name === freeName(nameFromUrl(form.baseUrl), taken)) - ? freeName(nameFromUrl(e.target.value), taken) - : form.name, - }) - } - /> - - ), - }); - } - // 协议只在真有得选时给选:只说一种接口的服务,选了那一格就定了 - if (rules.protocols) { - addr.push({ - key: "protocol", - node: ( - - - - ), - }); - } - if (showRegion) { - addr.push({ - key: "region", - node: ( - - (region !== null ? set({ baseUrl: bedrockUrl(r) }) : set({ awsRegion: r }))} - /> - - ), - }); - } - const addrColumns = rules.url - ? addr.map((c) => (c.key === "url" ? "minmax(0,2fr)" : "minmax(0,1fr)")).join(" ") - : "repeat(2,minmax(0,1fr))"; - return ( -
    +
    {/* 名称和出站代理在每种认证方式下都是这一行:换认证方式时它们不挪地方 */}
    @@ -258,13 +203,56 @@ export function ConnectionSection({ set({ proxy })} onNewProxy={onNewProxy} />
    - {addr.length > 0 && ( -
    - {addr.map((c) => ( -
    - {c.node} -
    - ))} + {/* + 地址、协议、区域排成一行:地址占两份,协议、区域各占一份(协议只在真有得选时给选,只说 + 一种接口的服务选了那一格就定了)。地址由这一格定了的(Z.ai 按站点)不显示,剩下的各占一半 + */} + {(rules.url || rules.protocols || showRegion) && ( +
    + {rules.url && ( + + + set({ + baseUrl: e.target.value, + // 新建时名称跟着地址猜,直到用户自己填了 + name: + !editing && (form.name === "" || form.name === freeName(nameFromUrl(form.baseUrl), taken)) + ? freeName(nameFromUrl(e.target.value), taken) + : form.name, + }) + } + /> + + )} + {rules.protocols && ( + + + + )} + {showRegion && ( + + (region !== null ? set({ baseUrl: bedrockUrl(r) }) : set({ awsRegion: r }))} + /> + + )}
    )} @@ -298,8 +286,8 @@ export function ConnectionSection({ )} - {/* `${变量名}` 在值的占位里说过;连着远程 core 时读的是服务器上的环境,另说一句 */} - + {/* 连着远程 core 时 `${变量名}` 读的是服务器上的环境 */} + + + {check && }
    ); } @@ -362,22 +352,44 @@ function AdvancedSettings({ {!open && {inside.join(t.listSep)}} {/* - 展开时左右两栏:左边请求头(自定义的余额来源接在下面,和请求头一样是这个服务自己的事), - 右边转发客户端身份、代理不可用时和并发上限 —— 上下排开放不进一屏 + 展开时一项接一项:请求头整行,转发客户端身份和它的说明,最后是几项短的(代理不可用时、 + 并发上限,自定义还有余额来源)两两并排 */} {open && ( -
    -
    - {children} - {/* 标签和下拉在一行:一屏放得下展开的这一节 */} +
    + {children} + {/* 只有中转站和自定义的服务会只接受特定客户端;各家官方的接口不看这个 */} + {rules.clientIdentity && ( +
    + +

    {t.clientIdentityDesc}

    +
    + )} +
    + + set({ onProxyFail: e.target.value === "direct" ? "direct" : "fail" })} + > + {t.failWithError} + {t.fallBackDirect} + + + {rules.balance && ( -
    - + set({ balance: e.target.value as BalanceSetting })} > @@ -387,38 +399,8 @@ function AdvancedSettings({ ))} -
    - )} -
    -
    - {/* 只有中转站和自定义的服务会只接受特定客户端;各家官方的接口不看这个 */} - {rules.clientIdentity && ( -
    - -

    {t.clientIdentityDesc}

    -
    - )} -
    - - set({ onProxyFail: e.target.value === "direct" ? "direct" : "fail" })} - > - {t.failWithError} - {t.fallBackDirect} - - -
    + )}
    )} @@ -481,12 +463,9 @@ export function ProxyField({ export function ConcurrencyField({ form, set, - bare = false, }: { form: UpstreamForm; set: (patch: Partial) => void; - /** 不写那句说明(窄的一格里)。写错时照样说 */ - bare?: boolean; }) { const t = useText(connectionSectionText); const bad = concurrencyOf(form) === undefined; @@ -494,7 +473,7 @@ export function ConcurrencyField({ {t.badConcurrency} : bare ? undefined : t.concurrencyDesc} + desc={bad ? {t.badConcurrency} : t.concurrencyDesc} > ENV_REF.test(v.trim()); return ( -
    -
    - - set({ awsKeyId: e.target.value })} - /> - - - set({ awsSecret: e.target.value })} - /> - - - set({ awsToken: e.target.value })} - /> - -
    - {remote && {rt.keyHint}} +
    + + set({ awsKeyId: e.target.value })} + /> + + + set({ awsSecret: e.target.value })} + /> + + + set({ awsToken: e.target.value })} + /> +
    ); } -/** - * OAuth 凭据,两行:Token 端点和 Refresh Token;Client ID、Client Secret,和检测用的 - * Access Token(凭据没改时检测用网关现有的 token,不要它) - */ +/** OAuth 凭据。检测用的 Access Token 只在凭据新填或改过时要:没改时检测用网关现有的 token */ function OAuth({ form, set, @@ -674,63 +652,58 @@ function OAuth({ set: (patch: Partial) => void; }) { const t = useText(connectionSectionText); - const access = !oauthKept(form); return ( -
    -
    - - set({ oauthEndpoint: e.target.value })} - /> - - +
    + + set({ oauthEndpoint: e.target.value })} + /> + + + set({ oauthRefresh: e.target.value })} + /> + + + set({ oauthClientId: e.target.value })} + /> + + + set({ oauthClientSecret: e.target.value })} + /> + + {!oauthKept(form) && ( + set({ oauthRefresh: e.target.value })} + value={form.oauthAccess} + onChange={(e) => set({ oauthAccess: e.target.value })} /> -
    -
    - - set({ oauthClientId: e.target.value })} - /> - - - set({ oauthClientSecret: e.target.value })} - /> - - {access && ( - - set({ oauthAccess: e.target.value })} - /> - - )} -
    + )}
    ); } /** - * 行菜单「检测连接」对话框里的结果:「连接正常 · 认证通过 · 响应 312ms · 经由 hk-socks · - * 发现 6 个模型」。 + * 检测连接的结果(行菜单的「检测连接」对话框,编辑时「连接」一节最后那一块):「连接正常 · + * 认证通过 · 响应 312ms · 经由 hk-socks · 发现 6 个模型」。 * * 检测时一并读了余额的,下面再一行:从哪儿读的,和上游表那一格写的那几样(`balanceBrief`)。 * 读取失败时这一行是琥珀色的原因。 @@ -778,9 +751,8 @@ export function TestLine({ result }: { result: ProviderTestResult }) { } /** - * 编辑时对话框底部左边的「检测连接」,结果接在右边一行:「✓ 连接正常 · 认证通过 · 响应 - * 312ms · 发现 6 个模型」,没通过是「连接失败」和 core 给的原因。一行放不下的截断,全文 - * (和检测时读到的余额)在悬停里 + * 编辑时「连接」一节最后的「检测连接」:按钮旁一句它做什么(检测中是转圈),检测完结果整句 + * 写在下面(`TestLine`:认证、响应时间、代理、模型数,读到余额的再一行) */ export function CheckButton({ testing, @@ -792,58 +764,30 @@ export function CheckButton({ onTest: () => void; }) { const t = useText(connectionSectionText); - const lines = test ? testLines(test, t) : []; return ( -
    - - - - {testing ? ( - - {t.checking} - - ) : ( - test && ( - - {lines.map((l, i) => ( - {l} - ))} - - } - > -
    - {test.ok ? ( - - ) : ( - - )} - {test.ok ? t.ok : t.failed} - {lines.slice(1).join(" · ")} -
    -
    - ) + {testing ? ( + + {t.checking} + + ) : ( + {t.checkNote} + )} +
    + {test && !testing && ( +
    + +
    )}
    ); } -/** 检测结果写成几行:结论、细节(或原因)、余额 */ -function testLines(r: ProviderTestResult, t: (typeof connectionSectionText)["zh"]): string[] { - if (!r.ok) return [t.failed, ...(r.error ? [coreText(r.error)] : [])]; - const parts = [t.authenticated, t.responded(ms(r.latency_ms)), r.via ? t.via(r.via) : null, describeModelList(r.models)]; - const balance = balanceBrief(r.balance, Date.now()); - return [ - t.ok, - parts.filter(Boolean).join(" · "), - ...(balance ? [`${balance.source} · ${balance.text}`] : []), - ]; -} - /** * 新建时「模型」一步顶上的一行:点「下一步」检测通过的结果。「连接正常 · 响应 312ms · 发现 * 24 个模型」,经由代理时带上代理;检测时读到了余额的,右边写余额(和上游表那一格同一套写法) diff --git a/src/upstreams/ManualModelInput.tsx b/src/upstreams/ManualModelInput.tsx index 3d3621e9..2ee79fd1 100644 --- a/src/upstreams/ManualModelInput.tsx +++ b/src/upstreams/ManualModelInput.tsx @@ -86,8 +86,8 @@ export function ManualModelList({ inputRowClassName?: string; inputRef?: Ref; /** - * 放在一屏排满的一节里(上游对话框的「模型」):框按剩下的高度收,`children` 里那一层滚动的 - * 跟着收,输入行和提示不收 + * 放在按剩下的高度排的一节里(上游对话框的「模型」):框按剩下的高度收,`children` 里那一层 + * 滚动的跟着收,输入行和提示不收 */ fill?: boolean; }) { diff --git a/src/upstreams/ModelsSection.tsx b/src/upstreams/ModelsSection.tsx index ca6af27d..8e137357 100644 --- a/src/upstreams/ModelsSection.tsx +++ b/src/upstreams/ModelsSection.tsx @@ -81,10 +81,11 @@ export function modelCount(hasList: boolean, listed: number, added: number): str } /** - * 对话框的「模型」一节,**一屏放下**:顶上一行(新建时是检测结果或登录的账号,编辑时是清单从哪儿 - * 来、多少个),一条工具条(启用范围、已启用几个、筛选、刷新),下面是上游列出的和手动添加的 + * 对话框的「模型」一节:顶上一行(新建时是检测结果或登录的账号,编辑时是清单从哪儿来、 + * 多少个),一条工具条(启用范围、已启用几个、筛选、刷新),下面是上游列出的和手动添加的 * 合成的一张表,最后一行手动添加模型(名单式输入,和模型弹窗的「添加模型…」共用,见 - * `ManualModelInput`)。**只有那张表在自己的框里滚**,框按剩下的高度收放。 + * `ManualModelInput`)。**那张表在自己的框里滚**,框按剩下的高度收放;剩下的太矮时框留着 + * 几行高,由对话框中间那一块去滚。 * * 手动添加的那几行标「手动」、行尾「移除」。加和移除都只改表单,和别的设置一起保存。 * 指定了启用范围时,新加的一并勾上(`withManualAdded`)。 diff --git a/src/upstreams/UpstreamDialog.tsx b/src/upstreams/UpstreamDialog.tsx index 96c8c72f..37b4bd62 100644 --- a/src/upstreams/UpstreamDialog.tsx +++ b/src/upstreams/UpstreamDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { LinkIcon } from "lucide-react"; import { Input } from "@/ui/input"; import { Banner } from "@/ui/banner"; @@ -34,7 +34,6 @@ import { BillingSection } from "./BillingSection"; import { ChatgptAccountSection } from "./ChatgptAccountSection"; import { AuthField, - CheckButton, CheckSummary, ConnectionSection, ProxyField, @@ -127,12 +126,15 @@ interface SignedIn { * 随意切换**,「保存」一次提交所有分节的改动 —— core 那边是一次写入、一个 * 配置版本。取消不写入任何东西。 * - * **一屏放下**:默认窗口(1100×720)里每一步都不出整页的滚动条,只有长列表(模型表、价格表) - * 在自己的框里滚。窗口再小时中间那一块可以滚。 + * **高度固定,内容高了中间那一块滚**。选项少、空白多的步(选服务类型、只要一把密钥的连接、 + * 登录)收拾紧凑,在默认窗口(1100×720)里一屏放下;设置多的步(展开的高级设置、OAuth、账号 + * 的额度与重置卡)可以滚,滚动条不算毛病。**不为了省掉滚动条**把选项挤成几栏、删掉有用的 + * 说明、或者把结果藏进悬停里。长列表(模型表、价格表)在自己的框里滚,框按剩下的高度收放。 * * **新建时点「下一步」就检测连接**:按钮转圈,通过了进「模型」,结果写在那一步顶上;没通过就 * 留在这一步,底部一条红色横幅说原因,给「重试」和「仍然继续」—— 有的上游不接受检测却能用, - * 有的没有模型列表。编辑时没有「下一步」,「检测连接」照旧是一个按钮。 + * 有的没有模型列表。编辑时没有「下一步」,「检测连接」是「连接」一节最后的一个按钮,结果 + * (连同读到的余额)整句写在它下面。 * * **登录账号(ChatGPT、Z.ai / BigModel)在第二步里完成**:登录成功时 core 已经把上游 * 写进配置,直接进「模型」,顶上写登的是哪个账号;之后的「模型」「计费」两步改的就是这个 @@ -244,21 +246,6 @@ export function UpstreamDialog({ const [visited, setVisited] = useState>( () => new Set
    (drafted ? ["service", "connection"] : ["service"]), ); - /** - * 导入的对话框顶上那一句占的高度(连同它和步骤条之间的间距)。**加在对话框的高度上,不从 - * 中间那一块里扣**:没有它时放得下的每一步,有它时照样放得下 - */ - // 对话框画在 portal 里,挂上得晚一步:等那一句真的挂上了再量 - const [noteEl, setNoteEl] = useState(null); - const [noteHeight, setNoteHeight] = useState(0); - useLayoutEffect(() => { - if (!noteEl) return; - const measure = () => setNoteHeight(noteEl.offsetHeight + NOTE_GAP); - measure(); - const ro = new ResizeObserver(measure); - ro.observe(noteEl); - return () => ro.disconnect(); - }, [noteEl]); /** 第一步的搜索框。放在这里:它画在步骤条右边,不在那一节里 */ const [query, setQuery] = useState(""); @@ -677,13 +664,12 @@ export function UpstreamDialog({ return ( !open && onClose()}> {/* - 固定高度:切换分节时对话框不跳动;默认窗口里每一步都放得下,只有长列表在自己的框里 - 滚,窗口更小时中间那一块滚。点到对话框外面不关 —— 填了一半的表单不该因为一次误点 - 丢掉;Esc、×、取消照常。登录进行中关掉就是放弃这一次登录(`useAccountLogin` 卸载时取消) + 固定高度:切换分节时对话框不跳动,内容高了中间那一块滚。点到对话框外面不关 —— 填了 + 一半的表单不该因为一次误点丢掉;Esc、×、取消照常。登录进行中关掉就是放弃这一次登录 + (`useAccountLogin` 卸载时取消) */} 0 ? { height: `calc(min(88vh, 760px) + ${noteHeight}px)` } : undefined} onInteractOutside={(e) => e.preventDefault()} // 导入链接打开的不是点出来的:焦点放在对话框上,不落到第一个按钮上画一圈焦点框 onOpenAutoFocus={imported ? focusSelf : undefined} @@ -754,9 +740,9 @@ export function UpstreamDialog({ )}
    + {/* 占的是中间那一块的高度:放不下时那一块滚 */} {imported && ( @@ -781,7 +767,7 @@ export function UpstreamDialog({ /> )} {section === "connection" && ( -
    +
    setNested({ kind: "proxy" })} account={zaiAccount} + // 新建时点「下一步」就检测,没有这个按钮 + check={editing ? { testing, test, onTest: () => void runTest() } : undefined} /> )}
    @@ -899,13 +887,7 @@ export function UpstreamDialog({ {editing ? ( <> - {/* 「检测连接」只在连接这一节,结果接在它右边 */} - {section === "connection" ? ( - void runTest()} /> - ) : ( - - )} - {blocking && {blocking}} + {blocking && {blocking}} @@ -1027,26 +1009,19 @@ export function UpstreamDialog({ ); } -/** 那一句和步骤条之间的间距:对话框各块之间是 16px,它往上提了 8px(`-mt-2`) */ -const NOTE_GAP = 8; - /** * 导入的对话框每一步顶上那一句:来自导入链接,请求和密钥发往哪台主机(OAuth 的 Token 端点在 * 别的主机上时两台都写)。主机名是 ASCII,IDN 已转成 punycode。只陈述,不是警告。 * - * `plain`:地址是内网的明文 `http://`,下面再一行说不加密 + * `plain`:地址是内网的明文 `http://`,下面再一行说不加密。主机名长的折行,不截断 */ -function ImportNote({ hosts, plain, ref }: { hosts: string[]; plain: boolean; ref?: Ref }) { +function ImportNote({ hosts, plain }: { hosts: string[]; plain: boolean }) { const t = useText(upstreamDialogText); return ( -
    -

    - - +

    +

    + + {t.importFrom} {hosts.length > 0 && ( <> @@ -1178,7 +1153,7 @@ function AccountStep({ existing.base_url.replace(/\/+$/, "") === ZAI_ENDPOINTS[family].replace(/\/+$/, ""); const taken = existing != null && !replaces; return ( -

    +