Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/webui/docs/design/design-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,13 @@ styled with the same names the token file declares.
a copy of it. A settings page brings its own content, never its own frame.
- The 用量与模型 page names the account's plan, it does not name a product: the plan name, the
model-source label and the 限额重置 section all follow the account's own answer
(`features/settings/projection/usage-plan.ts` and `features/settings/ui/UsageResetCards.tsx`).
(`features/settings/projection/usage-plan.public.ts` and `features/settings/ui/UsageResetCards.tsx`).
A hard-coded plan name is a bug, not a default — the legacy "Token Plan" label belongs to the
legacy `token_plan` subscription kind alone.
legacy `token_plan` subscription kind alone. The account's tier is shown as the membership answer
reports it: the M Plan levels (Go / Explore / Build) read as reported, and the pre-upgrade
identifiers (starter / plus / max / ultra / plus极速版) read as their product display names. Any
other reported tier name passes through, and only a numeric combo id falls back to the kind's
generic plan name.
- The settings pages share their controls too, not only their frame: `features/settings/ui/settings-atoms.tsx`
owns the page's button (`SettingsButton`, with a `compact` size for a row action) and its dialog
(`SettingsDialog` — a scrim, a surface, a header and a scrolling body). A control that each page
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@
// The text below is the declaration as it stood in the source module — no
// behaviour change.

import type { WebuiUsageQuotaResult } from "../../../../shared/contracts/usage-quota.js";
import { formatPlanName } from "../../settings/projection/usage-plan.public.js";

/**
* The line under the rail identity: the plan a signed-in account reports, the
* sign-in prompt for a signed-out one, and nothing until the quota answer
* arrives (or when it cannot be read).
*/
export function formatRailPlanLabel(
result: WebuiUsageQuotaResult | undefined,
): string | undefined {
if (!result) return undefined;
if (result.signedIn === false) return "请先登录账号!";
return formatPlanName(result);
}

/**
* Formats a quota reset timestamp:
* `3小时13分后重置` below a day, `5天7小时后重置` once days are involved,
Expand Down
12 changes: 5 additions & 7 deletions packages/webui/src/client/features/account/ui/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ import {
type SigninState,
} from "./UserMenuSignin.js";
import { UsagePanel, type UsageState } from "./UserMenuUsage.js";
import { formatRailPlanLabel } from "../projection/usage.public.js";

type AccountStatus = Record<string, unknown>;

interface UserMenuProps {
readonly collapsed: boolean;
readonly hostLabel?: string;
readonly dataDir?: string;
readonly version?: WebuiVersionInfo;
readonly sessionId?: string;
Expand Down Expand Up @@ -151,7 +151,6 @@ function Chevron(): ReactElement {

export function UserMenu({
collapsed,
hostLabel,
dataDir,
sessionId,
version,
Expand Down Expand Up @@ -285,10 +284,9 @@ export function UserMenu({
"MiniMax Code";
const avatarUrl =
profile?.avatarUrl?.trim() || profile?.defaultAvatarUrl?.trim();
const plan =
accountString(account, "plan") ??
accountString(account, "planName") ??
(hostLabel ? `本地 · ${hostLabel}` : "本地");
const planLabel = formatRailPlanLabel(
usage.status === "ready" ? usage.result : undefined,
);
const identity = objectValue(account?.identity);
const auth = objectValue(account?.auth);
const uid =
Expand Down Expand Up @@ -374,7 +372,7 @@ export function UserMenu({
</span>
<span className="webui-user-menu-identity">
<span>{nickname}</span>
<small>{plan}</small>
{planLabel ? <small>{planLabel}</small> : null}
</span>
<span className="webui-user-menu-bell" aria-hidden="true">
<WebuiIconBell />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// The 用量与模型 page's plan wording.
//
// The plan facts are the account's, not the settings page's: the membership
// answer carries the subscription kind, the tier and the plan name. What is
// here is only the wording the page derives from them.
//
// The rules:
//
// - a subscribed account shows its tier; the current M Plan levels (Go /
// Explore / Build) pass through as the membership answer reports them, the
// pre-upgrade identifiers (starter / plus / max / ultra / plus极速版) get
// their product display names, and any other reported tier name passes
// through. A numeric combo id falls back to the generic name of its kind;
// - an unsubscribed account shows the plan name the membership answer carries;
// - the generic name and the model-source label both depend on the subscription
// kind: the legacy `token_plan` kind reads "Token Plan", every other kind
// reads "M Plan".

/** The plan name a subscribed account shows when its tier is not a display name. */
export const WEBUI_PLAN_NAME = "M Plan";

/** The plan name an unsubscribed account shows when the account reports none. */
export const WEBUI_FREE_PLAN_NAME = "免费版";

/** The wording for an account that is not subscribed to a plan. */
export const WEBUI_NO_PLAN_SUBSCRIBED = "未订阅 M Plan";

/** The legacy subscription kind, whose wording still reads "Token Plan". */
const LEGACY_SUBSCRIPTION_TYPE = "token_plan";

/** The name the pre-upgrade `token_plan` subscription reads. */
const LEGACY_PLAN_NAME = "Token Plan";

/**
* The pre-upgrade tier identifiers, as the old accounts report them, and the
* display names the product uses for them. Any other value is the account's own
* plan name and passes through unchanged: the M Plan levels (Go / Explore /
* Build) and the probe-seen "Max Plan" all read as reported.
*/
const LEGACY_PLAN_DISPLAY_NAMES: Readonly<Record<string, string>> = {
starter: "Starter",
plus: "Plus",
max: "Max",
ultra: "Ultra",
plus极速版: "Plus 极速版",
};

export interface WebuiPlanFacts {
readonly hasTokenPlan?: boolean;
readonly tokenPlanTier?: string;
readonly planName?: string;
readonly subscriptionType?: string;
}

/**
* The tier as a display name. A bare numeric combo id uses the generic plan name.
*/
export function formatPlanTier(tier: string | undefined): string | undefined {
const trimmed = tier?.trim();
if (!trimmed) return undefined;
if (/^\d+$/u.test(trimmed)) return undefined;
return trimmed;
}

/**
* The plan name the UI shows for a reported identifier. A known pre-upgrade
* identifier (plus / max / ultra / starter / plus极速版, any casing or spacing)
* gets the product's display name; every other value is already a name the
* account chose to report, so it passes through.
*/
function formatPlanDisplayName(name: string | undefined): string | undefined {
const trimmed = name?.trim();
if (!trimmed) return undefined;
const key = trimmed.toLowerCase().replace(/\s+/gu, "");
return LEGACY_PLAN_DISPLAY_NAMES[key] ?? trimmed;
}

/** Whether the account counts as subscribed. */
export function hasWebuiPlan(facts: WebuiPlanFacts): boolean {
return facts.hasTokenPlan === true || Boolean(facts.tokenPlanTier);
}

/**
* The generic plan name for a subscribed account whose tier is a combo id.
* The subscription kind decides it: a pre-upgrade `token_plan` account reads
* "Token Plan", every other kind reads "M Plan".
*/
export function formatGenericPlanName(subscriptionType: string | undefined): string {
return subscriptionType?.trim() === LEGACY_SUBSCRIPTION_TYPE ? LEGACY_PLAN_NAME : WEBUI_PLAN_NAME;
}

/**
* The plan name the 当前套餐 row shows. A subscribed account shows its tier,
* then the plan name it reports; an unsubscribed one shows the plan name.
*/
export function formatPlanName(facts: WebuiPlanFacts): string {
const tier = formatPlanTier(facts.tokenPlanTier);
if (tier) return formatPlanDisplayName(tier)!;
const named = formatPlanDisplayName(facts.planName);
if (hasWebuiPlan(facts)) return named ?? formatGenericPlanName(facts.subscriptionType);
return named ?? WEBUI_FREE_PLAN_NAME;
}

/**
* The label of the model-source pill. The pill names the source in use, and
* the source's label depends on the subscription kind the account reports.
*/
export function formatPlanSourceLabel(subscriptionType: string | undefined): string {
return formatGenericPlanName(subscriptionType);
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { WebuiInline, WebuiStack } from "../../../ui/layout-primitives.js";
import { WebuiSkeleton } from "../../../ui/page.js";
import { SettingsButton, SettingsDialog } from "./settings-atoms.js";
import { useWebuiToastError } from "../../../ui/toast.js";
import { formatPlanName } from "../projection/usage-plan.js";
import { formatPlanName } from "../projection/usage-plan.public.js";
import {
buildUsageHeatmapModel,
formatSpacedToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useWebuiAccountWorkflows, useWebuiAccountWorkflowsState } from "../../.
import { ToggleSwitch } from "../../../ui/ToggleSwitch.js";
import { SettingsPage, SettingsSection } from "../../../ui/settings-layout.js";
import { formatResetLabel, getActiveSourceBadge, type ProviderHeaderDraft } from "../../../features/models/projection/usage-settings.public.js";
import { formatPlanName, formatPlanSourceLabel, hasWebuiPlan } from "../projection/usage-plan.js";
import { formatPlanName, formatPlanSourceLabel, hasWebuiPlan } from "../projection/usage-plan.public.js";
import { UsageResetCards } from "./UsageResetCards.js";
import { IconButton, RefreshIcon } from "../../../ui/form-atoms.js";
import { IconChevronDown, IconExternalLink, IconInfoCircle } from "@tabler/icons-react";
Expand Down
1 change: 0 additions & 1 deletion packages/webui/src/client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,6 @@ function WebuiClientRoot({
<WebuiClientFoundationApp
teamModeLabel={teamModeCopy().label}
label="webui-foundation"
hostLabel={location.host}
dataDir={dataDir}
/>
)}
Expand Down
9 changes: 1 addition & 8 deletions packages/webui/src/client/shell/WebuiClientFoundationApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,6 @@ export interface WebuiClientFoundationAppProps {
readonly initialUsageQuota?: WebuiUsageQuotaResult;
readonly locationHash?: string;
readonly dataDir?: string;
/**
* What the identity row shows under the product name. The signed-in
* account's plan would go there; the WebUI is loopback-only and has no account, so it reports
* the scope it actually runs in. `main.tsx` passes the page's host.
*/
readonly hostLabel?: string;
}

/**
Expand Down Expand Up @@ -214,7 +208,6 @@ export function WebuiClientFoundationApp(
version,
locationHash,
dataDir,
hostLabel,
} = props;
// The assembly's slices: cut once by the application, published on it and
// provided to the screens below. The shell holds no capability to cut its own
Expand Down Expand Up @@ -1135,7 +1128,7 @@ export function WebuiClientFoundationApp(
}}
footer={!railCollapsed ? <div className="relative flex-shrink-0 border-t-[0.5px] border-border_default">
<UserMenu
collapsed={railCollapsed} hostLabel={hostLabel} dataDir={dataDir}
collapsed={railCollapsed} dataDir={dataDir}
version={runtimeVersion} sessionId={selectedSessionId}
workspaceDir={selectedSession?.workspaceDir}
onOpenFileLine={(path, line) => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions packages/webui/test/gates/fixtures/path-metadata-f00.json
Original file line number Diff line number Diff line change
Expand Up @@ -2326,6 +2326,15 @@
"business": "settings",
"home": "target"
},
{
"path": "client/features/settings/projection/usage-plan.public.ts",
"layers": [
"view"
],
"role": "feature-projection",
"business": "settings",
"home": "target"
},
{
"path": "client/features/settings/projection/usage-plan.ts",
"layers": [
Expand Down
53 changes: 52 additions & 1 deletion packages/webui/test/unit/webui-account-billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
hasWebuiPlan,
WEBUI_FREE_PLAN_NAME,
WEBUI_PLAN_NAME,
} from "../../src/client/features/settings/projection/usage-plan.js";
} from "../../src/client/features/settings/projection/usage-plan.public.js";
import {
formatResetCardExpiry,
formatResetCardFlowLabel,
Expand Down Expand Up @@ -65,6 +65,57 @@ describe("the plan name is the account's, not a constant", () => {
expect(formatPlanName({ hasTokenPlan: true, tokenPlanTier: "101004" })).toBe(WEBUI_PLAN_NAME);
});

it("shows the M Plan tier names as the account reports them", () => {
for (const tier of ["Go", "Explore", "Build"])
expect(formatPlanName({ hasTokenPlan: true, tokenPlanTier: tier })).toBe(tier);
});

it("keeps a pre-upgrade tier name instead of forcing the new levels", () => {
// The plan was renamed before the M Plan levels existed; the account's own
// answer is the only name that is true for it.
expect(formatPlanName({ hasTokenPlan: true, tokenPlanTier: "Max Plan" })).toBe("Max Plan");
});

it("shows the pre-upgrade plan identifiers by their display name", () => {
// The old accounts report the tier as an identifier; the product names are
// the display form, in any casing or spacing the API hands over.
const cases: [string, string][] = [
["starter", "Starter"],
["plus", "Plus"],
["max", "Max"],
["ultra", "Ultra"],
["plus极速版", "Plus 极速版"],
["Plus 极速版", "Plus 极速版"],
["MAX", "Max"],
];
for (const [reported, display] of cases)
expect(formatPlanName({ hasTokenPlan: true, tokenPlanTier: reported })).toBe(display);
});

it("uses the account's plan name when the tier is a combo id", () => {
expect(
formatPlanName({ hasTokenPlan: true, tokenPlanTier: "101004", planName: "plus" }),
).toBe("Plus");
expect(formatPlanName({ hasTokenPlan: true, planName: "ultra" })).toBe("Ultra");
});

it("falls back by subscription kind when the tier is a combo id", () => {
expect(
formatPlanName({
hasTokenPlan: true,
tokenPlanTier: "101004",
subscriptionType: "token_plan",
}),
).toBe("Token Plan");
expect(
formatPlanName({
hasTokenPlan: true,
tokenPlanTier: "101004",
subscriptionType: "mplan",
}),
).toBe(WEBUI_PLAN_NAME);
});

it("counts a tier without a plan flag as subscribed", () => {
expect(hasWebuiPlan({ hasTokenPlan: false, tokenPlanTier: "M Plan" })).toBe(true);
expect(hasWebuiPlan({ hasTokenPlan: false })).toBe(false);
Expand Down
Loading
Loading