2 && 'lg:grid-cols-3',
+ 'flex gap-4 overflow-x-auto p-1 [&>*]:w-72 [&>*]:shrink-0 [&>*]:grow',
)}
>
- {plans.map((plan) => {
- const current = workspace.plan_name === plan.name;
- const downgradeBlocked =
- Boolean(
- workspace.plan_name && workspace.plan_name !== 'free',
- ) && plan.name === 'free';
- const price = cloudPlanPrice(plan, cycle, currency);
- const name =
- localizedPlanText(plan.display_name, i18n.language) ||
- plan.name.toUpperCase();
- return (
-
-
-
-
- {name}
-
- {current ? (
-
- {t('cloudPlans.current')}
-
- ) : plan.highlight ? (
-
{t('cloudPlans.recommended')}
- ) : null}
-
-
- {localizedPlanText(plan.description, i18n.language)}
-
-
- {price === 0
- ? t('cloudPlans.free')
- : formatPrice(price)}
- {price > 0 && (
-
- / {t(`cloudPlans.${cycle}`)}
-
+ {entries.map((plan) => {
+ const renderCard = (
+ plan: PublicCloudPlan,
+ ownedCard = false,
+ ) => {
+ const samePlan = workspace.plan_name === plan.name;
+ const current =
+ ownedCard || (samePlan && !distinct && !!owned);
+ // Both stacked revisions share one card style; only the
+ // badge text tells them apart.
+ const stacked = distinct && plan.name === owned?.name;
+ const downgradeBlocked =
+ Boolean(
+ workspace.plan_name && workspace.plan_name !== 'free',
+ ) && plan.name === 'free';
+ const price = cloudPlanPrice(plan, cycle, currency);
+ const name =
+ localizedPlanText(plan.display_name, i18n.language) ||
+ plan.name.toUpperCase();
+ return (
+
-
- {cycle === 'annual' && price > 0
- ? t('cloudPlans.annualNote', {
- price: formatPrice(price / 12),
- })
- : ''}
-
-
-
-
- {plan.subscription_disabled && (
-
- {t('cloudPlans.subscriptionUnavailableHint')}
-
- )}
- {plan.subscription_disabled && !current ? (
-
- {t('cloudPlans.subscriptionUnavailable')}
-
- ) : downgradeBlocked ? null : !canManage ? (
-
- {t('cloudPlans.ownerOnly')}
-
- ) : (
-
-
- {t(
- current
- ? 'cloudPlans.manageSubscription'
- : 'cloudPlans.choose',
+ aria-label={name}
+ >
+
+
+
+ {name}
+
+ {current ? (
+
+ {t('cloudPlans.current')}
+
+ ) : plan.highlight ? (
+
{t('cloudPlans.recommended')}
+ ) : null}
+
+
+ {localizedPlanText(plan.description, i18n.language)}
+ {distinct &&
+ !ownedCard &&
+ plan.name === owned?.name && (
+
+ {t('planRevision.latestQuota')}
+
)}
-
-
-
- )}
-
+ {ownedCard && subscribedOn && (
+
+ {subscribedOn}
+
+ )}
+
+ {ownedCard && !ownedCatalog ? (
+
+ ) : (
+ <>
+
+ {price === 0
+ ? t('cloudPlans.free')
+ : formatPrice(price)}
+ {price > 0 && (
+
+ / {t(`cloudPlans.${cycle}`)}
+
+ )}
+
+
+ {cycle === 'annual' && price > 0
+ ? t('cloudPlans.annualNote', {
+ price: formatPrice(price / 12),
+ })
+ : ''}
+
+ >
+ )}
+
+
+
+ {plan.subscription_disabled && (
+
+ {t('cloudPlans.subscriptionUnavailableHint')}
+
+ )}
+ {plan.subscription_disabled && !current ? (
+
+ {t('cloudPlans.subscriptionUnavailable')}
+
+ ) : downgradeBlocked ? null : !canManage ? (
+
+ {t('cloudPlans.ownerOnly')}
+
+ ) : distinct &&
+ !ownedCard &&
+ plan.name === owned?.name ? (
+
+
+
+
+ {t('cloudPlans.manageSubscription')}
+
+
+
+
+
+ {t('planRevision.cancelToSubscribe')}
+
+
+ ) : (
+
+
+ {t(
+ workspace.plan_name &&
+ workspace.plan_name !== 'free'
+ ? 'cloudPlans.manageSubscription'
+ : 'cloudPlans.choose',
+ )}
+
+
+
+ )}
+ {ownedCard && !latest && (
+
+ {t('planRevision.unavailable')}
+
+ )}
+
+ );
+ };
+ if (plan === 'owned')
+ return ownedPlan && !latest
+ ? renderCard(ownedPlan, true)
+ : null;
+ return ownedPlan && latest === plan ? (
+
+ ) : (
+ renderCard(plan)
);
})}
diff --git a/web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx b/web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx
new file mode 100644
index 000000000..22e5130a2
--- /dev/null
+++ b/web/src/app/home/components/workspace-settings/PlanRevisionStack.tsx
@@ -0,0 +1,84 @@
+import { useState, type ReactNode } from 'react';
+import { ChevronLeft } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { cn } from '@/lib/utils';
+
+type Layer = 'current' | 'latest';
+
+/**
+ * Stacks the purchased revision in front of the plan's latest revision. The
+ * toggle only changes which card is shown; it never changes the subscription.
+ * Layers stay opaque; the back card is dimmed with an overlay instead of
+ * opacity so the two cards never show through each other while swapping.
+ */
+export default function PlanRevisionStack({
+ current,
+ latest,
+}: {
+ current: ReactNode;
+ latest: ReactNode;
+}) {
+ const { t } = useTranslation();
+ const [front, setFront] = useState('current');
+ const back: Layer = front === 'current' ? 'latest' : 'current';
+ const label = t(
+ back === 'latest' ? 'planRevision.showLatest' : 'planRevision.showCurrent',
+ );
+ const layer = (key: Layer, node: ReactNode) => {
+ const isFront = front === key;
+ return (
+ article]:flex-1',
+ isFront
+ ? 'z-10 after:opacity-0'
+ : 'pointer-events-none z-0 translate-x-8 scale-[0.95] after:opacity-50 [&>article]:shadow-none',
+ )}
+ >
+ {node}
+
+ );
+ };
+ return (
+
+ {layer('current', current)}
+ {layer('latest', latest)}
+ {
+ setFront(back);
+ // Pointer clicks should not leave the strip highlighted.
+ if (event.detail > 0) event.currentTarget.blur();
+ }}
+ className="group absolute inset-y-2 right-0 z-20 flex w-8 flex-col items-center justify-center gap-2 rounded-r-xl text-muted-foreground outline-none transition-colors hover:bg-primary/5 hover:text-primary focus-visible:bg-primary/10 focus-visible:text-primary"
+ >
+
+
+ {t(
+ back === 'latest'
+ ? 'planRevision.latestTab'
+ : 'planRevision.currentTab',
+ )}
+
+
+
+ );
+}
diff --git a/web/src/app/home/components/workspace-settings/cloud-plans.ts b/web/src/app/home/components/workspace-settings/cloud-plans.ts
index f70d5dd2b..1ad7a392a 100644
--- a/web/src/app/home/components/workspace-settings/cloud-plans.ts
+++ b/web/src/app/home/components/workspace-settings/cloud-plans.ts
@@ -2,6 +2,7 @@ export type CloudPlanBillingCycle = 'monthly' | 'annual';
export type CloudPlanCurrency = 'CNY' | 'USD' | 'JPY';
export type PlanLocalizedText = Partial>;
export interface PublicCloudPlan {
+ entitlement_features?: Record;
uuid: string;
name: string;
revision: number;
@@ -158,3 +159,25 @@ export async function fetchPublicCloudPlans(
}
return plans.sort((a, b) => a.sort_order - b.sort_order);
}
+
+// Catalog metadata (copy, prices, display items) of one historical revision.
+export async function fetchCloudPlanRevision(
+ base: string,
+ name: string,
+ revision: number,
+ signal: AbortSignal,
+): Promise {
+ const response = await fetch(
+ spaceURL(
+ base,
+ `/api/v1/cloud/v2/plans/${encodeURIComponent(name)}/revisions/${revision}`,
+ ),
+ { credentials: 'omit', referrerPolicy: 'no-referrer', signal },
+ );
+ if (!response.ok) throw new Error('Could not load plan revision');
+ const result = await response.json();
+ const plan = result?.data as PublicCloudPlan | undefined;
+ if (result?.code !== 0 || !plan?.name || plan.name !== name)
+ throw new Error('Invalid plan revision');
+ return plan;
+}
diff --git a/web/src/app/home/components/workspace-settings/plan-revisions.ts b/web/src/app/home/components/workspace-settings/plan-revisions.ts
new file mode 100644
index 000000000..0c5cf31ec
--- /dev/null
+++ b/web/src/app/home/components/workspace-settings/plan-revisions.ts
@@ -0,0 +1,48 @@
+/** Only trusted subscription data may populate the current side of a comparison. */
+export interface PlanRevisionBenefits {
+ name: string;
+ revision: number;
+ limits: Record;
+ entitlement_features?: Record;
+ disabled_adapters?: string[];
+ chat_history_retention_days?: number;
+ monthly_bonus_credits?: number;
+}
+
+export function benefitValues(plan: PlanRevisionBenefits) {
+ const values: Record = {};
+ for (const [key, value] of Object.entries(plan.limits))
+ values[`limit:${key}`] = value;
+ values['limit:chat_history.retention_days'] =
+ plan.limits['chat_history.retention_days'] ??
+ plan.chat_history_retention_days ??
+ 30;
+ values.monthly_credits = plan.monthly_bonus_credits ?? 0;
+ for (const [key, value] of Object.entries(plan.entitlement_features ?? {}))
+ values[`feature:${key}`] = value;
+ for (const adapter of plan.disabled_adapters ?? [])
+ values[`feature:platform.${adapter}`] = false;
+ return values;
+}
+
+export function revisionRows(
+ current: PlanRevisionBenefits,
+ latest?: PlanRevisionBenefits,
+) {
+ const left = benefitValues(current);
+ const right = latest ? benefitValues(latest) : {};
+ return [...new Set([...Object.keys(left), ...Object.keys(right)])]
+ .sort()
+ .map((key) => {
+ // Platform adapters are allowed unless explicitly disabled. Other feature
+ // switches must be explicitly granted, matching Core's admission semantics.
+ const fallback = key.startsWith('feature:platform.')
+ ? true
+ : key.startsWith('feature:')
+ ? false
+ : undefined;
+ const before = left[key] ?? fallback;
+ const after = right[key] ?? fallback;
+ return { key, before, after, changed: !!latest && before !== after };
+ });
+}
diff --git a/web/src/app/infra/entities/workspace.ts b/web/src/app/infra/entities/workspace.ts
index f08d7ab75..f5b720558 100644
--- a/web/src/app/infra/entities/workspace.ts
+++ b/web/src/app/infra/entities/workspace.ts
@@ -24,6 +24,14 @@ export interface WorkspaceMembership {
}
export interface CurrentWorkspace {
+ current_plan?: {
+ subscription_started_at?: number | null;
+ name: string;
+ revision: number;
+ limits: Record;
+ entitlement_features: Record;
+ monthly_bonus_credits: number;
+ } | null;
workspace: Workspace;
membership: WorkspaceMembership;
permissions: string[];
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index b5d6733d5..092582e22 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -1,6 +1,51 @@
import slackSetup from './slack-setup/en-US';
import pipelineMigration from './pipeline-migration/en-US';
const enUS = {
+ planRevision: {
+ subscribedOn: 'Subscribed on {{date}}',
+ latestQuota: 'Latest quotas for this plan',
+ showLatest: 'Show latest quotas for this plan',
+ showCurrent: 'Show your current subscription',
+ latestTab: 'Latest',
+ currentTab: 'Current',
+ cancelToSubscribe:
+ 'Cancel your subscription to subscribe to the latest quota next period',
+
+ current: 'Current subscription: {{plan}} · revision {{revision}}',
+ owned: 'Purchased revision {{revision}}',
+ latest: 'Latest revision {{revision}}',
+ changed:
+ 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
+ same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
+ unavailable:
+ 'The latest plan is unavailable. Your purchased benefits remain in effect.',
+ benefit: 'Benefit',
+ change: 'Changed',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ monthlyCredits: 'Monthly bonus credits',
+ retention: 'Chat history retention (days)',
+ unknown:
+ 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
+ handoff:
+ 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
+ cancelFirst:
+ 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
+ canceled:
+ 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
+ prepaid:
+ 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
+ renew: 'Renew current subscription',
+ buying: 'Purchasing revision {{revision}}.',
+ fields: {
+ bots_max: 'Bots',
+ members_max: 'Members',
+ pipelines_max: 'Processors',
+ plugins_max: 'Extensions',
+ knowledge_bases_max: 'Knowledge bases',
+ managed_sandbox_sessions_max: 'Sandbox sessions',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: 'Not available to subscribe',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts
index c7475dada..f791c2ed3 100644
--- a/web/src/i18n/locales/es-ES.ts
+++ b/web/src/i18n/locales/es-ES.ts
@@ -1,5 +1,50 @@
import pipelineMigration from './pipeline-migration/es-ES';
const esES = {
+ planRevision: {
+ subscribedOn: 'Fecha de suscripción: {{date}}',
+ latestQuota: 'Cuotas más recientes de este plan',
+ showLatest: 'Ver las cuotas más recientes de este plan',
+ showCurrent: 'Ver tu suscripción actual',
+ latestTab: 'Más reciente',
+ currentTab: 'Actual',
+ cancelToSubscribe:
+ 'Cancela tu suscripción para acceder a las últimas cuotas el próximo período',
+
+ current: 'Current subscription: {{plan}} · revision {{revision}}',
+ owned: 'Purchased revision {{revision}}',
+ latest: 'Latest revision {{revision}}',
+ changed:
+ 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
+ same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
+ unavailable:
+ 'The latest plan is unavailable. Your purchased benefits remain in effect.',
+ benefit: 'Benefit',
+ change: 'Changed',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ monthlyCredits: 'Monthly bonus credits',
+ retention: 'Chat history retention (days)',
+ unknown:
+ 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
+ handoff:
+ 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
+ cancelFirst:
+ 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
+ canceled:
+ 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
+ prepaid:
+ 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
+ renew: 'Renovar la suscripción actual',
+ buying: 'Purchasing revision {{revision}}.',
+ fields: {
+ bots_max: 'Bots',
+ members_max: 'Members',
+ pipelines_max: 'Processors',
+ plugins_max: 'Extensions',
+ knowledge_bases_max: 'Knowledge bases',
+ managed_sandbox_sessions_max: 'Sandbox sessions',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: 'Suscripción no disponible',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts
index 0099d4c80..6e9b40eed 100644
--- a/web/src/i18n/locales/ja-JP.ts
+++ b/web/src/i18n/locales/ja-JP.ts
@@ -1,6 +1,51 @@
import slackSetup from './slack-setup/ja-JP';
import pipelineMigration from './pipeline-migration/ja-JP';
const jaJP = {
+ planRevision: {
+ subscribedOn: '契約日:{{date}}',
+ latestQuota: 'このプランの最新の利用枠',
+ showLatest: 'このプランの最新クォータを表示',
+ showCurrent: '現在のサブスクリプションを表示',
+ latestTab: '最新',
+ currentTab: '現在',
+ cancelToSubscribe:
+ '現在のサブスクリプションをキャンセルすると、次期から最新のクォータに登録できます',
+
+ current: '現在の契約:{{plan}} · revision {{revision}}',
+ owned: '購入済み revision {{revision}}',
+ latest: '最新 revision {{revision}}',
+ changed:
+ '購入済みの特典と最新バージョンに違いがあります。変更箇所を強調表示しています。',
+ same: '特典は最新 revision {{revision}} と同じです。購入済みバージョンは変更されません。',
+ unavailable:
+ '最新プランは現在利用できません。購入済みの特典は引き続き有効です。',
+ benefit: '特典',
+ change: '変更あり',
+ enabled: '有効',
+ disabled: '無効',
+ monthlyCredits: '毎月のボーナスクレジット',
+ retention: 'チャット履歴の保存日数',
+ unknown:
+ '現在の契約情報を取得できません。再読み込みするか Space で契約を管理してください。最新カタログの特典は購入済み特典ではありません。',
+ handoff:
+ 'Space で契約を管理してください。新バージョンの購入前に自動更新を解除してください。前払い契約は期限前に更新しなければ自動終了します。',
+ cancelFirst:
+ 'まず契約管理で自動更新を解除してください。支払済みの特典は期間終了まで保持され、返金はありません。解除の確認後、最新バージョンを購入でき、決済成功時に新しい特典が有効になります。',
+ canceled:
+ '更新は解除済みです。支払済みの特典は期間終了まで保持され、返金はありません。新しい購入の特典は決済成功時に有効になります。',
+ prepaid:
+ 'この前払い契約は期間終了時に自動終了します。期限前は現在の内容で更新でき、期限後は各プランの最新の利用枠でのみ契約できます。',
+ renew: '現在の契約を更新',
+ buying: 'revision {{revision}} を購入します。',
+ fields: {
+ bots_max: 'ボット',
+ members_max: 'メンバー',
+ pipelines_max: 'プロセッサー',
+ plugins_max: '拡張機能',
+ knowledge_bases_max: 'ナレッジベース',
+ managed_sandbox_sessions_max: 'サンドボックスセッション',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: '現在お申し込みできません',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts
index 9c26c0f4f..132fda288 100644
--- a/web/src/i18n/locales/ru-RU.ts
+++ b/web/src/i18n/locales/ru-RU.ts
@@ -1,5 +1,50 @@
import pipelineMigration from './pipeline-migration/ru-RU';
const ruRU = {
+ planRevision: {
+ subscribedOn: 'Дата подписки: {{date}}',
+ latestQuota: 'Актуальные квоты этого плана',
+ showLatest: 'Показать актуальные квоты этого тарифа',
+ showCurrent: 'Показать текущую подписку',
+ latestTab: 'Новые',
+ currentTab: 'Текущая',
+ cancelToSubscribe:
+ 'Отмените подписку, чтобы получить доступ к новым квотам со следующего периода',
+
+ current: 'Current subscription: {{plan}} · revision {{revision}}',
+ owned: 'Purchased revision {{revision}}',
+ latest: 'Latest revision {{revision}}',
+ changed:
+ 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
+ same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
+ unavailable:
+ 'The latest plan is unavailable. Your purchased benefits remain in effect.',
+ benefit: 'Benefit',
+ change: 'Changed',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ monthlyCredits: 'Monthly bonus credits',
+ retention: 'Chat history retention (days)',
+ unknown:
+ 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
+ handoff:
+ 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
+ cancelFirst:
+ 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
+ canceled:
+ 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
+ prepaid:
+ 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
+ renew: 'Продлить текущую подписку',
+ buying: 'Purchasing revision {{revision}}.',
+ fields: {
+ bots_max: 'Bots',
+ members_max: 'Members',
+ pipelines_max: 'Processors',
+ plugins_max: 'Extensions',
+ knowledge_bases_max: 'Knowledge bases',
+ managed_sandbox_sessions_max: 'Sandbox sessions',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: 'Подписка недоступна',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts
index 1658976a3..92e591ebb 100644
--- a/web/src/i18n/locales/th-TH.ts
+++ b/web/src/i18n/locales/th-TH.ts
@@ -1,5 +1,49 @@
import pipelineMigration from './pipeline-migration/th-TH';
const thTH = {
+ planRevision: {
+ subscribedOn: 'วันที่สมัคร: {{date}}',
+ latestQuota: 'โควตาล่าสุดของแผนนี้',
+ showLatest: 'ดูโควตาล่าสุดของแผนนี้',
+ showCurrent: 'ดูการสมัครสมาชิกปัจจุบัน',
+ latestTab: 'ล่าสุด',
+ currentTab: 'ปัจจุบัน',
+ cancelToSubscribe: 'ยกเลิกการสมัครสมาชิกเพื่อรับโควต้าล่าสุดในรอบถัดไป',
+
+ current: 'Current subscription: {{plan}} · revision {{revision}}',
+ owned: 'Purchased revision {{revision}}',
+ latest: 'Latest revision {{revision}}',
+ changed:
+ 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
+ same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
+ unavailable:
+ 'The latest plan is unavailable. Your purchased benefits remain in effect.',
+ benefit: 'Benefit',
+ change: 'Changed',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ monthlyCredits: 'Monthly bonus credits',
+ retention: 'Chat history retention (days)',
+ unknown:
+ 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
+ handoff:
+ 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
+ cancelFirst:
+ 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
+ canceled:
+ 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
+ prepaid:
+ 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
+ renew: 'ต่ออายุการสมัครปัจจุบัน',
+ buying: 'Purchasing revision {{revision}}.',
+ fields: {
+ bots_max: 'Bots',
+ members_max: 'Members',
+ pipelines_max: 'Processors',
+ plugins_max: 'Extensions',
+ knowledge_bases_max: 'Knowledge bases',
+ managed_sandbox_sessions_max: 'Sandbox sessions',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: 'ยังไม่เปิดรับการสมัคร',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts
index 5db80b39e..ebd9faf67 100644
--- a/web/src/i18n/locales/vi-VN.ts
+++ b/web/src/i18n/locales/vi-VN.ts
@@ -1,5 +1,50 @@
import pipelineMigration from './pipeline-migration/vi-VN';
const viVN = {
+ planRevision: {
+ subscribedOn: 'Ngày đăng ký: {{date}}',
+ latestQuota: 'Hạn mức mới nhất của gói này',
+ showLatest: 'Xem hạn mức mới nhất của gói này',
+ showCurrent: 'Xem gói đăng ký hiện tại',
+ latestTab: 'Mới nhất',
+ currentTab: 'Hiện tại',
+ cancelToSubscribe:
+ 'Hủy gói hiện tại để đăng ký hạn mức mới nhất vào kỳ tiếp theo',
+
+ current: 'Current subscription: {{plan}} · revision {{revision}}',
+ owned: 'Purchased revision {{revision}}',
+ latest: 'Latest revision {{revision}}',
+ changed:
+ 'Your purchased benefits and the latest version differ. Highlighted rows show the changes.',
+ same: 'Benefits match latest revision {{revision}}. Your purchased revision remains unchanged.',
+ unavailable:
+ 'The latest plan is unavailable. Your purchased benefits remain in effect.',
+ benefit: 'Benefit',
+ change: 'Changed',
+ enabled: 'Enabled',
+ disabled: 'Disabled',
+ monthlyCredits: 'Monthly bonus credits',
+ retention: 'Chat history retention (days)',
+ unknown:
+ 'Current subscription details are unavailable. Refresh or manage your subscription in Space; latest catalog benefits are not your purchased benefits.',
+ handoff:
+ 'Manage your subscription in Space. Cancel automatic renewal before purchasing a new version. Prepaid subscriptions end automatically unless renewed before expiry.',
+ cancelFirst:
+ 'Cancel automatic renewal in subscription management first. Cancellation preserves paid benefits until the end of the period, without a refund. After confirmation, you can purchase the latest version, effective when payment succeeds.',
+ canceled:
+ 'Renewal is canceled. Paid benefits remain until the end of the period, without a refund. A new purchase applies its benefits when payment succeeds.',
+ prepaid:
+ 'This prepaid subscription ends automatically. You can renew your current plan before expiry; after expiry, subscriptions use the latest quotas for each plan.',
+ renew: 'Gia hạn gói đăng ký hiện tại',
+ buying: 'Purchasing revision {{revision}}.',
+ fields: {
+ bots_max: 'Bots',
+ members_max: 'Members',
+ pipelines_max: 'Processors',
+ plugins_max: 'Extensions',
+ knowledge_bases_max: 'Knowledge bases',
+ managed_sandbox_sessions_max: 'Sandbox sessions',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: 'Chưa thể đăng ký',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index fed8f4b7f..4d4c3d0fc 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -1,6 +1,48 @@
import slackSetup from './slack-setup/zh-Hans';
import pipelineMigration from './pipeline-migration/zh-Hans';
const zhHans = {
+ planRevision: {
+ subscribedOn: '订阅日期:{{date}}',
+ latestQuota: '该计划最新配额',
+ showLatest: '查看该计划最新配额',
+ showCurrent: '查看当前订阅',
+ latestTab: '最新配额',
+ currentTab: '当前订阅',
+ cancelToSubscribe: '取消本期订阅,下一期即可订阅最新配额',
+
+ current: '当前订阅:{{plan}} · revision {{revision}}',
+ owned: '已购买 revision {{revision}}',
+ latest: '最新 revision {{revision}}',
+ changed: '已购买权益与最新版本存在差异,高亮行标示变动。',
+ same: '权益与最新 revision {{revision}} 相同,已购买版本保持不变。',
+ unavailable: '最新计划暂不可用,已购买权益继续有效。',
+ benefit: '权益',
+ change: '有变化',
+ enabled: '启用',
+ disabled: '禁用',
+ monthlyCredits: '每月赠送额度',
+ retention: '聊天记录保留天数',
+ unknown:
+ '当前订阅详情不可用。请刷新或前往 Space 管理订阅;最新目录权益不代表已购买权益。',
+ handoff:
+ '请在 Space 管理订阅。自动续费需先取消后才能购买新版本;预付订阅到期自动结束,除非到期前手动续订。',
+ cancelFirst:
+ '请先在订阅管理中取消自动续费。取消后保留本期已付权益,不退款。服务端确认取消后可重新购买最新版本,付款成功后新权益生效。',
+ canceled:
+ '续费已取消。本期已付权益保留至到期,不退款;重新购买的权益在付款成功后生效。',
+ prepaid:
+ '本预付订阅到期自动结束。到期前可续订当前方案;到期后只能按各计划的最新配额订阅。',
+ renew: '续订当前订阅',
+ buying: '正在购买 revision {{revision}}。',
+ fields: {
+ bots_max: '机器人',
+ members_max: '成员',
+ pipelines_max: '处理器',
+ plugins_max: '扩展',
+ knowledge_bases_max: '知识库',
+ managed_sandbox_sessions_max: '沙箱会话',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: '暂不可订阅',
subscriptionUnavailableHint:
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index da7b8f831..49b2ce982 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -1,6 +1,48 @@
import slackSetup from './slack-setup/zh-Hant';
import pipelineMigration from './pipeline-migration/zh-Hant';
const zhHant = {
+ planRevision: {
+ subscribedOn: '訂閱日期:{{date}}',
+ latestQuota: '該方案最新配額',
+ showLatest: '查看該計畫最新配額',
+ showCurrent: '查看目前訂閱',
+ latestTab: '最新配額',
+ currentTab: '目前訂閱',
+ cancelToSubscribe: '取消本期訂閱,下一期即可訂閱最新配額',
+
+ current: '当前订阅:{{plan}} · revision {{revision}}',
+ owned: '已购买 revision {{revision}}',
+ latest: '最新 revision {{revision}}',
+ changed: '已购买权益与最新版本存在差异,高亮行标示变动。',
+ same: '权益与最新 revision {{revision}} 相同,已购买版本保持不变。',
+ unavailable: '最新计划暂不可用,已购买权益继续有效。',
+ benefit: '权益',
+ change: '有变化',
+ enabled: '启用',
+ disabled: '禁用',
+ monthlyCredits: '每月赠送额度',
+ retention: '聊天记录保留天数',
+ unknown:
+ '当前订阅详情不可用。请刷新或前往 Space 管理订阅;最新目录权益不代表已购买权益。',
+ handoff:
+ '请在 Space 管理订阅。自动续费需先取消后才能购买新版本;预付订阅到期自动结束,除非到期前手动续订。',
+ cancelFirst:
+ '请先在订阅管理中取消自动续费。取消后保留本期已付权益,不退款。服务端确认取消后可重新购买最新版本,付款成功后新权益生效。',
+ canceled:
+ '续费已取消。本期已付权益保留至到期,不退款;重新购买的权益在付款成功后生效。',
+ prepaid:
+ '本預付訂閱到期自動結束。到期前可續訂目前方案;到期後只能按各方案的最新配額訂閱。',
+ renew: '續訂目前訂閱',
+ buying: '正在购买 revision {{revision}}。',
+ fields: {
+ bots_max: '机器人',
+ members_max: '成员',
+ pipelines_max: '处理器',
+ plugins_max: '扩展',
+ knowledge_bases_max: '知识库',
+ managed_sandbox_sessions_max: '沙箱会话',
+ },
+ },
cloudPlans: {
subscriptionUnavailable: '暫不可訂閱',
subscriptionUnavailableHint:
diff --git a/web/tests/revisions/index.html b/web/tests/revisions/index.html
new file mode 100644
index 000000000..ea3013314
--- /dev/null
+++ b/web/tests/revisions/index.html
@@ -0,0 +1 @@
+
diff --git a/web/tests/revisions/preview.tsx b/web/tests/revisions/preview.tsx
new file mode 100644
index 000000000..c40db14b6
--- /dev/null
+++ b/web/tests/revisions/preview.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import { createInstance } from 'i18next';
+import { I18nextProvider } from 'react-i18next';
+import enUS from '../../src/i18n/locales/en-US';
+import CloudPlansDialog from '../../src/app/home/components/workspace-settings/CloudPlansDialog';
+import '../../src/app/global.css';
+const mode = new URLSearchParams(location.search).get('mode') || 'changed';
+const current = {
+ name: 'pro',
+ subscription_started_at: 1790812800,
+ revision: 1,
+ limits: {
+ 'bots.max': 2,
+ 'members.max': 3,
+ 'chat_history.retention_days': 30,
+ },
+ entitlement_features: { mcp_stdio: false, 'platform.telegram': false },
+ monthly_bonus_credits: 10,
+};
+const i18n = createInstance();
+await i18n.init({
+ lng: 'en-US',
+ resources: { 'en-US': { translation: enUS } },
+});
+createRoot(document.getElementById('root')!).render(
+
+ {}}
+ workspace={
+ {
+ workspace: { uuid: 'fixture', name: 'Review workspace' },
+ membership: { role: mode === 'member' ? 'viewer' : 'owner' },
+ plan_name: 'pro',
+ current_plan: mode === 'unknown' ? null : current,
+ } as any
+ }
+ spaceURL="https://space.example.test"
+ />
+ ,
+);
diff --git a/web/tests/revisions/verify.mjs b/web/tests/revisions/verify.mjs
new file mode 100644
index 000000000..367ac2963
--- /dev/null
+++ b/web/tests/revisions/verify.mjs
@@ -0,0 +1,72 @@
+import {createServer} from 'vite';
+import {chromium} from '@playwright/test';
+import {fileURLToPath} from 'node:url';
+import path from 'node:path';
+import {mkdir,writeFile} from 'node:fs/promises';
+const workdir=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../../../..');
+const evidence=path.join(workdir,'evidence');
+await mkdir(evidence,{recursive:true});
+const servers=[];let browser;
+const results=[];
+const latest=(mode)=>({uuid:'latest',name:'pro',revision:mode==='same'?1:2,display_name:{en_US:'Pro'},description:{en_US:'Workspace plan'},price_cny:'70',price_usd:'10',price_jpy:'1500',features:[],limits: mode==='equal'||mode==='same'?{'bots.max':2,'members.max':3}:{'bots.max':8,'members.max':5},entitlement_features:{mcp_stdio:!['same','equal'].includes(mode)},disabled_adapters:['same','equal'].includes(mode)?['telegram']:[],chat_history_retention_days:['same','equal'].includes(mode)?30:90,monthly_bonus_credits:['same','equal'].includes(mode)?10:20,is_active:true,subscription_disabled:mode==='paused',sort_order:1,highlight:false});
+try {
+ for(const [repo,port] of [['space',4185],['core',4186]]){
+ const server=await createServer({configFile:path.join(workdir,repo,'web/tests/revisions/vite.config.mjs')});await server.listen();servers.push(server);
+ }
+ browser=await chromium.launch({headless:true});
+ for(const repo of ['space','core']){
+ const modes=repo==='space'?['changed','same','equal','paused','removed','member','stripe','cancel-failure']:['changed','same','equal','paused','removed','member','unknown','error'];
+ for(const mode of modes){
+ const page=await browser.newPage({viewport:{width:1360,height:1100}});
+ page.setDefaultTimeout(120000);
+ const errors=[];page.on('pageerror',error=>{errors.push(error.message);console.error(error.message);});
+ await page.route('**/*',async route=>{
+ const url=new URL(route.request().url());
+ if(url.hostname==='127.0.0.1')return route.continue();
+ if(url.pathname==='/api/v1/cloud/v2/plans')return route.fulfill({status:mode==='error'?500:200,contentType:'application/json',body:JSON.stringify({code:0,data:mode==='removed'?[]:[latest(mode)]})});
+ return route.abort();
+ });
+ const port=repo==='space'?4185:4186;
+ await page.goto(`http://127.0.0.1:${port}/tests/revisions/index.html?mode=${mode}${mode==='stripe'?'&intent=1':''}`,{waitUntil:'domcontentloaded',timeout:120000});
+ if(repo==='core'&&mode==='error')await page.getByRole('alert').waitFor();
+ else if(mode==='unknown')await page.getByText(/Current subscription details are unavailable/).waitFor();
+ else await page.locator(repo==='space'?'[data-plan-comparison]':'[data-cloud-plans] article').first().waitFor();
+ if(errors.length)throw new Error(`${repo}/${mode}: ${errors.join(';')}`);
+ if(await page.locator('[data-revision-comparison]').count())throw new Error('Standalone comparison remains');
+ if(['same','equal'].includes(mode) && await page.locator('[data-owned-plan]').count())throw new Error('Identical benefits duplicated');
+ const area=page.locator(repo==='space'?'[data-plan-comparison]':'[data-cloud-plans]');
+ if(!['unknown','error'].includes(mode)) {
+ const copy=await area.innerText();
+ if(/|revision|mcp stdio|workspace multi user|platform telegram/.test(copy))throw new Error('Internal metadata leaked into plan presentation');
+ if(repo==='core'&&await area.locator('[data-changed], .bg-amber-500\\/10').count())throw new Error('Diff highlighting remains');
+ if(['same','equal'].includes(mode)&&/Subscribed on|Latest quotas for this plan/.test(copy))throw new Error('Unnecessary comparison labels');
+ }
+ if(mode==='changed') {
+ await area.getByText(/Subscribed on/).waitFor();
+ await area.getByText('Latest quotas for this plan',{exact:true}).waitFor();
+ const owned=page.locator('[data-owned-plan]');
+ if(await owned.count()!==1)throw new Error('Missing integrated owned version');
+ const horizontal=await owned.evaluate(el=>{const next=el.nextElementSibling;return !!next && Math.abs(next.getBoundingClientRect().y-el.getBoundingClientRect().y)<5 && next.getBoundingClientRect().x>el.getBoundingClientRect().x;});
+ if(!horizontal)throw new Error('Owned and latest are not adjacent horizontally');
+ }
+ if(repo==='space'&&mode==='stripe'){
+ if(await page.getByRole('dialog').count())throw new Error('Deep link bypassed renewal gate');
+ await page.getByRole('button',{name:'Manage subscription'}).first().click();
+ await page.getByText('Server refresh confirmed cancellation').waitFor();
+ if (!(await page.getByRole('dialog').count())) await page.getByRole('button',{name:/Upgrade to|Subscribe to|Choose/}).last().click();
+ await page.getByRole('dialog').waitFor();
+ }
+ if(repo==='space'&&mode==='cancel-failure'){
+ await page.getByRole('button',{name:'Manage subscription'}).first().click();
+ await page.getByText('Cancellation failed').waitFor();
+ if(await page.getByRole('dialog').count())throw new Error('Failed cancellation opened checkout');
+ }
+ if(mode==='member'&&repo==='core'&&await page.getByRole('link').count())throw new Error('Member purchase link');
+ if(mode==='changed'||mode==='equal'||mode==='member'||mode==='paused')await page.screenshot({path:path.join(evidence,`${repo}-${mode}.png`),fullPage:true});
+ results.push(`${repo}/${mode}: PASS`);console.log(results.at(-1));await page.close();
+ }
+ }
+} finally {
+ await writeFile(path.join(evidence,'browser-results.txt'),results.join('\n')+'\n');
+ if(browser)await browser.close();for(const server of servers)await server.close();
+}
diff --git a/web/tests/revisions/vite.config.mjs b/web/tests/revisions/vite.config.mjs
new file mode 100644
index 000000000..390cf2604
--- /dev/null
+++ b/web/tests/revisions/vite.config.mjs
@@ -0,0 +1,5 @@
+import path from 'node:path';
+import tailwind from '@tailwindcss/postcss';
+import {fileURLToPath} from 'node:url';
+const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
+export default {root,resolve:{alias:{'@':path.join(root,'src')}},esbuild:{jsx:'automatic'},css:{postcss:{plugins:[tailwind({base:root})]}},server:{host:'127.0.0.1',port:4186,strictPort:true}};
diff --git a/web/tests/unit/plan-revisions.test.mjs b/web/tests/unit/plan-revisions.test.mjs
new file mode 100644
index 000000000..ebf985005
--- /dev/null
+++ b/web/tests/unit/plan-revisions.test.mjs
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFileSync } from 'node:fs';
+import ts from 'typescript';
+const m = { exports: {} };
+new Function('module', 'exports', ts.transpileModule(readFileSync(new URL('../../src/app/home/components/workspace-settings/plan-revisions.ts', import.meta.url), 'utf8'), {compilerOptions: {module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022}}).outputText)(m, m.exports);
+const { revisionRows } = m.exports;
+const current = { name: 'pro', revision: 1, limits: {'bots.max': 2, 'chat_history.retention_days': 30}, monthly_bonus_credits: 10, entitlement_features: {'platform.telegram': false, mcp_stdio: false} };
+test('same version and revision-only changes do not duplicate comparisons', () => {
+ assert.ok(revisionRows(current, current).every(row => !row.changed));
+ assert.ok(revisionRows(current, {...current, revision: 2}).every(row => !row.changed));
+});
+test('compares effective quotas, credits, retention, platforms and feature switches', () => {
+ const latest = {name:'pro', revision:2, limits:{'bots.max':5}, chat_history_retention_days:60, monthly_bonus_credits:20, entitlement_features:{mcp_stdio:true}, disabled_adapters:[]};
+ const changed = revisionRows(current, latest).filter(row => row.changed);
+ assert.deepEqual(changed.map(row => row.key).sort(), ['feature:mcp_stdio','feature:platform.telegram','limit:bots.max','limit:chat_history.retention_days','monthly_credits'].sort());
+ assert.equal(changed.find(row => row.key === 'feature:platform.telegram').after, true);
+});
+test('catalog representations normalize to snapshot values', () => {
+ const latest = {name:'pro',revision:2,limits:{'bots.max':2},chat_history_retention_days:30,monthly_bonus_credits:10,disabled_adapters:['telegram']};
+ assert.ok(revisionRows(current, latest).every(row => !row.changed));
+});
+test('missing latest keeps current values without invented changes', () => {
+ const rows=revisionRows(current);
+ assert.equal(rows.find(row=>row.key==='limit:bots.max').before,2);
+ assert.ok(rows.every(row=>!row.changed));
+});