diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index 5bab8f79d7..df4fb3462d 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -2353,6 +2353,9 @@ pages: description: Choose where the workspace opens. single_window: 'Single window: the character grows into the workspace' multi_window: 'Separate window: the character stays on the desktop' + official_responses: + title: Responses API for the official provider + description: Send chat requests to the official provider through the Responses API. If a model does not support it, chat uses Chat Completions for that model. color-scheme: description: Change the color scheme of the stage. title: Color Scheme diff --git a/packages/i18n/src/locales/zh-Hans/settings.yaml b/packages/i18n/src/locales/zh-Hans/settings.yaml index a0e6fae263..077b69e1bc 100644 --- a/packages/i18n/src/locales/zh-Hans/settings.yaml +++ b/packages/i18n/src/locales/zh-Hans/settings.yaml @@ -2153,6 +2153,9 @@ pages: description: 选择工作区开在哪里。 single_window: '单窗口:角色自己展开成工作区' multi_window: '多窗口:角色留在桌面上' + official_responses: + title: 官方提供商使用 Responses API + description: 通过 Responses API 向官方提供商发送聊天请求。如果模型不支持,这个模型改用 Chat Completions。 color-scheme: description: 更改舞台的配色方案 title: 配色方案 diff --git a/packages/stage-ui/README.md b/packages/stage-ui/README.md index 3b4a032f43..f3436582d3 100644 --- a/packages/stage-ui/README.md +++ b/packages/stage-ui/README.md @@ -7,7 +7,9 @@ Shared core for stage Register flags in `libs/feature-flags.ts`. Read their state through `useFeatureFlagsStore().isEnabled(key)`. Set `availability` to `local` or `cloud`. Local flags expose device switches without Cloud access. Cloud decides whether each granted Cloud flag allows account opt-in or uses direct control. Client registrations do not duplicate this mode. -`cloud-opt-in` flags expose switches only after Cloud grants access to a verified account. They start disabled and retain choices per account on this device. +`cloud-opt-in` flags expose switches only after Cloud grants access to a verified account. They retain choices per account on this device. +A `cloud-opt-in` policy can send `defaultEnabled`. It sets the state before the user makes a choice. A missing value means off. +A saved choice always wins over the Cloud default. **Use default** removes the saved choice and restores the Cloud default. `cloud-controlled` flags follow Cloud grants directly and never expose a switch. Missing Cloud grants disable Cloud features. Refresh failures and account changes clear grants, not local choices. `useCloudFetch()` in `composables/cloud.ts` shares the Cloud origin and authenticated fetch boundary. The store reads `GET /v1/feature-flags`. diff --git a/packages/stage-ui/src/libs/feature-flags.test.ts b/packages/stage-ui/src/libs/feature-flags.test.ts index c14069153a..a49e429176 100644 --- a/packages/stage-ui/src/libs/feature-flags.test.ts +++ b/packages/stage-ui/src/libs/feature-flags.test.ts @@ -3,7 +3,7 @@ import type { FeatureFlag } from './feature-flags' import { parse } from 'valibot' import { describe, expect, it } from 'vitest' -import { CHARACTER_CARD_SYNC_FLAG, featureFlagResponseSchema, featureFlags, FULLSCREEN_WORKSPACE_FLAG, offeredFeatureFlags, resolveFeatureFlag, resolveFeatureVariant } from './feature-flags' +import { CHARACTER_CARD_SYNC_FLAG, featureFlagResponseSchema, featureFlags, FULLSCREEN_WORKSPACE_FLAG, offeredFeatureFlags, OFFICIAL_RESPONSES_FLAG, resolveFeatureFlag, resolveFeatureVariant } from './feature-flags' const feature: FeatureFlag = { key: 'test-feature', titleKey: 'test.title', descriptionKey: 'test.description', defaultEnabled: false, availability: 'local' } @@ -31,11 +31,28 @@ describe('feature flag decisions', () => { expect(resolveFeatureFlag(optIn, false, policy, true).enabled).toBe(false) }) + // Cloud can turn an opt-in flag on for accounts that made no choice. The user can still turn it off or reset it. + it('starts opt-in grants in the Cloud default state until the user chooses', () => { + const optIn: FeatureFlag = { ...feature, availability: 'cloud' } + const policy = { key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: true } as const + expect(resolveFeatureFlag(optIn, undefined, policy, true)).toEqual({ enabled: true, source: 'global', selectable: true }) + expect(resolveFeatureFlag(optIn, false, policy, true)).toEqual({ enabled: false, source: 'local', selectable: true }) + expect(resolveFeatureFlag(optIn, true, { ...policy, defaultEnabled: false }, true)).toEqual({ enabled: true, source: 'local', selectable: true }) + expect(resolveFeatureFlag(optIn, undefined, { ...policy, defaultEnabled: false }, true)).toEqual({ enabled: false, source: 'global', selectable: true }) + }) + + it('keeps the Cloud default off for signed-out users', () => { + const optIn: FeatureFlag = { ...feature, availability: 'cloud' } + const policy = { key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: true } as const + expect(resolveFeatureFlag(optIn, undefined, policy, false)).toEqual({ enabled: false, source: 'default', selectable: false }) + }) + it('applies cloud control without exposing a toggle or accepting device preferences', () => { const controlled: FeatureFlag = { ...feature, availability: 'cloud' } const policy = { key: feature.key, mode: 'cloud-controlled', source: 'global' } as const expect(resolveFeatureFlag(controlled, false, policy, false)).toEqual({ enabled: true, source: 'global', selectable: false }) expect(resolveFeatureFlag(controlled, true, undefined, true).enabled).toBe(false) + expect(resolveFeatureFlag(controlled, undefined, { ...policy, defaultEnabled: false }, true)).toEqual({ enabled: true, source: 'global', selectable: false }) }) it('lets Cloud change modes without changing client registration', () => { @@ -55,6 +72,20 @@ describe('feature flag decisions', () => { expect(() => parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'local', source: 'account' }] })).toThrow() }) + it('accepts policies with and without a Cloud default, and rejects a non-boolean default', () => { + expect(parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: true }] }).flags[0].defaultEnabled).toBe(true) + expect(parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'cloud-opt-in', source: 'global' }] }).flags[0].defaultEnabled).toBeUndefined() + expect(() => parse(featureFlagResponseSchema, { flags: [{ key: feature.key, mode: 'cloud-opt-in', source: 'global', defaultEnabled: 'true' }] })).toThrow() + }) + + // The client registration never turns the Responses preference on. Only a Cloud grant shows the switch. + it('registers the official Responses preference as a Cloud flag that stays off without a grant', () => { + expect(featureFlags).toContain(OFFICIAL_RESPONSES_FLAG) + expect(OFFICIAL_RESPONSES_FLAG.availability).toBe('cloud') + expect(OFFICIAL_RESPONSES_FLAG.defaultEnabled).toBe(false) + expect(resolveFeatureFlag(OFFICIAL_RESPONSES_FLAG, undefined, undefined, true)).toEqual({ enabled: false, source: 'default', selectable: false }) + }) + it('registers character card sync as a local, off-by-default choice', () => { expect(featureFlags).toContain(CHARACTER_CARD_SYNC_FLAG) expect(CHARACTER_CARD_SYNC_FLAG.availability).toBe('local') diff --git a/packages/stage-ui/src/libs/feature-flags.ts b/packages/stage-ui/src/libs/feature-flags.ts index a2d6dc696c..2a33eef963 100644 --- a/packages/stage-ui/src/libs/feature-flags.ts +++ b/packages/stage-ui/src/libs/feature-flags.ts @@ -1,11 +1,16 @@ import type { InferOutput } from 'valibot' -import { array, boolean, object, picklist, record, string } from 'valibot' +import { array, boolean, object, optional, picklist, record, string } from 'valibot' export const featureFlagPolicySchema = object({ key: string(), mode: picklist(['cloud-opt-in', 'cloud-controlled']), source: picklist(['global', 'account']), + /** + * The state of a `cloud-opt-in` flag before the user makes a choice. A saved choice always wins. A missing value + * means off. `cloud-controlled` flags ignore it. + */ + defaultEnabled: optional(boolean()), }) export const featureFlagResponseSchema = object({ flags: array(featureFlagPolicySchema) }) @@ -83,8 +88,20 @@ export const FULLSCREEN_WORKSPACE_FLAG = { }, } as const satisfies FeatureFlag +/** + * Gates the Responses API preference of the official chat provider. The Cloud policy decides if an account gets the + * switch and its state before the user makes a choice. The registration stays off, so a missing grant keeps it off. + */ +export const OFFICIAL_RESPONSES_FLAG: FeatureFlag = { + key: 'official-responses', + titleKey: 'settings.pages.system.experimental.features.official_responses.title', + descriptionKey: 'settings.pages.system.experimental.features.official_responses.description', + defaultEnabled: false, + availability: 'cloud', +} + /** Only implemented experiments belong here. An empty catalog produces an empty settings page. */ -export const featureFlags: readonly FeatureFlag[] = [CHARACTER_CARD_SYNC_FLAG, DISPLAY_MODEL_SYNC_FLAG, FULLSCREEN_WORKSPACE_FLAG] +export const featureFlags: readonly FeatureFlag[] = [CHARACTER_CARD_SYNC_FLAG, DISPLAY_MODEL_SYNC_FLAG, FULLSCREEN_WORKSPACE_FLAG, OFFICIAL_RESPONSES_FLAG] /** The variant of a feature: the saved one while the feature still offers it, otherwise the first one. */ export function resolveFeatureVariant(feature: FeatureFlag | undefined, saved: string | undefined): string | undefined { @@ -99,7 +116,10 @@ export function offeredFeatureFlags(desktop: boolean): readonly FeatureFlag[] { return featureFlags.filter(feature => desktop || !feature.desktopOnly) } -/** Missing cloud grants disable features. Only local and account-granted opt-in features expose settings. */ +/** + * Missing cloud grants disable features. Only local and account-granted opt-in features expose settings. + * For a `cloud-opt-in` grant, the saved choice wins over the Cloud default, and the Cloud default wins over off. + */ export function resolveFeatureFlag(feature: FeatureFlag, preference: boolean | undefined, policy: InferOutput | undefined, authenticated: boolean): FeatureFlagDecision { if (feature.availability === 'local') return { enabled: preference ?? feature.defaultEnabled, source: preference === undefined ? 'default' : 'local', selectable: true } @@ -113,5 +133,8 @@ export function resolveFeatureFlag(feature: FeatureFlag, preference: boolean | u if (!authenticated) return { enabled: false, source: 'default', selectable: false } - return { enabled: preference ?? false, source: preference === undefined ? policy.source : 'local', selectable: true } + if (preference === undefined) + return { enabled: policy.defaultEnabled ?? false, source: policy.source, selectable: true } + + return { enabled: preference, source: 'local', selectable: true } }