diff --git a/.agents/skills/dsh-dev/SKILL.md b/.agents/skills/dsh-dev/SKILL.md index e2a682c32c..bfdce7e212 100644 --- a/.agents/skills/dsh-dev/SKILL.md +++ b/.agents/skills/dsh-dev/SKILL.md @@ -59,6 +59,8 @@ A saved AX model id may disappear from the pinned native catalog. Preserve the l ## Pitfall log +An AX launch pins `agent-default-model` through a command-line Patch. On DSH `0.2.1-rc.2`, changing that default through `onboardingModel` can therefore refuse with `Configuration for "agent-default-model" is overridden by a home patch or command-line overlay`. This is native configuration ownership, not a Remote Host transport failure. Preserve the refusal and unchanged default; verify preset writes separately. A no-op `home-model-set` does not prove the default can change. Qualify successful default changes on an unlocked isolated Profile, without editing another session's Patch or the shared AX selection (#2500). + Native approval `never` means reject before the answerer waterfall, even under `danger-full-access`; it does not mean automatic approval. In #2080, combining that policy with a BotHarness Human-only permission-management exception swallowed the intended card. Top-level Bot Full Access now uses `ask` and answers ordinary requests automatically at its existing application responder; delegated Subagents keep their native policy. Verify both an ordinary action and a permission-management Human card on the real Host. On DSH `0.2.1-rc.2`, the first-run Preview Notice can hide a shell whose Settings entry now lives under the account menu. An older verification prelude that waits for a visible `Settings` button before dismissing overlays times out although Client diagnostics report `shell-ready`. Preserve the failure capture, dismiss the actual `Continue` dialog, then address the observed `Bot mode` or `Account menu` entry by accessible name. This qualifies shell navigation only; it does not establish a model reply or native IM delivery. diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index 15ce99f903..8a03056399 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -2,7 +2,7 @@ | Feature | Needs a model | Scripted journey | File | | --- | --- | --- | --- | -| Browser Remote Host pairing, tabs and Host Feed | only for a reply | `verify-app` two-Host path | [remote-hosts.md](remote-hosts.md) | +| Browser Remote Host pairing, icon strip, Host Feed and Host-level settings | only for a reply | `verify-app` two-Host path | [remote-hosts.md](remote-hosts.md) | | VRM companion Panel | yes for speaking | `scripts/e2e-vrm-companion.mjs` | [vrm-companion.md](vrm-companion.md) | | Desktop whole-window zoom (controlled S1 / Windows candidate) | no | `scripts/e2e-desktop-zoom-launch.mjs`, `scripts/e2e-desktop-zoom-windows.mjs`, `scripts/e2e-dock-zoom.mjs`, `scripts/e2e-workspace-chrome.mjs` | [desktop-window-zoom.md](desktop-window-zoom.md) | | Global search: Alt/Option+K, settings results and pinyin matching | no | `verify-app` Bot mode path | [global-search.md](global-search.md) | diff --git a/.agents/skills/verify-deepseekbot/features/remote-hosts.md b/.agents/skills/verify-deepseekbot/features/remote-hosts.md index d3193d30b3..3a98726340 100644 --- a/.agents/skills/verify-deepseekbot/features/remote-hosts.md +++ b/.agents/skills/verify-deepseekbot/features/remote-hosts.md @@ -14,6 +14,7 @@ The browser pairs a Remote Host as a Paired Device and reads its Host Feed throu - `remove`: removing a Host forgets its rows, stops its Inbox and releases cached avatar object URLs. - `viewers`: a remote Bot DM opens Browser or Computer when that Bot has access. The shared Host viewer modules read frames and send input to that Host only; closing, switching source or losing the connection tears down the viewer and releases requested control. - `viewer-control`: queued input belongs to one takeover. Resume or frame loss invalidates it; frame recovery remains read-only even when the Host Feed never disconnected. Remove closes viewers and waits up to five seconds for takeover/release cleanup before revoking the pairing. +- `settings`: right-click a Remote Host icon → Remote settings opens that Host's models, usage and storage. Default model and preset changes use its shared Host client. Usage includes Memory activity, without a link to a This Host Bot page. Storage retains preview, acknowledgement and destructive confirmation. ## How to get to it (user POV) @@ -33,6 +34,10 @@ For control races, delay an input response, queue text, Resume, then take contro Keep the remote screen stationary during frame recovery. The Host must keep its sequence unchanged and answer matching `after` polls with 204 after ten seconds; the viewer recovers by fetching the current frame without that stale cursor, not by requiring a screen change. In both UI languages, inject a frame/input failure: the Web panel must show localized recovery guidance and a reconnect action, while the viewer snapshot retains the original diagnostic notice rather than rendering it as product copy. +For Remote settings, use two disposable Hosts with different home basenames and distinguishable model presets and chat data. Open Host B's icon menu in Host A's browser. Capture the menu and all three settings sections in light and dark. Change B's default model or a preset, then read both Hosts with `model-catalog` and `model-presets`; A must be unchanged. Compare Usage and Memory with B's `overview-usage` and `overview-memory`. In Storage, select a disposable chat, preview media or whole messages, verify Clear/Delete is disabled before acknowledgement, then confirm. Read B's storage and timeline and verify A's data is unchanged. Exercise both cleanup targets; use their matching CLI preview/confirm commands against B as well. Disconnect B and revoke its browser pairing in separate runs: settings must show an unavailable state or refusal, with no local data fallback or replayed write. Never clean up real conversations for this journey. + +The default-model proof must change the model value on a Profile whose native settings owner can save it; a same-value no-op is insufficient. Keep a page's observed revision, change the default from another client, and verify the old page refuses without overwriting it. Explicit refresh must permit a subsequent save. Omit the catalog revision at the test transport boundary to verify the shared client refuses before any write; label that case as fault injection. For existing presets, verify Enter plus blur increments the revision once, a later blur saves independently, composition Enter does not submit, and a delayed write remains on its original Host/preset after closing and switching Hosts. Preserve the draft/error on a rejected save. New presets retain an explicit create action. Reject a remote storage preview token on the other Host, and verify neither cleanup command proceeds without its confirmation flag. + ## Gotchas - The workspace hides `data-panel-empty-action` descendants. Viewer reconnect must stay in its own controls; verify the button is visible and clickable inside the real workspace, not only in a standalone panel render. diff --git a/.github/pr-screenshots/2500-t8/README.md b/.github/pr-screenshots/2500-t8/README.md new file mode 100644 index 0000000000..1c8a30436e --- /dev/null +++ b/.github/pr-screenshots/2500-t8/README.md @@ -0,0 +1,24 @@ +# Remote Host settings evidence + +Requested by Vain, issue #2500, PR #2540. Captured 2026-10-22 JST at 1440 × 900, English, light/dark, in disposable local Web Hosts. No Desktop, native phone or physical-machine tailnet qualification. + +These captures run source `e04195b49ad952b4c5da5e391ee5c720609b25f7` on main `30f62e48c7d9b79a085a52df691af277d0fe6fe9`, published at `68003945df1b81fb61f1574535e00b7447ef75e8`. Later rebases integrate Activity, viewers and the Expo patch baseline; these images remain historical runtime evidence, not fresh captures of the integrated head. `bl.png` / `bd.png` retain the original pre-feature menu baseline; new settings pages use that absence state as their predecessor. + +| Files | Observed result | +| ------------ | ---------------------------------------------------------------------------------------------------- | +| al/ad | Remote settings added beside Change icon and Remove | +| ml/md | Source Host default genuinely changed from Flash to Pro; native revision 1 → 2; local Host unchanged | +| pl/pd | Existing preset name saved on Enter/blur; composition Enter does not submit | +| st | Another client changed revision 2 → 3; the old page refused without overwriting | +| mr | Test transport omitted catalog revision; shared client refused before sending a write | +| ul/ud | Source Host usage and Memory activity | +| sl/sd, mc/xc | Source Host storage, media/message previews and explicit confirmation | +| ol/od | Offline settings content unmounted without local fallback | +| rl/rd | Revoked pairing unmounted settings content | +| xl/xd | Removing the only Remote Host forgot its browser record and hid the strip | + +The source Profile retained a provider-only AX Patch so the native default-model settings owner could persist changes. No owner or revision check was bypassed. CLI also performed a real Pro → Flash change, rejected stale revisions, rejected cleanup without `--confirm`, and rejected the source Host's preview token on the other Host. The browser's delayed preset write stayed on its original Host after closing and switching to This host. + +The fresh live-model attempt selected only `deepseek-v4-pro` and received `429 GoUsageLimitError: Go usage limit exceeded`. No successful new reply is claimed, and no retry was made. Existing task messages visible in the background are older fixtures. + +At that source version, full build, typecheck, lint, format, docs build, 119 related tests and 7 artifact/docs tests passed. Browser diagnostics showed no page exceptions; expected request cancellations and disconnect failures are retained in private task evidence. Task Hosts were stopped and their ports released after QA. Native iOS/Android light/dark/large-text checks remain an external handoff for the shared phone model-settings change. The integrated Expo baseline requires fresh native development builds in the coordinated device lane; source tests do not qualify existing device binaries. Native acceptance is a unified follow-up, not a separate merge gate under Vain's updated direction. diff --git a/.github/pr-screenshots/2500-t8/ad.png b/.github/pr-screenshots/2500-t8/ad.png new file mode 100644 index 0000000000..cd99f7628e Binary files /dev/null and b/.github/pr-screenshots/2500-t8/ad.png differ diff --git a/.github/pr-screenshots/2500-t8/al.png b/.github/pr-screenshots/2500-t8/al.png new file mode 100644 index 0000000000..1719560f9a Binary files /dev/null and b/.github/pr-screenshots/2500-t8/al.png differ diff --git a/.github/pr-screenshots/2500-t8/bd.png b/.github/pr-screenshots/2500-t8/bd.png new file mode 100644 index 0000000000..fc1fe6df61 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/bd.png differ diff --git a/.github/pr-screenshots/2500-t8/bl.png b/.github/pr-screenshots/2500-t8/bl.png new file mode 100644 index 0000000000..d0350ed475 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/bl.png differ diff --git a/.github/pr-screenshots/2500-t8/mc.png b/.github/pr-screenshots/2500-t8/mc.png new file mode 100644 index 0000000000..1fea8bfd7f Binary files /dev/null and b/.github/pr-screenshots/2500-t8/mc.png differ diff --git a/.github/pr-screenshots/2500-t8/md.png b/.github/pr-screenshots/2500-t8/md.png new file mode 100644 index 0000000000..b31ae15fa3 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/md.png differ diff --git a/.github/pr-screenshots/2500-t8/ml.png b/.github/pr-screenshots/2500-t8/ml.png new file mode 100644 index 0000000000..42b20fce6a Binary files /dev/null and b/.github/pr-screenshots/2500-t8/ml.png differ diff --git a/.github/pr-screenshots/2500-t8/mr.png b/.github/pr-screenshots/2500-t8/mr.png new file mode 100644 index 0000000000..e03abea62b Binary files /dev/null and b/.github/pr-screenshots/2500-t8/mr.png differ diff --git a/.github/pr-screenshots/2500-t8/od.png b/.github/pr-screenshots/2500-t8/od.png new file mode 100644 index 0000000000..6bbeaae8ea Binary files /dev/null and b/.github/pr-screenshots/2500-t8/od.png differ diff --git a/.github/pr-screenshots/2500-t8/ol.png b/.github/pr-screenshots/2500-t8/ol.png new file mode 100644 index 0000000000..c4833b18f3 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/ol.png differ diff --git a/.github/pr-screenshots/2500-t8/pd.png b/.github/pr-screenshots/2500-t8/pd.png new file mode 100644 index 0000000000..37803b776a Binary files /dev/null and b/.github/pr-screenshots/2500-t8/pd.png differ diff --git a/.github/pr-screenshots/2500-t8/pl.png b/.github/pr-screenshots/2500-t8/pl.png new file mode 100644 index 0000000000..e849d76448 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/pl.png differ diff --git a/.github/pr-screenshots/2500-t8/rd.png b/.github/pr-screenshots/2500-t8/rd.png new file mode 100644 index 0000000000..cf8cd70e30 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/rd.png differ diff --git a/.github/pr-screenshots/2500-t8/rl.png b/.github/pr-screenshots/2500-t8/rl.png new file mode 100644 index 0000000000..a7a3772f51 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/rl.png differ diff --git a/.github/pr-screenshots/2500-t8/sd.png b/.github/pr-screenshots/2500-t8/sd.png new file mode 100644 index 0000000000..618c1401f2 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/sd.png differ diff --git a/.github/pr-screenshots/2500-t8/sl.png b/.github/pr-screenshots/2500-t8/sl.png new file mode 100644 index 0000000000..c3b62c24ab Binary files /dev/null and b/.github/pr-screenshots/2500-t8/sl.png differ diff --git a/.github/pr-screenshots/2500-t8/st.png b/.github/pr-screenshots/2500-t8/st.png new file mode 100644 index 0000000000..fc6dce6fb7 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/st.png differ diff --git a/.github/pr-screenshots/2500-t8/ud.png b/.github/pr-screenshots/2500-t8/ud.png new file mode 100644 index 0000000000..b2cb2992c4 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/ud.png differ diff --git a/.github/pr-screenshots/2500-t8/ul.png b/.github/pr-screenshots/2500-t8/ul.png new file mode 100644 index 0000000000..375c2268ce Binary files /dev/null and b/.github/pr-screenshots/2500-t8/ul.png differ diff --git a/.github/pr-screenshots/2500-t8/xc.png b/.github/pr-screenshots/2500-t8/xc.png new file mode 100644 index 0000000000..12752c8872 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/xc.png differ diff --git a/.github/pr-screenshots/2500-t8/xd.png b/.github/pr-screenshots/2500-t8/xd.png new file mode 100644 index 0000000000..64c2ffba5a Binary files /dev/null and b/.github/pr-screenshots/2500-t8/xd.png differ diff --git a/.github/pr-screenshots/2500-t8/xl.png b/.github/pr-screenshots/2500-t8/xl.png new file mode 100644 index 0000000000..8c1064f7c8 Binary files /dev/null and b/.github/pr-screenshots/2500-t8/xl.png differ diff --git a/apps/mobile/src/host/bot-settings.ts b/apps/mobile/src/host/bot-settings.ts index e9e917e259..08657476fe 100644 --- a/apps/mobile/src/host/bot-settings.ts +++ b/apps/mobile/src/host/bot-settings.ts @@ -49,6 +49,7 @@ export interface ModelPreset { export interface BotModel { revision: number; + defaultRevision?: number; plan?: ModelPlan; repair?: string; models: ModelCatalogEntry[]; @@ -251,8 +252,14 @@ export async function loadGlobalModel(client: HostClient): Promise { const models = record(catalog)?.['models']; if (!Array.isArray(models)) throw new HostAnswerInvalid('modelCatalog returned no models.'); const fallback = route(record(catalog)?.['default']); + const defaultRevision = record(catalog)?.['defaultRevision']; return { revision: 0, + ...(typeof defaultRevision === 'number' && + Number.isSafeInteger(defaultRevision) && + defaultRevision >= 0 + ? { defaultRevision } + : {}), models: some(models, catalogEntry), presets: some(record(presets)?.['presets'], preset), ...(fallback === undefined ? {} : { fallback }), diff --git a/apps/mobile/src/host/model-settings.ts b/apps/mobile/src/host/model-settings.ts index b9b25d916c..eb3efc79e8 100644 --- a/apps/mobile/src/host/model-settings.ts +++ b/apps/mobile/src/host/model-settings.ts @@ -148,7 +148,18 @@ export async function setModelRoute( throw new HostAnswerInvalid('MODEL_UNAVAILABLE'); if (scope.kind === 'global') { if (field !== 'orchestrator') throw new HostAnswerInvalid('GLOBAL_MODEL_REQUIRED'); - await client.call('onboardingModel', { expectedRevision: 0, route, globalDefault: true }); + if ( + state.defaultRevision === undefined || + !Number.isSafeInteger(state.defaultRevision) || + state.defaultRevision < 0 + ) + throw new HostAnswerInvalid('MODEL_DEFAULT_REVISION_REQUIRED'); + await client.call('onboardingModel', { + expectedRevision: 0, + expectedDefaultRevision: state.defaultRevision, + route, + globalDefault: true, + }); return; } const previous = modelRoutes(state); diff --git a/apps/mobile/test/model-settings.test.ts b/apps/mobile/test/model-settings.test.ts index 41bf74ff7f..8fa0e76c18 100644 --- a/apps/mobile/test/model-settings.test.ts +++ b/apps/mobile/test/model-settings.test.ts @@ -52,7 +52,7 @@ function setup() { credential: fake.credential, carrier: fake.carrier, }); - fake.methods.set('modelCatalog', () => ({ models, default: flash })); + fake.methods.set('modelCatalog', () => ({ models, default: flash, defaultRevision: 5 })); fake.methods.set('modelPresets', () => ({ presets: [preset] })); fake.methods.set('modelPlan', () => ({ revision: 4, plan })); return { fake, client }; @@ -108,10 +108,17 @@ describe('native Model Plans and Presets', () => { it('writes the global default without a Bot slug or any Bot override mutation', async () => { const { fake, client } = setup(); fake.methods.set('onboardingModel', () => ({ revision: 0 })); - await setModelRoute(client, { kind: 'global' }, state, 'orchestrator', pro); - expect(fake.calls.map(({ method, args }) => [method, args])).toEqual([ - ['onboardingModel', { expectedRevision: 0, globalDefault: true, route: pro }], - ]); + const observed = await modelSettingsResource(client, { kind: 'global' }).refresh(); + await setModelRoute(client, { kind: 'global' }, observed, 'orchestrator', pro); + expect(fake.calls.at(-1)).toMatchObject({ + method: 'onboardingModel', + args: { + expectedRevision: 0, + expectedDefaultRevision: 5, + globalDefault: true, + route: pro, + }, + }); }); it('customizes only the Bot Orchestrator and uses the observed revision', async () => { diff --git a/changes/2500-remote-settings.md b/changes/2500-remote-settings.md new file mode 100644 index 0000000000..c5909d65d8 --- /dev/null +++ b/changes/2500-remote-settings.md @@ -0,0 +1,13 @@ +--- +section: Added +--- + +## en + +- Open a Remote Host's settings from its icon menu to change its default model and model presets, inspect usage and Memory activity, and preview and confirm storage cleanup on that Host ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). +- Remote Host and phone default model changes preserve the observed Host revision and refuse stale or missing versions. Existing remote preset names save on Enter or blur; creating a preset remains explicit ([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500)). + +## zh + +- 从远程图标菜单打开设置,修改该 Host 的默认模型和模型预设、查看用量与 Memory 活动,并预览和确认清理该 Host 的存储空间([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 +- 远程 Host 与手机修改默认模型时保留读取到的 Host 版本,拒绝过期或缺失版本的写入。已有远程预设名称按 Enter 或失焦保存;新建预设仍需明确创建([#2500](https://github.com/BotHarness/DeepSeekBot/issues/2500))。 diff --git a/docs/architecture/botharness-architecture.md b/docs/architecture/botharness-architecture.md index 6cc976c9f2..d7da660bd0 100644 --- a/docs/architecture/botharness-architecture.md +++ b/docs/architecture/botharness-architecture.md @@ -1142,6 +1142,8 @@ The application-defined browser `RemoteHosts` owner keeps its pairings and selec Activity Center consumes each Remote Host's shared Inbox through `RemoteHosts.activity`, reusing the existing `HumanInboxView` for Host groups. Its Host-scoped source supplies identity, pagination, source navigation and commands; it never receives This Host's `BridgeActions`. `createHostActivityActions` adapts the shared attention and request operations to the existing approval and question cards. Each operation resolves the current pairing before calling its Host client, and confirmed changes refresh that Host's Inbox. Filtered observers share the Host client and stop with their last subscriber. This Host still reads its existing bridge/store until that Host Feed slice migrates. Device Notification Preference remains a subsequent device-owned slice; the browser has no closed-page notification path. +Remote Host settings resolve the selected Host's existing client from `RemoteHosts`. The browser reuses `apps/mobile/src/host/model-settings`, `usage` and `storage` for default models, presets, usage/Memory activity and cleanup; the existing Web settings, model picker, charts and destructive confirmation controls present those operations. Settings unmount their content when the source is disconnected or removed. Model resources are keyed by client and scope; there is no This Host fallback, Bot-profile link or automatic write replay. Cleanup retains the owning Host's preview token, scope and explicit confirmation. Host looks remain device-local and independent of these Host-owned settings. + ### Version-bound Memory image previews (#2214, first Web slice) The application-defined Memory Service exposes bounded PNG representations alongside its existing current-file and Git-diff queries. Metadata identifies worktree/index/exact reachable commit plus a SHA-256 byte revision; authenticated image transfer rechecks that owner, repository selector, path and revision before returning `image/png` with no-store semantics. It does not accept arbitrary blob IDs, revision expressions or Host paths. Current reads retain path containment, control-directory exclusion and safe-open checks; Git reads accept regular-file blobs only. A changed mutable source returns an explicit stale refusal rather than current bytes under an old version label. diff --git a/docs/bot-cli.md b/docs/bot-cli.md index a8619ab8fe..80fa872b71 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -56,6 +56,10 @@ For a blank bot, `--core-memory ` or `--core-memory-stdin` writes the root ## Live Host commands +### Remote Host settings + +The browser's Remote Host icon menu → **Remote settings** uses the same Host owners as these online commands. Select the source Host with `--host ` and its private `--token-file ` (or the existing environment variables); never point a remote-setting command at the serving Host by default. `model-catalog`, `home-model-set`, `model-presets`, `model-preset-create` and `model-preset-update` cover models; `overview-usage` and `overview-memory` cover usage and Memory activity; `storage-usage` and both storage cleanup preview/confirm pairs cover storage. Destructive confirmations still require `--confirmation-stdin --confirm`, using the preview from that same Host. An unavailable or refusing Host never falls back to a local profile. These commands do not read browser pairing secrets or change device-local Host looks. + Set `DEEPSEEKBOT_HOST` to the running Host's loopback origin, or a tailnet HTTPS origin. A tailnet origin (for example `tailscale serve` in front of a loopback Host) works only when the Host was started with `--trusted-host ..ts.net`; otherwise login succeeds but DSH's `/api` Host fence refuses every request and the CLI returns `host-untrusted`. Supply its current launch token through `DEEPSEEKBOT_HOST_TOKEN` or `--token-file `, never a token argument. `--host ` overrides the environment. Login happens per invocation, cookies remain in memory, and restarting the Host requires its new token. Live commands never open the profile database. See [ADR-0159](adr/0159-live-cli-verbs-ride-the-dsh-http-carrier.md). ### Online creation and headless debugging diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 21e1b5108f..68c0e816d6 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -56,6 +56,10 @@ deepseekbot search ## 在线 Host 命令 +### 远程设置 + +浏览器远程图标菜单中的「远程设置」与以下在线命令使用同一个 Host 所有者。通过 `--host ` 和私有的 `--token-file `(或已有环境变量)指定来源 Host,不要默认指向提供当前网页的本机。模型使用 `model-catalog`、`home-model-set`、`model-presets`、`model-preset-create` 和 `model-preset-update`;用量与 Memory 活动使用 `overview-usage`、`overview-memory`;存储使用 `storage-usage` 及两组清理预览/确认命令。删除仍须传入 `--confirmation-stdin --confirm`,且预览必须来自同一 Host。连接失败或被拒绝时,不会改用本地 Profile。这些命令不读取浏览器的配对密钥,也不修改本设备保存的 Host 外观。 + 将 `DEEPSEEKBOT_HOST` 设为运行中 Host 的 loopback origin 或 tailnet HTTPS origin。使用 tailnet origin(例如 loopback Host 前面的 `tailscale serve`)时,Host 必须以 `--trusted-host ..ts.net` 启动;否则登录能成功,但 DSH 的 `/api` Host 校验会拒绝每个请求,CLI 返回 `host-untrusted`。`--host ` 可覆盖它。当前启动令牌通过 `DEEPSEEKBOT_HOST_TOKEN` 或 `--token-file <私有文件>` 提供,不得把令牌值放入参数。每次调用重新登录,cookie 只留在内存;Host 重启后须使用新令牌。在线命令不打开 Profile 数据库。参见 [ADR-0159](adr/0159-live-cli-verbs-ride-the-dsh-http-carrier.md)。 ### 在线创建与无浏览器调试 diff --git a/packages/client/src/client/bridge.ts b/packages/client/src/client/bridge.ts index b842ae8d0f..e7643e083c 100644 --- a/packages/client/src/client/bridge.ts +++ b/packages/client/src/client/bridge.ts @@ -2627,7 +2627,7 @@ export async function previewStorageCleanup( export async function confirmStorageCleanup( call: BridgeCall, - preview: StorageCleanupPreview, + preview: Pick, ): Promise { const result = asRecord( await unwrap(call, 'storageCleanupConfirm', { diff --git a/packages/client/src/client/host-tabs.tsx b/packages/client/src/client/host-tabs.tsx index a12e921712..4c4170e1b0 100644 --- a/packages/client/src/client/host-tabs.tsx +++ b/packages/client/src/client/host-tabs.tsx @@ -4,6 +4,7 @@ import * as stylex from '@stylexjs/stylex'; import type { RemoteHost, RemoteConversationTarget } from './remote-hosts.js'; import { ALL_HOSTS, remoteHosts, THIS_HOST } from './remote-hosts.js'; import { UnreadCount } from './unread-count.js'; +import { RemoteHostSettings } from './remote-host-settings.js'; import { ChannelSidebarIcon } from './channel-sidebar-icon.js'; import { RemoteAvatar } from './remote-avatar.js'; import { ConfirmDialog, FormDialog } from './ds/form-dialog.js'; @@ -262,6 +263,7 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null const [menu, setMenu] = useState<{ hostId: string; point: MenuPoint } | undefined>(undefined); const [removing, setRemoving] = useState(undefined); const [styling, setStyling] = useState<{ hostId: string; name: string } | undefined>(undefined); + const [settings, setSettings] = useState<{ hostId: string; name: string }>(); const proxy = useRef(null); if (snapshot.hosts.length === 0) return null; const thisId = thisHost.id ?? THIS_HOST; @@ -335,11 +337,16 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null { id: 'look', label: t('remoteHosts.look') }, ...(menuRemote === undefined ? [] - : [{ id: 'remove', label: t('remoteHosts.remove.confirm'), danger: true }]), + : [ + { id: 'settings', label: t('remoteHosts.settings') }, + { id: 'remove', label: t('remoteHosts.remove.confirm'), danger: true }, + ]), ]} onSelect={(id) => { setMenu(undefined); if (id === 'look') setStyling({ hostId: menu.hostId, name: menuName }); + if (id === 'settings' && menuRemote !== undefined) + setSettings({ hostId: menuRemote.host.hostId, name: menuRemote.host.hostName }); if (id === 'remove' && menuRemote !== undefined) setRemoving(menuRemote); }} onClose={() => setMenu(undefined)} @@ -354,6 +361,15 @@ export function HostTabs({ t }: { t: BotHarnessTranslate }): ReactElement | null t={t} /> )} + {settings === undefined ? null : ( + setSettings(undefined)} + t={t} + /> + )} {removing === undefined ? null : ( & Partial>; t: BotHarnessTranslate; }): ReactElement { const [value, setValue] = useState(); @@ -211,15 +211,17 @@ export function OverviewMemoryView({ {t('overviewMemory.unavailable')} )} - + {actions.openBot === undefined ? null : ( + + )} ))} diff --git a/packages/client/src/client/overview-usage-view.tsx b/packages/client/src/client/overview-usage-view.tsx index 0b3b3477c0..d2dad17ac9 100644 --- a/packages/client/src/client/overview-usage-view.tsx +++ b/packages/client/src/client/overview-usage-view.tsx @@ -5,7 +5,7 @@ import { IconRefreshOutlineRegular, IconRightUpOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives'; -import type { OverviewUsage } from '../../../core/src/bridge/methods.js'; +import type { OverviewUsage } from '../../../../apps/mobile/src/host/usage.js'; import { usageOverviewPeriods, type UsageOverviewPeriod, @@ -24,7 +24,9 @@ export function OverviewUsageView({ t, historyOnly = false, }: { - actions: BridgeActions; + actions: { + overviewUsage(period: UsageOverviewPeriod, after?: string): Promise; + } & Partial>; t: BotHarnessTranslate; historyOnly?: boolean; }): ReactElement { @@ -238,13 +240,13 @@ export function OverviewUsageView({ {!bot.current ? {t('overviewUsage.retained')} : null} {count(bot.totalTokens)} - {bot.current ? ( + {bot.current && actions.openBot !== undefined ? ( diff --git a/packages/client/src/client/remote-host-models.tsx b/packages/client/src/client/remote-host-models.tsx new file mode 100644 index 0000000000..a18c71df9f --- /dev/null +++ b/packages/client/src/client/remote-host-models.tsx @@ -0,0 +1,266 @@ +import { useRef, useState } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import { Checkbox } from '@deepseek-ai/dsh-client-ui-primitives'; +import type { HostClient } from '../../../../apps/mobile/src/host/client.js'; +import { sameModel, type ModelRoute } from '../../../../apps/mobile/src/host/bot-settings.js'; +import { + modelRoutes, + modelScopeKey, + modelSettingsResource, + saveModelPreset, + setModelRoute, + toggleAssignmentModel, + type ModelScope, + type ModelSettingsState, +} from '../../../../apps/mobile/src/host/model-settings.js'; +import { Combobox } from './combobox.js'; +import { TextField } from './ds/field.js'; +import { Note } from './ds/note.js'; +import { PillButton } from './ds/pill-button.js'; +import { Select } from './ds/select.js'; +import { SettingsRow } from './ds/settings-row.js'; +import { space } from './ds/tokens.stylex.js'; +import type { BotHarnessTranslate } from './locale.js'; +import { ModelPicker } from './model-picker.js'; +import { useMountedResource } from './mounted-resource.js'; + +const styles = stylex.create({ + stack: { display: 'flex', flexDirection: 'column', gap: space.x5, minWidth: 0 }, + choices: { display: 'flex', flexDirection: 'column', gap: space.x3 }, +}); + +type HostModelScope = Exclude; + +export function RemoteHostModels({ client, t }: { client: HostClient; t: BotHarnessTranslate }) { + const [scope, setScope] = useState({ kind: 'global' }); + return ( + + ); +} + +function Models({ + client, + scope, + onScope, + t, +}: { + client: HostClient; + scope: HostModelScope; + onScope: (scope: HostModelScope) => void; + t: BotHarnessTranslate; +}) { + const [resource] = useState(() => modelSettingsResource(client, scope)); + const [value, setValue] = useState(); + const [error, setError] = useState(false); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(); + const generation = useRef(0); + const pending = useRef(false); + const refresh = async () => { + const request = ++generation.current; + setError(false); + try { + const next = await resource.refresh(); + if (generation.current === request) setValue(next); + } catch { + if (generation.current === request) setError(true); + } + }; + const mount = useMountedResource(() => { + void refresh(); + return () => { + generation.current += 1; + }; + }, [resource]); + const run = async (work: () => Promise, onSaved?: () => void) => { + if (pending.current) return; + pending.current = true; + setBusy(true); + setError(false); + const request = ++generation.current; + try { + await resource.write(work); + const next = await resource.refresh(); + if (generation.current === request) { + setValue(next); + onSaved?.(); + } + } catch { + if (generation.current === request) setError(true); + } finally { + pending.current = false; + if (generation.current === request) setBusy(false); + } + }; + const saveName = () => { + if ( + scope.kind !== 'preset' || + value?.preset === undefined || + name === undefined || + name.trim() === value.preset.name + ) + return; + void run( + async () => { + await saveModelPreset(client, value, name, true); + }, + () => setName(undefined), + ); + }; + const routes = value === undefined ? undefined : modelRoutes(value); + const picker = (field: 'orchestrator' | 'assignment', route: ModelRoute | undefined) => + value === undefined ? null : ( + ({ ...entry, efforts: entry.efforts ?? [] }))} + options={value.models.map((entry) => ({ + value: JSON.stringify([entry.provider, entry.model]), + label: entry.modelName, + hint: `${entry.providerName}${entry.credential === undefined ? '' : ` · ${t('onboarding.needsKey')}`}`, + disabled: entry.credential !== undefined, + }))} + disabled={busy} + t={t} + onChange={({ key, effort }) => { + const entry = value.models.find( + (item) => JSON.stringify([item.provider, item.model]) === key, + ); + if (entry === undefined) return; + void run(() => + setModelRoute(client, scope, value, field, { + provider: entry.provider, + model: entry.model, + ...(effort === '' ? {} : { reasoningEffort: effort }), + }), + ); + }} + /> + ); + return ( +
+ {error ? {t('remoteHosts.settings.error')} : null} + void refresh()}> + {t('onboarding.refreshModels')} + + {value === undefined ? ( + error ? null : ( +

{t('modelPreset.loading')}

+ ) + ) : ( + <> + ({ value: preset.id, label: preset.name })), + ]} + label={t('remoteHosts.settings.models')} + disabled={busy} + onChange={(id) => onScope(id === '' ? { kind: 'global' } : { kind: 'preset', id })} + /> + } + /> + {picker('orchestrator', routes?.orchestrator)} + {scope.kind !== 'preset' ? null : ( + <> + {picker('assignment', routes?.assignmentDefault)} +
+ + entry.credential === undefined && + !routes?.assignmentModels.some((option) => sameModel(option, entry)), + ) + .map((entry) => ({ + value: JSON.stringify([entry.provider, entry.model]), + label: entry.modelName, + hint: entry.providerName, + }))} + label={t('modelPreset.allowedTitle')} + toggleLabel={t('modelPreset.showModels')} + placeholder={t('modelPreset.chooseModel')} + emptyLabel={t('modelPreset.noMatch')} + disabled={busy || routes?.assignmentDefault === undefined} + onSelect={(key) => { + const entry = value.models.find( + (item) => JSON.stringify([item.provider, item.model]) === key, + ); + if (entry !== undefined) + void run(() => toggleAssignmentModel(client, scope, value, entry)); + }} + /> + {routes?.assignmentModels.map((option) => ( + { + const entry = value.models.find((item) => sameModel(item, option)); + if (entry !== undefined) + void run(() => toggleAssignmentModel(client, scope, value, entry)); + }} + /> + ))} +
+ + )} + { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault(); + saveName(); + } + }} + /> + {scope.kind === 'global' ? ( + + void run( + async () => { + await saveModelPreset(client, value, name ?? '', false); + }, + () => setName(undefined), + ) + } + > + {t('modelPreset.saveAs')} + + ) : null} + + )} +
+ ); +} diff --git a/packages/client/src/client/remote-host-settings-source.ts b/packages/client/src/client/remote-host-settings-source.ts new file mode 100644 index 0000000000..a7447512f0 --- /dev/null +++ b/packages/client/src/client/remote-host-settings-source.ts @@ -0,0 +1,24 @@ +import type { HostClient } from '../../../../apps/mobile/src/host/client.js'; +import * as storage from '../../../../apps/mobile/src/host/storage.js'; +import { loadMemoryActivity, loadUsage } from '../../../../apps/mobile/src/host/usage.js'; +import type { UsagePeriod } from '../../../../apps/mobile/src/host/usage.js'; + +export function remoteHostSettingsSource(client: HostClient) { + return { + usage: { + overviewUsage: (period: UsagePeriod, after?: string) => loadUsage(client, period, after), + overviewMemory: (after?: string) => loadMemoryActivity(client, after), + }, + storage: { + usage: () => storage.loadStorageUsage(client), + previewMedia: (scope: storage.StorageCleanupScope) => + storage.previewStorageCleanup(client, scope), + confirmMedia: (preview: storage.StorageCleanupPreview) => + storage.confirmStorageCleanup(client, preview), + previewMessages: (scope: { before: string; channelIds?: string[] }) => + storage.previewStorageMessages(client, scope), + confirmMessages: (preview: storage.StorageMessagesPreview) => + storage.confirmStorageMessages(client, preview), + }, + }; +} diff --git a/packages/client/src/client/remote-host-settings.tsx b/packages/client/src/client/remote-host-settings.tsx new file mode 100644 index 0000000000..4373484b28 --- /dev/null +++ b/packages/client/src/client/remote-host-settings.tsx @@ -0,0 +1,96 @@ +import { useMemo, useState, useSyncExternalStore } from 'react'; +import { SettingsDialog } from './ds/settings-dialog.js'; +import { Note } from './ds/note.js'; +import type { BotHarnessTranslate } from './locale.js'; +import { OverviewUsageView } from './overview-usage-view.js'; +import { OverviewMemoryView } from './overview-memory-view.js'; +import { RemoteHostModels } from './remote-host-models.js'; +import { remoteHosts } from './remote-hosts.js'; +import { remoteHostSettingsSource } from './remote-host-settings-source.js'; +import { StorageSettings } from './storage-settings.js'; +import type { HostClient } from '../../../../apps/mobile/src/host/client.js'; + +export function RemoteHostSettings({ + hostId, + name, + onClose, + t, +}: { + hostId: string; + name: string; + onClose: () => void; + t: BotHarnessTranslate; +}) { + const sources = remoteHosts(); + const snapshot = useSyncExternalStore( + sources.subscribe, + sources.getSnapshot, + sources.getSnapshot, + ); + const remote = snapshot.hosts.find((entry) => entry.host.hostId === hostId); + const client = sources.client(hostId); + const [section, setSection] = useState('models'); + const sections = [ + { id: 'models', label: t('remoteHosts.settings.models') }, + { id: 'usage', label: t('overviewUsage.title') }, + { id: 'storage', label: t('storage.title') }, + ]; + return ( + entry.id === section)!.label} + navTitle={t('remoteHosts.settings')} + caption={name} + sections={sections} + activeId={section} + onSelect={setSection} + onClose={onClose} + labels={{ + nav: t('remoteHosts.settings'), + close: t('common.close'), + picker: (label) => label, + }} + > + {client === undefined || remote?.snapshot.connection.status !== 'connected' ? ( + + {t( + remote?.snapshot.connection.status === 'refused' + ? 'remoteHosts.refused' + : 'remoteHosts.offline', + { name }, + )} + + ) : ( + + )} + + ); +} + +function SettingsContent({ + client, + name, + section, + t, +}: { + client: HostClient; + name: string; + section: string; + t: BotHarnessTranslate; +}) { + const source = useMemo(() => remoteHostSettingsSource(client), [client]); + if (section === 'models') return ; + if (section === 'storage') + return ( + + ); + return ( + <> + + + + ); +} diff --git a/packages/client/src/client/storage-settings.tsx b/packages/client/src/client/storage-settings.tsx index b02ab0eb78..4a46aaf61a 100644 --- a/packages/client/src/client/storage-settings.tsx +++ b/packages/client/src/client/storage-settings.tsx @@ -1,7 +1,18 @@ import { useRef, useState, type ReactElement } from 'react'; import * as stylex from '@stylexjs/stylex'; import { Button, Checkbox } from '@deepseek-ai/dsh-client-ui-primitives'; -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'; +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'; +import { MethodRefused } from '../../../../apps/mobile/src/host/client.js'; +import { + STORAGE_MEDIA_KINDS, + type StorageCleanupScope, + type StorageCleanupPreview, + type StorageCleanupResult, + type StorageMediaKind, + type StorageMessagesPreview, + type StorageMessagesResult, + type StorageUsage, +} from '../../../../apps/mobile/src/host/storage.js'; import { formatBotZipSize } from './bot-zip.js'; import { @@ -11,14 +22,7 @@ import { loadStorageUsage, previewStorageCleanup, previewStorageMessages, - STORAGE_MEDIA_KINDS, type BridgeCall, - type StorageCleanupPreview, - type StorageCleanupResult, - type StorageMediaKind, - type StorageMessagesPreview, - type StorageMessagesResult, - type StorageUsage, } from './bridge.js'; import { PillButton } from './ds/pill-button.js'; import { FormDialog } from './ds/form-dialog.js'; @@ -167,9 +171,36 @@ function cutoff(age: Age): string { return new Date(age === 'all' ? Date.now() : Date.now() - Number(age) * DAY).toISOString(); } -export type StorageSettingsProps = PropsLocale<'botharness'> & InjectFace<{ call: BridgeCall }>; +export interface StorageSettingsSource { + usage(signal?: AbortSignal): Promise; + previewMedia(scope: StorageCleanupScope): Promise; + confirmMedia(preview: StorageCleanupPreview): Promise; + previewMessages(scope: { + before: string; + channelIds?: string[]; + }): Promise; + confirmMessages(preview: StorageMessagesPreview): Promise; +} + +export type StorageSettingsProps = PropsLocale<'botharness'> & { title?: string } & ( + | { call: BridgeCall; source?: never } + | { source: StorageSettingsSource; call?: never } + ); -export function StorageSettings({ call, t }: StorageSettingsProps): ReactElement { +export function StorageSettings({ + call, + source, + t, + title = t('storage.title'), +}: StorageSettingsProps): ReactElement { + const storage = source ?? { + usage: (signal?: AbortSignal) => loadStorageUsage(call!, signal), + previewMedia: (scope: StorageCleanupScope) => previewStorageCleanup(call!, scope), + confirmMedia: (preview: StorageCleanupPreview) => confirmStorageCleanup(call!, preview), + previewMessages: (scope: { before: string; channelIds?: string[] }) => + previewStorageMessages(call!, scope), + confirmMessages: (preview: StorageMessagesPreview) => confirmStorageMessages(call!, preview), + }; const [open, setOpen] = useState(false); const [usage, setUsage] = useState(); const [target, setTarget] = useState('media'); @@ -187,7 +218,7 @@ export function StorageSettings({ call, t }: StorageSettingsProps): ReactElement const controller = new AbortController(); request.current = controller; setUsage(undefined); - loadStorageUsage(call, controller.signal).then( + storage.usage(controller.signal).then( (value) => { if (!controller.signal.aborted) setUsage(value); }, @@ -235,8 +266,8 @@ export function StorageSettings({ call, t }: StorageSettingsProps): ReactElement try { setPreview( messagesTarget - ? await previewStorageMessages(call, scope) - : await previewStorageCleanup(call, { ...scope, kinds }), + ? await storage.previewMessages(scope) + : await storage.previewMedia({ ...scope, kinds }), ); setAcknowledged(false); } catch { @@ -252,12 +283,15 @@ export function StorageSettings({ call, t }: StorageSettingsProps): ReactElement try { setResult( mediaPreview !== undefined - ? await confirmStorageCleanup(call, mediaPreview) - : await confirmStorageMessages(call, preview as StorageMessagesPreview), + ? await storage.confirmMedia(mediaPreview) + : await storage.confirmMessages(preview as StorageMessagesPreview), ); } catch (failure) { setAcknowledged(false); - if (failure instanceof BridgeCallError && failure.code === 'storage-preview-stale') { + if ( + (failure instanceof BridgeCallError || failure instanceof MethodRefused) && + failure.code === 'storage-preview-stale' + ) { setPreview(undefined); setError(t('storage.stale')); } else setError(t('storage.error')); @@ -283,7 +317,7 @@ export function StorageSettings({ call, t }: StorageSettingsProps): ReactElement > {!open ? null : result !== undefined ? ( ) : messagesPreview !== undefined ? ( ) : mediaPreview !== undefined ? ( ) : ( { >(({ compact: _compact, ...props }, ref) => createElement('div', { ...props, ref })), useAnchoredPosition: () => ({ top: 0, left: 0 }), useDismissOnOutsidePointer: () => undefined, + Menu: ({ + items, + onSelect, + }: { + items: { id: string; label: ReactNode }[]; + onSelect: (id: string) => void; + }) => + createElement( + 'div', + { role: 'menu' }, + items.map((item) => + createElement( + 'button', + { key: item.id, role: 'menuitem', onClick: () => onSelect(item.id) }, + item.label, + ), + ), + ), Modal: ({ open, title, @@ -98,6 +116,11 @@ import { invalidateModelPlan, modelPlanOf } from '../src/client/model-plan-store import { OnboardingController } from '../src/client/onboarding.js'; import { ModelPresetProfile } from '../src/client/model-preset-profile.js'; import { OnboardingModelDialog, OnboardingWelcome } from '../src/client/onboarding-view.js'; +import { RemoteHostModels } from '../src/client/remote-host-models.js'; +import { createHostClient } from '../../../apps/mobile/src/host/client.js'; +import { createCore } from '../../core/src/plugin.js'; +import { createBridgeMethods } from '../../core/src/bridge/methods.js'; +import { createTempRoot } from '../../core/test/helpers.js'; function translate(key: string, params?: Record): string { let text = (en as Record)[key] ?? key; @@ -135,6 +158,7 @@ const catalog = [ let root: Root | undefined; let container: HTMLDivElement | undefined; +const remoteCores: ReturnType[] = []; afterEach(async () => { if (root !== undefined) await act(async () => root!.unmount()); @@ -142,6 +166,224 @@ afterEach(async () => { root = undefined; container = undefined; document.body.innerHTML = ''; + for (const core of remoteCores.splice(0)) { + await core.runtime.close(); + core.operationalDatabase.close(); + } +}); + +function remoteOwner() { + const core = createCore({ + dshHome: createTempRoot('bh-remote-preset-'), + agents: { + async runOrchestrator() {}, + async runAssignment() {}, + requestAssignment() { + throw new Error('Unexpected Assignment'); + }, + async close() {}, + }, + }); + remoteCores.push(core); + const route = { provider: 'deepseek', model: 'flash' }; + const first = core.modelPresets.create({ + name: 'First', + orchestrator: route, + assignmentDefault: route, + }); + const second = core.modelPresets.create({ + name: 'Second', + orchestrator: route, + assignmentDefault: route, + }); + let validate = async () => {}; + const bridge = createBridgeMethods({ + ...core, + modelCatalog: { list: async () => catalog, validate: () => validate() }, + defaultModel: { currentSelection: () => route, saveSelection: async () => {} }, + }); + const methods = new Map unknown>([ + ['modelCatalog', bridge.modelCatalog], + ['modelPresets', bridge.modelPresets], + ['modelPresetUpdate', bridge.modelPresetUpdate], + ['modelPresetCreate', bridge.modelPresetCreate], + ]); + const client = createHostClient({ + routes: ['https://source.example'], + credential: 'test-only', + carrier: { + async request(url, init) { + const method = methods.get(url.split('/').at(-1)!); + if (method === undefined) throw new Error('Unexpected method'); + const { payload } = JSON.parse(String(init.body)); + const result = await method(payload.args); + return { + status: 200, + text: async () => JSON.stringify({ type: 'server-response', result }), + }; + }, + openSocket() { + throw new Error('Unexpected socket'); + }, + }, + }); + return { + core, + client, + first, + second, + pause: (work: () => Promise) => { + validate = work; + }, + }; +} + +async function renderRemote(owner: ReturnType) { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + if (root === undefined) { + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + } + await act(async () => { + root!.render( + createElement(RemoteHostModels, { key: owner.first.id, client: owner.client, t: translate }), + ); + }); +} + +async function selectRemotePreset(name: string) { + await act(async () => { + document + .querySelector( + `button[aria-label="${translate('remoteHosts.settings.models')}"]`, + )! + .click(); + }); + await act(async () => button(name).click()); +} + +async function editRemoteName(name: string) { + const field = [...document.querySelectorAll('label')].find((label) => + label.textContent?.includes(translate('modelPreset.name')), + )!; + const input = document.getElementById(field.htmlFor) as HTMLInputElement; + await act(async () => { + input.focus(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, name); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + return input; +} + +describe('Remote preset name commits through the source Host owner', () => { + it('saves Enter followed by blur once and saves a later blur independently', async () => { + const owner = remoteOwner(); + await renderRemote(owner); + await selectRemotePreset('First'); + const input = await editRemoteName('Renamed'); + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + input.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + }); + expect(owner.core.modelPresets.get(owner.first.id)).toMatchObject({ + name: 'Renamed', + revision: 2, + }); + await act(async () => input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))); + expect(owner.core.modelPresets.get(owner.first.id)?.revision).toBe(2); + await editRemoteName('Blur saved'); + await act(async () => input.blur()); + expect(owner.core.modelPresets.get(owner.first.id)).toMatchObject({ + name: 'Blur saved', + revision: 3, + }); + }); + + it('retains the draft and error after an owner conflict until an explicit refresh and retry', async () => { + const owner = remoteOwner(); + await renderRemote(owner); + await selectRemotePreset('First'); + const input = await editRemoteName('My draft'); + owner.core.modelPresets.update(owner.first.id, { + ...owner.first, + name: 'Other device', + expectedRevision: 1, + }); + await act(async () => input.blur()); + expect(owner.core.modelPresets.get(owner.first.id)).toMatchObject({ + name: 'Other device', + revision: 2, + }); + expect(document.querySelector('[role="alert"]')?.textContent).toBe( + translate('remoteHosts.settings.error'), + ); + expect(input.value).toBe('My draft'); + await act(async () => button(translate('onboarding.refreshModels')).click()); + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })), + ); + expect(owner.core.modelPresets.get(owner.first.id)).toMatchObject({ + name: 'My draft', + revision: 3, + }); + expect(document.querySelector('[role="alert"]')).toBeNull(); + }); + + it('keeps pending writes bound to their original preset and Host after closing and reopening', async () => { + const owner = remoteOwner(); + await renderRemote(owner); + await selectRemotePreset('First'); + const first = await editRemoteName('First saved'); + await act(async () => first.blur()); + await selectRemotePreset('Second'); + const second = await editRemoteName('Second saved after close'); + let release = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + owner.pause(() => gate); + await act(async () => second.blur()); + expect(owner.core.modelPresets.get(owner.second.id)?.name).toBe('Second'); + await act(async () => root!.render(null)); + const next = remoteOwner(); + await renderRemote(next); + await selectRemotePreset('First'); + const nextInput = await editRemoteName('New Host draft'); + await act(async () => { + release(); + }); + expect(owner.core.modelPresets.get(owner.first.id)).toMatchObject({ + name: 'First saved', + revision: 2, + }); + expect(owner.core.modelPresets.get(owner.second.id)).toMatchObject({ + name: 'Second saved after close', + revision: 2, + }); + expect(nextInput.value).toBe('New Host draft'); + expect(next.core.modelPresets.get(next.first.id)).toMatchObject({ name: 'First', revision: 1 }); + expect(next.core.modelPresets.get(next.second.id)).toMatchObject({ + name: 'Second', + revision: 1, + }); + }); + + it('requires the create action for a new preset instead of Enter or blur', async () => { + const owner = remoteOwner(); + await renderRemote(owner); + const input = await editRemoteName('Created explicitly'); + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + input.blur(); + }); + expect(owner.core.modelPresets.list()).toHaveLength(2); + await act(async () => button(translate('modelPreset.saveAs')).click()); + expect(owner.core.modelPresets.list().map((preset) => preset.name)).toContain( + 'Created explicitly', + ); + expect(input.value).toBe(''); + }); }); async function render(actions: BridgeActions): Promise { diff --git a/packages/client/test/remote-host-settings.test.ts b/packages/client/test/remote-host-settings.test.ts new file mode 100644 index 0000000000..f6e345f428 --- /dev/null +++ b/packages/client/test/remote-host-settings.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { createHostClient } from '../../../apps/mobile/src/host/client.js'; +import { + modelSettingsResource, + saveModelPreset, + setModelRoute, +} from '../../../apps/mobile/src/host/model-settings.js'; +import { FakeHost } from '../../../apps/mobile/test/fake-host.js'; +import { remoteHostSettingsSource } from '../src/client/remote-host-settings-source.js'; + +function setup(name: string) { + const host = new FakeHost(); + const client = createHostClient({ + routes: [`https://${name}.example.ts.net`], + credential: host.credential, + carrier: host.carrier, + }); + const route = { provider: 'deepseek', model: name }; + host.methods.set('modelCatalog', () => ({ + models: [{ ...route, providerName: 'DeepSeek', modelName: name }], + default: route, + defaultRevision: 3, + })); + host.methods.set('modelPresets', () => ({ + presets: [{ id: 'same-id', name, revision: 3, orchestrator: route, assignmentDefault: route }], + })); + return { host, client, route, source: remoteHostSettingsSource(client) }; +} + +describe('Remote Host settings ownership', () => { + it('isolates global and same-id preset resources, and writes only to their source Host', async () => { + const a = setup('a'); + const b = setup('b'); + const scope = { kind: 'preset', id: 'same-id' } as const; + const [local, remote] = await Promise.all([ + modelSettingsResource(a.client, scope).refresh(), + modelSettingsResource(b.client, scope).refresh(), + ]); + expect(local).toMatchObject({ preset: { name: 'a' } }); + expect(remote).toMatchObject({ preset: { name: 'b' } }); + b.host.methods.set('modelPresetUpdate', () => ({ preset: { id: 'same-id' } })); + await saveModelPreset(b.client, remote, 'Renamed remote', true); + expect(b.host.calls.at(-1)).toMatchObject({ + method: 'modelPresetUpdate', + args: { id: 'same-id', expectedRevision: 3, name: 'Renamed remote', orchestrator: b.route }, + }); + b.host.methods.set('onboardingModel', () => ({})); + await setModelRoute( + b.client, + { kind: 'global' }, + await modelSettingsResource(b.client, { kind: 'global' }).refresh(), + 'orchestrator', + b.route, + ); + expect(b.host.calls.at(-1)).toMatchObject({ + method: 'onboardingModel', + args: { globalDefault: true, expectedDefaultRevision: 3, route: b.route }, + }); + expect(a.host.calls.map((call) => call.method)).toEqual(['modelCatalog', 'modelPresets']); + }); + + it('reads usage and storage through the selected Host and preserves explicit cleanup preview scope', async () => { + const a = setup('a'); + const b = setup('b'); + b.host.methods.set('overviewUsage', (args) => ({ source: 'b', ...args })); + b.host.methods.set('overviewMemory', (args) => ({ source: 'b', ...args })); + b.host.methods.set('storageUsage', () => ({ totalBytes: 12, chats: [], parts: [] })); + const scope = { + before: '2026-01-01T00:00:00.000Z', + channelIds: ['same-channel'], + kinds: ['image' as const], + }; + const preview = { + token: 'host-b-preview', + scope, + files: 1, + messages: 1, + bytes: 12, + freedBytes: 12, + kept: [], + }; + b.host.methods.set('storageCleanupPreview', () => preview); + b.host.methods.set('storageCleanupConfirm', () => ({ files: 1, messages: 1, freedBytes: 12 })); + expect(await b.source.usage.overviewUsage('week', 'cursor')).toEqual({ + source: 'b', + period: 'week', + after: 'cursor', + }); + expect(await b.source.usage.overviewMemory('cursor')).toEqual({ source: 'b', after: 'cursor' }); + expect(await b.source.storage.usage()).toMatchObject({ totalBytes: 12 }); + const result = await b.source.storage.previewMedia(scope); + expect(b.host.calls.some((call) => call.method === 'storageCleanupConfirm')).toBe(false); + await b.source.storage.confirmMedia(result); + expect(b.host.calls.at(-1)).toEqual({ + method: 'storageCleanupConfirm', + args: { ...scope, token: 'host-b-preview' }, + }); + expect(a.host.calls).toEqual([]); + }); + + it('keeps message deletion separate and propagates source Host refusals without retry or fallback', async () => { + const a = setup('a'); + const b = setup('b'); + const scope = { before: '2026-01-01T00:00:00.000Z', channelIds: ['same-channel'] }; + const preview = { + token: 'messages-b', + scope, + messages: 2, + bytes: 12, + files: 0, + chats: [], + kept: [], + memoryReferences: 0, + more: false, + }; + b.host.methods.set('storageMessagesCleanupPreview', () => preview); + b.host.failing.add('storageMessagesCleanupConfirm'); + await expect(b.source.storage.previewMessages(scope)).resolves.toEqual(preview); + await expect(b.source.storage.confirmMessages(preview)).rejects.toThrow(); + expect( + b.host.calls.filter((call) => call.method === 'storageMessagesCleanupConfirm'), + ).toHaveLength(1); + expect(a.host.calls).toEqual([]); + b.host.revoked = true; + await expect(b.source.storage.usage()).rejects.toThrow(); + await expect(modelSettingsResource(b.client, { kind: 'global' }).refresh()).rejects.toThrow(); + expect(a.host.calls).toEqual([]); + }); +}); diff --git a/packages/client/test/remote-hosts.test.ts b/packages/client/test/remote-hosts.test.ts index 757f6d6863..882d84b7d7 100644 --- a/packages/client/test/remote-hosts.test.ts +++ b/packages/client/test/remote-hosts.test.ts @@ -128,6 +128,10 @@ describe('remote hosts', () => { ); expect(sources.conversation(here)).toBe(sources.conversation({ ...here })); expect(sources.conversation({ ...here, hostId: 'host-c' })).toBeUndefined(); + expect(sources.client('host-b')).toBe(sources.client('host-b')); + expect(sources.client('host-b')).toBeDefined(); + expect(sources.client('local')).toBeUndefined(); + expect(sources.client('host-c')).toBeUndefined(); }); it('removes a remote, revokes this browser there and forgets its open chat', async () => { @@ -140,6 +144,7 @@ describe('remote hosts', () => { await sources.remove('host-b'); + expect(sources.client('host-b')).toBeUndefined(); expect(sources.getSnapshot()).toEqual({ hosts: [], avatars: new Map(), tab: ALL_HOSTS }); expect(storage.values.has(REMOTE_HOSTS_STORAGE_KEY)).toBe(false); expect(calls.at(-1)).toEqual({ diff --git a/packages/client/test/storage-settings.test.ts b/packages/client/test/storage-settings.test.ts index 199f87a9b9..9c474b6e56 100644 --- a/packages/client/test/storage-settings.test.ts +++ b/packages/client/test/storage-settings.test.ts @@ -53,7 +53,8 @@ vi.mock('../src/client/ds/select.js', async () => { }); import type { BridgeCall } from '../src/client/bridge.js'; -import { StorageSettings } from '../src/client/storage-settings.js'; +import { StorageSettings, type StorageSettingsSource } from '../src/client/storage-settings.js'; +import { MethodRefused } from '../../../apps/mobile/src/host/client.js'; import { zhTranslate } from '../src/client/locale.js'; import { buttonNamed } from './primitive-mocks.js'; @@ -103,13 +104,20 @@ function preview(scope: Record) { }; } -async function render(call: BridgeCall) { +async function render(call: BridgeCall | StorageSettingsSource) { Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); const host = document.createElement('div'); document.body.append(host); const root = createRoot(host); await act(async () => { - root.render(createElement(StorageSettings, { call, t: zhTranslate } as never)); + root.render( + createElement(StorageSettings, { + ...(typeof call === 'function' + ? { call } + : { source: call, title: 'Studio T8 · 存储空间' }), + t: zhTranslate, + } as never), + ); }); const press = async (button: HTMLButtonElement) => { await act(async () => { @@ -133,6 +141,61 @@ async function render(call: BridgeCall) { } describe('StorageSettings', () => { + it('names the Remote Host during confirmation and handles its stale preview without deleting again', async () => { + const confirmMedia = vi.fn(async () => { + throw new MethodRefused('storage-preview-stale', 'changed'); + }); + const source: StorageSettingsSource = { + usage: async () => ({ + totalBytes: 12, + parts: [], + chats: [ + { + channelId: 'remote-only', + name: 'Remote only', + type: 'dm', + messages: 1, + mediaBytes: 12, + }, + ], + }), + previewMedia: async (scope) => ({ + token: 'remote-preview', + scope: { ...scope, channelIds: [] }, + files: 1, + messages: 1, + bytes: 12, + freedBytes: 12, + kept: [], + }), + confirmMedia, + previewMessages: vi.fn(), + confirmMessages: vi.fn(), + }; + const view = await render(source); + try { + await view.open(); + expect(view.host.textContent).toContain('Studio T8 · 存储空间'); + expect(view.host.textContent).toContain('Remote only'); + await view.press(view.host.querySelector('[data-storage-review]')!); + expect(view.host.textContent).toContain('Studio T8 · 存储空间'); + expect(view.host.querySelector('[data-storage-confirm]')!.disabled).toBe( + true, + ); + expect(confirmMedia).not.toHaveBeenCalled(); + await act(async () => view.checkbox('我了解这些文件会被永久删除').click()); + await view.press(view.host.querySelector('[data-storage-confirm]')!); + expect(confirmMedia).toHaveBeenCalledTimes(1); + expect(confirmMedia).toHaveBeenCalledWith( + expect.objectContaining({ token: 'remote-preview' }), + ); + expect(view.host.textContent).toContain('内容有变化,请重新预览。'); + expect(view.host.querySelector('[data-storage-usage]')).not.toBeNull(); + } finally { + view.dispose(); + } + }); + it('shows space used, previews the chosen chat media and clears after confirmation', async () => { const call = vi.fn(async (endpoint, payload) => endpoint === 'storageUsage' diff --git a/packages/core/test/onboarding.test.ts b/packages/core/test/onboarding.test.ts index 10867274d2..993a58c7aa 100644 --- a/packages/core/test/onboarding.test.ts +++ b/packages/core/test/onboarding.test.ts @@ -7,6 +7,11 @@ import { createBridgeMethods } from '../src/bridge/methods.js'; import type { BotAgentAdapter, OrchestratorAgentRun } from '../src/runtime/bot-runtime.js'; import { createTempRoot } from './helpers.js'; import { DEEPSEEKBOT_RECIPE } from '../src/onboarding/service.js'; +import { createHostClient } from '../../../apps/mobile/src/host/client.js'; +import { + modelSettingsResource, + setModelRoute, +} from '../../../apps/mobile/src/host/model-settings.js'; function adapter( run: (input: OrchestratorAgentRun) => Promise = async () => {}, @@ -406,6 +411,107 @@ it('guards the native default owner revision without modifying independent Bot p }); }); +it.each(['stale', 'missing'] as const)( + 'refuses a shared-client default write with a %s observed revision at the source Host', + async (condition) => { + await withCore(async (core) => { + const old = { provider: 'official', model: 'old' }; + const other = { provider: 'official', model: 'other' }; + let current = old; + let revision = 5; + const bridge = createBridgeMethods({ + ...core, + modelCatalog: { + list: async () => + [old, other].map((route) => ({ + ...route, + providerName: 'Official', + modelName: route.model, + efforts: [], + })), + validate: async () => {}, + }, + defaultModel: { + currentSelection: () => current, + saveSelection: async (route) => { + current = route; + revision++; + }, + }, + ...(condition === 'missing' + ? {} + : { + defaultModelSettings: () => ({ + describe: () => [{ ns: 'agent-default-model', value: current, revision }], + replace: async (_ns: string, value: unknown, expected?: number) => { + if (expected !== revision) throw new Error('SETTINGS_CONFLICT'); + current = value as typeof current; + revision++; + }, + }), + }), + }); + const client = createHostClient({ + routes: ['https://source.example'], + credential: 'test-only', + carrier: { + async request(url, init) { + const { payload } = JSON.parse(String(init.body)); + const result = url.endsWith('/modelCatalog') + ? await bridge.modelCatalog(payload.args) + : url.endsWith('/modelPresets') + ? await bridge.modelPresets(payload.args) + : url.endsWith('/onboardingModel') + ? await bridge.onboardingModel(payload.args) + : undefined; + if (result === undefined) throw new Error('Unexpected method'); + return { + status: 200, + text: async () => JSON.stringify({ type: 'server-response', result }), + }; + }, + openSocket() { + throw new Error('Unexpected socket'); + }, + }, + }); + const resource = modelSettingsResource(client, { kind: 'global' }); + const observed = await resource.refresh(); + if (condition === 'stale') { + expect( + await bridge.onboardingModel({ + route: other, + globalDefault: true, + expectedRevision: 0, + expectedDefaultRevision: 5, + }), + ).toMatchObject({ ok: true }); + } + await expect( + setModelRoute( + client, + { kind: 'global' }, + observed, + 'orchestrator', + condition === 'stale' ? old : other, + ), + ).rejects.toMatchObject( + condition === 'stale' + ? { code: 'stale-setting' } + : { message: 'MODEL_DEFAULT_REVISION_REQUIRED' }, + ); + expect(current).toEqual(condition === 'stale' ? other : old); + expect(revision).toBe(condition === 'stale' ? 6 : 5); + if (condition === 'stale') { + const refreshed = await resource.refresh(); + await setModelRoute(client, { kind: 'global' }, refreshed, 'orchestrator', old); + expect(current).toEqual(old); + expect(revision).toBe(7); + } + }); + }, +); + it('rechecks both model preset and Bot revisions after asynchronous catalog validation', async () => { await withCore(async (core) => { core.registry.create({ slug: 'target', displayName: 'Target' }); diff --git a/scripts/cli-ui-inventory.json b/scripts/cli-ui-inventory.json index 1984594ee4..3bb9df83ba 100644 --- a/scripts/cli-ui-inventory.json +++ b/scripts/cli-ui-inventory.json @@ -260,7 +260,8 @@ "description": "#2088 reuses default-model, Bot plan and preset create/read/update/apply owners with scoped Tob revisions/catalog validation. No preset delete owner; credentials remain native private input.", "scope": "Host", "surfaces": [ - "setting:packages/client/src/client/onboarding-view.tsx:{t('onboarding.defaultTitle')}" + "setting:packages/client/src/client/onboarding-view.tsx:{t('onboarding.defaultTitle')}", + "setting:packages/client/src/client/remote-host-models.tsx:{t('remoteHosts.settings.models')}" ], "commands": [ "home-model-set",