# Development log A checkable record of how this project was built. For every milestone: **what the goal was, what changed, how it was verified, and which commit it landed in.** Everything below is either in this repository (source, scripts, commits) or a published artifact whose hash is listed in the release notes. Nothing here asks you to take my word for it — the "how to check" column and the [verification](#how-to-verify-any-line-in-this-log) section tell you how to reproduce each claim. [中文摘要见下方](#中文摘要) --- ## Timeline ### 1. Starting point — upstream dsh-pet 0.3.0 (MIT) | | | | --- | --- | | What it is | `dsh-pet` 0.3.0 by **PC2005-cloud** — "A floating desktop pet for the DeepSeek Harness Web UI: idle breathing, occasional direction turns, random actions, and screen wandering" | | License | MIT, full text preserved as [`assets/LICENSE.txt`](https://github.com/PC2005-cloud/dsh-pet) in this repository | | Upstream | | | What was imported | The whole package tree, unmodified, into `src/` | That import is the baseline for everything else. **232 of the 243 files in the published upstream package are still byte-identical**; the 11 that differ are listed one by one, with sizes and reasons, in [differences-from-upstream.md](differences-from-upstream.md). ### 2. Engine work on the imported copy — 2026-10-06 / 10-07 - **Goal**: get the pet to run outside the DSH Web UI, on a bare Windows desktop, and make her usable as a standalone app. - **Changes**: model selection simplified to a single model per role (`*.bak-before-singlemodel-20261006-190236` is the backup taken then), speech synthesis speed reworked (`*.bak-before-swspeed-20261006-184728`), plus the in-tree changes that later became the released 30 patch entries in `src/lib/index.js`, `src/runtime/electron-helper/{main,sprite,shared-core,events}.js`. - **Evidence in the repo**: those two backup files were dropped from the published tree (they are the only two files upstream has and this repository does not) — their names carry the dates: `20261006-184728` and `20261006-190236`. - **Commit**: none — this work happened before the project had its own repository, on the imported copy. ### 3. 2026-10-08 04:03 — 1.0.0: from a plugin to a distributable Windows application - **Goal**: someone who has never heard of DSH should be able to double-click something and get the pet. - **Changes**: - A standalone runner (`standalone/main.mjs`, `context.mjs`, `server.mjs`) that feeds the unchanged `apply(ctx)` a minimal fake ctx and redirects the three DSH packages to `standalone/shims/`. - A single-HTML console (`launcher/`) that starts, stops and configures her. - A first-run "local fit": read the GPU's VRAM from the registry, list the models already in Ollama, pick chat/vision models, write `device-profile.json`. - A packaging pipeline that lays out a `stage/` tree and produces a portable zip. - **Verification**: `stage/verify.mjs` self-check (tree fingerprints + a "must not be shipped" list), `check-release` byte-for-byte comparison against the source tree, a zip audit (`714` entries, every non-ASCII name carrying the UTF-8 flag, 15 key files extracted and compared byte for byte), and a full silent install/uninstall acceptance run in an isolated directory. - **Commit**: `9743b8f` — 可爱大肥鱼桌宠 1.0.0:把 dsh-pet 桌宠做成可分发 Windows 应用 ### 4. 2026-10-08 05:46 / 05:52 — the NSIS installer - **Goal**: no `.cmd` / `.ps1` step for the user; one wizard, no administrator rights. - **Changes**: `build/installer.nsi` (+ art, icon and the wizard pages), `build/mkexe.py`, the uninstall entry under `HKCU\...\Uninstall\BlueHairMaid`, desktop and start-menu shortcuts. - **Verification**: silent install into an isolated directory, then a byte-for-byte comparison of all installed files against `stage/`; silent uninstall; then the same again for the GUI path with a screenshot of every wizard page. - **Commits**: `341f19b` — 加 .exe 安装程序:一路下一步,不用再碰 cmd/ps1; `ab8d89d` — 验收补记:exe 路线的端到端演练脚本 + README ### 5. 2026-10-08 08:08 — 1.0.0 wrap-up - **Goal**: fix what real use had exposed, and make the package publishable. - **Changes**: automatic model fitting tuned to fill in chat *and* vision models; "online" mode switched on by filling in an API key and back to "auto" when cleared; vision endpoint and key configurable separately; a direct question always gets an answer first, and a failed model call now says so out loud instead of staying silent; the balance query is only offered when a key exists; the right-click menu and the console drive the same settings; a privacy sweep that removed local paths, deleted the leftover `cli-result.json` and stopped shipping the development `patched/` directory. - **Verification**: the whole release chain rebuilt and re-verified from scratch: `check-release`, `verify.mjs`, the zip audit, an isolated install/uninstall run, and a byte-for-byte comparison of the installed tree. - **Commit**: `9a4e73e` — 1.0.0 收尾:8 条体验修正 + 隐私排雷 + 开源 README ### 6. 2026-10-08 08:36 — installer: an upgrade must not install a second copy - **Goal**: a user who already has the pet installed should not end up with two 761 MB copies. - **Changes**: `Function .onInit` in `build/installer.nsi` reads `InstallLocation` from the uninstall registry key and reuses that directory when it still contains a working installation; silent installs (`/S`) are left alone so scripts keep control of the target directory. - **Verification**: the wizard was driven to the directory page with the registry key for the previous installation removed, leaving only the uninstall entry — the page still showed the previous directory (screenshot kept with the release material); the rebuilt installer was then silent-installed into an isolated directory and all installed files were compared byte for byte against `stage/`. - **Commit**: `a78b3b2` — 安装器:升级认得旧家,不再装出第二份 ### 7. 2026-10-08 09:46 — attribution material and a final privacy pass - **Goal**: before publishing anything, be able to answer "who owns what" in writing. - **Changes**: `NOTICE.md`; the third-party component list in `assets/第三方声明.txt` extended with the online speech service, online model endpoints and where private data lives; a plain-language privacy section in `assets/使用说明.txt`; the upstream MIT text kept byte-identical and shipped inside the package; `docs/code-signing.zh-CN.md` written; the 1.0.0 artifacts rebuilt so the package contains all of it. - **Verification**: a secret scan over the whole staged tree (zero hits), a check that the only absolute path left in the package is the `C:\Users\Public` fallback, `verify.mjs` and `check-release` green again, another silent install/uninstall acceptance run with a byte-for-byte comparison. - **Commit**: `d0351a2` — 开源材料补齐 + 隐私排雷 + 1.0.0 成品重打 ### 8. 2026-10-08 12:23 — 1.1.0: a clean standalone source repository - **Goal**: stop being "an upstream 0.3.0 tree plus a patch script" and become a repository that a stranger can clone, read and build. - **Changes**: - `overlay/` and the patch tooling deleted; the **actual shipped source** now lives in `src/`, with the previously patched files committed in their patched form. - `build/build.mjs` rewritten to lay the repository out into `stage/` (`app`, `launcher`, `standalone`, `defaults`, `assets`, `verify`, then the external `electron`, `node`, `speech` blocks); `build/toolchain.mjs` locates those three runtime pieces from an existing installation or from explicit flags, and checks their versions. - `build/check-paths.mjs` added as a build gate: no local absolute paths or private directories may enter the repository or the package. The `node.exe` lookup in the speech worker was rewritten to derive from the install layout, and test fixtures were changed to use a placeholder user name. - **Control-bridge hardening**: the desktop-side control server now requires a per-installation token (`Authorization: Bearer` / `X-Pet-Token`, constant-time comparison, `0600` token file, fail-closed if it cannot be written), the `Access-Control-Allow-Origin: *` header was replaced by an origin allow-list with `Vary: Origin`, `__proto__` key names are rejected, and `/health` stays unauthenticated but minimal. The standalone HTTP server got the same allow-list, and its `/shutdown` now requires `POST` + token — previously any web page could stop her with a single ``. - English-first `README.md` plus `README.zh-CN.md`, [SECURITY.md](../SECURITY.md), [CONTRIBUTING.md](../CONTRIBUTING.md), issue templates, and the docs in this directory. - **Verification**: `build/build.mjs` (full lay-out), `build/check-release.mjs` (the staged tree matches the repository byte for byte), `build/build.mjs verify` + `stage/verify.mjs` (all four tree fingerprints and 25 individual files), `build/check-paths.mjs` (170 files scanned, clean), the zip audit (714 entries, all non-ASCII names carrying the UTF-8 flag), and a full silent install/uninstall acceptance run: **711 files installed, 710 of them compared byte for byte against `stage/`** (`build/verify-install.py`), data root untouched, uninstall clean. - **Commit**: `82411e7` — 1.1.0:干净的独立仓库 + 控制口鉴权 + 隐私路径门禁 + 独立构建链 ### 9. 2026-10-08 12:52 — artifact file names are ASCII - **Goal**: the published file names must survive GitHub Releases. - **Finding**: GitHub **deletes** non-ASCII characters from release asset names — an uploaded `可爱大肥鱼桌宠-1.1.0-安装程序.exe` appears as `-1.1.0-.exe`, and renaming it back through the API is sanitized in exactly the same way. ASCII renames through the API work. - **Changes**: `build/mkexe.py`, `build/mkzip.py`, `build/checkzip.py`, `build/installer.nsi`, `assets/使用说明.txt` and the docs now use `cute-fat-fish-pet--setup.exe` and `cute-fat-fish-pet--win-x64.zip`; the 1.1.0 artifacts were rebuilt, re-verified and re-uploaded under the ASCII names. - **Commit**: `325b460` — 产物文件名一律 ASCII:GitHub 会删掉资产名里的非 ASCII ### 10. 2026-10-08 12:31 / 12:34 UTC — first public release - The repository was created as a public repository and the first push landed (`git rev-list --left-right --count origin/main...main` → `0 0`). - Release **v1.1.0** was published with the two artifacts and their two `.sha256` files. ### 11. 2026-10-08 21:4x — 1.1.1: a double-click really installs, and really uninstalls - **Goal** (a direct request): anyone who downloads the zip, unpacks it and double-clicks `安装.cmd` should end up with a working installation on the first try — and uninstalling must not leave debris behind. - **Root causes** (three old, one new — full write-ups in [known-issues.md](known-issues.md)): - KI-1: when `install.ps1` was missing (the "double-clicked inside the zip preview" case) Windows PowerShell printed an error and exited **0**, so the old wrapper's `if errorlevel 1` never paused and the window vanished without a word. - KI-2: the portable installer still wrote the old product name and version `1.0.0` into the shortcut and the uninstall entry. - KI-3: Chinese text in a UTF-8 `.cmd` plus `chcp 65001` made `cmd.exe` mis-parse the following lines (`'onPolicy' is not recognized …`). - KI-5 (found by testing this release): the uninstaller ran **from inside the directory it was deleting** — `cmd.exe`'s CWD was the install dir and it was reading `卸载.cmd` itself — so `Remove-Item -Recurse -Force -ErrorAction SilentlyContinue` failed as a whole, silently, leaving all 714 files. - **Changes**: - `assets\安装.cmd` 285 B → **3,941 B**, `assets\卸载.cmd` 1,086 B → **1,298 B**: pure ASCII / no BOM / LF; they check for the files they need before doing anything, print the Chinese explanation through `powershell -EncodedCommand` (code-page independent), always `pause`, and the uninstall wrapper parks its working directory in `%TEMP%` instead of the install folder. - `assets\install.ps1` 9,284 B → **11,085 B**: `$DisplayName = "可爱大肥鱼桌宠"` (same string as the NSIS `APP_NAME`), `$Version` read from the packaged `app\package.json`, `Publisher` shared with `APP_PUB`, legacy 「蓝毛小女仆」 shortcuts migrated to `.lnk.bak-` instead of leaving two icons. - `assets\uninstall.ps1` 5,221 B → **8,191 B**: handles both product names, deletes everything except the `.cmd` files that are still open (deleting the running batch file breaks the rest of the batch), and hands the leftovers to a detached hidden PowerShell helper that waits for the window to close and retries for up to ~2 minutes. - Version 1.1.1 in the four `package.json` files and in the `!define APP_VER` default of `build/installer.nsi`; `src/README.md` no longer names a version so future releases do not have to touch a packaged file. - **Verification**: real acceptance run on the shipped zip — unpack (714 entries) → run `安装.cmd` (10.2 s, English header + correct Chinese messages + `[ok] Setup finished.`) → `python build\verify-install.py stage `: **710 of 710 files byte-identical (797,834,384 B)** → registry `DisplayName=可爱大肥鱼桌宠`, `DisplayVersion=1.1.1`, `UninstallString` → that directory's `卸载.cmd` → run that uninstaller: shortcuts and uninstall entry removed, user data kept, **the install directory was gone two seconds after the window closed**. Gates all green: `build\check-release.mjs`, `build\nsi-syntax-check.py`, `build\mkzip.py`, `build\mkexe.py`, and the packaged `verify.mjs`. - **Commit**: `9633b0c` — 1.1.1:绿色包双击即装、卸载不留残骸 ### 12. 2026-10-09 — 1.1.2: the online model stops thinking out loud - **Goal** (a direct report): she was saying her whole thinking process out loud in chat — with the online model configured and the brain on 「在线(联网模型)」 or 「自动择优」. - **Two root causes found while investigating** (full write-up: [KI-6](known-issues.md)): - The saved `standalone\online.json` said `model: "DeepSeek-chat"`, while that endpoint serves `deepseek-flash` and `deepseek-v4-pro`; the capitalised name comes back as **HTTP 400** (the lowercase `deepseek-chat` is still accepted and answered by `deepseek-flash`). As saved, the online path was effectively unusable. - Those models are *thinking* models. With the pet's short budget (20–40 characters) the measured result was **0 characters of `content` and 556 characters of `reasoning_content`**, and `standalone/online.mjs` fell back to speaking the reasoning. `"enable_thinking": false` is not a real switch (still 21 tokens of thinking); `"thinking": {"type": "disabled"}` and `"reasoning_effort": "none"` are. - **Changes** — `standalone/online.mjs` 12,661 B → **14,228 B**, three edits: 1. `thinkingOff(baseUrl, model)` adds `"thinking": {"type": "disabled"}` for `api.deepseek.com` (never for `reasoner` models; other OpenAI-compatible endpoints are left untouched so no server rejects an unknown field). 2. A response that has reasoning but no content now returns `{ok: false, reason: "thinking-only"}` with 「联网模型只想了、没写正文:把预算调大,或在控制台换一个不思考的模型」 instead of narrating the reasoning — the policy the local path already followed (`src/lib/index.js:431`, the helper's `pet:local-quip`). 3. `toOpenAIMessages` now accepts a plain-string `content`; before, an array-only reader turned a string into an **empty prompt** and the model answered something unrelated. - Version 1.1.2 in the four `package.json` files and the `!define APP_VER` default of `build/installer.nsi`; `docs/release-notes-1.1.2.md` added, [known-issues.md](known-issues.md) gained KI-6. - **Verification**: `node --check`; real calls through the patched module — `onlinePing` → 「在的」; a persona with array-shaped content → 「主人早安呀~今天也要元气满满哦!✨」; a plain-string message → 「你真是独一无二的闪光存在!」. The same one-liner went from 400 tokens burned on thinking to **2 tokens**. Separately confirmed that `deepseek-flash` really reads images (a synthetic card came back as `FISH 42`), so the console's hint that it is a vision model is accurate. - **Commit**: ``dba4e2d`` — 1.1.2:联网模型不再把思考当台词 ### 13. 2026-10-09 — 1.1.3: 「打开 X」 reaches a website, a folder or a file - **Goal** (a direct request): in voice mode she had to understand and carry out 「打开xx游戏 / 打开A站首页 / 打开XX文件夹」 — an app, a URL or a file — and everything else had to be self-checked afterwards, with any bug found fixed. - **Reconnaissance**: the spoken and the typed command go through **one** function — `src/runtime/electron-helper/sprite.js` `onChatSendIntercept` (voice enters via `voiceRouteText` → `voiceTryIntent`). It had exactly two buckets, `isUrl(…) ? 'url' : 'app'`, where `isUrl` accepted only `^https?://`, `www.` or a small TLD list; and the main process (`ipcMain.handle('pet:open-target')`) had **no** branch for a local folder or file either. So 「打开 A站首页」, 「打开 下载文件夹」 and 「打开 报告.docx」 never got past parsing. No test file existed for any of it. - **Changes**: - New `src/runtime/electron-helper/targets.js` (**9,430 B**, plain logic, no Electron API): `classify(text)` → `url` / `file` / `app` in that priority (URL → local path → drive letter → system-folder token → site alias → app fallback), plus `siteUrl` / `folderToken` / `driveRoot` / `looksLikePath` / `searchUrl`. The site list is curated; `steam` and 音乐 are deliberately **not** in it, so 「打开蒸汽」 keeps starting the app and 「打开音乐」 keeps opening the music program instead of a folder. - `sprite.js` 245,629 B → **250,704 B**: `normOpenText()` (strips sentence-final particles and a leading 请/麻烦/劳驾, rewrites 「帮我把 B站 打开」 into 「打开 B站」) and `parseSearchIntent()` (「百度一下 / 搜一下 / 查一下 X」 → the result page, Bing by default, Baidu when named); `parseIntent`'s existing regex was left **byte-for-byte unchanged** so the already-working spoken forms could not break; the parsed target goes through `classify` and then through the matching `this.perm[kind]` gate; `execOpen` gained the `file` branch (opened / `ambiguous` → ask, do not guess / `not-found` → say so). - `main.js` 133,674 B → **138,421 B**: `pet:open-target` gained `kind: 'file'` → `openLocalTarget()` — a folder token resolves through `app.getPath()`, a drive letter or absolute path through `fs.existsSync` + `shell.openPath`, and a bare name through a read-only `readdirSync` of the **top level** of 桌面 / 下载 / 文档 / 图片 / 视频 / 音乐, **skipping `.lnk/.url/.exe/.bat/.cmd`** (those are apps: the file permission must never be able to launch software by accident). More than one hit asks instead of guessing. Nothing is ever deleted, moved, renamed or written. - `index.html` loads `targets.js` between `constants.js` and `sprite.js`; version 1.1.3 in the four `package.json` files and the `!define APP_VER` default of `build/installer.nsi`; `build/check-release.mjs` now pins `app/runtime/electron-helper/targets.js` as a key file; `docs/release-notes-1.1.3.md` added and [known-issues.md](known-issues.md) gained KI-7. - **Verification**: offline, `_accept/test-voice-targets.cjs` — **72 of 72** assertions (31 classification cases including 「打开音乐」/「打开steam」 regressions, 16 routing cases, ordinary chat not intercepted, each of the three permission switches blocking/allowing, recording priority, the ask/found/not-found replies, and `openLocalTarget` against a real temporary directory). In a real Electron renderer over CDP, `_accept/run-voice-live.cjs` + `_accept/test-voice-live.cjs` — **21 of 21**, including the real main process opening the Downloads folder and `%TEMP%` and answering `not-found` for a nonsense name. The packaged smoke self-test (drag, click-through, menus, animation webm, `errors: []`) found one **stale false negative of its own**: it checked the context menu immediately after the event, while the menu is built after two `await`s (`fetchWatchState` ≤2.5 s, `textModelMenuInfo` ≤1.2 s); it now polls, and reports `menuMounted: true, waitedMs: 200, panelCount: 35`. A 1.1.2 baseline run showed everything else identical, so the change introduced no regression. Gates: privacy scan (182 files clean), `check-release` (byte-for-byte), `--emit --write`, `stage/verify.mjs` (4 tree fingerprints + 26 key files), `mkzip`, `mkexe`. - **Commit**: `bd0ebd1` — 1.1.3:语音/打字说「打开 X」能开网页、文件夹和文件 ### 14. 2026-10-09 — 1.1.4: the console answers to a click instead of showing an API key - **Goal** (a direct request): before recording a promo video, she asked that sensitive things in the console — 「API key」 above all — be hideable, because they would end up on camera. Her second message the same day set the scope: 「我只需要能主动隐藏 API key 的功能,不要加录制什么的」 — only an active hide, no recording mode. - **Reconnaissance**: only two places ever showed a key — `launcher/index.html:163` `#onlineKey` and `:167` `#onlineVisionKey`, both `type="text"`. The plaintext travels `launcher/pet-api.js:35` `ONLINE_FILE = path.join(PATHS.standaloneDir, "online.json")` → `readOnline()` (`launcher/pet-api.js:230-242`, returns the raw key plus `hasKey/hasVisionKey/visionReady`) → `launcher/main.js:387` / `:415-427` `doOptions()` → the renderer's `syncForm` (`launcher/index.html:581`). Everything printed went through `log()` (`index.html:693-705`), `setupLine()` (`:712-720`), `#permLog` and the footer `#footRoot` / `#footData` — all of them `textContent = 原文`. - **Changes** (one file, `launcher/index.html`): - CSS: new `.eye` for the small 显示 / 隐藏 buttons, and the base input selector extended to `select, input[type=text], input[type=number], input[type=password]` — otherwise a password field would have no style and no `flex: 1`. The 「typed something ⇒ dirty」 selector at the bottom gained `input[type=password]` too. - Both key fields became `type="password"` (`autocomplete="off" spellcheck="false"`) with one `.eye` button each: the field shows dots by default and **she** decides when to look, either key independently. - New privacy block, `secretValues()` + `maskSecrets(text)`: the saved key becomes `••••••`, then `sk-[A-Za-z0-9_\-]{4,}` → `sk-••••••` and `(Bearer\s+)[A-Za-z0-9._\-]{6,}` → `$1••••••`; anything else is passed through untouched, and the function is idempotent. `applySecretVisibility()` flips `el.type` between `password` and `text` and keeps the button label (显示 / 隐藏) in sync. - `log()`, `setupLine()`, `#permLog` and the footer put `maskSecrets(text)` on the screen, so a key never lands in the terminal by accident. - A recording-mode switch (checkbox + `● 录制中` badge + `localStorage` + repainting old lines from `dataset.raw`) was written first and **removed the same day at her request**; nothing of it is left in the page. - **Verification**: in a real Electron renderer over CDP, `_accept/test-console-privacy.cjs` — **26 of 26** assertions: both fields start as dots while their `.value` is the real key (the whole `online.json` → `doOptions` → `syncForm` path proven); 显示 turns exactly that field to plain text, the button becomes 隐藏, the value does not change, the other field stays dotted; the page contains no `#privacyMode`, no `#privacyBadge`, no `body.privacy` class and no `localStorage` key; with the key on screen, keys, `sk-…` and `Bearer …` tokens are masked in the terminal, the setup log, the permission log and the footer; ordinary paths (`C:\Users\…\AppData\Roaming\BlueHairMaid`) and plain text pass through unchanged. The run used an isolated instance (temporary `DSH_PET_DATA_DIR`) and deleted its temporary `standalone\online.json` afterwards. Offline, the inline script extracted to `_accept/console-inline.js` (54,433 characters) passes `node --check`. - **Also**: KI-8 in [known-issues.md](known-issues.md); `docs/release-notes-1.1.4.md` (EN + Chinese) added and linked from both front pages; version 1.1.4 in the four `package.json` files, the `!define APP_VER` default of `build/installer.nsi`, `NOTICE.md`, both READMEs and the maintainer notes. The privacy gate then caught the new release notes themselves (2 hits) for quoting a literal local path as a counter-example — rewritten the way the other documents do it; the gate now scans 184 files clean. Removing the recording mode meant rebuilding both artifacts and refreshing their sizes and hashes in the documents. - **Acceptance**: both published artifacts were installed and uninstalled for real — the installer (52 s, 712 files, 711 of them byte-identical, `DisplayVersion=1.1.4`, data root untouched, uninstall 2 s) and the portable zip (extract 13 s, tree `verify.mjs` exit 0, `安装.cmd` 2.9 s, the same 711 files byte-identical, uninstall 4.3 s) — and her own 1.1.3 install was upgraded in place (55 s, 718 files, 711 of them byte-identical, `DisplayVersion` 1.1.4) and her pet restarted (4 `electron.exe`, `launcher/index.html` matching `stage/` byte-for-byte). Numbers in [known-issues.md](known-issues.md), 「1.1.4 的验收」. - **Commit**: `d30a214` — 1.1.4:控制台可以折叠 / 隐藏 API Key ### 15. 2026-10-09 — 1.2.0: the console can check GitHub and install a new version itself - **Goal** (a direct request): the console should be able to check whether a newer release exists and then download and install it with one button, instead of her fetching ~348 MB by hand and running the installer over a pet that is already running. - **Reconnaissance**: `launcher/` had no update code at all — every release up to 1.1.4 was a manual download, and the documents said plainly that a background auto-update was not implemented. Two facts shaped the design. First, a running `electron.exe` cannot overwrite its own files, so the install has to happen **after** the console has exited. Second, a console program (PowerShell) started from the Electron main process with `DETACHED_PROCESS` is unreliable: in this environment it reported exit 0 immediately and did nothing, which is exactly why the first build silently did nothing on a real machine (KI-10). - **Changes**: - New `launcher/update.js` (**27,340 B**): `check()` reads the GitHub `releases/latest` JSON (`tag_name`, `assets[].browser_download_url|url`, `size`, `digest: "sha256:<64hex>"`); `pickInstaller()` prefers `cute-fat-fish-pet--setup.exe`, then the version-less alias, then any `-setup.exe`; `download()` streams into `\updates\` (`.part` + rename, back-pressure aware), reuses a cached file only when its sha256 matches, and deletes anything whose size or hash disagrees; **an asset without a digest is refused outright**; `buildApplyScript()` + `buildVbsScript()` generate the hand-off pair; `apply()` first records `HKCU\Software\BlueHairMaid\InstallDir` with `reg.exe` (silent installs do not read the registry, so the folder must be passed as `/D=`), then starts `wscript.exe //nologo apply-update.vbs` detached; `readResult()` reads `updates\last-result.txt` once on the next start and decides by comparing versions (it tolerates the BOM PowerShell writes, ignores anything older than 7 days, and deletes the file either way). - The generated `apply-update.ps1` waits up to 60 s for the console PID to exit, clears `ELECTRON_RUN_AS_NODE` / `DSH_PET_SMOKE`, stops the pet if it was running, runs ` /S /D=` (8.3 short path when the folder contains a space), deletes the installer and the four portable scripts, writes `last-result.txt` (`ok` / `exit` / `from` / `to` / `at` / `log`) and starts the console again. `apply-update.vbs` exists only because PowerShell is a console program — started without a console it exits immediately and does nothing (KI-10). - `launcher/index.html` **95,329 B → 102,900 B**: `#checkUpdate` / `#doUpdate` / `#updateTag` / `#updateMsg` / `#updateBar` wired up — 「还没检查过」 → `当前 X → 新版 Y`, live download progress, the 「马上关掉窗口开始装」 hand-off, and on the next start 「刚更新过」 / 「上次更新没成」. - Version 1.2.0 in the four `package.json` files and the `!define APP_VER` default of `build/installer.nsi`; `verify.mjs` pins `launcher/update.js` as a 27th key file (the launcher tree grows from 9 to 10 files); `docs/release-notes-1.2.0.md` added; [known-issues.md](known-issues.md) gained KI-9 (the streamed download never wrote to disk — the hash matched because the hash and the file were fed the same chunks), KI-10 (the hand-off) and KI-11 (a PowerShell array built with `+` instead of `,` silently produced one file name). - **Verification**: offline `_accept/test-update.cjs` — **97 of 97** against a fake feed (cache reuse, short read, digest mismatch, a missing digest, the alias picker, the generated script's text, the VBS launcher); a hand-off rehearsal (`_accept/_probe/vbs-handoff.cjs`) against a fake root — 8 log lines, `ok=1`, the legacy scripts cleaned, the installer deleted, both generated scripts self-deleted. Then the real thing: `_accept/run-update-live.ps1` with a fake GitHub feed and the real 1.2.0 installer — the CDP-driven live run **21 of 21** (button disabled first, `当前 1.1.4 → 新版 1.2.0`, progress samples, the hand-off line, the full update log up to `收工`), `verify-install.py` exit 0 (**712 files byte-identical, 797,896,795 B**), read-back **4 of 4**, `failures: 0`. Both artifacts accepted for real (installer: 52 s, 713 files, 712 byte-identical, `DisplayVersion=1.2.0`, data root untouched, uninstall 2 s; zip: extract 21 s, in-tree `verify.mjs` exit 0 with 27 key files, `安装.cmd` 2.8 s, uninstall 3.8 s). Gates: privacy scan, `check-release --emit --write`, `stage/verify.mjs` (4 tree fingerprints + 27 key files), `nsi-syntax-check`, `mkzip` (716 files, 58 s), `mkexe` (574 s). Numbers in [known-issues.md](known-issues.md), 「1.2.0 的验收」. - **Commit**: `1f7fbdd` — 1.2.0:控制台能自己检查更新并一键装上 ### 16. 2026-10-09 — the online-test buttons now say what happened (a fix on `main`, still 1.2.0) - **Goal** (a direct report): clicking 「测一下通不通」 in the 联网模型 section gave no visible feedback at all, so there was no way to tell whether the online model was reachable. - **Reconnaissance**: two independent causes. First, the probe's result was only written to the terminal at the very bottom of the long console page (`log("系统", …)` → `#term`), far below the section the user is looking at — the click looked like it did nothing. Second, `$("testOnline").onclick` probed with the **saved** `standalone/online.json`: only 「保存」 and the vision button called `saveOnlineFields()`, so typing a new key and clicking the test still tested the previous configuration. - **Changes** (`launcher/index.html` only, 66 insertions / 10 deletions): each test button grew a `.testmsg` status row right under it (`#testOnlineMsg`, `#testVisionMsg`, plus `.testmsg.ok` / `.bad` / `.wait` colours). Clicking disables the button and shows an amber 「正在问……(最多 20 秒)」; when the probe returns, the same row turns green (`…:通了( ms),它回「…」`) or red (`…:没通 —— `) and the button comes back. Both handlers now `await saveOnlineFields(false)` first, so the probe uses whatever is in the boxes. 「测『看屏幕』」 with no vision model configured shows a neutral explanation (this one runs the local Ollama vision model, no network) instead of looking like an error. Every message goes through `maskSecrets()`, so the key cannot leak into the page. - **Verification**: new `_accept/test-online-feedback.cjs` — a fake OpenAI endpoint (answers 「在的」), a dead port, an isolated console (`DSH_PET_DATA_DIR`) and CDP clicks on the real buttons — **32 of 32, exit 0**: both initial explanations, the busy state at the instant of the click, `联网模型:通了(15 ms),它回「在的」` with the `ok` colour, the fake endpoint actually receiving `Bearer ` and the model name from the box (the saved config pointed at the dead port, so a green result can only come from the box), the terminal still logging a line, the red `没通 —— … fetch failed` answer with no key in it, and the three vision states. The `stage/standalone/online.json` it swaps out is restored afterwards. - **Commit**: `1054b79` — 修复:联网模型「测一下通不通」点完没有任何反馈 - **Not released, on purpose**: her call — 「只是修复,不用发 1.2.1,沿用 1.2.0」. No version string changed, no artifact was rebuilt and no release was published, so the 1.2.0 artifacts on GitHub still carry the old behaviour; this fix rides along with the next release. Her own install was patched in place (`launcher/index.html` → `93418C2D…4E309`, where the 1.2.0 build has `FDAD652B…470B`) and the console restarted, so she sees the new rows immediately. --- ### 17. 2026-10-09 — the console is usable by a real person: whole header rows click, focus is visible, results land where you look (a fix on `main`, still 1.2.0) - **Goal** (a direct request): 「模拟真人,对UI设计界面的互动性进行分析,不好的进行优化」 — walk the console the way a real person does (mouse, Tab, clicking) and fix what that walk exposes. - **Reconnaissance**: a new harness, `_accept/ui-audit.cjs`, starts an isolated console (`DSH_PET_DATA_DIR=%TEMP%\dsh-pet-ui-audit`, its own `--user-data-dir`) and drives it over CDP: it measures every control's box, cursor, transition and disabled state, really hovers with `Input.dispatchMouseEvent`, really presses Tab with `Input.dispatchKeyEvent`, and captures the whole page plus each section into `_accept/ui-shots/`, with the numbers in `_accept/ui-audit.json`. It is read-only — it steers clear of 发送命令 / 打开程序 / 录屏 / 更新 — and it restores `stage/standalone/online.json` (deletes it if it was absent: that file is runtime state and is not in the 1.2.0 package). What it found, in a 2405 px page with 131 interactive controls: only **2** `:focus` rules in the whole stylesheet, so Tab landed on buttons, headers and chips with nothing visible; `.seg input { display: none }` meant 「在线 / 本地 / 自动择优」 and the two-way persona choice were **unreachable by keyboard**; each collapsed `
` header was a **19 px** `` inside padding that made the row look 52 px tall, so clicking the top or bottom of the header did nothing; the terminal sat at y=1834 and was 104 px tall while most actions only write there, so clicking a button further up looked inert (the same root cause as step 16); editing a field set `dirty` but nothing said 「有改动没保存」; small targets were 19–26 px (chips 19 px, `.eye` 26 px, native checkboxes 13 px, range 16 px); and collapse state was never remembered (0 matching `localStorage` keys). - **Changes** (`launcher/index.html` only, every edit tagged `[互动@1]`): a `:focus-visible` ring for `button`, `summary`, `.chip`, `.seg label` and `.list .it`, with `.seg input` shrunk to 1 px instead of hidden so it can be focused and the ring is drawn on its label; collapsed headers now own the whole 44 px row (`details.sec > summary.sh { margin: -10px -14px 10px; padding: 13px 14px; min-height: 44px }`) with a hover tint; a permanent bottom status bar (`#statusbar`: latest message, 「去终端 ↓」, 「↑ 顶部」, `.ok/.err/.wait` colours, one-shot pulse) plus a 「↓ 有新消息」 pip in the terminal that appears when you scroll up and takes you back down, and a 「清屏」 button; a 「● 有改动没保存」 badge on the section you edited plus a highlighted save button, cleared when you save; deleting a persona now takes two clicks (3.2 s to arm, auto-disarms); small controls are all ≥26 px, buttons dip 1 px and darken on `:active` (previously only hover brightened), the command line supports `↑`/`↓` history (50 entries), and collapse state persists in `localStorage["pet.console.sections.v1"]` keyed by the first 12 characters of the header. Disabled 「停止桌宠 / 状态页」 buttons also gained `title` text explaining why they are dead. - **Verification**: `_accept/ui-audit.cjs` — **15 of 15, exit 0**: new terminal output scrolls to the bottom by itself; scrolling up raises 「有新消息」 and clicking it returns to the bottom; the status bar shows the latest line and stays `position: fixed` inside the viewport; all 40 Tab stops have a visible focus ring (the "no visible focus" list is empty); the segmented control is keyboard-reachable and not `display: none`; editing marks the section and saving clears it; an expanded section is still expanded after a reload; `↑` recalls the command just sent and 清屏 works; every common small control is ≥26 px; deleting a persona needs two clicks. Re-measured: the page is 2405 → **2375 px** and the only remaining sub-28 px hits are the 1 px visually-hidden `.seg input`s and the native checkboxes inside ≥26 px `