From 53fd6910624bc84c6ad40d847fef27d5294a3ac9 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:05:57 +0800 Subject: [PATCH 01/11] feat(mobile): add the set_model_defaults wire layer The phone can now read and write the desktop's model role defaults over a typed verb instead of a config passthrough. - device_model_defaults_v1 capability plus the three-slot command fields: the whole object always travels, and null is a value, so a host can never read an absent key as "leave unchanged". - RemoteDefaultModels now projects all six host slots as string|null, and setModelDefaults answers with the reconciled projection. A host that only acknowledges the command is followed by an authoritative device read, mirroring the permission-mode pattern. - The composer's UI projection keeps its "absent means nothing configured" shape, so the wider wire type does not leak into presentation. --- .../entry/src/main/ets/model/RemoteModels.ets | 38 +++++++++++-- .../ets/pages/state/ConversationUiModels.ets | 16 ++++-- .../ets/services/RemoteCommandFactory.ets | 17 ++++++ .../ets/services/RemoteSessionManager.ets | 57 ++++++++++++++++++- 4 files changed, 118 insertions(+), 10 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets index 6ed5dbbe03..afdaf59a47 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/model/RemoteModels.ets @@ -156,6 +156,12 @@ export interface RemoteCommand { tool_id?: string; updated_input?: Object; model_id?: string; + // set_model_defaults carries the whole object in one write, so all three + // managed slots are always present. A missing key means the same thing as an + // explicit null: no model configured (and, for `fast`, follow the primary). + primary?: string | null; + fast?: string | null; + image_understanding?: string | null; reason?: string; mode?: RemotePermissionMode; answers?: Object; @@ -366,6 +372,8 @@ export const REMOTE_CAPABILITY_PLAN_BUILD_V1: string = 'plan_build_v1'; export const REMOTE_CAPABILITY_HARNESS_PROFILES_V1: string = 'harness_profiles_v1'; /** The host resolves `workspace_id` in commands and stamps it on its projections. */ export const REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1: string = 'workspace_id_references_v1'; +/** The host reads and writes the device-level model role defaults. */ +export const REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: string = 'device_model_defaults_v1'; export interface RemoteModelConfig { id: string; @@ -400,11 +408,21 @@ export interface RemoteReasoningCatalogProjection { presets?: RemoteReasoningPresetDescriptor[]; } +/** + * Device-level model role defaults, exactly as the host persists them. + * + * All six slots are always projected; only `primary`, `fast` and + * `image_understanding` are writable from the phone. `null` is a real value and + * means "no model configured" (`fast` then follows `primary`), so every reader + * must treat it as such rather than as an absent field. + */ export interface RemoteDefaultModels { - primary?: string; - fast?: string; - search?: string; - image_understanding?: string; + primary?: string | null; + fast?: string | null; + search?: string | null; + image_understanding?: string | null; + image_generation?: string | null; + speech_recognition?: string | null; } export interface RemoteModelCatalog { @@ -423,6 +441,18 @@ export interface SetSessionModelResponse extends CommandStatusResponse { model_id?: string; } +/** + * `set_model_defaults` acknowledgement. + * + * `default_models` is the reconcile result the host read back, all six slots + * included. A host that only acknowledges the command without the payload + * leaves it undefined, which the client answers with its own authoritative + * re-read instead of guessing. + */ +export interface ModelDefaultsUpdatedResponse extends CommandStatusResponse { + default_models?: RemoteDefaultModels; +} + export interface RemoteToolStatusResponse { permission_request_id?: string; id?: string; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets index 13ec2329d4..773175ea62 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/ConversationUiModels.ets @@ -182,15 +182,23 @@ export function toConversationUiModelCatalog(source: RemoteModelCatalog): Conver version: source.version, models: source.models.map((model: RemoteModelConfig) => toConversationUiModel(model)), default_models: { - primary: source.default_models.primary, - fast: source.default_models.fast, - search: source.default_models.search, - image_understanding: source.default_models.image_understanding + primary: projectedDefaultModelId(source.default_models.primary), + fast: projectedDefaultModelId(source.default_models.fast), + search: projectedDefaultModelId(source.default_models.search), + image_understanding: projectedDefaultModelId(source.default_models.image_understanding) }, session_model_id: source.session_model_id }; } +/** + * The wire says `null` for a slot with no model; this projection says the same + * by leaving the id absent. Both mean "nothing configured" to every reader. + */ +function projectedDefaultModelId(value: string | null | undefined): string | undefined { + return value === undefined || value === null || value.length === 0 ? undefined : value; +} + export function toConversationUiSelectedImage(source: SelectedImageAttachment): ConversationUiSelectedImage { return { id: source.id, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets index bfe4898c3e..fbe9c5e4e8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteCommandFactory.ets @@ -181,6 +181,23 @@ export class RemoteCommandFactory { }; } + /** + * Writes the three phone-managed device model slots as one object. + * + * `null` is a value, not an omission: fast follows the primary model and the + * other two are left unconfigured. Every slot is always sent so a host can + * never read an absent key as "leave unchanged", which would make the write + * ambiguous between the two meanings. + */ + static setModelDefaults(primary: string | null, fast: string | null, imageUnderstanding: string | null): RemoteCommand { + return { + cmd: 'set_model_defaults', + primary, + fast, + image_understanding: imageUnderstanding + }; + } + static pollSession( sessionId: string, sinceVersion: number, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets index 2b11f882e1..ce7ee42fad 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteSessionManager.ets @@ -2,7 +2,7 @@ import { WorkspaceCatalog, projectWorkspaceCatalog } from './WorkspaceCatalog'; import { RuntimeFileLocation } from '../model/RemoteModels'; import { HostSessionCallbacks, HostSessionStream, REMOTE_CAPABILITY_HOST_STREAM_V1 } from './HostSessionStream'; import { SavedRuntimeConnection, SavedRuntimeConnectionsResponse } from '../model/RemoteModels'; -import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; +import { AssistantEntry, AssistantListResponse, ChatMessageItemResponse, ChatMessageResponse, CommandStatusResponse, CreateSessionOptions, CreateSessionResponse, FileInfo, FileInfoResponse, InitialSyncResult, ModelCatalogResponse, ModelDefaultsUpdatedResponse, PermissionModeResponse, PollSessionResponse, PollSessionResult, ReadFileChunkResponse, ReadFileChunkResult, ReadFileResult, RecentWorkspaceEntry, RecentWorkspaceListResponse, RemoteCommand, RemoteDefaultModels, RemoteImageContext, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, RemoteSession, RemoteWorkspaceIdentity, REMOTE_CAPABILITY_WORKSPACE_ID_REFERENCES_V1, SendMessageResponse, SessionListResponse, SessionListResult, SessionMessagesResponse, SessionMessagesResult, SessionSummary, SetAssistantResponse, SetSessionModelResponse, SetWorkspaceResponse, SteerTurnResponse, SteerTurnResult, WorkspaceInfo, WorkspaceInfoResponse } from '../model/RemoteModels'; import { Encoding } from './Encoding'; import { CloudAccountClient, CloudAccountRequestError, CloudAccountSession } from './CloudAccountClient'; import { RemoteI18n } from '../i18n/RemoteI18n'; @@ -16,6 +16,7 @@ import { RemoteChatCommandClient } from './RemoteChatCommandController'; import { RemoteFileDownloadClient } from './RemoteFileDownloadController'; import { RemoteLogger } from './RemoteLogger'; import { RemoteModelClient } from './RemoteModelController'; +import { RemoteModelDefaultsClient } from './RemoteModelDefaultsController'; import { RemoteResponseMapper } from './RemoteResponseMapper'; import { stampRemoteSessionsWorkspace } from './RemoteSessionIdentity'; import { TranscriptIntegrityPolicy } from './TranscriptIntegrityPolicy'; @@ -38,7 +39,7 @@ const ACCOUNT_HANDSHAKE_TIMEOUT_MS: number = 15000; interface PermissionReplyArgs { request: PermissionReplyRequest; } interface PermissionReplyRequest { requestId: string; reply: string; feedback?: string; updatedInput?: Object; } -export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteSessionClient, RemoteToolActionClient { +export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFileDownloadClient, RemoteModelClient, RemoteModelDefaultsClient, RemoteSessionClient, RemoteToolActionClient { private transport?: RemoteCommandTransport; private transportGeneration: number = 0; private workspace?: WorkspaceInfo; @@ -447,6 +448,58 @@ export class RemoteSessionManager implements RemoteChatCommandClient, RemoteFile return response.model_id || modelId; } + /** + * Writes the desktop's model role defaults and answers with what is stored. + * + * All six slots come back because the desktop reconciles on every write: a + * model that was removed can leave a role pointing at another model, and the + * caller must show the result rather than the request. A host that only + * acknowledges the command without echoing the slots leaves the answer to an + * authoritative read, exactly like a legacy permission-mode acknowledgement. + */ + async setModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise { + const response = await this.send( + RemoteCommandFactory.setModelDefaults(primary, fast, imageUnderstanding) + ); + const defaults = response.default_models; + if (!defaults) { + const catalog = await this.getModelCatalog(); + return RemoteSessionManager.modelDefaults(catalog.default_models); + } + return RemoteSessionManager.modelDefaults(defaults); + } + + /** + * Normalizes an echoed defaults projection into all six slots. + * + * A slot that is neither a string nor null cannot be a stored selection, so + * it is refused instead of being shown as a configured model. + */ + private static modelDefaults(defaults: RemoteDefaultModels): RemoteDefaultModels { + return { + primary: RemoteSessionManager.modelDefaultsSlot(defaults.primary), + fast: RemoteSessionManager.modelDefaultsSlot(defaults.fast), + search: RemoteSessionManager.modelDefaultsSlot(defaults.search), + image_understanding: RemoteSessionManager.modelDefaultsSlot(defaults.image_understanding), + image_generation: RemoteSessionManager.modelDefaultsSlot(defaults.image_generation), + speech_recognition: RemoteSessionManager.modelDefaultsSlot(defaults.speech_recognition) + }; + } + + private static modelDefaultsSlot(value: string | null | undefined): string | null { + if (value === undefined || value === null) { + return null; + } + if (typeof value !== 'string') { + throw new Error('Model defaults unavailable'); + } + return value; + } + async pollSession( sessionId: string, sinceVersion: number, From 7862dc54f2d55134143e263433c33d979755a0bf Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:07:13 +0800 Subject: [PATCH 02/11] feat(mobile): add the device model defaults controller The device settings page needs one owner for the gate, the read and the write, so the pages only render what it reports. - RemoteModelDefaultsController reads get_model_catalog without a session id (device scope), gates on device_model_defaults_v1 plus the live link, locks while a write runs, and never updates values optimistically: a write is followed by an explicit re-read and only that read may change what the page shows. A result that lands after the gate changed is dropped. - DeviceModelSettingsEntryPolicy decides the entry, its greyed state, its upgrade badge and its one-line reason together, so the row and the reason can never disagree; the entry belongs to the connected control target only. - Both locales gain the entry, degrade, scope and status keys, and the node suite pins the command shape, the six-slot response, the gate matrix, the busy lock, the write-then-read order and the two distinct failures. --- .../policy/DeviceModelSettingsEntryPolicy.ets | 66 +++ .../RemoteModelDefaultsController.ets | 373 +++++++++++++++ .../main/resources/en_US/element/string.json | 76 +++ .../main/resources/zh_CN/element/string.json | 76 +++ .../tests/device-model-defaults.test.cjs | 444 ++++++++++++++++++ 5 files changed, 1035 insertions(+) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets new file mode 100644 index 0000000000..7a2ec93b0c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/DeviceModelSettingsEntryPolicy.ets @@ -0,0 +1,66 @@ +import { REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 } from '../../model/RemoteModels'; + +/** + * Entry rules for the device model settings page. + * + * The page reads and writes the desktop the phone is driving right now, so the + * entry belongs to that row and to no other: an offline row has no link to + * answer with, and an online desktop this phone is not connected to would + * promise a read the client cannot perform. Capability, link and row identity + * are decided here together so the row, its badge and its reason line can never + * disagree. + */ +export class DeviceModelSettingsEntryPolicy { + /** One-line reason shown when the degraded entry is tapped. */ + static readonly DEGRADE_REASON_KEY: string = 'settings.model.unsupported'; + + /** The host advertises the device model defaults command. */ + static supportsDeviceModelDefaults(capabilities: string[]): boolean { + return (capabilities || []).indexOf(REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1) >= 0; + } + + /** Whether the device row carries the entry at all. */ + static showsEntry( + online: boolean, + deviceId: string, + phoneDeviceId: string, + controlTargetDeviceId: string, + connectionState: string + ): boolean { + return DeviceModelSettingsEntryPolicy.isDrivenTarget( + deviceId, phoneDeviceId, controlTargetDeviceId, connectionState + ) && online; + } + + /** Whether the entry opens the page. */ + static entryEnabled(supportsDeviceModelDefaults: boolean): boolean { + return supportsDeviceModelDefaults; + } + + /** + * The corner badge for an older desktop: the entry stays visible so the user + * learns why it is unavailable, and is greyed rather than silently missing. + */ + static showsUpgradeBadge(showsEntry: boolean, supportsDeviceModelDefaults: boolean): boolean { + return showsEntry && !supportsDeviceModelDefaults; + } + + /** The reason line to show, or an empty string when the entry is usable. */ + static degradeReasonKey(showsEntry: boolean, supportsDeviceModelDefaults: boolean): string { + return DeviceModelSettingsEntryPolicy.showsUpgradeBadge(showsEntry, supportsDeviceModelDefaults) ? + DeviceModelSettingsEntryPolicy.DEGRADE_REASON_KEY : ''; + } + + /** The desktop this phone is connected to and controlling, by row identity. */ + private static isDrivenTarget( + deviceId: string, + phoneDeviceId: string, + controlTargetDeviceId: string, + connectionState: string + ): boolean { + return deviceId.length > 0 && + deviceId !== phoneDeviceId && + deviceId === controlTargetDeviceId && + connectionState === 'connected'; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets new file mode 100644 index 0000000000..e5f6ecddae --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/RemoteModelDefaultsController.ets @@ -0,0 +1,373 @@ +import { RemoteDefaultModels, RemoteModelCatalog } from '../model/RemoteModels'; +import { RemoteI18n } from '../i18n/RemoteI18n'; +import { RemoteUiState } from './RemoteUiState'; + +/** + * The three model slots a phone may write on the controlled desktop. + * + * `null` is a value: the fast model then follows the primary model, and the + * other two are left unconfigured. The slots the phone does not manage + * (`search`, `image_generation`, `speech_recognition`) are never sent. + */ +export interface RemoteModelDefaultsRoles { + primary: string | null; + fast: string | null; + image_understanding: string | null; +} + +/** Roles the sheet can edit, in sheet order. */ +export type RemoteModelDefaultsRole = 'primary' | 'fast' | 'image_understanding'; + +/** + * Device-level model client. + * + * `getModelCatalog()` is called without a session id on purpose: the defaults + * belong to the desktop, not to whichever session happens to be open. + */ +export interface RemoteModelDefaultsClient { + getModelCatalog(sessionId?: string): Promise; + setModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise; +} + +/** Which operation failed, so a retry can offer the matching one. */ +export type RemoteModelDefaultsFailure = '' | 'load' | 'write'; + +/** + * Everything that has to hold before the device page is answerable: the host + * advertises `device_model_defaults_v1`, the link is up, and the desktop it + * points at is the one the values belong to. + */ +export interface RemoteModelDefaultsGate { + supported: boolean; + connected: boolean; + deviceId: string; +} + +/** Everything the device model settings pages render. */ +export interface RemoteModelDefaultsState { + /** The gate allows a read and a write. */ + available: boolean; + /** A device catalog read is in flight. */ + loading: boolean; + /** A write is in flight; the sheet is locked while it is true. */ + busy: boolean; + /** Device catalog: models with their `capabilities`, in host order. */ + catalog: RemoteModelCatalog; + /** Current values. Only ever replaced by a read, never by a write's intent. */ + roles: RemoteModelDefaultsRoles; + /** Raw failure text; empty when the last operation succeeded. */ + error: string; + /** Which operation produced `error`. */ + failure: RemoteModelDefaultsFailure; + /** Transient i18n status line: reading, saving, updated. */ + statusText: string; + /** True once a read has applied values for the current gate. */ + loaded: boolean; +} + +export interface RemoteModelDefaultsCallbacks { + onStateChanged: (state: RemoteModelDefaultsState) => void; +} + +/** + * Device model role defaults for the connected desktop. + * + * Reads and writes go to the desktop's own catalog, so this controller never + * guesses: a write is followed by an explicit re-read and the values on screen + * change only when that read returns. The host repairs an impossible selection + * while persisting it, and showing the intent instead of the result would + * report a configuration that was never stored. + */ +export class RemoteModelDefaultsController { + /** The reason an older desktop cannot serve the device model settings page. */ + private static readonly UNSUPPORTED_REASON_KEY: string = 'settings.model.unsupported'; + + private readonly client: RemoteModelDefaultsClient; + private readonly callbacks: RemoteModelDefaultsCallbacks; + private state: RemoteModelDefaultsState = RemoteModelDefaultsController.unavailableState(); + private gate: RemoteModelDefaultsGate = RemoteModelDefaultsController.closedGate(); + /** + * Bumped when the gate changes or the controller is reset. A result that + * lands after that belongs to a desktop the sheet no longer shows. + */ + private generation: number = 0; + + constructor(client: RemoteModelDefaultsClient, callbacks: RemoteModelDefaultsCallbacks) { + this.client = client; + this.callbacks = callbacks; + } + + getState(): RemoteModelDefaultsState { + return this.state; + } + + /** + * Re-gates the page. Any change of capability, link or desktop drops what is + * on screen (values from one desktop must never render against another) and + * returns true, so the caller knows it has to read again. + */ + setGate(gate: RemoteModelDefaultsGate): boolean { + if (RemoteModelDefaultsController.sameGate(this.gate, gate)) { + return false; + } + this.generation += 1; + this.gate = gate; + const available = gate.supported && gate.connected; + const next: RemoteModelDefaultsState = RemoteModelDefaultsController.unavailableState(); + next.available = available; + if (available) { + next.statusText = RemoteI18n.t('status.loadingModelDefaults'); + } else { + next.error = gate.supported ? + RemoteI18n.t('status.remoteUnavailable') : + RemoteI18n.t(RemoteModelDefaultsController.UNSUPPORTED_REASON_KEY); + } + this.publish(next); + return true; + } + + /** + * Reads the desktop catalog and the defaults that belong to it. + * + * Skips while a read or a write is already running so opening the page during + * a reconnect cannot queue a second read behind the first. + */ + async load(): Promise { + if (!this.state.available || this.state.loading || this.state.busy) { + return; + } + const generation = this.generation; + this.publish(RemoteModelDefaultsController.workingState(this.state, true, false, + RemoteI18n.t('status.loadingModelDefaults'))); + try { + const catalog = await this.readCatalog(generation); + if (!catalog) { + return; + } + this.publish(RemoteModelDefaultsController.readState(this.state, catalog)); + } catch (err) { + if (!this.isCurrent(generation)) { + return; + } + this.publish(RemoteModelDefaultsController.failedState(this.state, err, 'load', + RemoteI18n.t('status.modelDefaultsLoadFailed'))); + } + } + + /** + * Writes one role by sending the whole object, then reads the result back. + * + * Selecting the value a role already has writes nothing: the host reconciles + * on every write, and there is no intent to persist. + */ + async applyRoleSelection(role: RemoteModelDefaultsRole, modelId: string | null): Promise { + if (!this.state.available || this.state.busy || this.state.loading) { + return; + } + const nextRoles = RemoteModelDefaultsController.rolesWithSelection(this.state.roles, role, modelId); + if (RemoteModelDefaultsController.sameRoles(nextRoles, this.state.roles)) { + return; + } + const generation = this.generation; + this.publish(RemoteModelDefaultsController.workingState(this.state, false, true, + RemoteI18n.t('status.updatingModelDefaults'))); + try { + await this.client.setModelDefaults( + nextRoles.primary, + nextRoles.fast, + nextRoles.image_understanding + ); + if (!this.isCurrent(generation)) { + return; + } + // The acknowledgement says what was written, not what is now stored: the + // desktop may have repaired the selection, so the read decides. + try { + const catalog = await this.readCatalog(generation); + if (!catalog) { + return; + } + const readState = RemoteModelDefaultsController.readState(this.state, catalog); + readState.busy = true; + readState.statusText = RemoteI18n.t('status.modelDefaultsUpdated'); + this.publish(readState); + } catch (err) { + if (!this.isCurrent(generation)) { + return; + } + const failed = RemoteModelDefaultsController.failedState(this.state, err, 'load', + RemoteI18n.t('status.modelDefaultsLoadFailed')); + failed.busy = true; + this.publish(failed); + } + } catch (err) { + if (!this.isCurrent(generation)) { + return; + } + this.publish(RemoteModelDefaultsController.failedState(this.state, err, 'write', + RemoteI18n.t('status.modelDefaultsUpdateFailed'))); + } finally { + if (this.isCurrent(generation)) { + const done = RemoteModelDefaultsController.copyState(this.state); + done.busy = false; + this.publish(done); + } + } + } + + /** Clears the page while it is closed. */ + reset(): void { + this.generation += 1; + this.gate = RemoteModelDefaultsController.closedGate(); + this.publish(RemoteModelDefaultsController.unavailableState()); + } + + /** The i18n key of the reason an old desktop cannot serve this page. */ + static unsupportedReasonKey(): string { + return RemoteModelDefaultsController.UNSUPPORTED_REASON_KEY; + } + + /** The values for one role, mapped from the wire shape (`undefined` = null). */ + static rolesFromDefaults(defaults: RemoteDefaultModels): RemoteModelDefaultsRoles { + return { + primary: RemoteModelDefaultsController.slotValue(defaults.primary), + fast: RemoteModelDefaultsController.slotValue(defaults.fast), + image_understanding: RemoteModelDefaultsController.slotValue(defaults.image_understanding) + }; + } + + private async readCatalog(generation: number): Promise { + const catalog = await this.client.getModelCatalog(); + return this.isCurrent(generation) ? catalog : undefined; + } + + private isCurrent(generation: number): boolean { + return generation === this.generation; + } + + private publish(state: RemoteModelDefaultsState): void { + this.state = state; + this.callbacks.onStateChanged(state); + } + + private static slotValue(value: string | null | undefined): string | null { + return value === undefined || value === null || value.length === 0 ? null : value; + } + + private static rolesWithSelection( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + modelId: string | null + ): RemoteModelDefaultsRoles { + if (role === 'primary') { + return { primary: modelId, fast: roles.fast, image_understanding: roles.image_understanding }; + } + if (role === 'fast') { + return { primary: roles.primary, fast: modelId, image_understanding: roles.image_understanding }; + } + if (role === 'image_understanding') { + return { primary: roles.primary, fast: roles.fast, image_understanding: modelId }; + } + return RemoteModelDefaultsController.copyRoles(roles); + } + + private static sameRoles(first: RemoteModelDefaultsRoles, second: RemoteModelDefaultsRoles): boolean { + return first.primary === second.primary && + first.fast === second.fast && + first.image_understanding === second.image_understanding; + } + + private static sameGate(first: RemoteModelDefaultsGate, second: RemoteModelDefaultsGate): boolean { + return first.supported === second.supported && + first.connected === second.connected && + first.deviceId === second.deviceId; + } + + private static copyRoles(roles: RemoteModelDefaultsRoles): RemoteModelDefaultsRoles { + return { + primary: roles.primary, + fast: roles.fast, + image_understanding: roles.image_understanding + }; + } + + private static copyState(state: RemoteModelDefaultsState): RemoteModelDefaultsState { + return { + available: state.available, + loading: state.loading, + busy: state.busy, + catalog: state.catalog, + roles: RemoteModelDefaultsController.copyRoles(state.roles), + error: state.error, + failure: state.failure, + statusText: state.statusText, + loaded: state.loaded + }; + } + + private static workingState( + state: RemoteModelDefaultsState, + loading: boolean, + busy: boolean, + statusText: string + ): RemoteModelDefaultsState { + const next = RemoteModelDefaultsController.copyState(state); + next.loading = loading; + next.busy = busy; + next.error = ''; + next.failure = ''; + next.statusText = statusText; + return next; + } + + /** Applies a read: the only path that may change the values on screen. */ + private static readState(state: RemoteModelDefaultsState, catalog: RemoteModelCatalog): RemoteModelDefaultsState { + const next = RemoteModelDefaultsController.copyState(state); + next.catalog = catalog; + next.roles = RemoteModelDefaultsController.rolesFromDefaults(catalog.default_models || {}); + next.loading = false; + next.error = ''; + next.failure = ''; + next.statusText = ''; + next.loaded = true; + return next; + } + + private static failedState( + state: RemoteModelDefaultsState, + err: Object, + failure: RemoteModelDefaultsFailure, + fallbackText: string + ): RemoteModelDefaultsState { + const next = RemoteModelDefaultsController.copyState(state); + // The host's own rejection (an unknown model id, for example) is kept as it + // arrived: replacing it with a generic line would hide what to fix. + next.error = err instanceof Error && err.message.length > 0 ? err.message : fallbackText; + next.failure = failure; + next.loading = false; + next.statusText = ''; + return next; + } + + private static unavailableState(): RemoteModelDefaultsState { + return { + available: false, + loading: false, + busy: false, + catalog: RemoteUiState.emptyModelCatalog(), + roles: { primary: null, fast: null, image_understanding: null }, + error: '', + failure: '', + statusText: '', + loaded: false + }; + } + + private static closedGate(): RemoteModelDefaultsGate { + return { supported: false, connected: false, deviceId: '' }; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 525fac1830..bc5254e0dc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -568,6 +568,54 @@ "name": "settings_model_default", "value": "Default model" }, + { + "name": "settings_model_settingsTitle", + "value": "Model settings" + }, + { + "name": "settings_model_upgradeBadge", + "value": "Update" + }, + { + "name": "settings_model_unsupported", + "value": "The OpenBitFun app on this device is too old to configure model defaults. Update OpenBitFun on that device to continue." + }, + { + "name": "settings_model_scopeNewSessions", + "value": "These defaults apply to new sessions on this device; sessions already running are unaffected." + }, + { + "name": "settings_model_scopeFastFollowsPrimary", + "value": "When the fast model is not set, it follows the primary model." + }, + { + "name": "settings_model_scopeImageUnderstanding", + "value": "The multimodal model is used for image understanding only and does not replace the primary model." + }, + { + "name": "settings_model_capabilityImageUnderstanding", + "value": "Image understanding" + }, + { + "name": "settings_model_emptyTitle", + "value": "This device has no models configured yet" + }, + { + "name": "settings_model_emptyText", + "value": "Add models in OpenBitFun on that device, then come back to set role defaults." + }, + { + "name": "settings_model_filterTextChat", + "value": "Only enabled models that support text chat (text_chat) are listed · Applies to new sessions on %1$s" + }, + { + "name": "settings_model_filterImageUnderstanding", + "value": "Only models that support image understanding (image_understanding) are listed · Applies to new sessions on %1$s" + }, + { + "name": "settings_model_updatedResult", + "value": "Updated to %1$s · Re-read the current values on this device" + }, { "name": "settings_devices_section", "value": "Devices" @@ -1888,6 +1936,14 @@ "name": "chat_modelRoleFast", "value": "Fast" }, + { + "name": "chat_modelImage", + "value": "Multimodal model" + }, + { + "name": "chat_modelFollowPrimary", + "value": "Follow the primary model" + }, { "name": "chat_loadOlder", "value": "Load older messages" @@ -2408,6 +2464,26 @@ "name": "status_modelSwitched", "value": "Model switched" }, + { + "name": "status_loadingModelDefaults", + "value": "Reading the device's model defaults" + }, + { + "name": "status_updatingModelDefaults", + "value": "Saving the device's model defaults" + }, + { + "name": "status_modelDefaultsUpdated", + "value": "Updated" + }, + { + "name": "status_modelDefaultsLoadFailed", + "value": "Could not read the device's model defaults. Try again." + }, + { + "name": "status_modelDefaultsUpdateFailed", + "value": "Could not save the device's model defaults. Try again." + }, { "name": "status_loadOlderMessages", "value": "Load older messages" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index c24e5f3678..9772833913 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -568,6 +568,54 @@ "name": "settings_model_default", "value": "默认模型" }, + { + "name": "settings_model_settingsTitle", + "value": "模型设置" + }, + { + "name": "settings_model_upgradeBadge", + "value": "升级" + }, + { + "name": "settings_model_unsupported", + "value": "该设备上的 OpenBitFun 版本过旧,不支持配置模型默认值。请升级该设备上的 OpenBitFun 后继续。" + }, + { + "name": "settings_model_scopeNewSessions", + "value": "这些默认值作用于该设备上的新会话,进行中的会话不受影响。" + }, + { + "name": "settings_model_scopeFastFollowsPrimary", + "value": "快速模型未配置时,跟随主力模型。" + }, + { + "name": "settings_model_scopeImageUnderstanding", + "value": "多模态模型只用于图片理解,不替换主力模型。" + }, + { + "name": "settings_model_capabilityImageUnderstanding", + "value": "图片理解" + }, + { + "name": "settings_model_emptyTitle", + "value": "该设备尚未配置任何模型" + }, + { + "name": "settings_model_emptyText", + "value": "请先在该设备的 OpenBitFun 里添加模型,再回到这里设置角色默认值。" + }, + { + "name": "settings_model_filterTextChat", + "value": "仅显示已启用且支持文本对话(text_chat)的模型 · 作用于 %1$s 上的新会话" + }, + { + "name": "settings_model_filterImageUnderstanding", + "value": "仅显示支持图片理解(image_understanding)的模型 · 作用于 %1$s 上的新会话" + }, + { + "name": "settings_model_updatedResult", + "value": "已更新为 %1$s · 已重新读取该设备上的当前值" + }, { "name": "settings_devices_section", "value": "设备" @@ -1888,6 +1936,14 @@ "name": "chat_modelRoleFast", "value": "快速" }, + { + "name": "chat_modelImage", + "value": "多模态模型" + }, + { + "name": "chat_modelFollowPrimary", + "value": "跟随主力" + }, { "name": "chat_loadOlder", "value": "加载更早消息" @@ -2408,6 +2464,26 @@ "name": "status_modelSwitched", "value": "模型已切换" }, + { + "name": "status_loadingModelDefaults", + "value": "正在读取模型默认值" + }, + { + "name": "status_updatingModelDefaults", + "value": "正在保存模型默认值" + }, + { + "name": "status_modelDefaultsUpdated", + "value": "已更新" + }, + { + "name": "status_modelDefaultsLoadFailed", + "value": "读取模型默认值失败,请重试。" + }, + { + "name": "status_modelDefaultsUpdateFailed", + "value": "保存模型默认值失败,请重试。" + }, { "name": "status_loadOlderMessages", "value": "加载更早消息" diff --git a/src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs b/src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs new file mode 100644 index 0000000000..8c07295f52 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/device-model-defaults.test.cjs @@ -0,0 +1,444 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '../..'); +const etsRoot = path.join(root, 'entry/src/main/ets'); + +// Run the production ArkTS classes with only platform/decorator-free mocks. The +// controller, the wire sender and the entry policy are plain classes on purpose: +// the rules they encode are what this suite pins. +function load(relativePath, resolve) { + const source = fs.readFileSync(path.join(etsRoot, relativePath), 'utf8'); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(name => (resolve ? resolve(name) : undefined) || {}, exported); + return exported; +} + +const factory = load('services/RemoteCommandFactory.ets'); +const {RemoteCommandFactory} = factory; +const {RemoteSessionManager} = load('services/RemoteSessionManager.ets', name => + name.endsWith('RemoteCommandFactory') ? factory : {}); + +function controllerModule() { + return load('services/RemoteModelDefaultsController.ets', name => + name.endsWith('RemoteI18n') ? {RemoteI18n: {t: key => key}} : + name.endsWith('RemoteUiState') ? {RemoteUiState: {emptyModelCatalog: () => emptyCatalog()}} : {}); +} + +const emptyCatalog = () => ({version: 0, models: [], default_models: {}}); + +function catalogNames(locale) { + const entries = JSON.parse( + fs.readFileSync(path.join(root, `entry/src/main/resources/${locale}/element/string.json`), 'utf8') + ).string; + return new Set(entries.map(entry => entry.name)); +} + +// Resource names cannot carry dots, so a code key maps to its underscore name. +test('every key the entry and the controller render exists in both catalogs', () => { + const english = catalogNames('en_US'); + const chinese = catalogNames('zh_CN'); + const keys = [ + 'settings.model.settingsTitle', + 'settings.model.upgradeBadge', + 'settings.model.unsupported', + 'settings.model.section', + 'settings.model.default', + 'status.loadingModelDefaults', + 'status.updatingModelDefaults', + 'status.modelDefaultsUpdated', + 'status.modelDefaultsLoadFailed', + 'status.modelDefaultsUpdateFailed', + 'status.remoteUnavailable' + ]; + for (const key of keys) { + const name = key.replace(/\./g, '_'); + assert.ok(english.has(name), `${key} is missing from en_US (${name})`); + assert.ok(chinese.has(name), `${key} is missing from zh_CN (${name})`); + } +}); +const catalog = (defaults = {}, extra = {}) => ({ + version: 3, + models: [ + {id: 'm-primary', name: 'Primary', provider: 'openai', base_url: '', model_name: 'm-primary', enabled: true, capabilities: []}, + { + id: 'm-vision', name: 'Vision', provider: 'openai', base_url: '', model_name: 'm-vision', + enabled: true, capabilities: ['text_chat', 'image_understanding'] + } + ], + default_models: defaults, + ...extra +}); + +const flush = () => new Promise(resolve => setImmediate(resolve)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return {promise, resolve, reject}; +} + +function controllerWith(client) { + const {RemoteModelDefaultsController} = controllerModule(); + const states = []; + const controller = new RemoteModelDefaultsController(client, { + onStateChanged: state => { states.push(state); } + }); + return {controller, states, state: () => controller.getState()}; +} + +const gate = (supported, connected, deviceId) => ({supported, connected, deviceId}); + +test('set_model_defaults sends every slot in one object and null is a value', () => { + assert.deepEqual(RemoteCommandFactory.setModelDefaults('m-primary', 'm-fast', null), { + cmd: 'set_model_defaults', + primary: 'm-primary', + fast: 'm-fast', + image_understanding: null + }); + const cleared = RemoteCommandFactory.setModelDefaults(null, null, null); + assert.deepEqual(cleared, { + cmd: 'set_model_defaults', + primary: null, + fast: null, + image_understanding: null + }); + // An absent key would be ambiguous between "clear" and "leave unchanged", so + // the whole object is always on the wire. + assert.deepEqual(Object.keys(cleared).sort(), ['cmd', 'fast', 'image_understanding', 'primary']); +}); + +test('the acknowledgement is parsed as all six slots, nulls included', async () => { + const manager = Object.create(RemoteSessionManager.prototype); + manager.send = async () => ({ + resp: 'model_defaults_updated', + default_models: { + primary: 'm-primary', + fast: null, + search: 'm-search', + image_understanding: 'm-vision', + image_generation: 'm-image', + speech_recognition: 'm-speech' + } + }); + assert.deepEqual(await manager.setModelDefaults('m-primary', null, 'm-vision'), { + primary: 'm-primary', + fast: null, + search: 'm-search', + image_understanding: 'm-vision', + image_generation: 'm-image', + speech_recognition: 'm-speech' + }); +}); + +test('an acknowledgement without slots is followed by an authoritative device read', async () => { + const manager = Object.create(RemoteSessionManager.prototype); + const commands = []; + manager.send = async command => { + commands.push(command); + if (command.cmd === 'set_model_defaults') { + return {resp: 'model_defaults_updated'}; + } + return {resp: 'ok', catalog: catalog({primary: 'm-primary', fast: null})}; + }; + const defaults = await manager.setModelDefaults('m-primary', null, null); + assert.deepEqual(commands.map(command => command.cmd), ['set_model_defaults', 'get_model_catalog']); + // Device scope: the defaults belong to the desktop, not to an open session. + assert.deepEqual(commands[1], {cmd: 'get_model_catalog'}); + assert.deepEqual(defaults, { + primary: 'm-primary', + fast: null, + search: null, + image_understanding: null, + image_generation: null, + speech_recognition: null + }); +}); + +test('a slot that is not a model id is refused instead of shown as configured', async () => { + const manager = Object.create(RemoteSessionManager.prototype); + manager.send = async () => ({resp: 'model_defaults_updated', default_models: {primary: 42}}); + await assert.rejects(manager.setModelDefaults('m-primary', null, null), /Model defaults unavailable/); +}); + +test('the entry belongs to the connected control target and to no other row', () => { + const {DeviceModelSettingsEntryPolicy: entry} = load( + 'pages/policy/DeviceModelSettingsEntryPolicy.ets', + name => name.endsWith('RemoteModels') ? + {REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: 'device_model_defaults_v1'} : {} + ); + const shows = (online, deviceId, targetId, connectionState) => + entry.showsEntry(online, deviceId, 'phone-1', targetId, connectionState); + + assert.equal(shows(true, 'desk-1', 'desk-1', 'connected'), true); + // Offline rows carry no entry at all. + assert.equal(shows(false, 'desk-1', 'desk-1', 'connected'), false); + // An online desktop this phone is not driving cannot answer a device write. + assert.equal(shows(true, 'desk-2', 'desk-1', 'connected'), false); + assert.equal(shows(true, 'desk-1', 'desk-1', 'reconnecting'), false); + assert.equal(shows(true, 'desk-1', 'desk-1', 'idle'), false); + // Never on the phone's own row, and never without an identity. + assert.equal(shows(true, 'phone-1', 'phone-1', 'connected'), false); + assert.equal(shows(true, '', '', 'connected'), false); + + assert.equal(entry.supportsDeviceModelDefaults(['device_model_defaults_v1', 'host_stream_v1']), true); + assert.equal(entry.supportsDeviceModelDefaults(['workspace_id_references_v1']), false); + assert.equal(entry.supportsDeviceModelDefaults([]), false); + assert.equal(entry.supportsDeviceModelDefaults(undefined), false); +}); + +test('a missing capability greys the entry, badges it and explains the tap', () => { + const {DeviceModelSettingsEntryPolicy: entry} = load( + 'pages/policy/DeviceModelSettingsEntryPolicy.ets', + name => name.endsWith('RemoteModels') ? + {REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: 'device_model_defaults_v1'} : {} + ); + const shown = entry.showsEntry(true, 'desk-1', 'phone-1', 'desk-1', 'connected'); + + assert.equal(entry.entryEnabled(true), true); + assert.equal(entry.entryEnabled(false), false); + assert.equal(entry.showsUpgradeBadge(shown, true), false); + assert.equal(entry.showsUpgradeBadge(shown, false), true); + assert.equal(entry.degradeReasonKey(shown, true), ''); + assert.equal(entry.degradeReasonKey(shown, false), 'settings.model.unsupported'); + // No entry means no reason band: the row cannot be tapped in the first place. + assert.equal(entry.showsUpgradeBadge(false, false), false); + assert.equal(entry.degradeReasonKey(false, false), ''); +}); + +test('the controller reads the device catalog without a session id', async () => { + const calls = []; + const {controller, state} = controllerWith({ + getModelCatalog: async (...args) => { + calls.push(args); + return catalog({primary: 'm-primary', fast: null, image_understanding: 'm-vision'}); + }, + setModelDefaults: async () => { + throw new Error('write is not expected here'); + } + }); + + assert.equal(controller.setGate(gate(true, true, 'desk-1')), true); + await controller.load(); + + assert.deepEqual(calls, [[]]); + assert.equal(state().available, true); + assert.equal(state().loaded, true); + assert.equal(state().loading, false); + assert.equal(state().statusText, ''); + assert.deepEqual(state().roles, {primary: 'm-primary', fast: null, image_understanding: 'm-vision'}); + assert.equal(state().catalog.models.length, 2); +}); + +test('an old desktop is explained, not read', async () => { + let reads = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { reads += 1; return catalog(); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + + assert.equal(controller.setGate(gate(false, true, 'desk-1')), true); + await controller.load(); + + assert.equal(reads, 0); + assert.equal(state().available, false); + assert.equal(state().error, 'settings.model.unsupported'); + assert.equal(state().failure, ''); + assert.deepEqual(state().roles, {primary: null, fast: null, image_understanding: null}); +}); + +test('a dropped link is unavailable and gates both actions', async () => { + let reads = 0; + let writes = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { reads += 1; return catalog({primary: 'm-primary'}); }, + setModelDefaults: async () => { writes += 1; return {}; } + }); + + assert.equal(controller.setGate(gate(true, false, 'desk-1')), true); + await controller.load(); + await controller.applyRoleSelection('fast', 'm-vision'); + + assert.equal(reads, 0); + assert.equal(writes, 0); + assert.equal(state().available, false); + assert.equal(state().error, 'status.remoteUnavailable'); +}); + +test('a write is locked while it runs and sends the whole object', async () => { + const write = deferred(); + const writeCalls = []; + let stored = {primary: 'm-primary', fast: null, image_understanding: null}; + const {controller, state} = controllerWith({ + getModelCatalog: async () => catalog(stored), + setModelDefaults: async (...args) => { + writeCalls.push(args); + stored = {primary: args[0], fast: args[1], image_understanding: args[2]}; + return write.promise; + } + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + + const pending = controller.applyRoleSelection('fast', 'm-vision'); + assert.equal(state().busy, true); + assert.equal(state().statusText, 'status.updatingModelDefaults'); + assert.deepEqual(writeCalls, [['m-primary', 'm-vision', null]]); + // The three slots travel together; only the selected one changed. + await controller.applyRoleSelection('primary', 'm-vision'); + await controller.load(); + assert.deepEqual(writeCalls, [['m-primary', 'm-vision', null]]); + + write.resolve({primary: 'm-primary', fast: 'm-vision', image_understanding: null}); + await pending; + assert.equal(state().busy, false); + assert.equal(state().statusText, 'status.modelDefaultsUpdated'); + assert.equal(state().roles.fast, 'm-vision'); +}); + +test('values change only after the read-back, never optimistically', async () => { + const write = deferred(); + const read = deferred(); + let reads = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { + reads += 1; + // The first read is the page open; the second is the post-write re-read. + return reads === 1 ? + catalog({primary: 'm-primary', fast: null, image_understanding: null}) : read.promise; + }, + setModelDefaults: async () => write.promise + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + assert.equal(state().roles.primary, 'm-primary'); + + const pending = controller.applyRoleSelection('primary', 'm-vision'); + // In flight: the request is out, nothing on screen moved. + assert.equal(state().busy, true); + assert.equal(state().roles.primary, 'm-primary'); + + write.resolve({}); + await flush(); + // Acknowledged: still nothing on screen, because the desktop reconciles and + // the read has not answered yet. + assert.equal(reads, 2); + assert.equal(state().roles.primary, 'm-primary'); + assert.equal(state().busy, true); + + // The desktop repaired the selection while persisting it: the read decides. + read.resolve(catalog({primary: 'm-reconciled', fast: 'm-vision', image_understanding: null})); + await pending; + assert.deepEqual(state().roles, {primary: 'm-reconciled', fast: 'm-vision', image_understanding: null}); + assert.equal(state().busy, false); + assert.equal(state().statusText, 'status.modelDefaultsUpdated'); +}); + +test('selecting the value a role already has writes nothing', async () => { + let writes = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => catalog({primary: 'm-primary', fast: 'm-vision'}), + setModelDefaults: async () => { writes += 1; return {}; } + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + await controller.applyRoleSelection('fast', 'm-vision'); + await controller.applyRoleSelection('fast', 'm-vision'); + + assert.equal(writes, 0); + assert.equal(state().busy, false); +}); + +test('an unknown model id and a transport failure stay distinct', async () => { + const rejected = controllerWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => { throw new Error('Unknown model id: m-missing'); } + }); + rejected.controller.setGate(gate(true, true, 'desk-1')); + await rejected.controller.load(); + await rejected.controller.applyRoleSelection('primary', 'm-missing'); + assert.equal(rejected.state().error, 'Unknown model id: m-missing'); + assert.equal(rejected.state().failure, 'write'); + assert.equal(rejected.state().busy, false); + assert.equal(rejected.state().roles.primary, 'm-primary'); + + const offline = controllerWith({ + getModelCatalog: async () => { throw new Error('Remote transport is not connected.'); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + offline.controller.setGate(gate(true, true, 'desk-1')); + await offline.controller.load(); + assert.equal(offline.state().error, 'Remote transport is not connected.'); + assert.equal(offline.state().failure, 'load'); + assert.equal(offline.state().loading, false); +}); + +test('a failed read-back after an accepted write is a read failure', async () => { + let reads = 0; + const {controller, state} = controllerWith({ + getModelCatalog: async () => { + reads += 1; + if (reads === 1) { + return catalog({primary: 'm-primary'}); + } + throw new Error('Remote transport is not connected.'); + }, + setModelDefaults: async () => ({primary: 'm-vision'}) + }); + + controller.setGate(gate(true, true, 'desk-1')); + await controller.load(); + await controller.applyRoleSelection('primary', 'm-vision'); + + assert.equal(state().failure, 'load'); + assert.equal(state().error, 'Remote transport is not connected.'); + assert.equal(state().busy, false); + // The write was accepted but unconfirmed, so the old value stays on screen. + assert.equal(state().roles.primary, 'm-primary'); +}); + +test('a result that lands after a gate change is dropped', async () => { + const read = deferred(); + const {controller, state} = controllerWith({ + getModelCatalog: async () => read.promise, + setModelDefaults: async () => ({}) + }); + + controller.setGate(gate(true, true, 'desk-1')); + const pending = controller.load(); + controller.setGate(gate(true, true, 'desk-2')); + read.resolve(catalog({primary: 'm-primary'})); + await pending; + + // The values belong to the desktop the sheet has already left. + assert.deepEqual(state().roles, {primary: null, fast: null, image_understanding: null}); + assert.equal(state().loaded, false); + assert.equal(state().loading, false); +}); + +test('reset clears the page and a repeated gate change reports no reset', async () => { + const {controller, state} = controllerWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => ({}) + }); + + assert.equal(controller.setGate(gate(true, true, 'desk-1')), true); + assert.equal(controller.setGate(gate(true, true, 'desk-1')), false); + await controller.load(); + assert.equal(state().loaded, true); + + controller.reset(); + assert.equal(state().available, false); + assert.equal(state().loaded, false); + assert.deepEqual(state().roles, {primary: null, fast: null, image_understanding: null}); +}); From 5d6f61aacf976456eb8e66b3825ec5715d70ae15 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:07:25 +0800 Subject: [PATCH 03/11] feat(mobile): enter the device model settings from the device row The connected desktop's row now offers its own model role settings instead of a re-connect action, and the sheet carries one more page for it. - The row's trailing pill is the same 14-radius SOFT capsule as the connect action, because it is the same slot; an offline row keeps no entry at all, and an older desktop keeps a greyed pill with an upgrade corner badge whose tap explains itself in place. - The sheet page id 'deviceModels' is registered now and reachable from the row. The pages of that page are still a placeholder marked TODO, so the navigation, the sheet state and the degrade path are real before the panels land. - Settings now own typed device-model actions (device-level catalog read and whole-object write) next to the permission-mode pair, so the pages can build the controller client without reaching into the transport. --- .../actions/AppRootPresentationActions.ets | 29 +++- .../pages/components/AccountProfilePanel.ets | 142 +++++++++++++++++- .../components/AppRootOverlaySurfaces.ets | 6 +- .../ets/pages/components/SettingsSheet.ets | 84 ++++++++++- .../pages/preview/ImmersiveBottomPreview.ets | 2 + .../runtime/AppRootRuntimeComposition.ets | 9 ++ .../pages/viewmodel/SettingsController.ets | 31 +++- 7 files changed, 294 insertions(+), 9 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets index 5d42e53bc9..4ce6ec83d3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/AppRootPresentationActions.ets @@ -1,5 +1,5 @@ import { WorkspaceToolsIntent } from '../state/WorkspaceToolsState'; -import { RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; +import { RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode, RemoteSession } from '../../model/RemoteModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ConversationIntent } from './ConversationIntent'; import { AppRoute } from '../navigation/AppRouteContract'; @@ -87,6 +87,21 @@ export interface SettingsPresentationActions { readonly cloudSelectDevice: (device: CloudAccountDevice) => Promise; readonly getPermissionMode: () => Promise; readonly setPermissionMode: (mode: RemotePermissionMode) => Promise; + /** + * Device-level model catalog of the connected desktop: read without a session + * id, so the projection carries the defaults that belong to the desktop. + */ + readonly getDeviceModelCatalog: () => Promise; + /** + * Writes the three phone-managed slots as one object. `null` is a value: + * the fast model then follows the primary one and the others are left + * unconfigured. The answer is the desktop's reconciled projection. + */ + readonly setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise; readonly setLanguage: (language: string) => Promise; } @@ -131,6 +146,18 @@ export function emptyAppRootPresentationActions(): AppRootPresentationActions { cloudListDevices: async () => [], cloudSelectDevice: async () => {}, getPermissionMode: async () => 'ask', setPermissionMode: async (mode: RemotePermissionMode) => mode, + getDeviceModelCatalog: async () => ({ version: 0, models: [], default_models: {} }), + setDeviceModelDefaults: async ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => { + return { + primary, + fast, + image_understanding: imageUnderstanding + }; + }, setLanguage: async () => {} }, onConnect: { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets index 192074fd82..b06870f0de 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets @@ -2,7 +2,19 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/Mo import { RemoteI18n } from '../../i18n/RemoteI18n'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { AccountDeviceSelectionPolicy } from '../policy/AccountDeviceSelectionPolicy'; -import { CARD, STATUS_SUCCESS, INK, LINE, MUTED, PAGE_BG, STATUS_DANGER, SOFT } from './Theme'; +import { DeviceModelSettingsEntryPolicy } from '../policy/DeviceModelSettingsEntryPolicy'; +import { + CARD, + CONTENT_ON_ACTION, + INK, + LINE, + MUTED, + PAGE_BG, + PRIMARY_ACTION, + SOFT, + STATUS_DANGER, + STATUS_SUCCESS +} from './Theme'; import { DefaultAccountAvatar } from './DefaultAccountAvatar'; /** Account identity and device management shared by Settings and account entry. */ @@ -16,12 +28,15 @@ export struct AccountProfilePanel { @Param controlTargetType: string = 'none'; @Param controlTargetDeviceId: string = ''; @Param connectionState: string = 'idle'; + /** The connected desktop advertises `device_model_defaults_v1`. */ + @Param supportsDeviceModelDefaults: boolean = false; @BuilderParam preferences: () => void = this.EmptyBuilder; @BuilderParam connection: () => void = this.EmptyBuilder; @BuilderParam about: () => void = this.EmptyBuilder; @Event onLoggedOut: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; @Event onReconnect: () => void = () => {}; + @Event onOpenDeviceModelSettings: () => void = () => {}; @Event cloudLogout: () => Promise = async (): Promise => {}; @Event cloudListDevices: () => Promise = async (): Promise => []; @@ -32,6 +47,14 @@ export struct AccountProfilePanel { @Local accountDevicesError: string = ''; @Local switchingDeviceId: string = ''; @Local logoutBusy: boolean = false; + /** + * Why the device model settings entry could not open, shown once under the + * row that was tapped. A missing capability is a state, not a failure, so it + * is explained in place instead of by opening a page that cannot answer. + */ + @Local deviceModelSettingsReason: string = ''; + /** The row the reason belongs to; empty when no row was tapped. */ + @Local deviceModelSettingsReasonDeviceId: string = ''; aboutToAppear(): void { if (this.isAccountAuthenticated()) { @@ -157,9 +180,18 @@ export struct AccountProfilePanel { .fontSize(MobileDesignTypography.bodySmall.size).lineHeight(MobileDesignTypography.bodySmall.lineHeight).fontColor(MUTED).width('100%') } else { ForEach(this.desktopDevices(), (device: CloudAccountDevice) => { - this.AccountDeviceRow(device) + Column({ space: 0 }) { + this.AccountDeviceRow(device) + if (this.deviceModelSettingsReasonDeviceId === device.deviceId && + this.deviceModelSettingsReason.length > 0) { + this.DeviceModelSettingsReasonBand() + } + } + .width('100%') }, (device: CloudAccountDevice): string => - `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}`) + `${device.deviceId}:${device.online ? 'online' : 'offline'}:${device.lastSeenAt || 0}:${device.deviceName}` + + `:${this.isCurrentControlDevice(device) ? 'driven' : 'idle'}` + + `:${this.supportsDeviceModelDefaults ? 'model-defaults' : 'no-model-defaults'}`) } if (this.accountDevicesError.length > 0 && this.desktopDevices().length > 0) { Text(this.accountDevicesError) @@ -195,7 +227,9 @@ export struct AccountProfilePanel { } .layoutWeight(1) .alignItems(HorizontalAlign.Start) - if (this.shouldShowDeviceConnectAction(device)) { + if (this.showsDeviceModelSettingsEntry(device)) { + this.DeviceModelSettingsEntry(device) + } else if (this.shouldShowDeviceConnectAction(device)) { Text(this.switchingDeviceId === device.deviceId ? RemoteI18n.t('remote.settings.deviceConnecting') : RemoteI18n.t('connect.connect')) .fontSize(MobileDesignTypography.bodyMedium.size).fontColor(INK) @@ -215,6 +249,66 @@ export struct AccountProfilePanel { }) } + /** + * The model settings entry on the row of the desktop this phone is driving. + * + * Same pill as the row's connect action, because it is the same slot: the + * connected target offers its own settings instead of re-connecting. An older + * desktop keeps the pill, greyed, with an upgrade corner badge, and tapping it + * explains itself instead of opening a page that cannot answer. + */ + @Builder + private DeviceModelSettingsEntry(device: CloudAccountDevice) { + // The Stack wraps the pill, so the badge can be lifted above the pill's top + // corner without taking part in the row's layout: an inline badge would push + // the device name out of the row's width. + Stack({ alignContent: Alignment.TopEnd }) { + Row({ space: 4 }) { + SymbolGlyph($r('sys.symbol.gearshape')) + .fontSize(14) + .fontColor([MUTED]) + Text(RemoteI18n.t('settings.model.settingsTitle')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(14) + .opacity(this.deviceModelSettingsEnabled() ? 1 : 0.38) + .onClick(() => { + this.openDeviceModelSettings(device); + }) + + if (!this.deviceModelSettingsEnabled()) { + Text(RemoteI18n.t('settings.model.upgradeBadge')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontWeight(FontWeight.Bold) + .fontColor(CONTENT_ON_ACTION) + .height(15) + .padding({ left: 4, right: 4 }) + .backgroundColor(PRIMARY_ACTION) + .borderRadius(4) + .offset({ x: 0, y: -14 }) + } + } + } + + @Builder + private DeviceModelSettingsReasonBand() { + Text(this.deviceModelSettingsReason) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .backgroundColor(SOFT) + .borderRadius(MobileDesignGeometry.settingsCompactCardRadius) + .margin({ left: 4, right: 4, bottom: 4 }) + } + @Builder private LogoutAction() { Divider() @@ -286,6 +380,36 @@ export struct AccountProfilePanel { ); } + /** The entry belongs to the desktop this phone is connected to and driving. */ + private showsDeviceModelSettingsEntry(device: CloudAccountDevice): boolean { + return DeviceModelSettingsEntryPolicy.showsEntry( + device.online, + device.deviceId, + this.deviceId, + this.controlTargetDeviceId, + this.connectionState + ); + } + + private deviceModelSettingsEnabled(): boolean { + return DeviceModelSettingsEntryPolicy.entryEnabled(this.supportsDeviceModelDefaults); + } + + private openDeviceModelSettings(device: CloudAccountDevice): void { + const reasonKey = DeviceModelSettingsEntryPolicy.degradeReasonKey( + this.showsDeviceModelSettingsEntry(device), + this.supportsDeviceModelDefaults + ); + if (reasonKey.length > 0) { + this.deviceModelSettingsReasonDeviceId = device.deviceId; + this.deviceModelSettingsReason = RemoteI18n.t(reasonKey); + return; + } + this.deviceModelSettingsReasonDeviceId = ''; + this.deviceModelSettingsReason = ''; + this.onOpenDeviceModelSettings(); + } + private async connectAccountDevice(device: CloudAccountDevice): Promise { if (!this.canActivateAccountDevice(device)) return; this.switchingDeviceId = device.deviceId; @@ -364,4 +488,14 @@ export struct AccountProfilePanel { this.refreshAccountDevices(); } } + + /** + * A link or capability change makes the explanation stale: it described the + * host as it was when the entry was tapped. + */ + @Monitor('connectionState', 'supportsDeviceModelDefaults') + private clearDeviceModelSettingsReason(): void { + this.deviceModelSettingsReasonDeviceId = ''; + this.deviceModelSettingsReason = ''; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets index b571d00dc3..b65596fece 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets @@ -13,7 +13,7 @@ import { SettingsPagePolicy } from '../policy/SettingsPagePolicy'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { DeviceDirectoryState } from '../state/DeviceDirectoryState'; import { RemotePageState } from '../state/RemotePageState'; -import { REMOTE_CAPABILITY_HARNESS_PROFILES_V1 } from '../../model/RemoteModels'; +import { REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, REMOTE_CAPABILITY_HARNESS_PROFILES_V1 } from '../../model/RemoteModels'; import { AppSidebar } from './AppSidebar'; import { ConnectView } from './ConnectView'; import { AccountLoginSheet } from './AccountLoginSheet'; @@ -156,6 +156,8 @@ export struct AppSettingsSurface { controlTargetType: this.remotePageState.controlTargetType, controlTargetDeviceId: this.remotePageState.controlTargetDeviceId, connectionState: this.remotePageState.connectionState, + supportsDeviceModelDefaults: this.remotePageState.supportsHostCapability( + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1), statusText: this.remotePageState.conversation.statusText, onOpenAccount: this.actions.onSettings.openAccount, onDisconnect: this.actions.onSettings.disconnect, @@ -166,6 +168,8 @@ export struct AppSettingsSurface { cloudSelectDevice: this.actions.onSettings.cloudSelectDevice, getPermissionMode: this.actions.onSettings.getPermissionMode, setPermissionMode: this.actions.onSettings.setPermissionMode, + getDeviceModelCatalog: this.actions.onSettings.getDeviceModelCatalog, + setDeviceModelDefaults: this.actions.onSettings.setDeviceModelDefaults, onSetLanguage: this.actions.onSettings.setLanguage, onClose: this.actions.onSettings.close }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index d90d6044b8..15addbcd98 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -1,7 +1,7 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { SettingsSheetState } from '../state/SettingsSheetState'; -import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; +import { CARD, INK, LINE, MUTED, PAGE_BG, STATUS_DANGER } from './Theme'; import { CurrentControlSection } from './CurrentControlSection'; import { AccountProfilePanel } from './AccountProfilePanel'; import { LanguageSettingsPanel } from './LanguageSettingsPanel'; @@ -9,7 +9,7 @@ import { SheetCloseHeader } from './SheetCloseHeader'; import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; -import { RemotePermissionMode } from '../../model/RemoteModels'; +import { RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode } from '../../model/RemoteModels'; import { RemotePermissionSection } from './RemotePermissionSection'; @ComponentV2 @@ -25,6 +25,8 @@ export struct SettingsSheet { @Param controlTargetType: string = 'none'; @Param controlTargetDeviceId: string = ''; @Param connectionState: string = 'idle'; + /** The connected desktop advertises `device_model_defaults_v1`. */ + @Param supportsDeviceModelDefaults: boolean = false; @Param statusText: string = ''; @Event onClose: () => void = () => {}; @Event onOpenAccount: () => void = () => {}; @@ -39,6 +41,25 @@ export struct SettingsSheet { @Event getPermissionMode: () => Promise = async (): Promise => 'ask'; @Event setPermissionMode: (mode: RemotePermissionMode) => Promise = async (mode: RemotePermissionMode): Promise => mode; + /** Device-level model catalog of the connected desktop (no session id). */ + @Event getDeviceModelCatalog: () => Promise = + async (): Promise => ({ version: 0, models: [], default_models: {} }); + /** Whole-object write of the three phone-managed model slots. */ + @Event setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise = async ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + return { + primary, + fast, + image_understanding: imageUnderstanding + }; + }; @Event onSetLanguage: (language: string) => Promise = async (_language: string) => {}; /** The sheet's own bottom edge, so its scrolling rows can run under the bar and still rest above it. */ @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); @@ -58,6 +79,8 @@ export struct SettingsSheet { Stack({ alignContent: Alignment.TopEnd }) { if (this.sheetState.page === 'language') { this.LanguagePanel() + } else if (this.sheetState.page === 'deviceModels') { + this.DeviceModelsPlaceholder() } else { this.SettingsHome() } @@ -96,11 +119,15 @@ export struct SettingsSheet { controlTargetType: this.controlTargetType, controlTargetDeviceId: this.controlTargetDeviceId, connectionState: this.connectionState, + supportsDeviceModelDefaults: this.supportsDeviceModelDefaults, cloudLogout: this.cloudLogout, cloudListDevices: this.cloudListDevices, cloudSelectDevice: this.cloudSelectDevice, onReconnect: this.onReconnect, onOpenAccount: this.onOpenAccount, + onOpenDeviceModelSettings: () => { + this.sheetState.setPage('deviceModels'); + }, onLoggedOut: this.onClose, connection: () => { this.CurrentConnectionSection() @@ -276,4 +303,57 @@ export struct SettingsSheet { } }) } + + /** + * Device model role settings, reached from the connected device row. + * + * TODO(model-roles-pages): the device model defaults panel and the role picker + * page of this work package replace this placeholder. It exists so the entry + * navigates to a real destination, the sheet keeps one page model, and the + * degrade path stays visible, without pretending the pages are finished. + */ + @Builder + DeviceModelsPlaceholder() { + Column() { + SheetCloseHeader({ + title: RemoteI18n.t('settings.model.settingsTitle'), + showDivider: true, + onClose: () => { + this.sheetState.setPage('main'); + } + }) + + Scroll() { + Column({ space: 0 }) { + this.SectionTitle(RemoteI18n.t('settings.model.section')) + Text(this.deviceModelsPlaceholderText()) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(this.supportsDeviceModelDefaults ? MUTED : STATUS_DANGER) + .padding({ left: 8, right: 8 }) + } + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 22, + bottom: this.insets.tailSpacing(34) + }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + } + + private deviceModelsPlaceholderText(): string { + if (!this.supportsDeviceModelDefaults) { + return RemoteI18n.t('settings.model.unsupported'); + } + const device = this.desktopName || this.desktopId; + return `${RemoteI18n.t('settings.model.default')} · ${device}`; + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets index 0066fb2a97..c81a4dc43f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -219,6 +219,8 @@ function previewActionsWithDevices(devices: CloudAccountDevice[]): AppRootPresen cloudSelectDevice: base.onSettings.cloudSelectDevice, getPermissionMode: base.onSettings.getPermissionMode, setPermissionMode: base.onSettings.setPermissionMode, + getDeviceModelCatalog: base.onSettings.getDeviceModelCatalog, + setDeviceModelDefaults: base.onSettings.setDeviceModelDefaults, setLanguage: base.onSettings.setLanguage }, onConnect: base.onConnect, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 6d13f2fd08..8b134232fc 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -8,6 +8,7 @@ import { SidebarConnectionActionPolicy } from '../policy/SidebarConnectionAction import { accountDeviceLink } from '../../services/AccountDeviceLink'; import { ChatMessage, + RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode, RemoteQuestionAnswerPayload, @@ -914,6 +915,14 @@ export abstract class AppRootRuntimeComposition { getPermissionMode: (): Promise => this.settingsController.getRemotePermissionMode(), setPermissionMode: (mode: RemotePermissionMode): Promise => this.settingsController.setRemotePermissionMode(mode), + getDeviceModelCatalog: (): Promise => + this.settingsController.getDeviceModelCatalog(), + setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => + this.settingsController.setDeviceModelDefaults(primary, fast, imageUnderstanding), setLanguage: async (language: string): Promise => { await this.localeController.setLanguage(language); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets index ea1b1e6dae..2f73c252f6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/SettingsController.ets @@ -17,7 +17,7 @@ import { Encoding } from '../../services/Encoding'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteSessionManager } from '../../services/RemoteSessionManager'; import { WatchProvisionOutcome } from '../../services/WatchProvisionController'; -import { RemotePermissionMode } from '../../model/RemoteModels'; +import { RemoteDefaultModels, RemoteModelCatalog, RemotePermissionMode } from '../../model/RemoteModels'; import { RemotePageState } from '../state/RemotePageState'; export interface CloudAccountSettingsHooks { @@ -480,6 +480,35 @@ export class SettingsController { return cloud.sessionManager.setPermissionMode(mode); } + /** + * The connected desktop's own model catalog: no session id, so the read is + * device scope and carries the reconciled default models. + */ + async getDeviceModelCatalog(): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('status.remoteUnavailable')); + } + return cloud.sessionManager.getModelCatalog(); + } + + /** + * Writes the three phone-managed model slots as one object and answers with + * the desktop's reconciled values. `null` is a value: the fast model then + * follows the primary one and the others are left unconfigured. + */ + async setDeviceModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise { + const cloud = this.requireCloud(); + if (!cloud.hooks.remoteAvailable()) { + throw new Error(RemoteI18n.t('status.remoteUnavailable')); + } + return cloud.sessionManager.setModelDefaults(primary, fast, imageUnderstanding); + } + /** * Puts the account back on the desktop it was last driving. * From 91c9c59b279d591d898aa3286ef74a1329e696fe Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:08:47 +0800 Subject: [PATCH 04/11] feat(remote-connect): add the set_model_defaults device role verb A controller could read a device's `ai.default_models` through `get_model_catalog` but never write it. Add the typed write path instead of routing a raw config mutation: `SetModelDefaults` writes the three managed role slots as one object (an absent field and an explicit `null` both clear a slot), `ModelDefaultsUpdated` answers with the host's post-reconcile six-slot mapping, and `device_model_defaults_v1` lets a caller negotiate the verb before offering it. The response is a role mapping only, so no model credential can travel on it. Validation rejects a slot that names a model the device does not configure and rejects the semantic session selectors (`primary` / `fast` / `auto` / `default`). Enablement and capability validity stay with the config reconcile, whose repair the response reports instead of echoing the request. --- .../src/remote_connect.rs | 313 ++++++++++++++++++ .../tests/remote_connect_contracts.rs | 214 +++++++++++- 2 files changed, 520 insertions(+), 7 deletions(-) diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index a270ced364..973d5cc09d 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -559,6 +559,11 @@ pub const REMOTE_CAPABILITY_DIALOG_STEER_V1: &str = "dialog_steer_v1"; pub const REMOTE_CAPABILITY_USER_QUESTION_INTERACTION_V1: &str = "user_question_interaction_v1"; pub const REMOTE_CAPABILITY_SESSION_ROLLBACK_V1: &str = "session_rollback_v1"; pub const REMOTE_CAPABILITY_PLAN_BUILD_V1: &str = "plan_build_v1"; +/// Device-level role defaults (primary / fast / image understanding) are +/// readable and writable over the typed `set_model_defaults` verb. A caller +/// must check this string before offering the write; a host without it can +/// still answer `get_model_catalog` read-only. +pub const REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: &str = "device_model_defaults_v1"; pub use host_stream::REMOTE_CAPABILITY_HOST_STREAM_V1; fn remote_host_capabilities() -> Vec { @@ -571,6 +576,7 @@ fn remote_host_capabilities() -> Vec { REMOTE_CAPABILITY_SESSION_ROLLBACK_V1.to_string(), REMOTE_CAPABILITY_USER_QUESTION_INTERACTION_V1.to_string(), REMOTE_CAPABILITY_HOST_STREAM_V1.to_string(), + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1.to_string(), ] } @@ -1353,6 +1359,16 @@ pub fn remote_session_model_updated_response( } } +/// Report the host's post-reconcile device role defaults. The payload is the +/// host's own mapping, never an echo of the request: a slot the host had to +/// repair (disabled model, missing capability) already shows the repaired +/// value, so a controller can display the truth instead of an optimistic copy. +pub fn remote_model_defaults_updated_response( + default_models: RemoteDefaultModelsConfig, +) -> RemoteResponse { + RemoteResponse::ModelDefaultsUpdated { default_models } +} + pub fn remote_messages_response( session_id: impl Into, messages: Vec, @@ -1434,6 +1450,22 @@ pub trait RemoteSessionRuntimeHost: Send + Sync { model_id: &str, reasoning_preset: Option>, ) -> Result; + /// Replace the three device role defaults a controller configures for this + /// host (`primary`, `fast`, `image_understanding`); host-only slots such as + /// `search` and `image_generation` always keep their device values. `None` + /// clears a slot. The returned mapping is the host's reconciled truth for + /// all six slots, not the requested patch. + /// + /// A host that cannot configure device defaults reports an unsupported + /// error here instead of answering with an unchanged-looking success. + async fn set_model_defaults( + &self, + _primary: Option<&str>, + _fast: Option<&str>, + _image_understanding: Option<&str>, + ) -> Result { + Err("Host does not support device model defaults".to_string()) + } async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String>; async fn update_session_title(&self, session_id: &str, title: &str) -> Result; async fn resolve_session_storage_dir(&self, session_id: &str) -> Option; @@ -1674,6 +1706,26 @@ where Ok(selection) => remote_session_model_updated_response(session_id.clone(), selection), Err(message) => RemoteResponse::Error { message }, }, + RemoteCommand::SetModelDefaults { + primary, + fast, + image_understanding, + } => { + // Device-level: the command names no session or workspace. The + // three fields are a whole-object write, so an absent field is + // already a cleared slot by deserialization. + match host + .set_model_defaults( + primary.as_deref(), + fast.as_deref(), + image_understanding.as_deref(), + ) + .await + { + Ok(default_models) => remote_model_defaults_updated_response(default_models), + Err(message) => RemoteResponse::Error { message }, + } + } RemoteCommand::UpdateSessionTitle { session_id, title } => { if let Err(message) = host.ensure_session_loaded(session_id).await { return RemoteResponse::Error { message }; @@ -2186,6 +2238,32 @@ pub fn normalize_remote_model_selection( .ok_or_else(|| format!("Unknown model selection: {requested_model_id}")) } +/// Validate one managed device role-default slot of `set_model_defaults`. +/// +/// A provided value must name a concrete model the device already configures; +/// the semantic selectors (`primary`, `fast`, `auto`, `default`) choose a +/// *session* model and are never a device default value. `None` clears the +/// slot. Whether the named model is enabled or carries the capability its slot +/// needs is deliberately not decided here: the config reconcile owns that +/// repair and the response reports its result. +pub fn resolve_remote_model_default_slot( + slot: &str, + model_id: Option<&str>, + is_known_model: impl Fn(&str) -> bool, +) -> Result, String> { + let Some(model_id) = model_id else { + return Ok(None); + }; + let model_id = model_id.trim(); + if model_id.is_empty() { + return Err(format!("{slot} model id is required")); + } + if !is_known_model(model_id) { + return Err(format!("Unknown model selection: {model_id}")); + } + Ok(Some(model_id.to_string())) +} + #[derive(Debug, Clone, PartialEq)] pub struct RemoteModelCatalogPollDelta { pub changed: bool, @@ -2710,6 +2788,24 @@ pub enum RemoteCommand { #[serde(default, deserialize_with = "deserialize_present_nullable")] reasoning_preset: Option>, }, + /// Replace the device-level role defaults (`ai.default_models.primary` / + /// `.fast` / `.image_understanding`) of the receiving host. + /// + /// This is a whole-object write of the three managed slots: an absent field + /// and an explicit `null` both clear that slot, so the command has no + /// tri-state ambiguity — a caller that wants to keep a slot must send its + /// current catalog value. Host-only slots (`search`, `image_generation`, + /// `speech_recognition`) are never part of the request and keep their + /// device values. Values must be concrete catalog model ids; a semantic + /// selector is rejected. Requires the `device_model_defaults_v1` capability. + SetModelDefaults { + #[serde(default)] + primary: Option, + #[serde(default)] + fast: Option, + #[serde(default)] + image_understanding: Option, + }, UpdateSessionTitle { session_id: String, title: String, @@ -2985,6 +3081,12 @@ pub enum RemoteResponse { model_id: String, reasoning_preset: Option, }, + /// All six device role-default slots after the host reconciled the write. + /// The body is a role mapping only: it carries model ids, never model + /// credentials. + ModelDefaultsUpdated { + default_models: RemoteDefaultModelsConfig, + }, SessionTitleUpdated { session_id: String, title: String, @@ -3217,6 +3319,7 @@ where | RemoteCommand::CreateSession { .. } | RemoteCommand::GetModelCatalog { .. } | RemoteCommand::SetSessionModel { .. } + | RemoteCommand::SetModelDefaults { .. } | RemoteCommand::UpdateSessionTitle { .. } | RemoteCommand::GetSessionMessages { .. } | RemoteCommand::DeleteSession { .. } @@ -4736,6 +4839,8 @@ mod tests { created_requests: Mutex>, list_identities: Mutex>, model_updates: Mutex>)>>, + model_default_updates: Mutex, Option, Option)>>, + model_defaults_error: Option, removed_trackers: Mutex>, history_error: Option, rollback_requests: Mutex)>>, @@ -4899,6 +5004,34 @@ mod tests { Ok(()) } + async fn set_model_defaults( + &self, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + ) -> Result { + self.model_default_updates.lock().unwrap().push(( + primary.map(str::to_string), + fast.map(str::to_string), + image_understanding.map(str::to_string), + )); + if let Some(error) = self.model_defaults_error.as_ref() { + return Err(error.clone()); + } + // The fake answers with the host's reconciled mapping, not the + // request, mirroring a config service that repaired a slot. + Ok(RemoteDefaultModelsConfig { + primary: primary + .map(str::to_string) + .or(Some("primary-fallback".into())), + fast: fast.map(str::to_string), + search: Some("device-search".to_string()), + image_understanding: image_understanding.map(str::to_string), + image_generation: Some("device-image-generation".to_string()), + speech_recognition: Some("device-speech".to_string()), + }) + } + async fn update_session_title( &self, _session_id: &str, @@ -5241,6 +5374,186 @@ mod tests { ); } + #[tokio::test] + async fn remote_session_handler_forwards_device_model_defaults() { + let host = FakeSessionHost::default(); + + let response = handle_remote_session_command( + &host, + &RemoteCommand::SetModelDefaults { + primary: Some("model-primary".to_string()), + fast: None, + image_understanding: Some("model-vision".to_string()), + }, + ) + .await; + + // The response is the host's reconciled mapping, including the slots + // the request never carries. + assert_eq!( + response, + RemoteResponse::ModelDefaultsUpdated { + default_models: RemoteDefaultModelsConfig { + primary: Some("model-primary".to_string()), + fast: None, + search: Some("device-search".to_string()), + image_understanding: Some("model-vision".to_string()), + image_generation: Some("device-image-generation".to_string()), + speech_recognition: Some("device-speech".to_string()), + }, + } + ); + assert_eq!( + host.model_default_updates.lock().unwrap().as_slice(), + [( + Some("model-primary".to_string()), + None, + Some("model-vision".to_string()), + )] + ); + } + + #[tokio::test] + async fn remote_session_handler_reports_device_model_defaults_error() { + let host = FakeSessionHost { + model_defaults_error: Some("Unknown model selection: model-gone".to_string()), + ..FakeSessionHost::default() + }; + + let response = handle_remote_session_command( + &host, + &RemoteCommand::SetModelDefaults { + primary: Some("model-gone".to_string()), + fast: None, + image_understanding: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::Error { + message: "Unknown model selection: model-gone".to_string(), + } + ); + } + + /// A host that does not implement device role defaults must say so instead + /// of answering with an unchanged-looking success. + #[tokio::test] + async fn remote_session_host_without_device_model_defaults_reports_unsupported() { + struct LegacySessionHost; + + #[async_trait::async_trait] + impl RemoteSessionRuntimeHost for LegacySessionHost { + async fn resolve_legacy_workspace( + &self, + _workspace_path: &str, + _remote_connection_id: Option<&str>, + _remote_ssh_host: Option<&str>, + ) -> Result, String> { + Ok(None) + } + + async fn list_session_metadata( + &self, + _workspace_path: &Path, + _workspace_identity: RemoteSessionWorkspaceIdentity, + ) -> Result, String> { + Ok(Vec::new()) + } + + async fn resolve_default_assistant_workspace( + &self, + ) -> Result { + Err("no assistant workspace".to_string()) + } + + async fn create_session( + &self, + _request: AgentSessionCreateRequest, + ) -> Result { + Err("no session creation".to_string()) + } + + async fn load_model_catalog( + &self, + _session_id: Option<&str>, + ) -> Result { + Err("no catalog".to_string()) + } + + async fn update_session_model_selection( + &self, + _session_id: &str, + _model_id: &str, + _reasoning_preset: Option>, + ) -> Result { + Err("no session model selection".to_string()) + } + + async fn ensure_session_loaded(&self, _session_id: &str) -> Result<(), String> { + Err("no session restore".to_string()) + } + + async fn update_session_title( + &self, + _session_id: &str, + _title: &str, + ) -> Result { + Err("no title update".to_string()) + } + + async fn resolve_session_storage_dir(&self, _session_id: &str) -> Option { + None + } + + async fn load_remote_chat_messages( + &self, + _session_storage_dir: &Path, + _session_id: &str, + ) -> Result<(Vec, bool), String> { + Err("no history".to_string()) + } + + async fn rollback_session_to_turn( + &self, + _session_id: &str, + _target_turn_id: &str, + _expected_storage_turn_index: Option, + ) -> Result { + Err("no rollback".to_string()) + } + + async fn delete_session( + &self, + _session_storage_dir: &Path, + _session_id: &str, + ) -> Result<(), String> { + Err("no delete".to_string()) + } + + fn remove_tracker(&self, _session_id: &str) {} + } + + let response = handle_remote_session_command( + &LegacySessionHost, + &RemoteCommand::SetModelDefaults { + primary: None, + fast: None, + image_understanding: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::Error { + message: "Host does not support device model defaults".to_string(), + } + ); + } + #[tokio::test] async fn remote_session_handler_forwards_rollback_target_and_guard() { let host = FakeSessionHost::default(); diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index ffb4e9ce5c..8d9d439d37 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -23,7 +23,8 @@ use openbitfun_services_integrations::remote_connect::{ remote_dialog_submit_outcome_from_scheduler, remote_dialog_submit_response, remote_file_chunk_response, remote_file_content_response, remote_file_display_name, remote_file_info_response, remote_initial_sync_response, remote_interaction_accepted_response, - remote_messages_response, remote_model_catalog_poll_delta, remote_model_selection_needs_config, + remote_messages_response, remote_model_catalog_poll_delta, + remote_model_defaults_updated_response, remote_model_selection_needs_config, remote_no_change_poll_response, remote_persisted_poll_response, remote_plan_build_content, remote_recent_workspaces_response, remote_session_created_response, remote_session_deleted_response, remote_session_info, remote_session_list_response, @@ -31,9 +32,10 @@ use openbitfun_services_integrations::remote_connect::{ remote_snapshot_poll_response, remote_task_cancel_response, remote_workspace_info_response, remote_workspace_updated_response, resolve_remote_agent_type, resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, - resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, - ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, - QrGenerator, RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, + resolve_remote_model_default_slot, resolve_remote_workspace_path, + should_send_remote_model_catalog, submit_remote_dialog, ActiveTurnSnapshot, + ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, QrGenerator, + RemoteAssistantWorkspaceFacts, RemoteCancelDecision, RemoteCancelRuntimeHost, RemoteCancelTaskRequest, RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteCommand, RemoteCommandRuntimeHost, RemoteConnectSubmissionSource, @@ -47,7 +49,8 @@ use openbitfun_services_integrations::remote_connect::{ RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteSessionWorkspaceIdentity, RemoteTerminalPrewarmRequest, RemoteToolStatus, RemoteWorkspaceFacts, RemoteWorkspaceFileChunk, RemoteWorkspaceFileContent, RemoteWorkspaceFileInfo, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, REMOTE_CAPABILITY_DIALOG_STEER_V1, + RemoteWorkspaceKind, RemoteWorkspaceUpdate, TrackerEvent, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, REMOTE_CAPABILITY_DIALOG_STEER_V1, REMOTE_CAPABILITY_HARNESS_PROFILES_V1, REMOTE_CAPABILITY_HOST_STREAM_V1, REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, @@ -2227,7 +2230,8 @@ fn remote_connect_workspace_response_helpers_own_wire_shape() { REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, "user_question_interaction_v1", - REMOTE_CAPABILITY_HOST_STREAM_V1 + REMOTE_CAPABILITY_HOST_STREAM_V1, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 ]) ); let mut legacy_info_json = info_json.clone(); @@ -2474,7 +2478,8 @@ fn remote_connect_session_response_helpers_own_pagination_and_timestamps() { REMOTE_CAPABILITY_PLAN_BUILD_V1, REMOTE_CAPABILITY_SESSION_ROLLBACK_V1, "user_question_interaction_v1", - REMOTE_CAPABILITY_HOST_STREAM_V1 + REMOTE_CAPABILITY_HOST_STREAM_V1, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 ]) ); let mut legacy_initial_json = initial_json; @@ -3688,3 +3693,198 @@ fn dialog_queue_wire_keeps_legacy_send_messages_readable() { let command: RemoteCommand = serde_json::from_value(queue.clone()).unwrap(); assert_eq!(serde_json::to_value(command).unwrap(), queue); } + +#[test] +fn device_model_defaults_command_is_a_whole_object_write_of_three_slots() { + // Absent fields and explicit nulls both clear a slot, so the command has no + // tri-state ambiguity; the host-only slots are not part of the request. + let empty: RemoteCommand = + serde_json::from_value(serde_json::json!({ "cmd": "set_model_defaults" })) + .expect("deserialize empty device defaults command"); + assert_eq!( + empty, + RemoteCommand::SetModelDefaults { + primary: None, + fast: None, + image_understanding: None, + } + ); + let explicit_nulls: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_model_defaults", + "primary": null, + "fast": "fast-model", + "image_understanding": null + })) + .expect("deserialize explicit null slots"); + assert_eq!( + explicit_nulls, + RemoteCommand::SetModelDefaults { + primary: None, + fast: Some("fast-model".to_string()), + image_understanding: None, + } + ); + let host_slots_are_ignored: RemoteCommand = serde_json::from_value(serde_json::json!({ + "cmd": "set_model_defaults", + "primary": "model-1", + "search": "search-model", + "speech_recognition": "speech-model" + })) + .expect("host-only slots are not part of the request"); + assert_eq!( + host_slots_are_ignored, + RemoteCommand::SetModelDefaults { + primary: Some("model-1".to_string()), + fast: None, + image_understanding: None, + } + ); + + let serialized = serde_json::to_value(RemoteCommand::SetModelDefaults { + primary: Some("model-1".to_string()), + fast: None, + image_understanding: Some("vision-1".to_string()), + }) + .expect("serialize device defaults command"); + assert_eq!( + serialized, + serde_json::json!({ + "cmd": "set_model_defaults", + "primary": "model-1", + "fast": null, + "image_understanding": "vision-1" + }) + ); +} + +#[test] +fn device_model_defaults_response_carries_role_mappings_only() { + let default_models = RemoteDefaultModelsConfig { + primary: Some("model-1".to_string()), + fast: Some("fast-model".to_string()), + search: Some("search-model".to_string()), + image_understanding: Some("vision-model".to_string()), + image_generation: Some("image-model".to_string()), + speech_recognition: Some("speech-model".to_string()), + }; + let response = remote_model_defaults_updated_response(default_models.clone()); + assert_eq!( + response, + RemoteResponse::ModelDefaultsUpdated { + default_models: default_models.clone(), + } + ); + + let json = serde_json::to_value(&response).expect("serialize device defaults response"); + assert_eq!(json["resp"], "model_defaults_updated"); + let mut response_keys: Vec = json + .as_object() + .expect("response is an object") + .keys() + .cloned() + .collect(); + response_keys.sort(); + assert_eq!(response_keys, ["default_models", "resp"]); + + // All six slots travel; the three host-only slots are reported even though + // the request never carries them. + let slots = json["default_models"] + .as_object() + .expect("default models is an object"); + let mut slot_keys: Vec = slots.keys().cloned().collect(); + slot_keys.sort(); + assert_eq!( + slot_keys, + [ + "fast", + "image_generation", + "image_understanding", + "primary", + "search", + "speech_recognition" + ] + ); + assert!(slots.values().all(serde_json::Value::is_string)); + assert_eq!(slots["search"], "search-model"); + + // The payload is a role mapping, never a model record: no credential, + // endpoint, or provider field may appear anywhere in it. + let body = serde_json::to_string(&response).expect("serialize device defaults response"); + for forbidden in [ + "api_key", + "apikey", + "secret", + "token", + "credential", + "password", + "base_url", + "provider", + ] { + assert!( + !body.contains(forbidden), + "device model defaults response must not carry {forbidden}: {body}" + ); + } +} + +fn device_default_known_model(model_id: &str) -> bool { + matches!(model_id, "text-1" | "vision-1") +} + +#[test] +fn device_model_default_slots_reject_unknown_and_semantic_ids() { + assert_eq!( + resolve_remote_model_default_slot("primary", None, device_default_known_model) + .expect("a cleared slot is valid"), + None + ); + assert_eq!( + resolve_remote_model_default_slot( + "primary", + Some(" text-1 "), + device_default_known_model + ) + .expect("a catalog model is valid"), + Some("text-1".to_string()) + ); + assert_eq!( + resolve_remote_model_default_slot("fast", Some("model-gone"), device_default_known_model) + .expect_err("unknown model ids are rejected"), + "Unknown model selection: model-gone" + ); + assert_eq!( + resolve_remote_model_default_slot( + "image_understanding", + Some("model-gone"), + device_default_known_model + ) + .expect_err("unknown model ids are rejected"), + "Unknown model selection: model-gone" + ); + // The semantic selectors name a session role, never a device default value. + for semantic in ["primary", "fast", "auto", "default"] { + assert!( + resolve_remote_model_default_slot("fast", Some(semantic), device_default_known_model) + .is_err(), + "{semantic} must not be accepted as a device default model id" + ); + } + assert_eq!( + resolve_remote_model_default_slot("primary", Some(" "), device_default_known_model) + .expect_err("an empty model id is not a cleared slot"), + "primary model id is required" + ); +} + +#[test] +fn device_model_defaults_capability_is_advertised_by_the_host() { + assert_eq!( + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, + "device_model_defaults_v1" + ); + let RemoteResponse::WorkspaceInfo { capabilities, .. } = remote_workspace_info_response(None) + else { + panic!("workspace info response"); + }; + assert!(capabilities.contains(&REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1.to_string())); +} From 9be6d2ae0b79ef462803215f2091e133810ab156 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:08:57 +0800 Subject: [PATCH 05/11] feat(core): write device model roles for the remote session host Implement `RemoteSessionRuntimeHost::set_model_defaults` so a controller's primary / fast / image-understanding choice persists on the device it controls. The write merges the three managed slots over the current `ai.default_models` and stores the whole object through the config service, so the host-only slots (`search`, `image_generation`, `speech_recognition`) keep their device values and the existing reconcile runs. The reply is read back from the reconciled config: a slot pointing at a disabled model or one without the capability its role needs reports the repaired value rather than what the request asked for. An id the device does not configure is rejected before anything is persisted. --- .../core/src/service_agent_runtime.rs | 277 ++++++++++++++++-- 1 file changed, 252 insertions(+), 25 deletions(-) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 8dfd43836e..90adb222f2 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -43,22 +43,22 @@ use openbitfun_services_integrations::remote_connect::{ build_remote_model_catalog, normalize_remote_model_selection as normalize_remote_model_selection_contract, normalize_remote_session_model_id, project_remote_chat_user, - remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config, ChatMessage, - LocalModelsDevCatalogs, RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, - RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, - RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, - RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, - RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, - RemoteDialogSteerOutcome, RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, - RemoteDialogSubmitOutcome, RemoteDialogWorkspaceBinding, RemoteImageContext, - RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, - RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, - RemotePollRuntimeHost, RemoteRecentWorkspaceFacts, RemoteSessionMetadata, - RemoteSessionModelSelection, RemoteSessionRollbackOutcome, RemoteSessionRuntimeHost, - RemoteSessionStateTracker, RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, - RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, - RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceRuntimeHost, - RemoteWorkspaceUpdate, + remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config, + resolve_remote_model_default_slot, ChatMessage, LocalModelsDevCatalogs, + RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, RemoteChatHistoryRound, + RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, + RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, + RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, + RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSteerOutcome, + RemoteDialogSteerRequest, RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, + RemoteDialogWorkspaceBinding, RemoteImageContext, RemoteInitialSyncRuntimeHost, + RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, + RemoteModelCatalogFacts, RemoteModelFacts, RemotePermissionMode, RemotePollRuntimeHost, + RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionModelSelection, + RemoteSessionRollbackOutcome, RemoteSessionRuntimeHost, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, + RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, }; use std::sync::Arc; use std::time::Duration; @@ -87,7 +87,7 @@ use crate::infrastructure::ai::reasoning_catalog::{ use crate::service::remote_connect::remote_server::RemoteExecutionDispatcher; #[cfg(feature = "remote-connect")] -use crate::service::config::types::{AIConfig, GlobalConfig, ModelCapability}; +use crate::service::config::types::{AIConfig, DefaultModelsConfig, GlobalConfig, ModelCapability}; #[cfg(feature = "remote-connect")] use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; @@ -890,6 +890,51 @@ fn remote_model_capability_fact(capability: ModelCapability) -> RemoteModelCapab } } +/// Project the device `ai.default_models` object for a remote caller. Every +/// slot travels because a controller renders the whole role mapping, including +/// the slots only this device may write. +#[cfg(feature = "remote-connect")] +fn remote_default_models_config(defaults: &DefaultModelsConfig) -> RemoteDefaultModelsConfig { + RemoteDefaultModelsConfig { + primary: defaults.primary.clone(), + fast: defaults.fast.clone(), + search: defaults.search.clone(), + image_understanding: defaults.image_understanding.clone(), + image_generation: defaults.image_generation.clone(), + speech_recognition: defaults.speech_recognition.clone(), + } +} + +/// Merge a controller's three managed role defaults over the device's current +/// `ai.default_models`. +/// +/// The managed slots are replaced as a whole object, so `None` clears a slot, +/// and every provided id must name a model this device already configures. The +/// other three slots are carried over untouched. Enablement and capability +/// validity are deliberately not decided here: the config reconcile owns that +/// repair and the caller reports its result. +#[cfg(feature = "remote-connect")] +fn merge_remote_model_defaults( + current: &DefaultModelsConfig, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + is_known_model: &impl Fn(&str) -> bool, +) -> Result { + Ok(DefaultModelsConfig { + primary: resolve_remote_model_default_slot("primary", primary, is_known_model)?, + fast: resolve_remote_model_default_slot("fast", fast, is_known_model)?, + image_understanding: resolve_remote_model_default_slot( + "image_understanding", + image_understanding, + is_known_model, + )?, + search: current.search.clone(), + image_generation: current.image_generation.clone(), + speech_recognition: current.speech_recognition.clone(), + }) +} + /// An attachment recorded before pixels travelled inline holds a host path and /// nothing else, and only this host can resolve it. Above this size the image is /// left as a name: reading it would cost more than the thumbnail is worth. @@ -2283,14 +2328,7 @@ impl CoreServiceAgentRuntime { models, provider_catalog, models_dev_reasoning_catalog, - default_models: RemoteDefaultModelsConfig { - primary: ai_config.default_models.primary, - fast: ai_config.default_models.fast, - search: ai_config.default_models.search, - image_understanding: ai_config.default_models.image_understanding, - image_generation: ai_config.default_models.image_generation, - speech_recognition: ai_config.default_models.speech_recognition, - }, + default_models: remote_default_models_config(&ai_config.default_models), session_model_id, session_reasoning_preset, })) @@ -2465,6 +2503,46 @@ impl CoreServiceAgentRuntime { .await; } + /// Apply a controller's three device role defaults and report the device's + /// reconciled six-slot mapping. + /// + /// The whole `ai.default_models` object is written, so the slots a + /// controller may not write keep their device values, and the config + /// service reconciles the write. The reply is read back from the reconciled + /// config: a slot whose model is disabled or lacks the capability its role + /// needs reports the repaired value rather than what was requested. + #[cfg(feature = "remote-connect")] + pub(crate) async fn update_remote_model_defaults( + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + ) -> Result { + let config_service = crate::service::config::get_global_config_service() + .await + .map_err(|_| "Config service not available".to_string())?; + let ai_config: AIConfig = config_service + .get_config(Some("ai")) + .await + .map_err(|e| format!("Failed to load AI config: {e}"))?; + let merged = merge_remote_model_defaults( + &ai_config.default_models, + primary, + fast, + image_understanding, + &|model_id: &str| ai_config.models.iter().any(|model| model.id == model_id), + )?; + config_service + .set_config("ai.default_models", &merged) + .await + .map_err(|e| format!("Failed to persist device model defaults: {e}"))?; + + let reconciled: AIConfig = config_service + .get_config(Some("ai")) + .await + .map_err(|e| format!("Failed to reload AI config: {e}"))?; + Ok(remote_default_models_config(&reconciled.default_models)) + } + #[cfg(feature = "remote-connect")] pub(crate) fn remote_control_state_port( coordinator: &ConversationCoordinator, @@ -3568,6 +3646,18 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { .await } + /// Device-level role defaults: no session or workspace is involved, and the + /// reply is the device's reconciled mapping rather than the request. + async fn set_model_defaults( + &self, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, + ) -> Result { + CoreServiceAgentRuntime::update_remote_model_defaults(primary, fast, image_understanding) + .await + } + async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String> { let binding = CoreServiceAgentRuntime::resolve_session_workspace_binding(session_id) .await @@ -4278,6 +4368,143 @@ mod tests { assert!(!body.contains("restore_session_from_storage_path")); } + fn device_default_model_catalog() -> GlobalConfig { + let mut config = GlobalConfig::default(); + config.ai = serde_json::from_value(serde_json::json!({ + "models": [ + {"id": "text-1", "name": "Text", "provider": "openai", "enabled": true, + "capabilities": ["text_chat"]}, + {"id": "vision-1", "name": "Vision", "provider": "openai", "enabled": true, + "capabilities": ["image_understanding"]}, + {"id": "retired-fast", "name": "Retired", "provider": "openai", "enabled": false, + "capabilities": ["text_chat"]}, + {"id": "search-1", "name": "Search", "provider": "openai", "enabled": true, + "capabilities": ["search"]}, + {"id": "image-1", "name": "Image", "provider": "openai", "enabled": true, + "capabilities": ["image_generation"]}, + {"id": "speech-1", "name": "Speech", "provider": "openai", "enabled": true, + "capabilities": ["speech_recognition"]} + ], + "default_models": { + "primary": "text-1", + "search": "search-1", + "image_generation": "image-1", + "speech_recognition": "speech-1" + } + })) + .expect("device model catalog fixture"); + config + } + + #[test] + fn device_model_defaults_merge_owns_three_slots_and_preserves_host_slots() { + let mut config = device_default_model_catalog(); + config.ai.default_models.fast = Some("retired-fast".to_string()); + config.ai.default_models.image_understanding = Some("vision-1".to_string()); + let current = config.ai.default_models.clone(); + let is_known = |model_id: &str| config.ai.models.iter().any(|model| model.id == model_id); + + // A cleared slot (absent field or explicit null) is a real write. + let cleared = merge_remote_model_defaults(¤t, None, None, None, &is_known).unwrap(); + assert_eq!(cleared.primary, None); + assert_eq!(cleared.fast, None); + assert_eq!(cleared.image_understanding, None); + // The three slots only this device writes survive untouched. + assert_eq!(cleared.search.as_deref(), Some("search-1")); + assert_eq!(cleared.image_generation.as_deref(), Some("image-1")); + assert_eq!(cleared.speech_recognition.as_deref(), Some("speech-1")); + + let assigned = merge_remote_model_defaults( + ¤t, + Some("text-1"), + None, + Some("vision-1"), + &is_known, + ) + .unwrap(); + assert_eq!(assigned.primary.as_deref(), Some("text-1")); + assert_eq!(assigned.fast, None); + assert_eq!(assigned.image_understanding.as_deref(), Some("vision-1")); + assert_eq!(assigned.search.as_deref(), Some("search-1")); + assert_eq!(assigned.image_generation.as_deref(), Some("image-1")); + assert_eq!(assigned.speech_recognition.as_deref(), Some("speech-1")); + + // A model this device does not configure, a semantic session selector, + // and an empty value are all rejected before anything is persisted. + assert_eq!( + merge_remote_model_defaults(¤t, Some("model-gone"), None, None, &is_known) + .unwrap_err(), + "Unknown model selection: model-gone" + ); + assert!( + merge_remote_model_defaults(¤t, None, Some("fast"), None, &is_known).is_err() + ); + assert!(merge_remote_model_defaults(¤t, None, None, Some(" "), &is_known).is_err()); + } + + #[test] + fn device_model_defaults_echo_the_reconciled_device_state() { + use crate::service::config::reconcile_model_references; + + let mut config = device_default_model_catalog(); + let is_known = |model_id: &str| config.ai.models.iter().any(|model| model.id == model_id); + // `retired-fast` exists but is disabled, and `text-1` cannot understand + // images: both pass the verb's existence check, so the request is a real + // write and only the reconcile can repair it. + let requested = merge_remote_model_defaults( + &config.ai.default_models, + Some("text-1"), + Some("retired-fast"), + Some("text-1"), + &is_known, + ) + .unwrap(); + assert_eq!(requested.fast.as_deref(), Some("retired-fast")); + assert_eq!(requested.image_understanding.as_deref(), Some("text-1")); + + config.ai.default_models = requested.clone(); + reconcile_model_references(&mut config); + + let echo = remote_default_models_config(&config.ai.default_models); + // The reply is the device's repaired truth, not what was requested. + assert_ne!(echo.fast, requested.fast); + assert_eq!(echo.fast.as_deref(), Some("text-1")); + assert_ne!(echo.image_understanding, requested.image_understanding); + assert_eq!(echo.image_understanding.as_deref(), Some("vision-1")); + assert_eq!(echo.primary.as_deref(), Some("text-1")); + // Host-only slots stay out of the repair entirely. + assert_eq!(echo.search.as_deref(), Some("search-1")); + assert_eq!(echo.image_generation.as_deref(), Some("image-1")); + assert_eq!(echo.speech_recognition.as_deref(), Some("speech-1")); + } + + #[test] + fn device_model_defaults_write_goes_through_the_reconciling_config_service() { + let source = include_str!("service_agent_runtime.rs"); + let body = source + .split("pub(crate) async fn update_remote_model_defaults") + .nth(1) + .and_then(|source| { + source + .split("pub(crate) fn remote_control_state_port") + .next() + }) + .expect("device model defaults writer"); + + // One whole-object write of the managed slots, then a read-back of the + // reconciled config: no per-slot write may bypass the reconcile, and the + // echo must not be built from the merged request. + assert!(body.contains("merge_remote_model_defaults(")); + assert!(body.contains(r#".set_config("ai.default_models", &merged)"#)); + assert!(body.contains("remote_default_models_config(&reconciled.default_models)")); + for slot in ["search", "image_generation", "speech_recognition"] { + assert!( + !body.contains(&format!(".set_config(\"ai.default_models.{slot}\"")), + "host-only slot {slot} must not be written by the remote verb" + ); + } + } + #[test] fn remote_generated_turn_ids_are_uuid_unique() { let ids = (0..1_024) From 2d4e80790ab4685c58d28f17c610b82d11a2f249 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:35:33 +0800 Subject: [PATCH 06/11] feat(mobile): own the device model roles and the picker's page stack The settings pages need to know which models a role may be set to, what a role row says, and how the picker is entered and left before any of that is drawn. - `ModelRoleCandidatesPolicy` filters the desktop catalog per role (enabled and capable, desktop order preserved), names the pinned no-model value of each role, derives a role row's value and hint, and locates a role's resolved model with the fast slot falling back to the primary. - `DeviceModelDefaultsSheetState` holds the controller for the connected desktop and owns the write's feedback window, the busy lock and the read-back rule: a value only ever changes through a read. - `SettingsSheetState` gains the sheet's second level (`main` -> `deviceModels` -> `modelPick`) and resets the model state when the sheet closes. --- .../policy/ModelRoleCandidatesPolicy.ets | 262 ++++++++++++++++++ .../state/DeviceModelDefaultsSheetState.ets | 219 +++++++++++++++ .../ets/pages/state/SettingsSheetState.ets | 34 +++ 3 files changed, 515 insertions(+) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets new file mode 100644 index 0000000000..0e6b60b63f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets @@ -0,0 +1,262 @@ +import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; +import { + RemoteModelDefaultsRole, + RemoteModelDefaultsRoles +} from '../../services/RemoteModelDefaultsController'; + +/** + * The vocabulary the two device-model pages share. + * + * Which models a role may be set to, which of them the picker offers, and what a + * role row says are one reading of one catalog, so they are decided here as pure + * functions over it: no i18n lookup, no controller, no ArkUI. The pages resolve + * the keys this returns and own every pixel. + * + * The desktop is the authority. A role's value is whatever the last device read + * returned; a candidate is a model the desktop itself lists as enabled and + * capable, in the desktop's own order. Nothing here reorders, renames or repairs + * that list. + */ + +/** What one role row of the device model settings page shows. */ +export interface ModelRoleRowProjection { + /** The model the role points at; empty when the role has no model of its own. */ + modelId: string; + /** The catalog entry for `modelId`, when the desktop still lists it. */ + model: RemoteModelConfig | undefined; + /** The role has no model of its own: `fast` follows the primary, the rest are off. */ + unset: boolean; + /** The value line's placeholder key; empty while `modelId` names a model. */ + unsetKey: string; + /** The hint under the value; empty unless the role explicitly falls back. */ + hintKey: string; + /** + * The role resolves to an enabled model the desktop lists — its own, or for + * `fast` the primary it falls back to. This is what the row's leading mark + * states. + */ + resolved: boolean; +} + +export class ModelRoleCandidatesPolicy { + /** Model capabilities, as the desktop reports them. */ + static readonly CAPABILITY_TEXT_CHAT: string = 'text_chat'; + static readonly CAPABILITY_IMAGE_UNDERSTANDING: string = 'image_understanding'; + + /** The neutral "nothing is set here" value, shared with the model panels. */ + static readonly NOT_CONFIGURED_KEY: string = 'settings.modelService.notConfigured'; + + /** + * Models the picker may offer for a role: enabled, capable, in catalog order. + * + * A disabled model is not a candidate at all — the desktop would refuse the + * write, and offering it would turn a filter into an error message — and the + * capability is what makes the model usable for that role in the first place. + */ + static textChatCandidates(catalog: RemoteModelCatalog): RemoteModelConfig[] { + return ModelRoleCandidatesPolicy.capableModels( + catalog, ModelRoleCandidatesPolicy.CAPABILITY_TEXT_CHAT); + } + + static imageUnderstandingCandidates(catalog: RemoteModelCatalog): RemoteModelConfig[] { + return ModelRoleCandidatesPolicy.capableModels( + catalog, ModelRoleCandidatesPolicy.CAPABILITY_IMAGE_UNDERSTANDING); + } + + static candidatesForRole( + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): RemoteModelConfig[] { + return role === 'image_understanding' ? + ModelRoleCandidatesPolicy.imageUnderstandingCandidates(catalog) : + ModelRoleCandidatesPolicy.textChatCandidates(catalog); + } + + /** The desktop lists no model at all: the pickers have nothing to offer. */ + static isEmptyCatalog(catalog: RemoteModelCatalog): boolean { + return catalog.models.length === 0; + } + + /** + * Whether the picker offers the "no model" row. + * + * The primary model is required — the desktop always resolves one, and a phone + * that cleared it could leave the device with no model to run — so its picker + * has no such row. The other two are optional by contract: an empty `fast` is + * "follow the primary" and an empty `image_understanding` is "off". + */ + static allowsUnset(role: RemoteModelDefaultsRole): boolean { + return role !== 'primary'; + } + + /** The pinned row's label key. */ + static unsetLabelKey(role: RemoteModelDefaultsRole): string { + if (role === 'fast') { + return 'chat.modelFollowPrimary'; + } + return ModelRoleCandidatesPolicy.NOT_CONFIGURED_KEY; + } + + /** The pinned row's hint key. */ + static unsetHintKey(role: RemoteModelDefaultsRole): string { + if (role === 'fast') { + return 'chat.modelFastNotConfigured'; + } + if (role === 'image_understanding') { + return 'settings.model.imageUnderstandingUnset'; + } + return ''; + } + + /** The role's own label, shared by the rows, the picker title and the sent row. */ + static roleLabelKey(role: RemoteModelDefaultsRole): string { + if (role === 'primary') { + return 'chat.modelPrimary'; + } + if (role === 'fast') { + return 'chat.modelFast'; + } + if (role === 'image_understanding') { + return 'chat.modelImage'; + } + return ''; + } + + /** The filter sentence above the candidate list, templated with the device name. */ + static filterKey(role: RemoteModelDefaultsRole): string { + return role === 'image_understanding' ? + 'settings.model.filterImageUnderstanding' : 'settings.model.filterTextChat'; + } + + /** + * The capability badge belongs to the multimodal picker only: it explains why + * that list is short, and on any other page it would only widen every row. + */ + static showsCapabilityBadge(role: RemoteModelDefaultsRole): boolean { + return role === 'image_understanding'; + } + + /** The model id a role currently holds; empty when it holds none. */ + static selectedValue(roles: RemoteModelDefaultsRoles, role: RemoteModelDefaultsRole): string { + if (role === 'primary') { + return roles.primary || ''; + } + if (role === 'fast') { + return roles.fast || ''; + } + if (role === 'image_understanding') { + return roles.image_understanding || ''; + } + return ''; + } + + /** True while the role is set to "no model", i.e. the pinned row is the checked one. */ + static isUnsetSelected(roles: RemoteModelDefaultsRoles, role: RemoteModelDefaultsRole): boolean { + return ModelRoleCandidatesPolicy.allowsUnset(role) && + ModelRoleCandidatesPolicy.selectedValue(roles, role).length === 0; + } + + /** True when this candidate row is the role's current model. */ + static isCandidateSelected( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + modelId: string + ): boolean { + return ModelRoleCandidatesPolicy.selectedValue(roles, role) === modelId; + } + + /** + * One role row of the settings page. + * + * An empty catalog is its own answer: with no model on the device there is + * nothing for any role to point at, so every row states the same unconfigured + * value and no row claims to resolve. Saying "follows the primary" there would + * promise a fallback the desktop cannot perform. + */ + static roleRow( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): ModelRoleRowProjection { + if (ModelRoleCandidatesPolicy.isEmptyCatalog(catalog)) { + return { + modelId: '', + model: undefined, + unset: true, + unsetKey: ModelRoleCandidatesPolicy.NOT_CONFIGURED_KEY, + hintKey: '', + resolved: false + }; + } + const modelId = ModelRoleCandidatesPolicy.selectedValue(roles, role); + const model = ModelRoleCandidatesPolicy.modelById(catalog, modelId); + const unset = modelId.length === 0; + return { + modelId, + model, + unset, + unsetKey: unset ? ModelRoleCandidatesPolicy.unsetLabelKey(role) : '', + hintKey: unset ? ModelRoleCandidatesPolicy.unsetHintKey(role) : '', + resolved: ModelRoleCandidatesPolicy.resolves(roles, role, catalog) + }; + } + + /** + * The model a role actually runs: its own, or for `fast` the primary model it + * falls back to. `image_understanding` has no fallback — an empty one means the + * capability is off rather than borrowed from another slot. + * + * A role only runs an enabled model, so a slot the desktop points at a disabled + * model resolves to nothing here even though the row still names it. This is + * the same reading of a catalog the composer's role rows use. + */ + static resolvedModel( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): RemoteModelConfig | undefined { + const own = ModelRoleCandidatesPolicy.enabledModelById( + catalog, ModelRoleCandidatesPolicy.selectedValue(roles, role)); + if (own) { + return own; + } + if (role === 'fast') { + return ModelRoleCandidatesPolicy.enabledModelById(catalog, roles.primary || ''); + } + return undefined; + } + + private static resolves( + roles: RemoteModelDefaultsRoles, + role: RemoteModelDefaultsRole, + catalog: RemoteModelCatalog + ): boolean { + return ModelRoleCandidatesPolicy.resolvedModel(roles, role, catalog) !== undefined; + } + + private static enabledModelById( + catalog: RemoteModelCatalog, + modelId: string + ): RemoteModelConfig | undefined { + const model = ModelRoleCandidatesPolicy.modelById(catalog, modelId); + return model && model.enabled ? model : undefined; + } + + private static modelById( + catalog: RemoteModelCatalog, + modelId: string + ): RemoteModelConfig | undefined { + if (modelId.length === 0) { + return undefined; + } + return catalog.models.find((model: RemoteModelConfig): boolean => model.id === modelId); + } + + private static capableModels( + catalog: RemoteModelCatalog, + capability: string + ): RemoteModelConfig[] { + return catalog.models.filter((model: RemoteModelConfig): boolean => + model.enabled && (model.capabilities || []).indexOf(capability) >= 0); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets new file mode 100644 index 0000000000..a0bf242e98 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/DeviceModelDefaultsSheetState.ets @@ -0,0 +1,219 @@ +import { RemoteDefaultModels, RemoteModelCatalog } from '../../model/RemoteModels'; +import { RemoteUiState } from '../../services/RemoteUiState'; +import { + RemoteModelDefaultsClient, + RemoteModelDefaultsController, + RemoteModelDefaultsGate, + RemoteModelDefaultsRole, + RemoteModelDefaultsState +} from '../../services/RemoteModelDefaultsController'; +import { ModelRoleCandidatesPolicy } from '../policy/ModelRoleCandidatesPolicy'; + +/** How long the confirmed-write line stays on screen. */ +export const MODEL_DEFAULTS_FEEDBACK_MS: number = 2000; + +/** + * The state a page shows before anything has been read or bound. + * + * It is the same shape `RemoteModelDefaultsController` publishes for a closed + * gate, so a page mounted before its client is bound renders an empty page + * instead of nothing at all. The controller's own snapshot is what a page + * normally sees; `model-role-pages.test.cjs` pins the two together so this copy + * cannot drift away from it. + */ +export function emptyModelDefaultsState(): RemoteModelDefaultsState { + return { + available: false, + loading: false, + busy: false, + catalog: RemoteUiState.emptyModelCatalog(), + roles: { primary: null, fast: null, image_understanding: null }, + error: '', + failure: '', + statusText: '', + loaded: false + }; +} + +/** + * The request path the sheet hands to the pages: the same two calls + * `RemoteModelDefaultsClient` describes, declared as properties. + * + * ArkTS only accepts an object literal against a type whose members are + * properties, and the sheet binds its two request events as a literal, because + * the desktop they go to is chosen by the sheet and not by either page. The + * shape is the same one the controller calls, so this is a spelling of that + * interface rather than a second protocol. + */ +export interface DeviceModelDefaultsRequestPath { + getModelCatalog: (sessionId?: string) => Promise; + setModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise; +} + +/** + * Device model role defaults for the connected desktop, owned by the settings + * sheet. + * + * The sheet's two model pages (`deviceModels` and the `modelPick` level above + * it) are one workflow over one controller: the picker writes, the settings page + * shows the read-back, and swapping between them must not re-read the device or + * lose a write in flight. So the controller lives here, on the sheet state the + * two pages are both handed, rather than inside either page. + * + * It is also the client the controller calls: `bind` decides which desktop the + * pages are talking to, and the sheet rebinds the same object when it opens on + * another one. Everything else — gating, the write, the read-back, the stale + * result fence — belongs to the controller. + */ +@ObservedV2 +export class DeviceModelDefaultsSheetState implements RemoteModelDefaultsClient { + /** The controller's latest snapshot; the only thing the pages render. */ + @Trace state: RemoteModelDefaultsState = emptyModelDefaultsState(); + /** The role a write is running for, or the one that just failed. */ + @Trace pendingRole: RemoteModelDefaultsRole | '' = ''; + /** The value that write carries; `null` is a value, not an absence. */ + @Trace pendingModelId: string | null = null; + /** True while a confirmed write's result line is on screen. */ + @Trace feedbackVisible: boolean = false; + + private readonly feedbackDurationMs: number; + private readonly controller: RemoteModelDefaultsController; + private target?: DeviceModelDefaultsRequestPath; + private feedbackTimer: number = 0; + + constructor(feedbackDurationMs: number = MODEL_DEFAULTS_FEEDBACK_MS) { + this.feedbackDurationMs = feedbackDurationMs; + this.controller = new RemoteModelDefaultsController(this, { + onStateChanged: (state: RemoteModelDefaultsState): void => { + this.state = state; + } + }); + this.state = this.controller.getState(); + } + + /** Points the pages at one desktop's request path. */ + bind(target: DeviceModelDefaultsRequestPath): void { + this.target = target; + } + + /** Re-gates the pages; true when what is on screen has to be read again. */ + setGate(gate: RemoteModelDefaultsGate): boolean { + return this.controller.setGate(gate); + } + + /** Reads once per gate: a page that opens twice does not read twice. */ + async ensureLoaded(): Promise { + if (this.state.loaded || this.state.loading) { + return; + } + await this.controller.load(); + } + + async load(): Promise { + await this.controller.load(); + } + + /** + * Sets one role and waits for the controller's read-back. + * + * The feedback line is raised only after that read returns, and only when a + * write really happened: selecting the value a role already holds writes + * nothing, and an unavailable gate writes nothing either, so neither may claim + * the device was updated. What the line says is derived from the read-back + * value at render time — the desktop may have repaired the selection, and the + * intent is not the result. + */ + async selectRole(role: RemoteModelDefaultsRole, modelId: string | null): Promise { + // One write at a time, and none while the read that answers the page is still + // out: either one would leave the intent recorded here describing a value no + // request ever carried. + if (this.state.busy || this.state.loading) { + return; + } + const requested = modelId === null ? '' : modelId; + const current = ModelRoleCandidatesPolicy.selectedValue(this.state.roles, role); + this.pendingRole = role; + this.pendingModelId = modelId; + this.clearFeedback(); + if (current === requested) { + return; + } + await this.controller.applyRoleSelection(role, modelId); + if (this.state.available && this.state.error.length === 0) { + this.showFeedback(); + } + } + + /** + * Repeats whatever failed: the write that was rejected, or the read that failed + * after an accepted write. The failure the controller reported decides which, + * because a retry of the wrong one would ask the user to re-answer a question + * they already answered. + */ + async retry(): Promise { + const role = this.pendingRole; + if (this.state.failure === 'write' && role !== '') { + const modelId = this.pendingModelId; + await this.controller.applyRoleSelection(role, modelId); + if (this.state.available && this.state.error.length === 0) { + this.showFeedback(); + } + return; + } + this.clearFeedback(); + await this.controller.load(); + } + + /** Clears the pages while the sheet is closed. */ + reset(): void { + this.clearFeedback(); + this.pendingRole = ''; + this.pendingModelId = null; + this.controller.reset(); + } + + getModelCatalog(sessionId?: string): Promise { + const target = this.target; + if (!target) { + return Promise.reject(new Error('The device model pages have no client bound.')); + } + return target.getModelCatalog(sessionId); + } + + setModelDefaults( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise { + const target = this.target; + if (!target) { + return Promise.reject(new Error('The device model pages have no client bound.')); + } + return target.setModelDefaults(primary, fast, imageUnderstanding); + } + + private showFeedback(): void { + this.feedbackVisible = true; + this.cancelTimer(); + this.feedbackTimer = setTimeout(() => { + this.feedbackTimer = 0; + this.feedbackVisible = false; + }, this.feedbackDurationMs); + } + + private clearFeedback(): void { + this.cancelTimer(); + this.feedbackVisible = false; + } + + private cancelTimer(): void { + if (this.feedbackTimer !== 0) { + clearTimeout(this.feedbackTimer); + this.feedbackTimer = 0; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets index a4aca3d561..4ed2410bee 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/state/SettingsSheetState.ets @@ -1,11 +1,25 @@ +import { DeviceModelDefaultsSheetState } from './DeviceModelDefaultsSheetState'; + /** * Settings sheet navigation and editor drafts live here so a fold-driven * rebuild does not send the user back to the home list or wipe in-progress * local-model fields. + * + * It is also where the device model pages' controller lives. Those pages are two + * levels of one workflow over the connected desktop — the settings page and the + * role picker it pushes — so the controller outlives either of them and belongs + * to the sheet state the sheet hands to both. */ @ObservedV2 export class SettingsSheetState { @Trace page: string = 'main'; + /** + * The role the second-level picker edits. Empty while the picker is closed, + * so a page rebuilt from the wrong level cannot open someone else's list. + */ + @Trace pickRole: string = ''; + /** Device model role defaults for the connected desktop; reset when the sheet closes. */ + readonly deviceModels: DeviceModelDefaultsSheetState = new DeviceModelDefaultsSheetState(); @Trace savedGeneralChatApiUrl: string = ''; @Trace savedGeneralChatModelName: string = ''; @Trace savedGeneralChatHasApiKey: boolean = false; @@ -16,9 +30,27 @@ export class SettingsSheetState { @Trace editorDraftHydrated: boolean = false; setPage(page: string): void { + if (page !== 'modelPick') { + this.pickRole = ''; + } this.page = page; } + /** + * Pushes the role picker: the second level of the sheet's own page stack + * (`main` → `deviceModels` → `modelPick`). + */ + openModelPick(role: string): void { + this.pickRole = role; + this.page = 'modelPick'; + } + + /** Returns one level: the picker goes back to the settings page that pushed it. */ + backFromModelPick(): void { + this.pickRole = ''; + this.page = 'deviceModels'; + } + syncSaved(apiUrl: string, modelName: string, hasApiKey: boolean): void { this.savedGeneralChatApiUrl = apiUrl; this.savedGeneralChatModelName = modelName; @@ -38,6 +70,8 @@ export class SettingsSheetState { resetOnClose(): void { this.page = 'main'; + this.pickRole = ''; + this.deviceModels.reset(); this.draftApiUrl = ''; this.draftApiKey = ''; this.draftModelName = ''; From de4ba198df632c5794ee984905456693b0817506 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:35:43 +0800 Subject: [PATCH 07/11] feat(mobile): show and edit the device model roles Replaces the device model placeholder with the two real pages. The settings page shows the desktop's summary, one row per role (primary always resolved, fast saying so when it follows the primary, multimodal naming the capability it needs), the three scope lines, and the writes' own feedback: the whole sheet locks while a write is out, the confirmed value is reported for a moment after the read that answers it, and a refusal is shown verbatim with a retry that repeats exactly what failed. There is no manual refresh. The role picker is a second level of the same sheet: the pinned no-model row first, then the desktop's own candidates in its own order, with the multimodal badge only where the capability is the point. Selecting writes and returns; the value shown afterwards is always the one the desktop read back, never the tap. Both pages read the catalog as the protocol reports it and project models through the shared presentation mapper, so a model is named here exactly as the rest of the app names it. A catalog with nothing to offer keeps the rows tappable and says why the lists are empty; a device that does not support the feature says so instead of pretending to be empty. ArkTS notes: the sheet binds its request path as a literal, which ArkTS only accepts against property-typed members, so the two calls are declared as `DeviceModelDefaultsRequestPath` rather than as the controller's method-shaped client interface. --- .../components/DeviceModelDefaultsPanel.ets | 508 ++++++++++++++++++ .../pages/components/ModelRolePickerPanel.ets | 348 ++++++++++++ .../ets/pages/components/SettingsSheet.ets | 123 +++-- .../main/resources/en_US/element/string.json | 8 + .../main/resources/zh_CN/element/string.json | 8 + 5 files changed, 947 insertions(+), 48 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets new file mode 100644 index 0000000000..e880872c01 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets @@ -0,0 +1,508 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { + RemoteModelDefaultsRole, + RemoteModelDefaultsState +} from '../../services/RemoteModelDefaultsController'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; +import { ModelRoleCandidatesPolicy, ModelRoleRowProjection } from '../policy/ModelRoleCandidatesPolicy'; +import { toConversationUiModel } from '../state/ConversationUiModels'; +import { DeviceModelDefaultsSheetState } from '../state/DeviceModelDefaultsSheetState'; +import { SettingsSheetState } from '../state/SettingsSheetState'; +import { NavigationBackButton } from './NavigationBackButton'; +import { SheetCloseButton } from './SheetCloseButton'; +import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING, SHEET_HORIZONTAL_PADDING } from './SheetLayout'; +import { + CARD, + INK, + LINE, + MUTED, + PAGE_BG, + SOFT, + STATUS_DANGER, + STATUS_SUCCESS, + SUBTLE, + TRANSPARENT +} from './Theme'; + +/** The three roles in the order the page lists them. */ +const ROLE_ORDER: RemoteModelDefaultsRole[] = ['primary', 'fast', 'image_understanding']; + +/** + * The device model settings page: the three role slots of the desktop this phone + * is driving, on the same sheet, one level above the settings list. + * + * It is a page of the sheet rather than a stand-alone service panel: the header + * carries back and close, the rows are the settings list's row language, and the + * write feedback happens in place. The desktop owns the values, so a row shows + * the last device read and never the tap that is still in flight: selecting a + * model returns here and the value changes only when the read-back lands. + * + * Control flow stays in the sheet's state object the whole time — an older + * desktop that cannot answer, a desktop that stopped answering, and a phone that + * is simply not connected are three different states, and all three are stated + * in the page instead of failing the tap silently. + */ +@ComponentV2 +export struct DeviceModelDefaultsPanel { + @Param sheetState: SettingsSheetState = new SettingsSheetState(); + @Param deviceModels: DeviceModelDefaultsSheetState = new DeviceModelDefaultsSheetState(); + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Param controlTargetType: string = 'none'; + @Param connectionState: string = 'idle'; + @Event onClose: () => void = () => {}; + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + // The page is where the device is actually read: opening the sheet only arms + // the gate, so a user who never comes here never spends the read. + void this.deviceModels.ensureLoaded(); + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + + build() { + Column({ space: 0 }) { + this.PageHeader() + Scroll() { + Column({ space: 0 }) { + this.DeviceSummaryCard() + if (this.deviceModels.state.available) { + this.RoleSection() + } else { + this.UnavailableCard() + } + this.ScopeCard() + } + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 22, + bottom: this.insets.tailSpacing(34) + }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + /** + * The page header, in the one header language the sheet has: a quiet control on + * each side and the page's identity between them. Back is a real second level — + * this page was pushed from the settings list — so it sits next to the close + * that leaves the sheet entirely. + */ + @Builder + private PageHeader() { + Row() { + NavigationBackButton({ + isEnabled: !this.locked(), + onBack: (): void => { + this.sheetState.setPage('main'); + } + }) + Column({ space: 3 }) { + Text(RemoteI18n.t('settings.model.settingsTitle')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.deviceLabel()) + .width('100%') + .fontSize(MobileDesignTypography.headlineSmall.size) + .lineHeight(MobileDesignTypography.headlineSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + SheetCloseButton({ + isEnabled: !this.locked(), + onClose: this.onClose + }) + } + .width('100%') + .height(SHEET_HEADER_HEIGHT) + .padding({ left: SHEET_HEADER_SIDE_PADDING, right: SHEET_HEADER_SIDE_PADDING }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: LINE }) + } + + /** + * Which desktop these values belong to. This page edits a device, not a + * session, so the device is stated once at the top rather than repeated on + * every row. + */ + @Builder + private DeviceSummaryCard() { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(20) + .fontColor([INK]) + .opacity(0.9) + } + .width(28) + .height(28) + Column({ space: 2 }) { + Text(this.deviceLabel()) + .width('100%') + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.summaryStatus()) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .fontColor(this.connectionState === 'connected' ? STATUS_SUCCESS : MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.modelSourceRowHeight }) + .padding({ left: 18, right: 18, top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .margin({ bottom: 24 }) + } + + @Builder + private RoleSection() { + Column({ space: 0 }) { + if (!this.deviceModels.state.loaded) { + this.ReadingCard() + } else { + Text(RemoteI18n.t('settings.model.section')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .margin({ left: 8, bottom: 10 }) + Column() { + ForEach(ROLE_ORDER, (role: RemoteModelDefaultsRole) => { + this.RoleRow(role) + }, (role: RemoteModelDefaultsRole): string => role) + } + .width('100%') + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + if (this.deviceModels.state.error.length > 0 || this.deviceModels.feedbackVisible) { + this.WriteFeedback() + } + if (ModelRoleCandidatesPolicy.isEmptyCatalog(this.deviceModels.state.catalog)) { + this.EmptyCatalogCard() + } + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + } + + @Builder + private ReadingCard() { + Text(this.readingText()) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + /** + * One role slot. The row is the settings list's own row language — a leading + * mark, a two-line body and a trailing control — because these three rows sit + * on the same sheet as the permission rows above them. + * + * The mark states whether the role resolves to a model at all, the meta line + * states which one, and the row is the way into that role's picker. Nothing is + * written from here: the picker is the only place a model is chosen. + */ + @Builder + private RoleRow(role: RemoteModelDefaultsRole) { + Row({ space: 12 }) { + Stack({ alignContent: Alignment.Center }) { + if (this.roleBusy(role)) { + LoadingProgress() + .width(16) + .height(16) + .color(INK) + } else if (this.roleProjection(role).resolved) { + SymbolGlyph($r('sys.symbol.checkmark_circle_fill')) + .fontSize(19) + .fontColor([INK]) + .width(20) + .height(20) + } + } + .width(22) + .height(24) + Column({ space: 3 }) { + Text(RemoteI18n.t(ModelRoleCandidatesPolicy.roleLabelKey(role))) + .width('100%') + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.roleMeta(role)) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Row({ space: 4 }) { + if (this.roleUpdated(role)) { + Text(RemoteI18n.t('status.modelDefaultsUpdated')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(STATUS_SUCCESS) + .maxLines(1) + } + SymbolGlyph($r('sys.symbol.chevron_right')) + .fontSize(14) + .fontColor([MUTED]) + } + .alignItems(VerticalAlign.Center) + } + .width('100%') + .height(72) + .padding({ left: 18, right: 18 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(this.roleBusy(role) ? SOFT : TRANSPARENT) + .opacity(this.locked() ? 0.6 : 1) + .id(`device-model-role-${role}`) + .enabled(!this.locked()) + .onClick(() => { + this.sheetState.openModelPick(role); + }) + } + + /** + * What the write did, in the two shapes it has: the host's own rejection with a + * way to answer it again, or a confirmed write whose value has already been + * read back. Neither is a place to show the value that was asked for — that is + * the point of reading the desktop back. + */ + @Builder + private WriteFeedback() { + Row({ space: 10 }) { + if (this.deviceModels.state.error.length > 0) { + Text(this.deviceModels.state.error) + .layoutWeight(1) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_DANGER) + .maxLines(3) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(RemoteI18n.t('common.retry')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(14) + .id('device-model-retry') + .enabled(!this.locked()) + .onClick(() => { + void this.deviceModels.retry(); + }) + } else if (this.deviceModels.feedbackVisible) { + Text(RemoteI18n.f('settings.model.updatedResult', this.writtenValue())) + .layoutWeight(1) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_SUCCESS) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .id('device-model-updated') + } + } + .width('100%') + .padding({ left: 8, right: 8, top: 10 }) + .alignItems(VerticalAlign.Center) + } + + /** + * The desktop has no models at all. The rows stay where they are — the user + * came here for three slots and three slots are what the device has — and this + * card says what to do about it, because nothing on this page can add a model. + */ + @Builder + private EmptyCatalogCard() { + Column({ space: 6 }) { + SymbolGlyph($r('sys.symbol.desktop')) + .fontSize(26) + .fontColor([SUBTLE]) + Text(RemoteI18n.t('settings.model.emptyTitle')) + .fontSize(MobileDesignTypography.bodyLarge.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .textAlign(TextAlign.Center) + Text(RemoteI18n.t('settings.model.emptyText')) + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .textAlign(TextAlign.Center) + } + .width('100%') + .padding({ left: 18, right: 18, top: 26, bottom: 22 }) + .alignItems(HorizontalAlign.Center) + .margin({ top: 24 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + /** + * The page's one scope card, in every state it has: an older desktop is told + * why in place of the rows, and the rules that hold regardless are still true + * once it is upgraded. + */ + @Builder + private ScopeCard() { + Column({ space: 4 }) { + this.ScopeLine(RemoteI18n.t('settings.model.scopeNewSessions')) + this.ScopeLine(RemoteI18n.t('settings.model.scopeFastFollowsPrimary')) + this.ScopeLine(RemoteI18n.t('settings.model.scopeImageUnderstanding')) + } + .width('100%') + .padding({ left: 18, right: 18, top: 14, bottom: 14 }) + .alignItems(HorizontalAlign.Start) + .margin({ top: 24 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + @Builder + private ScopeLine(text: string) { + Text(text) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + } + + /** + * The desktop cannot answer this page. The entry that led here is greyed for + * the same reason, but the page is reachable from a stale capability set too, + * so it states the reason itself — the controller already decided which of the + * two it is, and this card does not re-judge it. + */ + @Builder + private UnavailableCard() { + Text(this.deviceModels.state.error.length > 0 ? this.deviceModels.state.error : + RemoteI18n.t('status.remoteUnavailable')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_DANGER) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + } + + private deviceLabel(): string { + return this.desktopName.length > 0 ? this.desktopName : this.desktopId; + } + + /** The desktop this page belongs to, and how the phone reached it. */ + private summaryStatus(): string { + const presence = this.connectionState === 'connected' ? + RemoteI18n.t('connect.connected') : RemoteI18n.t('connect.disconnected'); + const source = this.controlTargetType === 'account_device' ? + RemoteI18n.t('settings.modelService.accountSource') : + RemoteI18n.t('settings.modelService.localSource'); + return `${presence} · ${source}`; + } + + private readingText(): string { + const status = this.deviceModels.state.statusText; + return status.length > 0 ? status : RemoteI18n.t('status.loadingModelDefaults'); + } + + /** A write in flight owns the page: both header controls and every row wait for it. */ + private locked(): boolean { + return this.deviceModels.state.busy; + } + + private deviceModelState(): RemoteModelDefaultsState { + return this.deviceModels.state; + } + + private roleProjection(role: RemoteModelDefaultsRole): ModelRoleRowProjection { + return ModelRoleCandidatesPolicy.roleRow( + this.deviceModelState().roles, role, this.deviceModelState().catalog); + } + + private roleBusy(role: RemoteModelDefaultsRole): boolean { + return this.locked() && this.deviceModels.pendingRole === role; + } + + /** True while the just-written role row carries the transient result word. */ + private roleUpdated(role: RemoteModelDefaultsRole): boolean { + return this.deviceModels.feedbackVisible && this.deviceModels.pendingRole === role; + } + + /** + * The role's value line: the model the desktop returned, or the placeholder + * for a role that has none, with its fallback hint. A model the desktop holds + * but no longer lists is shown by id — the value is real even when the catalog + * cannot describe it. + */ + private roleMeta(role: RemoteModelDefaultsRole): string { + const base = this.roleValue(role); + const projection = this.roleProjection(role); + if (projection.hintKey.length > 0) { + return `${base} · ${RemoteI18n.t(projection.hintKey)}`; + } + return base; + } + + private roleValue(role: RemoteModelDefaultsRole): string { + const projection = this.roleProjection(role); + if (projection.model) { + return ConversationModelPresentationPolicy.roleMetaLabel( + toConversationUiModel(projection.model), projection.modelId); + } + if (projection.modelId.length > 0) { + return projection.modelId; + } + return RemoteI18n.t(projection.unsetKey); + } + + /** The value the confirmed write left behind, read back from the device. */ + private writtenValue(): string { + const role = this.deviceModels.pendingRole; + return role === '' ? RemoteI18n.t('status.modelDefaultsUpdated') : this.roleValue(role); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets new file mode 100644 index 0000000000..0bb1d0b060 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets @@ -0,0 +1,348 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { RemoteModelConfig } from '../../model/RemoteModels'; +import { RemoteModelDefaultsRole } from '../../services/RemoteModelDefaultsController'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; +import { ModelRoleCandidatesPolicy } from '../policy/ModelRoleCandidatesPolicy'; +import { toConversationUiModel } from '../state/ConversationUiModels'; +import { DeviceModelDefaultsSheetState } from '../state/DeviceModelDefaultsSheetState'; +import { SettingsSheetState } from '../state/SettingsSheetState'; +import { NavigationBackButton } from './NavigationBackButton'; +import { SheetCloseButton } from './SheetCloseButton'; +import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING, SHEET_HORIZONTAL_PADDING } from './SheetLayout'; +import { CARD, INK, LINE, MUTED, PAGE_BG, SOFT, TRANSPARENT } from './Theme'; + +/** + * The role picker: the second level of the sheet's model settings stack, for one + * role at a time. + * + * Candidates are the desktop's own catalog, filtered to what the role can + * actually run and kept in the order the desktop reports, so the list here and + * the list on the desktop describe the same device. The pinned row is the "no + * model" value, which is a value: for `fast` it means "follow the primary" and + * for the multimodal slot it means the capability is off. The primary model has + * no such row — it is the one slot the desktop must always be able to resolve. + * + * Selecting does not draw the selection: it asks the controller to write and + * leaves for the settings page, which then shows the value the desktop read + * back. A picker that checked the row the user tapped would be promising a + * configuration that may have been repaired while it was stored. + */ +@ComponentV2 +export struct ModelRolePickerPanel { + @Param sheetState: SettingsSheetState = new SettingsSheetState(); + @Param deviceModels: DeviceModelDefaultsSheetState = new DeviceModelDefaultsSheetState(); + @Param desktopName: string = ''; + @Param desktopId: string = ''; + @Event onClose: () => void = () => {}; + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + // The picker never opens before the page that pushes it, but it can be the + // first thing a restored sheet shows: reading here keeps that state answered. + void this.deviceModels.ensureLoaded(); + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + + build() { + Column({ space: 0 }) { + this.PageHeader() + Scroll() { + Column({ space: 0 }) { + this.FilterLine() + if (this.allowsUnset()) { + this.UnsetRow() + } + ForEach(this.candidates(), (model: RemoteModelConfig) => { + this.CandidateRow(model) + }, (model: RemoteModelConfig): string => model.id) + if (this.candidates().length === 0) { + this.NoCandidatesLine() + } + } + .width('100%') + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 12, + bottom: this.insets.tailSpacing(34) + }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + /** Same header language as the page below it: back one level, close the sheet. */ + @Builder + private PageHeader() { + Row() { + NavigationBackButton({ + isEnabled: !this.locked(), + onBack: (): void => { + this.sheetState.backFromModelPick(); + } + }) + Column({ space: 3 }) { + Text(RemoteI18n.t('settings.model.settingsTitle')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.roleLabel()) + .width('100%') + .fontSize(MobileDesignTypography.headlineSmall.size) + .lineHeight(MobileDesignTypography.headlineSmall.lineHeight) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + SheetCloseButton({ + isEnabled: !this.locked(), + onClose: this.onClose + }) + } + .width('100%') + .height(SHEET_HEADER_HEIGHT) + .padding({ left: SHEET_HEADER_SIDE_PADDING, right: SHEET_HEADER_SIDE_PADDING }) + .alignItems(VerticalAlign.Center) + .border({ width: { bottom: 1 }, color: LINE }) + } + + /** + * Why the list is as short as it is. Filtering silently is what makes a user + * think the device lost its models, so the rule is stated above the rows + * together with the device it applies to. + */ + @Builder + private FilterLine() { + Text(RemoteI18n.f(ModelRoleCandidatesPolicy.filterKey(this.role()), this.deviceLabel())) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 4, right: 4 }) + .margin({ bottom: 12 }) + } + + /** + * The "no model" value of this role, pinned above the candidates and separated + * from them, because it is not one of them: it is the state of having none. + */ + @Builder + private UnsetRow() { + Column({ space: 0 }) { + Row({ space: 10 }) { + this.SelectionMark(ModelRoleCandidatesPolicy.isUnsetSelected( + this.deviceModels.state.roles, this.role())) + Column({ space: 2 }) { + Text(RemoteI18n.t(ModelRoleCandidatesPolicy.unsetLabelKey(this.role()))) + .width('100%') + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(RemoteI18n.t(ModelRoleCandidatesPolicy.unsetHintKey(this.role()))) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.modelAccountRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(ModelRoleCandidatesPolicy.isUnsetSelected( + this.deviceModels.state.roles, this.role()) ? SOFT : TRANSPARENT) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) + .id('device-model-pick-unset') + .enabled(!this.locked()) + .opacity(this.locked() ? 0.6 : 1) + .onClick(() => { + this.select(null); + }) + // The pinned value is a different kind of answer from a model, so the list + // says so with a rule instead of letting it read as the first candidate. + if (this.candidates().length > 0) { + Divider() + .strokeWidth(1) + .color(LINE) + .margin({ top: 6, bottom: 6 }) + } + } + .width('100%') + } + + /** One candidate model: what it is called, what it runs on, and its capability. */ + @Builder + private CandidateRow(model: RemoteModelConfig) { + Row({ space: 10 }) { + this.SelectionMark(ModelRoleCandidatesPolicy.isCandidateSelected( + this.deviceModels.state.roles, this.role(), model.id)) + Column({ space: 2 }) { + Row({ space: 6 }) { + Text(this.modelLabel(model)) + .layoutWeight(1) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.showsCapabilityBadge()) { + Text(RemoteI18n.t('settings.model.capabilityImageUnderstanding')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontColor(MUTED) + .padding({ left: 6, right: 6, top: 1, bottom: 1 }) + .backgroundColor(SOFT) + .borderRadius(4) + } + } + .width('100%') + Text(this.modelMeta(model)) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .constraintSize({ minHeight: MobileDesignGeometry.modelAccountRowHeight }) + .padding({ left: 10, right: 10, top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + .backgroundColor(ModelRoleCandidatesPolicy.isCandidateSelected( + this.deviceModels.state.roles, this.role(), model.id) ? SOFT : TRANSPARENT) + .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) + .id(`device-model-pick-${model.id}`) + .enabled(!this.locked()) + .opacity(this.locked() ? 0.6 : 1) + .onClick(() => { + this.select(model.id); + }) + } + + /** + * A candidate row's leading mark. The outline glyph is deliberate: every row + * would carry a check if the filled one were used, and the selected row is + * already told apart by its fill, so the mark only has to point at the choice + * the role currently holds. + */ + @Builder + private SelectionMark(selected: boolean) { + Stack({ alignContent: Alignment.Center }) { + if (selected) { + SymbolGlyph($r('sys.symbol.checkmark_circle')) + .fontSize(16) + .fontColor([INK]) + } + } + .width(20) + .height(20) + } + + /** + * No model on this device can serve the role. The pinned row above already + * says what the role is left at, so this line only states that the emptiness + * is the device's answer rather than a failed read. + */ + @Builder + private NoCandidatesLine() { + Text(RemoteI18n.t('settings.model.pickEmpty')) + .width('100%') + .fontSize(MobileDesignTypography.labelSmall.size) + .lineHeight(MobileDesignTypography.labelSmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 10, right: 10, top: 10, bottom: 10 }) + } + + /** + * The two labels a candidate row carries. The catalog is the protocol's own + * shape, so it is projected through the shared presentation mapper before the + * shared label policy reads it — one boundary, one naming rule. + */ + private modelLabel(model: RemoteModelConfig): string { + return ConversationModelPresentationPolicy.primaryLabel( + toConversationUiModel(model), RemoteI18n.t('chat.model')); + } + + private modelMeta(model: RemoteModelConfig): string { + return ConversationModelPresentationPolicy.modelContextLabel(toConversationUiModel(model)); + } + + private role(): RemoteModelDefaultsRole { + const role = this.sheetState.pickRole; + if (role === 'primary' || role === 'fast' || role === 'image_understanding') { + return role; + } + return 'primary'; + } + + /** The picker knows which slot it is editing; without one it must not write. */ + private hasRole(): boolean { + return this.role() === this.sheetState.pickRole; + } + + private allowsUnset(): boolean { + return ModelRoleCandidatesPolicy.allowsUnset(this.role()); + } + + private showsCapabilityBadge(): boolean { + return ModelRoleCandidatesPolicy.showsCapabilityBadge(this.role()); + } + + private candidates(): RemoteModelConfig[] { + return ModelRoleCandidatesPolicy.candidatesForRole(this.role(), this.deviceModels.state.catalog); + } + + private locked(): boolean { + return this.deviceModels.state.busy; + } + + private roleLabel(): string { + return RemoteI18n.t(ModelRoleCandidatesPolicy.roleLabelKey(this.role())); + } + + private deviceLabel(): string { + return this.desktopName.length > 0 ? this.desktopName : this.desktopId; + } + + /** + * Writes the role and returns to the page that shows the result. The write is + * not awaited here: the settings page is what reports it, and it is the page + * the user is left looking at. + */ + private select(modelId: string | null): void { + if (this.locked() || !this.hasRole()) { + return; + } + void this.deviceModels.selectRole(this.role(), modelId); + this.sheetState.backFromModelPick(); + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets index 15addbcd98..fbbe311a8d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/SettingsSheet.ets @@ -1,9 +1,12 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { SettingsSheetState } from '../state/SettingsSheetState'; -import { CARD, INK, LINE, MUTED, PAGE_BG, STATUS_DANGER } from './Theme'; +import { DeviceModelDefaultsRequestPath } from '../state/DeviceModelDefaultsSheetState'; +import { CARD, INK, LINE, MUTED, PAGE_BG } from './Theme'; import { CurrentControlSection } from './CurrentControlSection'; import { AccountProfilePanel } from './AccountProfilePanel'; +import { DeviceModelDefaultsPanel } from './DeviceModelDefaultsPanel'; +import { ModelRolePickerPanel } from './ModelRolePickerPanel'; import { LanguageSettingsPanel } from './LanguageSettingsPanel'; import { SheetCloseHeader } from './SheetCloseHeader'; import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; @@ -69,18 +72,66 @@ export struct SettingsSheet { if (context) { this.insets.bind(this.getUIContext(), context); } + // The device model pages are answered by the sheet, not by themselves: the + // desktop they read and write is whichever one this sheet is open on, so the + // request path is bound here before either page can mount. + const modelDefaultsClient: DeviceModelDefaultsRequestPath = { + getModelCatalog: (): Promise => this.getDeviceModelCatalog(), + setModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + return this.setDeviceModelDefaults(primary, fast, imageUnderstanding); + } + }; + this.sheetState.deviceModels.bind(modelDefaultsClient); + this.applyDeviceModelGate(); } aboutToDisappear(): void { this.insets.unbind(); } + /** + * Re-gates the device model pages whenever the desktop, the link or its + * capability set changes. + * + * Arming the gate is not reading: the page spends the read when it opens, so a + * sheet that is opened for the language setting does not ask the desktop about + * its models. A gate that changes while one of the two pages is already open is + * the other case — what is on screen belongs to the previous desktop — so that + * one is read again immediately. + */ + private applyDeviceModelGate(): void { + const needsRead = this.sheetState.deviceModels.setGate({ + supported: this.supportsDeviceModelDefaults, + connected: this.connectionState === 'connected', + deviceId: this.controlTargetDeviceId + }); + if (needsRead && this.onDeviceModelPage()) { + void this.sheetState.deviceModels.load(); + } + } + + private onDeviceModelPage(): boolean { + const page = this.sheetState.page; + return page === 'deviceModels' || page === 'modelPick'; + } + + @Monitor('connectionState', 'supportsDeviceModelDefaults', 'controlTargetDeviceId') + private onDeviceModelTargetChanged(): void { + this.applyDeviceModelGate(); + } + build() { Stack({ alignContent: Alignment.TopEnd }) { if (this.sheetState.page === 'language') { this.LanguagePanel() } else if (this.sheetState.page === 'deviceModels') { - this.DeviceModelsPlaceholder() + this.DeviceModelSettingsPage() + } else if (this.sheetState.page === 'modelPick') { + this.ModelRolePickerPage() } else { this.SettingsHome() } @@ -305,55 +356,31 @@ export struct SettingsSheet { } /** - * Device model role settings, reached from the connected device row. - * - * TODO(model-roles-pages): the device model defaults panel and the role picker - * page of this work package replace this placeholder. It exists so the entry - * navigates to a real destination, the sheet keeps one page model, and the - * degrade path stays visible, without pretending the pages are finished. + * Device model role settings, reached from the connected device row: the second + * level of the sheet's own page stack. */ @Builder - DeviceModelsPlaceholder() { - Column() { - SheetCloseHeader({ - title: RemoteI18n.t('settings.model.settingsTitle'), - showDivider: true, - onClose: () => { - this.sheetState.setPage('main'); - } - }) - - Scroll() { - Column({ space: 0 }) { - this.SectionTitle(RemoteI18n.t('settings.model.section')) - Text(this.deviceModelsPlaceholderText()) - .width('100%') - .fontSize(MobileDesignTypography.bodySmall.size) - .lineHeight(MobileDesignTypography.bodySmall.lineHeight) - .fontColor(this.supportsDeviceModelDefaults ? MUTED : STATUS_DANGER) - .padding({ left: 8, right: 8 }) - } - .width('100%') - .padding({ - left: SHEET_HORIZONTAL_PADDING, - right: SHEET_HORIZONTAL_PADDING, - top: 22, - bottom: this.insets.tailSpacing(34) - }) - } - .width('100%') - .layoutWeight(1) - .scrollBar(BarState.Off) - } - .width('100%') - .height('100%') + DeviceModelSettingsPage() { + DeviceModelDefaultsPanel({ + sheetState: this.sheetState, + deviceModels: this.sheetState.deviceModels, + desktopName: this.desktopName, + desktopId: this.desktopId, + controlTargetType: this.controlTargetType, + connectionState: this.connectionState, + onClose: this.onClose + }) } - private deviceModelsPlaceholderText(): string { - if (!this.supportsDeviceModelDefaults) { - return RemoteI18n.t('settings.model.unsupported'); - } - const device = this.desktopName || this.desktopId; - return `${RemoteI18n.t('settings.model.default')} · ${device}`; + /** The role picker pushed above it; both pages share the sheet's controller. */ + @Builder + ModelRolePickerPage() { + ModelRolePickerPanel({ + sheetState: this.sheetState, + deviceModels: this.sheetState.deviceModels, + desktopName: this.desktopName, + desktopId: this.desktopId, + onClose: this.onClose + }) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index bc5254e0dc..10b0bdd4f8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -616,6 +616,14 @@ "name": "settings_model_updatedResult", "value": "Updated to %1$s · Re-read the current values on this device" }, + { + "name": "settings_model_imageUnderstandingUnset", + "value": "Without a multimodal model, image understanding is unavailable" + }, + { + "name": "settings_model_pickEmpty", + "value": "No models on this device match this role yet" + }, { "name": "settings_devices_section", "value": "Devices" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index 9772833913..866b96d156 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -616,6 +616,14 @@ "name": "settings_model_updatedResult", "value": "已更新为 %1$s · 已重新读取该设备上的当前值" }, + { + "name": "settings_model_imageUnderstandingUnset", + "value": "未指定多模态模型时,图片理解相关能力不可用" + }, + { + "name": "settings_model_pickEmpty", + "value": "该设备暂无可用于该角色的模型" + }, { "name": "settings_devices_section", "value": "设备" From 059fde99382ab93cdfaf4db7ae095bf06d59a606 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:35:47 +0800 Subject: [PATCH 08/11] test(mobile): cover the device model pages' rules Runs the candidate policy and the sheet state for real over a recorded desktop: which models each role may take and in whose order, what the pinned no-model row means per role, what a role row says, the write lock and the read-back, the refusal and its retry, the stale write a closed sheet drops, and the sheet's two-level page stack. The pages' own tripwires keep the placeholder gone, the refresh button absent, and every scenario id reachable. --- .../tools/tests/model-role-pages.test.cjs | 563 ++++++++++++++++++ 1 file changed, 563 insertions(+) create mode 100644 src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs diff --git a/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs b/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs new file mode 100644 index 0000000000..3060e54890 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs @@ -0,0 +1,563 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '../..'); +const etsRoot = path.join(root, 'entry/src/main/ets'); + +// The two device-model pages are one workflow, and most of what they promise is +// decided before ArkUI sees anything: which models a role may be set to, what a +// role row says, when a write is allowed, and what the sheet reports afterwards. +// Those rules are plain classes and pure functions, so they are run here for +// real. The decorators are the platform's binding rather than part of the rules, +// so they come off before transpiling. +function load(relativePath, resolve) { + const source = fs.readFileSync(path.join(etsRoot, relativePath), 'utf8') + .replace(/@ObservedV2\s*/g, '').replace(/@Trace\s*/g, ''); + const js = ts.transpileModule(source, { + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS} + }).outputText; + const exported = {}; + new Function('require', 'exports', js)(name => (resolve ? resolve(name) : undefined) || {}, exported); + return exported; +} + +function source(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +const emptyCatalog = () => ({version: 0, models: [], default_models: {}}); + +function controllerModule() { + return load('services/RemoteModelDefaultsController.ets', name => + name.endsWith('RemoteI18n') ? {RemoteI18n: {t: key => key}} : + name.endsWith('RemoteUiState') ? {RemoteUiState: {emptyModelCatalog: () => emptyCatalog()}} : {}); +} + +function policyModule() { + return load('pages/policy/ModelRoleCandidatesPolicy.ets'); +} + +function sheetStateModule() { + const controller = controllerModule(); + const policy = policyModule(); + return load('pages/state/DeviceModelDefaultsSheetState.ets', name => + name.endsWith('RemoteModelDefaultsController') ? controller : + name.endsWith('RemoteUiState') ? {RemoteUiState: {emptyModelCatalog: () => emptyCatalog()}} : + name.endsWith('ModelRoleCandidatesPolicy') ? policy : {}); +} + +function settingsSheetStateModule() { + const models = sheetStateModule(); + return load('pages/state/SettingsSheetState.ets', name => + name.endsWith('DeviceModelDefaultsSheetState') ? models : {}); +} + +const catalog = (defaults = {}) => ({ + version: 3, + models: [ + {id: 'm-primary', name: 'Primary', provider: 'openai', base_url: '', model_name: 'm-primary', enabled: true, capabilities: ['text_chat']}, + { + id: 'm-vision', name: 'Vision', provider: 'openai', base_url: '', model_name: 'm-vision', + enabled: true, capabilities: ['text_chat', 'image_understanding'] + } + ], + default_models: defaults +}); + +const catalogOf = models => ({version: 1, models, default_models: {}}); +const ids = models => models.map(model => model.id); +const gate = (supported, connected, deviceId) => ({supported, connected, deviceId}); +const flush = () => new Promise(resolve => setImmediate(resolve)); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function deferred() { + let resolve; + let reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return {promise, resolve, reject}; +} + +/** The sheet state over one fixture desktop, with the reads and writes recorded. */ +function sheetWith(client, feedbackDurationMs = 2000) { + const {DeviceModelDefaultsSheetState} = sheetStateModule(); + const sheet = new DeviceModelDefaultsSheetState(feedbackDurationMs); + sheet.bind(client); + return sheet; +} + +// --------------------------------------------------------------------------- +// Candidates +// --------------------------------------------------------------------------- + +test('the picker offers enabled, capable models in the catalog own order', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const models = [ + {id: 'm-first', enabled: true, capabilities: ['text_chat']}, + {id: 'm-disabled', enabled: false, capabilities: ['text_chat', 'image_understanding']}, + {id: 'm-both', enabled: true, capabilities: ['text_chat', 'image_understanding']}, + {id: 'm-vision', enabled: true, capabilities: ['image_understanding']}, + {id: 'm-plain', enabled: true, capabilities: []}, + {id: 'm-uncapable', enabled: true} + ]; + const catalog = catalogOf(models); + + // A disabled model is not a candidate at all, and neither is one without the + // capability the role needs: the desktop would refuse both writes. + assert.deepEqual(ids(policy.textChatCandidates(catalog)), ['m-first', 'm-both']); + assert.deepEqual(ids(policy.imageUnderstandingCandidates(catalog)), ['m-both', 'm-vision']); + // Order is the desktop's, not a local sort: this list and the desktop's own + // model list have to describe the same device. + assert.deepEqual(ids(policy.textChatCandidates(catalog)), ids(models.filter(model => + model.enabled && (model.capabilities || []).indexOf('text_chat') >= 0))); + + assert.deepEqual(ids(policy.candidatesForRole('primary', catalog)), ['m-first', 'm-both']); + assert.deepEqual(ids(policy.candidatesForRole('fast', catalog)), ['m-first', 'm-both']); + assert.deepEqual(ids(policy.candidatesForRole('image_understanding', catalog)), ['m-both', 'm-vision']); + + assert.deepEqual(policy.textChatCandidates(emptyCatalog()), []); + assert.deepEqual(policy.imageUnderstandingCandidates(emptyCatalog()), []); + assert.equal(policy.isEmptyCatalog(emptyCatalog()), true); + assert.equal(policy.isEmptyCatalog(catalog), false); +}); + +test('the pinned row is the role own "no model" value, and only the primary has none', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + + // The primary model is required: a phone that cleared it would leave the + // desktop with nothing to run. + assert.equal(policy.allowsUnset('primary'), false); + assert.equal(policy.allowsUnset('fast'), true); + assert.equal(policy.allowsUnset('image_understanding'), true); + + assert.equal(policy.unsetLabelKey('fast'), 'chat.modelFollowPrimary'); + assert.equal(policy.unsetLabelKey('image_understanding'), 'settings.modelService.notConfigured'); + assert.equal(policy.unsetHintKey('fast'), 'chat.modelFastNotConfigured'); + assert.equal(policy.unsetHintKey('image_understanding'), 'settings.model.imageUnderstandingUnset'); + assert.equal(policy.unsetHintKey('primary'), ''); + + const roles = {primary: 'm-primary', fast: null, image_understanding: 'm-vision'}; + assert.equal(policy.isUnsetSelected(roles, 'fast'), true); + assert.equal(policy.isUnsetSelected(roles, 'image_understanding'), false); + // The primary can never be "unset", so its picker has no such row to check. + assert.equal(policy.isUnsetSelected(roles, 'primary'), false); + assert.equal(policy.isCandidateSelected(roles, 'image_understanding', 'm-vision'), true); + assert.equal(policy.isCandidateSelected(roles, 'image_understanding', 'm-both'), false); + assert.equal(policy.isCandidateSelected(roles, 'fast', 'm-primary'), false); + + // The capability badge explains one list only; elsewhere it would widen rows. + assert.equal(policy.showsCapabilityBadge('image_understanding'), true); + assert.equal(policy.showsCapabilityBadge('fast'), false); + assert.equal(policy.showsCapabilityBadge('primary'), false); + assert.equal(policy.roleLabelKey('image_understanding'), 'chat.modelImage'); + assert.equal(policy.filterKey('image_understanding'), 'settings.model.filterImageUnderstanding'); + assert.equal(policy.filterKey('fast'), 'settings.model.filterTextChat'); + assert.equal(policy.filterKey('primary'), 'settings.model.filterTextChat'); +}); + +// --------------------------------------------------------------------------- +// Role rows +// --------------------------------------------------------------------------- + +test('a role row states the value the device returned and the fallback it uses', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const roles = {primary: 'm-primary', fast: null, image_understanding: null}; + const fixture = catalog({primary: 'm-primary', fast: null, image_understanding: null}); + + const primary = policy.roleRow(roles, 'primary', fixture); + assert.equal(primary.modelId, 'm-primary'); + assert.equal(primary.model.id, 'm-primary'); + assert.equal(primary.unset, false); + assert.equal(primary.unsetKey, ''); + assert.equal(primary.resolved, true); + + // An empty fast slot follows the primary, and says so in those words. + const fast = policy.roleRow(roles, 'fast', fixture); + assert.equal(fast.unset, true); + assert.equal(fast.unsetKey, 'chat.modelFollowPrimary'); + assert.equal(fast.hintKey, 'chat.modelFastNotConfigured'); + assert.equal(fast.resolved, true); + + // The multimodal slot has no fallback: an empty one means the capability is + // off, not borrowed from another role. + const image = policy.roleRow(roles, 'image_understanding', fixture); + assert.equal(image.unsetKey, 'settings.modelService.notConfigured'); + assert.equal(image.hintKey, 'settings.model.imageUnderstandingUnset'); + assert.equal(image.resolved, false); + assert.equal(policy.resolvedModel(roles, 'image_understanding', fixture), undefined); + + // A value the catalog no longer lists keeps its id: the desktop holds it, and + // hiding it behind "not configured" would misreport the device. + const orphan = policy.roleRow({primary: 'm-gone'}, 'primary', fixture); + assert.equal(orphan.modelId, 'm-gone'); + assert.equal(orphan.model, undefined); + assert.equal(orphan.unset, false); + assert.equal(orphan.resolved, false); + + // A disabled model is not a resolved one either. + const disabled = catalogOf([{id: 'm-off', enabled: false, capabilities: ['text_chat']}]); + assert.equal(policy.roleRow({primary: 'm-off'}, 'primary', disabled).resolved, false); + + // With no models at all every row says unconfigured and none promises a + // fallback, because there is no primary to follow. + for (const role of ['primary', 'fast', 'image_understanding']) { + const empty = policy.roleRow(roles, role, emptyCatalog()); + assert.equal(empty.unset, true, role); + assert.equal(empty.unsetKey, 'settings.modelService.notConfigured', role); + assert.equal(empty.hintKey, '', role); + assert.equal(empty.resolved, false, role); + } +}); + +// --------------------------------------------------------------------------- +// Page state: gating, the write, the read-back, the feedback +// --------------------------------------------------------------------------- + +test('the gate is armed when the sheet opens and spent when a page opens', async () => { + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { reads += 1; return catalog({primary: 'm-primary'}); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), true); + // Arming is not reading: a sheet opened for another setting asks nothing. + assert.equal(reads, 0); + assert.equal(sheet.state.available, true); + assert.equal(sheet.state.loaded, false); + + await sheet.ensureLoaded(); + await sheet.ensureLoaded(); + assert.equal(reads, 1); + assert.equal(sheet.state.loaded, true); + assert.equal(sheet.state.roles.primary, 'm-primary'); + + // The same gate is not a reset, so what is on screen survives a rebuild. + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), false); + await sheet.ensureLoaded(); + assert.equal(reads, 1); + + // Another desktop is a different answer, so the page drops what it had. + assert.equal(sheet.setGate(gate(true, true, 'desk-2')), true); + assert.equal(sheet.state.loaded, false); + assert.equal(sheet.state.available, true); +}); + +test('an older desktop and a dropped link are stated, not read', async () => { + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { reads += 1; return catalog(); }, + setModelDefaults: async () => { throw new Error('write is not expected here'); } + }); + + assert.equal(sheet.setGate(gate(false, true, 'desk-1')), true); + await sheet.ensureLoaded(); + await sheet.selectRole('primary', 'm-primary'); + assert.equal(reads, 0); + assert.equal(sheet.state.available, false); + assert.equal(sheet.state.error, 'settings.model.unsupported'); + // Nothing was written and nothing claims it was. + assert.equal(sheet.feedbackVisible, false); + + assert.equal(sheet.setGate(gate(true, false, 'desk-1')), true); + assert.equal(sheet.state.available, false); + assert.equal(sheet.state.error, 'status.remoteUnavailable'); +}); + +test('a write locks the pages, sends every slot, and reports the read-back', async () => { + const write = deferred(); + const writes = []; + let stored = {primary: 'm-primary', fast: null, image_understanding: null}; + const sheet = sheetWith({ + getModelCatalog: async () => catalog(stored), + setModelDefaults: async (primary, fast, image) => { + writes.push([primary, fast, image]); + stored = {primary, fast, image_understanding: image}; + return write.promise; + } + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + const pending = sheet.selectRole('fast', 'm-vision'); + assert.equal(sheet.state.busy, true); + assert.equal(sheet.feedbackVisible, false); + assert.deepEqual(writes, [['m-primary', 'm-vision', null]]); + assert.equal(sheet.pendingRole, 'fast'); + assert.equal(sheet.pendingModelId, 'm-vision'); + + // A second tap while the first write is out cannot overwrite the intent the + // retry and the result line are read from. + await sheet.selectRole('fast', 'm-primary'); + assert.equal(writes.length, 1); + assert.equal(sheet.pendingModelId, 'm-vision'); + + write.resolve({}); + await pending; + assert.equal(sheet.state.busy, false); + assert.equal(sheet.state.roles.fast, 'm-vision'); + assert.equal(sheet.feedbackVisible, true); + await wait(40); + assert.equal(sheet.feedbackVisible, false); +}); + +test('selecting the value a role already holds writes nothing and says nothing', async () => { + let writes = 0; + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary', fast: 'm-vision'}), + setModelDefaults: async () => { writes += 1; return {}; } + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + await sheet.selectRole('primary', 'm-primary'); + await sheet.selectRole('fast', 'm-vision'); + assert.equal(writes, 0); + assert.equal(sheet.feedbackVisible, false); + assert.equal(sheet.state.busy, false); +}); + +test('a rejected write keeps the host error verbatim and retries the same write', async () => { + const writes = []; + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async (primary, fast, image) => { + writes.push([primary, fast, image]); + if (writes.length === 1) { + throw new Error('Unknown model id: m-missing'); + } + return {}; + } + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + await sheet.selectRole('primary', 'm-missing'); + assert.equal(sheet.state.error, 'Unknown model id: m-missing'); + assert.equal(sheet.state.failure, 'write'); + assert.equal(sheet.feedbackVisible, false); + // The value never moved: the write was refused, so the device still holds it. + assert.equal(sheet.state.roles.primary, 'm-primary'); + assert.deepEqual(writes, [['m-missing', null, null]]); + + await sheet.retry(); + assert.equal(writes.length, 2); + assert.deepEqual(writes[1], ['m-missing', null, null]); + assert.equal(sheet.state.error, ''); + assert.equal(sheet.feedbackVisible, true); +}); + +test('a read that fails after an accepted write is retried as a read', async () => { + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { + reads += 1; + if (reads === 2) { + throw new Error('Remote transport is not connected.'); + } + return catalog({primary: reads === 1 ? 'm-primary' : 'm-vision'}); + }, + setModelDefaults: async () => ({}) + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + await sheet.selectRole('primary', 'm-vision'); + assert.equal(sheet.state.failure, 'load'); + assert.equal(sheet.state.error, 'Remote transport is not connected.'); + assert.equal(sheet.feedbackVisible, false); + assert.equal(sheet.state.roles.primary, 'm-primary'); + + await sheet.retry(); + assert.equal(reads, 3); + assert.equal(sheet.state.error, ''); + assert.equal(sheet.state.roles.primary, 'm-vision'); + // A re-read confirms nothing new was written, so it does not claim an update. + assert.equal(sheet.feedbackVisible, false); +}); + +test('a write that lands after the sheet closed is dropped', async () => { + const write = deferred(); + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => write.promise + }, 5); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + const pending = sheet.selectRole('primary', 'm-vision'); + sheet.reset(); + write.resolve({primary: 'm-vision'}); + await pending; + assert.equal(sheet.state.available, false); + assert.equal(sheet.state.busy, false); + assert.equal(sheet.feedbackVisible, false); + assert.deepEqual(sheet.state.roles, {primary: null, fast: null, image_understanding: null}); +}); + +test('the page starts from the controller own unavailable snapshot', () => { + const {DeviceModelDefaultsSheetState, emptyModelDefaultsState} = sheetStateModule(); + const {RemoteModelDefaultsController} = controllerModule(); + const controller = new RemoteModelDefaultsController({ + getModelCatalog: async () => emptyCatalog(), + setModelDefaults: async () => ({}) + }, {onStateChanged: () => {}}); + // The copy the state object carries as its initial value has to stay the shape + // the controller publishes, or a page mounted before `bind` renders nonsense. + assert.deepEqual(emptyModelDefaultsState(), controller.getState()); + assert.deepEqual(new DeviceModelDefaultsSheetState().state, controller.getState()); + assert.equal(DeviceModelDefaultsSheetState.name, 'DeviceModelDefaultsSheetState'); +}); + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +test('the sheet stacks main into the model pages and closes back to the top', () => { + const {SettingsSheetState} = settingsSheetStateModule(); + const state = new SettingsSheetState(); + + assert.equal(state.page, 'main'); + assert.equal(state.pickRole, ''); + state.setPage('deviceModels'); + assert.equal(state.page, 'deviceModels'); + + state.openModelPick('fast'); + assert.equal(state.page, 'modelPick'); + assert.equal(state.pickRole, 'fast'); + + // Back returns exactly one level, to the page that pushed the picker. + state.backFromModelPick(); + assert.equal(state.page, 'deviceModels'); + assert.equal(state.pickRole, ''); + + // Any other route out of the picker disarms the role, so a page rebuilt from + // the wrong level cannot edit someone else's slot. + state.openModelPick('image_understanding'); + state.setPage('main'); + assert.equal(state.pickRole, ''); + state.openModelPick('image_understanding'); + state.setPage('deviceModels'); + assert.equal(state.pickRole, ''); + + state.openModelPick('primary'); + state.resetOnClose(); + assert.equal(state.page, 'main'); + assert.equal(state.pickRole, ''); + assert.equal(state.deviceModels.state.available, false); + assert.equal(state.deviceModels.state.loaded, false); +}); + +// --------------------------------------------------------------------------- +// Wiring the app has to keep +// --------------------------------------------------------------------------- + +test('the sheet routes both model pages and keeps no placeholder', () => { + const settingsSheet = source('entry/src/main/ets/pages/components/SettingsSheet.ets'); + + assert.equal(settingsSheet.includes('DeviceModelsPlaceholder'), false, + 'the placeholder page has to be gone once the real pages exist'); + assert.equal(settingsSheet.includes('deviceModelsPlaceholderText'), false, + 'the placeholder copy has to be gone with it'); + assert.match(settingsSheet, /page === 'deviceModels'/, 'the settings page must stay a page of the sheet'); + assert.match(settingsSheet, /page === 'modelPick'/, 'the picker must be a page of the same sheet'); + assert.match(settingsSheet, /this\.sheetState\.deviceModels\.bind\(/, 'the sheet owns the request path of the pages'); + assert.match(settingsSheet, /@Monitor\('connectionState', 'supportsDeviceModelDefaults', 'controlTargetDeviceId'\)/, + 'the gate has to follow the desktop, the link and its capability set'); +}); + +test('neither model page offers a manual refresh', () => { + const pages = [ + 'entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets', + 'entry/src/main/ets/pages/components/ModelRolePickerPanel.ets' + ]; + for (const page of pages) { + const text = source(page); + assert.equal(text.includes("'common.refresh'"), false, + `${page} must not grow a manual refresh: opening the page is the read`); + // The write lock is what keeps a second write and an early exit out. + assert.match(text, /state\.busy|locked\(\)/, `${page} must answer the write lock`); + } +}); + +test('every key the two pages render exists in both catalogs', () => { + const catalogs = locale => new Set(JSON.parse( + source(`entry/src/main/resources/${locale}/element/string.json`) + ).string.map(entry => entry.name)); + const english = catalogs('en_US'); + const chinese = catalogs('zh_CN'); + const keys = [ + 'settings.model.settingsTitle', + 'settings.model.section', + 'settings.model.emptyTitle', + 'settings.model.emptyText', + 'settings.model.imageUnderstandingUnset', + 'settings.model.pickEmpty', + 'settings.model.capabilityImageUnderstanding', + 'settings.model.scopeNewSessions', + 'settings.model.scopeFastFollowsPrimary', + 'settings.model.scopeImageUnderstanding', + 'settings.modelService.notConfigured', + 'settings.modelService.accountSource', + 'settings.modelService.localSource', + 'chat.model', + 'chat.modelPrimary', + 'chat.modelFast', + 'chat.modelImage', + 'chat.modelFollowPrimary', + 'chat.modelFastNotConfigured', + 'connect.connected', + 'connect.disconnected', + 'common.retry', + 'status.loadingModelDefaults', + 'status.modelDefaultsUpdated', + 'status.remoteUnavailable' + ]; + // The three templated sentences are used with `RemoteI18n.f`, so they have to + // keep their placeholder in both locales. + const templated = [ + 'settings.model.filterTextChat', + 'settings.model.filterImageUnderstanding', + 'settings.model.updatedResult' + ]; + for (const key of keys.concat(templated)) { + const name = key.replace(/\./g, '_'); + assert.ok(english.has(name), `${key} is missing from en_US (${name})`); + assert.ok(chinese.has(name), `${key} is missing from zh_CN (${name})`); + } + const value = (locale, key) => JSON.parse( + source(`entry/src/main/resources/${locale}/element/string.json`) + ).string.find(entry => entry.name === key.replace(/\./g, '_')).value; + for (const key of templated) { + assert.ok(value('en_US', key).includes('%1$s'), `${key} must keep its placeholder in en_US`); + assert.ok(value('zh_CN', key).includes('%1$s'), `${key} must keep its placeholder in zh_CN`); + } +}); + +test('the device-model scenarios are one family the launcher can open', () => { + const entryAbility = source('entry/src/main/ets/entryability/EntryAbility.ets'); + const gallery = source('entry/src/main/ets/pages/preview/MobileDesignGallery.ets'); + const preview = source('entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets'); + + assert.match(entryAbility, /String\(scenarioId \|\| ''\)\.startsWith\('device-model'\)/, + 'the launcher has to recognize the whole device-model family'); + assert.match(entryAbility, /deviceModelScenario && String\(scenarioId\)\.endsWith\('-dark'\)/, + 'the dark variants have to lock the dark colour mode'); + assert.match(gallery, /startsWith\('device-model'\)/, + 'the gallery has to route the family to the preview'); + for (const scenario of [ + 'device-model-pick-fast', 'device-model-pick-multimodal', 'device-model-pick-primary', + 'device-model-writing', 'device-model-updated', 'device-model-degraded', 'device-model-empty' + ]) { + assert.ok(preview.includes(`'${scenario}'`), `${scenario} has to be a state the fixture can mount`); + } + // `device-models` is the settings page itself: the state every other scenario + // changes one thing away from, so it is the fixture's own default. + assert.match(preview, /scenario === 'device-model-empty' \? \[\] : previewModels\(\)/, + 'the settings page over a full catalog has to be the default state'); + // The preview mounts the production sheet rather than a picture of it. + assert.match(preview, /AppShell\(\{/, 'the fixture must mount the production shell'); + assert.match(preview, /AppSettingsSurface\(\{/, 'and the production settings surface'); +}); From 7d757d926d0cfaab5a7982ca93afe3cc64e7ef34 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 01:35:51 +0800 Subject: [PATCH 09/11] feat(mobile): preview the device model pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design gallery mounts the production settings sheet over a fixture desktop that speaks the two device-model requests, so the previews exercise the real panels, the real gate and the real controller — including the write lock, which only exists while a request is actually out. Scenarios: device-models (the fixture's own default state), device-model-pick- fast, device-model-pick-multimodal, device-model-pick-primary, device-model-writing, device-model-updated, device-model-degraded and device-model-empty. The family carries one `-dark` suffix for the colour mode, so every state is reachable in both. --- .../main/ets/entryability/EntryAbility.ets | 13 +- .../preview/DeviceModelDefaultsPreview.ets | 285 ++++++++++++++++++ .../ets/pages/preview/MobileDesignGallery.ets | 3 + 3 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index a5e11d4441..d2ae0f54d2 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,6 +13,10 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; + // The device-model scenarios are one family: the prefix opens the design + // gallery and the `-dark` suffix locks the colour mode, so the list below + // does not have to grow by one id per state of that feature. + const deviceModelScenario: boolean = String(scenarioId || '').startsWith('device-model'); if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || @@ -20,16 +24,19 @@ export default class EntryAbility extends UIAbility { scenarioId === 'cold-start-home' || scenarioId === 'cold-start-home-dark' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-dark' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || - scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') { + scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark' || + deviceModelScenario) { this.initialPage = 'pages/preview/MobileDesignGallery'; this.previewStorage = new LocalStorage(); this.previewStorage.setOrCreate('scenarioId', scenarioId); AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' || scenarioId === 'copy-selection-dark' || + (deviceModelScenario && String(scenarioId).endsWith('-dark')) ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK - : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' + : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || + (deviceModelScenario && !String(scenarioId).endsWith('-dark')) ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets new file mode 100644 index 0000000000..77481eb258 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets @@ -0,0 +1,285 @@ +import { + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1, + RemoteDefaultModels, + RemoteModelCatalog, + RemoteModelConfig +} from '../../model/RemoteModels'; +import { CloudAccountDevice } from '../../services/CloudAccountClient'; +import { DeviceModelDefaultsRequestPath } from '../state/DeviceModelDefaultsSheetState'; +import { AppRootPresentationActions, emptyAppRootPresentationActions } from '../actions/AppRootPresentationActions'; +import { AppSettingsSurface } from '../components/AppRootOverlaySurfaces'; +import { AppShell } from '../components/AppShell'; +import { PAGE_BG } from '../components/Theme'; +import { AppShellState } from '../state/AppShellState'; +import { RemotePageState } from '../state/RemotePageState'; + +/** + * The device model pages over a fixture desktop: the production sheet, its two + * model pages and the production controller, with the device's answers supplied + * locally. + * + * Nothing here is a picture of a page. The scenario id picks a device state — how + * many models it has, whether it can serve the page at all, whether a write is + * still in flight — and the sheet is mounted the way the app mounts it, so every + * state on screen is one the app can actually reach, and the interaction between + * them (tapping a role, choosing a model, watching the read-back land) works. + */ + +/** One model of the fixture desktop. */ +function previewModel( + id: string, + name: string, + provider: string, + modelName: string, + contextWindow: number, + enabled: boolean, + capabilities: string[] +): RemoteModelConfig { + return { + id, + name, + provider, + base_url: '', + model_name: modelName, + context_window: contextWindow, + enabled, + capabilities + }; +} + +/** + * The fixture desktop's catalog: four roles' worth of models, so `fast` and the + * multimodal slot can be told apart — one text-only model, one that carries both + * capabilities, one multimodal-only model, and one disabled model that no list + * may offer. + */ +function previewModels(): RemoteModelConfig[] { + return [ + previewModel('anthropic/claude-sonnet-4.5', 'Claude Sonnet 4.5', 'anthropic', + 'claude-sonnet-4-5', 200000, true, ['text_chat']), + previewModel('openbitfun:gpt-5-mini', 'GPT-5 mini', 'openai', + 'openbitfun:gpt-5-mini', 128000, true, ['text_chat']), + previewModel('google/gemini-2.5-flash', 'Gemini 2.5 Flash', 'google', + 'gemini-2.5-flash', 1000000, true, ['text_chat', 'image_understanding']), + previewModel('local:qwen3-coder', 'Qwen3 Coder', 'local', + 'qwen3-coder', 32768, true, ['text_chat']), + previewModel('preview/vision-preview', 'Vision Preview', 'openbitfun', + 'vision-preview', 64000, true, ['image_understanding']), + previewModel('legacy/disabled-model', 'Legacy model', 'legacy', + 'legacy-model', 8192, false, ['text_chat']) + ]; +} + +/** A primary model and two empty slots: the state the page opens on. */ +function previewDefaults(): RemoteDefaultModels { + return { + primary: 'anthropic/claude-sonnet-4.5', + fast: null, + image_understanding: null + }; +} + +/** The model the two write-driven scenarios select for the fast slot. */ +const PREVIEW_WRITE_MODEL: string = 'openbitfun:gpt-5-mini'; + +/** + * The fixture desktop's request path. + * + * `hangWrites` is the one behaviour a static fixture cannot express any other + * way: an answer that never arrives is what the locked page looks like, and it + * cannot be faked by a state flag without also faking the busy lock that the + * controller derives from the request being out. + */ +class PreviewModelDefaultsClient implements DeviceModelDefaultsRequestPath { + hangWrites: boolean = false; + private models: RemoteModelConfig[]; + private stored: RemoteDefaultModels; + + constructor(models: RemoteModelConfig[], stored: RemoteDefaultModels) { + this.models = models; + this.stored = stored; + } + + // Declared as fields rather than methods: the pages bind the request path as a + // property-typed shape, and ArkTS does not treat a method as one of those. + getModelCatalog: (sessionId?: string) => Promise = + async (): Promise => { + return { + version: 1, + models: this.models.slice(), + default_models: this.stored + }; + }; + + setModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ) => Promise = async ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + if (this.hangWrites) { + return new Promise((): void => {}); + } + this.stored = { + primary, + fast, + search: this.stored.search, + image_understanding: imageUnderstanding, + image_generation: this.stored.image_generation, + speech_recognition: this.stored.speech_recognition + }; + return this.stored; + }; +} + +/** + * The settings surface the production shell mounts, over the fixture's device + * catalog. Only the two device-model requests differ from the app's own wiring. + */ +function previewActions(client: PreviewModelDefaultsClient): AppRootPresentationActions { + const base = emptyAppRootPresentationActions(); + return { + onWorkspaceTools: base.onWorkspaceTools, + onNavigationBack: base.onNavigationBack, + onConversationIntent: base.onConversationIntent, + onCloseSidebar: base.onCloseSidebar, + onCompactLayoutEntered: base.onCompactLayoutEntered, + onLayoutModeChanged: base.onLayoutModeChanged, + onRemoteHome: base.onRemoteHome, + onSidebar: base.onSidebar, + onSettings: { + close: base.onSettings.close, + accountSettled: base.onSettings.accountSettled, + disconnect: base.onSettings.disconnect, + clearPairing: base.onSettings.clearPairing, + reconnect: base.onSettings.reconnect, + openAccount: base.onSettings.openAccount, + cloudCancelLogin: base.onSettings.cloudCancelLogin, + cloudLogin: base.onSettings.cloudLogin, + cloudLogout: base.onSettings.cloudLogout, + cloudListDevices: async (): Promise => [], + cloudSelectDevice: base.onSettings.cloudSelectDevice, + getPermissionMode: base.onSettings.getPermissionMode, + setPermissionMode: base.onSettings.setPermissionMode, + getDeviceModelCatalog: (): Promise => client.getModelCatalog(), + setDeviceModelDefaults: ( + primary: string | null, + fast: string | null, + imageUnderstanding: string | null + ): Promise => { + return client.setModelDefaults(primary, fast, imageUnderstanding); + }, + setLanguage: base.onSettings.setLanguage + }, + onConnect: base.onConnect, + onFilePreview: base.onFilePreview, + generalStatus: base.generalStatus + }; +} + +@ComponentV2 +export struct DeviceModelDefaultsPreview { + @Local shellState: AppShellState = new AppShellState(); + @Local remotePageState: RemotePageState = new RemotePageState(); + @Local actions: AppRootPresentationActions = emptyAppRootPresentationActions(); + private client: PreviewModelDefaultsClient = + new PreviewModelDefaultsClient(previewModels(), previewDefaults()); + + aboutToAppear(): void { + const scenario = this.scenarioId(); + this.client = new PreviewModelDefaultsClient( + scenario === 'device-model-empty' ? [] : previewModels(), + previewDefaults() + ); + this.client.hangWrites = scenario === 'device-model-writing'; + this.remotePageState.setAccountUserId('octocat'); + this.remotePageState.setAccountUsername('octocat'); + this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); + this.remotePageState.setDesktopIdentity('Account desktop 1', 'account-desktop-1'); + this.remotePageState.connectionState = 'connected'; + // An older desktop is one that never advertised the command, so the page is + // reached through the same capability check the app uses. + this.remotePageState.setHostCapabilities(scenario === 'device-model-degraded' ? + ['workspace_id_references_v1'] : [REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1]); + this.actions = previewActions(this.client); + this.shellState.showSettings = true; + this.shellState.settingsSheet.setPage('deviceModels'); + const role = this.pickerRole(scenario); + if (role.length > 0) { + this.shellState.settingsSheet.openModelPick(role); + } + if (scenario === 'device-model-writing' || scenario === 'device-model-updated') { + void this.driveWrite(); + } + } + + /** + * Runs the write the scenario is about, before the first frame. + * + * The sheet arms the same gate a moment later from its own parameters, so this + * only has to happen first: a scenario that has to show a write in flight — or + * its result — cannot wait for the page to be tapped. + */ + private async driveWrite(): Promise { + const deviceModels = this.shellState.settingsSheet.deviceModels; + deviceModels.bind(this.client); + deviceModels.setGate({ supported: true, connected: true, deviceId: 'account-desktop-1' }); + await deviceModels.ensureLoaded(); + await deviceModels.selectRole('fast', PREVIEW_WRITE_MODEL); + } + + private scenarioId(): string { + const scenario = AppStorage.get('scenarioId') ?? ''; + return scenario.endsWith('-dark') ? scenario.slice(0, scenario.length - 5) : scenario; + } + + /** The role the picker is opened on, or an empty string for the settings page. */ + private pickerRole(scenario: string): string { + if (scenario === 'device-model-pick-fast') { + return 'fast'; + } + if (scenario === 'device-model-pick-multimodal') { + return 'image_understanding'; + } + if (scenario === 'device-model-pick-primary') { + return 'primary'; + } + return ''; + } + + build() { + AppShell({ + shellState: this.shellState, + allowSidebar: true, + content: () => { + this.EmptyConversation() + }, + settings: () => { + this.Settings() + }, + onCloseSidebar: () => { + this.shellState.showSidebar = false; + } + }) + } + + @Builder + private EmptyConversation() { + Column() + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + } + + @Builder + private Settings() { + AppSettingsSurface({ + shellState: this.shellState, + remotePageState: this.remotePageState, + actions: this.actions + }) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index 8da4cd02c6..8f781beb59 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -10,6 +10,7 @@ import { SubagentProcessPreview } from './SubagentProcessPreview'; import { FileVisibilityPreview } from './FileVisibilityPreview'; import { ComposerOverflowPreview } from './ComposerOverflowPreview'; import { ImmersiveBottomPreview } from './ImmersiveBottomPreview'; +import { DeviceModelDefaultsPreview } from './DeviceModelDefaultsPreview'; import { WelcomeHome } from '../components/WelcomeHome'; import { InteractionMailboxPreview } from './InteractionMailboxPreview'; import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; @@ -67,6 +68,8 @@ struct MobileDesignGallery { ImmersiveBottomPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('device-selector')) { DeviceSelectorPreview() + } else if ((AppStorage.get('scenarioId') || '').startsWith('device-model')) { + DeviceModelDefaultsPreview() } else if (AppStorage.get('scenarioId') === 'welcome-home') { WelcomeHome() } else if ((AppStorage.get('scenarioId') || '').startsWith('copy-selection')) { From 8adcfaf14cfe37a0d79851b386da6c20684efca4 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 02:19:14 +0800 Subject: [PATCH 10/11] fix(core): merge remote device model roles under one config write lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote device-model verb read `ai` and then wrote a whole `ai.default_models` object built from that snapshot, so a desktop-side save committed between the two locks was silently reverted — the hazard the config service warns about for a get_config-then-set_config pair. Merge, validate, and store now happen inside one `update_config("ai", ...)` closure: the id check reads the same `ai.models` the manager will persist against, the host-only slots are carried over from the same locked read, and the manager's own `set` path still reconciles the object before it commits. A rejected id returns from the closure without touching the stored section, so it is never a partial write. The verb also documents what clearing actually means per slot: unset `fast` follows primary and unset `image_understanding` stays unconfigured, while unset `primary` is re-filled by the reconcile with the device's first enabled text-capable model. Tests: the write-path seam is pinned to the single-closure call, and a new behavioral fixture runs the real config service and reconcile to cover the device-owned slots surviving the write, the persisted result, the cleared `primary` fallback, and the rejected-id no-op. --- .../core/src/service_agent_runtime.rs | 252 +++++++++++++----- .../src/remote_connect.rs | 23 +- 2 files changed, 202 insertions(+), 73 deletions(-) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 90adb222f2..cd8e90dbfb 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -906,35 +906,69 @@ fn remote_default_models_config(defaults: &DefaultModelsConfig) -> RemoteDefault } /// Merge a controller's three managed role defaults over the device's current -/// `ai.default_models`. +/// `ai` section, taken from the config write lock that will store the result. /// -/// The managed slots are replaced as a whole object, so `None` clears a slot, -/// and every provided id must name a model this device already configures. The -/// other three slots are carried over untouched. Enablement and capability -/// validity are deliberately not decided here: the config reconcile owns that -/// repair and the caller reports its result. +/// The managed slots are replaced as a whole object, so `None` leaves a slot +/// unset, and every provided id must name a model this device already +/// configures. The other three slots are carried over untouched. Enablement and +/// capability validity are deliberately not decided here: the config reconcile +/// owns that repair and the caller reports its result. #[cfg(feature = "remote-connect")] fn merge_remote_model_defaults( - current: &DefaultModelsConfig, + ai_config: &AIConfig, primary: Option<&str>, fast: Option<&str>, image_understanding: Option<&str>, - is_known_model: &impl Fn(&str) -> bool, ) -> Result { + let is_known_model = |model_id: &str| ai_config.models.iter().any(|model| model.id == model_id); Ok(DefaultModelsConfig { - primary: resolve_remote_model_default_slot("primary", primary, is_known_model)?, - fast: resolve_remote_model_default_slot("fast", fast, is_known_model)?, + primary: resolve_remote_model_default_slot("primary", primary, &is_known_model)?, + fast: resolve_remote_model_default_slot("fast", fast, &is_known_model)?, image_understanding: resolve_remote_model_default_slot( "image_understanding", image_understanding, - is_known_model, + &is_known_model, )?, - search: current.search.clone(), - image_generation: current.image_generation.clone(), - speech_recognition: current.speech_recognition.clone(), + search: ai_config.default_models.search.clone(), + image_generation: ai_config.default_models.image_generation.clone(), + speech_recognition: ai_config.default_models.speech_recognition.clone(), }) } +/// Merge, validate, and persist the remote-managed role slots. +/// +/// The `ai` section is read, changed, and written inside one config write lock, +/// so a desktop save to `ai.default_models` — or to `ai.models`, which the id +/// check reads — cannot be reverted by a snapshot taken before it. A rejected +/// id returns before anything is stored, and the manager's own `set` path +/// reconciles the object before it commits. +#[cfg(feature = "remote-connect")] +async fn persist_remote_model_defaults( + config_service: &crate::service::config::ConfigService, + primary: Option<&str>, + fast: Option<&str>, + image_understanding: Option<&str>, +) -> Result<(), String> { + let rejection = config_service + .update_config("ai", |ai: &mut AIConfig| { + match merge_remote_model_defaults(ai, primary, fast, image_understanding) { + Ok(merged) => { + ai.default_models = merged; + Ok(None) + } + // An unusable id is not a config failure: report it to the + // caller unchanged and leave the stored section alone. + Err(message) => Ok(Some(message)), + } + }) + .await + .map_err(|e| format!("Failed to persist device model defaults: {e}"))?; + match rejection { + Some(message) => Err(message), + None => Ok(()), + } +} + /// An attachment recorded before pixels travelled inline holds a host path and /// nothing else, and only this host can resolve it. Above this size the image is /// left as a name: reading it would cost more than the thumbnail is worth. @@ -2506,11 +2540,11 @@ impl CoreServiceAgentRuntime { /// Apply a controller's three device role defaults and report the device's /// reconciled six-slot mapping. /// - /// The whole `ai.default_models` object is written, so the slots a - /// controller may not write keep their device values, and the config - /// service reconciles the write. The reply is read back from the reconciled - /// config: a slot whose model is disabled or lacks the capability its role - /// needs reports the repaired value rather than what was requested. + /// The merge, its validation, and the persist share one config write lock + /// (see [`persist_remote_model_defaults`]), so a concurrent desktop save to + /// the same section cannot be reverted. The reply is read back from the + /// reconciled config: a slot whose model is disabled or lacks the capability + /// its role needs reports the repaired value rather than what was requested. #[cfg(feature = "remote-connect")] pub(crate) async fn update_remote_model_defaults( primary: Option<&str>, @@ -2520,21 +2554,7 @@ impl CoreServiceAgentRuntime { let config_service = crate::service::config::get_global_config_service() .await .map_err(|_| "Config service not available".to_string())?; - let ai_config: AIConfig = config_service - .get_config(Some("ai")) - .await - .map_err(|e| format!("Failed to load AI config: {e}"))?; - let merged = merge_remote_model_defaults( - &ai_config.default_models, - primary, - fast, - image_understanding, - &|model_id: &str| ai_config.models.iter().any(|model| model.id == model_id), - )?; - config_service - .set_config("ai.default_models", &merged) - .await - .map_err(|e| format!("Failed to persist device model defaults: {e}"))?; + persist_remote_model_defaults(&config_service, primary, fast, image_understanding).await?; let reconciled: AIConfig = config_service .get_config(Some("ai")) @@ -4401,11 +4421,9 @@ mod tests { let mut config = device_default_model_catalog(); config.ai.default_models.fast = Some("retired-fast".to_string()); config.ai.default_models.image_understanding = Some("vision-1".to_string()); - let current = config.ai.default_models.clone(); - let is_known = |model_id: &str| config.ai.models.iter().any(|model| model.id == model_id); // A cleared slot (absent field or explicit null) is a real write. - let cleared = merge_remote_model_defaults(¤t, None, None, None, &is_known).unwrap(); + let cleared = merge_remote_model_defaults(&config.ai, None, None, None).unwrap(); assert_eq!(cleared.primary, None); assert_eq!(cleared.fast, None); assert_eq!(cleared.image_understanding, None); @@ -4414,14 +4432,9 @@ mod tests { assert_eq!(cleared.image_generation.as_deref(), Some("image-1")); assert_eq!(cleared.speech_recognition.as_deref(), Some("speech-1")); - let assigned = merge_remote_model_defaults( - ¤t, - Some("text-1"), - None, - Some("vision-1"), - &is_known, - ) - .unwrap(); + let assigned = + merge_remote_model_defaults(&config.ai, Some("text-1"), None, Some("vision-1")) + .unwrap(); assert_eq!(assigned.primary.as_deref(), Some("text-1")); assert_eq!(assigned.fast, None); assert_eq!(assigned.image_understanding.as_deref(), Some("vision-1")); @@ -4432,14 +4445,11 @@ mod tests { // A model this device does not configure, a semantic session selector, // and an empty value are all rejected before anything is persisted. assert_eq!( - merge_remote_model_defaults(¤t, Some("model-gone"), None, None, &is_known) - .unwrap_err(), + merge_remote_model_defaults(&config.ai, Some("model-gone"), None, None).unwrap_err(), "Unknown model selection: model-gone" ); - assert!( - merge_remote_model_defaults(¤t, None, Some("fast"), None, &is_known).is_err() - ); - assert!(merge_remote_model_defaults(¤t, None, None, Some(" "), &is_known).is_err()); + assert!(merge_remote_model_defaults(&config.ai, None, Some("fast"), None).is_err()); + assert!(merge_remote_model_defaults(&config.ai, None, None, Some(" ")).is_err()); } #[test] @@ -4447,16 +4457,14 @@ mod tests { use crate::service::config::reconcile_model_references; let mut config = device_default_model_catalog(); - let is_known = |model_id: &str| config.ai.models.iter().any(|model| model.id == model_id); // `retired-fast` exists but is disabled, and `text-1` cannot understand // images: both pass the verb's existence check, so the request is a real // write and only the reconcile can repair it. let requested = merge_remote_model_defaults( - &config.ai.default_models, + &config.ai, Some("text-1"), Some("retired-fast"), Some("text-1"), - &is_known, ) .unwrap(); assert_eq!(requested.fast.as_deref(), Some("retired-fast")); @@ -4478,28 +4486,142 @@ mod tests { assert_eq!(echo.speech_recognition.as_deref(), Some("speech-1")); } + /// The write path against a real config service and a real reconcile: the + /// stored section is read inside the same critical section as the store, the + /// device-owned slots survive, a cleared `primary` is re-filled, and a + /// rejected id is never a partial write. + #[tokio::test] + async fn device_model_defaults_write_behavior_against_a_real_config_service() { + use crate::service::config::{ConfigManagerSettings, ConfigService}; + + let dir = tempfile::tempdir().expect("tempdir"); + let path_manager = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests( + dir.path().join("remote-device-model-defaults"), + ), + ); + let service = ConfigService::with_settings(ConfigManagerSettings { + path_manager: Some(path_manager.clone()), + auto_save: true, + backup_count: 0, + }) + .await + .expect("config service"); + + let seeded = device_default_model_catalog(); + service + .set_config("ai.models", seeded.ai.models) + .await + .expect("seed device models"); + service + .set_config( + "ai.default_models", + serde_json::to_value(&seeded.ai.default_models).expect("seed defaults"), + ) + .await + .expect("seed device role defaults"); + + persist_remote_model_defaults(&service, Some("text-1"), None, Some("vision-1")) + .await + .expect("remote device defaults write"); + + let ai: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!(ai.default_models.primary.as_deref(), Some("text-1")); + assert_eq!(ai.default_models.fast, None); + assert_eq!( + ai.default_models.image_understanding.as_deref(), + Some("vision-1") + ); + // The device-owned slots came from the section the write lock read, not + // from a snapshot taken before it, and reached disk. + assert_eq!(ai.default_models.search.as_deref(), Some("search-1")); + assert_eq!( + ai.default_models.image_generation.as_deref(), + Some("image-1") + ); + assert_eq!( + ai.default_models.speech_recognition.as_deref(), + Some("speech-1") + ); + let persisted: GlobalConfig = serde_json::from_slice( + &tokio::fs::read(path_manager.app_config_file()) + .await + .expect("persisted config"), + ) + .expect("valid persisted config"); + assert_eq!( + persisted.ai.default_models.image_understanding.as_deref(), + Some("vision-1") + ); + assert_eq!( + persisted.ai.default_models.search.as_deref(), + Some("search-1") + ); + + // A cleared primary does not stay unset: the reconcile re-fills it with + // the first enabled text-capable model, while the optional slots stay + // unconfigured. + persist_remote_model_defaults(&service, None, None, None) + .await + .expect("clear device roles"); + let ai: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!(ai.default_models.primary.as_deref(), Some("text-1")); + assert_eq!(ai.default_models.fast, None); + assert_eq!(ai.default_models.image_understanding, None); + + // A rejected id is reported before anything is stored. + let before: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!( + persist_remote_model_defaults(&service, Some("model-gone"), None, None) + .await + .expect_err("unknown model ids are rejected"), + "Unknown model selection: model-gone" + ); + let after: AIConfig = service.get_config(Some("ai")).await.expect("ai config"); + assert_eq!( + serde_json::to_value(&after.default_models).expect("defaults json"), + serde_json::to_value(&before.default_models).expect("defaults json") + ); + } + #[test] - fn device_model_defaults_write_goes_through_the_reconciling_config_service() { + fn device_model_defaults_write_merges_and_persists_under_one_config_lock() { let source = include_str!("service_agent_runtime.rs"); - let body = source - .split("pub(crate) async fn update_remote_model_defaults") + let merge_and_persist = source + .split("async fn persist_remote_model_defaults") .nth(1) .and_then(|source| { source - .split("pub(crate) fn remote_control_state_port") + .split("/// An attachment recorded before pixels travelled inline") .next() }) .expect("device model defaults writer"); - // One whole-object write of the managed slots, then a read-back of the - // reconciled config: no per-slot write may bypass the reconcile, and the - // echo must not be built from the merged request. - assert!(body.contains("merge_remote_model_defaults(")); - assert!(body.contains(r#".set_config("ai.default_models", &merged)"#)); - assert!(body.contains("remote_default_models_config(&reconciled.default_models)")); + // The merge, its validation, and the store happen inside one + // `update_config` closure: a read before the write lock would let a + // concurrent desktop save be reverted by a stale snapshot. + assert!(merge_and_persist.contains(r#".update_config("ai", |ai: &mut AIConfig|"#)); + assert!(merge_and_persist.contains("merge_remote_model_defaults(ai, primary, fast")); + assert!(merge_and_persist.contains("ai.default_models = merged;")); + assert!(!merge_and_persist.contains("get_config(")); + assert!(!merge_and_persist.contains("set_config(")); + + let caller = source + .split("pub(crate) async fn update_remote_model_defaults") + .nth(1) + .and_then(|source| { + source + .split("pub(crate) fn remote_control_state_port") + .next() + }) + .expect("device model defaults verb"); + // The echo is read back from the committed, reconciled config rather + // than built from the merged request. + assert!(caller.contains("persist_remote_model_defaults(&config_service")); + assert!(caller.contains("remote_default_models_config(&reconciled.default_models)")); for slot in ["search", "image_generation", "speech_recognition"] { assert!( - !body.contains(&format!(".set_config(\"ai.default_models.{slot}\"")), + !caller.contains(&format!("ai.default_models.{slot}")), "host-only slot {slot} must not be written by the remote verb" ); } diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 973d5cc09d..15b9639cdf 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -1452,9 +1452,12 @@ pub trait RemoteSessionRuntimeHost: Send + Sync { ) -> Result; /// Replace the three device role defaults a controller configures for this /// host (`primary`, `fast`, `image_understanding`); host-only slots such as - /// `search` and `image_generation` always keep their device values. `None` - /// clears a slot. The returned mapping is the host's reconciled truth for - /// all six slots, not the requested patch. + /// `search` and `image_generation` always keep their device values. A slot + /// sent as `None` is written as unset: `fast` then follows primary, and + /// `image_understanding` is unconfigured. A cleared `primary` does not stay + /// unset — the device reconcile re-fills it with the first enabled + /// text-capable model. The returned mapping is the host's reconciled truth + /// for all six slots, not the requested patch. /// /// A host that cannot configure device defaults reports an unsupported /// error here instead of answering with an unchanged-looking success. @@ -2792,12 +2795,16 @@ pub enum RemoteCommand { /// `.fast` / `.image_understanding`) of the receiving host. /// /// This is a whole-object write of the three managed slots: an absent field - /// and an explicit `null` both clear that slot, so the command has no + /// and an explicit `null` both leave that slot unset, so the command has no /// tri-state ambiguity — a caller that wants to keep a slot must send its - /// current catalog value. Host-only slots (`search`, `image_generation`, - /// `speech_recognition`) are never part of the request and keep their - /// device values. Values must be concrete catalog model ids; a semantic - /// selector is rejected. Requires the `device_model_defaults_v1` capability. + /// current catalog value. Clearing is not uniform: unset `fast` follows + /// primary and unset `image_understanding` stays unconfigured, while unset + /// `primary` falls back to the device's first enabled text-capable model + /// once the host reconciles, so the echoed mapping stays authoritative. + /// Host-only slots (`search`, `image_generation`, `speech_recognition`) are + /// never part of the request and keep their device values. Values must be + /// concrete catalog model ids; a semantic selector is rejected. Requires the + /// `device_model_defaults_v1` capability. SetModelDefaults { #[serde(default)] primary: Option, From 61b1cea3c72f8b5f42cd42094078556a3db4a4e9 Mon Sep 17 00:00:00 2001 From: Tant Date: Wed, 14 Oct 2026 02:24:14 +0800 Subject: [PATCH 11/11] fix(mobile): state a device that cannot answer on the model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker could be open when the desktop stopped answering it, and it drew the last roles it had as if the device had just reported them: the pinned row was checked — "fast follows the primary" on a desktop the phone could no longer reach — and an empty list was blamed on the catalog instead of on the link. `ModelRoleCandidatesPolicy.pickerView` now decides one picker frame from one device state: whether the reason card is shown, whether a retry can repeat the read, whether the rows are drawn, and whether those rows are the device's own answer at all. A mark and the "no models match this role" line are only drawn from a list the device answered; a page that has not been read yet says it is reading rather than blaming a catalog nobody has seen. Both pages state the reason in one shared card, with the retry offered only while the device can still answer, so a closed gate does not grow a button that cannot do anything. The connected desktop's model settings entry was also never exercised: the settings fixture advertised no host capabilities, so every shot of the device row could only ever show the greyed entry with its upgrade badge. The fixture now advertises the command, the pill carries its state in its id, and a test pins the capability chain from the state the shell reads to the branch the row draws. Previews: device-model-degraded-pick and device-model-pick-failed add the two picker states that have no list. --- .../pages/components/AccountProfilePanel.ets | 4 + .../components/DeviceModelDefaultsPanel.ets | 24 ++- .../pages/components/ModelRolePickerPanel.ets | 107 ++++++++++-- .../pages/components/ModelUnavailableCard.ets | 63 +++++++ .../policy/ModelRoleCandidatesPolicy.ets | 64 ++++++- .../preview/DeviceModelDefaultsPreview.ets | 18 +- .../pages/preview/ImmersiveBottomPreview.ets | 12 +- .../tools/tests/model-role-pages.test.cjs | 163 ++++++++++++++++++ 8 files changed, 426 insertions(+), 29 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets index b06870f0de..e42de18746 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AccountProfilePanel.ets @@ -278,6 +278,10 @@ export struct AccountProfilePanel { .backgroundColor(SOFT) .borderRadius(14) .opacity(this.deviceModelSettingsEnabled() ? 1 : 0.38) + // The two states of this pill are its whole contract, so the state is part + // of its identity: an enabled entry that still looked greyed would be the + // same bug as a greyed one that opened the page. + .id(`device-model-entry-${this.deviceModelSettingsEnabled() ? 'enabled' : 'degraded'}`) .onClick(() => { this.openDeviceModelSettings(device); }) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets index e880872c01..802934e527 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets @@ -10,6 +10,7 @@ import { ModelRoleCandidatesPolicy, ModelRoleRowProjection } from '../policy/Mod import { toConversationUiModel } from '../state/ConversationUiModels'; import { DeviceModelDefaultsSheetState } from '../state/DeviceModelDefaultsSheetState'; import { SettingsSheetState } from '../state/SettingsSheetState'; +import { ModelUnavailableCard } from './ModelUnavailableCard'; import { NavigationBackButton } from './NavigationBackButton'; import { SheetCloseButton } from './SheetCloseButton'; import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING, SHEET_HORIZONTAL_PADDING } from './SheetLayout'; @@ -417,18 +418,23 @@ export struct DeviceModelDefaultsPanel { * the same reason, but the page is reachable from a stale capability set too, * so it states the reason itself — the controller already decided which of the * two it is, and this card does not re-judge it. + * + * The card is shared with the picker, which reaches it in one more state: a + * read that failed while the device could still answer. This page shows that + * case above its own rows instead, so the retry here never has work to do. */ @Builder private UnavailableCard() { - Text(this.deviceModels.state.error.length > 0 ? this.deviceModels.state.error : - RemoteI18n.t('status.remoteUnavailable')) - .width('100%') - .fontSize(MobileDesignTypography.bodySmall.size) - .lineHeight(MobileDesignTypography.bodySmall.lineHeight) - .fontColor(STATUS_DANGER) - .padding({ left: 18, right: 18, top: 16, bottom: 16 }) - .backgroundColor(CARD) - .borderRadius(MobileDesignGeometry.settingsCardRadius) + ModelUnavailableCard({ + reason: this.deviceModels.state.error.length > 0 ? this.deviceModels.state.error : + RemoteI18n.t('status.remoteUnavailable'), + canRetry: ModelRoleCandidatesPolicy.canRepeatRead(this.deviceModelState()), + busy: this.deviceModels.state.loading || this.locked(), + cardId: 'device-model-unavailable', + onRetry: (): void => { + void this.deviceModels.retry(); + } + }) } private deviceLabel(): string { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets index 0bb1d0b060..273734ed27 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelRolePickerPanel.ets @@ -4,10 +4,11 @@ import { RemoteModelConfig } from '../../model/RemoteModels'; import { RemoteModelDefaultsRole } from '../../services/RemoteModelDefaultsController'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { ConversationModelPresentationPolicy } from '../policy/ConversationModelPresentationPolicy'; -import { ModelRoleCandidatesPolicy } from '../policy/ModelRoleCandidatesPolicy'; +import { ModelPickerView, ModelRoleCandidatesPolicy } from '../policy/ModelRoleCandidatesPolicy'; import { toConversationUiModel } from '../state/ConversationUiModels'; import { DeviceModelDefaultsSheetState } from '../state/DeviceModelDefaultsSheetState'; import { SettingsSheetState } from '../state/SettingsSheetState'; +import { ModelUnavailableCard } from './ModelUnavailableCard'; import { NavigationBackButton } from './NavigationBackButton'; import { SheetCloseButton } from './SheetCloseButton'; import { SHEET_HEADER_HEIGHT, SHEET_HEADER_SIDE_PADDING, SHEET_HORIZONTAL_PADDING } from './SheetLayout'; @@ -57,15 +58,27 @@ export struct ModelRolePickerPanel { this.PageHeader() Scroll() { Column({ space: 0 }) { - this.FilterLine() - if (this.allowsUnset()) { - this.UnsetRow() + // The card comes first: it is why the list below is what it is, and on a + // device that cannot answer there is no list at all. + if (this.view().reason) { + this.ReasonCard() } - ForEach(this.candidates(), (model: RemoteModelConfig) => { - this.CandidateRow(model) - }, (model: RemoteModelConfig): string => model.id) - if (this.candidates().length === 0) { - this.NoCandidatesLine() + if (this.view().reading) { + this.ReadingCard() + } + if (this.view().list) { + this.FilterLine() + if (this.allowsUnset()) { + this.UnsetRow() + } + ForEach(this.candidates(), (model: RemoteModelConfig) => { + this.CandidateRow(model) + }, (model: RemoteModelConfig): string => model.id) + // Only a list the device itself answered may be explained away: after a + // failed read it is short for a reason the card above already states. + if (this.view().answered && this.candidates().length === 0) { + this.NoCandidatesLine() + } } } .width('100%') @@ -151,8 +164,7 @@ export struct ModelRolePickerPanel { private UnsetRow() { Column({ space: 0 }) { Row({ space: 10 }) { - this.SelectionMark(ModelRoleCandidatesPolicy.isUnsetSelected( - this.deviceModels.state.roles, this.role())) + this.SelectionMark(this.pinSelected()) Column({ space: 2 }) { Text(RemoteI18n.t(ModelRoleCandidatesPolicy.unsetLabelKey(this.role()))) .width('100%') @@ -176,8 +188,7 @@ export struct ModelRolePickerPanel { .constraintSize({ minHeight: MobileDesignGeometry.modelAccountRowHeight }) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .alignItems(VerticalAlign.Center) - .backgroundColor(ModelRoleCandidatesPolicy.isUnsetSelected( - this.deviceModels.state.roles, this.role()) ? SOFT : TRANSPARENT) + .backgroundColor(this.pinSelected() ? SOFT : TRANSPARENT) .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .id('device-model-pick-unset') .enabled(!this.locked()) @@ -201,8 +212,7 @@ export struct ModelRolePickerPanel { @Builder private CandidateRow(model: RemoteModelConfig) { Row({ space: 10 }) { - this.SelectionMark(ModelRoleCandidatesPolicy.isCandidateSelected( - this.deviceModels.state.roles, this.role(), model.id)) + this.SelectionMark(this.candidateSelected(model.id)) Column({ space: 2 }) { Row({ space: 6 }) { Text(this.modelLabel(model)) @@ -237,8 +247,7 @@ export struct ModelRolePickerPanel { .constraintSize({ minHeight: MobileDesignGeometry.modelAccountRowHeight }) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .alignItems(VerticalAlign.Center) - .backgroundColor(ModelRoleCandidatesPolicy.isCandidateSelected( - this.deviceModels.state.roles, this.role(), model.id) ? SOFT : TRANSPARENT) + .backgroundColor(this.candidateSelected(model.id) ? SOFT : TRANSPARENT) .borderRadius(MobileDesignGeometry.composerModelSelectorRowRadius) .id(`device-model-pick-${model.id}`) .enabled(!this.locked()) @@ -282,6 +291,40 @@ export struct ModelRolePickerPanel { .padding({ left: 10, right: 10, top: 10, bottom: 10 }) } + /** + * The device stopped answering this page — the link dropped, the desktop was + * replaced by an older one, or the read itself failed — so the picker states + * the reason instead of a list. Without this a dropped link read as an empty + * catalog, and the null roles of the closed gate read as the user's choices. + */ + @Builder + private ReasonCard() { + ModelUnavailableCard({ + reason: this.reasonText(), + canRetry: this.view().retry, + busy: this.locked(), + cardId: 'device-model-pick-unavailable', + onRetry: (): void => { + void this.deviceModels.retry(); + } + }) + } + + /** Nothing has been read for this device yet, so there is nothing to list. */ + @Builder + private ReadingCard() { + Text(this.deviceModels.state.statusText.length > 0 ? + this.deviceModels.state.statusText : RemoteI18n.t('status.loadingModelDefaults')) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(MUTED) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .id('device-model-pick-reading') + } + /** * The two labels a candidate row carries. The catalog is the protocol's own * shape, so it is projected through the shared presentation mapper before the @@ -321,6 +364,36 @@ export struct ModelRolePickerPanel { return ModelRoleCandidatesPolicy.candidatesForRole(this.role(), this.deviceModels.state.catalog); } + /** What this frame may show, decided from the one state the page renders. */ + private view(): ModelPickerView { + return ModelRoleCandidatesPolicy.pickerView(this.deviceModels.state); + } + + /** + * The reason the card carries: the controller's own sentence for a state it + * judged, or the host's words when a read failed. The page never invents one. + */ + private reasonText(): string { + const error = this.deviceModels.state.error; + return error.length > 0 ? error : RemoteI18n.t('status.remoteUnavailable'); + } + + /** + * Whether the pinned row carries the check. A role's value is only an answer + * while the device is answering: after the link dropped, every role reads as + * null, and drawing that as "this is your setting" would state a choice the + * user never made and the device never confirmed. + */ + private pinSelected(): boolean { + return this.view().answered && + ModelRoleCandidatesPolicy.isUnsetSelected(this.deviceModels.state.roles, this.role()); + } + + private candidateSelected(modelId: string): boolean { + return this.view().answered && ModelRoleCandidatesPolicy.isCandidateSelected( + this.deviceModels.state.roles, this.role(), modelId); + } + private locked(): boolean { return this.deviceModels.state.busy; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets new file mode 100644 index 0000000000..fd6f75d6c5 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ModelUnavailableCard.ets @@ -0,0 +1,63 @@ +import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { CARD, INK, SOFT, STATUS_DANGER } from './Theme'; + +/** + * Why a model page cannot be answered, and the way to ask again. + * + * Both pages of the device model settings can be open when the desktop stops + * answering: the settings page when the phone is not connected to it, and the + * picker on top of it when the link drops while the list is up. The reason + * belongs to neither page — the controller already decided which sentence + * describes the state — so it is rendered here, once, in the sheet's own card + * language. + * + * The retry is offered only when repeating the read can actually help, which the + * caller decides: a gate that is closed re-arms by itself when the desktop comes + * back, and a button that cannot do anything is worse than no button. + */ +@ComponentV2 +export struct ModelUnavailableCard { + /** The controller's resolved reason, or the host's own words for a failed read. */ + @Param reason: string = ''; + /** Repeat the read that failed. */ + @Param canRetry: boolean = false; + /** A request is in flight, so the page does not take a second one. */ + @Param busy: boolean = false; + /** Distinguishes the two pages' cards in the layout hierarchy. */ + @Param cardId: string = 'model-unavailable'; + @Event onRetry: () => void = () => {}; + + build() { + Column({ space: 10 }) { + Text(this.reason) + .width('100%') + .fontSize(MobileDesignTypography.bodySmall.size) + .lineHeight(MobileDesignTypography.bodySmall.lineHeight) + .fontColor(STATUS_DANGER) + .maxLines(3) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.canRetry) { + Text(RemoteI18n.t('common.retry')) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .padding({ left: 10, right: 10, top: 6, bottom: 6 }) + .backgroundColor(SOFT) + .borderRadius(14) + .id(`${this.cardId}-retry`) + .enabled(!this.busy) + .opacity(this.busy ? 0.6 : 1) + .onClick(() => { + this.onRetry(); + }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Start) + .padding({ left: 18, right: 18, top: 16, bottom: 16 }) + .backgroundColor(CARD) + .borderRadius(MobileDesignGeometry.settingsCardRadius) + .id(this.cardId) + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets index 0e6b60b63f..b3c3f25c55 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/ModelRoleCandidatesPolicy.ets @@ -1,7 +1,8 @@ import { RemoteModelCatalog, RemoteModelConfig } from '../../model/RemoteModels'; import { RemoteModelDefaultsRole, - RemoteModelDefaultsRoles + RemoteModelDefaultsRoles, + RemoteModelDefaultsState } from '../../services/RemoteModelDefaultsController'; /** @@ -18,6 +19,33 @@ import { * that list. */ +/** + * What the role picker shows for one device state. + * + * The picker is a list only while the device is actually answering. A snapshot + * that the gate replaced with "unavailable" still carries its last roles, and a + * role that is null there reads as a choice the user made — "fast follows the + * primary" on a desktop that was never asked, or on one the phone can no longer + * reach. So the marks, the list and the sentence about an empty list are decided + * here together with the reason, from the same state the reason comes from. + */ +export interface ModelPickerView { + /** The reason card: the device refused the page, or the last read failed. */ + reason: boolean; + /** The retry: the failure is a read this device can repeat. */ + retry: boolean; + /** The reading card: nothing has been read for this device yet. */ + reading: boolean; + /** The pinned row and the candidates are drawn. */ + list: boolean; + /** + * The rows on screen are the device's own answer, so a selected mark and an + * empty list may be read from them. A closed gate, a failed read or a page that + * has not been read yet all say no. + */ + answered: boolean; +} + /** What one role row of the device model settings page shows. */ export interface ModelRoleRowProjection { /** The model the role points at; empty when the role has no model of its own. */ @@ -77,6 +105,40 @@ export class ModelRoleCandidatesPolicy { return catalog.models.length === 0; } + /** + * The device cannot answer this page: it never advertised the command, or the + * phone is not connected to it, or the read itself failed. All three are the + * same fact to the picker — the list on screen is not an answer — and the + * controller has already resolved which sentence describes it into `error`. + */ + static isUnavailable(state: RemoteModelDefaultsState): boolean { + return !state.available || state.error.length > 0; + } + + /** + * Whether repeating the read is a way out. Only a device that can still answer + * can be asked again: a closed gate is the sheet's own business, and it re-arms + * by itself when the link or the desktop comes back. + */ + static canRepeatRead(state: RemoteModelDefaultsState): boolean { + return state.available && state.error.length > 0; + } + + /** The render decisions of one picker frame, all read from one state. */ + static pickerView(state: RemoteModelDefaultsState): ModelPickerView { + const reason = ModelRoleCandidatesPolicy.isUnavailable(state); + const reading = state.available && !reason && !state.loaded; + return { + reason, + retry: ModelRoleCandidatesPolicy.canRepeatRead(state), + reading, + // A device that can still answer keeps its list even after a failed read: + // those rows are the last thing it said, and they are not what failed. + list: state.available && !reading, + answered: !reason && !reading + }; + } + /** * Whether the picker offers the "no model" row. * diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets index 77481eb258..7240c48378 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/DeviceModelDefaultsPreview.ets @@ -92,6 +92,8 @@ const PREVIEW_WRITE_MODEL: string = 'openbitfun:gpt-5-mini'; */ class PreviewModelDefaultsClient implements DeviceModelDefaultsRequestPath { hangWrites: boolean = false; + /** The desktop stops answering the catalog read, which is a state of its own. */ + failReads: boolean = false; private models: RemoteModelConfig[]; private stored: RemoteDefaultModels; @@ -104,6 +106,9 @@ class PreviewModelDefaultsClient implements DeviceModelDefaultsRequestPath { // property-typed shape, and ArkTS does not treat a method as one of those. getModelCatalog: (sessionId?: string) => Promise = async (): Promise => { + if (this.failReads) { + throw new Error('The desktop stopped answering the model catalog.'); + } return { version: 1, models: this.models.slice(), @@ -195,6 +200,7 @@ export struct DeviceModelDefaultsPreview { previewDefaults() ); this.client.hangWrites = scenario === 'device-model-writing'; + this.client.failReads = scenario === 'device-model-pick-failed'; this.remotePageState.setAccountUserId('octocat'); this.remotePageState.setAccountUsername('octocat'); this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); @@ -202,7 +208,7 @@ export struct DeviceModelDefaultsPreview { this.remotePageState.connectionState = 'connected'; // An older desktop is one that never advertised the command, so the page is // reached through the same capability check the app uses. - this.remotePageState.setHostCapabilities(scenario === 'device-model-degraded' ? + this.remotePageState.setHostCapabilities(this.degraded(scenario) ? ['workspace_id_references_v1'] : [REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1]); this.actions = previewActions(this.client); this.shellState.showSettings = true; @@ -236,6 +242,11 @@ export struct DeviceModelDefaultsPreview { return scenario.endsWith('-dark') ? scenario.slice(0, scenario.length - 5) : scenario; } + /** The desktop never advertised the command, on the settings page or its picker. */ + private degraded(scenario: string): boolean { + return scenario === 'device-model-degraded' || scenario === 'device-model-degraded-pick'; + } + /** The role the picker is opened on, or an empty string for the settings page. */ private pickerRole(scenario: string): string { if (scenario === 'device-model-pick-fast') { @@ -247,6 +258,11 @@ export struct DeviceModelDefaultsPreview { if (scenario === 'device-model-pick-primary') { return 'primary'; } + // The picker in the two states it has no list for: a desktop that cannot + // answer this page at all, and one that stopped answering it. + if (scenario === 'device-model-degraded-pick' || scenario === 'device-model-pick-failed') { + return 'fast'; + } return ''; } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets index c81a4dc43f..1356cbfb69 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -1,5 +1,10 @@ import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; -import { ChatMessage, RecentWorkspaceEntry, RemoteSession } from '../../model/RemoteModels'; +import { + ChatMessage, + RecentWorkspaceEntry, + RemoteSession, + REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1 +} from '../../model/RemoteModels'; import { MiniAppSummary } from '../../model/MiniAppModels'; import { CloudAccountDevice } from '../../services/CloudAccountClient'; import { ChatSurface } from '../state/ChatSurface'; @@ -341,6 +346,11 @@ export struct ImmersiveBottomPreview { this.remotePageState.setControlTarget('account_device', 'account-desktop-1', 'Account desktop 1'); this.remotePageState.setDesktopIdentity('Account desktop 1', 'account-desktop-1'); this.remotePageState.connectionState = 'connected'; + // The model settings entry on the controlled desktop's row is offered or + // greyed by the desktop's own capability set, and an empty set can only ever + // be the greyed state. This fixture is the connected desktop's, so it + // advertises the command and the sheet shows the entry it really shows. + this.remotePageState.setHostCapabilities([REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1]); this.previewActions = previewActionsWithDevices(settingsDevices()); return; } diff --git a/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs b/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs index 3060e54890..a8d1617398 100644 --- a/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/model-role-pages.test.cjs @@ -411,6 +411,103 @@ test('the page starts from the controller own unavailable snapshot', () => { assert.equal(DeviceModelDefaultsSheetState.name, 'DeviceModelDefaultsSheetState'); }); +// --------------------------------------------------------------------------- +// The picker over a device that stopped answering +// --------------------------------------------------------------------------- + +test('a gate that drops while the picker is open states the reason, not a choice', async () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const sheet = sheetWith({ + getModelCatalog: async () => catalog({primary: 'm-primary'}), + setModelDefaults: async () => ({}) + }); + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), true); + await sheet.ensureLoaded(); + + // The frame the picker draws for a device that is answering: the list is the + // device's, and the mark on the pinned row is the value it reported. + assert.deepEqual(policy.pickerView(sheet.state), { + reason: false, retry: false, reading: false, list: true, answered: true + }); + assert.equal(policy.isUnsetSelected(sheet.state.roles, 'fast'), true); + + // The link drops while the picker is up. The snapshot keeps the roles it was + // read with, so the raw predicate still calls the empty slot a choice — which + // is exactly the sentence the page must not draw from it. + assert.equal(sheet.setGate(gate(true, false, 'desk-1')), true); + assert.equal(sheet.state.error, 'status.remoteUnavailable'); + assert.equal(policy.isUnsetSelected(sheet.state.roles, 'fast'), true); + const dropped = policy.pickerView(sheet.state); + assert.equal(dropped.reason, true, 'the page states the reason instead of a list'); + assert.equal(dropped.answered, false, 'a snapshot the gate replaced answers nothing'); + assert.equal(dropped.list, false, 'a device that cannot answer has no list to offer'); + // Reconnecting is the sheet's own business: it re-arms the gate when the link + // returns, so there is nothing here for a retry to repeat. + assert.equal(dropped.retry, false); + assert.equal(policy.canRepeatRead(sheet.state), false); + + // An older desktop is the same page, with the reason it has. + assert.equal(sheet.setGate(gate(false, true, 'desk-1')), true); + assert.equal(sheet.state.error, 'settings.model.unsupported'); + assert.equal(policy.pickerView(sheet.state).reason, true); + assert.equal(policy.pickerView(sheet.state).retry, false); +}); + +test('a read that fails while the picker is open keeps the rows but not their marks', async () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + let reads = 0; + const sheet = sheetWith({ + getModelCatalog: async () => { + reads += 1; + if (reads === 2) { + throw new Error('The desktop stopped answering the model catalog.'); + } + return catalog({primary: 'm-primary'}); + }, + setModelDefaults: async () => ({}) + }); + sheet.setGate(gate(true, true, 'desk-1')); + await sheet.ensureLoaded(); + + // Another desktop is a different answer, so the page reads again — and this + // read is the one that fails. + assert.equal(sheet.setGate(gate(true, true, 'desk-2')), true); + await sheet.load(); + assert.equal(sheet.state.failure, 'load'); + assert.equal(sheet.state.error, 'The desktop stopped answering the model catalog.'); + + const view = policy.pickerView(sheet.state); + assert.equal(view.reason, true, 'the host\'s own words are what the page states'); + assert.equal(view.retry, true, 'repeating the read is the way out of a failed read'); + assert.equal(view.list, true, 'the device can still answer, so its rows stay'); + assert.equal(view.answered, false, 'but the last read is not this desktop\'s answer'); + assert.equal(view.reading, false); + + // The retry repeats that read and nothing else. + assert.equal(reads, 2); + await sheet.retry(); + assert.equal(reads, 3); + assert.equal(sheet.state.error, ''); + assert.deepEqual(policy.pickerView(sheet.state), { + reason: false, retry: false, reading: false, list: true, answered: true + }); +}); + +test('a picker opened before the first read says so instead of blaming the catalog', () => { + const {ModelRoleCandidatesPolicy: policy} = policyModule(); + const sheet = sheetWith({ + getModelCatalog: async () => catalog(), + setModelDefaults: async () => ({}) + }); + // Armed, and not read: a sheet restored straight onto the picker is here. + assert.equal(sheet.setGate(gate(true, true, 'desk-1')), true); + const view = policy.pickerView(sheet.state); + assert.equal(view.reading, true); + assert.equal(view.list, false); + assert.equal(view.answered, false, 'a list nobody has read yet is not an empty catalog'); + assert.equal(view.reason, false); +}); + // --------------------------------------------------------------------------- // Navigation // --------------------------------------------------------------------------- @@ -482,6 +579,64 @@ test('neither model page offers a manual refresh', () => { } }); +test('both model pages state a device that cannot answer', () => { + const picker = source('entry/src/main/ets/pages/components/ModelRolePickerPanel.ets'); + + // The card, the marks and the sentence about an empty list all come from one + // view of one state, so a dropped link cannot leave a marked row or an empty + // list being blamed on the catalog behind. + assert.match(picker, /if \(this\.view\(\)\.reason\) \{/, 'the reason has to replace the list'); + assert.match(picker, /if \(this\.view\(\)\.reading\) \{/, 'and an unread page has to say so'); + assert.match(picker, /if \(this\.view\(\)\.list\) \{/, 'the list is drawn only while it is an answer'); + assert.match(picker, /this\.view\(\)\.answered && this\.candidates\(\)\.length === 0/, + 'an empty list is only explained away when the device is the one saying it'); + assert.match(picker, /view\(\)\.answered &&/, 'the marks have to come from the view'); + assert.match(picker, /ModelUnavailableCard\(\{/, 'the picker states the reason in the shared card'); + assert.match(picker, /deviceModels\.retry\(\)/, 'and offers the retry the state allows'); + + // The settings page reaches the same card, so the two pages cannot describe the + // same desktop differently. + const settings = source('entry/src/main/ets/pages/components/DeviceModelDefaultsPanel.ets'); + assert.match(settings, /ModelUnavailableCard\(\{/, 'the settings page shares the card'); + assert.match(settings, /canRepeatRead\(this\.deviceModelState\(\)\)/, + 'and decides the retry from the same rule as the picker'); +}); + +test('the connected desktop offers the model settings entry the previews shoot', () => { + const normalize = text => text.replace(/\s+/g, ' '); + const immersive = source('entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets'); + const surfaces = source('entry/src/main/ets/pages/components/AppRootOverlaySurfaces.ets'); + const profile = source('entry/src/main/ets/pages/components/AccountProfilePanel.ets'); + const pageState = source('entry/src/main/ets/pages/state/RemotePageState.ets'); + const models = source('entry/src/main/ets/model/RemoteModels.ets'); + + // The chain behind the pill, end to end: the capability the desktop advertised + // is what the sheet is told, and what the row draws. + assert.match(pageState, /setHostCapabilities\(capabilities: string\[\]\): void \{/, + 'the host capability set is state the page carries'); + assert.match(pageState, /supportsHostCapability\(capability: string\): boolean \{/, + 'and the app asks it by capability'); + assert.match(normalize(surfaces), + /supportsDeviceModelDefaults: this\.remotePageState\.supportsHostCapability\( REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1\)/, + 'AppSettingsSurface has to hand the sheet the desktop\'s own capability'); + assert.match(normalize(immersive), /setHostCapabilities\(\[REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1\]\)/, + 'the settings fixture has to advertise it, or every shot of the entry is the greyed one'); + // A literal here would advertise a capability the app does not look for, so both + // ends name the constant the wire value is declared once. + assert.match(models, /REMOTE_CAPABILITY_DEVICE_MODEL_DEFAULTS_V1: string = 'device_model_defaults_v1'/); + assert.equal(/['"]device_model_defaults_v1['"]/.test(immersive), false, + 'the fixture has to name the constant, not its value'); + assert.equal(/['"]device_model_defaults_v1['"]/.test(surfaces), false); + + // The pill's enabled branch, distinct from the greyed one with its badge. + assert.match(profile, /entryEnabled\(this\.supportsDeviceModelDefaults\)/); + assert.match(profile, /\.opacity\(this\.deviceModelSettingsEnabled\(\) \? 1 : 0\.38\)/); + assert.match(profile, /if \(!this\.deviceModelSettingsEnabled\(\)\) \{/); + assert.match(profile, /settings\.model\.upgradeBadge/); + assert.match(profile, /\.id\(`device-model-entry-\$\{this\.deviceModelSettingsEnabled\(\) \? 'enabled' : 'degraded'\}`\)/, + 'the pill has to say which of its two states it is in'); +}); + test('every key the two pages render exists in both catalogs', () => { const catalogs = locale => new Set(JSON.parse( source(`entry/src/main/resources/${locale}/element/string.json`) @@ -549,10 +704,18 @@ test('the device-model scenarios are one family the launcher can open', () => { 'the gallery has to route the family to the preview'); for (const scenario of [ 'device-model-pick-fast', 'device-model-pick-multimodal', 'device-model-pick-primary', + 'device-model-pick-failed', 'device-model-degraded-pick', 'device-model-writing', 'device-model-updated', 'device-model-degraded', 'device-model-empty' ]) { assert.ok(preview.includes(`'${scenario}'`), `${scenario} has to be a state the fixture can mount`); } + // The two picker states with no list: a desktop that cannot answer the page and + // one that stopped answering it are different reasons, so they are different + // boards rather than one flag. + assert.match(preview, /this\.client\.failReads = scenario === 'device-model-pick-failed'/, + 'a failed read has to be a state the fixture can be put in'); + assert.match(preview, /scenario === 'device-model-degraded' \|\| scenario === 'device-model-degraded-pick'/, + 'the older desktop has to cover its picker too'); // `device-models` is the settings page itself: the state every other scenario // changes one thing away from, so it is the fixture's own default. assert.match(preview, /scenario === 'device-model-empty' \? \[\] : previewModels\(\)/,