diff --git a/.dockerignore b/.dockerignore
index 8ad528c1..1e4337a9 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,10 +1,20 @@
+# What never enters a build context rooted here: the runtime image (Dockerfile) copies
+# pyproject.toml, README.md, LICENSE, nanomuse/ and config/config.example.toml; the showcase's
+# Caddy image (demo/showcase/caddy/Dockerfile, built from this root too) copies demo/mobilegym/
+# and demo/showcase/site/. A pattern without `**/` matches at the root only (Go's
+# filepath.Match, not gitignore), so the caches are listed with it.
.git
+.github
.venv
.env
-__pycache__
-*.pyc
-.pytest_cache
-.ruff_cache
+.env.*
+**/__pycache__
+**/*.pyc
+**/.pytest_cache
+**/.ruff_cache
+**/.mypy_cache
+**/*.egg-info
+**/node_modules
workspace/
config/config.toml
config/*.local.toml
@@ -12,6 +22,21 @@ tests/
docs/
dist/
build/
-*.egg-info
-web/node_modules/
-web/dist/
+# the other trees: the Android and iPhone apps with their native builds, the desktop, the
+# relay, the web console's sources (its build is committed under nanomuse/server/static/),
+# the docs site, the project page
+android/
+harness/
+cloud/
+web/
+website/
+site/
+assets/
+scripts/
+CHANGELOG.md
+# the showcase's run-time directories (demo/showcase/.gitignore): the dataset, other sites
+demo/showcase/data/
+demo/showcase/site/dist/
+demo/showcase/sites.d/
+demo/showcase/www/
+demo/showcase/mobilegym/
diff --git a/.gitattributes b/.gitattributes
index bc9a5a00..8c3b624d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -3,3 +3,19 @@
# normalised: android/ is an upstream subtree and carries a few vendored files as they came.
*.sh text eol=lf
gradlew text eol=lf
+
+# The desktop (harness/) is built and tested on Windows runners, whose checkout turns LF into
+# CRLF under core.autocrlf; its tests read their own sources and look for `\n` in them (the
+# v1.0.3 tag's Windows installer build failed on exactly that), so the tree's text files keep
+# LF on every platform. Git tells text from binary itself; the clips and pictures are named
+# so that the detection can never touch them.
+harness/** text=auto eol=lf
+harness/**/*.mp4 binary
+harness/**/*.png binary
+harness/**/*.webp binary
+harness/**/*.icns binary
+harness/**/*.ico binary
+# The phones' copies of the desktop's catalogues and Ideas lists: the desktop's tests compare
+# them with its own byte for byte (tests/fences.test.mjs), so they keep LF the same way.
+android/src/android/app/src/main/assets/nanomuse/*.json text eol=lf
+android/src/ios/NanoMuse/Resources/*.json text eol=lf
diff --git a/.github/ISSUE_TEMPLATE/app_bug_report.yml b/.github/ISSUE_TEMPLATE/app_bug_report.yml
index eb3402a9..932c29a0 100644
--- a/.github/ISSUE_TEMPLATE/app_bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/app_bug_report.yml
@@ -30,7 +30,7 @@ body:
id: version
attributes:
label: nanoMuse version
- description: Settings → About, e.g. 1.0.2 (45)
+ description: Settings → About, e.g. 1.0.3 (46)
validations:
required: true
- type: input
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 8cc569a6..14e0a4d5 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -58,7 +58,7 @@ body:
id: os
attributes:
label: OS and runtime
- description: e.g. macOS 15 / Python 3.12, Ubuntu 24.04 / Docker, Windows 11 / desktop 1.0.2, iOS 18 / iPhone 15
+ description: e.g. macOS 15 / Python 3.12, Ubuntu 24.04 / Docker, Windows 11 / desktop 1.0.3, iOS 18 / iPhone 15
- type: textarea
id: logs
attributes:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index f095b419..e12724cb 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -2,7 +2,7 @@
## What
-
+
## Why
@@ -12,7 +12,7 @@
-- [ ] The checks for the part I changed pass (runtime: `ruff check nanomuse tests scripts && ruff format --check nanomuse tests scripts && mypy && python -m pytest -q -m "not live"`; relay: `cd cloud && ruff check . && pytest`; web: `cd web && npm run check && npm run build`; desktop: `cd harness/dsh-nanomuse && pnpm build && pnpm typecheck && pnpm test`)
+- [ ] The checks for the part I changed pass (runtime: `ruff check nanomuse tests scripts && ruff format --check nanomuse tests scripts && mypy && python -m pytest -q -m "not live"`; relay: `cd cloud && ruff check . && ruff format --check . && pytest`; web: `cd web && npm run check && npm run build`; desktop: `cd harness/dsh-nanomuse && pnpm build && pnpm typecheck && pnpm test`)
- [ ] New behaviour has a test; tests use `MockLLM`, no network
- [ ] If a person sees or does something differently: a line under `## [Unreleased]` in `CHANGELOG.md` (past tense, concrete, in the right section) and the page under `docs/` with its `docs/zh/` twin
- [ ] If a user-visible string was added or changed: every locale of that file (English first, then 简体中文, then the rest; same placeholders)
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 00000000..30399278
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,52 @@
+# Platform labels for pull requests, from the files a pull request touches (actions/labeler).
+# The kind label (bug, enhancement, documentation, ci, release, maintenance) comes from the title;
+# see .github/workflows/labels.yml and CONTRIBUTING.md, "Issues and pull requests (maintainers)".
+
+android:
+ - changed-files:
+ - any-glob-to-any-file:
+ - "android/src/android/**"
+ - "android/deps/**"
+ - "android/scripts/**"
+ - "scripts/android/**"
+ - "scripts/release-apk.sh"
+
+ios:
+ - changed-files:
+ - any-glob-to-any-file:
+ - "android/src/ios/**"
+ - "scripts/gen-ios-icons.py"
+ - ".github/workflows/ios-*.yml"
+
+desktop:
+ - changed-files:
+ - any-glob-to-any-file:
+ - "harness/**"
+ - ".github/workflows/desktop-app.yml"
+ - ".github/workflows/harness.yml"
+
+web:
+ - changed-files:
+ - any-glob-to-any-file:
+ - "web/**"
+ - "nanomuse/server/static/**"
+ - "demo/**"
+
+runtime:
+ - changed-files:
+ - any-glob-to-any-file:
+ # nanomuse/ without nanomuse/server/static/, the committed web build (that is `web`,
+ # above): the four globs together cover every other file under nanomuse/
+ - "nanomuse/*"
+ - "nanomuse/!(server)/**"
+ - "nanomuse/server/*"
+ - "nanomuse/server/!(static)/**"
+ - "tests/**"
+ - "pyproject.toml"
+ - "Dockerfile"
+ - "docker-compose.yml"
+
+cloud:
+ - changed-files:
+ - any-glob-to-any-file:
+ - "cloud/**"
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index a7631070..f0dd4803 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -88,12 +88,24 @@ jobs:
./gradlew :app:assembleDebug --console=plain --no-daemon
# Our unit tests (io.github.nanomuse.*: the guards, the hub client, the sandbox paths, the
- # strings' parity). Same Gradle invocation as AGENTS.md; the compiled classes are warm.
+ # strings' parity), plus the upstream provider and sandbox tests, the two OpenMinis packages
+ # our edits touch (#333, #334, #335). Same Gradle invocation as AGENTS.md; the compiled
+ # classes are warm.
- name: Unit tests
working-directory: android/src/android
run: |
export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
- ./gradlew :app:testDebugUnitTest --tests 'io.github.nanomuse.*' --console=plain --no-daemon
+ ./gradlew :app:testDebugUnitTest --tests 'io.github.nanomuse.*' \
+ --tests 'com.openminis.app.provider.*' --tests 'com.openminis.app.sandbox.*' \
+ --console=plain --no-daemon
+
+ # The on-device tests cannot run here (no emulator), but the source set must keep
+ # compiling or io.github.nanomuse.reach.ReachHubInstrumentedTest never runs anywhere (#332).
+ - name: androidTest compiles
+ working-directory: android/src/android
+ run: |
+ export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION"
+ ./gradlew :app:compileDebugAndroidTestKotlin --console=plain --no-daemon
- name: Badging
run: |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5aa75a8d..f9559dfa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,7 @@ jobs:
- "nanomuse/**"
- "tests/**"
- "scripts/**"
+ - "config/**" # config.example.toml: the tests read it, the image carries it
- "pyproject.toml"
- "Dockerfile"
- ".dockerignore"
@@ -172,6 +173,10 @@ jobs:
if: env.RUN == 'true'
run: uv run ruff check .
+ - name: Ruff format
+ if: env.RUN == 'true'
+ run: uv run ruff format --check .
+
- name: Tests
if: env.RUN == 'true'
run: uv run python -m pytest -q
diff --git a/.github/workflows/desktop-app.yml b/.github/workflows/desktop-app.yml
index a7878365..06449af7 100644
--- a/.github/workflows/desktop-app.yml
+++ b/.github/workflows/desktop-app.yml
@@ -8,8 +8,8 @@
# Each platform builds the bundle (harness/dsh-nanomuse: pnpm build, test, pack), stages dsh
# next to it with npm (scripts/prepare-dsh.mjs, the platform's own native prebuilds), builds
# the runtime (scripts/desktop-app/build-runtime.py --target harness/desktop/runtime, a
-# PyInstaller build), wraps it all with electron-builder, and
-# then boots the unpacked app's harness once in Node mode (scripts/smoke.mjs) to see the Host
+# PyInstaller build), runs the shell's own tests (harness/desktop: npm test), wraps it all
+# with electron-builder, and then boots the unpacked app's harness once in Node mode (scripts/smoke.mjs) to see the Host
# come up with the bundle's rows. On macOS the Swift helper that holds the hands' permissions
# (harness/desktop/mac/computer-use/build.sh → "nanoMuse Computer Use.app") is built first and
# electron-builder lays out the .app with it inside;
@@ -40,7 +40,10 @@ permissions:
contents: read
# One run per branch; a newer push to the same branch cancels the older build,
-# but a tag or a manual run is never cancelled.
+# but a tag or a manual run is never cancelled. A manual run shares the group of the branch it
+# runs on: "Run workflow" on main while a push to main is building waits for that build to end
+# rather than running beside it, which is slower but never leaves two runs attaching the
+# same installers.
concurrency:
group: desktop-app-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' && !startsWith(github.ref, 'refs/tags/') }}
@@ -144,6 +147,7 @@ jobs:
run: |
npm ci
npm run typecheck
+ npm test
npm run dist
- name: The helper that holds the hands' permissions (macOS, nanoMuse Computer Use.app, Swift)
if: runner.os == 'macOS'
@@ -167,6 +171,7 @@ jobs:
run: |
npm ci
npm run typecheck
+ npm test
npm run dist:dir
../../scripts/desktop-app/package-mac.sh "${{ matrix.label == 'macos-arm64' && 'arm64' || 'x64' }}"
- name: Boot the packaged harness once
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 770b26f7..50cf7263 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -3,6 +3,7 @@ name: Docker image
# Publishes ghcr.io/nano-muse/nanomuse for linux/amd64 and linux/arm64:
# main -> :edge
# tag vX.Y.Z -> :X.Y.Z, :X.Y, :latest
+# tag vX.Y.Z-rc1 -> :X.Y.Z-rc1 only (a pre-release moves neither :X.Y nor :latest)
# Pull requests build the amd64 image without pushing (the CI workflow smoke-tests it).
on:
@@ -47,7 +48,8 @@ jobs:
type=edge,branch=main
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-fork.') }}
+ # fork tags (v1.0.3-fork.1) contain '-' and never move latest; neither do prereleases
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') }}
labels: |
org.opencontainers.image.title=nanoMuse
org.opencontainers.image.description=Open-source version of Meta's Muse personal agent: phone app, Sentinel gatekeeper, credential vault, memory and goals. Bring your own model.
@@ -80,7 +82,8 @@ jobs:
type=edge,branch=main
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-fork.') }}
+ # fork tags (v1.0.3-fork.1) contain '-' and never move latest; neither do prereleases
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') }}
labels: |
org.opencontainers.image.title=nanoMuse (with browser)
org.opencontainers.image.description=nanoMuse with Chromium bundled for the browser tool.
diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml
index 18616ca5..71ea614b 100644
--- a/.github/workflows/harness.yml
+++ b/.github/workflows/harness.yml
@@ -5,20 +5,24 @@
# (docs/harness.md, harness/README.md); nanoMuse Desktop
# itself carries the same bundle (desktop-app.yml)
#
-# A push or pull request that touches harness/ builds, typechecks and tests the bundle; a
-# release tag `v*` also packs it and attaches the tarball and its checksum to the release of
+# A push or pull request that touches harness/ builds, typechecks and tests the bundle and the
+# Electron shell (harness/desktop: typecheck and its node tests, no installer), on Linux and on
+# Windows, where the tests see a checkout made with core.autocrlf and Windows paths; a
+# release tag `v*` also packs the bundle and attaches the tarball and its checksum to the release of
# the same name (the one scripts/release-apk.sh creates with the APK), retrying until it
# exists. "Run workflow" with a release tag does the same for a release that already exists.
# The bundle's version must be the tag's: the release recipe bumps harness/dsh-nanomuse/package.json.
+# The paths include the phones' copies of the catalogues and of the Ideas list: the tests
+# check that they match the desktop's (tests/fences.test.mjs, connectors-json.mjs --check).
name: harness bundle
on:
push:
branches: ["**"]
- paths: ["harness/**", "scripts/connectors-json.mjs", "scripts/providers-json.mjs", "nanomuse/llm/providers.json", "android/src/android/app/src/main/assets/nanomuse/connectors.json", "android/src/android/app/src/main/assets/nanomuse/providers.json", "android/src/ios/NanoMuse/Resources/connectors.json", "android/src/ios/NanoMuse/Resources/providers.json", ".github/workflows/harness.yml"]
+ paths: ["harness/**", "scripts/connectors-json.mjs", "scripts/providers-json.mjs", "nanomuse/llm/providers.json", "android/src/android/app/src/main/assets/nanomuse/connectors.json", "android/src/android/app/src/main/assets/nanomuse/providers.json", "android/src/android/app/src/main/assets/nanomuse/ideas.*.json", "android/src/ios/NanoMuse/Resources/connectors.json", "android/src/ios/NanoMuse/Resources/providers.json", ".github/workflows/harness.yml"]
tags: ["v*"]
pull_request:
- paths: ["harness/**", "scripts/connectors-json.mjs", "scripts/providers-json.mjs", "nanomuse/llm/providers.json", "android/src/android/app/src/main/assets/nanomuse/connectors.json", "android/src/android/app/src/main/assets/nanomuse/providers.json", "android/src/ios/NanoMuse/Resources/connectors.json", "android/src/ios/NanoMuse/Resources/providers.json", ".github/workflows/harness.yml"]
+ paths: ["harness/**", "scripts/connectors-json.mjs", "scripts/providers-json.mjs", "nanomuse/llm/providers.json", "android/src/android/app/src/main/assets/nanomuse/connectors.json", "android/src/android/app/src/main/assets/nanomuse/providers.json", "android/src/android/app/src/main/assets/nanomuse/ideas.*.json", "android/src/ios/NanoMuse/Resources/connectors.json", "android/src/ios/NanoMuse/Resources/providers.json", ".github/workflows/harness.yml"]
workflow_dispatch:
inputs:
release_tag:
@@ -40,6 +44,7 @@ jobs:
bundle:
name: build · test · pack
runs-on: ubuntu-latest
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
@@ -59,6 +64,16 @@ jobs:
pnpm build
pnpm typecheck
pnpm test
+ # The Electron shell's own checks (AGENTS.md). Its tests stand in a fake `electron`
+ # module, so the Electron binary is not downloaded; desktop-app.yml builds the real thing.
+ - name: The desktop shell typechecks and its tests pass
+ working-directory: harness/desktop
+ env:
+ ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
+ run: |
+ npm ci
+ npm run typecheck
+ npm test
- name: The phones' copies of the connectors catalogue are current
run: node scripts/connectors-json.mjs --check
- name: The clients' copies of the provider catalogue are current
@@ -99,6 +114,42 @@ jobs:
path: packages/*
if-no-files-found: error
+ # The same build, typecheck and tests on Windows, on every pull request: the v1.0.3 tag's
+ # Windows installer build failed on a test that first met a CRLF checkout there, which
+ # desktop-app.yml only runs on a tag or by hand. No pack, no dsh install, no installer.
+ windows:
+ name: build · test (Windows)
+ runs-on: windows-latest
+ # a test that leaves a handle open hangs the process; the limit turns that into a failure
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v7
+ - uses: pnpm/action-setup@v6
+ with:
+ version: 11
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: harness/dsh-nanomuse/pnpm-lock.yaml
+ - name: The bundle
+ working-directory: harness/dsh-nanomuse
+ shell: bash
+ run: |
+ pnpm install --frozen-lockfile
+ pnpm build
+ pnpm typecheck
+ pnpm test
+ - name: The desktop shell
+ working-directory: harness/desktop
+ shell: bash
+ env:
+ ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
+ run: |
+ npm ci
+ npm run typecheck
+ npm test
+
release:
if: (startsWith(github.ref, 'refs/tags/v') || inputs.release_tag != '') && !contains(inputs.release_tag || github.ref_name, '-fork.')
needs: bundle
diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml
index 26f7a38f..191913c4 100644
--- a/.github/workflows/ios-testflight.yml
+++ b/.github/workflows/ios-testflight.yml
@@ -6,6 +6,8 @@ name: iOS · TestFlight
# IOS_DIST_P12_BASE64, IOS_DIST_P12_PASSWORD
# Signing is manual: the team's Apple Distribution certificate from the secrets, the App Store
# profiles fetched from the account with the key. Nothing that signs is in the repository.
+# The build number (CFBundleVersion) is this workflow's run number, so every upload counts up
+# on its own and the release notes can name it; the marketing version is the project's.
on:
workflow_dispatch:
diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml
new file mode 100644
index 00000000..03ad501e
--- /dev/null
+++ b/.github/workflows/labels.yml
@@ -0,0 +1,60 @@
+name: Labels
+
+# Every pull request carries labels: the platforms from the files it touches (.github/labeler.yml)
+# and its kind from the title (feat is enhancement, fix is bug, docs is documentation, ci is ci,
+# build(release) is release, the other types are maintenance). Labels are only added, never
+# removed, so a maintainer's hand-set labels stay. pull_request_target runs this on main's copy
+# of the workflow with the token it needs; it checks out nothing from the pull request.
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize, edited]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+concurrency:
+ group: labels-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+jobs:
+ label:
+ name: Platform and kind
+ runs-on: ubuntu-latest
+ steps:
+ - name: Platforms, from the files touched
+ uses: actions/labeler@v5
+ with:
+ configuration-path: .github/labeler.yml
+ sync-labels: false
+ - name: Kind, from the title
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ NUMBER: ${{ github.event.pull_request.number }}
+ TITLE: ${{ github.event.pull_request.title }}
+ run: |
+ set -euo pipefail
+ kind=""
+ re='^([a-z]+)(\(([^)]*)\))?!?: '
+ if [[ "$TITLE" =~ $re ]]; then
+ type="${BASH_REMATCH[1]}"
+ scope="${BASH_REMATCH[3]}"
+ case "$type" in
+ feat) kind=enhancement ;;
+ fix) kind=bug ;;
+ docs) kind=documentation ;;
+ ci) kind=ci ;;
+ build|chore|refactor|test|style|perf|revert)
+ if [[ "$scope" == *release* ]]; then kind=release; else kind=maintenance; fi ;;
+ esac
+ fi
+ have=$(gh pr view "$NUMBER" --repo "$REPO" --json labels --jq '.labels[].name')
+ if [[ -n "$kind" ]] && ! grep -qx "$kind" <<<"$have"; then
+ gh pr edit "$NUMBER" --repo "$REPO" --add-label "$kind"
+ have="$have"$'\n'"$kind"
+ fi
+ if [[ -z "$(tr -d '[:space:]' <<<"$have")" ]]; then
+ gh pr edit "$NUMBER" --repo "$REPO" --add-label maintenance
+ fi
diff --git a/.github/workflows/showcase.yml b/.github/workflows/showcase.yml
index 49ffdb62..54b87a39 100644
--- a/.github/workflows/showcase.yml
+++ b/.github/workflows/showcase.yml
@@ -37,6 +37,8 @@ jobs:
with:
python-version: "3.12"
- run: pip install -e './demo/showcase/gateway[dev]'
+ # the gateway, the mirror's scripts and the tests: the same lint the runtime gets
+ - run: ruff check demo && ruff format --check demo
- run: pytest -q demo/showcase/gateway/tests
publish:
diff --git a/.gitignore b/.gitignore
index 2499f685..3e775d17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@ config/config.toml
config/*.local.toml
.env
.env.*
+*.env
!.env.example
# runtime data
@@ -55,7 +56,8 @@ android/build/
android/app/build/
android/local.properties
android/keystore.properties
-android/.kotlin/
+# the Kotlin daemon's session files, beside the Gradle project (android/src/android)
+android/src/android/.kotlin/
android/native/build/
*.jks
*.keystore
diff --git a/AGENTS.md b/AGENTS.md
index 0fbead22..edaa51bd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,7 @@ mypy
python -m pytest -q -m "not live"
# Relay (cloud/)
-cd cloud && pip install -e ".[dev]" && ruff check . && pytest
+cd cloud && pip install -e ".[dev]" && ruff check . && ruff format --check . && pytest
# Web console (web/); the built app under nanomuse/server/static/ is committed; rebuild and commit it
cd web && npm ci && npm run check && npm run build
@@ -48,7 +48,7 @@ cd harness/desktop && npm ci && npm run typecheck && npm test
# Android (android/src/android); JDK 21, NDK r27c; natives first: bash scripts/android/build-natives.sh
cd android/src/android && ./gradlew :app:assembleDebug --console=plain --no-daemon
-./gradlew :app:testDebugUnitTest --tests 'io.github.nanomuse.*' --console=plain --no-daemon # our unit tests; CI runs them too
+./gradlew :app:testDebugUnitTest --tests 'io.github.nanomuse.*' --tests 'com.openminis.app.provider.*' --tests 'com.openminis.app.sandbox.*' --console=plain --no-daemon # our unit tests and the two upstream packages we edit; CI runs them too
# iOS (android/src/ios); macOS only; the device build must finish with zero warnings in NanoMuse/
xcodebuild build -project Minis.xcodeproj -scheme Minis -configuration Debug -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO
@@ -72,7 +72,7 @@ cd website && npm ci && npm run docs:build # also the dead-link check; `np
- **Commits** follow Conventional Commits (`feat(android): …`, `fix(relay): …`), carry a DCO sign-off (`git commit -s`), and are merged by rebase or merge commit, never squash.
- **No secrets, ever.** No keys, tokens, phone numbers, e-mail addresses or private hosts in code, docs, tests or fixtures; the one address allowed is the maintainer's contact in the READMEs' *Maintainers* table, there at his request. `config/config.toml` and `cloud/.env` are git-ignored; secrets reach the model as `{{vault:NAME}}` and nothing else.
- **Some material stays out of the repository.** Muse's system prompt, Meta's brand screenshots, anything decompiled or intercepted from the Muse app, and internal planning (budgets, capacity estimates, promotion drafts). A screenshot we publish has order numbers, names, addresses, hostnames and keys masked.
-- **Issues and outside pull requests** are handled as [CONTRIBUTING.md](CONTRIBUTING.md#issues-and-pull-requests-maintainers) says: labels on every issue, a fix says `For #N` and the issue is closed by hand once the fix ships, a contributor's commit is never rewritten.
+- **Issues and outside pull requests** are handled as [CONTRIBUTING.md](CONTRIBUTING.md#issues-and-pull-requests-maintainers) says: labels on every issue and every pull request (the *Labels* workflow adds the platform and the kind), a fix says `For #N` (never `Fixes`, `Closes` or `Resolves`, which would close the issue on merge), a landed fix gets the `fixed on main` label and a reply, the issue stays open until a maintainer decides to close it, a contributor's commit is never rewritten.
- **Everything that acts goes through the Sentinel** with an honest `risk`; tests use `MockLLM`; Ruff line length 100; type hints everywhere.
## Wire contracts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74f92bf1..eeff5a95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,29 +6,121 @@ All notable changes to nanoMuse. The format follows [Keep a Changelog](https://k
### Cloud
+- **Signing a device out also drops its line to the hub.** *Sign out everywhere*, a sign-in revoked on the account page and a device's own sign-out revoked the key at once, but a hub socket that device had open stayed open until it next reconnected, so a lost phone could still reach the account's other devices for a while. The relay now closes such a socket with `4001 bad_key` the moment the key is revoked, and the other devices see the device offline.
+- **The web console open in two tabs no longer fights itself.** Every tab of one browser said hello to the hub with the same device id, so the two took turns replacing each other and reconnecting for as long as both stayed open, and a message sent from the tab that had just lost came back as disconnected. Each tab is now its own device on the hub; a reload keeps the tab's id.
+- **A self-hosted relay's backup recipe takes a consistent copy.** The command in the self-hosting guide called a `sqlite3` binary the image does not have and fell back to copying `cloud.db`, which under WAL misses the newest writes; it now uses SQLite's backup API inside the running container and streams the copy out. The production notes restore a backup into the data directory as uid 10001, the user the relay runs as. A code mail composed with a `CODE_TTL_S` under a minute says the code is good for one minute, as the SMS template already did.
+- **The admin page shows who invited whom.** The funnel line said how many people had made a code, brought someone and come through one, and nothing more. A new *Invites* view (`GET /v1/admin/invites`) ranks the inviters by the accounts their code brought, with how many of those ever made a request, how many were seen from one of the inviter's own addresses, what they spent, what the inviter earned from them and when the first and the last signed up; a row unfolds to the people it brought. An account's page names its inviter by identifier, with a link, and lists everyone it brought with when they joined, whether they used anything and from where; the accounts table marks an inviter with the count and can sort by it. In English and 简体中文.
+
+### Runtime
+
+- **Creating a reminder, a trigger or a goal counts as a change, not a read.** The `reminders`, `triggers` and `goals` tools declared every action `safe`, the level for reads with no side effect, although each `create` sets up work the model takes up later on its own. `create` is now `moderate`, so `strict` mode asks before a routine, a mail trigger or a goal is set up; `list`, `cancel` and the goals' bookkeeping stay `safe`, and `ask` and `auto` mode behave as before. The risk table in the Sentinel page says so (#387).
+- **`web_fetch` and the browser refuse every address that is not globally routable.** The carrier-grade NAT block 100.64.0.0/10, which a Tailscale network or an ISP uses, passed the guard; it is now refused with the loopback, link-local, private, multicast and reserved ranges, in IPv4 and IPv6 and through redirects, and a test pins each range (#387).
+- **A calendar feed that fails no longer shows its link.** A private calendar link is a secret, and the status error for a feed that answered 404 or 401 carried the full URL into the connections view and the log; it now reads `HTTP 404`, and any other error has the address taken out (#387).
+- **`app-settings.json` is written whole or not at all, and a broken file is reported.** The app's settings were written in place; a crash or a full disk mid-write left a truncated file that the next start read as a first start without a word, and the endpoint migration ran again. The file is now written beside the old one, with owner-only permissions before any content is in it, and renamed over it; a file that cannot be read is named in the log (#387).
+- **The endpoints list keeps a chat slot's missing key reference.** On the first start with the list, a chat slot whose key named a vault entry nothing answered became an endpoint that said *no key* while the slot said *missing*, and choosing the endpoint again cleared the slot's reference. The endpoint now carries the same reference, both say *missing*, and a key typed on the endpoint mends both (#387).
+- **`nanomuse config show` masks every kind of credential.** A value under an MCP server's `env` whose name ends in `_KEY`, an `Authorization` or `Cookie` header under `extra_headers`, and the password in a proxy URL were printed in full; they are shortened or taken out like the API keys (#387).
+
+### Web
+
+- **Adding an MCP server with its tools marked *high* no longer fails.** The risk chips under *Connections → MCP servers* offered *high*, a level the runtime does not have, so saving answered with a 400; the third chip is now *sensitive*, the level the Sentinel knows. The API key fields, the working hours and quiet hours times, the contacts search, the chat composer and the browser take-over fields are named for screen readers, the Settings selects are tied to their labels, a sheet's title names the dialog, the toast is announced, a pending sensitive approval keeps its red edge in the dark theme, sheets and drawers appear in place under *reduce motion*, and the places that showed a bare dash (the chat model summary without a model, a goal without a next step, a reminder without a time) say so in words. The docs' description of the chat now matches the screen (the ink pill, the chats drawer top left, the + button for attachments, the `conversation` grant scope). (#382)
+
+### Desktop
+
+- **A clip of your own with a fragmented layout is read right, and the connector sheet's Chinese title has its space.** A fragmented MP4 whose 64-bit movie header says "unknown" for the length was refused as too long instead of being read from its `mehd` box; it is now read like the 32-bit case. The sheet that connects a service reads 连接 Notion rather than 连接Notion in Chinese. The desktop shell's own tests now run in the desktop workflow on every platform and pass on a Windows checkout with CRLF line ends ([#383](https://github.com/nano-muse/nanoMuse/pull/383)).
+
+### Android
+
+- **A clip of your own stops copying at 30 MB.** The import copied the whole file into the avatar folder and only then checked its size, so a picker that gave no size up front could have a long video copied in full before it was refused; the copy now stops the moment the limit is passed, deletes the partial file and refuses with the usual sentence. The preview's refusal line reads *> 30 MB* when the true size is unknown (#362).
+- **Quoted words show their quotes again.** aapt reads a bare `"` inside a string as quoting and drops it, so the empty Permissions and Routines pages read *answer always allow* and *something every morning*, and the avatar studio's lines lost the quotes around the description, in English and in 简体中文; the English strings now escape them and the Chinese ones use “ ”. A unit test refuses a bare quote in any of the seventeen locale files from now on.
+- **The hub no longer takes the app down when the socket drops mid-task.** A task another device had running on the phone sent its progress and its answer through the hub socket; if the connection had dropped meanwhile the send threw on a worker thread, which Android treats as a crash. Such frames are now logged and lost (the relay fails the call to its caller on its own), and a client that was stopped for a sign-out or a key change no longer reports its own closing as the state of the new connection.
+
+### iOS
+
+- **A wrong password says so.** The sign-in and account pages name the relay's password refusals: a wrong address or password, too many wrong attempts in a row, a password that is too short, too long or too plain, the current password left out. Before, a wrong password read as a sign-in that had expired, the lock-out read as too many requests, and a password with too few different characters was told to use eight or more. In all nine languages.
+- **The nanoMuse Cloud row under Providers stops saying *0% of the allowance left*.** On a relay without a token ceiling it shows who is signed in; on one that prices in money it shows the yuan left, the same figure as the Settings card.
+- **The relay's key is written to this phone's keychain only, from the first moment.** Signing in wrote the key as an iCloud-synchronizable item first and replaced it with a device-only one after the model list arrived; the device-only write now follows at once.
+- **Reduce Motion is honoured.** With it on, the pill bar's indicator moves to the tapped tab without the spring, and the face keeps still: no breath, bob, tilt, pop or shake; the mood still changes with its crossfade, and a clip of your own still plays.
+- **Three translations put their numbers back in order.** The avatar studio's *n of m moods drawn* line in Chinese and Traditional Chinese, and the Korean *nearly used up* line above the composer, had the two figures swapped.
+- **The Devices footer tells the truth about approvals.** A step a computer must ask about is approved on that computer, not on the phone; the footer said approvals would show here.
+
+### Project
+
+- **The READMEs' visitor counter runs on hits.sh.** The badge from komarev.com stopped showing now and then: GitHub's image proxy answers 404 for it at times, as its own tracker reports, and the badge was blank until a reload. The counter is now `hits.sh/github.com/nano-muse/nanoMuse.svg`, which also takes a starting number, so the 16,719 views counted so far are carried over; the same badge in the ten READMEs, twice each (the badge row and the end), one URL so a visit counts once.
+- **The showcase page no longer takes the phone's address from the link.** MobileGym's page reads `?sim=` to point its frame at a separate dev server; on the public site that let a crafted link put any page, a `javascript:` one included, inside the phone on the site's own origin, where a visitor's sign-in ticket is kept. The composed page now loads `/phone.html` and nothing else.
+- **The showcase gateway lets DeepSeek Flash see screenshots.** A visitor's own key with `deepseek-flash` or `deepseek-v4-flash` (the names DeepSeek serves with V4.1 Flash, and the key page's default) was marked text-only, so the chat model in that session ran with vision off; the gateway now follows the runtime's rule (`nanomuse/llm/vision.py`). The DeepSeek V4 Pro, V4 and chat ids stay text-only.
+- **The showcase's containers roll their logs.** The gateway, Caddy and the Docker proxy keep five 20 MB files each (`docker compose logs`) instead of one file that grows for as long as the box runs.
+- **The showcase pages say what the showcase does.** Case 4 of `docs/showcase.md` pointed at a note "at the end of this page" that was never there; the line now says what the 2026-09-24 attempt found and where it is tracked. The *Never in public material* section says which 微信 it means (the real app; the simulator's re-creation is what the hosted demo's Chinese lines use, as `demo/showcase/README.md` already said). The Chinese *every device* page described the embedded phone as waiting for a tap, which the English page and the code had stopped doing. The Chinese showcase page's 轨迹 labels showed their asterisks on the site (a bold run that closes right after a Chinese colon is not bold under CommonMark); the colon now sits outside the run.
+- **The showcase workflow lints what it tests.** `showcase.yml` runs `ruff check` and `ruff format --check` on `demo/` before the gateway's tests, the lint the runtime already gets; and a `*.env` file anywhere in the checkout (the production box's `gateway.env` has that shape) is ignored by git like `.env`.
+- **The docs say what the update check does.** The privacy page and the configuration page said the runtime asked the GitHub API every six hours; every client asks the project's download index at `nanomuse.cn/dl/index.json` first and GitHub only when that does not answer, once a day, and the request's `User-Agent` names the app and its version. The same pass brought the design, brand and app pages up to the monochrome chat and the bundled dragon (the red panda and the plush dolls were still described as the current face), added `/forget-approvals` and `/exit` to the CLI page, put DeepSeek Harness and Electron in `THIRD_PARTY_NOTICES.md`, and made the pull request template ask for `For #N` as CONTRIBUTING.md does. Eleven more spots on the Chinese pages (desktop-muse, every-device, ios, parity) showed their asterisks on the site for the reason the showcase page did; they are rewritten, CONTRIBUTING.md states the rule, and a test now renders every page and fails on a literal marker.
+- **A web-only pull request is labelled `web`, not `web` and `runtime`.** The committed web build under `nanomuse/server/static/` matched the runtime's `nanomuse/**` glob in `.github/labeler.yml`, so every web pull request carried both labels; the runtime globs now leave that directory out, and a pull request touching `nanomuse/server/api.py` still gets `runtime`.
+- **The desktop's checks run on Windows on every pull request, and the Electron shell's tests run at all.** The *harness bundle* workflow builds, typechecks and tests the bundle and the shell (`harness/desktop`: typecheck and its node tests, which no workflow ran before) on Linux and on Windows; the v1.0.3 tag's Windows installer build had failed on a test that first met a CRLF checkout there, because the Windows build only ran on a tag. `.gitattributes` also keeps `harness/` at LF on every platform, and a change to the phones' Ideas lists now triggers the workflow that checks they match the desktop's.
+- **A pre-release tag no longer moves the Docker image's `latest`.** `ghcr.io/nano-muse/nanomuse:latest` and `:X.Y` follow a `vX.Y.Z` tag only; a `vX.Y.Z-rc1` tag publishes `:X.Y.Z-rc1` alone.
+- **The Docker build contexts are small and carry no caches.** The root `.dockerignore` leaves out the trees no image copies (`android/`, `harness/`, `cloud/`, `web/`, `website/`, `site/`) and matches the Python caches at every depth (a plain `__pycache__` matched at the root only, so a developer's `.pyc` files were copied into the image); a clean checkout's context went from 112 MB to 6 MB. The relay has a `cloud/.dockerignore` of its own that keeps `data/` and `.env` out of its context.
+- **The release scripts write the roadmap rows and check the iPhone's version.** `scripts/release-docs.py` takes `--roadmap "" --roadmap-zh "<一句话>"` and puts the version's row at the end of the past-releases tables of `docs/roadmap.md` and `docs/zh/roadmap.md`, two edits the 1.0.3 release still made by hand; `scripts/release-apk.sh` refuses to publish when the iOS project's `MARKETING_VERSION` is not the release's, beside the six files it already checked.
+
+## [1.0.3] - 2026-10-12 · Helm
+
+Helm: the day after 1.0.2, with the pull requests and reports that came in that day. @Ichiro786's redesign of the Android app landed in three pull requests ([#329](https://github.com/nano-muse/nanoMuse/pull/329), [#330](https://github.com/nano-muse/nanoMuse/pull/330), [#331](https://github.com/nano-muse/nanoMuse/pull/331)): a floating frosted-glass bottom bar, the drawer and the chat on the theme's own canvas with black and white pills, a scrim for a header; the iPhone, the web console and the desktop follow it, so the chat reads the same on every device. A clip of your own can be the face on Android, the iPhone and the desktop ([#324](https://github.com/nano-muse/nanoMuse/issues/324)), and the desktop and the web console keep several custom OpenAI-compatible endpoints at once instead of one ([#294](https://github.com/nano-muse/nanoMuse/issues/294)). @Mola-maker's ten reports on the Android app ([#332](https://github.com/nano-muse/nanoMuse/issues/332) to [#340](https://github.com/nano-muse/nanoMuse/issues/340), [#347](https://github.com/nano-muse/nanoMuse/issues/347)) are all fixed, from an own Anthropic key that could not send to a debug log that kept raw keys; the iPhone gets a voice prompt from the Action Button ([#298](https://github.com/nano-muse/nanoMuse/pull/298) by @cypggs) and OpenCode Go works on every client ([#325](https://github.com/nano-muse/nanoMuse/pull/325) by @PovedaAqui). The relay is 0.24.2: its image runs as an unprivileged user and its guidance lists Go apart; nothing changes on the wire.
+
+### Cloud
+
- **The "ways on" guidance lists OpenCode Go apart.** An entry whose vendor wants a session id per conversation (`session_header`) goes under a new `session_providers` key instead of `providers`, so the phone apps of 1.0.1 and before, which do not send the id, never offer it; the apps that do send it append the list (thanks @PovedaAqui).
+- **The relay's Docker image runs as an unprivileged user.** The container runs the relay as `relay`, uid 10001, instead of root, so a bind-mounted data directory must belong to that uid on the host (`sudo chown -R 10001:10001 data`, once; `scripts/self-host.sh` and the production `deploy.sh` do it themselves), and a relay that cannot write its data directory now stops at start with a sentence that says so and names the chown, not with sqlite's traceback. `cloud/README.md` and the self-hosting guide say what a bind-mounted directory must be owned by.
### Runtime
- **OpenCode Go is in the own-key catalogue, and the runtime tells it which conversation a call belongs to.** Go ($10 a month, Go Plus $40) is the subscription beside Zen on `https://opencode.ai/zen/go/v1`, a key from [opencode.ai/auth](https://opencode.ai/auth). OpenCode asks a client to name itself and to send one stable id per conversation in `x-opencode-session`. A catalogue entry can now name such a header (`session_header`); for one that does, the chat clients send `nanoMuse/` as the user agent (unless `extra_headers` sets one) and the header on every request, with the conversation's id while the agent loop works on a turn (the hands' model included) and an id of the client's own for a call outside a conversation. A slot that points an OpenAI-shaped provider at Go's address gets the same. Chat only, on the OpenAI shape, as with Zen (thanks @PovedaAqui).
+- **Several custom OpenAI-compatible endpoints at once.** The runtime keeps a list of them in `app-settings.json` (`endpoints`: id, name, address, the models it serves, and its key in the vault as `ENDPOINT__API_KEY`) in place of the single *Other OpenAI-compatible endpoint* slot, so adding a gateway no longer replaces the one before. `POST`, `PUT` and `DELETE /api/connections/endpoints` add, change and remove an entry, `POST /api/connections/endpoints/{id}/models` asks it for its models again, and `PUT /api/connections/llm`, `/gui`, `/image` and `/video` take `endpoint` instead of an address and a key; a rename, a new key or a new address reaches every slot that uses the endpoint, and removing one is refused while the chat model answers through it. A chat model set from the app on an unlisted host becomes endpoint number one on the next start, its key copied to the endpoint's own vault entry, nothing asked (reported by @Alasco123, #294).
+- **The web tools' tests no longer depend on the machine's resolver.** The fetch tests use hosts under the reserved `.example` domain, and the SSRF guard asks DNS about a host before the mocked transport sees the request; on a resolver that answers unknown names with `127.0.0.1` or `0.0.0.0` instead of "no such name", the guard refused `shop.example` and three tests failed. The test suite now answers every `.example` host itself with a fixed public address, and a new test checks that `localhost` and `.internal` names are still refused without asking DNS at all. The guard in the runtime is unchanged (reported by @Mola-maker, #339).
+- **The Docker image reports its health.** The runtime image asks its own `/api/health` every 30 seconds, the way the relay's image does, so `docker ps` says *healthy* or *unhealthy*; the compose file turns the check off for the `muse` and `daemon` services, which run no server.
+- **`POST /api/avatar/estimate` is gone.** No client called it: the cost comes with the session `/api/avatar/begin` returns and shows on the card. The estimate itself is unchanged.
### Web
+- **The phone layout's tab bar is a floating frosted-glass pill.** The five tabs sit in a rounded pill 12 px above the bottom edge, as wide as the composer above it (420 px at most, centred on a wider screen), icons only and no labels; the pill is translucent over a blur of what is behind it and has a hairline edge. The current tab shows its filled glyph on an ink indicator, black in the light theme and white in the dark one as on Android, the others stay outlined in grey. The screens and the chat's composer keep clear of the pill, and the toast rises above it; a wide window and the desktop app keep the sidebar (follows #329 by @Ichiro786).
+- **The chat is monochrome, as on the phone.** Your messages are black pills with white text in the light theme and white pills with black text in the dark one, the agent's replies sit in a pill a shade off the canvas, and the send button and the *Latest* button are the same ink disc instead of blue; a link inside your pill keeps the pill's text colour, underlined. The light canvas is plain white. The chat's header is a scrim over the conversation rather than a bar: messages scroll under the face and dissolve into the canvas, and the top edge fades fully while older messages are being read. The coding agents' chat uses the same pills (follows #331 by @Ichiro786).
+- **Every field on the Connections screen is named for a screen reader.** A field's label is now tied to its input or menu, so a screen reader says *Base URL* or *Chat model* on reaching the box instead of an unnamed field, and the hint under a field is read with it; a row of choices or a pair of times is a group named by its label. Nothing changes on screen.
+- **Several custom OpenAI-compatible endpoints on the Connections screen.** Under *Local or your own endpoint* each endpoint of the runtime's list is a tile reading its name over its host, and *Add an endpoint* is the last tile: a name, the address, a key or *No key*, and the model ids when the endpoint does not list them. A chosen endpoint shows a card with where its key is, how many models it serves and which slots use it, with *Rename*, *Change key*, *Edit models*, *Ask for its models* and *Remove*; saving the chat model names the endpoint by id, never its key. The *Hands model* picker gains one group per other endpoint, and the *Making pictures* and *Making clips* selects list them under *Your endpoints*, so a second gateway can serve the hands while the first answers the chat. The *Chat model* summary and the *Currently* lines read the endpoint's name (reported by @Alasco123, #294).
+
### Desktop
+- **The chat is monochrome, as on the phone.** In the Muse look your messages are black pills with white text in the light theme and white pills with black text in the dark one, the agent's replies sit in a grey pill a step off the canvas, and the send disc and the harness's scroll-to-latest disc are the same ink with the glyph in the opposite colour, instead of a bubble toned from the accent and a dark grey disc; a link inside your pill keeps the pill's text colour, underlined, and your messages shown from another device and in the coding agents' chat use the same pill. The chat's canvas is plain white in the light theme; the paper stays under the rail, the columns and the pages. The chat's header is a scrim over the conversation rather than a bar: messages scroll under the face and dissolve into the canvas, and no line runs under it (follows #331 by @Ichiro786).
- **OpenCode Go works from the desktop.** Saving a Go key writes the model adapter's own `opencode-go` route (no `api`, no base URL), which sends `x-opencode-session` with the harness's conversation id and reaches each model on its own shape, so the models that route's catalog lists on the Responses and Messages shapes come through as well. A hand-declared route, the way the other own keys are written, sends no session id (thanks @PovedaAqui).
+- **Several custom OpenAI-compatible endpoints at once.** *Any OpenAI-compatible endpoint* under *Settings → Account* is one row per saved endpoint, each with its own key (`NANOMUSE_KEY_CUSTOM`, `NANOMUSE_KEY_CUSTOM_2`, ...), its name over its host, and *Rename* beside *Change key* and *Remove*; the *Add an endpoint* row under them adds another without touching the ones there, where before a second endpoint replaced the first and its key. *Change key* on a row keeps its address, capabilities and models unless retyped. The Models page's pickers list each endpoint as a group of its own, so the chat can answer through one gateway while the hands see through another. `POST /providers/save` takes `row` to change a given endpoint and `POST /providers/rename` renames one; an endpoint saved before this is read back under its old row (reported by @Alasco123, #294).
+- **A freshly installed update is no longer offered to itself.** The daily update check keeps its result for a day, and the state file outlives an update, so right after installing 1.0.2 over 1.0.1 the sidebar still showed the dot and *Update to 1.0.2* from the check 1.0.1 had made, until the next day's check or *Check for updates*. The cached result is now read against the version that runs, and a result another version made is not answered from the cache (reported by @jiangkaiqi2005, #341).
+- **A clip of your own as the face.** The pen on the face in the agent's page and the avatar studio's first step offer *Your own clip…*: the file dialog, then a preview of the clip playing in the face's round frame with *Use* and *Cancel*. The clip plays muted in a loop for the mood at rest and for every other mood too; the drawn stills keep their place where a still is shown, and with no drawn face the clip's first frame (taken at the preview) is the still. MP4 or MOV with H.264 video, at most 15 seconds and 30 MB; the host reads the file's boxes itself and refuses a longer, larger or differently encoded clip with a sentence that states the limits. The clip stays on this computer under `$DSH_HOME/nanomuse/avatar/own-clip.mp4`, is never sent to nanoMuse Cloud, survives a new drawn face, and *Default face* removes it (requested by @Yicheng-1218, #324).
### Android
+- **The bottom bar is a floating frosted-glass pill.** The five tabs sit in a rounded pill a little above the bottom edge, as wide as the composer above it (420 dp at most, centred on a wider screen), icons only and no labels; the pill blurs what is behind it and has a hairline edge. The current tab shows a filled glyph on an ink indicator, black in light and white in dark, that springs across, the others stay outlined. The menus take the chat's own canvas, black in dark and white in light, with a hairline edge instead of a tinted surface (thanks @Ichiro786, #329).
+- **The chats drawer sits on the same canvas.** Black in dark and white in light instead of the grey sheet, the agent's name larger at the top, the side chats label in small capitals, the selected row and the main-chat row on a quiet translucent pill, and the settings, system files and new chat buttons as 44 dp discs beside the search field, all in the text colours of the theme rather than a tint (thanks @Ichiro786, #330).
+- **The chat sits on the same canvas too.** Your messages are white pills with black text in the dark theme and black pills with white text in the light theme, the assistant's replies sit in a pill a shade off the canvas, and the send button and the scroll-to-latest disc are white with a black arrow (inverted in light) instead of blue. The header is a soft scrim over the conversation, so messages dissolve under it instead of stopping at a bar; the conversation scrolls on behind the floating pill, which blurs it, while the composer, the steps bar, the approval card and the scroll buttons stay above the pill at its measured height, with the keyboard open as well; a fade above the composer appears while reading older messages. The Feed, Ideas, Goals and Library lists end above the pill (thanks @Ichiro786, #331).
- **OpenCode Go works on the phone.** Chat and Responses requests to OpenCode's host carry `x-opencode-session`, the chat's prompt cache key (a hash of its first message, the same on every turn), beside the app's own `nanoMuse/` user agent; the ways-on card lists Go from the relay's new `session_providers`, and a provider set up on Go's address is told apart from Zen by the whole address (thanks @PovedaAqui).
+- **An own Anthropic API key sends again.** The provider read the Claude Code OAuth identifier on every request, API key or not; the public build ships that value empty and the read throws, so each message on an own key stopped with "ANTHROPIC_OAUTH_IDENTIFIER_PROMPT is not configured" before anything left the phone. The identifier is now read on the OAuth path only; an API key, with or without a system prompt, sends as it did on OpenMinis (reported by @Mola-maker, #333).
+- **A carriage return in tool output overwrites instead of erasing.** The sandbox's sanitizer kept only the last text after a `\r`, so `AAAA\rBB` came out as `BB` and a progress line followed by a shorter final line handed the model a truncated result. A `\r` now moves the cursor to column 0 and shorter text keeps the old tail, the way a terminal shows it: `AAAA\rBB` is `BBAA`, `10%\r50%\r100%` is `100%`, `\r\n` stays a line break (reported by @Mola-maker, #334).
+- **Leaving the sign-in page or the network page ends its request.** Asking nanoMuse Cloud for a code, signing in, and the network page's proxy test used to keep their connection open until the read timeout when the page was left; now the request is cancelled with the page, and a request cancelled that way no longer lands in the page's error state (reported by @Mola-maker, #336).
+- **A new set of avatar candidates no longer shows the old set's failures.** Asking for four new pictures while the previous four were still drawing cancelled the old run, but the cancelled pictures wrote "failed" into the new tiles and offered a retry while the new set was on its way; a result now only reaches the tiles of the set it was asked for (reported by @Mola-maker, #337).
+- **Stop right after the first message no longer keeps the welcome as a reply.** In the first conversation, tapping Stop while the first send was still being set up (the chat being created, the message written) took the opening lines, which exist on screen only, for the interrupted reply and saved them under the chat as the model's turn; the opening lines are left alone now, Stop during that setup cancels the send itself and puts the text and attachments back in the composer, and a reply that cannot be saved is logged rather than crashing the app (reported by @Mola-maker, #340).
+- **The debug request log no longer keeps API keys.** The in-memory log that debug builds expose over the debug RPC now stores its own copy of the request headers with the credential ones masked (`Authorization`, `X-API-Key`, `Cookie` and the like, in any case, as `***`), so a key never sits in that buffer and a later change to the caller's map cannot rewrite a recorded request (#347).
+- **Icon-only controls have names for a screen reader.** The show/hide password eye on the sign-in, account and first-run screens says *Show password* or *Hide password*; the Appearance page's more menu says *More*, and its candidate tiles and the share sheet's cards are selectable items with a number and a selected state; the Goals tab's add buttons say *Add* and the completion box says *Mark as done* or *Mark as not done* with a done state; the feed's Like says *Like* or *Unlike* with a liked state and the info button says *Post details*. Nothing changes on screen; the strings are in all 17 languages (reported by @Mola-maker, #338).
+- **A clip of your own as the face.** The avatar page and the pen on the face in the profile offer *Your own clip…*: the system's video picker, then a preview of the clip playing in the face's round frame with *Use* and *Cancel*. The clip plays muted in a loop for the mood at rest and for every other mood too; the studio's stills keep their place where a still is shown, and with no studio face the clip's first frame is the still. MP4 or MOV with H.264 video, at most 15 seconds and 30 MB; a longer, larger or differently encoded clip is refused with a sentence that states the limits. The clip stays on the phone under `minis-global/nanomuse/avatar/own-clip.mp4`, is never sent to nanoMuse Cloud, survives a new studio face, and *Default face* removes it. The strings are in all 17 languages (requested by @Yicheng-1218, #324).
+- **The feed's and a goal's daily time is written in plain digits.** The feed tab's empty card and routine row and the goal card's *daily at* line formatted the time with the phone's locale, so a phone set to a language with its own digits (Arabic, Bengali) could show them; the time is `09:30` everywhere now, as the system-files page already had it.
### iOS
- **A voice prompt from the Action Button.** A *Voice Prompt* Shortcut opens the app and starts listening, so a prompt can be spoken instead of typed: bind it to the Action Button, Back Tap or a widget, press, speak, and the words appear in the composer; sending stays a tap on the send button. It is the composer's own voice panel, started for you, with the same microphone and speech permissions; with no session named it lands in the chat that is open, the main chat in the Muse layout. Written by @cypggs (#296, #298).
- **OpenCode Go works on the phone.** Chat and Responses requests to OpenCode's host carry `x-opencode-session`, the chat's prompt cache key (a hash of its first message, the same on every turn), beside the app's own `nanoMuse/` user agent; the ways-on card lists Go from the relay's new `session_providers` (thanks @PovedaAqui).
+- **The bottom bar is a floating frosted-glass pill.** The five tabs sit in a rounded pill 12 pt above the bottom edge, as wide as the composer above it (420 pt at most, centred on a wider screen), icons only; the pill blurs what is behind it under the shell's surface tone and has a hairline edge. The current tab shows a filled glyph on an ink indicator capsule, black in light and white in dark as on Android, that springs across to the tab tapped next, the others stay outlined. The chat's composer keeps its place above the bar, and the rooms' content scrolls under it (follows #329 by @Ichiro786).
+- **The chat is monochrome, as on Android.** Your messages are black pills with white text in the light theme and white pills with black text in the dark one, the agent's replies sit in a pill a shade off the canvas, and the send arrow, the stop square and the scroll-to-latest disc are the same ink with the glyph in the canvas colour instead of a glyph alone. The header over the main chat is a scrim rather than a blur: the conversation scrolls under the face and dissolves into the canvas, and a second fade comes in while you read older messages (follows #331 by @Ichiro786).
+- **A clip of your own as the face.** The pen on the face in the agent's page and the avatar studio offer *Your own clip…*: the photo library's video picker, then a preview of the clip playing in the face's round frame with *Use* and *Cancel*. The clip plays muted in a loop for the mood at rest and for every other mood too; the drawn stills keep their place where a still is shown, and with no drawn face the clip's first frame is the still. MP4 or MOV with H.264 or HEVC video, at most 15 seconds and 30 MB; a longer, larger or differently encoded clip is refused with a sentence that states the limits. The clip stays on the phone under `Application Support/nanomuse/avatar/own-clip.mp4`, is never sent to nanoMuse Cloud, survives a new drawn face and a reset to the dragon, and *Default face* removes it. The strings are in all nine languages (requested by @Yicheng-1218, #324).
### Project
+- **CI runs the upstream provider and sandbox tests too.** The Android job's unit-test step now covers `com.openminis.app.provider.*` and `com.openminis.app.sandbox.*` beside our own `io.github.nanomuse.*`, so an edit inside those OpenMinis files (the Anthropic key path, the carriage-return fold, the streaming fixtures) is caught before it ships; the 291 tests of the two packages pass on main.
+- **The Android test sources follow the code again, and CI keeps the on-device set compiling.** `ExecutionCoordinatorInstrumentedTest` still read a single `mountedSessionId` the coordinator dropped when it moved to one shell per session, so `compileDebugAndroidTestKotlin` failed with eight unresolved references and our own `ReachHubInstrumentedTest` could never run; its assertions now observe the per-session shells through one small test accessor, and the Android workflow compiles the instrumentation sources after the unit tests. `OpenAIProviderTest` fed `sendMessage` plain JSON bodies while the provider deliberately streams every request and folds the deltas, so 14 of its cases failed with the empty-response error; the fixtures are SSE streams now, the request assertions expect `stream: true` with `stream_options.include_usage`, and the empty-choices case expects the documented transient error (reported by @Mola-maker, #332, #335).
+- **Every pull request carries labels.** A *Labels* workflow adds the platforms from the files a pull request touches (`android`, `ios`, `desktop`, `web`, `runtime`, `cloud`) and its kind from the title (`enhancement`, `bug`, `documentation`, `ci`, `release`, `maintenance`); the pull requests merged so far were labelled the same way by hand.
+- **An issue stays open until a maintainer closes it.** CONTRIBUTING.md and AGENTS.md now say it the same way: a pull request names its issue as `For #N` and never `Fixes`, `Closes` or `Resolves`, which would close the issue on merge; a landed fix gets the `fixed on main` label and a reply, and the issue is closed when a maintainer decides, not by a merge. The relay's README documents `NANOMUSE_CLOUD_BASE`, the relay address the admin CLI uses when `--base` is not given.
+
## [1.0.2] - 2026-10-11 · Trim
Trim: a pass over every part of the project after the first two days of 1.0, with the reports and pull requests that came in. The Sentinel's taint rule now holds for `shell` and `send_email` in auto mode, so a tool on the always-ask list is never waved through after a private read ([#291](https://github.com/nano-muse/nanoMuse/pull/291) by @hobostay); the Android app starts beside OpenMinis, its sandbox socket carrying the application id, and the crash that remains names the socket that failed ([#271](https://github.com/nano-muse/nanoMuse/issues/271), [#288](https://github.com/nano-muse/nanoMuse/issues/288)); on the desktop the permission card comes up again when the runtime refuses a hands step ([#292](https://github.com/nano-muse/nanoMuse/pull/292) by @hobostay), reading a picture on Linux no longer kills the harness ([#274](https://github.com/nano-muse/nanoMuse/issues/274)), and on Windows the caption buttons no longer cover page buttons ([#273](https://github.com/nano-muse/nanoMuse/pull/273) by @jiangkaiqi2005); the memory tidy-up judges every script and refuses what it cannot judge ([#270](https://github.com/nano-muse/nanoMuse/issues/270)); a second `nanomuse serve` on a busy port says so and stops; `nanomuse doctor` names a config key nothing reads and ends on its summary, every CLI option has a help text, and an MCP server's key stays out of the log; a refused request in the web console and on the desktop's Media, Studio, Coding and Tasks pages says a sentence in your language; the iPhone's own pages read in all nine languages; OpenCode Zen joins the own-key catalogue (@PovedaAqui); the docs read without em dashes, the showcase's lines to try run end to end in both languages, and the READMEs say where nanoMuse is going. The relay is 0.24.1, carrying the catalogue; nothing changes on the wire.
diff --git a/CITATION.cff b/CITATION.cff
index 2aaee5f7..be63f42d 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -7,8 +7,8 @@ authors:
repository-code: "https://github.com/zeeshanhaque21/nanoMuse"
url: "https://github.com/zeeshanhaque21/nanoMuse"
license: GPL-3.0-or-later
-version: 1.0.2
-date-released: 2026-10-11
+version: 1.0.3
+date-released: 2026-10-12
keywords:
- personal agent
- AI assistant
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5d588c65..428f8e1e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,17 +1,17 @@
# Contributing
-Use nanoMuse for a real task, report what broke, then pick something focused. Issues and pull requests are welcome; for anything larger than a fix, open an issue first so we can agree on the shape. [docs/roadmap.md](docs/roadmap.md) says where help is wanted and where to start in each area. For a quick question or to talk through an idea before writing it up, there is a [Discord](https://discord.gg/bkTySmm28X); decisions still land in issues and pull requests, where they can be found later.
+Open an issue or send a pull request; either one helps nanoMuse get better. Use nanoMuse for a real task, report what broke, then pick something focused. For anything larger than a fix, open an issue first so we can agree on the shape. [docs/roadmap.md](docs/roadmap.md) says where help is wanted and where to start in each area. For a quick question or to talk through an idea before writing it up, there is a [Discord](https://discord.gg/bkTySmm28X); decisions still land in issues and pull requests, where they can be found later.
[AGENTS.md](AGENTS.md) is the short version of this file for coding agents and for people in a hurry: the layout, the check commands per area, and the conventions below in one screen. The two must agree; when they do not, fix both.
## Two trees
-- **`android/`** — the phone apps. A modified copy of [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13 imported with `git subtree` (GPL-3.0): the Android app under `android/src/android`, the iOS app under `android/src/ios`. Upstream's rules apply here (below), so that the subtree stays pullable.
-- **Everything else is ours** — the Python runtime (`nanomuse/`), the relay (`cloud/`), the web console (`web/`), the desktop app (`harness/dsh-nanomuse/` and the Electron shell in `harness/desktop/`), the showcase (`demo/`), the docs (`docs/`, served by `website/`) and the scripts. One set of conventions, the ones in this file; [AGENTS.md](AGENTS.md) has the check command for each area and [docs/architecture.md](docs/architecture.md) explains how the pieces fit. [docs/roadmap.md](docs/roadmap.md) says which version brought what and where help is wanted.
+- **`android/`**: the phone apps. A modified copy of [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13 imported with `git subtree` (GPL-3.0): the Android app under `android/src/android`, the iOS app under `android/src/ios`. Upstream's rules apply here (below), so that the subtree stays pullable.
+- **Everything else is ours**: the Python runtime (`nanomuse/`), the relay (`cloud/`), the web console (`web/`), the desktop app (`harness/dsh-nanomuse/` and the Electron shell in `harness/desktop/`), the showcase (`demo/`), the docs (`docs/`, served by `website/`) and the scripts. One set of conventions, the ones in this file; [AGENTS.md](AGENTS.md) has the check command for each area and [docs/architecture.md](docs/architecture.md) explains how the pieces fit. [docs/roadmap.md](docs/roadmap.md) says which version brought what and where help is wanted.
## Licence and sign-off
-nanoMuse is **GPL-3.0-or-later** ([LICENSE](LICENSE), [NOTICE](NOTICE)). By contributing you agree that your contribution is licensed the same way. Every commit must carry a [Developer Certificate of Origin](https://developercertificate.org) sign-off — `git commit -s` adds the line:
+nanoMuse is **GPL-3.0-or-later** ([LICENSE](LICENSE), [NOTICE](NOTICE)). By contributing you agree that your contribution is licensed the same way. Every commit must carry a [Developer Certificate of Origin](https://developercertificate.org) sign-off; `git commit -s` adds the line:
```
Signed-off-by: Your Name
@@ -20,8 +20,9 @@ Signed-off-by: Your Name
CI checks it. Not accepted, ever:
- code under a licence that cannot be combined with GPL-3.0 (GPL-2.0-*only*, SSPL, BUSL, "source-available", proprietary SDKs);
-- anything obtained by decompiling, unpacking or scraping the Meta Muse app, or the OpenMinis binaries beyond what their source already shows;
-- the OpenMinis or Meta Muse names and logos as part of nanoMuse's own identity (attribution in the About screen and NOTICE is required and stays).
+- anything obtained by decompiling, unpacking, intercepting or scraping the Meta Muse app (its system prompt included), or the OpenMinis binaries beyond what their source already shows;
+- the OpenMinis or Meta Muse names and logos as part of nanoMuse's own identity (attribution in the About screen and NOTICE is required and stays), and Meta's brand screenshots;
+- internal planning: budgets, capacity estimates, promotion drafts. The repository holds what runs and what explains it.
## Working in `android/`
@@ -29,12 +30,12 @@ Upstream is a mirror of a private tree, squashed roughly monthly, and does not t
1. **Do not rename the Kotlin package** `com.openminis.app` or the Gradle `namespace`. Only the `applicationId` (`io.github.nanomuse.app`) is ours.
2. **Do not rename sandbox paths or CLI names** inside the root file system (`/var/minis`, `minis-global`, `minis-open`, `minis-mcp-cli`, `android-*`). They are upstream's contract with itself.
-3. **New code goes in new files** — package `io.github.nanomuse.*` or a new file next to the upstream one. When an upstream file must change, add a `// nanoMuse:` comment at the spot, and make one change per spot.
-4. **Rebranding is a script, not hand edits.** `scripts/rebrand.py` (names, ids, colours, links — Android and iOS), `scripts/gen-android-icons.py` and `scripts/gen-ios-icons.py` (icons) are idempotent; run them after every upstream pull. Do not fix a rebranding miss by hand — fix the script.
+3. **New code goes in new files**: package `io.github.nanomuse.*` or a new file next to the upstream one. When an upstream file must change, add a `// nanoMuse:` comment at the spot, and make one change per spot.
+4. **Rebranding is a script, not hand edits.** `scripts/rebrand.py` (names, ids, colours, links; Android and iOS), `scripts/gen-android-icons.py` and `scripts/gen-ios-icons.py` (icons) are idempotent; run them after every upstream pull. Do not fix a rebranding miss by hand; fix the script.
5. **Binary resources** (icon PNGs) are overwritten under the upstream name; on a pull conflict take ours (`git checkout --ours`).
6. **iOS follows the same rules.** The iOS half of upstream lives at `android/src/ios` (see [docs/ios.md](docs/ios.md)); our Swift goes in `android/src/ios/NanoMuse/`, an edit inside an upstream Swift file carries a `// nanoMuse:` comment.
-Build steps are in [android/BUILDING.md](android/BUILDING.md) (upstream) and, for the toolchain this repository is built with, in `scripts/android/` — JDK 21, SDK CMake 3.22.1, NDK r27c, Go 1.25+, `gomobile`; `deps/build_proot.sh` builds proot from the `android/deps/proot` submodule (our fork; portable `awk`, no gawk needed) and must run before `scripts/prepare_android_sandbox.sh`.
+Build steps are in [android/BUILDING.md](android/BUILDING.md) (upstream) and, for the toolchain this repository is built with, in `scripts/android/`: JDK 21, SDK CMake 3.22.1, NDK r27c, Go 1.25+, `gomobile`; `android/deps/build_proot.sh` builds proot from the `android/deps/proot` submodule (our fork; portable `awk`, no gawk needed) and must run before `android/scripts/prepare_android_sandbox.sh`.
### Pulling an upstream release
@@ -63,18 +64,18 @@ ruff check nanomuse tests scripts && ruff format nanomuse tests scripts
mypy # types; config in pyproject.toml
python -m pytest -q # MockLLM only, no network
cd web && npm run check && npm run build # if you touched web/; commit the build
-cd cloud && pip install -e ".[dev]" && ruff check . && pytest # if you touched the relay
+cd cloud && pip install -e ".[dev]" && ruff check . && ruff format --check . && pytest # if you touched the relay
```
The desktop app, the Electron shell and the docs site have their own lines in [AGENTS.md](AGENTS.md). Guidelines that apply everywhere: everything that acts goes through the Sentinel with an honest `risk`; secrets never reach the model (`{{vault:NAME}}`); test with `MockLLM`; no internal endpoints or keys in the repo; Ruff, line length 100, type hints; docs are part of the change.
## Every string in every language
-nanoMuse is used in more than one country, so a user-visible string is never added in one language. Write it in English first, then in 简体中文, then in every other locale the file already has — `res/values-*/nm_strings.xml` on Android (keys start with `nm_`; upstream's `strings.xml` is not ours to edit), `Localizable.xcstrings` on iOS, `en` and `zh` in `harness/dsh-nanomuse/src/client/locales.ts` on the desktop, the console and e-mail templates in `cloud/`. Dates, numbers and currency use the person's locale. Do not assume a +86 phone number, a Chinese app or Beijing time: SMS codes reach mainland-China numbers only, so a sign-in screen says that and offers e-mail. The voice is the same in every language — plain, specific, no marketing words, no exclamation marks — and Meta's own UI text is never copied verbatim.
+nanoMuse is used in more than one country, so a user-visible string is never added in one language. Write it in English first, then in 简体中文, then in every other locale the file already has: `res/values-*/nm_strings.xml` on Android (keys start with `nm_`; upstream's `strings.xml` is not ours to edit), `Localizable.xcstrings` on iOS, `en` and `zh` in `harness/dsh-nanomuse/src/client/locales.ts` on the desktop, the console and e-mail templates in `cloud/`. Dates, numbers and currency use the person's locale. Do not assume a +86 phone number, a Chinese app or Beijing time: SMS codes reach mainland-China numbers only, so a sign-in screen says that and offers e-mail. The voice is the same in every language (plain, specific, no marketing words, no exclamation marks), and Meta's own UI text is never copied verbatim. New text, in the apps and in the docs, has no em or en dashes: a comma, a colon, a pair of parentheses or a full stop does the work; a dash stays only where it is data (a number range in a table, a code sample, a quoted log line). Chinese copy is written as Chinese, not rendered word for word from the English, and does without 颠覆, 王炸, 保姆级, 一键 and 赋能; its terms are the glossary's below.
## Translating the docs
-The docs site (built by `website/` from `docs/`, published by the Website workflow) is in English and 简体中文. The Chinese pages live in `docs/zh/` with the **same file names** as the English ones — `docs/zh/android.md` is the twin of `docs/android.md` — and `website/.vitepress/config.mts` holds the Chinese nav and sidebar. A change to an English page updates its Chinese twin in the same pull request; a new English page gets a Chinese page and a line in both sidebars. `cd website && npm run docs:build` is the dead-link check for both languages.
+The docs site (built by `website/` from `docs/`, published by the Website workflow) is in English and 简体中文. The Chinese pages live in `docs/zh/` with the **same file names** as the English ones (`docs/zh/android.md` is the twin of `docs/android.md`), and `website/.vitepress/config.mts` holds the Chinese nav and sidebar. A change to an English page updates its Chinese twin in the same pull request; a new English page gets a Chinese page and a line in both sidebars. `cd website && npm run docs:build` is the dead-link check for both languages.
Rules for a Chinese page:
@@ -83,6 +84,7 @@ Rules for a Chinese page:
- Paths: pages link to pages by bare name (`android.md`); images and other files under `docs/` are one level up (`../screenshots/feed.png`); the repository is two levels up (`../../nanomuse/config.py`).
- Voice as everywhere else: plain, specific, human; Chinese idiom, never a word-for-word rendering of the English sentence; no exclamation marks, no marketing words. Second person 你, the agent is 它; menu paths as 设置 → 账号 → 设备, button and switch names in 「」.
- Things are called what the apps' Chinese screens call them. The glossary below is derived from `res/values-zh/nm_strings.xml`, the `zh` block of `locales.ts`, `Localizable.xcstrings` (zh-Hans), `web/src/i18n/zh-CN.ts` and the relay console; when a screen and the glossary disagree, the screen wins and the glossary is corrected.
+- Punctuation stays outside a bold or italic run: `**轨迹**:` and `*提议*:`, not `**轨迹:**` or `*提议:*`. Under CommonMark a run that closes right after a colon or a bracket and is followed by a character is not closed, and the asterisks show on the site; `tests/test_docs_markup.py` renders every page and fails on one.
| English | 简体中文 | English | 简体中文 |
|---|---|---|---|
@@ -118,22 +120,42 @@ Rules for a Chinese page:
| the showcase | 展示站 | self-hosting | 自己部署 |
| quick chat / dictation | 快速唤起 / 听写 | roadmap / changelog / release notes | 路线图 / 更新日志 / 发布说明 |
+Two that go wrong most often: 非营利, never 非盈利; and `nanoMuse Cloud`, the product's name, which is never translated (not 云, not 云端).
+
+## Facts come from the repository
+
+Every feature, number and count in a README, a docs page or a release note is something a reader could check in this repository: a provider count is the length of `nanomuse/llm/providers.json`, a locale count is the `values-*` directories, a connector count is the catalogue. A feature that ships is described as what it does, never as a demo, a preview or something coming soon; a feature that does not ship yet is on the [roadmap](docs/roadmap.md), not in the install table. Copy about the avatar says what it does and does not state how many pictures or poses it has, since that number is the studio's and changes.
+
+## The ten READMEs
+
+`README.md` and the nine translations under `docs/readme/` are one document in ten languages: the same sections in the same order, the same install table, the same badges, the same *News* lines, changed in the same commit (`scripts/readme-contributors.py --check` and `tests/test_readme_contributors.py` hold parts of that). The badge row under the title is two lines, the longer one first. A *News* line is one short sentence, the date in inline code at the start, at most one emoji, the newest line first; only a milestone release gets one, which a major version (`x.0.0`) always is and a smaller one is not, and the earlier lines stay as written, date, version and words.
+
## Commits and pull requests
-- The subject line follows [Conventional Commits](https://www.conventionalcommits.org): `type(scope): what changed`, in the imperative, no full stop — `feat(android): hands capsule shows the model's thought`, `fix(relay): keep the face a device drew when another renames`, `docs(readme): move the translations to docs/readme/`, `test(gateway): sighted model for the operator lane`. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`; the scope is the part of the tree (`runtime`, `android`, `web`, `desktop`, `relay`, `showcase`, `mobilegym`, `harness`, `docs`, `deps`…). The body says why, in plain words. The *Commits* check on a pull request fails on a subject that does not fit; merge commits are exempt.
-- One pull request, one topic. Screenshots for anything visible in the app.
+- The subject line follows [Conventional Commits](https://www.conventionalcommits.org): `type(scope): what changed`, in the imperative, no full stop: `feat(android): hands capsule shows the model's thought`, `fix(relay): keep the face a device drew when another renames`, `docs(readme): move the translations to docs/readme/`, `test(gateway): sighted model for the operator lane`. Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`; the scope is the part of the tree (`runtime`, `android`, `web`, `desktop`, `relay`, `showcase`, `mobilegym`, `harness`, `docs`, `deps`…). The body says why, in plain words. The *Commits* check on a pull request fails on a subject that does not fit; merge commits are exempt.
+- One pull request, one topic. Screenshots for anything visible in the app, with order numbers, names, addresses, hostnames and keys masked; the same for every screenshot the project publishes.
- Say which device and Android version you tested on. Phone-side features are tested on real hardware; the emulator is x86_64 and cannot run the arm64 APK.
-- `main` is protected. Code lands through a pull request, and the merge waits for four checks: the Python runtime on Ubuntu / Python 3.12, the web app build, the Android debug APK, and the DCO sign-off. Each workflow first works out which tree the change touched and skips the jobs that do not apply, so a docs-only pull request is not held up by a build it never needed. The rest of the matrix (other Python versions, macOS, Windows) runs and shows up, but does not gate the merge; the Python runtime's Windows job is marked experimental (its shell tool assumes a POSIX shell) and cannot fail the build — the Windows desktop app is built and released by its own workflow and is supported. GitHub runs no `pull_request` workflow at all while the branch conflicts with `main` (`gh pr checks` says "no checks reported", and it stays that way), so a pull request that shows no checks needs a rebase on `main` first, not a longer wait.
+- `main` is protected. Code lands through a pull request, and the merge waits for four checks: the Python runtime on Ubuntu / Python 3.12, the web app build, the Android debug APK, and the DCO sign-off. Each workflow first works out which tree the change touched and skips the jobs that do not apply, so a docs-only pull request is not held up by a build it never needed. The rest of the matrix (other Python versions, macOS, Windows) runs and shows up, but does not gate the merge; the Python runtime's Windows job is marked experimental (its shell tool assumes a POSIX shell) and cannot fail the build; the Windows desktop app is built and released by its own workflow and is supported. GitHub runs no `pull_request` workflow at all while the branch conflicts with `main` (`gh pr checks` says "no checks reported", and it stays that way), so a pull request that shows no checks needs a rebase on `main` first, not a longer wait.
- Merges are **rebase** or **merge commit**, never squash: the `Signed-off-by` on each commit is the record, and squashing would drop it. A merge commit is also what `git subtree pull` needs for the `android/` tree.
-- Maintainers work the same way: one branch and one pull request per version (`0.1.39`, `0.1.40`, …), merged when green, tagged and released from `main` (the recipe is below). Documentation and copy — READMEs, `docs/`, release notes — may go straight to `main`.
+- Maintainers work the same way: one branch and one pull request per version (`0.1.39`, `0.1.40`, …), merged when green, tagged and released from `main` (the recipe is below). Documentation and copy (READMEs, `docs/`, release notes) may go straight to `main`.
- Dependabot opens its pull requests once a month, grouped; they are merged when the checks are green, and a bump that changes the committed web bundle gets its rebuild right after.
+## Issues and pull requests (maintainers)
+
+- Every issue gets labels: its kind (`bug`, `enhancement`, `question`, `documentation`), its platform (`android`, `ios`, `desktop`, `web`, `runtime`, `cloud`), and `fixed on main` once the fix is merged or `duplicate` with a link to the first report.
+- Every pull request gets labels too, and the *Labels* workflow adds them: the platforms from the files it touches (`.github/labeler.yml`) and its kind from the title (`feat` is `enhancement`, `fix` is `bug`, `docs` is `documentation`, `ci` is `ci`, `build(release)` is `release`, the other types are `maintenance`). The workflow only adds; a maintainer adjusts by hand when the title or the paths do not tell the whole story.
+- A fix references its issue as `For #N` in the pull request and the commit body, never `Fixes #N`, `Closes #N` or `Resolves #N`: those close the issue when the pull request merges, and no issue in this project is closed automatically. When the fix lands, the issue gets the `fixed on main` label and a reply saying so; it stays open until a maintainer decides to close it, usually once the fix has shipped in a release, with the version named.
+- Replies on issues and pull requests are in English, one to three plain sentences, no lists, no sign-offs; say what was found and what happens next.
+- A contributor's commit is never amended, rebased with edits or rewritten by a maintainer: it carries their `Signed-off-by`. What needs adjusting goes in a separate maintainer commit on the same branch (or a follow-up pull request), and the branch is merged with a merge commit. When a pull request cannot be taken, the reason is written on the issue it was for, so the conclusion can be found later.
+
## Releasing (maintainers)
-A version is a plain number — `0.1.39`, `0.1.40`, … — because the apps' update check compares them; each release also carries a one-word English codename (Foundation, Identity, Home, … Keys, Clear; `CHANGELOG.md` has the list) and its title is `nanoMuse · `. The apps, the runtime, the web console and the desktop app share one version; the relay has its own (`cloud/pyproject.toml`) and moves only when it changed. Versions move in the release commit and nowhere else.
+A version is a plain number (`0.1.41`, `1.0.0`, …) because the apps' update check compares them; each release also carries a codename, one capitalised English word that no earlier release used (Foundation, Identity, Home, … Trim, Helm; `CHANGELOG.md` has the list), and its title is `nanoMuse · `. The apps, the runtime, the web console and the desktop app share one version; the relay has its own (`cloud/pyproject.toml`) and moves only when it changed. Versions move in the release commit and nowhere else.
+
+A release goes out as soon as a fix people are waiting for is on `main` (a crash, a security issue, an app that will not install), and otherwise every few days, when the *Unreleased* block has something worth a version. What ships: the APK, the TestFlight build, the desktop installers for Windows, macOS and Linux, the harness tarball and the Docker image; no terminal builds and no pre-releases, every release is *Latest*. Before tagging, a maintainer installs the build that will ship on a phone and taps through the main flows.
-1. **The release commit** `build(release): `, on its own branch and pull request. `scripts/release-bump.sh [ ]` moves every version file (`pyproject.toml`, `nanomuse/__init__.py`, `CITATION.cff`, `web/package.json`, the two `harness/*/package.json` and their lock files, the relay console's `VERSION`, the `VERSION_NAME`/`VERSION_CODE` in `scripts/rebrand.py`); then `python scripts/rebrand.py` writes the Android `versionName`/`versionCode` and the iOS `MARKETING_VERSION` (a second run must print `clean`). `scripts/release-docs.py ` opens the version's block in `CHANGELOG.md` under *Unreleased* and moves the download links of `README.md`, `docs/readme/*` and `docs/index.md` to the new tag. By hand: the lead paragraph of the `CHANGELOG.md` block and the empty `### Cloud / Runtime / Web / Desktop / Android / iOS / Project` headings for the next *Unreleased*; `docs/releases/v.md` from [docs/release-notes-template.md](docs/release-notes-template.md) — a short story, *Highlights*, *Upgrade Notes*, *Community*, English first and the same in Chinese in a collapsed block, every link checked; the *News* list of the ten READMEs (one short line per milestone, the date in inline code: milestones such as the paper and the first release stay, only the line that names the latest version is replaced by the new one); the past-releases row in `docs/roadmap.md`; any "next release" cell in `docs/parity.md`. Run the checks for what changed ([AGENTS.md](AGENTS.md); `scripts/rebrand.py` → `clean`, `connectors-json.mjs --check`, `providers-json.mjs --check`, `cd website && npm run docs:build`), and the *iOS · build check* workflow on the branch. Merge when green; `M` is the merge commit.
-2. **The APK and the GitHub release.** `scripts/release-apk.sh ` at `M` builds the release APK, verifies the signature and writes the sha256; `scripts/release-apk.sh --publish-only --target M` tags `v` on `M` and creates the release (marked Latest; `--prerelease` for one that is not) with `docs/releases/v.md` as the body. *What's Changed*, *New Contributors*, *Contributors* and the *Full Changelog* link are filled in from the pull requests merged since the previous tag by `scripts/release_notes.py` (`scripts/release_notes.py ` shows the body it would publish). The tag starts the desktop, harness, Docker and test workflows; the desktop installers, their `SHA256SUMS-desktop.txt`, the harness tarball and its `SHA256SUMS-harness.txt` are attached to the release by CI as they finish, the Docker image lands on `ghcr.io/nano-muse/nanomuse` as ``, `` and `latest`. Every workflow must end green before the release is announced. The signing key is one key for every version so an update installs over the previous one; it is not in the repository and not in CI.
+1. **The release commit** `build(release): `, on its own branch and pull request. `scripts/release-bump.sh [ ]` moves every version file (`pyproject.toml`, `nanomuse/__init__.py`, `CITATION.cff`, `web/package.json`, the two `harness/*/package.json` and their lock files, the relay console's `VERSION`, the `VERSION_NAME`/`VERSION_CODE` in `scripts/rebrand.py`); then `python scripts/rebrand.py` writes the Android `versionName`/`versionCode` and the iOS `MARKETING_VERSION` (a second run must print `clean`). `scripts/release-docs.py ` opens the version's block in `CHANGELOG.md` under *Unreleased* and moves the download links of `README.md`, `docs/readme/*`, `docs/index.md` and `docs/zh/index.md` to the new tag, the example versions in the issue forms (`.github/ISSUE_TEMPLATE/*.yml`) to the new version and `versionCode`, adds the codename to the list in the release-notes template and, with `--roadmap "" --roadmap-zh "<一句话>"`, puts the version's row at the end of the past-releases tables of `docs/roadmap.md` and `docs/zh/roadmap.md`; the *News* lists it leaves as written, since their lines are milestones and keep their date, version and words for good (`--dry-run` shows the first three lines of every list without writing, and the script says whether this release gets a News line). By hand: the lead paragraph of the `CHANGELOG.md` block and the empty `### Cloud / Runtime / Web / Desktop / Android / iOS / Project` headings for the next *Unreleased*; `docs/releases/v.md` from [docs/release-notes-template.md](docs/release-notes-template.md) (a short story, *Highlights*, *Upgrade Notes*, *Community*, English first and the same in Chinese in a collapsed block, every link checked); a new milestone line for the *News* lists when the release is one, which a major version (`x.0.0`) always is and a smaller one is not (one sentence of about a line, the date in inline code, one emoji, first in the list; the same line in the ten READMEs and on the homepage; the earlier versions' lines stay); the past-releases rows in `docs/roadmap.md` and `docs/zh/roadmap.md` when the script was run without `--roadmap`; any "next release" cell in `docs/parity.md`; `scripts/readme-contributors.py`, so the *Community Contributors* avatars in the ten READMEs are current (`--check` says whether they are). Run the checks for what changed ([AGENTS.md](AGENTS.md); `scripts/rebrand.py` → `clean`, `connectors-json.mjs --check`, `providers-json.mjs --check`, `cd website && npm run docs:build`); the *iOS · build check* workflow runs on the pull request when `android/src/ios/` changed and can be started by hand from the Actions tab otherwise. Merge when green; `M` is the merge commit.
+2. **The APK and the GitHub release.** `scripts/release-apk.sh ` at `M` builds the release APK, verifies the signature and writes the sha256; `scripts/release-apk.sh --publish-only --target M` tags `v` on `M` and creates the release (marked Latest; the script also has `--prerelease`, which the project does not use) with `docs/releases/v.md` as the body. *What's Changed*, *New Contributors*, *Contributors* and the *Full Changelog* link are filled in from the pull requests merged since the previous tag by `scripts/release_notes.py` (`scripts/release_notes.py ` shows the body it would publish). The tag starts the desktop, harness, Docker and test workflows; the desktop installers, their `SHA256SUMS-desktop.txt`, the harness tarball and its `SHA256SUMS-harness.txt` are attached to the release by CI as they finish, the Docker image lands on `ghcr.io/nano-muse/nanomuse` as ``, `` and `latest`. Every workflow must end green before the release is announced. The signing key is one key for every version so an update installs over the previous one; it is not in the repository and not in CI.
3. **The relay**, when its version moved: `cloud/deploy/nanomuse-hk/deploy.sh` from the checkout at `M`; `/healthz` must answer with the new version.
4. **TestFlight.** The *iOS · TestFlight* workflow on `main` (or a tag `ios-*`) uploads the build; its number is the run number. Once App Store Connect shows it processed, the external group gets it after Apple's beta review; `docs/releases/v.md` names the build number. [docs/ios.md](docs/ios.md) has the details.
5. **The docs site** (published by the Website workflow, `.github/workflows/pages.yml`, on a push to `main`): `cd website && npm run docs:build` is the dead-link check. The version line and the download links live in `README.md` and `docs/index.md`; the Chinese twins in `docs/zh/` change with them.
diff --git a/Dockerfile b/Dockerfile
index 0fb27383..e2323742 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -52,6 +52,10 @@ USER muse
VOLUME ["/data", "/workspace"]
EXPOSE 8787
+# For the default `serve` command; a container running `chat` or `run` has no server and
+# shows unhealthy, which `--no-healthcheck` on `docker run` turns off.
+HEALTHCHECK --interval=30s --timeout=5s CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8787/api/health', timeout=3).status == 200 else 1)"
+
ENTRYPOINT ["nanomuse"]
# The phone app. Override with `chat`, `run "..."`, `daemon`, ...
CMD ["serve", "--host", "0.0.0.0", "--no-qr"]
diff --git a/README.md b/README.md
index 4ae7f4fd..ce3b0cf8 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@
**nanoMuse is an open-source personal agent for every device you own.** One agent with a name and a look of its own, in the style of Meta's [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/): it does things instead of answering questions, keeps working while the app is closed, remembers you, and stops to ask before anything you could not undo.
-*nano* means the whole set, small enough to run and deploy yourself: the phone app, the desktop app, the web console and the relay that joins them are all in this repository, under GPL-3.0-or-later. **[Free, open source, non-profit. Let's build it together.](CONTRIBUTING.md)** Sign in and you get a free allowance of model use on the community relay (the developer pays for it); when it is gone, [use your own key](docs/own-key.md). The same relay runs on a server of yours, so nothing has to leave your house. Latest: **1.0.2 Trim**, [release notes](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano* means the whole set, small enough to run and deploy yourself: the phone app, the desktop app, the web console and the relay that joins them are all in this repository, under GPL-3.0-or-later. **[Free, open source, non-profit. Let's build it together.](CONTRIBUTING.md)** Sign in and you get a free allowance of model use on the community relay (the developer pays for it); when it is gone, [use your own key](docs/own-key.md). The same relay runs on a server of yours, so nothing has to leave your house. Latest: **1.0.3 Helm**, [release notes](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse keeps evolving**
@@ -55,11 +55,11 @@ Every version: [releases](https://github.com/zeeshanhaque21/nanoMuse/releases).
| | |
|---|---|
-| **Android** 8.0+, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk): every version is signed with the same key and installs over the last |
+| **Android** 8.0+, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk): every version is signed with the same key and installs over the last |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): a beta that passed Apple's beta review; the link installs it · [iOS](docs/ios.md) |
-| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). Not notarised: right-click → *Open* the first time |
-| **Windows** 10+ | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe): click *Run anyway* once |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). Not notarised: right-click → *Open* the first time |
+| **Windows** 10+ | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe): click *Run anyway* once |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` for your own relay; `docker compose up -d app` for the web app on a server of yours; see [self-hosting](docs/self-hosting.md) |
All downloads come from the [latest release](https://github.com/zeeshanhaque21/nanoMuse/releases/latest). Open the app, sign in with an e-mail or a mainland-China phone number, and it has a model to think with. The phone, the desktop and the web share one account and show the same conversations.
@@ -132,9 +132,11 @@ Everyone whose change has landed on `main`, from GitHub's contributors list.
+
-
+
+
@@ -173,6 +175,6 @@ nanoMuse is an independent community project, not affiliated with or endorsed by
[GPL-3.0-or-later](LICENSE). The phone app is based on OpenMinis 1.13 (GPL-3.0), modified since 2026-09-24; see [NOTICE](NOTICE). Earlier versions of the Python line were MIT (tag `pre-openminis`).
- Thanks for visiting ✨ nanoMuse!
+ Thanks for visiting ✨ nanoMuse.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 4828617b..ae7dd547 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -29,6 +29,7 @@ The app is a modified copy of [OpenMinis](https://github.com/OpenMinis/OpenMinis
| Coil (coil-compose) | 2.7.0 | **Apache-2.0** |
| multiplatform-markdown-renderer (+ m3), mikepenz | 0.33.0 | **Apache-2.0** |
| Reorderable (sh.calvin.reorderable) | 2.4.0 | **Apache-2.0** |
+| Haze (dev.chrisbanes.haze:haze-android), the blur behind the bottom bar | 1.6.10 | **Apache-2.0** |
| ACRA (acra-core) | 5.12.0 | **Apache-2.0** |
| Shizuku API + provider (dev.rikka.shizuku) | 13.1.5 | **MIT** |
| RealTimeCutVADLibraryForAndroid (JitPack) | see build file | **MIT** (Silero VAD **MIT**, ONNX Runtime **MIT**, WebRTC APM **BSD-3-Clause**) |
@@ -60,10 +61,14 @@ Test-only: JUnit 4.13.2 (**EPL-1.0**), MockWebServer 4.12.0 (**Apache-2.0**), ko
The name nanoMuse and the mark in `assets/brand/` are the project's own. Use them to refer to this project; do not use them to suggest that something else is nanoMuse or endorsed by it.
-## The runtime, the relay, the web console and the showcase (`nanomuse/`, `cloud/`, `web/`, `demo/`)
+## The runtime, the relay, the web console, the desktop app and the showcase (`nanomuse/`, `cloud/`, `web/`, `harness/`, `demo/`)
Our own code, GPL-3.0-or-later like the rest. Third-party code inside it keeps its own licence and notice; the ports named below say where they come from.
+### DeepSeek Harness (MIT)
+
+The desktop app is a plugin for [DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh) (`@deepseek-ai/dsh` and the `@deepseek-ai/dsh-*` packages, 0.2.0-rc.2, MIT), carried inside nanoMuse Desktop unmodified with every package's licence file; it is not vendored in this repository (`harness/dsh-nanomuse/package.json`, `harness/desktop/scripts/prepare-dsh.mjs`). "DeepSeek Harness" and "DSH" are DeepSeek's names; *harness* in `harness/` is the word ([harness/README.md](harness/README.md#licence-and-names)). The shell is [Electron](https://github.com/electron/electron) (MIT), installed by npm at build time.
+
### MemGUI-Bench (MIT)
The phone operator (`nanomuse/phone/operator.py`) is a port of the `mobile_use` operator in [MemGUI-Bench](https://github.com/lgy0404/MemGUI-Bench) (`src/mobile_world/agents/implementations/qwen3vl.py` and its prompt): the tool schema, the system prompt's wording, the `Thought` / `Action` / `` reply shape, the 999-grid coordinates and the step history. Rewritten in nanoMuse's own structure, with rules added (passwords, payments, `ask_user`), Sentinel labels and traces.
diff --git a/android/src/android/app/build.gradle.kts b/android/src/android/app/build.gradle.kts
index c768967f..62237dab 100644
--- a/android/src/android/app/build.gradle.kts
+++ b/android/src/android/app/build.gradle.kts
@@ -57,8 +57,8 @@ android {
applicationId = "io.github.nanomuse.app"
minSdk = 26
targetSdk = 35
- versionCode = 45
- versionName = "1.0.2"
+ versionCode = 46
+ versionName = "1.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -206,6 +206,10 @@ dependencies {
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
+ // nanoMuse: the frosted-glass pill bottom bar: Haze backdrop blur, in the Pixel taskbar's manner.
+ // 1.6.10, not 2.x: Haze 1.7.x and 2.x need compileSdk 37, and this module is on 36.
+ implementation("dev.chrisbanes.haze:haze-android:1.6.10")
+
// nanoMuse: the camera on a video call (a JPEG frame a second to the real-time model).
val cameraX = "1.4.2"
implementation("androidx.camera:camera-core:$cameraX")
diff --git a/android/src/android/app/src/androidTest/java/com/openminis/app/sandbox/ExecutionCoordinatorInstrumentedTest.kt b/android/src/android/app/src/androidTest/java/com/openminis/app/sandbox/ExecutionCoordinatorInstrumentedTest.kt
index 82ad7ef7..34e7c116 100644
--- a/android/src/android/app/src/androidTest/java/com/openminis/app/sandbox/ExecutionCoordinatorInstrumentedTest.kt
+++ b/android/src/android/app/src/androidTest/java/com/openminis/app/sandbox/ExecutionCoordinatorInstrumentedTest.kt
@@ -49,20 +49,26 @@ class ExecutionCoordinatorInstrumentedTest {
skipIfNoBoot()
val sessionId = "session-mount-test"
- ExecutionCoordinator.execute(sessionId, "echo hello")
+ val result = ExecutionCoordinator.execute(sessionId, "echo hello")
- assertEquals(sessionId, ExecutionCoordinator.mountedSessionId)
+ // nanoMuse: the coordinator keeps one shell per session; there is no single mounted id.
+ assertEquals(0, result.exitCode)
+ assertTrue(ExecutionCoordinator.hasShell(sessionId))
}
@Test
fun executeSwitchesSessionMounts() = runBlocking {
skipIfNoBoot()
- ExecutionCoordinator.execute("session-A", "echo a")
- assertEquals("session-A", ExecutionCoordinator.mountedSessionId)
+ // nanoMuse: sessions no longer swap one mount; each keeps its own shell side by side.
+ val resultA = ExecutionCoordinator.execute("session-A", "echo a")
+ assertEquals(0, resultA.exitCode)
+ assertTrue(ExecutionCoordinator.hasShell("session-A"))
- ExecutionCoordinator.execute("session-B", "echo b")
- assertEquals("session-B", ExecutionCoordinator.mountedSessionId)
+ val resultB = ExecutionCoordinator.execute("session-B", "echo b")
+ assertEquals(0, resultB.exitCode)
+ assertTrue(ExecutionCoordinator.hasShell("session-B"))
+ assertTrue(ExecutionCoordinator.hasShell("session-A"))
}
@Test
@@ -78,7 +84,8 @@ class ExecutionCoordinatorInstrumentedTest {
// Mounts should remain the same (not cleared and re-added)
assertEquals(mountCount, PRootKernel.bindMounts.size)
- assertEquals("session-X", ExecutionCoordinator.mountedSessionId)
+ // nanoMuse: the session's shell is reused, not remounted.
+ assertTrue(ExecutionCoordinator.hasShell("session-X"))
}
@Test
@@ -90,7 +97,8 @@ class ExecutionCoordinatorInstrumentedTest {
// Should have session-level + global bind mounts
// Session: attachments, offloads, workspace, browser (4)
// Global: memory, skills (2)
- assertEquals(6, PRootKernel.bindMounts.size)
+ // nanoMuse: the coordinator's global set is memory, skills, shared and mcp-servers, so eight mounts in all.
+ assertEquals(8, PRootKernel.bindMounts.size)
// Verify session-level mounts
assertTrue(PRootKernel.bindMounts.containsKey("/var/minis/attachments"))
@@ -234,13 +242,15 @@ class ExecutionCoordinatorInstrumentedTest {
skipIfNoBoot()
ExecutionCoordinator.execute("session-terminate", "echo test")
- assertEquals("session-terminate", ExecutionCoordinator.mountedSessionId)
+ // nanoMuse: observe the session's shell instead of the removed mountedSessionId.
+ assertTrue(ExecutionCoordinator.hasShell("session-terminate"))
assertTrue(PRootKernel.bindMounts.isNotEmpty())
ExecutionCoordinator.sessionDidTerminate("session-terminate")
- assertNull(ExecutionCoordinator.mountedSessionId)
- assertTrue(PRootKernel.bindMounts.isEmpty())
+ // nanoMuse: sessionDidTerminate stops and drops the shell; it does not touch the
+ // kernel's bind mount table, so only the registry entry is asserted.
+ assertFalse(ExecutionCoordinator.hasShell("session-terminate"))
}
@Test
@@ -252,14 +262,17 @@ class ExecutionCoordinatorInstrumentedTest {
ExecutionCoordinator.sessionDidTerminate("session-other")
- assertEquals("session-keep", ExecutionCoordinator.mountedSessionId)
+ // nanoMuse: terminating another session leaves this session's shell in place.
+ assertTrue(ExecutionCoordinator.hasShell("session-keep"))
+ assertFalse(ExecutionCoordinator.hasShell("session-other"))
assertEquals(mountsBefore, PRootKernel.bindMounts.size)
}
@Test
fun sessionDidTerminateIsNoOpWhenNoSession() {
ExecutionCoordinator.sessionDidTerminate("any-session")
- assertNull(ExecutionCoordinator.mountedSessionId)
+ // nanoMuse: nothing was registered for this id, so nothing is registered afterwards.
+ assertFalse(ExecutionCoordinator.hasShell("any-session"))
}
// ==================== stopCurrentCommand ====================
@@ -349,21 +362,21 @@ class ExecutionCoordinatorInstrumentedTest {
PRootKernel.clearBindMounts()
PRootKernel.customEnvironment.clear()
- // Reset ExecutionCoordinator mountedSessionId
- try {
- val field = ExecutionCoordinator::class.java.getDeclaredField("mountedSessionId")
- field.isAccessible = true
- field.set(ExecutionCoordinator, null)
- } catch (_: Exception) { }
+ // nanoMuse: there is no mountedSessionId field to reset any more; stop and drop every
+ // per-session shell a previous test may have left behind instead.
+ testSessionIds.forEach { ExecutionCoordinator.sessionDidTerminate(it) }
}
+ // nanoMuse: shared between the per-session reset above and the directory cleanup below.
+ private val testSessionIds = listOf("session-mount-test", "session-A", "session-B", "session-X",
+ "session-mounts", "session-dirs-test", "session-verify-paths",
+ "session-output", "session-fail", "session-ok", "session-timeout",
+ "session-callback", "session-serial", "session-terminate",
+ "session-keep", "session-iso-A", "session-iso-B",
+ "session-global-A", "session-global-B")
+
private fun cleanupSessionDirs() {
- listOf("session-mount-test", "session-A", "session-B", "session-X",
- "session-mounts", "session-dirs-test", "session-verify-paths",
- "session-output", "session-fail", "session-ok", "session-timeout",
- "session-callback", "session-serial", "session-terminate",
- "session-keep", "session-iso-A", "session-iso-B",
- "session-global-A", "session-global-B").forEach { id ->
+ testSessionIds.forEach { id ->
File(context.filesDir, "minis-sessions/$id").deleteRecursively()
}
File(context.filesDir, "minis-global").deleteRecursively()
diff --git a/android/src/android/app/src/main/java/com/openminis/app/debug/LLMRequestLog.kt b/android/src/android/app/src/main/java/com/openminis/app/debug/LLMRequestLog.kt
index 8d019e85..d0aa695f 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/debug/LLMRequestLog.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/debug/LLMRequestLog.kt
@@ -38,6 +38,23 @@ object LLMRequestLog {
private val entries = mutableListOf()
+ // nanoMuse: header names whose values are credentials; matched case-insensitively, and any
+ // name containing "token" or "secret" counts too. The copy keeps the caller's order (#347).
+ private val CREDENTIAL_HEADERS = setOf(
+ "authorization", "proxy-authorization", "x-api-key", "api-key", "apikey",
+ "x-goog-api-key", "cookie", "set-cookie",
+ )
+
+ private fun maskHeaders(headers: Map): Map {
+ val copy = LinkedHashMap(headers.size)
+ for ((name, value) in headers) {
+ val lower = name.lowercase()
+ val secret = lower in CREDENTIAL_HEADERS || lower.contains("token") || lower.contains("secret")
+ copy[name] = if (secret) "***" else value
+ }
+ return copy
+ }
+
@Synchronized
fun add(entry: Entry) {
// T302: belt-and-suspenders. Release builds short-circuit the whole
@@ -48,12 +65,15 @@ object LLMRequestLog {
// each entry truncated to MAX_BODY_CHARS so even a single retained
// entry can't push a memory-tight device over the line.
if (!BuildConfig.DEBUG) return
- val safeEntry = if (entry.requestBody.length > MAX_BODY_CHARS) {
- entry.copy(
- requestBody = entry.requestBody.take(MAX_BODY_CHARS) +
- "\n…[truncated ${entry.requestBody.length - MAX_BODY_CHARS} chars]",
+ // nanoMuse: keep an independent, masked copy of the headers so no credential sits
+ // in this buffer and a later change to the caller's map cannot rewrite the entry (#347).
+ val masked = entry.copy(requestHeaders = maskHeaders(entry.requestHeaders))
+ val safeEntry = if (masked.requestBody.length > MAX_BODY_CHARS) {
+ masked.copy(
+ requestBody = masked.requestBody.take(MAX_BODY_CHARS) +
+ "\n…[truncated ${masked.requestBody.length - MAX_BODY_CHARS} chars]",
)
- } else entry
+ } else masked
entries.add(safeEntry)
if (entries.size > MAX_ENTRIES) {
entries.removeAt(0)
diff --git a/android/src/android/app/src/main/java/com/openminis/app/provider/anthropic/AnthropicProvider.kt b/android/src/android/app/src/main/java/com/openminis/app/provider/anthropic/AnthropicProvider.kt
index d47ff6c1..dd4a1f15 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/provider/anthropic/AnthropicProvider.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/provider/anthropic/AnthropicProvider.kt
@@ -287,8 +287,10 @@ class AnthropicProvider(
* Returns null when the prompt is null/empty (iOS parity — no empty `system` field).
*/
internal fun resolveSystemPrompt(userPrompt: String?): JSONArray? {
- val claudeCodePrefix = com.openminis.app.auth.ClaudeOAuthManager.ANTHROPIC_OAUTH_IDENTIFIER_PROMPT
if (isOAuth) {
+ // nanoMuse: read the Claude Code identifier only on the OAuth path; the getter throws when the
+ // public build leaves it empty, and an API key never needs it (#333)
+ val claudeCodePrefix = com.openminis.app.auth.ClaudeOAuthManager.ANTHROPIC_OAUTH_IDENTIFIER_PROMPT
// Strip the prefix if the caller already prepended it; the tail is the real user prompt.
val tail = when {
userPrompt == null -> ""
diff --git a/android/src/android/app/src/main/java/com/openminis/app/sandbox/ExecutionCoordinator.kt b/android/src/android/app/src/main/java/com/openminis/app/sandbox/ExecutionCoordinator.kt
index a6c8fb64..f6057522 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/sandbox/ExecutionCoordinator.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/sandbox/ExecutionCoordinator.kt
@@ -60,6 +60,10 @@ object ExecutionCoordinator {
appContext = context.applicationContext
}
+ // nanoMuse: test-only view of the per-session registry, so the instrumentation test can
+ // observe a shell's lifetime without reflection (the old single mountedSessionId is gone).
+ internal fun hasShell(sessionId: String): Boolean = shells.containsKey(sessionId)
+
/**
* Execute a command in the session's persistent shell.
*
diff --git a/android/src/android/app/src/main/java/com/openminis/app/sandbox/TerminalSanitizer.kt b/android/src/android/app/src/main/java/com/openminis/app/sandbox/TerminalSanitizer.kt
index 3556d269..21831ae2 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/sandbox/TerminalSanitizer.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/sandbox/TerminalSanitizer.kt
@@ -75,13 +75,15 @@ object TerminalSanitizer {
continue
}
- // Split on CR and simulate overwriting.
- // Each CR resets cursor to column 0. The last non-empty segment wins.
- val segments = line.split('\r')
- val lastNonEmpty = segments.lastOrNull { it.isNotEmpty() }
- if (lastNonEmpty != null) {
- result.append(lastNonEmpty)
+ // nanoMuse: a CR moves the cursor to column 0 and the next segment overwrites from there;
+ // shorter text keeps the old tail, so "AAAA\rBB" is "BBAA", not "BB" (#334)
+ val buf = StringBuilder()
+ for (segment in line.split('\r')) {
+ for ((column, ch) in segment.withIndex()) {
+ if (column < buf.length) buf.setCharAt(column, ch) else buf.append(ch)
+ }
}
+ result.append(buf)
}
return result.toString()
diff --git a/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatScreen.kt b/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatScreen.kt
index b71c99b4..17166e26 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatScreen.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bolt
@@ -210,6 +211,7 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.TileMode // nanoMuse
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.nativeCanvas
@@ -536,6 +538,9 @@ fun ChatScreen(
// nanoMuse: non-null when this chat is a tab of the home shell — hamburger
// instead of back, round kebab, big-face header on the main chat.
nmHome: io.github.nanomuse.ui.chat.NmHomeChrome? = null,
+ // nanoMuse: height of the floating pill bar overlaying the bottom. The
+ // composer sits above it; the message list slides behind it for the blur.
+ nmBottomOverlay: androidx.compose.ui.unit.Dp = 0.dp,
) {
val context = LocalContext.current
val keyboardController = LocalSoftwareKeyboardController.current
@@ -1194,6 +1199,8 @@ fun ChatScreen(
"ScrollSrc",
"scrollToItem src=$source idx=$idx off=$off canBwd=${listState.canScrollBackward} firstIdx=${listState.firstVisibleItemIndex} firstOff=${listState.firstVisibleItemScrollOffset} inProgress=${listState.isScrollInProgress}",
)
+ // nanoMuse: contentPadding clears the measured footer. Offset zero
+ // pins the latest row above it; another overlay offset would count it twice.
runCatching { listState.scrollToItem(idx, off) }
Unit
}
@@ -2604,6 +2611,20 @@ fun ChatScreen(
containerColor = ChatColors.background,
contentWindowInsets = WindowInsets(0),
topBar = {
+ // nanoMuse: the header is an overlay. The list slides underneath and
+ // the scrim melts it away: a soft shade at the top edge dissolving
+ // to clear, never a bar.
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ Brush.verticalGradient(
+ 0f to ChatColors.background.copy(alpha = 0.8f),
+ 0.55f to ChatColors.background.copy(alpha = 0.35f),
+ 1f to Color.Transparent,
+ )
+ ),
+ ) {
TopAppBar(
title = {
// iOS-style centered layout: "nanoMuse" + group row + provider·model row
@@ -2735,7 +2756,10 @@ fun ChatScreen(
modifier = Modifier
// nanoMuse: the name sits in a capsule under the face (Muse's name pill).
.clip(CircleShape)
- .background(ChatColors.userBubble)
+ // nanoMuse: not userBubble, which is now the
+ // white message pill; the capsule keeps a
+ // neutral wash.
+ .background(ChatColors.secondaryBg)
.clickable { io.github.nanomuse.ui.header.openSoulSettings(context) }
.padding(horizontal = 12.dp, vertical = 3.dp),
)
@@ -3331,9 +3355,11 @@ fun ChatScreen(
}
},
windowInsets = WindowInsets.statusBars,
+ // nanoMuse: transparent; the gradient wrapper behind this bar
+ // carries the header fade from the top edge.
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
- containerColor = ChatColors.background.copy(alpha = 0.92f),
- scrolledContainerColor = ChatColors.background.copy(alpha = 0.92f),
+ containerColor = Color.Transparent,
+ scrolledContainerColor = Color.Transparent,
),
// [T-android-topbar-shrink] 76dp → 68dp. The earlier
// T-topbar-model-row-clip fix bumped 60dp → 76dp to give the
@@ -3360,6 +3386,7 @@ fun ChatScreen(
else -> 72.dp
},
)
+ } // nanoMuse: close the header-fade wrapper
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
@@ -3369,13 +3396,39 @@ fun ChatScreen(
Box(
modifier = Modifier
.fillMaxSize()
- .padding(padding)
+ // nanoMuse: the message list slides under the header, which is an
+ // overlay, so only the bottom bar insets the content. The
+ // header itself is a scrim over the scrolling list.
+ .padding(bottom = padding.calculateBottomPadding())
.imePadding()
.onGloballyPositioned { chatPaneWidthPx = it.size.width },
) {
- Column(
+ // nanoMuse: merge live steps once so the footer and its reserve agree
+ // on the same frame, including tools still in the streaming side channel.
+ val streamingById by viewModel.streamingById.collectAsState()
+ val lastToolBlocks = remember(messages, streamingById) {
+ val merged = if (streamingById.isEmpty()) messages
+ else mergeStreamingOverlay(messages, streamingById)
+ merged.filter { it.role == "assistant" }
+ .flatMap { it.toolBlocks }
+ .filter { it.toolStatus != null && it.kind != "thinking" && it.kind != "info" }
+ }
+ var nmBarLingers by remember { mutableStateOf(false) }
+ LaunchedEffect(isStreaming) {
+ if (isStreaming) nmBarLingers = true
+ else if (nmBarLingers) { kotlinx.coroutines.delay(1_500); nmBarLingers = false }
+ }
+ val nmToolBarShown = nmShowSteps && (isStreaming || nmBarLingers) && lastToolBlocks.isNotEmpty()
+ // nanoMuse: browser/tool controls take priority over the composer's top fade.
+ val nmBrowserHandOver by io.github.nanomuse.browser.BrowserHandOver.pending.collectAsState()
+ val nmComposerFadeSuppressed = nmToolBarShown || showBrowserSheet || nmBrowserHandOver != null
+
+ // nanoMuse: measure the whole footer before the list, while keeping the
+ // list full-size behind the pill for Haze. No asynchronous height feedback.
+ io.github.nanomuse.ui.chat.NmChatBottomLayout(
modifier = Modifier.fillMaxSize(),
- ) {
+ bottomInset = nmBottomOverlay,
+ content = { bottomChromeHeight ->
// Dismiss keyboard when the USER scrolls the messages. Gated on
// `isUserDragging` (a real finger drag) rather than
// `listState.isScrollInProgress` — the latter is also true during
@@ -3395,122 +3448,22 @@ fun ChatScreen(
}
// Messages + scroll-to-bottom button
- Box(modifier = Modifier.weight(1f)) {
- var toolBarHeightPx by remember { mutableStateOf(0) }
- val density = LocalDensity.current
- val toolBarHeightDp = with(density) { toolBarHeightPx.toDp() }
- // T166 / T170 / T173: bottomReserve must clear the visible
- // top of the floating tool-status overlay. Layout primitives
- // come from FloatingToolStatusBar:
- // - status bar height = 38 dp
- // - thumbnail floats over the bar with overhang = 27 dp
- // - thumbnail TOP = bar top - overhang = 65 dp above the
- // input bar's upper edge (which is also the LazyColumn
- // bottom edge under reverseLayout).
- //
- // `onGloballyPositioned` on the wrapper Box reports ~98 dp
- // because it includes wrapper padding(bottom=6) + horizontal
- // padding insets + shadow allowance — none of which are
- // *visually occluding* the LazyColumn. Using the measured
- // value + 8 dp left a ~25 dp gap above the thumbnail (red
- // box in the user's report).
- //
- // Pin to the visual constant: thumbnail height (65 dp) + a
- // visual buffer (18 dp) so the latest row's bottom has clear
- // breathing room above the thumbnail top.
- //
- // T174: an earlier version gated reserve on `toolBarHeightPx
- // > 0`, but `onGloballyPositioned` fires asynchronously after
- // the first floating-bar layout pass; for one frame after
- // toolBlocks appeared the reserve evaluated the small
- // default (28 dp) and the just-arrived user bubble landed
- // beneath the bar. Logcat showed the inverse glitch too:
- // `toolBarHeightPx=258 toolBarHeightDp=0 reserve=28` — the
- // px state and the dp/reserve values come from different
- // recomposition snapshots. Drive the reserve directly off
- // the same predicate used for *whether* the floating bar is
- // emitted (`hasFloatingTools` below) so reserve and bar
- // visibility flip on the same frame.
- // [T-android-chat-cannot-scroll-bottom-many-tools]
- // Bug 𝙓𝙄𝙉 TG36286: with 7+ tools the user couldn't scroll the
- // last messages above the floating tool status bar.
- //
- // Asymmetry between the bar's render condition and its
- // bottomReserve gate: [lastToolBlocks] (drives whether to
- // mount FloatingToolStatusBar) merges `messages` with the
- // streaming-side-channel `streamingById`, so during a live
- // turn the in-flight tool's toolStatus shows up there →
- // bar renders. [hasFloatingTools] (drives bottomReserve)
- // only read `messages`, which the streaming architecture
- // intentionally leaves stable during a turn — so the
- // in-flight tool is invisible to this predicate → reserve
- // collapsed to 20dp while a 65dp+6dp floating bar covered
- // the bottom of the LazyColumn. The new arrivals (status
- // pill, "nanoMuse is thinking" indicator, inline retry banner) landed
- // behind the bar with no way to scroll them into view.
- //
- // Fix: also subscribe to streamingById so the predicate
- // matches the bar's actual mount condition. The bar's
- // mount uses `lastToolBlocks.isNotEmpty()` over the merged
- // view; we mirror that semantically by checking the same
- // filter on both sources.
- val streamingById by viewModel.streamingById.collectAsState()
- // nanoMuse (issue #67): the bar is for the run under way. It
- // used to stay as long as the chat had any step in it — a
- // finished step hung over the composer for good. Now it is
- // up while the turn streams and for a moment after, so the
- // last step is seen through to Done, and only when the steps
- // are shown at all (Settings → Appearance → Conversation).
- // The reserve below and the mount further down read this
- // same predicate, so they flip together.
- var nmBarLingers by remember { mutableStateOf(false) }
- LaunchedEffect(isStreaming) {
- if (isStreaming) nmBarLingers = true
- else if (nmBarLingers) { kotlinx.coroutines.delay(1_500); nmBarLingers = false }
- }
- val nmAnyToolStep = remember(messages, streamingById) {
- val merged = if (streamingById.isEmpty()) messages
- else mergeStreamingOverlay(messages, streamingById)
- merged.any { msg ->
- msg.role == "assistant" && msg.toolBlocks.any { tb ->
- tb.toolStatus != null && tb.kind != "thinking" && tb.kind != "info"
- }
- }
- }
- val hasFloatingTools = nmShowSteps && (isStreaming || nmBarLingers) && nmAnyToolStep // nanoMuse: the steps bar
- val visualOverlayHeight = 65.dp // thumbnailHeight in FloatingToolStatusBar
- // Halve the breathing room above the input bar in both
- // states — felt too sparse before. The thumbnail's 65dp
- // physical height is preserved (it has to clear the
- // floating overlay).
- //
- // T245: buffer raised 9dp → 14dp so the gap between the
- // last LazyColumn tool row and the floating thumbnail's
- // top reads at least as loose as the inter-tool spacing
- // (each ToolCallPill carries padding(vertical = 3.dp) +
- // LazyColumn spacedBy(2.dp) = ~8dp inter-tool gap; the
- // 9dp buffer combined with the floating bar's internal
- // overhang was visually tighter than 8dp). 14dp also
- // matches the no-tool branch — single visual constant
- // for "row-bottom → bottom chrome" breathing room.
- // [T-bottom-occluded 0a6d3c92] No-tools branch bumped from
- // 14dp → 20dp to give the last message bubble a comfortable
- // gap above the composer's top edge. With 14dp the trailing
- // line sat too close to the composer shadow / rounded edge
- // (user reported "the bottom of the text is slightly clipped"). The floating-tools branch
- // already reserves visualOverlayHeight (65dp) + buffer and
- // was not part of the report; keep its +14 buffer.
- val bottomReserve =
- if (hasFloatingTools) visualOverlayHeight + 14.dp else 20.dp
- // T174: when bottomReserve changes (toolbar appearing /
- // disappearing or thumbnail height shift), re-pin to bottom
+ // nanoMuse: the list fills the whole Box, extending behind the
+ // pill bar so the haze has content to blur. Bottom contentPadding
+ // keeps the last message clear of the composer + pill.
+ Box(modifier = Modifier.fillMaxSize()) {
+ // nanoMuse: the measured footer already includes the tool preview,
+ // approval cards, composer and pill inset. Only breathing room is extra.
+ val bottomReserve = if (nmToolBarShown) 14.dp else 20.dp
+ // nanoMuse: when the measured footer changes (tools, composer,
+ // approval or keyboard), re-pin to bottom
// if we are currently following. Without this, the new
// contentPadding is honoured for layout but reverseLayout
// won't actively scroll the list — items can wind up below
// the viewport's new bottom edge until the next streaming
// delta triggers a follow. iOS does the same in V3:54-68
// when contentInset.bottom changes.
- LaunchedEffect(bottomReserve) {
+ LaunchedEffect(bottomChromeHeight, bottomReserve) {
// [T-android-send-no-autoscroll-behind-preview] Send-grace
// bypass: within SEND_FOLLOW_GRACE_MS of a user message
// append the isNearBottom gate is waived — the user JUST
@@ -3810,7 +3763,7 @@ fun ChatScreen(
// covers. contentPadding.bottom already reserves that space
// via bottomReserve, yet the reserve does NOT change here:
// a tool bar was already on screen before the enqueue, so
- // hasFloatingTools stays true and LE(bottomReserve) never
+ // nmToolBarShown stays true and the footer resize effect never
// re-fires (verified: zero `reserve-change` events at the
// enqueue moment). The bubble is laid out inside the
// reserved band and stays half-occluded.
@@ -4015,9 +3968,14 @@ fun ChatScreen(
// the bubble's 4dp = 8dp (≈22px), tighter but still a clear
// breath under the title bar. Bottom padding and inter-
// message spacing are untouched.
+ // nanoMuse: the list slides under the overlay header, so it
+ // starts below it: the Scaffold's top inset is the header's
+ // measured height (status bar included), plus breathing room.
+ // The bottom clears the floating pill bar the same way.
contentPadding = PaddingValues(
- top = 4.dp,
- bottom = if (bottomReserve == 0.dp) 12.dp else bottomReserve,
+ top = padding.calculateTopPadding() + 8.dp,
+ // nanoMuse: use this layout pass's full footer height.
+ bottom = bottomChromeHeight + bottomReserve,
),
modifier = Modifier
.fillMaxWidth()
@@ -4703,109 +4661,25 @@ fun ChatScreen(
} // Box (selection scope)
} // CompositionLocalProvider
- // Floating tool status bar — shows only actual tool calls (not text/thinking/info).
- // Matches iOS: filter on toolStatus != nil (text blocks have toolStatus = null).
- //
- // T-streaming-side-channel-tool-blocks: derive lastToolBlocks
- // from a state that combines messages + streamingById INSIDE
- // a LaunchedEffect (not via a top-level collectAsState read),
- // so streaming-tick churn stays off the ChatScreen invalidation
- // list. Without including streamingById, a tool pill clicked
- // mid-turn is missing from lastToolBlocks → ToolDetailSheet
- // never opens (and its sentinel LaunchedEffect immediately
- // closes the detail state because the id "doesn't exist").
- var lastToolBlocks by remember { mutableStateOf>(emptyList()) }
- LaunchedEffect(messages) {
- kotlinx.coroutines.flow.combine(
- kotlinx.coroutines.flow.flowOf(messages),
- viewModel.streamingById,
- ) { msgs, stream ->
- val merged = if (stream.isEmpty()) msgs else mergeStreamingOverlay(msgs, stream)
- merged.filter { it.role == "assistant" }
- .flatMap { it.toolBlocks }
- .filter { it.toolStatus != null && it.kind != "thinking" && it.kind != "info" }
- }.collect { lastToolBlocks = it }
- }
- val allToolBlocks = lastToolBlocks
- // nanoMuse (issue #67): mounted on the same predicate as the
- // bottom reserve (`hasFloatingTools`, above) — while the run
- // is under way and a moment after, steps shown — never for
- // a step that finished long ago.
- val nmToolBarShown = hasFloatingTools && lastToolBlocks.isNotEmpty()
- if (nmToolBarShown) {
- Box(
- modifier = Modifier
- .align(Alignment.BottomCenter)
- // [T-android-chat-max-content-width] The tool status
- // bar is part of the conversation column, so it
- // takes the same cap as the message list and the
- // composer. Without it the bar alone spanned the
- // full pane while everything above and below it was
- // capped, so on a tablet it visibly overhung both.
- //
- // ORDER MATTERS: widthIn must come BEFORE
- // fillMaxWidth. fillMaxWidth pins the incoming
- // MIN width to the full pane as well as the max, so
- // a widthIn placed after it is raised back up by
- // that min and does nothing — which is exactly how
- // the first attempt at this failed. Declared first,
- // widthIn narrows the constraint and fillMaxWidth
- // then fills the already-narrowed one.
- //
- // BottomCenter on the parent centres the result, so
- // no extra alignment is needed.
- .widthIn(max = CHAT_MAX_CONTENT_WIDTH)
- .fillMaxWidth()
- .onGloballyPositioned { toolBarHeightPx = it.size.height }
- .padding(horizontal = 12.dp)
- .padding(bottom = 6.dp),
- ) {
- FloatingToolStatusBar(
- toolBlocks = lastToolBlocks,
- // T14: per-card stop on the floating bar — same
- // global cancel as the message-list pill button.
- onStop = { viewModel.cancelStream() },
- onOpenTerminalWithCommand = onOpenTerminalWithCommand,
- // T261: route detail open through the same VM
- // state as in-list pills so both surfaces share
- // one always-mounted sheet instance.
- onOpenDetail = { viewModel.openToolDetail(it) },
- )
- }
- } else {
- SideEffect { toolBarHeightPx = 0 }
- }
-
// [T-android-tts-capsule] Floating speech-player control for
// "Read replies" TTS — expand/compact capsule with mute, model
// switch and speed cycling. Mounted LAST in this Box so it
// draws above the list, the FABs and the floating tool bar
// (iOS mounts its SpeechPlayerControl at app root; chat-screen
// scope is the Android first pass).
- // [T-android-tts-capsule-avoid] toolBarHeightPx is the same
- // measurement bottomReserve uses — the capsule lifts above the
- // floating tool bar instead of covering its trailing edge.
- // [T-android-tts-capsule-avoid-fabs] The scroll FABs share the
- // capsule's bottom-end corner and OVERLAPPED it (user report:
- // capsule stacked on the jump-to-user-message / scroll-to-
- // bottom buttons). Mirror their exact placement math — same
- // visibility predicates, same base offsets as the FAB blocks
- // below — so the capsule clears the TOP of whatever part of
- // the FAB stack is currently visible, and drops back when the
- // FABs hide. This is the Android stand-in for iOS's
- // protectedRects: derived from the same layout constants
- // instead of measured rects, which keeps it deterministic.
+ // nanoMuse: speech controls and scroll buttons share the same
+ // measured footer obstruction, even when no tools are shown.
val upFabVisible = messages.isNotEmpty() && !isNearBottom.value
val downFabVisible =
userScrolledAway && contentOverflows.value && messages.isNotEmpty()
- val fabBaseDp = if (nmToolBarShown) 80.dp else 8.dp // nanoMuse: follows the bar
+ val fabBaseDp = bottomChromeHeight + 8.dp
val fabStackTopDp = when {
upFabVisible -> fabBaseDp + 46.dp + 36.dp
downFabVisible -> fabBaseDp + 36.dp
else -> 0.dp
}
com.openminis.app.ui.chat.voice.SpeechPlayerCapsule(
- bottomObstructionPx = toolBarHeightPx,
+ bottomObstructionPx = with(LocalDensity.current) { bottomChromeHeight.roundToPx() },
additionalObstructionDp = fabStackTopDp,
)
@@ -4858,6 +4732,65 @@ fun ChatScreen(
if (leftover > 0.dp) leftover / 2 else 0.dp
}
+ // nanoMuse: fade into the canvas above the composer while reading
+ // older messages. Browser/tool controls hide it immediately, even
+ // if a scroll animation or the alpha tween is still in progress.
+ val nmEdgeFade = ChatColors.background
+ val nmScrolledUp = listState.firstVisibleItemIndex > 0 ||
+ listState.firstVisibleItemScrollOffset > 0
+ val nmBottomFadeAlpha by animateFloatAsState(
+ targetValue = if (nmScrolledUp && !nmComposerFadeSuppressed) 1f else 0f,
+ animationSpec = tween(250),
+ label = "nmBottomFade",
+ )
+ if (!nmComposerFadeSuppressed) {
+ val nmFadeHeight = 90.dp
+ val nmFooterHeight = (bottomChromeHeight - nmBottomOverlay).coerceAtLeast(0.dp)
+ // nanoMuse: the gradient ends at the footer's top, then its
+ // solid canvas continues behind the composer padding and
+ // rounded corners. Stop above the nav pill to retain its blur.
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(bottom = nmBottomOverlay)
+ .height(nmFadeHeight + nmFooterHeight)
+ .graphicsLayer { alpha = nmBottomFadeAlpha }
+ .background(
+ Brush.verticalGradient(
+ 0f to Color.Transparent,
+ 1f to nmEdgeFade,
+ endY = with(LocalDensity.current) { nmFadeHeight.toPx() },
+ tileMode = TileMode.Clamp,
+ ),
+ ),
+ )
+ }
+
+ // nanoMuse: the top vignette. When scrolled up away from the
+ // latest, the top edge gets a slow fade in the canvas colour so
+ // messages dissolve under the header instead of hitting a hard
+ // edge. Distinct from the header's static scrim; this one comes
+ // and goes with the scroll.
+ val nmTopFadeAlpha by animateFloatAsState(
+ targetValue = if (nmScrolledUp) 1f else 0f,
+ animationSpec = tween(250),
+ label = "nmTopFade",
+ )
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .height(80.dp)
+ .graphicsLayer { alpha = nmTopFadeAlpha }
+ .background(
+ Brush.verticalGradient(
+ 0f to nmEdgeFade,
+ 1f to Color.Transparent,
+ )
+ ),
+ )
+
// Scroll-to-bottom FAB (iOS: circle chevron.down, bottom-right)
// T138 phase 2 v3: show on user-scroll intent, not transient
// layout state. Otherwise the FAB flickers whenever multi-tool
@@ -4879,7 +4812,8 @@ fun ChatScreen(
// spacing). Tapping walks BACK one user turn at a time rather
// than jumping to the oldest message.
if (messages.isNotEmpty() && !isNearBottom.value) {
- val upBaseBottom = if (nmToolBarShown) 80.dp else 8.dp // nanoMuse: follows the bar
+ // nanoMuse: clear every visible part of the measured footer.
+ val upBaseBottom = bottomChromeHeight + 8.dp
androidx.compose.material3.FilledIconButton(
onClick = {
// [T-android-updown-fab-asymmetry] Arm the same flag a
@@ -4921,7 +4855,8 @@ fun ChatScreen(
}
if (userScrolledAway && contentOverflows.value && messages.isNotEmpty()) {
- val fabBottomPadding = if (nmToolBarShown) 80.dp else 8.dp // nanoMuse: follows the bar
+ // nanoMuse: clear every visible part of the measured footer.
+ val fabBottomPadding = bottomChromeHeight + 8.dp
androidx.compose.material3.FilledIconButton(
onClick = {
// [T-android-scroll-fab-down-stuck] Clear the
@@ -4950,14 +4885,17 @@ fun ChatScreen(
tracedScrollToItem("FAB-DOWN/settle", 0, 0)
}
},
+ // nanoMuse: scroll-to-latest, centered under the conversation:
+ // a disc in the send button's colour with an arrow in the
+ // canvas colour (white on black in dark, inverted in light).
modifier = Modifier
- .align(Alignment.BottomEnd)
- .padding(end = 12.dp + fabEndInset, bottom = fabBottomPadding)
- .shadow(4.dp, CircleShape)
- .size(36.dp),
+ .align(Alignment.BottomCenter)
+ .padding(bottom = fabBottomPadding)
+ .shadow(6.dp, CircleShape)
+ .size(40.dp),
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
- containerColor = ChatColors.inputBg,
- contentColor = ChatColors.primaryText,
+ containerColor = ChatColors.sendButton,
+ contentColor = ChatColors.background,
),
) {
Icon(
@@ -4983,6 +4921,28 @@ fun ChatScreen(
io.github.nanomuse.ui.guard.RiskScrim(sessionId = viewModel.activeSessionId)
}
+ },
+ bottomContent = {
+ // nanoMuse: tools and composer are siblings in one footer, so their
+ // bounds cannot overlap regardless of composer/preview height.
+ if (nmToolBarShown) {
+ Box(
+ modifier = Modifier
+ .widthIn(max = CHAT_MAX_CONTENT_WIDTH)
+ .fillMaxWidth()
+ .align(Alignment.CenterHorizontally)
+ .padding(horizontal = 12.dp)
+ .padding(bottom = 6.dp),
+ ) {
+ FloatingToolStatusBar(
+ toolBlocks = lastToolBlocks,
+ onStop = { viewModel.cancelStream() },
+ onOpenTerminalWithCommand = onOpenTerminalWithCommand,
+ onOpenDetail = { viewModel.openToolDetail(it) },
+ )
+ }
+ }
+
// nanoMuse: Muse's "Needs approval" card sits between the messages and the composer.
io.github.nanomuse.ui.guard.RiskApprovalHost(
sessionId = viewModel.activeSessionId,
@@ -5026,6 +4986,7 @@ fun ChatScreen(
!com.openminis.app.ui.chat.voice.VoiceModePrefs.isVoiceActive &&
!nmRecording && editingId == null
// ─── Input area (iOS-style: rounded box with text + buttons below) ───
+ // nanoMuse: the footer places the composer above the pill.
Column(
modifier = Modifier
.fillMaxWidth()
@@ -5050,7 +5011,12 @@ fun ChatScreen(
// which is exactly how the first attempt at this fix still
// looked broken.
.onGloballyPositioned { composerWidthPx = it.size.width }
- .navigationBarsPadding()
+ // nanoMuse: the footer owns the home pill inset; standalone
+ // chats still consume their system navigation inset here.
+ .then(
+ if (nmBottomOverlay > 0.dp) Modifier
+ else Modifier.navigationBarsPadding()
+ )
.padding(horizontal = if (nmPill) 16.dp else 12.dp) // nanoMuse: Muse's 16dp margins
.padding(top = 2.dp, bottom = 8.dp),
) {
@@ -7007,14 +6973,23 @@ fun ChatScreen(
// Streaming with content → Send-into-queue; Idle with content → Send.
// Idle without text or attachments → disabled.
val canActivate = hasContent
+ // nanoMuse: monochrome send. The palette's sendButton is
+ // white in the dark theme and black in the light one, the
+ // arrow takes the canvas colour; no blue accent.
+ val nmSendBg = if (canActivate) {
+ ChatColors.sendButton
+ } else {
+ ChatColors.sendButton.copy(alpha = if (ChatColors.isDark) 0.15f else 0.08f)
+ }
+ val nmSendFg = if (canActivate) {
+ ChatColors.background
+ } else {
+ ChatColors.primaryText.copy(alpha = 0.4f)
+ }
Box(
modifier = Modifier
.size(38.dp)
- .background(
- if (canActivate) ChatColors.sendButton
- else ChatColors.sendButtonDisabled,
- CircleShape,
- )
+ .background(nmSendBg, CircleShape)
.clip(CircleShape)
.clickable(enabled = canActivate) {
// T-drag-send-queue: route through the
@@ -7034,8 +7009,7 @@ fun ChatScreen(
Icon(
Icons.Default.ArrowUpward,
contentDescription = "Send",
- tint = if (canActivate) ChatColors.background
- else ChatColors.primaryText.copy(alpha = 0.5f),
+ tint = nmSendFg,
modifier = Modifier.size(20.dp),
)
}
@@ -7190,7 +7164,8 @@ fun ChatScreen(
},
)
}
- }
+ },
+ )
// Top gradient fade: messages fade into the Scaffold background.
Box(
modifier = Modifier
diff --git a/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatUserMessageUI.kt b/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatUserMessageUI.kt
index ca3daddb..1ea2d0d9 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatUserMessageUI.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatUserMessageUI.kt
@@ -374,7 +374,9 @@ internal fun UserMessageBubble(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
- val textColor = if (isQueued) secondaryTextColor else MaterialTheme.colorScheme.onSurface
+ // nanoMuse: ink on the user pill (white pill, black ink in
+ // the dark theme, inverted in light), not onSurface.
+ val textColor = if (isQueued) secondaryTextColor else ChatColors.onUserBubble
val bubbleBg = if (isQueued) Color.Transparent else userBubbleColor
val shape = RoundedCornerShape(18.dp)
val dashedStroke = if (isQueued) {
diff --git a/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatViewModel.kt b/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatViewModel.kt
index 762beff0..1339adcb 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatViewModel.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/ui/chat/ChatViewModel.kt
@@ -6965,7 +6965,9 @@ class ChatViewModel(
if (editingId != null) _editingMessageId.value = null
viewModelScope.launch {
+ streamJob = coroutineContext[Job] // nanoMuse: Stop during the setup (session, attachments, the user row) cancels it too, not only the agent loop launched below (#340)
var streamLaunched = false
+ var userRowShown = false // nanoMuse: set once the person's line is on screen; a Stop before that gives the composer its text back (#340)
try {
// Ensure session exists in DB (creates on first message for draft sessions)
val activeSessionId = ensureSession()
@@ -7021,6 +7023,7 @@ class ChatViewModel(
attachmentUris = prepared.nonImageUris + (pasted?.uiUris ?: emptyList()),
)
_messages.value = _messages.value + userMsg
+ userRowShown = true // nanoMuse: (#340)
val imageParts = prepared.imageParts
// T132: build the user contentParts in iOS order — caption first
@@ -7174,6 +7177,14 @@ class ChatViewModel(
}
AppLogger.info(TAG_STREAM, "send streamJob EXIT")
}
+ } catch (e: CancellationException) {
+ // nanoMuse: Stop before the user row is on screen means the message was not sent; the
+ // composer gets its text and attachments back instead of losing them (#340)
+ if (!streamLaunched && !userRowShown) {
+ if (_inputText.value.isEmpty()) _inputText.value = text
+ if (_attachments.value.isEmpty()) _attachments.value = currentAttachments
+ }
+ throw e
} finally {
if (!streamLaunched) {
AppLogger.info(TAG_STREAM, "send _isStreaming=false (setup aborted)")
@@ -12136,7 +12147,7 @@ Scheduled tasks: crontab / at / nohup loops will stop when the app is suspended,
*/
private fun handleUserCancelledCleanup() {
val msgs = _messages.value.toMutableList()
- val lastIdx = msgs.indexOfLast { it.role == "assistant" }
+ val lastIdx = io.github.nanomuse.chat.InterruptedReply.lastRealAssistantIndex(msgs) // nanoMuse: never the on-screen-only intro (#340)
if (lastIdx < 0) return
var last = msgs[lastIdx]
@@ -12236,7 +12247,9 @@ Scheduled tasks: crontab / at / nohup loops will stop when the app is suspended,
)
viewModelScope.launch(Dispatchers.IO) {
val partsJson = buildAssistantPartsJson(parts)
- chatRepository.appendMessage(activeSessionId, "assistant", partsJson)
+ // nanoMuse: a session id the database does not know yet (Stop inside the session insert) must not take the process down (#340)
+ runCatching { chatRepository.appendMessage(activeSessionId, "assistant", partsJson) }
+ .onFailure { AppLogger.warning(TAG_STREAM, "cancel: interrupted reply not persisted: ${it.message}") }
}
// [T-android-group-pause-badge-restamp] A LIVE interruption just
// happened: this is a real entry into the paused state, so the
diff --git a/android/src/android/app/src/main/java/com/openminis/app/ui/components/MinisMenu.kt b/android/src/android/app/src/main/java/com/openminis/app/ui/components/MinisMenu.kt
index 41f0cef8..6fd40f2c 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/ui/components/MinisMenu.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/ui/components/MinisMenu.kt
@@ -71,7 +71,9 @@ fun MinisMenu(
// in dark, a subtle shade in light) so the menu pops out — same fix already
// validated on the text-selection toolbar (T-android-text-toolbar-dark-
// visibility). The dark-mode `border` below adds the extra edge definition.
- containerColor: Color = if (ChatColors.isDark) MaterialTheme.colorScheme.surfaceContainerHigh else Color.White,
+ // nanoMuse: pure canvas like the drawer and chat, black in dark and white in
+ // light. The hairline border carries the definition.
+ containerColor: Color = ChatColors.background,
tonalElevation: Dp = 3.dp,
// [T-android-menu-launcher-shadow] Analyzed against the Pixel Launcher
// long-press popup (screenshot sample): its shadow is a WIDE, very
@@ -86,10 +88,12 @@ fun MinisMenu(
// Light mode now also gets a hairline (was null): with the launcher-style
// soft halo replacing the old tight shadow, the popup's edge needs a
// subtle line of its own to stay crisply bounded on light surfaces.
+ // nanoMuse: a touch stronger in dark so the black menu stays defined
+ // against the black chat.
border: BorderStroke? = if (ChatColors.isDark) {
- BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant)
+ BorderStroke(0.75.dp, Color.White.copy(alpha = 0.14f))
} else {
- BorderStroke(0.5.dp, Color.Black.copy(alpha = 0.05f))
+ BorderStroke(0.5.dp, Color.Black.copy(alpha = 0.08f))
},
minWidth: Dp = 180.dp,
// T238: when true, anchor the menu's RIGHT edge to the anchor box's
diff --git a/android/src/android/app/src/main/java/com/openminis/app/ui/theme/ChatColors.kt b/android/src/android/app/src/main/java/com/openminis/app/ui/theme/ChatColors.kt
index ee5c7541..0df2a27e 100644
--- a/android/src/android/app/src/main/java/com/openminis/app/ui/theme/ChatColors.kt
+++ b/android/src/android/app/src/main/java/com/openminis/app/ui/theme/ChatColors.kt
@@ -34,6 +34,10 @@ data class ChatPalette(
val tertiaryText: Color,
val disabledText: Color,
val userBubble: Color,
+ // nanoMuse: the text ink on the user bubble, and the assistant's own pill
+ // (a touch off the background, both themes).
+ val onUserBubble: Color,
+ val assistantBubble: Color,
val toolBg: Color,
val toolBorder: Color,
val toolCapsuleBg: Color,
@@ -70,12 +74,16 @@ val LightChatPalette = ChatPalette(
secondaryText = Color(0x993C3C43),
tertiaryText = Color(0x4D3C3C43),
disabledText = Color(0x2E3C3C43),
- userBubble = Color(0xFFE5F0FF),
+ // nanoMuse: the user's own message is a black pill with white ink (white
+ // pill, black ink in the dark theme below). scripts/rebrand.py writes these.
+ userBubble = Color(0xFF000000),
+ onUserBubble = Color(0xFFFFFFFF),
+ assistantBubble = Color(0xFFF2F2F7),
toolBg = Color(0xFFF2F2F7),
toolBorder = Color(0x14000000),
toolCapsuleBg = Color(0xFFF2F2F7),
separator = Color(0x4D3C3C43),
- sendButton = Color(0xFF015CFB),
+ sendButton = Color(0xFF000000),
sendButtonDisabled = Color(0x2E3C3C43),
codeBlockBg = Color(0xFF000000),
codeBlockText = Color(0xFFE5E5EA),
@@ -116,18 +124,17 @@ val DarkChatPalette = ChatPalette(
secondaryText = Color(0x99EBEBF5),
tertiaryText = Color(0x4DEBEBF5),
disabledText = Color(0x2EEBEBF5),
- // [T-android-user-bubble-dark-contrast] The old 0x247676D7 was a 14%-alpha
- // translucent blue-grey that washed out to near-invisible on the #000 chat
- // background on real (≈500-nit) displays — exactly the "crush to a near-black
- // wash" failure the palette header warns about. Use an OPAQUE cool slate-blue
- // so the user's own messages read as a distinct accent; white primaryText
- // stays legible on it.
- userBubble = Color(0xFF1A2B4A),
+ // nanoMuse: the user's own message is a white pill with black ink; the
+ // assistant's pill is a touch off the pure-black wash. (Replaces the earlier
+ // opaque slate-blue user bubble.) scripts/rebrand.py writes these.
+ userBubble = Color(0xFFFFFFFF),
+ onUserBubble = Color(0xFF000000),
+ assistantBubble = Color(0xFF1C1C1E),
toolBg = Color(0xFF3A3A3F),
toolBorder = Color(0x40545458),
toolCapsuleBg = Color(0xFF28282C),
separator = Color(0x99545458),
- sendButton = Color(0xFF58A6FF),
+ sendButton = Color(0xFFFFFFFF),
sendButtonDisabled = Color(0x2EEBEBF5),
codeBlockBg = Color(0xFF262626),
codeBlockText = Color(0xFFE5E5EA),
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/account/AccountData.kt b/android/src/android/app/src/main/java/io/github/nanomuse/account/AccountData.kt
index a608cca7..b7878132 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/account/AccountData.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/account/AccountData.kt
@@ -217,6 +217,7 @@ object AccountData {
runCatching { io.github.nanomuse.feed.FeedStore.reload() }
runCatching { io.github.nanomuse.goals.GoalStore.get(ctx).reload() }
runCatching { io.github.nanomuse.avatar.AvatarStore.reload() }
+ runCatching { io.github.nanomuse.avatar.OwnClip.rescan() }
runCatching { com.openminis.app.agent.SoulStore.refreshCache(ctx) }
runCatching { io.github.nanomuse.home.MainChat.reset() }
}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStore.kt b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStore.kt
index 2479da06..7225c2c8 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStore.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStore.kt
@@ -43,6 +43,12 @@ object AvatarStore {
dir = File(context.filesDir, "minis-global/nanomuse/avatar").apply { mkdirs() }
reload()
AvatarMotion.init()
+ OwnClip.init()
+ }
+
+ /** Tests only: the pictures go under [root]; nothing is reloaded. */
+ internal fun useDirForTests(root: File) {
+ dir = root.apply { mkdirs() }
}
fun root(): File = dir
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStudio.kt b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStudio.kt
index 89806008..8f6a04d4 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStudio.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/AvatarStudio.kt
@@ -6,16 +6,19 @@ import androidx.annotation.StringRes
import com.openminis.app.R
import com.openminis.app.logging.AppLogger
import io.github.nanomuse.ui.avatar.AgentMood
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -56,7 +59,16 @@ object AvatarStudio {
data class MoodProgress(val done: Int, val total: Int, val failed: List, val running: Boolean)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private var candidateJob: Job? = null
+ /** The run drawing the current candidates (tests join it). */
+ internal var candidateJob: Job? = null
+ private set
+ /**
+ * Which call to [generateCandidates] the slots belong to. A new call cancels the old run,
+ * but the old run's coroutines still finish on their own threads; only a result carrying
+ * the current generation reaches the slots, so a cancelled run never writes "failed" (or
+ * a late picture) into its replacement's tiles (#337).
+ */
+ @Volatile private var generation = 0
private var moodJob: Job? = null
/** Image endpoints throttle hard; two in flight is what most of them tolerate. */
private val lane = Semaphore(2)
@@ -75,6 +87,8 @@ object AvatarStudio {
while (true) {
try {
return lane.withPermit { block() }
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
if (attempt >= 2 || !looksRateLimited(e)) throw e
attempt += 1
@@ -132,19 +146,31 @@ object AvatarStudio {
style.value = chosen
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString("avatar.description", text).putString("avatar.style", chosen.id).apply()
+ startCandidates(context, ep, text, chosen, referenceImage)
+ return true
+ }
+
+ /** The run itself, once the endpoint and the words are settled; tests enter here with a fake [drawer]. */
+ internal fun startCandidates(context: Context, ep: ImageGen.Endpoint, text: String, chosen: Style, referenceImage: Bitmap?) {
candidateJob?.cancel()
+ val gen = ++generation
_selected.value = null
_error.value = null
_slots.value = List(CANDIDATES) { Slot.Loading }
lastRequest = Triple(text, chosen, ep)
reference = referenceImage
candidateJob = scope.launch {
- (0 until CANDIDATES).map { i -> async { drawCandidate(context, ep, text, chosen, i) } }.awaitAll()
- if (_slots.value.all { it is Slot.Failed }) {
+ (0 until CANDIDATES).map { i -> async { drawCandidate(context, ep, text, chosen, i, gen) } }.awaitAll()
+ if (gen == generation && _slots.value.all { it is Slot.Failed }) {
_error.value = (_slots.value.first() as Slot.Failed).message
}
}
- return true
+ }
+
+ /** Draws one picture: from [reference] through the edit endpoint, else from the prompt alone. Tests swap it. */
+ internal var drawer: suspend (Context, ImageGen.Endpoint, Bitmap?, String) -> Bitmap = { context, ep, ref, prompt ->
+ if (ref != null) ImageGen.edit(context, ep, ref, REFERENCE_PREFIX + prompt)
+ else ImageGen.generate(context, ep, prompt)
}
/** The same sentence again — a fresh set of four. */
@@ -158,19 +184,17 @@ object AvatarStudio {
val (text, chosen, ep) = lastRequest ?: return
if (_slots.value.getOrNull(index) !is Slot.Failed) return
_slots.update { list -> list.toMutableList().also { it[index] = Slot.Loading } }
- scope.launch { drawCandidate(context, ep, text, chosen, index) }
+ val gen = generation
+ scope.launch { drawCandidate(context, ep, text, chosen, index, gen) }
}
- private suspend fun drawCandidate(context: Context, ep: ImageGen.Endpoint, text: String, chosen: Style, i: Int) {
+ private suspend fun drawCandidate(context: Context, ep: ImageGen.Endpoint, text: String, chosen: Style, i: Int, gen: Int) {
val dir = AvatarStore.candidatesDir()
val prompt = buildPrompt(text, chosen, i)
val ref = reference
- val result = runCatching {
- throttled {
- if (ref != null) ImageGen.edit(context, ep, ref, REFERENCE_PREFIX + prompt)
- else ImageGen.generate(context, ep, prompt)
- }
- }
+ val result = runCatching { throttled { drawer(context, ep, ref, prompt) } }
+ // Cancelled, or a new set was asked for meanwhile: nothing to say about this tile any more.
+ if (result.exceptionOrNull() is CancellationException || !currentCoroutineContext().isActive || gen != generation) return
val slot = result.fold(
onSuccess = { bmp ->
val f = File(dir, "$i.png")
@@ -185,7 +209,8 @@ object AvatarStudio {
)
// Atomic: the four candidates finish on different threads, and a plain
// read-modify-write here would let one tile's result overwrite another's.
- _slots.update { list -> list.toMutableList().also { it[i] = slot } }
+ // Only for the set this picture was asked for: a later set owns the slots now.
+ if (gen == generation) _slots.update { list -> list.toMutableList().also { it[i] = slot } }
}
/** The picked candidate becomes the face; the moods follow in the background. */
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/ImageGen.kt b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/ImageGen.kt
index b4b8376e..37aa9b9f 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/ImageGen.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/ImageGen.kt
@@ -14,6 +14,7 @@ import com.openminis.app.data.model.ProviderType
import com.openminis.app.logging.AppLogger
import com.openminis.app.provider.ProviderFactory
import com.openminis.app.provider.openai.OpenAIProvider
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@@ -242,6 +243,8 @@ object ImageGen {
provider(context, ep, ep.model).generateImage(prompt = prompt, n = 1, size = size)
} catch (e: ImageGenException) {
throw e
+ } catch (e: CancellationException) {
+ throw e // the caller was cancelled; not a failure of the picture (#337)
} catch (e: Exception) {
throw ImageGenException(e.message ?: e.javaClass.simpleName)
}
@@ -261,6 +264,8 @@ object ImageGen {
n = 1,
size = "1024x1024",
)
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
throw ImageGenException(e.message ?: e.javaClass.simpleName)
}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/avatar/OwnClip.kt b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/OwnClip.kt
new file mode 100644
index 00000000..4ec4e1a2
--- /dev/null
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/avatar/OwnClip.kt
@@ -0,0 +1,232 @@
+package io.github.nanomuse.avatar
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.media.MediaExtractor
+import android.media.MediaFormat
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.provider.OpenableColumns
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import com.openminis.app.logging.AppLogger
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import java.io.File
+import java.io.FileOutputStream
+import java.io.InputStream
+
+/**
+ * A clip of the person's own as the face (#324). One short video, picked from the phone's
+ * gallery, plays muted in a loop wherever the face moves: for the mood at rest, and for every
+ * other mood too, since there is only the one clip. The stills keep their place where a still is
+ * shown: a studio face's mood pictures sit under the clip until its first frame is on screen;
+ * with no studio face the clip's own first frame is the still.
+ *
+ * The clip lives next to the pictures as `avatar/own-clip.mp4` with its first frame in
+ * `avatar/own-clip.png`, and nowhere else: it is never sent to the relay ([ProfileSync] reads
+ * [AvatarStore] only) and does not follow the account to another device. It survives a new
+ * studio face and a reset to the built-in one; only *Default face* removes it.
+ */
+object OwnClip {
+ private const val TAG = "OwnClip"
+
+ /** The longest clip taken, in whole seconds; a clip that rounds down to this is fine. */
+ const val MAX_SECONDS = 15
+
+ /** The largest file taken. */
+ const val MAX_BYTES = 30L * 1024 * 1024
+
+ /** The video codec the phone's player is counted on to decode. */
+ const val H264 = "video/avc"
+
+ /** The containers taken: MP4 and QuickTime (MOV). */
+ val containers: Set = setOf("video/mp4", "video/quicktime")
+
+ enum class Refusal { FORMAT, TOO_LONG, TOO_LARGE }
+
+ /** What a look at a picked file found; a null field is one that could not be read. */
+ data class Probe(
+ val container: String?,
+ val videoCodec: String?,
+ val durationMs: Long?,
+ val bytes: Long?,
+ )
+
+ /** A clip that passed the checks and was copied in, waiting for *Use* or *Cancel*. */
+ data class Pending(val file: File, val poster: Bitmap?, val durationMs: Long, val bytes: Long)
+
+ sealed class Outcome {
+ data class Ready(val pending: Pending) : Outcome()
+ data class Refused(val why: Refusal, val probe: Probe) : Outcome()
+ data class Failed(val message: String) : Outcome()
+ }
+
+ /**
+ * The rule, apart from the phone: the format first (an unreadable file is a format problem,
+ * whatever its size), then the length, then the size. Null means the clip is taken.
+ */
+ fun check(p: Probe): Refusal? {
+ val codec = p.videoCodec ?: return Refusal.FORMAT
+ val duration = p.durationMs ?: return Refusal.FORMAT
+ if (codec != H264) return Refusal.FORMAT
+ if (p.container != null && p.container !in containers) return Refusal.FORMAT
+ if (duration >= (MAX_SECONDS + 1) * 1000L) return Refusal.TOO_LONG
+ if (p.bytes != null && p.bytes > MAX_BYTES) return Refusal.TOO_LARGE
+ return null
+ }
+
+ /** The clip on disk; [modified] tells a replaced clip from the one before at the same path. */
+ data class Clip(val file: File, val modified: Long) {
+ val key: String get() = "${file.path}:$modified"
+ }
+
+ private val _current = MutableStateFlow(null)
+
+ /** The clip on disk, or null for none. */
+ val current: StateFlow = _current
+ private val _poster = MutableStateFlow(null)
+
+ /** The clip's first frame, the still shown under it and in its place when there is no studio face. */
+ val poster: StateFlow = _poster
+
+ fun clipFile(): File = File(AvatarStore.root(), "own-clip.mp4")
+ fun posterFile(): File = File(AvatarStore.root(), "own-clip.png")
+ private fun pendingFile(): File = File(AvatarStore.root(), "own-clip.pending.mp4")
+
+ fun init() {
+ // a copy that was never used or cancelled (the app died in between) is of no use now
+ pendingFile().delete()
+ rescan()
+ }
+
+ fun rescan() {
+ val f = clipFile()
+ _current.value = f.takeIf { it.exists() && it.length() > 0 }?.let { Clip(it, it.lastModified()) }
+ _poster.value = if (_current.value != null) AvatarStore.decodeBitmap(posterFile())?.asImageBitmap() else null
+ }
+
+ /** *Use*: the pending copy becomes the clip, its first frame the still. */
+ fun adopt(pending: Pending) {
+ val f = clipFile()
+ if (!pending.file.renameTo(f)) {
+ f.delete()
+ if (!pending.file.renameTo(f)) { pending.file.copyTo(f, overwrite = true); pending.file.delete() }
+ }
+ val poster = pending.poster
+ if (poster != null) AvatarStore.write(posterFile(), poster) else posterFile().delete()
+ rescan()
+ }
+
+ /** *Cancel* in the preview: the copy goes. */
+ fun discard(pending: Pending) {
+ pending.file.delete()
+ }
+
+ /** *Default face*: back to the studio face, or the built-in one. */
+ fun clear() {
+ clipFile().delete()
+ posterFile().delete()
+ pendingFile().delete()
+ rescan()
+ }
+
+ /**
+ * Looks at the picked file without copying it: the size from the provider, the container
+ * and the length from the metadata, the codec from the first video track.
+ */
+ fun probe(context: Context, uri: Uri): Probe {
+ val resolver = context.contentResolver
+ var bytes: Long? = runCatching {
+ resolver.openAssetFileDescriptor(uri, "r")?.use { it.length }
+ }.getOrNull()?.takeIf { it >= 0 }
+ if (bytes == null) {
+ bytes = runCatching {
+ resolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { c ->
+ if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null
+ }
+ }.getOrNull()
+ }
+ var container: String? = null
+ var durationMs: Long? = null
+ runCatching {
+ val r = MediaMetadataRetriever()
+ try {
+ r.setDataSource(context, uri)
+ container = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)
+ durationMs = r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull()
+ } finally {
+ r.release()
+ }
+ }.onFailure { AppLogger.info(TAG, "metadata: ${it.message}") }
+ var codec: String? = null
+ runCatching {
+ val x = MediaExtractor()
+ try {
+ x.setDataSource(context, uri, null)
+ for (i in 0 until x.trackCount) {
+ val mime = x.getTrackFormat(i).getString(MediaFormat.KEY_MIME) ?: continue
+ if (mime.startsWith("video/")) { codec = mime; break }
+ }
+ } finally {
+ x.release()
+ }
+ }.onFailure { AppLogger.info(TAG, "tracks: ${it.message}") }
+ return Probe(container, codec, durationMs, bytes)
+ }
+
+ /**
+ * Copies [input] into [dst], but never more than [limit] bytes: the copy stops as soon as
+ * the limit is passed, the partial file is deleted and null comes back, so a provider that
+ * gave no size up front cannot fill the disk with a long clip. Otherwise the byte count.
+ */
+ internal fun copyBounded(input: InputStream, dst: File, limit: Long = MAX_BYTES): Long? {
+ var copied = 0L
+ var over = false
+ val buffer = ByteArray(64 * 1024)
+ FileOutputStream(dst).use { out ->
+ while (true) {
+ val n = input.read(buffer)
+ if (n < 0) break
+ if (copied + n > limit) { over = true; break }
+ out.write(buffer, 0, n)
+ copied += n
+ }
+ }
+ if (over) { dst.delete(); return null }
+ return copied
+ }
+
+ /**
+ * The whole import: a look first, and a clip the rule refuses never touches the disk; one it
+ * takes is copied into the avatar directory as a pending file, its first frame decoded, and
+ * handed back for the preview.
+ */
+ fun import(context: Context, uri: Uri): Outcome {
+ val probe = probe(context, uri)
+ check(probe)?.let { return Outcome.Refused(it, probe) }
+ val dst = pendingFile()
+ try {
+ dst.delete()
+ val input = context.contentResolver.openInputStream(uri) ?: return Outcome.Failed("cannot open")
+ // The provider gave no size up front (or a wrong one) and the file turned out larger:
+ // the copy stopped at the limit, so the real size is unknown and stays null.
+ val copied = input.use { copyBounded(it, dst) }
+ ?: return Outcome.Refused(Refusal.TOO_LARGE, probe.copy(bytes = null))
+ val poster = runCatching {
+ val r = MediaMetadataRetriever()
+ try {
+ r.setDataSource(dst.path)
+ r.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
+ } finally {
+ r.release()
+ }
+ }.onFailure { AppLogger.info(TAG, "first frame: ${it.message}") }.getOrNull()
+ return Outcome.Ready(Pending(dst, poster, probe.durationMs ?: 0L, copied))
+ } catch (e: Exception) {
+ AppLogger.warning(TAG, "import: ${e.message}")
+ dst.delete()
+ return Outcome.Failed(e.message ?: "cannot read")
+ }
+ }
+}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/chat/InterruptedReply.kt b/android/src/android/app/src/main/java/io/github/nanomuse/chat/InterruptedReply.kt
new file mode 100644
index 00000000..18511446
--- /dev/null
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/chat/InterruptedReply.kt
@@ -0,0 +1,25 @@
+package io.github.nanomuse.chat
+
+import com.openminis.app.ui.chat.ChatMessage
+
+/**
+ * Which message Stop interrupted. The chat list holds a few messages of ours that only exist
+ * on screen: the first conversation's opening lines (`nm_intro_*`, assistant), the naming card
+ * and the avatar cards (`nm_*`, system). None of them is in the database or in the model's
+ * history, so none can be the reply the model was writing. Before this, Stop during the first
+ * send's setup (the session being created, the user row written) found no reply of the
+ * model's yet and took the opening line as the interrupted one: it was persisted under the
+ * session as a model turn, or under the draft id when Stop landed inside the session insert
+ * (#340).
+ */
+object InterruptedReply {
+ /** Our on-screen-only messages carry ids with this prefix. */
+ private const val VIRTUAL_PREFIX = "nm_"
+
+ /** True for a message that is on screen only, never in the database or the history. */
+ fun isVirtual(id: String): Boolean = id.startsWith(VIRTUAL_PREFIX)
+
+ /** The index of the last assistant message the model wrote (or is writing), or -1 when there is none. */
+ fun lastRealAssistantIndex(messages: List): Int =
+ messages.indexOfLast { it.role == "assistant" && !isVirtual(it.id) }
+}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/cloud/NanoMuseCloud.kt b/android/src/android/app/src/main/java/io/github/nanomuse/cloud/NanoMuseCloud.kt
index 903680b5..bc5468f4 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/cloud/NanoMuseCloud.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/cloud/NanoMuseCloud.kt
@@ -13,6 +13,7 @@ import com.openminis.app.data.model.ProviderInstance
import com.openminis.app.data.model.ProviderType
import com.openminis.app.data.repository.ProviderRepository
import io.github.nanomuse.avatar.ImageGen
+import io.github.nanomuse.net.await
import java.io.IOException
import java.util.UUID
import java.util.concurrent.TimeUnit
@@ -24,6 +25,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
import org.json.JSONArray
import org.json.JSONObject
@@ -443,13 +445,13 @@ object NanoMuseCloud {
*/
fun profile(context: Context, withFace: Boolean): JSONObject {
val key = apiKey(context) ?: throw CloudException("bad_key", "Not signed in")
- return call(context, "GET", if (withFace) "/v1/me/profile" else "/v1/me/profile?face=false", null, token = key)
+ return callBlocking(context, "GET", if (withFace) "/v1/me/profile" else "/v1/me/profile?face=false", null, token = key)
}
/** This phone's name and look for the account (last writer wins); the new `rev`. Blocking. */
fun putProfile(context: Context, body: JSONObject): JSONObject {
val key = apiKey(context) ?: throw CloudException("bad_key", "Not signed in")
- return call(context, "PUT", "/v1/me/profile", body, token = key)
+ return callBlocking(context, "PUT", "/v1/me/profile", body, token = key)
}
/** Delete every turn the relay kept from this account; returns how many went. */
@@ -1097,9 +1099,33 @@ object NanoMuseCloud {
io.github.nanomuse.sync.ConversationSync.forget(context) // the next account starts with its own ids and cursor
}
- private fun call(context: Context, method: String, path: String, body: JSONObject?, token: String?): JSONObject {
- // No relay configured is a configuration error, not a network one: say so plainly
- // instead of handing OkHttp a relative URL (which raises IllegalArgumentException).
+ /**
+ * One request to the relay, suspending until it answers. Cancelling the coroutine cancels
+ * the call ([await]): a sign-in page left while the code is on its way frees the socket
+ * at once (#336). Every suspending caller is on IO already.
+ */
+ private suspend fun call(context: Context, method: String, path: String, body: JSONObject?, token: String?): JSONObject {
+ val response = try {
+ http.newCall(request(context, method, path, body, token)).await()
+ } catch (e: IOException) {
+ throw CloudException("unreachable", e.message ?: "unreachable")
+ }
+ return read(response)
+ }
+
+ /** The same request, blocking: for [ProfileSync]'s worker thread, which runs no coroutine. */
+ private fun callBlocking(context: Context, method: String, path: String, body: JSONObject?, token: String?): JSONObject {
+ val response = try {
+ http.newCall(request(context, method, path, body, token)).execute()
+ } catch (e: IOException) {
+ throw CloudException("unreachable", e.message ?: "unreachable")
+ }
+ return read(response)
+ }
+
+ private fun request(context: Context, method: String, path: String, body: JSONObject?, token: String?): Request {
+ // No relay configured is a configuration error, not a network one: refuse before any
+ // network I/O instead of handing OkHttp a relative URL (IllegalArgumentException).
val builder = Request.Builder().url(requireBaseUrl(context) + path)
if (token != null) builder.header("Authorization", "Bearer $token")
builder.header("User-Agent", "nanoMuse-Android/${BuildConfig.VERSION_NAME}")
@@ -1107,11 +1133,11 @@ object NanoMuseCloud {
"GET" -> builder.get()
else -> builder.method(method, (body?.toString() ?: "{}").toRequestBody(json))
}
- val response = try {
- http.newCall(builder.build()).execute()
- } catch (e: IOException) {
- throw CloudException("unreachable", e.message ?: "unreachable")
- }
+ return builder.build()
+ }
+
+ /** The relay's reply as JSON, or the [CloudException] its error body describes. */
+ private fun read(response: Response): JSONObject {
response.use { r ->
val text = r.body?.string().orEmpty()
if (r.isSuccessful) {
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/goals/GoalFlow.kt b/android/src/android/app/src/main/java/io/github/nanomuse/goals/GoalFlow.kt
index b90c8c37..d3455b5b 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/goals/GoalFlow.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/goals/GoalFlow.kt
@@ -235,7 +235,7 @@ object GoalFlow {
val cadence = if (goal.checkEveryHours > 0) {
context.resources.getQuantityString(R.plurals.nm_goal_every_hours, goal.checkEveryHours, goal.checkEveryHours)
} else {
- context.getString(R.string.nm_goal_daily_at, "%02d:%02d".format(goal.checkHour, goal.checkMinute))
+ context.getString(R.string.nm_goal_daily_at, "%02d:%02d".format(java.util.Locale.ROOT, goal.checkHour, goal.checkMinute))
}
val next = goal.taskId?.let { ScheduledTaskManager(context).get(it) }?.nextTriggerMs()
return if (next != null) {
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/hub/HubClient.kt b/android/src/android/app/src/main/java/io/github/nanomuse/hub/HubClient.kt
index 27a76eee..6509e406 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/hub/HubClient.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/hub/HubClient.kt
@@ -48,7 +48,12 @@ data class Device(
class HubException(val code: String, message: String) : IOException(message)
-/** A call another device made to this phone; answer it exactly once. */
+/**
+ * A call another device made to this phone; answer it exactly once. A frame that cannot be
+ * sent because the socket dropped meanwhile is logged and lost, never thrown: the handlers run
+ * on worker threads and in their own coroutines, where an exception would end the process, and
+ * the relay fails the call to its caller on its own when no answer arrives.
+ */
class IncomingCall internal constructor(
val id: String,
val from: JSONObject,
@@ -63,19 +68,27 @@ class IncomingCall internal constructor(
val senderKind: String get() = from.optString("kind")
fun event(body: JSONObject) {
- if (!answered) client.send(JSONObject().put("type", "event").put("id", id).put("body", body))
+ if (!answered) trySend(JSONObject().put("type", "event").put("id", id).put("body", body))
}
fun result(body: JSONObject) {
if (answered) return
answered = true
- client.send(JSONObject().put("type", "result").put("id", id).put("ok", true).put("body", body))
+ trySend(JSONObject().put("type", "result").put("id", id).put("ok", true).put("body", body))
}
fun fail(code: String, message: String) {
if (answered) return
answered = true
- client.send(JSONObject().put("type", "result").put("id", id).put("ok", false).put("error", code).put("message", message))
+ trySend(JSONObject().put("type", "result").put("id", id).put("ok", false).put("error", code).put("message", message))
+ }
+
+ private fun trySend(frame: JSONObject) {
+ try {
+ client.send(frame)
+ } catch (e: HubException) {
+ AppLogger.info("HubClient", "$action for $senderName: ${frame.optString("type")} not sent, ${e.message}")
+ }
}
}
@@ -160,8 +173,10 @@ class HubClient(
connected = false
ws = null
failAll("disconnected", "the hub connection dropped")
- onState(false, reason)
+ // A stopped client says nothing more: its own close arrives here a moment after
+ // stop(), when Hub may already run a new client whose state this would overwrite.
if (stopped) return
+ onState(false, reason)
retry?.cancel(false)
val wait = maxOf(delayMs, atLeastMs)
retry = timer.schedule({ connect() }, wait, TimeUnit.MILLISECONDS)
@@ -185,6 +200,7 @@ class HubClient(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ if (stopped) return // our own close after stop(); see scheduleReconnect
if (code == 4001 || code == 4002) {
// Bad key or bad device: the relay will say the same thing next time. Stop here
// and say why; a new sign-in makes a new client.
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/net/OkHttpAwait.kt b/android/src/android/app/src/main/java/io/github/nanomuse/net/OkHttpAwait.kt
new file mode 100644
index 00000000..2a599cc8
--- /dev/null
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/net/OkHttpAwait.kt
@@ -0,0 +1,32 @@
+package io.github.nanomuse.net
+
+import java.io.IOException
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlinx.coroutines.suspendCancellableCoroutine
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.Response
+
+/**
+ * Runs the call and suspends until it answers, the way a coroutine expects: cancelling the
+ * coroutine cancels the call, which closes the socket at once. `execute()` inside
+ * `withContext(Dispatchers.IO)` does not notice cancellation; it keeps the IO thread parked in
+ * the read until OkHttp's own timeout, so leaving the sign-in page or the network page used to
+ * leave the request running for up to half a minute (#336).
+ *
+ * Throws [IOException] as `execute()` would, and `CancellationException` when the coroutine
+ * was cancelled; a response that arrives after the cancellation is closed, not leaked.
+ */
+suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
+ enqueue(object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ cont.resume(response) { response.close() }
+ }
+
+ override fun onFailure(call: Call, e: IOException) {
+ if (!cont.isCancelled) cont.resumeWithException(e)
+ }
+ })
+ cont.invokeOnCancellation { cancel() }
+}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/net/OwnProviderProxy.kt b/android/src/android/app/src/main/java/io/github/nanomuse/net/OwnProviderProxy.kt
index 1847723f..2f30cb84 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/net/OwnProviderProxy.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/net/OwnProviderProxy.kt
@@ -171,7 +171,7 @@ object OwnProviderProxy {
/**
* Fetches [url] through [config] (not through whatever is installed) and says what came
* back; a 2xx–4xx from the host counts as reached, since the point is the path, not the
- * page. Runs on IO.
+ * page. Runs on IO; cancelling the caller (the page was left) hangs up at once.
*/
suspend fun probe(config: Config, url: String): Probe = withContext(Dispatchers.IO) {
val host = hostOf(url)
@@ -186,7 +186,7 @@ object OwnProviderProxy {
.callTimeout(15, TimeUnit.SECONDS)
.followRedirects(false)
.build()
- client.newCall(Request.Builder().url(url).header("User-Agent", "nanoMuse").build()).execute().use { r ->
+ client.newCall(Request.Builder().url(url).header("User-Agent", "nanoMuse").build()).await().use { r ->
val body = if (r.code == 403) r.peekBody(4096).string() else ""
val reach = ProviderReach.fromHttp(r.code, body, host, oauth = false)
Probe(reach == null && r.code < 500, r.code, System.currentTimeMillis() - started, host, reach)
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AgentAvatar.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AgentAvatar.kt
index 41a9a455..3d421a00 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AgentAvatar.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AgentAvatar.kt
@@ -122,7 +122,8 @@ fun rememberAgentMood(isStreaming: Boolean, error: String?): AgentMood {
* The face at [size]: the built-in dragon, or the user's own picture set from Settings →
* Appearance. It cross-fades between moods and moves the way Muse's does — a slow breath at
* rest, a busy bob while working, a curious tilt while waiting, a pop when it is pleased and a
- * quick shake when something failed. Tapping it opens the appearance page.
+ * quick shake when something failed. Tapping it opens the appearance page. A clip of the
+ * person's own ([io.github.nanomuse.avatar.OwnClip]) plays over the still for every mood.
*/
@Composable
fun AgentAvatar(
@@ -134,6 +135,8 @@ fun AgentAvatar(
) {
val custom by AvatarStore.current.collectAsState()
val clips by io.github.nanomuse.avatar.AvatarMotion.clips.collectAsState()
+ val ownClip by io.github.nanomuse.avatar.OwnClip.current.collectAsState()
+ val ownPoster by io.github.nanomuse.avatar.OwnClip.poster.collectAsState()
val density = LocalDensity.current
val motion = rememberInfiniteTransition(label = "avatarMotion")
@@ -199,46 +202,66 @@ fun AgentAvatar(
rotationZ = rotation
}
.clip(CircleShape)
- Crossfade(
- targetState = mood,
- animationSpec = tween(durationMillis = 260),
- label = "avatarMood",
- modifier = if (onClick != null) base.clickable(onClick = onClick) else base,
- ) { current ->
- val set = custom
- if (set != null) {
- Image(
- bitmap = set.forMood(current),
- contentDescription = contentDescription,
- contentScale = ContentScale.Crop,
- modifier = Modifier.size(size),
- )
- // The clip for this mood, when the video model has made one: it plays over the
- // still and shows only once its first frame is on screen, so nothing flickers.
- val clip = clips[current]
- if (clip != null) {
- androidx.compose.runtime.key(clip.path) {
- LoopingClip(key = clip.path, modifier = Modifier.size(size)) { it.setDataSource(clip.path) }
+ val own = ownClip
+ androidx.compose.foundation.layout.Box(if (onClick != null) base.clickable(onClick = onClick) else base) {
+ Crossfade(
+ targetState = mood,
+ animationSpec = tween(durationMillis = 260),
+ label = "avatarMood",
+ modifier = Modifier.size(size),
+ ) { current ->
+ val set = custom
+ val poster = ownPoster
+ if (set != null) {
+ Image(
+ bitmap = set.forMood(current),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(size),
+ )
+ // The clip for this mood, when the video model has made one: it plays over the
+ // still and shows only once its first frame is on screen, so nothing flickers.
+ // A clip of the person's own stands in for all of them (below, outside the fade).
+ val clip = if (own == null) clips[current] else null
+ if (clip != null) {
+ androidx.compose.runtime.key(clip.path) {
+ LoopingClip(key = clip.path, modifier = Modifier.size(size)) { it.setDataSource(clip.path) }
+ }
}
- }
- } else {
- Image(
- painter = painterResource(current.drawable),
- contentDescription = contentDescription,
- contentScale = ContentScale.Crop,
- modifier = Modifier.size(size),
- )
- // The built-in character ships with its clips, so it moves without a video model.
- val res = current.motion
- if (res != null) {
- val context = androidx.compose.ui.platform.LocalContext.current
- androidx.compose.runtime.key(res) {
- LoopingClip(key = "raw:$res", modifier = Modifier.size(size)) { player ->
- context.resources.openRawResourceFd(res).use { fd -> player.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) }
+ } else if (own != null && poster != null) {
+ // No studio face: the clip's first frame is the still, for every mood.
+ Image(
+ bitmap = poster,
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(size),
+ )
+ } else {
+ Image(
+ painter = painterResource(current.drawable),
+ contentDescription = contentDescription,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(size),
+ )
+ // The built-in character ships with its clips, so it moves without a video model.
+ val res = if (own == null) current.motion else null
+ if (res != null) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ androidx.compose.runtime.key(res) {
+ LoopingClip(key = "raw:$res", modifier = Modifier.size(size)) { player ->
+ context.resources.openRawResourceFd(res).use { fd -> player.setDataSource(fd.fileDescriptor, fd.startOffset, fd.length) }
+ }
}
}
}
}
+ // The person's own clip (#324): one clip for every mood, so it sits outside the mood
+ // fade and keeps playing while the still under it changes.
+ if (own != null) {
+ androidx.compose.runtime.key(own.key) {
+ LoopingClip(key = own.key, modifier = Modifier.size(size)) { it.setDataSource(own.file.path) }
+ }
+ }
}
}
@@ -276,7 +299,7 @@ fun AgentAvatarDisc(
* frame renders, paused while the app is in the background, released with the surface.
*/
@Composable
-private fun LoopingClip(key: String, modifier: Modifier = Modifier, open: (android.media.MediaPlayer) -> Unit) {
+internal fun LoopingClip(key: String, modifier: Modifier = Modifier, open: (android.media.MediaPlayer) -> Unit) {
var ready by remember(key) { mutableStateOf(false) }
val player = remember(key) { android.media.MediaPlayer() }
val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarShareSheet.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarShareSheet.kt
index 48b4e993..97867d27 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarShareSheet.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarShareSheet.kt
@@ -4,7 +4,6 @@ import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -20,6 +19,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -119,11 +119,11 @@ fun AvatarShareSheet(onDismiss: () -> Unit) {
.aspectRatio(AvatarShare.WIDTH / AvatarShare.HEIGHT.toFloat())
.clip(shape)
.then(if (i == selected) Modifier.border(2.5.dp, MuseTones.action, shape) else Modifier)
- .clickable { selected = i },
+ .selectable(selected = i == selected, onClick = { selected = i }),
) {
Image(
bitmap = bmp.asImageBitmap(),
- contentDescription = null,
+ contentDescription = stringResource(R.string.nm_a11y_share_card, i + 1),
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize(),
)
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarStudioScreen.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarStudioScreen.kt
index aa5060f3..d0f2b7a3 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarStudioScreen.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/AvatarStudioScreen.kt
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -108,6 +109,10 @@ fun AvatarStudioScreen(onBack: () -> Unit, onOpenSoul: () -> Unit, onOpenMediaMo
var style by remember(savedStyle) { mutableStateOf(savedStyle) }
var menu by remember { mutableStateOf(false) }
var previewMood by remember { mutableStateOf(AgentMood.IDLE) }
+ // A clip of the person's own as the face (#324): picked here, previewed in a sheet.
+ val ownClip by io.github.nanomuse.avatar.OwnClip.current.collectAsState()
+ val clipChooser = rememberOwnClipChooser()
+ OwnClipChooserHost(clipChooser)
val generating = slots.any { it is AvatarStudio.Slot.Loading }
// Read each time: the media page may have changed it while this screen was below it.
val endpoint = ImageGen.endpoint(context)
@@ -140,12 +145,19 @@ fun AvatarStudioScreen(onBack: () -> Unit, onOpenSoul: () -> Unit, onOpenMediaMo
Box {
Surface(onClick = { menu = true }, shape = CircleShape, color = MuseTones.surface, border = BorderStroke(1.dp, MuseTones.hairline)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
- Icon(Icons.Filled.MoreHoriz, contentDescription = null, tint = MaterialTheme.colorScheme.onSurface)
+ Icon(Icons.Filled.MoreHoriz, contentDescription = stringResource(R.string.nm_more), tint = MaterialTheme.colorScheme.onSurface)
}
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text(stringResource(R.string.nm_avatar_menu_soul)) }, onClick = { menu = false; onOpenSoul() })
DropdownMenuItem(text = { Text(stringResource(R.string.nm_avatar_menu_model)) }, onClick = { menu = false; onOpenMediaModels() })
+ DropdownMenuItem(text = { Text(stringResource(R.string.nm_own_clip_menu)) }, onClick = { menu = false; clipChooser.choose() })
+ if (ownClip != null) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nm_own_clip_default)) },
+ onClick = { menu = false; io.github.nanomuse.avatar.OwnClip.clear() },
+ )
+ }
if (current != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.nm_avatar_menu_regenerate_moods)) },
@@ -315,6 +327,7 @@ fun AvatarStudioScreen(onBack: () -> Unit, onOpenSoul: () -> Unit, onOpenMediaMo
val i = row * 2 + col
CandidateTile(
slot = slots[i],
+ index = i,
selected = selected == i,
onClick = {
when (slots[i]) {
@@ -356,6 +369,52 @@ fun AvatarStudioScreen(onBack: () -> Unit, onOpenSoul: () -> Unit, onOpenMediaMo
}
}
}
+ item(key = "own-clip") {
+ Column(Modifier.padding(horizontal = 20.dp, vertical = 12.dp)) {
+ HorizontalDivider(color = MuseTones.hairline)
+ Spacer(Modifier.height(14.dp))
+ Text(stringResource(R.string.nm_own_clip_title), fontSize = 17.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ stringResource(if (ownClip != null) R.string.nm_own_clip_on else R.string.nm_own_clip_body),
+ fontSize = 13.sp,
+ lineHeight = 18.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ Button(
+ onClick = { clipChooser.choose() },
+ modifier = Modifier.weight(1f).height(44.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(containerColor = MuseTones.fill, contentColor = MaterialTheme.colorScheme.onSurface),
+ ) {
+ Text(
+ stringResource(if (ownClip != null) R.string.nm_own_clip_replace else R.string.nm_own_clip_choose),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+ if (ownClip != null) {
+ Button(
+ onClick = { io.github.nanomuse.avatar.OwnClip.clear() },
+ modifier = Modifier.weight(1f).height(44.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(containerColor = MuseTones.fill, contentColor = MaterialTheme.colorScheme.onSurface),
+ ) {
+ Text(stringResource(R.string.nm_own_clip_default), fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.nm_own_clip_local),
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
item(key = "footer") {
Text(
stringResource(R.string.nm_avatar_footer),
@@ -371,24 +430,25 @@ fun AvatarStudioScreen(onBack: () -> Unit, onOpenSoul: () -> Unit, onOpenMediaMo
}
@Composable
-private fun CandidateTile(slot: AvatarStudio.Slot, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
+private fun CandidateTile(slot: AvatarStudio.Slot, index: Int, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
val shape = RoundedCornerShape(20.dp)
val shimmer = rememberInfiniteTransition(label = "shimmer")
val a by shimmer.animateFloat(0.45f, 1f, infiniteRepeatable(tween(900), RepeatMode.Reverse), label = "shimmerAlpha")
+ // selectable, not clickable: a screen reader then says "selected" or "not selected" for the tile.
Box(
modifier
.aspectRatio(1f)
.clip(shape)
.background(MuseTones.fill)
.then(if (selected) Modifier.border(3.dp, MuseTones.action, shape) else Modifier)
- .clickable(onClick = onClick),
+ .selectable(selected = selected, onClick = onClick),
contentAlignment = Alignment.Center,
) {
when (slot) {
is AvatarStudio.Slot.Ready -> {
Image(
bitmap = slot.bitmap.asImageBitmap(),
- contentDescription = null,
+ contentDescription = stringResource(R.string.nm_a11y_avatar_candidate, index + 1),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/OwnClipChooser.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/OwnClipChooser.kt
new file mode 100644
index 00000000..24505fcd
--- /dev/null
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/avatar/OwnClipChooser.kt
@@ -0,0 +1,194 @@
+package io.github.nanomuse.ui.avatar
+
+import android.content.Context
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.PickVisualMediaRequest
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import com.openminis.app.R
+import io.github.nanomuse.avatar.OwnClip
+import io.github.nanomuse.ui.home.MuseTones
+import io.github.nanomuse.ui.home.avatarDiscColor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Picking a clip of one's own as the face (#324): the system's video picker, the checks, and
+ * the preview sheet with the clip playing in the face's own frame, *Use* and *Cancel*. The
+ * screen that offers it calls [OwnClipChooserState.choose] from a button or a menu and places
+ * [OwnClipChooserHost] once, the way a sheet is placed.
+ */
+class OwnClipChooserState internal constructor() {
+ internal var request by mutableIntStateOf(0)
+
+ /** Open the picker. */
+ fun choose() { request += 1 }
+}
+
+@Composable
+fun rememberOwnClipChooser(): OwnClipChooserState = remember { OwnClipChooserState() }
+
+/** The one sentence a refused clip gets, with the limits in it. */
+internal fun refusalText(context: Context, why: OwnClip.Refusal, probe: OwnClip.Probe): String = when (why) {
+ OwnClip.Refusal.FORMAT -> context.getString(R.string.nm_own_clip_refused_format)
+ OwnClip.Refusal.TOO_LONG -> context.getString(R.string.nm_own_clip_refused_long, ((probe.durationMs ?: 0L) / 1000L).toInt())
+ OwnClip.Refusal.TOO_LARGE -> context.getString(
+ R.string.nm_own_clip_refused_large,
+ // a null size: the copy stopped at the limit, so all that is known is "more than that"
+ probe.bytes?.let { android.text.format.Formatter.formatShortFileSize(context, it) }
+ ?: ("> " + android.text.format.Formatter.formatShortFileSize(context, OwnClip.MAX_BYTES)),
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OwnClipChooserHost(state: OwnClipChooserState) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var pending by remember { mutableStateOf(null) }
+ var message by remember { mutableStateOf(null) }
+ var busy by remember { mutableStateOf(false) }
+
+ val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
+ if (uri == null) return@rememberLauncherForActivityResult
+ busy = true
+ scope.launch {
+ val outcome = withContext(Dispatchers.IO) { OwnClip.import(context, uri) }
+ busy = false
+ when (outcome) {
+ // Every pending copy lives at the same path, and the import just replaced it:
+ // discarding the old one here would delete the new file.
+ is OwnClip.Outcome.Ready -> pending = outcome.pending
+ is OwnClip.Outcome.Refused -> message = refusalText(context, outcome.why, outcome.probe)
+ is OwnClip.Outcome.Failed -> message = context.getString(R.string.nm_own_clip_unreadable)
+ }
+ }
+ }
+ LaunchedEffect(state.request) {
+ if (state.request > 0) picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))
+ }
+
+ if (busy) {
+ Dialog(onDismissRequest = {}) {
+ Box(Modifier.size(72.dp).clip(CircleShape).background(MuseTones.surface), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp, color = MuseTones.action)
+ }
+ }
+ }
+
+ val p = pending
+ if (p != null) {
+ val sheet = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val cancel = { OwnClip.discard(p); pending = null }
+ ModalBottomSheet(onDismissRequest = cancel, sheetState = sheet, containerColor = MuseTones.canvas) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(stringResource(R.string.nm_own_clip_preview_title), fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface)
+ Spacer(Modifier.height(4.dp))
+ Text(
+ stringResource(R.string.nm_own_clip_preview_body),
+ fontSize = 13.sp,
+ lineHeight = 18.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(20.dp))
+ // The same disc the studio shows the face on: the first frame, then the clip over it.
+ Box(Modifier.size(148.dp).clip(CircleShape).background(avatarDiscColor()), contentAlignment = Alignment.Center) {
+ val poster = p.poster
+ if (poster != null) {
+ Image(
+ bitmap = poster.asImageBitmap(),
+ contentDescription = stringResource(R.string.nm_a11y_own_clip_preview),
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.size(148.dp),
+ )
+ }
+ LoopingClip(key = "pending:" + p.file.path, modifier = Modifier.size(148.dp)) { it.setDataSource(p.file.path) }
+ }
+ Spacer(Modifier.height(12.dp))
+ Text(
+ stringResource(
+ R.string.nm_own_clip_details,
+ ((p.durationMs + 500L) / 1000L).toInt(),
+ android.text.format.Formatter.formatShortFileSize(context, p.bytes),
+ ),
+ fontSize = 13.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(Modifier.height(20.dp))
+ Button(
+ onClick = { OwnClip.adopt(p); pending = null },
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(containerColor = MuseTones.action, contentColor = Color.White),
+ ) {
+ Text(stringResource(R.string.nm_own_clip_use), fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
+ }
+ Spacer(Modifier.height(4.dp))
+ TextButton(onClick = cancel, modifier = Modifier.fillMaxWidth()) {
+ Text(stringResource(R.string.nm_cancel), fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
+ }
+ Spacer(Modifier.height(8.dp))
+ Text(
+ stringResource(R.string.nm_own_clip_local),
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ }
+
+ val m = message
+ if (m != null) {
+ AlertDialog(
+ onDismissRequest = { message = null },
+ confirmButton = { TextButton(onClick = { message = null }) { Text(stringResource(android.R.string.ok)) } },
+ title = { Text(stringResource(R.string.nm_own_clip_title)) },
+ text = { Text(m) },
+ )
+ }
+}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmChatBottomLayout.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmChatBottomLayout.kt
new file mode 100644
index 00000000..bb43c17d
--- /dev/null
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmChatBottomLayout.kt
@@ -0,0 +1,54 @@
+package io.github.nanomuse.ui.chat
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.SubcomposeLayout
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+
+private enum class BottomSlot { Footer, Content }
+
+/**
+ * Overlays a footer above [bottomInset] without shortening the scrolling blur source.
+ * The footer is measured first, so [content] receives its complete bottom obstruction
+ * during the same layout pass, including tool previews and an expanded composer.
+ * The caller supplies a bounded chat pane and handles its IME insets.
+ */
+@Composable
+fun NmChatBottomLayout(
+ bottomInset: Dp,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.(bottomChromeHeight: Dp) -> Unit,
+ bottomContent: @Composable ColumnScope.() -> Unit,
+) {
+ SubcomposeLayout(modifier) { constraints ->
+ val width = constraints.maxWidth
+ val height = constraints.maxHeight
+ val inset = bottomInset.roundToPx().coerceIn(0, height)
+ val footer = subcompose(BottomSlot.Footer) {
+ Column(Modifier.fillMaxWidth(), content = bottomContent)
+ }.single().measure(
+ Constraints(
+ minWidth = width,
+ maxWidth = width,
+ maxHeight = height - inset,
+ ),
+ )
+ val bottomChromeHeight = footer.height + inset
+ val bottomChromeHeightDp = bottomChromeHeight.toDp()
+ val body = subcompose(BottomSlot.Content) {
+ Box(Modifier.fillMaxSize()) { content(bottomChromeHeightDp) }
+ }.single().measure(Constraints.fixed(width, height))
+
+ layout(width, height) {
+ body.placeRelative(0, 0)
+ footer.placeRelative(0, height - bottomChromeHeight)
+ }
+ }
+}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmHomeChrome.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmHomeChrome.kt
index 3ab84f19..53a1daad 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmHomeChrome.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/chat/NmHomeChrome.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import com.openminis.app.ui.settings.KEY_NM_HEADER_MODEL
import com.openminis.app.ui.settings.KEY_NM_SHOW_STEPS
import com.openminis.app.ui.settings.getAppearancePrefs
+import com.openminis.app.ui.theme.ChatColors
import com.openminis.app.ui.settings.headerModelEnabled
import com.openminis.app.ui.settings.showStepsEnabled
import io.github.nanomuse.ui.home.MuseTones
@@ -95,7 +96,8 @@ fun NmAssistantBubble(
modifier = Modifier
.widthIn(max = 340.dp)
.clip(RoundedCornerShape(20.dp))
- .background(MuseTones.bubble)
+ // The assistant's pill: a touch off the canvas, both themes.
+ .background(ChatColors.assistantBubble)
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
content()
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudAccountScreen.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudAccountScreen.kt
index 1003217a..d8f1cd97 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudAccountScreen.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudAccountScreen.kt
@@ -898,7 +898,11 @@ private fun PasswordDialog(hasPassword: Boolean, onDismiss: () -> Unit, onSaved:
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { show = !show }) {
- Icon(if (show) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, contentDescription = null, tint = muted)
+ Icon(
+ if (show) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
+ contentDescription = stringResource(if (show) R.string.nm_a11y_hide_password else R.string.nm_a11y_show_password),
+ tint = muted,
+ )
}
},
shape = RoundedCornerShape(12.dp),
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudSignInScreen.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudSignInScreen.kt
index 888e822d..80f33dc8 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudSignInScreen.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/cloud/CloudSignInScreen.kt
@@ -65,6 +65,7 @@ import io.github.nanomuse.cloud.RelayAddress
import io.github.nanomuse.cloud.SignInIdentifier
import io.github.nanomuse.ui.home.MuseTones
import io.github.nanomuse.ui.muse.MuseTopAppBar
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -125,6 +126,8 @@ fun CloudSignInScreen(
NanoMuseCloud.requestCode(context, identifier)
codeSent = true
countdown = 60
+ } catch (e: CancellationException) {
+ throw e // the page was left: the request is cancelled, not failed (#336)
} catch (e: Exception) {
error = NanoMuseCloud.describe(context, e)
}
@@ -140,6 +143,8 @@ fun CloudSignInScreen(
try {
NanoMuseCloud.verify(context, identifier, code, invite)
onSignedIn()
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
error = NanoMuseCloud.describe(context, e)
verifying = false
@@ -155,6 +160,8 @@ fun CloudSignInScreen(
try {
NanoMuseCloud.login(context, identifier, password)
onSignedIn()
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
error = NanoMuseCloud.describe(context, e)
verifying = false
@@ -296,7 +303,7 @@ fun CloudSignInScreen(
IconButton(onClick = { showPassword = !showPassword }) {
Icon(
if (showPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
- contentDescription = null,
+ contentDescription = stringResource(if (showPassword) R.string.nm_a11y_hide_password else R.string.nm_a11y_show_password),
tint = muted,
)
}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/feed/FeedTab.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/feed/FeedTab.kt
index 33491ea7..254d71bc 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/feed/FeedTab.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/feed/FeedTab.kt
@@ -60,9 +60,12 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.openminis.app.R
@@ -98,6 +101,7 @@ fun FeedTab(
header: @Composable () -> Unit,
onDiscuss: (FeedPost) -> Unit,
onEditRoutine: (String) -> Unit,
+ bottomInset: Dp = 0.dp,
) {
val context = LocalContext.current
val posts by FeedStore.posts.collectAsState()
@@ -114,7 +118,8 @@ fun FeedTab(
LazyColumn(
modifier = Modifier.fillMaxSize().background(MuseTones.canvas),
- contentPadding = PaddingValues(bottom = 28.dp),
+ // The last card ends above the floating pill, which overlays the page.
+ contentPadding = PaddingValues(bottom = 28.dp + bottomInset),
) {
item(key = "header") { header() }
@@ -262,6 +267,8 @@ private fun PostCard(
icon = if (post.liked) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
tint = heartTint,
label = null,
+ contentDescription = stringResource(if (post.liked) R.string.nm_a11y_unlike else R.string.nm_a11y_like),
+ stateDescription = stringResource(if (post.liked) R.string.nm_a11y_liked else R.string.nm_a11y_not_liked),
onClick = onLike,
iconModifier = Modifier.graphicsLayer { scaleX = heartScale.value; scaleY = heartScale.value },
)
@@ -270,30 +277,45 @@ private fun PostCard(
icon = Icons.Outlined.ChatBubbleOutline,
tint = MaterialTheme.colorScheme.onSurface,
label = stringResource(R.string.nm_feed_discuss),
+ contentDescription = null,
onClick = onDiscuss,
)
Spacer(Modifier.weight(1f))
- FooterAction(icon = Icons.Outlined.Info, tint = MaterialTheme.colorScheme.onSurface, label = null, onClick = onInfo)
+ FooterAction(
+ icon = Icons.Outlined.Info,
+ tint = MaterialTheme.colorScheme.onSurface,
+ label = null,
+ contentDescription = stringResource(R.string.nm_a11y_post_details),
+ onClick = onInfo,
+ )
}
}
}
}
+/**
+ * One action under a post. [label] is the visible text, if any; [contentDescription] is the
+ * name a screen reader gets for an icon-only action (null when the label already names it);
+ * [stateDescription] is read after the name for a toggle such as Like.
+ */
@Composable
private fun FooterAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
tint: Color,
label: String?,
+ contentDescription: String?,
onClick: () -> Unit,
+ stateDescription: String? = null,
iconModifier: Modifier = Modifier,
) {
Row(
modifier = Modifier
+ .then(if (stateDescription != null) Modifier.semantics { this.stateDescription = stateDescription } else Modifier)
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Icon(icon, contentDescription = label, tint = tint, modifier = iconModifier.size(22.dp))
+ Icon(icon, contentDescription = contentDescription, tint = tint, modifier = iconModifier.size(22.dp))
if (label != null) {
Spacer(Modifier.width(6.dp))
Text(label, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface)
@@ -346,7 +368,8 @@ private fun IntroCard(onEdit: () -> Unit, onAck: () -> Unit) {
private fun EmptyState(generating: Boolean, onWriteNow: () -> Unit) {
val context = LocalContext.current
val task = remember { FeedFlow.task(context) }
- val time = task?.let { "%02d:%02d".format(it.timeOfDayHour, it.timeOfDayMinute) } ?: "%02d:%02d".format(FeedFlow.DEFAULT_HOUR, FeedFlow.DEFAULT_MINUTE)
+ val time = task?.let { "%02d:%02d".format(java.util.Locale.ROOT, it.timeOfDayHour, it.timeOfDayMinute) }
+ ?: "%02d:%02d".format(java.util.Locale.ROOT, FeedFlow.DEFAULT_HOUR, FeedFlow.DEFAULT_MINUTE)
StaticCard("🖼️", stringResource(R.string.nm_feed_empty_title), stringResource(R.string.nm_feed_empty_body, time))
StaticCard("📝", stringResource(R.string.nm_feed_empty_how_title), stringResource(R.string.nm_feed_empty_how_body))
FeedCard {
@@ -434,7 +457,7 @@ private fun FeedSettingsSheet(onDismiss: () -> Unit, onEditRoutine: (String) ->
Row(Modifier.padding(horizontal = 14.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.nm_feed_routine_switch), fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurface)
- val time = task?.let { "%02d:%02d".format(it.timeOfDayHour, it.timeOfDayMinute) }
+ val time = task?.let { "%02d:%02d".format(java.util.Locale.ROOT, it.timeOfDayHour, it.timeOfDayMinute) }
Text(
if (time != null) stringResource(R.string.nm_feed_routine_time, time) else stringResource(R.string.nm_feed_routine_needs_model),
fontSize = 12.sp,
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/goals/GoalsTab.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/goals/GoalsTab.kt
index 79165236..76c04c06 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/goals/GoalsTab.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/goals/GoalsTab.kt
@@ -64,8 +64,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.openminis.app.R
@@ -100,6 +103,7 @@ fun GoalsTab(
onEditRoutine: (String?) -> Unit,
onRoutineRuns: (String) -> Unit,
onAllRoutines: () -> Unit,
+ bottomInset: Dp = 0.dp,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -129,7 +133,8 @@ fun GoalsTab(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().background(ChatColors.background),
- contentPadding = PaddingValues(bottom = 24.dp),
+ // The last row ends above the floating pill, which overlays the page.
+ contentPadding = PaddingValues(bottom = 24.dp + bottomInset),
) {
item(key = "header") { header() }
item(key = "title") {
@@ -305,7 +310,7 @@ private fun SectionHeader(
Text(label, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = labelColor, modifier = Modifier.weight(1f))
trailing?.invoke()
IconButton(onClick = onAdd) {
- Icon(Icons.Filled.Add, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.nm_a11y_add), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -343,13 +348,15 @@ private fun GoalRow(goal: Goal, onOpenSession: (String) -> Unit) {
)
.padding(start = 18.dp, end = 4.dp, top = 8.dp, bottom = 10.dp),
) {
+ // The name says what a tap does, the state says where the goal stands; the look is the same box.
+ val doneState = stringResource(if (done) R.string.nm_a11y_done else R.string.nm_a11y_not_done)
IconButton(
onClick = { GoalFlow.markDone(context, goal, !done) },
- modifier = Modifier.size(28.dp),
+ modifier = Modifier.size(28.dp).semantics { stateDescription = doneState },
) {
Icon(
if (done) Icons.Outlined.CheckBox else Icons.Outlined.CheckBoxOutlineBlank,
- contentDescription = null,
+ contentDescription = stringResource(if (done) R.string.nm_a11y_mark_not_done else R.string.nm_a11y_mark_done),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
@@ -540,7 +547,7 @@ private fun CategoryRow(category: GoalCategory, onClick: () -> Unit) {
modifier = Modifier.weight(1f),
)
IconButton(onClick = onClick) {
- Icon(Icons.Filled.Add, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.nm_a11y_add), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/MuseBottomBar.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/MuseBottomBar.kt
index 1720c13f..b942e960 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/MuseBottomBar.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/MuseBottomBar.kt
@@ -1,58 +1,135 @@
package io.github.nanomuse.ui.home
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import com.openminis.app.ui.theme.ChatColors
+import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.HazeStyle
+import dev.chrisbanes.haze.hazeEffect
/**
- * Muse's bottom bar: a plain white strip, five glyphs, the current one filled and
- * dark, the others outlined and softer. No labels, no indicator pill — the fill is the state.
+ * nanoMuse's bottom bar: a floating frosted-glass pill, five glyphs, icons only. The pill blurs
+ * whatever is behind it (Haze, in the Pixel taskbar's manner) and carries a hairline edge. The
+ * current tab shows a filled glyph on a soft indicator pill that springs in; the others are
+ * outlined. No labels: the fill and the indicator are the state.
*/
@Composable
fun MuseBottomBar(
selected: HomeTab,
onSelect: (HomeTab) -> Unit,
+ hazeState: HazeState,
modifier: Modifier = Modifier,
) {
- Column(
+ // Hoisted out of the hazeEffect block: Haze 1.x's block is not a @Composable scope,
+ // so MaterialTheme lookups cannot happen inside it.
+ val barBackground = MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.55f)
+ // The selected tab: a white disc with a black glyph in the dark theme,
+ // inverted in light. Follows the chat background, not the system setting,
+ // since the app has its own theme override.
+ val nmDark = MaterialTheme.colorScheme.background.luminance() < 0.5f
+ val nmSelectedBg = if (nmDark) Color.White else Color.Black
+ val nmSelectedFg = if (nmDark) Color.Black else Color.White
+ Box(
modifier = modifier
.fillMaxWidth()
- .background(ChatColors.background)
- .windowInsetsPadding(WindowInsets.navigationBars),
+ .windowInsetsPadding(WindowInsets.navigationBars)
+ .padding(bottom = 12.dp)
+ // The pill is as wide as the composer above it: the same 16 dp
+ // margins ChatScreen gives the home composer, so the two capsules
+ // stack with one edge. A wide screen caps it and centres it.
+ .padding(horizontal = PILL_INSET),
+ contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
+ // widthIn before fillMaxWidth: the other way round the fill
+ // pins the constraints and the cap has nothing to shrink.
+ .widthIn(max = PILL_MAX_WIDTH)
.fillMaxWidth()
- .height(56.dp),
- horizontalArrangement = Arrangement.SpaceEvenly,
+ // Clip first: it must wrap the haze drawing, otherwise the
+ // blurred rect paints unclipped and the pill's ends look square.
+ .clip(CircleShape)
+ .hazeEffect(state = hazeState) {
+ style = HazeStyle(
+ backgroundColor = barBackground,
+ blurRadius = 28.dp,
+ noiseFactor = 0.08f,
+ // tints named explicitly: HazeStyle has a twin constructor taking tint: HazeTint?
+ tints = emptyList(),
+ )
+ }
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+ shape = CircleShape,
+ )
+ .padding(horizontal = 14.dp, vertical = PILL_PADDING_V),
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HomeTab.entries.forEach { tab ->
val isSelected = tab == selected
- IconButton(onClick = { onSelect(tab) }) {
- Box(contentAlignment = Alignment.Center) {
+ val indicator by animateColorAsState(
+ targetValue = if (isSelected) nmSelectedBg
+ else Color.Transparent,
+ animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
+ label = "nmTabIndicator",
+ )
+ // Five slots share the pill's width; the indicator disc keeps
+ // its 60 x 46 inside the slot (narrower only when the slot is).
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .height(TAB_HEIGHT)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = { onSelect(tab) },
+ ),
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .widthIn(max = TAB_WIDTH)
+ .fillMaxWidth()
+ .height(TAB_HEIGHT)
+ .clip(CircleShape)
+ .background(indicator),
+ contentAlignment = Alignment.Center,
+ ) {
Icon(
imageVector = if (isSelected) tab.selectedIcon else tab.icon,
contentDescription = stringResource(tab.label),
- tint = if (isSelected) MaterialTheme.colorScheme.onSurface
- else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.72f),
+ tint = if (isSelected) nmSelectedFg
+ else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(26.dp),
)
}
@@ -61,3 +138,16 @@ fun MuseBottomBar(
}
}
}
+
+/** The composer's horizontal margin in the home shell (ChatScreen, `nmPill`). */
+private val PILL_INSET = 16.dp
+
+/** Wider screens get a centred pill of this width, not a bar across the window. */
+private val PILL_MAX_WIDTH = 420.dp
+
+/** Vertical padding inside the pill; the bar's height follows from it (46 + 2 x 6 = 58). */
+private val PILL_PADDING_V = 6.dp
+
+/** The indicator disc behind the selected glyph. */
+private val TAB_WIDTH = 60.dp
+private val TAB_HEIGHT = 46.dp
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/NanoMuseHome.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/NanoMuseHome.kt
index d9a0fbbb..31a9a3f9 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/NanoMuseHome.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/NanoMuseHome.kt
@@ -52,6 +52,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
+import dev.chrisbanes.haze.hazeSource
+import dev.chrisbanes.haze.rememberHazeState
import com.openminis.app.R
import com.openminis.app.agent.SoulStore
import com.openminis.app.data.repository.ChatRepository
@@ -329,13 +331,16 @@ fun NanoMuseHome(
// The drawer + tab shell, as a local composable so it can share every piece of state above
// and still be one branch of the phase switch below.
+ // nanoMuse: the frosted pill bar blurs this shell's content; the shell is the haze source.
+ val hazeState = rememberHazeState()
val homeShell: @Composable () -> Unit = {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen || tab == HomeTab.CHAT,
drawerContent = {
ModalDrawerSheet(
- drawerContainerColor = MuseTones.surface,
+ // nanoMuse: pure canvas like Muse's drawer, not the grey sheet.
+ drawerContainerColor = com.openminis.app.ui.theme.ChatColors.background,
drawerShape = androidx.compose.foundation.shape.RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp),
) {
SideChatDrawer(
@@ -360,7 +365,7 @@ fun NanoMuseHome(
containerColor = ChatColors.background,
contentWindowInsets = WindowInsets(0),
bottomBar = {
- MuseBottomBar(selected = tab, onSelect = { picked ->
+ MuseBottomBar(selected = tab, hazeState = hazeState, onSelect = { picked ->
if (picked == HomeTab.CHAT && tab == HomeTab.CHAT && !isMainChat) {
showMain()
} else {
@@ -371,10 +376,16 @@ fun NanoMuseHome(
},
) { padding ->
val bottom = padding.calculateBottomPadding()
+ // Scaffold measures the entire bar, including its floating gap and
+ // navigation inset, before laying out this content. Use that same
+ // inset for placement and IME consumption, without a frame-late callback.
Box(
modifier = Modifier
.fillMaxSize()
- .padding(bottom = bottom)
+ .hazeSource(hazeState)
+ // No bottom inset: the pill bar is an overlay and the content
+ // slides behind it for the frosted blur. The chat and each tab
+ // keep their own scrollable content above the pill.
.consumeWindowInsets(PaddingValues(bottom = bottom)),
) {
// Chat tab — always composed, hidden while another tab is on top.
@@ -415,6 +426,9 @@ fun NanoMuseHome(
},
onModelGroupsClick = { navController.safeNavigate(Routes.MODEL_GROUPS) },
nmHome = NmHomeChrome(isMainChat = isMainChat, onOpenDrawer = { openDrawer() }),
+ // nanoMuse: preserve the full-size blur source, but keep
+ // the chat footer above the bar measured by Scaffold.
+ nmBottomOverlay = bottom,
)
}
}
@@ -432,6 +446,9 @@ fun NanoMuseHome(
) {
Surface(
color = ChatColors.background,
+ // No bottom padding: tab content flows behind the pill
+ // overlay like the chat does. Each tab ends its list
+ // above the pill through bottomInset.
modifier = Modifier.fillMaxSize(),
) {
// Pages cross-fade; the header sits at the same spot on every page,
@@ -463,12 +480,14 @@ fun NanoMuseHome(
header = header,
onDiscuss = { discussPost(it) },
onEditRoutine = { navController.safeNavigate(Routes.scheduledTaskEdit(it)) },
+ bottomInset = bottom,
)
HomeTab.IDEAS -> IdeasTab(
header = header,
onSendToChat = { sendToMainChat(it) },
onCreateRoutine = { createRoutine(it) },
onStartGoal = { category, seed -> startGoal(category, seed) },
+ bottomInset = bottom,
)
HomeTab.GOALS -> GoalsTab(
header = header,
@@ -477,6 +496,7 @@ fun NanoMuseHome(
onEditRoutine = { navController.safeNavigate(Routes.scheduledTaskEdit(it)) },
onRoutineRuns = { navController.safeNavigate(Routes.scheduledTaskRuns(it)) },
onAllRoutines = { navController.safeNavigate(Routes.SCHEDULED_TASKS) },
+ bottomInset = bottom,
)
HomeTab.LIBRARY -> LibraryTab(
header = header,
@@ -486,6 +506,7 @@ fun NanoMuseHome(
navController.safeNavigate(Routes.FILE_PREVIEW)
},
onOpenSession = { showSession(it) },
+ bottomInset = bottom,
)
HomeTab.CHAT -> Unit
}
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/SideChatDrawer.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/SideChatDrawer.kt
index a121ea87..1718bc3e 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/SideChatDrawer.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/home/SideChatDrawer.kt
@@ -48,6 +48,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -59,6 +60,7 @@ import androidx.compose.ui.unit.sp
import com.openminis.app.R
import com.openminis.app.data.db.ChatSessionEntity
import com.openminis.app.data.repository.ChatRepository
+import com.openminis.app.ui.theme.ChatColors
import java.text.DateFormat
import java.util.Date
@@ -108,16 +110,22 @@ fun SideChatDrawer(
Column(
modifier = Modifier
.fillMaxSize()
- .background(MuseTones.surface)
+ .background(ChatColors.background)
.statusBarsPadding()
.navigationBarsPadding(),
) {
+ // nanoMuse: Muse's drawer voice: the agent name big and plain, rows in
+ // quiet pills, everything monochrome.
+ val nmDark = ChatColors.isDark
+ val nmRowSelected = if (nmDark) Color.White.copy(alpha = 0.09f) else Color.Black.copy(alpha = 0.06f)
+ val nmInk = ChatColors.primaryText
+ val nmMuted = ChatColors.secondaryText
Text(
text = agentName,
- fontSize = 24.sp,
+ fontSize = 28.sp,
fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(start = 20.dp, top = 18.dp, end = 20.dp, bottom = 14.dp),
+ color = nmInk,
+ modifier = Modifier.padding(start = 20.dp, top = 22.dp, end = 20.dp, bottom = 18.dp),
)
// Main chat row — selected when it is what the chat tab shows.
@@ -127,18 +135,18 @@ fun SideChatDrawer(
modifier = Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
- .background(if (mainSelected) MuseTones.fill else MuseTones.surface)
+ .clip(RoundedCornerShape(12.dp))
+ .background(if (mainSelected) nmRowSelected else Color.Transparent)
.clickable(onClick = onOpenMain)
- .padding(horizontal = 14.dp, vertical = 14.dp),
+ .padding(horizontal = 14.dp, vertical = 13.dp),
) {
- Icon(Icons.Outlined.Home, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurface)
- Spacer(Modifier.size(12.dp))
+ Icon(Icons.Outlined.Home, contentDescription = null, modifier = Modifier.size(22.dp), tint = nmInk)
+ Spacer(Modifier.size(14.dp))
Text(
text = stringResource(R.string.nm_drawer_main_chat),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
+ color = nmInk,
)
}
@@ -149,17 +157,17 @@ fun SideChatDrawer(
modifier = Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
+ .clip(RoundedCornerShape(12.dp))
.clickable(onClick = onDevices)
- .padding(horizontal = 14.dp, vertical = 14.dp),
+ .padding(horizontal = 14.dp, vertical = 13.dp),
) {
- Icon(Icons.Outlined.Devices, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurface)
- Spacer(Modifier.size(12.dp))
+ Icon(Icons.Outlined.Devices, contentDescription = null, modifier = Modifier.size(22.dp), tint = nmInk)
+ Spacer(Modifier.size(14.dp))
Text(
text = stringResource(R.string.nm_devices_title),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
+ color = nmInk,
modifier = Modifier.weight(1f),
)
Text(
@@ -169,7 +177,7 @@ fun SideChatDrawer(
else -> pluralStringResource(R.plurals.nm_hub_service_devices, othersOnline, othersOnline)
},
fontSize = 13.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = nmMuted,
)
}
}
@@ -181,43 +189,44 @@ fun SideChatDrawer(
modifier = Modifier
.padding(horizontal = 12.dp)
.fillMaxWidth()
- .clip(RoundedCornerShape(14.dp))
+ .clip(RoundedCornerShape(12.dp))
.clickable(onClick = onCoding)
- .padding(horizontal = 14.dp, vertical = 14.dp),
+ .padding(horizontal = 14.dp, vertical = 13.dp),
) {
- Icon(Icons.Outlined.Terminal, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurface)
- Spacer(Modifier.size(12.dp))
+ Icon(Icons.Outlined.Terminal, contentDescription = null, modifier = Modifier.size(22.dp), tint = nmInk)
+ Spacer(Modifier.size(14.dp))
Text(
text = stringResource(R.string.nm_coding_title),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurface,
+ color = nmInk,
modifier = Modifier.weight(1f),
)
Text(
text = codingComputers.toString(),
fontSize = 13.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = nmMuted,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.padding(start = 26.dp, end = 12.dp, top = 12.dp),
+ modifier = Modifier.padding(start = 26.dp, end = 12.dp, top = 14.dp),
) {
Text(
- text = stringResource(R.string.nm_drawer_side_chats),
- fontSize = 14.sp,
- fontWeight = FontWeight.Medium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = stringResource(R.string.nm_drawer_side_chats).uppercase(),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 1.sp,
+ color = nmMuted,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAllChats) {
Icon(
Icons.Outlined.Archive,
contentDescription = stringResource(R.string.nm_drawer_all_chats),
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ tint = nmMuted,
modifier = Modifier.size(20.dp),
)
}
@@ -232,7 +241,7 @@ fun SideChatDrawer(
Icon(
Icons.Outlined.Forum,
contentDescription = null,
- tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ tint = nmMuted,
modifier = Modifier.size(30.dp),
)
Spacer(Modifier.height(10.dp))
@@ -240,13 +249,13 @@ fun SideChatDrawer(
text = stringResource(R.string.nm_drawer_empty_title),
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
- color = MaterialTheme.colorScheme.onSurface,
+ color = nmInk,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.nm_drawer_empty_body),
fontSize = 13.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = nmMuted,
textAlign = TextAlign.Center,
)
}
@@ -256,6 +265,9 @@ fun SideChatDrawer(
SideChatRow(
session = session,
selected = session.id == currentSessionId,
+ selectedFill = nmRowSelected,
+ ink = nmInk,
+ muted = nmMuted,
onClick = { onOpenSession(session.id) },
onSetMain = { onSetMain(session.id) },
)
@@ -263,37 +275,53 @@ fun SideChatDrawer(
}
}
- // Bottom strip: settings · search · new
+ // Bottom strip: settings · search · new, Muse's circular buttons round a search pill.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
- .padding(start = 8.dp, end = 8.dp, bottom = 8.dp, top = 6.dp),
+ .padding(start = 12.dp, end = 12.dp, bottom = 10.dp, top = 6.dp),
) {
- IconButton(onClick = onSettings) {
- Icon(Icons.Outlined.Settings, contentDescription = stringResource(R.string.nm_drawer_settings), tint = MaterialTheme.colorScheme.onSurface)
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(nmRowSelected)
+ .clickable(onClick = onSettings),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Settings, contentDescription = stringResource(R.string.nm_drawer_settings), tint = nmInk, modifier = Modifier.size(22.dp))
}
if (onSystemFiles != null) {
- IconButton(onClick = onSystemFiles) {
- Icon(Icons.Outlined.Description, contentDescription = stringResource(R.string.nm_sysfiles_title), tint = MaterialTheme.colorScheme.onSurface)
+ Spacer(Modifier.size(8.dp))
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(nmRowSelected)
+ .clickable(onClick = onSystemFiles),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Description, contentDescription = stringResource(R.string.nm_sysfiles_title), tint = nmInk, modifier = Modifier.size(22.dp))
}
}
+ Spacer(Modifier.size(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
- .height(40.dp)
+ .height(44.dp)
.clip(CircleShape)
- .background(MuseTones.fill)
- .padding(horizontal = 12.dp),
+ .background(nmRowSelected)
+ .padding(horizontal = 14.dp),
) {
- Icon(Icons.Outlined.Search, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
+ Icon(Icons.Outlined.Search, contentDescription = null, tint = nmMuted, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Box(Modifier.weight(1f)) {
if (query.isEmpty()) {
Text(
stringResource(R.string.nm_drawer_search),
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = nmMuted,
fontSize = 15.sp,
)
}
@@ -301,14 +329,22 @@ fun SideChatDrawer(
value = query,
onValueChange = { query = it },
singleLine = true,
- textStyle = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.onSurface, fontSize = 15.sp),
- cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ textStyle = LocalTextStyle.current.copy(color = nmInk, fontSize = 15.sp),
+ cursorBrush = SolidColor(nmInk),
modifier = Modifier.fillMaxWidth(),
)
}
}
- IconButton(onClick = onNewChat) {
- Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.nm_drawer_new_chat), tint = MaterialTheme.colorScheme.onSurface)
+ Spacer(Modifier.size(8.dp))
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(nmRowSelected)
+ .clickable(onClick = onNewChat),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.nm_drawer_new_chat), tint = nmInk, modifier = Modifier.size(22.dp))
}
}
}
@@ -319,6 +355,9 @@ fun SideChatDrawer(
private fun SideChatRow(
session: ChatSessionEntity,
selected: Boolean,
+ selectedFill: androidx.compose.ui.graphics.Color,
+ ink: androidx.compose.ui.graphics.Color,
+ muted: androidx.compose.ui.graphics.Color,
onClick: () -> Unit,
onSetMain: () -> Unit,
) {
@@ -330,7 +369,7 @@ private fun SideChatRow(
.padding(horizontal = 12.dp, vertical = 1.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
- .background(if (selected) MuseTones.fill else MuseTones.surface)
+ .background(if (selected) selectedFill else Color.Transparent)
.combinedClickable(onClick = onClick, onLongClick = { menu = true })
.padding(horizontal = 14.dp, vertical = 11.dp),
) {
@@ -338,7 +377,7 @@ private fun SideChatRow(
Text(
text = session.title?.takeIf { it.isNotBlank() } ?: stringResource(R.string.nm_drawer_untitled),
fontSize = 15.sp,
- color = MaterialTheme.colorScheme.onSurface,
+ color = ink,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -347,7 +386,7 @@ private fun SideChatRow(
Text(
text = relativeDay(androidx.compose.ui.platform.LocalContext.current, session.updatedAt),
fontSize = 12.sp,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ color = muted,
)
}
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/ideas/IdeasTab.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/ideas/IdeasTab.kt
index c0a3efaf..6960449e 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/ideas/IdeasTab.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/ideas/IdeasTab.kt
@@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.openminis.app.R
@@ -65,6 +66,7 @@ fun IdeasTab(
onSendToChat: (String) -> Unit,
onCreateRoutine: (Idea) -> Unit,
onStartGoal: (GoalCategory, String) -> Unit,
+ bottomInset: Dp = 0.dp,
) {
val context = LocalContext.current
val sections = remember { Ideas.load(context) }
@@ -72,7 +74,8 @@ fun IdeasTab(
LazyColumn(
modifier = Modifier.fillMaxSize().background(ChatColors.background),
- contentPadding = PaddingValues(bottom = 24.dp),
+ // The last row ends above the floating pill, which overlays the page.
+ contentPadding = PaddingValues(bottom = 24.dp + bottomInset),
) {
item(key = "header") { header() }
item(key = "title") { io.github.nanomuse.ui.home.MusePageTitle(stringResource(R.string.nm_ideas_title)) }
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/library/LibraryTab.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/library/LibraryTab.kt
index 3e828bf8..a9bd4312 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/library/LibraryTab.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/library/LibraryTab.kt
@@ -57,6 +57,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
@@ -84,6 +85,7 @@ fun LibraryTab(
chatRepository: ChatRepository,
onPreview: (FileItem) -> Unit,
onOpenSession: (String) -> Unit,
+ bottomInset: Dp = 0.dp,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -105,7 +107,8 @@ fun LibraryTab(
LazyColumn(
modifier = Modifier.fillMaxSize().background(ChatColors.background),
- contentPadding = PaddingValues(bottom = 24.dp),
+ // The last row ends above the floating pill, which overlays the page.
+ contentPadding = PaddingValues(bottom = 24.dp + bottomInset),
) {
item(key = "header") { header() }
item(key = "segments") {
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/onboarding/FirstRunSetup.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/onboarding/FirstRunSetup.kt
index e6a036bf..7db67c2f 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/onboarding/FirstRunSetup.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/onboarding/FirstRunSetup.kt
@@ -356,7 +356,11 @@ private fun PasswordPage(onDone: () -> Unit) {
colors = fieldColors,
trailingIcon = {
IconButton(onClick = { show = !show }) {
- Icon(if (show) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(
+ if (show) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility,
+ contentDescription = stringResource(if (show) R.string.nm_a11y_hide_password else R.string.nm_a11y_show_password),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
}
},
shape = RoundedCornerShape(12.dp),
diff --git a/android/src/android/app/src/main/java/io/github/nanomuse/ui/profile/AgentProfileScreen.kt b/android/src/android/app/src/main/java/io/github/nanomuse/ui/profile/AgentProfileScreen.kt
index f07cee66..a96a9ce5 100644
--- a/android/src/android/app/src/main/java/io/github/nanomuse/ui/profile/AgentProfileScreen.kt
+++ b/android/src/android/app/src/main/java/io/github/nanomuse/ui/profile/AgentProfileScreen.kt
@@ -72,6 +72,8 @@ import io.github.nanomuse.sysfiles.SystemFiles
import io.github.nanomuse.ui.avatar.AgentAvatar
import io.github.nanomuse.ui.avatar.AgentMood
import io.github.nanomuse.ui.avatar.AvatarShareSheet
+import io.github.nanomuse.ui.avatar.OwnClipChooserHost
+import io.github.nanomuse.ui.avatar.rememberOwnClipChooser
import io.github.nanomuse.ui.home.MuseRoundButton
import io.github.nanomuse.ui.home.MuseTones
import kotlinx.coroutines.Dispatchers
@@ -86,7 +88,7 @@ const val ROUTE_AGENT_PROFILE = "nanomuse/profile"
* Muse's agent page — tap the face to get here: the face with its edit badge, the name,
* "online", and four panes: what it did today, the approvals it holds, its daily routines,
* and its soul & memory. The pen on the face offers "Change avatar" (back to the chat with the
- * request pre-typed) and "Edit name".
+ * request pre-typed), "Edit name", a clip of your own as the face, and the studio.
*/
@Composable
fun AgentProfileScreen(
@@ -106,6 +108,9 @@ fun AgentProfileScreen(
var pane by rememberSaveable { mutableIntStateOf(0) }
var penMenu by remember { mutableStateOf(false) }
var share by remember { mutableStateOf(false) }
+ // A clip of the person's own as the face (#324), offered under the pen as well.
+ val ownClip by io.github.nanomuse.avatar.OwnClip.current.collectAsState()
+ val clipChooser = rememberOwnClipChooser()
Scaffold(containerColor = MuseTones.canvas) { padding ->
Column(
@@ -158,6 +163,16 @@ fun AgentProfileScreen(
text = { Text(stringResource(R.string.nm_profile_edit_name)) },
onClick = { penMenu = false; onEditName() },
)
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nm_own_clip_menu)) },
+ onClick = { penMenu = false; clipChooser.choose() },
+ )
+ if (ownClip != null) {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.nm_own_clip_default)) },
+ onClick = { penMenu = false; io.github.nanomuse.avatar.OwnClip.clear() },
+ )
+ }
DropdownMenuItem(
text = { Text(stringResource(R.string.nm_profile_avatar_studio), color = MaterialTheme.colorScheme.onSurfaceVariant) },
onClick = { penMenu = false; onOpenAvatarStudio() },
@@ -195,6 +210,7 @@ fun AgentProfileScreen(
}
}
if (share) AvatarShareSheet(onDismiss = { share = false })
+ OwnClipChooserHost(clipChooser)
}
/** Muse's four-way segmented control: glyphs on a white bar, a grey pill under the current one. */
diff --git a/android/src/android/app/src/main/res/values-de/nm_strings.xml b/android/src/android/app/src/main/res/values-de/nm_strings.xml
index b640b2bf..82149500 100644
--- a/android/src/android/app/src/main/res/values-de/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-de/nm_strings.xml
@@ -351,6 +351,23 @@
Dieses verwenden
Die Stimmungen (arbeitet, wartet, fertig, hoppla) werden als Nächstes daraus gezeichnet, im Hintergrund.
Bilder liegen auf diesem Telefon unter minis-global/nanomuse/avatar. Jedes Gesicht kostet fünf Bilder bei deinem Anbieter: das gewählte plus vier Stimmungen.
+ Dein eigener Clip
+ Ein kurzes Video von dir läuft als Gesicht im Ruhezustand, und die anderen Stimmungen greifen darauf zurück. MP4 oder MOV mit H.264-Video, höchstens 15 Sekunden und 30 MB; es läuft stumm in einer Schleife.
+ Dein Clip ist aktiv und läuft für jede Stimmung; wo ein Standbild gezeigt wird, bleiben die gezeichneten Bilder. „Standardgesicht“ entfernt ihn.
+ Der Clip bleibt auf diesem Telefon. Er wird weder zu nanoMuse Cloud hochgeladen noch mit deinen anderen Geräten synchronisiert.
+ Clip auswählen
+ Clip ersetzen
+ Standardgesicht
+ Dein eigener Clip…
+ Vorschau
+ So läuft er als Gesicht: stumm, in einer Schleife, auf den Kreis zugeschnitten.
+ %1$d s · %2$s
+ Verwenden
+ Diese Datei kann hier nicht verwendet werden. Wähle ein MP4 oder MOV mit H.264-Video, höchstens 15 Sekunden und 30 MB.
+ Dieser Clip dauert %1$d Sekunden. Die Grenze liegt bei 15 Sekunden und 30 MB.
+ Dieser Clip hat %1$s. Die Grenze liegt bei 30 MB und 15 Sekunden.
+ Der Clip konnte nicht gelesen werden. Versuche einen anderen.
+ Vorschau deines Clips
Willkommen bei nanoMuse
@@ -1107,4 +1124,19 @@
Modelle suchen
Suche löschen
Kein Modell passt
+
+ Passwort anzeigen
+ Passwort verbergen
+ Kandidat %1$d
+ Karte %1$d
+ Hinzufügen
+ Als erledigt markieren
+ Als nicht erledigt markieren
+ Erledigt
+ Nicht erledigt
+ Gefällt mir
+ Gefällt mir nicht mehr
+ Gefällt mir
+ Gefällt mir nicht
+ Beitragsdetails
diff --git a/android/src/android/app/src/main/res/values-es/nm_strings.xml b/android/src/android/app/src/main/res/values-es/nm_strings.xml
index e53cafaa..85a70e4c 100644
--- a/android/src/android/app/src/main/res/values-es/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-es/nm_strings.xml
@@ -351,6 +351,23 @@
Usar esta
Los estados de ánimo (trabajando, esperando, hecho, ups) se dibujan a partir de ella después, en segundo plano.
Las imágenes se guardan en este teléfono en minis-global/nanomuse/avatar. Cada cara cuesta cinco imágenes de tu proveedor: la que eliges más cuatro estados de ánimo.
+ Tu propio clip
+ Un vídeo corto tuyo se reproduce como la cara en reposo, y los demás estados recurren a él. MP4 o MOV con vídeo H.264, de hasta 15 segundos y 30 MB; se reproduce sin sonido en bucle.
+ Tu clip está activo y se reproduce en todos los estados; donde se muestra una imagen fija siguen usándose las dibujadas. «Cara predeterminada» lo quita.
+ El clip se queda en este teléfono. No se sube a nanoMuse Cloud ni se sincroniza con tus otros dispositivos.
+ Elegir un clip
+ Sustituir el clip
+ Cara predeterminada
+ Tu propio clip…
+ Vista previa
+ Así se reproduce como cara: sin sonido, en bucle, recortado al círculo.
+ %1$d s · %2$s
+ Usar
+ Ese archivo no se puede usar aquí. Elige un MP4 o MOV con vídeo H.264, de hasta 15 segundos y 30 MB.
+ Ese clip dura %1$d segundos. El límite es 15 segundos, y 30 MB.
+ Ese clip ocupa %1$s. El límite es 30 MB, y 15 segundos.
+ No se pudo leer el clip. Prueba con otro.
+ Vista previa de tu clip
Te damos la bienvenida a nanoMuse
@@ -1107,4 +1124,19 @@
Buscar modelos
Borrar la búsqueda
Ningún modelo coincide
+
+ Mostrar contraseña
+ Ocultar contraseña
+ Candidato %1$d
+ Tarjeta %1$d
+ Añadir
+ Marcar como hecho
+ Marcar como no hecho
+ Hecho
+ No hecho
+ Me gusta
+ Quitar me gusta
+ Te gusta
+ No te gusta
+ Detalles de la publicación
diff --git a/android/src/android/app/src/main/res/values-fil/nm_strings.xml b/android/src/android/app/src/main/res/values-fil/nm_strings.xml
index 60fa1540..c73ad6fb 100644
--- a/android/src/android/app/src/main/res/values-fil/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-fil/nm_strings.xml
@@ -351,6 +351,23 @@
Gamitin ito
Ang mga mood (gumagawa, naghihintay, tapos na, nagkamali) ay iguguhit mula rito sa susunod, sa background.
Nakatago ang mga larawan sa teleponong ito sa ilalim ng minis-global/nanomuse/avatar. Ang bawat mukha ay nagkakahalaga ng limang larawan mula sa provider mo: isang pinili mo at apat na mood.
+ Ang sarili mong clip
+ Isang maikling video mo ang tumutugtog bilang mukha kapag nakapahinga, at ginagamit din ito ng ibang mood. MP4 o MOV na may H.264 na video, hanggang 15 segundo at 30 MB; naka-mute at paulit-ulit itong tumutugtog.
+ Naka-on ang clip mo at tumutugtog ito sa bawat mood; kung saan ipinapakita ang isang still, ginagamit pa rin ang mga iginuhit na larawan. Inaalis ito ng “Default na mukha”.
+ Nananatili ang clip sa teleponong ito. Hindi ito ina-upload sa nanoMuse Cloud o sini-sync sa iba mong device.
+ Pumili ng clip
+ Palitan ang clip
+ Default na mukha
+ Ang sarili mong clip…
+ Preview
+ Ganito ito tumutugtog bilang mukha: naka-mute, paulit-ulit, pinutol sa bilog.
+ %1$d s · %2$s
+ Gamitin
+ Hindi magagamit ang file na iyon dito. Pumili ng MP4 o MOV na may H.264 na video, hanggang 15 segundo at 30 MB.
+ %1$d segundo ang clip na iyon. Ang limitasyon ay 15 segundo, at 30 MB.
+ %1$s ang clip na iyon. Ang limitasyon ay 30 MB, at 15 segundo.
+ Hindi mabasa ang clip. Sumubok ng iba.
+ Preview ng clip mo
Maligayang pagdating sa nanoMuse
@@ -1107,4 +1124,19 @@
Maghanap ng mga modelo
I-clear ang paghahanap
Walang tumutugmang modelo
+
+ Ipakita ang password
+ Itago ang password
+ Kandidato %1$d
+ Card %1$d
+ Idagdag
+ Markahan bilang tapos na
+ Markahan bilang hindi pa tapos
+ Tapos na
+ Hindi pa tapos
+ Gusto
+ Alisin ang gusto
+ Nagustuhan
+ Hindi nagustuhan
+ Mga detalye ng post
diff --git a/android/src/android/app/src/main/res/values-fr/nm_strings.xml b/android/src/android/app/src/main/res/values-fr/nm_strings.xml
index f559d719..8a742e3c 100644
--- a/android/src/android/app/src/main/res/values-fr/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-fr/nm_strings.xml
@@ -351,6 +351,23 @@
Utiliser celui-ci
Les humeurs (au travail, en attente, terminé, oups) en seront dessinées ensuite, en arrière-plan.
Les images sont stockées sur ce téléphone sous minis-global/nanomuse/avatar. Chaque visage coûte cinq images chez votre fournisseur : celle que vous avez choisie plus quatre humeurs.
+ Votre propre clip
+ Une courte vidéo à vous s\'affiche comme visage au repos, et les autres humeurs s\'y rabattent. MP4 ou MOV en vidéo H.264, 15 secondes et 30 Mo au plus ; elle tourne en boucle, sans le son.
+ Votre clip est actif et joue pour chaque humeur ; là où une image fixe est montrée, les images dessinées restent. « Visage par défaut » le retire.
+ Le clip reste sur ce téléphone. Il n\'est ni envoyé à nanoMuse Cloud ni synchronisé avec vos autres appareils.
+ Choisir un clip
+ Remplacer le clip
+ Visage par défaut
+ Votre propre clip…
+ Aperçu
+ Voici comment il joue comme visage : sans le son, en boucle, recadré dans le cercle.
+ %1$d s · %2$s
+ Utiliser
+ Ce fichier ne peut pas être utilisé ici. Choisissez un MP4 ou MOV en vidéo H.264, 15 secondes et 30 Mo au plus.
+ Ce clip dure %1$d secondes. La limite est de 15 secondes, et 30 Mo.
+ Ce clip pèse %1$s. La limite est de 30 Mo, et 15 secondes.
+ Le clip n\'a pas pu être lu. Essayez-en un autre.
+ Aperçu de votre clip
Bienvenue dans nanoMuse
@@ -1107,4 +1124,19 @@
Rechercher des modèles
Effacer la recherche
Aucun modèle ne correspond
+
+ Afficher le mot de passe
+ Masquer le mot de passe
+ Candidat %1$d
+ Carte %1$d
+ Ajouter
+ Marquer comme terminé
+ Marquer comme non terminé
+ Terminé
+ Non terminé
+ J\'aime
+ Je n\'aime plus
+ Aimé
+ Non aimé
+ Détails de la publication
diff --git a/android/src/android/app/src/main/res/values-in/nm_strings.xml b/android/src/android/app/src/main/res/values-in/nm_strings.xml
index 208e54a4..c49bb09b 100644
--- a/android/src/android/app/src/main/res/values-in/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-in/nm_strings.xml
@@ -348,6 +348,23 @@
Pakai yang ini
Ekspresinya (bekerja, menunggu, selesai, ups) digambar dari gambar ini berikutnya, di latar belakang.
Gambar disimpan di ponsel ini di minis-global/nanomuse/avatar. Setiap wajah memakai lima gambar dari penyediamu: satu yang kamu pilih ditambah empat ekspresi.
+ Klip Anda sendiri
+ Video pendek milik Anda diputar sebagai wajah saat diam, dan suasana lain memakainya juga. MP4 atau MOV dengan video H.264, paling lama 15 detik dan 30 MB; diputar tanpa suara, berulang.
+ Klip Anda aktif dan diputar untuk setiap suasana; di tempat gambar diam ditampilkan, gambar yang digambar tetap dipakai. “Wajah bawaan” menghapusnya.
+ Klip tetap di ponsel ini. Tidak diunggah ke nanoMuse Cloud dan tidak disinkronkan ke perangkat Anda yang lain.
+ Pilih klip
+ Ganti klip
+ Wajah bawaan
+ Klip Anda sendiri…
+ Pratinjau
+ Beginilah klip diputar sebagai wajah: tanpa suara, berulang, dipotong ke lingkaran.
+ %1$d dtk · %2$s
+ Gunakan
+ File itu tidak bisa dipakai di sini. Pilih MP4 atau MOV dengan video H.264, paling lama 15 detik dan 30 MB.
+ Klip itu berdurasi %1$d detik. Batasnya 15 detik, dan 30 MB.
+ Klip itu berukuran %1$s. Batasnya 30 MB, dan 15 detik.
+ Klip tidak bisa dibaca. Coba yang lain.
+ Pratinjau klip Anda
Selamat datang di nanoMuse
@@ -1103,4 +1120,19 @@
Cari model
Hapus pencarian
Tidak ada model yang cocok
+
+ Tampilkan kata sandi
+ Sembunyikan kata sandi
+ Kandidat %1$d
+ Kartu %1$d
+ Tambah
+ Tandai selesai
+ Tandai belum selesai
+ Selesai
+ Belum selesai
+ Suka
+ Batal suka
+ Disukai
+ Belum disukai
+ Detail kiriman
diff --git a/android/src/android/app/src/main/res/values-ja/nm_strings.xml b/android/src/android/app/src/main/res/values-ja/nm_strings.xml
index 988af0ea..4c172b05 100644
--- a/android/src/android/app/src/main/res/values-ja/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-ja/nm_strings.xml
@@ -348,6 +348,23 @@
これにする
次に、表情(作業中、待機中、完了、おっと)をこの画像からバックグラウンドで描きます。
画像は端末の minis-global/nanomuse/avatar に保存されます。顔 1 つにつきプロバイダから 5 枚の画像を使います: 選んだ 1 枚と 4 つの表情です。
+ 自分の動画
+ 自分の短い動画が待機中の顔として再生され、他の状態でも同じ動画が使われます。H.264 の MP4 または MOV、最長 15 秒、最大 30 MB。無音でループ再生されます。
+ 自分の動画が有効で、すべての状態で再生されます。静止画が使われる場所では描かれた画像がそのまま使われます。「標準の顔」で取り除けます。
+ 動画はこの端末にだけ保存されます。nanoMuse Cloud にアップロードされず、他の端末にも同期されません。
+ 動画を選ぶ
+ 動画を差し替える
+ 標準の顔
+ 自分の動画…
+ プレビュー
+ 顔として再生されるとこうなります: 無音、ループ、円形に切り抜き。
+ %1$d 秒 · %2$s
+ 使う
+ このファイルはここでは使えません。H.264 の MP4 または MOV で、15 秒以内かつ 30 MB 以内のものを選んでください。
+ この動画は %1$d 秒あります。上限は 15 秒、および 30 MB です。
+ この動画は %1$s あります。上限は 30 MB、および 15 秒です。
+ 動画を読み込めませんでした。別のものを試してください。
+ 自分の動画のプレビュー
nanoMuse へようこそ
@@ -1103,4 +1120,19 @@
モデルを検索
検索をクリア
一致するモデルはありません
+
+ パスワードを表示
+ パスワードを隠す
+ 候補 %1$d
+ カード %1$d
+ 追加
+ 完了にする
+ 未完了に戻す
+ 完了
+ 未完了
+ いいね
+ いいねを取り消す
+ いいね済み
+ いいねしていません
+ 投稿の詳細
diff --git a/android/src/android/app/src/main/res/values-ko/nm_strings.xml b/android/src/android/app/src/main/res/values-ko/nm_strings.xml
index d8d0ae23..7888747b 100644
--- a/android/src/android/app/src/main/res/values-ko/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-ko/nm_strings.xml
@@ -348,6 +348,23 @@
이것으로 하기
표정(작업 중, 기다리는 중, 완료, 이런)은 이어서 백그라운드에서 그립니다.
그림은 이 휴대전화의 minis-global/nanomuse/avatar에 저장됩니다. 얼굴 하나에 제공자에서 이미지 다섯 장이 듭니다. 고른 한 장과 네 가지 표정.
+ 내 동영상
+ 내 짧은 동영상이 대기 중 얼굴로 재생되고, 다른 상태에서도 같은 동영상을 씁니다. H.264 영상의 MP4 또는 MOV, 최대 15초, 30 MB까지. 음소거로 반복 재생됩니다.
+ 내 동영상이 켜져 있고 모든 상태에서 재생됩니다. 정지 화면이 쓰이는 곳에서는 그린 그림이 그대로 쓰입니다. “기본 얼굴”로 제거할 수 있습니다.
+ 동영상은 이 휴대폰에만 남습니다. nanoMuse Cloud에 올라가지 않고 다른 기기와 동기화되지도 않습니다.
+ 동영상 선택
+ 동영상 바꾸기
+ 기본 얼굴
+ 내 동영상…
+ 미리보기
+ 얼굴로 재생되면 이렇게 보입니다. 음소거, 반복, 원형으로 잘라서.
+ %1$d초 · %2$s
+ 사용
+ 이 파일은 여기서 쓸 수 없습니다. H.264 영상의 MP4 또는 MOV로, 15초 이하이고 30 MB 이하인 것을 고르세요.
+ 이 동영상은 %1$d초입니다. 제한은 15초, 그리고 30 MB입니다.
+ 이 동영상은 %1$s입니다. 제한은 30 MB, 그리고 15초입니다.
+ 동영상을 읽을 수 없습니다. 다른 것을 시도해 보세요.
+ 내 동영상 미리보기
nanoMuse에 오신 것을 환영합니다
@@ -1103,4 +1120,19 @@
모델 검색
검색 지우기
일치하는 모델이 없습니다
+
+ 비밀번호 표시
+ 비밀번호 숨기기
+ 후보 %1$d
+ 카드 %1$d
+ 추가
+ 완료로 표시
+ 미완료로 표시
+ 완료
+ 미완료
+ 좋아요
+ 좋아요 취소
+ 좋아요 표시됨
+ 좋아요 없음
+ 게시물 세부정보
diff --git a/android/src/android/app/src/main/res/values-ms/nm_strings.xml b/android/src/android/app/src/main/res/values-ms/nm_strings.xml
index 0ad04da5..914132ae 100644
--- a/android/src/android/app/src/main/res/values-ms/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-ms/nm_strings.xml
@@ -348,6 +348,23 @@
Guna yang ini
Ekspresi (bekerja, menunggu, selesai, alamak) dilukis daripadanya selepas ini, di latar belakang.
Gambar disimpan di telefon ini di bawah minis-global/nanomuse/avatar. Setiap wajah memerlukan lima imej daripada penyedia anda: satu yang anda pilih dan empat ekspresi.
+ Klip anda sendiri
+ Video pendek milik anda dimainkan sebagai wajah semasa rehat, dan mood lain turut menggunakannya. MP4 atau MOV dengan video H.264, paling lama 15 saat dan 30 MB; dimainkan tanpa bunyi, berulang.
+ Klip anda aktif dan dimainkan untuk setiap mood; di tempat imej pegun ditunjukkan, gambar yang dilukis kekal digunakan. “Wajah lalai” membuangnya.
+ Klip kekal di telefon ini. Ia tidak dimuat naik ke nanoMuse Cloud dan tidak disegerakkan ke peranti anda yang lain.
+ Pilih klip
+ Ganti klip
+ Wajah lalai
+ Klip anda sendiri…
+ Pratonton
+ Beginilah ia dimainkan sebagai wajah: tanpa bunyi, berulang, dipotong ke bulatan.
+ %1$d s · %2$s
+ Guna
+ Fail itu tidak boleh digunakan di sini. Pilih MP4 atau MOV dengan video H.264, paling lama 15 saat dan 30 MB.
+ Klip itu berdurasi %1$d saat. Hadnya 15 saat, dan 30 MB.
+ Klip itu bersaiz %1$s. Hadnya 30 MB, dan 15 saat.
+ Klip tidak dapat dibaca. Cuba yang lain.
+ Pratonton klip anda
Selamat datang ke nanoMuse
@@ -1103,4 +1120,19 @@
Cari model
Kosongkan carian
Tiada model yang sepadan
+
+ Tunjukkan kata laluan
+ Sembunyikan kata laluan
+ Calon %1$d
+ Kad %1$d
+ Tambah
+ Tandakan selesai
+ Tandakan belum selesai
+ Selesai
+ Belum selesai
+ Suka
+ Batalkan suka
+ Disukai
+ Belum disukai
+ Butiran kiriman
diff --git a/android/src/android/app/src/main/res/values-pl/nm_strings.xml b/android/src/android/app/src/main/res/values-pl/nm_strings.xml
index 3a2c27b5..46ebbd90 100644
--- a/android/src/android/app/src/main/res/values-pl/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-pl/nm_strings.xml
@@ -357,6 +357,23 @@
Użyj tego
Nastroje (praca, czekanie, gotowe, ups) rysowane są z niego w następnej kolejności, w tle.
Obrazki są przechowywane na tym telefonie w minis-global/nanomuse/avatar. Każda twarz to pięć obrazów od twojego dostawcy: jeden wybrany przez ciebie plus cztery nastroje.
+ Twój własny klip
+ Krótki film z Twojej galerii gra jako twarz w spoczynku, a pozostałe nastroje też z niego korzystają. MP4 lub MOV z wideo H.264, najwyżej 15 sekund i 30 MB; odtwarza się w pętli, bez dźwięku.
+ Twój klip jest włączony i gra w każdym nastroju; tam, gdzie pokazywany jest nieruchomy obraz, nadal używane są narysowane obrazy. „Domyślna twarz” go usuwa.
+ Klip zostaje na tym telefonie. Nie jest wysyłany do nanoMuse Cloud ani synchronizowany z Twoimi innymi urządzeniami.
+ Wybierz klip
+ Zamień klip
+ Domyślna twarz
+ Twój własny klip…
+ Podgląd
+ Tak gra jako twarz: bez dźwięku, w pętli, przycięty do koła.
+ %1$d s · %2$s
+ Użyj
+ Tego pliku nie da się tu użyć. Wybierz MP4 lub MOV z wideo H.264, najwyżej 15 sekund i 30 MB.
+ Ten klip trwa %1$d sekund. Limit to 15 sekund i 30 MB.
+ Ten klip ma %1$s. Limit to 30 MB i 15 sekund.
+ Nie udało się odczytać klipu. Spróbuj innego.
+ Podgląd Twojego klipu
Witaj w nanoMuse
@@ -1115,4 +1132,19 @@
Szukaj modeli
Wyczyść wyszukiwanie
Żaden model nie pasuje
+
+ Pokaż hasło
+ Ukryj hasło
+ Kandydat %1$d
+ Karta %1$d
+ Dodaj
+ Oznacz jako ukończone
+ Oznacz jako nieukończone
+ Ukończone
+ Nieukończone
+ Lubię to
+ Już nie lubię
+ Polubione
+ Niepolubione
+ Szczegóły wpisu
diff --git a/android/src/android/app/src/main/res/values-pt-rBR/nm_strings.xml b/android/src/android/app/src/main/res/values-pt-rBR/nm_strings.xml
index ab2c29fc..202f7460 100644
--- a/android/src/android/app/src/main/res/values-pt-rBR/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-pt-rBR/nm_strings.xml
@@ -351,6 +351,23 @@
Usar este
Os humores (trabalhando, esperando, concluído, ops) são desenhados a partir dele em seguida, em segundo plano.
As imagens ficam neste celular em minis-global/nanomuse/avatar. Cada rosto custa cinco imagens do seu provedor: a que você escolheu mais quatro humores.
+ Seu próprio clipe
+ Um vídeo curto seu toca como o rosto em repouso, e os outros humores recorrem a ele. MP4 ou MOV com vídeo H.264, de até 15 segundos e 30 MB; toca sem som, em repetição.
+ Seu clipe está ativo e toca em todos os humores; onde uma imagem parada é mostrada, as desenhadas continuam em uso. “Rosto padrão” o remove.
+ O clipe fica neste telefone. Não é enviado ao nanoMuse Cloud nem sincronizado com seus outros dispositivos.
+ Escolher um clipe
+ Substituir o clipe
+ Rosto padrão
+ Seu próprio clipe…
+ Prévia
+ É assim que ele toca como rosto: sem som, em repetição, recortado no círculo.
+ %1$d s · %2$s
+ Usar
+ Esse arquivo não pode ser usado aqui. Escolha um MP4 ou MOV com vídeo H.264, de até 15 segundos e 30 MB.
+ Esse clipe dura %1$d segundos. O limite é 15 segundos, e 30 MB.
+ Esse clipe tem %1$s. O limite é 30 MB, e 15 segundos.
+ Não foi possível ler o clipe. Tente outro.
+ Prévia do seu clipe
Bem-vindo ao nanoMuse
@@ -1107,4 +1124,19 @@
Buscar modelos
Limpar a busca
Nenhum modelo corresponde
+
+ Mostrar senha
+ Ocultar senha
+ Candidato %1$d
+ Cartão %1$d
+ Adicionar
+ Marcar como concluído
+ Marcar como não concluído
+ Concluído
+ Não concluído
+ Curtir
+ Descurtir
+ Curtido
+ Não curtido
+ Detalhes da publicação
diff --git a/android/src/android/app/src/main/res/values-ro/nm_strings.xml b/android/src/android/app/src/main/res/values-ro/nm_strings.xml
index 2e110419..06c1674b 100644
--- a/android/src/android/app/src/main/res/values-ro/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-ro/nm_strings.xml
@@ -354,6 +354,23 @@
Folosește-o pe aceasta
Stările (lucrează, așteaptă, gata, ups) se desenează din ea în continuare, în fundal.
Imaginile sunt stocate pe acest telefon sub minis-global/nanomuse/avatar. Fiecare față costă cinci imagini de la furnizorul tău: una aleasă de tine plus patru stări.
+ Clipul tău
+ Un videoclip scurt al tău rulează ca față în repaus, iar celelalte stări îl folosesc și ele. MP4 sau MOV cu video H.264, cel mult 15 secunde și 30 MB; rulează fără sunet, în buclă.
+ Clipul tău este activ și rulează pentru fiecare stare; acolo unde se arată o imagine statică rămân imaginile desenate. „Fața implicită” îl elimină.
+ Clipul rămâne pe acest telefon. Nu este încărcat în nanoMuse Cloud și nu este sincronizat cu celelalte dispozitive ale tale.
+ Alege un clip
+ Înlocuiește clipul
+ Fața implicită
+ Clipul tău…
+ Previzualizare
+ Așa rulează ca față: fără sunet, în buclă, decupat în cerc.
+ %1$d s · %2$s
+ Folosește
+ Fișierul acela nu poate fi folosit aici. Alege un MP4 sau MOV cu video H.264, cel mult 15 secunde și 30 MB.
+ Clipul acela durează %1$d secunde. Limita este de 15 secunde, și 30 MB.
+ Clipul acela are %1$s. Limita este de 30 MB, și 15 secunde.
+ Clipul nu a putut fi citit. Încearcă altul.
+ Previzualizarea clipului tău
Bine ai venit la nanoMuse
@@ -1111,4 +1128,19 @@
Caută modele
Șterge căutarea
Niciun model nu se potrivește
+
+ Afișează parola
+ Ascunde parola
+ Candidatul %1$d
+ Cardul %1$d
+ Adaugă
+ Marchează ca finalizat
+ Marchează ca nefinalizat
+ Finalizat
+ Nefinalizat
+ Apreciază
+ Retrage aprecierea
+ Apreciat
+ Neapreciat
+ Detaliile postării
diff --git a/android/src/android/app/src/main/res/values-ru/nm_strings.xml b/android/src/android/app/src/main/res/values-ru/nm_strings.xml
index 68527bd1..1e210a7e 100644
--- a/android/src/android/app/src/main/res/values-ru/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-ru/nm_strings.xml
@@ -357,6 +357,23 @@
Использовать этот
Дальше из него в фоне рисуются настроения: работает, ждёт, готово, ошибка.
Картинки хранятся на этом телефоне в minis-global/nanomuse/avatar. Каждый образ стоит пять изображений у вашего провайдера: одно выбранное плюс четыре настроения.
+ Ваш собственный клип
+ Короткое видео из вашей галереи играет как лицо в покое, а остальные состояния тоже используют его. MP4 или MOV с видео H.264, не длиннее 15 секунд и не больше 30 МБ; играет без звука, по кругу.
+ Ваш клип включён и играет в каждом состоянии; там, где показывается неподвижная картинка, по-прежнему используются нарисованные. «Лицо по умолчанию» убирает его.
+ Клип остаётся на этом телефоне. Он не загружается в nanoMuse Cloud и не синхронизируется с другими вашими устройствами.
+ Выбрать клип
+ Заменить клип
+ Лицо по умолчанию
+ Ваш собственный клип…
+ Предпросмотр
+ Так он играет как лицо: без звука, по кругу, обрезанный до круга.
+ %1$d с · %2$s
+ Использовать
+ Этот файл здесь не подходит. Выберите MP4 или MOV с видео H.264, не длиннее 15 секунд и не больше 30 МБ.
+ Этот клип длится %1$d с. Предел 15 секунд, и 30 МБ.
+ Этот клип весит %1$s. Предел 30 МБ, и 15 секунд.
+ Не удалось прочитать клип. Попробуйте другой.
+ Предпросмотр вашего клипа
Добро пожаловать в nanoMuse
@@ -1115,4 +1132,19 @@
Поиск моделей
Очистить поиск
Ни одна модель не подходит
+
+ Показать пароль
+ Скрыть пароль
+ Кандидат %1$d
+ Карточка %1$d
+ Добавить
+ Отметить как выполненное
+ Отметить как невыполненное
+ Выполнено
+ Не выполнено
+ Нравится
+ Убрать отметку «Нравится»
+ Отмечено как понравившееся
+ Не отмечено
+ Сведения о записи
diff --git a/android/src/android/app/src/main/res/values-th/nm_strings.xml b/android/src/android/app/src/main/res/values-th/nm_strings.xml
index e52e8e59..9922f3fb 100644
--- a/android/src/android/app/src/main/res/values-th/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-th/nm_strings.xml
@@ -348,6 +348,23 @@
ใช้แบบนี้
อารมณ์ต่าง ๆ (ทำงาน รอ เสร็จ ผิดพลาด) จะวาดจากรูปนี้ต่อในเบื้องหลัง
รูปภาพเก็บไว้บนโทรศัพท์เครื่องนี้ที่ minis-global/nanomuse/avatar ใบหน้าแต่ละแบบใช้ห้ารูปจากผู้ให้บริการของคุณ: รูปที่คุณเลือกหนึ่งรูปและอารมณ์อีกสี่รูป
+ คลิปของคุณเอง
+ วิดีโอสั้นของคุณจะเล่นเป็นใบหน้าตอนพัก และอารมณ์อื่นก็ใช้คลิปเดียวกัน MP4 หรือ MOV ที่เป็นวิดีโอ H.264 ยาวไม่เกิน 15 วินาทีและไม่เกิน 30 MB เล่นวนซ้ำแบบปิดเสียง
+ คลิปของคุณเปิดอยู่และเล่นในทุกอารมณ์ ตรงที่แสดงภาพนิ่งยังใช้ภาพที่วาดไว้ “ใบหน้าเริ่มต้น” จะลบคลิปออก
+ คลิปอยู่ในโทรศัพท์เครื่องนี้เท่านั้น ไม่ได้อัปโหลดไป nanoMuse Cloud และไม่ซิงค์ไปยังอุปกรณ์อื่นของคุณ
+ เลือกคลิป
+ เปลี่ยนคลิป
+ ใบหน้าเริ่มต้น
+ คลิปของคุณเอง…
+ ดูตัวอย่าง
+ เมื่อเป็นใบหน้าจะเล่นแบบนี้: ปิดเสียง วนซ้ำ ตัดเป็นวงกลม
+ %1$d วิ · %2$s
+ ใช้
+ ใช้ไฟล์นี้ที่นี่ไม่ได้ เลือก MP4 หรือ MOV ที่เป็นวิดีโอ H.264 ยาวไม่เกิน 15 วินาทีและไม่เกิน 30 MB
+ คลิปนี้ยาว %1$d วินาที ขีดจำกัดคือ 15 วินาที และ 30 MB
+ คลิปนี้มีขนาด %1$s ขีดจำกัดคือ 30 MB และ 15 วินาที
+ อ่านคลิปไม่ได้ ลองคลิปอื่น
+ ตัวอย่างคลิปของคุณ
ยินดีต้อนรับสู่ nanoMuse
@@ -1103,4 +1120,19 @@
ค้นหาโมเดล
ล้างการค้นหา
ไม่มีโมเดลที่ตรงกัน
+
+ แสดงรหัสผ่าน
+ ซ่อนรหัสผ่าน
+ ตัวเลือกที่ %1$d
+ การ์ดที่ %1$d
+ เพิ่ม
+ ทำเครื่องหมายว่าเสร็จแล้ว
+ ทำเครื่องหมายว่ายังไม่เสร็จ
+ เสร็จแล้ว
+ ยังไม่เสร็จ
+ ถูกใจ
+ เลิกถูกใจ
+ ถูกใจแล้ว
+ ยังไม่ถูกใจ
+ รายละเอียดโพสต์
diff --git a/android/src/android/app/src/main/res/values-tr/nm_strings.xml b/android/src/android/app/src/main/res/values-tr/nm_strings.xml
index 78d44138..c1066a98 100644
--- a/android/src/android/app/src/main/res/values-tr/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-tr/nm_strings.xml
@@ -351,6 +351,23 @@
Bunu kullan
Ruh halleri (çalışıyor, bekliyor, tamamlandı, hata) bundan sonra arka planda ondan çizilir.
Resimler bu telefonda minis-global/nanomuse/avatar altında saklanır. Her yüz sağlayıcından beş görsel tutar: seçtiğin bir tane artı dört ruh hali.
+ Kendi klibiniz
+ Size ait kısa bir video, beklemedeki yüz olarak oynar ve diğer ruh halleri de ona döner. H.264 videolu MP4 veya MOV, en fazla 15 saniye ve 30 MB; sessiz ve döngüde oynar.
+ Klibiniz açık ve her ruh halinde oynuyor; sabit bir görüntünün gösterildiği yerlerde çizilen resimler kullanılmaya devam eder. “Varsayılan yüz” onu kaldırır.
+ Klip bu telefonda kalır. nanoMuse Cloud\'a yüklenmez ve diğer cihazlarınızla eşitlenmez.
+ Klip seç
+ Klibi değiştir
+ Varsayılan yüz
+ Kendi klibiniz…
+ Önizleme
+ Yüz olarak böyle oynar: sessiz, döngüde, daireye kırpılmış.
+ %1$d sn · %2$s
+ Kullan
+ Bu dosya burada kullanılamaz. H.264 videolu, en fazla 15 saniye ve 30 MB olan bir MP4 veya MOV seçin.
+ Bu klip %1$d saniye sürüyor. Sınır 15 saniye ve 30 MB.
+ Bu klip %1$s. Sınır 30 MB ve 15 saniye.
+ Klip okunamadı. Başka birini deneyin.
+ Klibinizin önizlemesi
nanoMuse’a hoş geldin
@@ -1107,4 +1124,19 @@
Model ara
Aramayı temizle
Eşleşen model yok
+
+ Parolayı göster
+ Parolayı gizle
+ Aday %1$d
+ Kart %1$d
+ Ekle
+ Tamamlandı olarak işaretle
+ Tamamlanmadı olarak işaretle
+ Tamamlandı
+ Tamamlanmadı
+ Beğen
+ Beğeniyi geri al
+ Beğenildi
+ Beğenilmedi
+ Gönderi ayrıntıları
diff --git a/android/src/android/app/src/main/res/values-zh-rTW/nm_strings.xml b/android/src/android/app/src/main/res/values-zh-rTW/nm_strings.xml
index 5584f0f1..1db817c7 100644
--- a/android/src/android/app/src/main/res/values-zh-rTW/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-zh-rTW/nm_strings.xml
@@ -336,6 +336,23 @@
就用這個
接下來會在背景以它為底畫出各個狀態:工作、等你、搞定、出錯。
圖片存在這台手機的 minis-global/nanomuse/avatar 下。每換一次形象會向你的提供方要五張圖:你選的那張加四個狀態。
+ 你自己的影片
+ 一段你自己的短影片會作為待機時的形象播放,其他狀態也沿用它。MP4 或 MOV,H.264 影片,最長 15 秒、最大 30 MB;靜音循環播放。
+ 你的影片已啟用,所有狀態都播放它;需要靜態圖的地方仍用畫好的圖片。「預設形象」可移除它。
+ 影片只保存在這支手機上,不會上傳到 nanoMuse Cloud,也不會同步到你的其他裝置。
+ 選擇影片
+ 更換影片
+ 預設形象
+ 你自己的影片…
+ 預覽
+ 作為形象時就是這樣播放的:靜音、循環、裁成圓形。
+ %1$d 秒 · %2$s
+ 使用
+ 這個檔案無法在這裡使用。請選擇 MP4 或 MOV(H.264 影片),最長 15 秒、最大 30 MB。
+ 這段影片長 %1$d 秒。上限是 15 秒,以及 30 MB。
+ 這段影片有 %1$s。上限是 30 MB,以及 15 秒。
+ 無法讀取這段影片,請換一段試試。
+ 你的影片預覽
歡迎使用 nanoMuse
@@ -1073,4 +1090,19 @@
搜尋模型
清除搜尋
沒有符合的模型
+
+ 顯示密碼
+ 隱藏密碼
+ 候選 %1$d
+ 卡片 %1$d
+ 新增
+ 標記為已完成
+ 標記為未完成
+ 已完成
+ 未完成
+ 喜歡
+ 取消喜歡
+ 已喜歡
+ 未喜歡
+ 貼文詳情
diff --git a/android/src/android/app/src/main/res/values-zh/nm_strings.xml b/android/src/android/app/src/main/res/values-zh/nm_strings.xml
index 46723307..047c6b80 100644
--- a/android/src/android/app/src/main/res/values-zh/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values-zh/nm_strings.xml
@@ -341,6 +341,23 @@
就用这个
接下来会在后台以它为底画出各个状态:干活、等你、搞定、出错。
图片存在这台手机的 minis-global/nanomuse/avatar 下。每换一次形象会向你的提供方要五张图:你选的那张加四个状态。
+ 你自己的视频
+ 一段你自己的短视频会作为待机时的形象播放,其他状态也沿用它。MP4 或 MOV,H.264 视频,最长 15 秒、最大 30 MB;静音循环播放。
+ 你的视频已启用,所有状态都播放它;需要静态图的地方仍用画好的图片。「默认形象」可移除它。
+ 视频只保存在这台手机上,不会上传到 nanoMuse Cloud,也不会同步到你的其他设备。
+ 选择视频
+ 更换视频
+ 默认形象
+ 你自己的视频…
+ 预览
+ 作为形象时就是这样播放的:静音、循环、裁成圆形。
+ %1$d 秒 · %2$s
+ 使用
+ 这个文件无法在这里使用。请选择 MP4 或 MOV(H.264 视频),最长 15 秒、最大 30 MB。
+ 这段视频长 %1$d 秒。上限是 15 秒,以及 30 MB。
+ 这段视频有 %1$s。上限是 30 MB,以及 15 秒。
+ 无法读取这段视频,请换一段试试。
+ 你的视频预览
欢迎使用 nanoMuse
@@ -383,7 +400,7 @@
虚拟形象大小
- 首页顶部形象的大小。选择"已隐藏"时只显示名字。
+ 首页顶部形象的大小。选择“已隐藏”时只显示名字。
小
中
大
@@ -404,11 +421,11 @@
今天
昨天
今天还没有动态。你提的要求和它做的事会显示在这里。
- 还没有长期批准。对有风险的操作选择"始终允许"后,会列在这里,随时可以撤回。
+ 还没有长期批准。对有风险的操作选择“始终允许”后,会列在这里,随时可以撤回。
已始终允许
已始终允许 · %1$s
管理权限
- 还没有每日任务。跟它说"每天早上……",就会出现在这里。
+ 还没有每日任务。跟它说“每天早上……”,就会出现在这里。
管理每日任务
SOUL
记忆
@@ -416,21 +433,21 @@
正在生成选项
正在完成形象
- 正在画四个"%1$s"的样子…
- 点一个你喜欢的,或者直接告诉我"第二个"。
+ 正在画四个“%1$s”的样子…
+ 点一个你喜欢的,或者直接告诉我“第二个”。
图片没有生成出来。到 ••• → 形象工坊检查一下图像模型,再试一次。
生图服务现在有点忙,请过一分钟再试。
再来一组
先不换了
第 %1$d 个
重试
- 几个"%1$s"的形象准备好了,选一个你最喜欢的。
- 我这就去画几版"%1$s"。出图需要一点时间,画好了我会告诉你。
+ 几个“%1$s”的形象准备好了,选一个你最喜欢的。
+ 我这就去画几版“%1$s”。出图需要一点时间,画好了我会告诉你。
再画一轮,稍等一下。
换好了,我的新形象是%1$s。
第 %1$d 个
现在还画不了新形象:%1$s
- - %1$s:用户把我的虚拟形象换成了"%2$s"。
+ - %1$s:用户把我的虚拟形象换成了“%2$s”。
我的新形象:%1$s,分享给朋友看看。
分享形象
以后再说
@@ -1080,4 +1097,19 @@
搜索模型
清除搜索
没有匹配的模型
+
+ 显示密码
+ 隐藏密码
+ 候选 %1$d
+ 卡片 %1$d
+ 添加
+ 标记为已完成
+ 标记为未完成
+ 已完成
+ 未完成
+ 喜欢
+ 取消喜欢
+ 已喜欢
+ 未喜欢
+ 帖子详情
diff --git a/android/src/android/app/src/main/res/values/nm_strings.xml b/android/src/android/app/src/main/res/values/nm_strings.xml
index 4daa8170..ac15771c 100644
--- a/android/src/android/app/src/main/res/values/nm_strings.xml
+++ b/android/src/android/app/src/main/res/values/nm_strings.xml
@@ -345,6 +345,23 @@
Use this one
The moods (working, waiting, done, oops) are drawn from it next, in the background.
Pictures are stored on this phone under minis-global/nanomuse/avatar. Each face costs five images from your provider: one you chose plus four moods.
+ Your own clip
+ A short video of yours plays as the face at rest, and the other moods fall back to it. MP4 or MOV with H.264 video, up to 15 seconds and 30 MB; it plays muted in a loop.
+ Your clip is on and plays for every mood; the drawn stills still show where a still is used. Default face removes it.
+ The clip stays on this phone. It is not uploaded to nanoMuse Cloud or synced to your other devices.
+ Choose a clip
+ Replace the clip
+ Default face
+ Your own clip…
+ Preview
+ This is how it plays as the face: muted, in a loop, cropped to the circle.
+ %1$d s · %2$s
+ Use
+ That file cannot be used here. Pick an MP4 or MOV with H.264 video, up to 15 seconds and 30 MB.
+ That clip runs %1$d seconds. The limit is 15 seconds, and 30 MB.
+ That clip is %1$s. The limit is 30 MB, and 15 seconds.
+ The clip could not be read. Try another one.
+ Preview of your clip
Welcome to nanoMuse
@@ -408,11 +425,11 @@
Today
Yesterday
Nothing yet today. What you ask for and what the agent does about it shows up here.
- No standing approvals. When you answer "always allow" to a risky step, it is listed here and can be revoked.
+ No standing approvals. When you answer \"always allow\" to a risky step, it is listed here and can be revoked.
Always allowed
Always allowed · %1$s
Manage permissions
- No routines yet. Ask the agent for something "every morning" and it will appear here.
+ No routines yet. Ask the agent for something \"every morning\" and it will appear here.
Manage routines
SOUL
Memory
@@ -420,21 +437,21 @@
Generating options
Finalizing avatar
- Drawing four takes on "%1$s"…
- Tap the one you like, or tell me: "the second one".
+ Drawing four takes on \"%1$s\"…
+ Tap the one you like, or tell me: \"the second one\".
The pictures did not come through. Check the image model under ••• → Avatar studio and try again.
The image provider is busy right now; try again in a minute.
Again
Keep current
Option %1$d
Retry
- Here are a few takes on "%1$s": pick the one you like best.
- I\'m drawing a few takes on "%1$s" now. Pictures take a little while; I\'ll tell you when they\'re ready.
+ Here are a few takes on \"%1$s\": pick the one you like best.
+ I\'m drawing a few takes on \"%1$s\" now. Pictures take a little while; I\'ll tell you when they\'re ready.
One more round, give me a moment.
Done, my new look is on. I\'m %1$s now.
Option %1$d
I can\'t draw a new look yet: %1$s
- - %1$s: the user changed my avatar to "%2$s".
+ - %1$s: the user changed my avatar to \"%2$s\".
My new look: %1$s. Have a look.
Share avatar
Later
@@ -1084,4 +1101,19 @@
Search models
Clear the search
No model matches
+
+ Show password
+ Hide password
+ Candidate %1$d
+ Card %1$d
+ Add
+ Mark as done
+ Mark as not done
+ Done
+ Not done
+ Like
+ Unlike
+ Liked
+ Not liked
+ Post details
diff --git a/android/src/android/app/src/main/res/xml/nanomuse_backup_rules.xml b/android/src/android/app/src/main/res/xml/nanomuse_backup_rules.xml
index 2b5e441c..1e50275c 100644
--- a/android/src/android/app/src/main/res/xml/nanomuse_backup_rules.xml
+++ b/android/src/android/app/src/main/res/xml/nanomuse_backup_rules.xml
@@ -12,6 +12,8 @@
+
+
diff --git a/android/src/android/app/src/main/res/xml/nanomuse_data_extraction_rules.xml b/android/src/android/app/src/main/res/xml/nanomuse_data_extraction_rules.xml
index 74ed2953..397bb9a3 100644
--- a/android/src/android/app/src/main/res/xml/nanomuse_data_extraction_rules.xml
+++ b/android/src/android/app/src/main/res/xml/nanomuse_data_extraction_rules.xml
@@ -10,6 +10,8 @@
+
+
@@ -19,6 +21,8 @@
+
+
diff --git a/android/src/android/app/src/test/java/com/openminis/app/provider/AnthropicProviderTest.kt b/android/src/android/app/src/test/java/com/openminis/app/provider/AnthropicProviderTest.kt
index da804bc3..62e930a9 100644
--- a/android/src/android/app/src/test/java/com/openminis/app/provider/AnthropicProviderTest.kt
+++ b/android/src/android/app/src/test/java/com/openminis/app/provider/AnthropicProviderTest.kt
@@ -602,6 +602,12 @@ class AnthropicProviderTest {
// mimicry beta set must NOT carry `redact-thinking-2026-02-12`; with it,
// the server blanks thinking text (signature only) even though the model
// reasons. Omitting it == showThinkingSummaries:true.
+ // nanoMuse: the OAuth path needs the Claude Code identifier, which the public build leaves
+ // empty; skip rather than fail where provider-customization.properties is absent (#333)
+ org.junit.Assume.assumeTrue(
+ "ANTHROPIC_OAUTH_IDENTIFIER_PROMPT is empty in this build",
+ com.openminis.app.BuildConfig.ANTHROPIC_OAUTH_IDENTIFIER_PROMPT.isNotEmpty(),
+ )
val oauthProvider = AnthropicProvider(
apiKey = "test-oauth-token",
model = LLMModel.claudeSonnet5,
diff --git a/android/src/android/app/src/test/java/com/openminis/app/provider/OpenAIProviderTest.kt b/android/src/android/app/src/test/java/com/openminis/app/provider/OpenAIProviderTest.kt
index 4e656d54..5e448436 100644
--- a/android/src/android/app/src/test/java/com/openminis/app/provider/OpenAIProviderTest.kt
+++ b/android/src/android/app/src/test/java/com/openminis/app/provider/OpenAIProviderTest.kt
@@ -38,21 +38,44 @@ class OpenAIProviderTest {
server.shutdown()
}
+ // nanoMuse: sendMessage always issues a streaming request and folds the deltas back into
+ // one LLMResponse (OpenAIProvider.sendMessageClamped); a plain JSON body yields no chunk and
+ // failOnSilentEmptyCompletion raises TransientError. The sendMessage fixtures below are
+ // therefore Chat Completions SSE streams: deltas, then one chunk with finish_reason and
+ // usage (what stream_options.include_usage asks for), then [DONE].
+ private fun sse(vararg events: String): MockResponse {
+ val body = buildString {
+ events.forEach { event ->
+ appendLine("data: $event")
+ appendLine()
+ }
+ appendLine("data: [DONE]")
+ appendLine()
+ }
+ return MockResponse().setBody(body).setHeader("Content-Type", "text/event-stream")
+ }
+
+ /** A one-delta assistant reply followed by the terminal chunk; `usage` must be one line. */
+ private fun sseReply(
+ text: String = "ok",
+ finishReason: String = "stop",
+ usage: String = """{"prompt_tokens":0,"completion_tokens":0}""",
+ ): MockResponse = sse(
+ """{"choices":[{"delta":{"role":"assistant","content":"$text"}}]}""",
+ """{"choices":[{"delta":{},"finish_reason":"$finishReason"}],"usage":$usage}""",
+ )
+
// -- sendMessage response parsing --
@Test
fun `sendMessage parses ChatCompletions response`() = runBlocking {
- val responseBody = """
- {
- "choices": [{
- "message": {"role": "assistant", "content": "Hello from GPT!"},
- "finish_reason": "stop"
- }],
- "usage": {"prompt_tokens": 10, "completion_tokens": 5}
- }
- """.trimIndent()
-
- server.enqueue(MockResponse().setBody(responseBody))
+ // nanoMuse: streamed, see sse().
+ server.enqueue(
+ sseReply(
+ text = "Hello from GPT!",
+ usage = """{"prompt_tokens": 10, "completion_tokens": 5}""",
+ ),
+ )
val response = provider.sendMessage(
listOf(LLMMessage(LLMMessage.Role.USER, "Hi")),
@@ -67,21 +90,18 @@ class OpenAIProviderTest {
@Test
fun `sendMessage parses cached tokens from prompt_tokens_details`() = runBlocking {
- val responseBody = """
- {
- "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
- "usage": {
- "prompt_tokens": 100,
- "completion_tokens": 10,
- "prompt_tokens_details": {"cached_tokens": 50}
- }
- }
- """.trimIndent()
-
- server.enqueue(MockResponse().setBody(responseBody))
+ // nanoMuse: streamed, see sse().
+ server.enqueue(
+ sseReply(
+ usage = """{"prompt_tokens":100,"completion_tokens":10,"prompt_tokens_details":{"cached_tokens":50}}""",
+ ),
+ )
val response = provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "Hi")), null, 1024)
- assertEquals(100, response.usage?.inputTokens)
+ // nanoMuse: parseChatCompletionsUsage reports inputTokens as the fresh part only
+ // (prompt_tokens minus cached_tokens) and keeps the full prompt in latestContextTokens.
+ assertEquals(50, response.usage?.inputTokens)
+ assertEquals(100, response.usage?.latestContextTokens)
assertEquals(10, response.usage?.outputTokens)
assertEquals(50, response.usage?.cacheReadInputTokens)
assertNull(response.usage?.cacheCreationInputTokens)
@@ -89,36 +109,33 @@ class OpenAIProviderTest {
@Test
fun `sendMessage returns null cacheReadInputTokens when zero`() = runBlocking {
- val responseBody = """
- {
- "choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
- "usage": {
- "prompt_tokens": 10,
- "completion_tokens": 5,
- "prompt_tokens_details": {"cached_tokens": 0}
- }
- }
- """.trimIndent()
-
- server.enqueue(MockResponse().setBody(responseBody))
+ // nanoMuse: streamed, see sse().
+ server.enqueue(
+ sseReply(
+ usage = """{"prompt_tokens":10,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":0}}""",
+ ),
+ )
val response = provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "Hi")), null, 1024)
+ // nanoMuse: the usage chunk did arrive; only the zero cache count is dropped.
+ assertEquals(10, response.usage?.inputTokens)
assertNull(response.usage?.cacheReadInputTokens)
}
- @Test
- fun `sendMessage handles empty choices`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":0}}"""))
+ // nanoMuse: a stream with no content and no finish_reason is the documented transient
+ // failure (failOnSilentEmptyCompletion), not an empty success.
+ @Test(expected = LLMError.TransientError::class)
+ fun `sendMessage treats empty choices as a transient error`() = runBlocking {
+ server.enqueue(sse("""{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":0}}"""))
- val response = provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "Hi")), null, 1024)
- assertEquals("", response.text)
- assertNull(response.stopReason)
+ provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "Hi")), null, 1024)
+ Unit
}
// -- Request construction --
@Test
fun `sendMessage includes Bearer auth header`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "test")), null, 100)
@@ -129,7 +146,7 @@ class OpenAIProviderTest {
@Test
fun `sendMessage includes system prompt as system message`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(
listOf(LLMMessage(LLMMessage.Role.USER, "test")),
@@ -148,7 +165,7 @@ class OpenAIProviderTest {
@Test
fun `sendMessage omits system message when null`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "test")), null, 100)
@@ -161,7 +178,7 @@ class OpenAIProviderTest {
@Test
fun `sendMessage includes temperature when set`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "test")), null, 100, temperature = 0.8)
@@ -172,7 +189,7 @@ class OpenAIProviderTest {
@Test
fun `sendMessage omits temperature when null`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "test")), null, 100, temperature = null)
@@ -183,7 +200,7 @@ class OpenAIProviderTest {
@Test
fun `sendMessage uses max_completion_tokens for OpenAI`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "test")), null, 2048)
@@ -193,16 +210,18 @@ class OpenAIProviderTest {
assertTrue(!body.has("max_tokens"))
}
+ // nanoMuse: sendMessage streams on the wire (some gateways reject stream=false), so the
+ // request carries stream=true and asks for the usage chunk.
@Test
- fun `sendMessage sets stream false for non-streaming`() = runBlocking {
- server.enqueue(MockResponse().setBody("""{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":0,"completion_tokens":0}}"""))
+ fun `sendMessage streams on the wire and asks for usage`() = runBlocking {
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "test")), null, 100)
val request = server.takeRequest()
val body = JSONObject(request.body.readUtf8())
- assertEquals(false, body.getBoolean("stream"))
- assertTrue(!body.has("stream_options"))
+ assertTrue(body.getBoolean("stream"))
+ assertTrue(body.getJSONObject("stream_options").getBoolean("include_usage"))
}
// -- Streaming --
@@ -361,12 +380,7 @@ class OpenAIProviderTest {
/** Drive one request through the real stack and return the parsed body. */
private fun captureBody(model: LLMModel, level: ThinkingLevel): JSONObject {
- server.enqueue(
- MockResponse().setBody(
- """{"choices":[{"message":{"role":"assistant","content":"ok"},
- "finish_reason":"stop"}]}""".trimIndent(),
- ),
- )
+ server.enqueue(sseReply()) // nanoMuse: streamed, see sse().
provider.model = model
runBlocking {
provider.sendMessageClamped(
diff --git a/android/src/android/app/src/test/java/com/openminis/app/sandbox/TerminalSanitizerTest.kt b/android/src/android/app/src/test/java/com/openminis/app/sandbox/TerminalSanitizerTest.kt
index a2e0dd90..d1718d68 100644
--- a/android/src/android/app/src/test/java/com/openminis/app/sandbox/TerminalSanitizerTest.kt
+++ b/android/src/android/app/src/test/java/com/openminis/app/sandbox/TerminalSanitizerTest.kt
@@ -123,14 +123,16 @@ class TerminalSanitizerTest {
@Test
fun `sanitize strips ANSI after CR folding`() {
val input = "\u001B[32mdownloading\u001B[0m\r\u001B[32mcomplete \u001B[0m"
- assertEquals("complete ", TerminalSanitizer.sanitize(input))
+ // nanoMuse: sanitize() trims the final result, so the trailing spaces do not survive (#334)
+ assertEquals("complete", TerminalSanitizer.sanitize(input))
}
@Test
fun `sanitize handles real-world wget output`() {
// wget progress: " 50%[=====> ] 500K --.-KB/s\r 100%[=========>] 1.0M 1.5MB/s"
val input = " 50%[=====> ]\r 100%[=========>]"
- assertEquals(" 100%[=========>]", TerminalSanitizer.sanitize(input))
+ // nanoMuse: sanitize() trims the final result, so the leading space does not survive (#334)
+ assertEquals("100%[=========>]", TerminalSanitizer.sanitize(input))
}
// ==================== truncateIfNeeded ====================
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/avatar/AvatarStudioTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/avatar/AvatarStudioTest.kt
index 9f654e3e..576ee34d 100644
--- a/android/src/android/app/src/test/java/io/github/nanomuse/avatar/AvatarStudioTest.kt
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/avatar/AvatarStudioTest.kt
@@ -1,12 +1,105 @@
package io.github.nanomuse.avatar
+import android.content.Context
+import android.content.ContextWrapper
+import android.graphics.Bitmap
+import com.openminis.app.data.model.ProviderCredential
+import com.openminis.app.data.model.ProviderInstance
+import com.openminis.app.data.model.ProviderType
+import io.github.nanomuse.avatar.AvatarStudio.Slot
import io.github.nanomuse.ui.avatar.AgentMood
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicInteger
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
+import org.junit.Rule
import org.junit.Test
+import org.junit.rules.TemporaryFolder
class AvatarStudioTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private val endpoint = ImageGen.Endpoint(
+ instance = ProviderInstance(id = "p", label = "p", providerType = ProviderType.openAI, credentialType = ProviderCredential.apiKey),
+ apiKey = "k",
+ model = "m",
+ )
+ private val context: Context = object : ContextWrapper(null) {}
+ private val realDrawer = AvatarStudio.drawer
+
+ @After fun restore() {
+ AvatarStudio.drawer = realDrawer
+ }
+
+ /** The stubbed android.jar cannot make a Bitmap the usual way; its no-argument constructor does. */
+ private fun picture(): Bitmap = Bitmap::class.java.getDeclaredConstructor().apply { isAccessible = true }.newInstance()
+
+ private suspend fun waitUntil(what: String, condition: () -> Boolean) {
+ withTimeout(5_000) { while (!condition()) delay(10) }
+ assertTrue(what, condition())
+ }
+
+ @Test fun `a cancelled run does not mark the tiles of its replacement failed`() = runBlocking {
+ AvatarStore.useDirForTests(tmp.newFolder())
+ val firstGate = CompletableDeferred()
+ val secondGate = CompletableDeferred()
+ val pandas = AtomicInteger(0)
+ val bitmap = picture()
+ // Two pictures are drawn at a time (the lane), so a set has two calls in flight and two waiting.
+ AvatarStudio.drawer = { _, _, _, prompt ->
+ if ("panda" in prompt) { pandas.incrementAndGet(); firstGate.await() } else secondGate.await()
+ bitmap
+ }
+ AvatarStudio.startCandidates(context, endpoint, "a round red panda", AvatarStudio.Style.MUSE, null)
+ val first = AvatarStudio.candidateJob!!
+ waitUntil("the first set is being drawn") { pandas.get() == 2 }
+ // A second description while the first set is still drawing: the first run is cancelled,
+ // its four coroutines see CancellationException and must stay out of the new tiles.
+ AvatarStudio.startCandidates(context, endpoint, "a small blue whale", AvatarStudio.Style.MUSE, null)
+ first.join()
+ assertTrue(AvatarStudio.slots.value.toString(), AvatarStudio.slots.value.all { it is Slot.Loading })
+ secondGate.complete(Unit)
+ AvatarStudio.candidateJob!!.join()
+ assertTrue(AvatarStudio.slots.value.toString(), AvatarStudio.slots.value.all { it is Slot.Ready })
+ assertEquals(null, AvatarStudio.error.value)
+ }
+
+ @Test fun `a retry that finishes after a new set is asked for does not touch the new tiles`() = runBlocking {
+ AvatarStore.useDirForTests(tmp.newFolder())
+ val retryGate = CompletableDeferred()
+ val secondGate = CompletableDeferred()
+ val bitmap = picture()
+ var phase = 0
+ AvatarStudio.drawer = { _, _, _, _ ->
+ when (phase) {
+ 0 -> throw IOException("boom")
+ 1 -> { retryGate.await(); bitmap }
+ else -> { secondGate.await(); bitmap }
+ }
+ }
+ AvatarStudio.startCandidates(context, endpoint, "a round red panda", AvatarStudio.Style.MUSE, null)
+ AvatarStudio.candidateJob!!.join()
+ assertTrue(AvatarStudio.slots.value.all { it is Slot.Failed })
+ phase = 1
+ AvatarStudio.retrySlot(context, 0)
+ waitUntil("the retry is being drawn") { AvatarStudio.slots.value[0] is Slot.Loading }
+ phase = 2
+ AvatarStudio.startCandidates(context, endpoint, "a small blue whale", AvatarStudio.Style.MUSE, null)
+ // The retry was launched outside the run, so nothing cancels it; its picture belongs to
+ // the old set and must not land in the new one.
+ retryGate.complete(Unit)
+ delay(300)
+ assertTrue(AvatarStudio.slots.value.toString(), AvatarStudio.slots.value.all { it is Slot.Loading })
+ secondGate.complete(Unit)
+ AvatarStudio.candidateJob!!.join()
+ assertTrue(AvatarStudio.slots.value.toString(), AvatarStudio.slots.value.all { it is Slot.Ready })
+ }
@Test fun `four candidates get four different prompts from one description`() {
val prompts = (0 until 4).map { AvatarStudio.buildPrompt("A round red panda", AvatarStudio.Style.FLAT, it) }
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/avatar/OwnClipTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/avatar/OwnClipTest.kt
new file mode 100644
index 00000000..8d084948
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/avatar/OwnClipTest.kt
@@ -0,0 +1,225 @@
+package io.github.nanomuse.avatar
+
+import io.github.nanomuse.avatar.OwnClip.Probe
+import io.github.nanomuse.avatar.OwnClip.Refusal
+import java.io.File
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+
+/** The rule for a clip of one's own (#324) and what the store does with the files. */
+class OwnClipTest {
+ @get:Rule val tmp = TemporaryFolder()
+
+ private fun ok(
+ container: String? = "video/mp4",
+ codec: String? = OwnClip.H264,
+ durationMs: Long? = 8_000L,
+ bytes: Long? = 4L * 1024 * 1024,
+ ) = Probe(container, codec, durationMs, bytes)
+
+ @Test fun `an H264 MP4 within the limits is taken`() {
+ assertNull(OwnClip.check(ok()))
+ }
+
+ @Test fun `a MOV with H264 video is taken too`() {
+ assertNull(OwnClip.check(ok(container = "video/quicktime")))
+ }
+
+ @Test fun `a clip that rounds down to 15 seconds is taken and 16 seconds is not`() {
+ assertNull(OwnClip.check(ok(durationMs = 15_000L)))
+ assertNull(OwnClip.check(ok(durationMs = 15_500L)))
+ assertNull(OwnClip.check(ok(durationMs = 15_999L)))
+ assertEquals(Refusal.TOO_LONG, OwnClip.check(ok(durationMs = 16_000L)))
+ assertEquals(Refusal.TOO_LONG, OwnClip.check(ok(durationMs = 60_000L)))
+ }
+
+ @Test fun `a picture or a text file has no video track and is a format refusal`() {
+ // what probe() returns for a JPEG: the retriever gives a container and no length, the extractor no video track
+ assertEquals(Refusal.FORMAT, OwnClip.check(Probe("image/jpeg", null, null, 300_000L)))
+ // a text file: nothing can be read of it but its size
+ assertEquals(Refusal.FORMAT, OwnClip.check(Probe(null, null, null, 12L)))
+ // an audio-only MP4: a container and a length, but no video track
+ assertEquals(Refusal.FORMAT, OwnClip.check(Probe("video/mp4", null, 8_000L, 300_000L)))
+ }
+
+ @Test fun `a HEVC MOV is refused for its codec, not its container`() {
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(container = "video/quicktime", codec = "video/hevc")))
+ }
+
+ @Test fun `30 MB is the largest file taken`() {
+ assertNull(OwnClip.check(ok(bytes = OwnClip.MAX_BYTES)))
+ assertEquals(Refusal.TOO_LARGE, OwnClip.check(ok(bytes = OwnClip.MAX_BYTES + 1)))
+ assertEquals(30L * 1024 * 1024, OwnClip.MAX_BYTES)
+ assertEquals(15, OwnClip.MAX_SECONDS)
+ }
+
+ @Test fun `another codec or container is a format refusal`() {
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(codec = "video/hevc")))
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(codec = "video/x-vnd.on2.vp9")))
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(container = "video/webm")))
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(container = "video/x-matroska")))
+ }
+
+ @Test fun `a file whose tracks or length cannot be read is a format refusal, whatever its size`() {
+ assertEquals(Refusal.FORMAT, OwnClip.check(Probe(null, null, null, 100L)))
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(codec = null)))
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(durationMs = null)))
+ assertEquals(Refusal.FORMAT, OwnClip.check(Probe(null, null, null, OwnClip.MAX_BYTES * 2)))
+ }
+
+ @Test fun `an unknown container or size does not stand in the way of a readable H264 clip`() {
+ assertNull(OwnClip.check(ok(container = null)))
+ assertNull(OwnClip.check(ok(bytes = null)))
+ }
+
+ @Test fun `the format is checked before the length and the length before the size`() {
+ assertEquals(Refusal.FORMAT, OwnClip.check(ok(codec = "video/hevc", durationMs = 60_000L, bytes = OwnClip.MAX_BYTES * 2)))
+ assertEquals(Refusal.TOO_LONG, OwnClip.check(ok(durationMs = 60_000L, bytes = OwnClip.MAX_BYTES * 2)))
+ }
+
+ // -- the copy -----------------------------------------------------------------------------
+
+ @Test fun `a file within the limit is copied whole and its size reported`() {
+ val dst = File(tmp.newFolder(), "pending.mp4")
+ val bytes = ByteArray(200_000) { (it % 251).toByte() }
+ assertEquals(200_000L, OwnClip.copyBounded(bytes.inputStream(), dst, limit = 1_000_000L))
+ assertTrue(dst.readBytes().contentEquals(bytes))
+ }
+
+ @Test fun `a file exactly at the limit is taken`() {
+ val dst = File(tmp.newFolder(), "pending.mp4")
+ val bytes = ByteArray(70_000)
+ assertEquals(70_000L, OwnClip.copyBounded(bytes.inputStream(), dst, limit = 70_000L))
+ assertEquals(70_000L, dst.length())
+ }
+
+ @Test fun `the copy stops at the limit, deletes the partial file and reads no further`() {
+ val dst = File(tmp.newFolder(), "pending.mp4")
+ var served = 0L
+ val endless = object : java.io.InputStream() {
+ override fun read(): Int { served++; return 7 }
+ override fun read(b: ByteArray, off: Int, len: Int): Int { served += len; return len }
+ }
+ assertNull(OwnClip.copyBounded(endless, dst, limit = 1_000_000L))
+ assertFalse("the partial copy is gone", dst.exists())
+ assertTrue("stopped soon after the limit, not at the end of the stream (read $served bytes)", served <= 1_000_000L + 2 * 64 * 1024)
+ }
+
+ @Test fun `the default limit is the 30 MB rule`() {
+ val dst = File(tmp.newFolder(), "pending.mp4")
+ val overLimit = object : java.io.InputStream() {
+ var left = OwnClip.MAX_BYTES + 1
+ override fun read(): Int = if (left-- > 0) 0 else -1
+ override fun read(b: ByteArray, off: Int, len: Int): Int {
+ if (left <= 0) return -1
+ val n = minOf(len.toLong(), left).toInt(); left -= n; return n
+ }
+ }
+ assertNull(OwnClip.copyBounded(overLimit, dst))
+ assertFalse(dst.exists())
+ }
+
+ // -- the files ----------------------------------------------------------------------------
+
+ private fun pending(root: File, content: String = "clip"): OwnClip.Pending {
+ val f = File(root, "own-clip.pending.mp4").apply { writeText(content) }
+ return OwnClip.Pending(f, poster = null, durationMs = 8_000L, bytes = f.length())
+ }
+
+ @Test fun `use moves the pending copy into place and the store sees it`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ OwnClip.init()
+ assertNull(OwnClip.current.value)
+ val p = pending(root)
+ OwnClip.adopt(p)
+ val clip = OwnClip.current.value
+ assertEquals(File(root, "own-clip.mp4"), clip?.file)
+ assertEquals("clip", clip?.file?.readText())
+ assertFalse("the pending copy is gone", p.file.exists())
+ assertNull("no first frame could be decoded here, so no poster", OwnClip.poster.value)
+ assertTrue(clip!!.key.startsWith(clip.file.path))
+ }
+
+ @Test fun `cancel in the preview deletes the copy and changes nothing`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ OwnClip.init()
+ val p = pending(root)
+ OwnClip.discard(p)
+ assertFalse(p.file.exists())
+ assertNull(OwnClip.current.value)
+ assertFalse(File(root, "own-clip.mp4").exists())
+ }
+
+ @Test fun `default face removes the clip and its still`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ OwnClip.init()
+ OwnClip.adopt(pending(root))
+ File(root, "own-clip.png").writeText("png")
+ OwnClip.rescan()
+ OwnClip.clear()
+ assertNull(OwnClip.current.value)
+ assertNull(OwnClip.poster.value)
+ assertFalse(File(root, "own-clip.mp4").exists())
+ assertFalse(File(root, "own-clip.png").exists())
+ }
+
+ @Test fun `a replaced clip gets a new key so the player starts over`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ OwnClip.init()
+ OwnClip.adopt(pending(root, "one"))
+ val first = OwnClip.current.value!!
+ val second = pending(root, "two")
+ second.file.setLastModified(first.modified + 5_000L)
+ OwnClip.adopt(second)
+ val now = OwnClip.current.value!!
+ assertEquals("two", now.file.readText())
+ assertEquals(first.file, now.file)
+ assertNotEquals(first.key, now.key)
+ }
+
+ @Test fun `a pending copy left behind by a crash is dropped on start`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ val stale = File(root, "own-clip.pending.mp4").apply { writeText("stale") }
+ OwnClip.init()
+ assertFalse(stale.exists())
+ assertNull(OwnClip.current.value)
+ }
+
+ @Test fun `an empty file is no clip`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ File(root, "own-clip.mp4").writeBytes(ByteArray(0))
+ OwnClip.rescan()
+ assertNull(OwnClip.current.value)
+ }
+
+ @Test fun `the clip lives beside the studio pictures and survives a reset of the studio face`() {
+ val root = tmp.newFolder()
+ AvatarStore.useDirForTests(root)
+ OwnClip.init()
+ OwnClip.adopt(pending(root))
+ // the studio's own files, and a clip the video model made
+ AvatarStore.baseFile().writeText("base")
+ File(AvatarStore.root(), "motion").mkdirs()
+ AvatarMotion.clipFile(io.github.nanomuse.ui.avatar.AgentMood.IDLE).writeText("idle")
+ AvatarMotion.rescan()
+ assertEquals(1, AvatarMotion.clips.value.size)
+ AvatarStore.reset()
+ assertFalse(AvatarStore.baseFile().exists())
+ assertTrue("the studio clips belong to the studio face", AvatarMotion.clips.value.isEmpty())
+ assertTrue("the person's own clip stays", OwnClip.clipFile().exists())
+ OwnClip.rescan()
+ assertEquals(File(root, "own-clip.mp4"), OwnClip.current.value?.file)
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/chat/InterruptedReplyTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/chat/InterruptedReplyTest.kt
new file mode 100644
index 00000000..efc4f5fe
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/chat/InterruptedReplyTest.kt
@@ -0,0 +1,45 @@
+package io.github.nanomuse.chat
+
+import com.openminis.app.ui.chat.AssistantBlock
+import com.openminis.app.ui.chat.ChatMessage
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class InterruptedReplyTest {
+ private fun intro(i: Int) = ChatMessage(
+ id = "nm_intro_$i",
+ role = "assistant",
+ content = "Hello, I am nanoMuse. $i",
+ toolBlocks = listOf(AssistantBlock(id = "nm_intro_${i}_text", kind = "text", content = "Hello, I am nanoMuse. $i")),
+ )
+ private val namingCard = ChatMessage(id = "nm_naming_card", role = "system", content = "")
+ private val user = ChatMessage(id = "u-1", role = "user", content = "Call yourself Pip")
+ private val placeholder = ChatMessage(id = "a-1", role = "assistant", content = "", isStreaming = true, isAwaitingModelResponse = true)
+ private val reply = ChatMessage(id = "a-0", role = "assistant", content = "Pip it is.")
+
+ @Test fun `the opening lines and the cards are never the interrupted reply`() {
+ assertEquals(-1, InterruptedReply.lastRealAssistantIndex(listOf(intro(0), intro(1), intro(2), namingCard)))
+ assertEquals(-1, InterruptedReply.lastRealAssistantIndex(listOf(intro(0), intro(1), intro(2), user)))
+ assertEquals(-1, InterruptedReply.lastRealAssistantIndex(emptyList()))
+ }
+
+ @Test fun `the streaming placeholder after the opening is`() {
+ val msgs = listOf(intro(0), intro(1), intro(2), user, placeholder)
+ assertEquals(4, InterruptedReply.lastRealAssistantIndex(msgs))
+ }
+
+ @Test fun `the last reply of the model wins over an earlier one and over a later card`() {
+ val msgs = listOf(user, reply, user.copy(id = "u-2"), placeholder, namingCard)
+ assertEquals(3, InterruptedReply.lastRealAssistantIndex(msgs))
+ assertEquals(1, InterruptedReply.lastRealAssistantIndex(listOf(user, reply, namingCard)))
+ }
+
+ @Test fun `only our prefix marks a message as virtual`() {
+ assertTrue(InterruptedReply.isVirtual("nm_intro_2"))
+ assertTrue(InterruptedReply.isVirtual("nm_avatar_share_card"))
+ assertFalse(InterruptedReply.isVirtual("a-1"))
+ assertFalse(InterruptedReply.isVirtual("0c6b7f2e-nm_"))
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/cloud/NanoMuseCloudCancelTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/cloud/NanoMuseCloudCancelTest.kt
new file mode 100644
index 00000000..b2036110
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/cloud/NanoMuseCloudCancelTest.kt
@@ -0,0 +1,76 @@
+package io.github.nanomuse.cloud
+
+import android.content.Context
+import android.content.ContextWrapper
+import android.content.SharedPreferences
+import io.github.nanomuse.net.HoldingServer
+import java.util.concurrent.TimeUnit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The relay client's requests follow their coroutine: leaving the sign-in page while the code
+ * is being asked for hangs up instead of parking an IO thread until the read timeout (#336).
+ */
+class NanoMuseCloudCancelTest {
+
+ /** A context whose preferences live in a map: enough for [NanoMuseCloud.baseUrl]. */
+ private class PrefsContext : ContextWrapper(null) {
+ private val stores = mutableMapOf()
+ override fun getSharedPreferences(name: String, mode: Int): SharedPreferences = stores.getOrPut(name) { MapPrefs() }
+ override fun getApplicationContext(): Context = this
+ }
+
+ private class MapPrefs : SharedPreferences {
+ val values = mutableMapOf()
+ override fun getAll(): MutableMap = values
+ override fun getString(key: String, defValue: String?): String? = values[key] as? String ?: defValue
+ override fun getStringSet(key: String, defValues: MutableSet?): MutableSet? =
+ @Suppress("UNCHECKED_CAST") (values[key] as? MutableSet) ?: defValues
+ override fun getInt(key: String, defValue: Int): Int = values[key] as? Int ?: defValue
+ override fun getLong(key: String, defValue: Long): Long = values[key] as? Long ?: defValue
+ override fun getFloat(key: String, defValue: Float): Float = values[key] as? Float ?: defValue
+ override fun getBoolean(key: String, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
+ override fun contains(key: String): Boolean = values.containsKey(key)
+ override fun edit(): SharedPreferences.Editor = Editor()
+ override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+ override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {}
+
+ inner class Editor : SharedPreferences.Editor {
+ private val pending = mutableMapOf()
+ private val removed = mutableSetOf()
+ private var clearAll = false
+ override fun putString(key: String, value: String?) = apply { pending[key] = value }
+ override fun putStringSet(key: String, values: MutableSet?) = apply { pending[key] = values }
+ override fun putInt(key: String, value: Int) = apply { pending[key] = value }
+ override fun putLong(key: String, value: Long) = apply { pending[key] = value }
+ override fun putFloat(key: String, value: Float) = apply { pending[key] = value }
+ override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value }
+ override fun remove(key: String) = apply { removed += key }
+ override fun clear() = apply { clearAll = true }
+ override fun commit(): Boolean { apply(); return true }
+ override fun apply() {
+ if (clearAll) values.clear()
+ removed.forEach { values.remove(it) }
+ values.putAll(pending)
+ }
+ }
+ }
+
+ @Test fun `cancelling the code request hangs up on the relay at once`() = runBlocking {
+ HoldingServer().use { server ->
+ val context = PrefsContext()
+ NanoMuseCloud.setBaseUrl(context, "http://127.0.0.1:${server.port}")
+ // on another thread: the latch waits below block this one
+ val job = launch(Dispatchers.Default) { NanoMuseCloud.requestCode(context, "someone") }
+ assertTrue("the request never reached the relay", server.accepted.await(5, TimeUnit.SECONDS))
+ job.cancel()
+ // Before #336 the socket stayed open until the client's 30 s read timeout.
+ assertTrue("the relay connection stayed open after the cancel", server.closedByClient.await(1, TimeUnit.SECONDS))
+ job.join()
+ }
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/debug/LLMRequestLogTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/debug/LLMRequestLogTest.kt
new file mode 100644
index 00000000..54fa82e4
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/debug/LLMRequestLogTest.kt
@@ -0,0 +1,95 @@
+package io.github.nanomuse.debug
+
+import com.openminis.app.debug.LLMRequestLog
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * The debug request log keeps its own masked copy of the request headers (#347).
+ * BuildConfig.DEBUG is true under testDebugUnitTest, so add() records.
+ */
+class LLMRequestLogTest {
+ private val credentialNames = listOf(
+ "Authorization", "PROXY-AUTHORIZATION", "x-api-key", "Api-Key", "apikey",
+ "X-Goog-Api-Key", "cookie", "Set-Cookie", "X-Access-Token",
+ )
+
+ private fun entry(headers: Map, body: String = "{\"model\":\"m\"}") =
+ LLMRequestLog.Entry(
+ provider = "test",
+ requestURL = "https://api.example.com/v1/chat/completions",
+ requestHeaders = headers,
+ requestBody = body,
+ )
+
+ private fun headersOf(json: JSONObject, index: Int): JSONObject =
+ json.getJSONArray("requests").getJSONObject(index).getJSONObject("requestHeaders")
+
+ private fun assertMasked(headers: Map) {
+ for (name in credentialNames) assertEquals(name, "***", headers[name])
+ assertEquals("application/json", headers["Content-Type"])
+ assertFalse(headers.values.any { it.contains("secret") })
+ }
+
+ private fun assertMasked(headers: JSONObject) {
+ for (name in credentialNames) assertEquals(name, "***", headers.getString(name))
+ assertEquals("application/json", headers.getString("Content-Type"))
+ for (key in headers.keys()) assertFalse(key, headers.getString(key).contains("secret"))
+ }
+
+ @Before fun clear() = LLMRequestLog.clear()
+
+ @Test fun `credential headers are masked everywhere the log is read`() {
+ val headers = LinkedHashMap()
+ headers["Content-Type"] = "application/json"
+ for (name in credentialNames) headers[name] = "secret-$name"
+ LLMRequestLog.add(entry(headers))
+
+ assertMasked(LLMRequestLog.getAll().single().requestHeaders)
+ assertMasked(LLMRequestLog.getLast(1).single().requestHeaders)
+ assertMasked(headersOf(LLMRequestLog.toJSON(), 0))
+ assertMasked(headersOf(LLMRequestLog.toJSON(1), 0))
+ assertEquals(listOf("Content-Type") + credentialNames, LLMRequestLog.getAll().single().requestHeaders.keys.toList())
+ }
+
+ @Test fun `the recorded entry is independent of the caller's map`() {
+ val headers = mutableMapOf("Content-Type" to "application/json", "X-Trace" to "a")
+ LLMRequestLog.add(entry(headers))
+ headers["X-Trace"] = "b"
+ headers["X-API-Key"] = "late-secret"
+ headers.remove("Content-Type")
+
+ val recorded = LLMRequestLog.getAll().single().requestHeaders
+ assertEquals("a", recorded["X-Trace"])
+ assertEquals("application/json", recorded["Content-Type"])
+ assertFalse(recorded.containsKey("X-API-Key"))
+ val json = headersOf(LLMRequestLog.toJSON(), 0)
+ assertEquals("a", json.getString("X-Trace"))
+ assertFalse(json.has("X-API-Key"))
+ }
+
+ @Test fun `a long body is still truncated and the headers still masked`() {
+ val body = "x".repeat(64 * 1024 + 100)
+ LLMRequestLog.add(entry(mapOf("Authorization" to "Bearer secret", "Content-Type" to "application/json"), body))
+
+ val recorded = LLMRequestLog.getAll().single()
+ assertTrue(recorded.requestBody.endsWith("[truncated 100 chars]"))
+ assertEquals(64 * 1024, recorded.requestBody.indexOf('\n'))
+ assertEquals("***", recorded.requestHeaders["Authorization"])
+ assertEquals("application/json", recorded.requestHeaders["Content-Type"])
+ }
+
+ @Test fun `the ring keeps the newest twenty in order`() {
+ for (i in 1..21) LLMRequestLog.add(entry(mapOf("X-N" to "$i"), body = "{\"n\":$i}"))
+
+ val all = LLMRequestLog.getAll()
+ assertEquals(20, all.size)
+ assertEquals((2..21).map { "$it" }, all.map { it.requestHeaders["X-N"] })
+ assertEquals("21", LLMRequestLog.getLast(1).single().requestHeaders["X-N"])
+ assertEquals(20, LLMRequestLog.toJSON().getInt("count"))
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/hub/HubClientTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/hub/HubClientTest.kt
new file mode 100644
index 00000000..e8d79d00
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/hub/HubClientTest.kt
@@ -0,0 +1,62 @@
+package io.github.nanomuse.hub
+
+import org.json.JSONObject
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/** The client's behaviour apart from the socket: what a call does when the socket is gone. */
+class HubClientTest {
+ private fun client(onState: (Boolean, String) -> Unit = { _, _ -> }) = HubClient(
+ url = "wss://relay.example/v1/hub",
+ key = "nm_test",
+ hello = { JSONObject() },
+ onCall = {},
+ onDevices = {},
+ onState = onState,
+ )
+
+ private fun call(client: HubClient) = IncomingCall(
+ id = "c1",
+ from = JSONObject().put("id", "pc-1").put("name", "Desk"),
+ action = "task",
+ args = JSONObject(),
+ client = client,
+ )
+
+ @Test fun `an answer on a dropped socket is lost quietly, not thrown`() {
+ val c = call(client())
+ c.event(JSONObject().put("stage", "thinking"))
+ c.result(JSONObject().put("text", "done"))
+ assertTrue(c.answered)
+ c.fail("failed", "late")
+ assertTrue(c.answered)
+ }
+
+ @Test fun `a call is answered once`() {
+ val c = call(client())
+ assertFalse(c.answered)
+ c.fail("not_allowed", "no")
+ assertTrue(c.answered)
+ c.result(JSONObject())
+ assertTrue(c.answered)
+ }
+
+ @Test fun `our own call throws disconnected when there is no socket`() {
+ val states = mutableListOf()
+ val c = client { _, detail -> states += detail }
+ val e = runCatching { c.call("pc-1", "info", JSONObject(), timeoutMs = 1_000L) }.exceptionOrNull()
+ assertTrue(e is HubException)
+ assertEquals("disconnected", (e as HubException).code)
+ assertTrue("no state change was reported for a client that never started", states.isEmpty())
+ }
+
+ @Test fun `a stopped client reports no state`() {
+ val states = mutableListOf>()
+ val c = client { on, detail -> states += on to detail }
+ c.stop()
+ assertFalse(c.connected)
+ assertTrue(states.isEmpty())
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/i18n/NmStringsParityTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/i18n/NmStringsParityTest.kt
index edcb8a90..736f9b02 100644
--- a/android/src/android/app/src/test/java/io/github/nanomuse/i18n/NmStringsParityTest.kt
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/i18n/NmStringsParityTest.kt
@@ -59,6 +59,23 @@ class NmStringsParityTest {
}
}
+ /**
+ * aapt strips a bare `"` from a string (it reads it as quoting, to keep whitespace), so
+ * *answer "always allow"* reaches the screen as *answer always allow*. A quote inside a
+ * string is written `\"`, or as the language's own marks (“” 「」 „“ «»); a string wrapped
+ * whole in quotes, to keep a trailing space, is the one allowed use.
+ */
+ @Test fun `no bare double quote inside a string`() {
+ for (f in localeFiles() + File(res, "values/nm_strings.xml")) {
+ val bare = stringRx.findAll(f.readText()).filter { m ->
+ val body = m.groupValues[2]
+ val wrapped = body.length >= 2 && body.startsWith("\"") && body.endsWith("\"") && body.count { it == '"' } == 2
+ !wrapped && Regex("""(?(), bare)
+ }
+ }
+
/** The house style: no em dashes in copy; a comma, colon or full stop instead. 非营利 is the word, not 非盈利. */
@Test fun `no em dashes and no 非盈利 in the strings`() {
val commentRx = Regex("""""", RegexOption.DOT_MATCHES_ALL)
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/models/AnthropicApiKeyRequestTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/models/AnthropicApiKeyRequestTest.kt
new file mode 100644
index 00000000..d3bcd930
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/models/AnthropicApiKeyRequestTest.kt
@@ -0,0 +1,75 @@
+package io.github.nanomuse.models
+
+import com.openminis.app.data.model.LLMMessage
+import com.openminis.app.data.model.LLMModel
+import com.openminis.app.provider.anthropic.AnthropicProvider
+import kotlinx.coroutines.runBlocking
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.json.JSONObject
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Before
+import org.junit.Test
+import java.util.concurrent.TimeUnit
+
+/**
+ * An own Anthropic API key on the public build (#333): the provider must not touch the Claude Code
+ * OAuth identifier, which is empty there and throws when read, so a request has to leave the phone
+ * with or without a system prompt.
+ */
+class AnthropicApiKeyRequestTest {
+ private lateinit var server: MockWebServer
+ private lateinit var provider: AnthropicProvider
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ provider = AnthropicProvider(
+ apiKey = "test-key",
+ model = LLMModel.claudeHaiku45,
+ basePath = server.url("/").toString().trimEnd('/'),
+ )
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun enqueueReply() {
+ server.enqueue(
+ MockResponse().setBody(
+ """{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}""",
+ ),
+ )
+ }
+
+ @Test
+ fun `an API key with no system prompt sends one request and no system field`() = runBlocking {
+ enqueueReply()
+
+ val response = provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "hi")), null, 256)
+
+ assertEquals("ok", response.text)
+ val request = server.takeRequest(5, TimeUnit.SECONDS)!!
+ val body = JSONObject(request.body.readUtf8())
+ assertFalse("an API-key request without a prompt carries no system field", body.has("system"))
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun `an API key sends the system prompt as the ordinary prompt`() = runBlocking {
+ enqueueReply()
+
+ provider.sendMessage(listOf(LLMMessage(LLMMessage.Role.USER, "hi")), "You are concise.", 256)
+
+ val body = JSONObject(server.takeRequest(5, TimeUnit.SECONDS)!!.body.readUtf8())
+ val system = body.getJSONArray("system")
+ assertEquals(1, system.length())
+ assertEquals("You are concise.", system.getJSONObject(0).getString("text"))
+ assertEquals(1, server.requestCount)
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/net/HoldingServer.kt b/android/src/android/app/src/test/java/io/github/nanomuse/net/HoldingServer.kt
new file mode 100644
index 00000000..1af84766
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/net/HoldingServer.kt
@@ -0,0 +1,40 @@
+package io.github.nanomuse.net
+
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.util.concurrent.CountDownLatch
+import kotlin.concurrent.thread
+
+/**
+ * A loopback server for the cancellation tests: it accepts one connection, reads whatever the
+ * client sends and never answers, so the only way the line ends is the client hanging up.
+ * [accepted] opens once the client is connected; [closedByClient] once the client closed the
+ * socket (the read returned end of stream).
+ */
+class HoldingServer : AutoCloseable {
+ private val socket = ServerSocket(0, 1, InetAddress.getLoopbackAddress())
+ val port: Int get() = socket.localPort
+ val accepted = CountDownLatch(1)
+ val closedByClient = CountDownLatch(1)
+
+ init {
+ thread(isDaemon = true, name = "holding-server") {
+ runCatching {
+ socket.accept().use { client ->
+ accepted.countDown()
+ client.soTimeout = 60_000
+ val input = client.getInputStream()
+ val buffer = ByteArray(4096)
+ while (input.read(buffer) >= 0) {
+ // the request arrives here; keep reading until the client hangs up
+ }
+ closedByClient.countDown()
+ }
+ }
+ }
+ }
+
+ override fun close() {
+ socket.close()
+ }
+}
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/net/OwnProviderProxyTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/net/OwnProviderProxyTest.kt
index 2faaf9de..c71c8f6a 100644
--- a/android/src/android/app/src/test/java/io/github/nanomuse/net/OwnProviderProxyTest.kt
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/net/OwnProviderProxyTest.kt
@@ -1,5 +1,8 @@
package io.github.nanomuse.net
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
@@ -12,6 +15,7 @@ import org.junit.Test
import java.net.Proxy
import java.net.ProxySelector
import java.net.URI
+import java.util.concurrent.TimeUnit
class OwnProviderProxyTest {
private val routed = setOf("chatgpt.com", "auth.openai.com", "api.openai.com", "dashscope.aliyuncs.com")
@@ -84,6 +88,19 @@ class OwnProviderProxyTest {
assertNull(OwnProviderProxy.authenticator.authenticate(null, challenge(answered)))
}
+ @Test fun `cancelling the probe hangs up on the proxy at once`() = runBlocking {
+ HoldingServer().use { server ->
+ val cfg = OwnProviderProxy.Config(enabled = true, host = "127.0.0.1", port = server.port)
+ // on another thread: the latch waits below block this one
+ val job = launch(Dispatchers.Default) { OwnProviderProxy.probe(cfg, "http://provider.invalid/") }
+ assertTrue("the probe never reached the proxy", server.accepted.await(5, TimeUnit.SECONDS))
+ job.cancel()
+ // Before #336 the socket stayed open until the 10 s read timeout.
+ assertTrue("the proxy connection stayed open after the cancel", server.closedByClient.await(1, TimeUnit.SECONDS))
+ job.join()
+ }
+ }
+
@Test fun `the host of a URL`() {
assertEquals("chatgpt.com", OwnProviderProxy.hostOf("https://chatgpt.com/backend-api"))
assertEquals("dashscope.aliyuncs.com", OwnProviderProxy.hostOf(" https://dashscope.aliyuncs.com/compatible-mode/v1 "))
diff --git a/android/src/android/app/src/test/java/io/github/nanomuse/sandbox/TerminalCarriageReturnTest.kt b/android/src/android/app/src/test/java/io/github/nanomuse/sandbox/TerminalCarriageReturnTest.kt
new file mode 100644
index 00000000..e9719216
--- /dev/null
+++ b/android/src/android/app/src/test/java/io/github/nanomuse/sandbox/TerminalCarriageReturnTest.kt
@@ -0,0 +1,37 @@
+package io.github.nanomuse.sandbox
+
+import com.openminis.app.sandbox.TerminalSanitizer
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * A carriage return in tool output moves the cursor to column 0; what follows overwrites from there
+ * and a shorter rewrite keeps the old tail (#334). The model reads the result, so nothing may be lost.
+ */
+class TerminalCarriageReturnTest {
+
+ @Test
+ fun `a shorter rewrite keeps the tail of the longer text`() {
+ assertEquals("BBAA", TerminalSanitizer.sanitize("AAAA\rBB"))
+ }
+
+ @Test
+ fun `a progress line ends with its last state`() {
+ assertEquals("100%", TerminalSanitizer.sanitize("10%\r50%\r100%"))
+ }
+
+ @Test
+ fun `a newline ends the overwrite`() {
+ assertEquals("LINE1\nline2", TerminalSanitizer.sanitize("line1\rLINE\nline2"))
+ }
+
+ @Test
+ fun `CRLF is a plain line break`() {
+ assertEquals("line1\nline2\nline3", TerminalSanitizer.sanitize("line1\r\nline2\r\nline3"))
+ }
+
+ @Test
+ fun `a longer rewrite replaces the whole line`() {
+ assertEquals("downloaded", TerminalSanitizer.sanitize("busy\rdownloaded"))
+ }
+}
diff --git a/android/src/ios/Localizable.xcstrings b/android/src/ios/Localizable.xcstrings
index 9867d78a..bfdb92ea 100644
--- a/android/src/ios/Localizable.xcstrings
+++ b/android/src/ios/Localizable.xcstrings
@@ -792,6 +792,65 @@
}
}
},
+ "%d s · %@" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d s · %@"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d s · %@"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d s · %@"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d s · %@"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d 秒 · %@"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d초 · %@"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d с · %@"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d 秒 · %@"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d 秒 · %@"
+ }
+ }
+ }
+ },
"%d thinking rules" : {
"extractionState" : "manual",
"localizations" : {
@@ -1830,6 +1889,65 @@
}
}
},
+ "A short video of yours plays as the face at rest, and the other moods fall back to it. MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB; it plays muted in a loop." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ein kurzes Video von dir läuft als Gesicht im Ruhezustand, und die anderen Stimmungen greifen darauf zurück. MP4 oder MOV mit H.264- oder HEVC-Video, höchstens 15 Sekunden und 30 MB; es läuft stumm in einer Schleife."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "A short video of yours plays as the face at rest, and the other moods fall back to it. MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB; it plays muted in a loop."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un vídeo corto tuyo se reproduce como la cara en reposo, y los demás estados recurren a él. MP4 o MOV con vídeo H.264 o HEVC, de hasta 15 segundos y 30 MB; se reproduce sin sonido en bucle."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Une courte vidéo à vous s'affiche comme visage au repos, et les autres humeurs s'y rabattent. MP4 ou MOV en vidéo H.264 ou HEVC, 15 secondes et 30 Mo au plus ; elle tourne en boucle, sans le son."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "自分の短い動画が待機中の顔として再生され、他の状態でも同じ動画が使われます。H.264 または HEVC の MP4 または MOV、最長 15 秒、最大 30 MB。無音でループ再生されます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "내 짧은 동영상이 대기 중 얼굴로 재생되고, 다른 상태에서도 같은 동영상을 씁니다. H.264 또는 HEVC 영상의 MP4 또는 MOV, 최대 15초, 30 MB까지. 음소거로 반복 재생됩니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Короткое видео из вашей галереи играет как лицо в покое, а остальные состояния тоже используют его. MP4 или MOV с видео H.264 или HEVC, не длиннее 15 секунд и не больше 30 МБ; играет без звука, по кругу."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "一段你自己的短视频会作为待机时的形象播放,其他状态也沿用它。MP4 或 MOV,H.264 或 HEVC 视频,最长 15 秒、最大 30 MB;静音循环播放。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "一段你自己的短影片會作為待機時的形象播放,其他狀態也沿用它。MP4 或 MOV,H.264 或 HEVC 影片,最長 15 秒、最大 30 MB;靜音循環播放。"
+ }
+ }
+ }
+ },
"A subscription you already pay for" : {
"extractionState" : "manual",
"localizations" : {
@@ -2966,6 +3084,124 @@
}
}
},
+ "Choose a clip" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clip auswählen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choose a clip"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elegir un clip"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Choisir un clip"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "動画を選ぶ"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "동영상 선택"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Выбрать клип"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "选择视频"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "選擇影片"
+ }
+ }
+ }
+ },
+ "Default face" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Standardgesicht"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Default face"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cara predeterminada"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visage par défaut"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "標準の顔"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "기본 얼굴"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Лицо по умолчанию"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "默认形象"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "預設形象"
+ }
+ }
+ }
+ },
"Existing session to continue. Leave empty for the chat that is open." : {
"localizations" : {
"de" : {
@@ -3082,6 +3318,537 @@
}
}
},
+ "Preview of your clip" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vorschau deines Clips"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Preview of your clip"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vista previa de tu clip"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aperçu de votre clip"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "自分の動画のプレビュー"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "내 동영상 미리보기"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Предпросмотр вашего клипа"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你的视频预览"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你的影片預覽"
+ }
+ }
+ }
+ },
+ "Replace the clip" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clip ersetzen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Replace the clip"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sustituir el clip"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Remplacer le clip"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "動画を差し替える"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "동영상 바꾸기"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Заменить клип"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "更换视频"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "更換影片"
+ }
+ }
+ }
+ },
+ "That clip is %@. The limit is 30 MB, and 15 seconds." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dieser Clip hat %@. Die Grenze liegt bei 30 MB und 15 Sekunden."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "That clip is %@. The limit is 30 MB, and 15 seconds."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ese clip ocupa %@. El límite es 30 MB, y 15 segundos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ce clip pèse %@. La limite est de 30 Mo, et 15 secondes."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "この動画は %@ あります。上限は 30 MB、および 15 秒です。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 동영상은 %@입니다. 제한은 30 MB, 그리고 15초입니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Этот клип весит %@. Предел 30 МБ, и 15 секунд."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "这段视频有 %@。上限是 30 MB,以及 15 秒。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "這段影片有 %@。上限是 30 MB,以及 15 秒。"
+ }
+ }
+ }
+ },
+ "That clip runs %d seconds. The limit is 15 seconds, and 30 MB." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dieser Clip dauert %d Sekunden. Die Grenze liegt bei 15 Sekunden und 30 MB."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "That clip runs %d seconds. The limit is 15 seconds, and 30 MB."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ese clip dura %d segundos. El límite es 15 segundos, y 30 MB."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ce clip dure %d secondes. La limite est de 15 secondes, et 30 Mo."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "この動画は %d 秒あります。上限は 15 秒、および 30 MB です。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 동영상은 %d초입니다. 제한은 15초, 그리고 30 MB입니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Этот клип длится %d с. Предел 15 секунд, и 30 МБ."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "这段视频长 %d 秒。上限是 15 秒,以及 30 MB。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "這段影片長 %d 秒。上限是 15 秒,以及 30 MB。"
+ }
+ }
+ }
+ },
+ "That file cannot be used here. Pick an MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Diese Datei kann hier nicht verwendet werden. Wähle ein MP4 oder MOV mit H.264- oder HEVC-Video, höchstens 15 Sekunden und 30 MB."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "That file cannot be used here. Pick an MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ese archivo no se puede usar aquí. Elige un MP4 o MOV con vídeo H.264 o HEVC, de hasta 15 segundos y 30 MB."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ce fichier ne peut pas être utilisé ici. Choisissez un MP4 ou MOV en vidéo H.264 ou HEVC, 15 secondes et 30 Mo au plus."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このファイルはここでは使えません。H.264 または HEVC の MP4 または MOV で、15 秒以内かつ 30 MB 以内のものを選んでください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "이 파일은 여기서 쓸 수 없습니다. H.264 또는 HEVC 영상의 MP4 또는 MOV로, 15초 이하이고 30 MB 이하인 것을 고르세요."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Этот файл здесь не подходит. Выберите MP4 или MOV с видео H.264 или HEVC, не длиннее 15 секунд и не больше 30 МБ."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "这个文件无法在这里使用。请选择 MP4 或 MOV(H.264 或 HEVC 视频),最长 15 秒、最大 30 MB。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "這個檔案無法在這裡使用。請選擇 MP4 或 MOV(H.264 或 HEVC 影片),最長 15 秒、最大 30 MB。"
+ }
+ }
+ }
+ },
+ "The clip could not be read. Try another one." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Der Clip konnte nicht gelesen werden. Versuche einen anderen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "The clip could not be read. Try another one."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "No se pudo leer el clip. Prueba con otro."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le clip n'a pas pu être lu. Essayez-en un autre."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "動画を読み込めませんでした。別のものを試してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "동영상을 읽을 수 없습니다. 다른 것을 시도해 보세요."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Не удалось прочитать клип. Попробуйте другой."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "无法读取这段视频,请换一段试试。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "無法讀取這段影片,請換一段試試。"
+ }
+ }
+ }
+ },
+ "The clip stays on this phone. It is not uploaded to nanoMuse Cloud or synced to your other devices." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Der Clip bleibt auf diesem Telefon. Er wird weder zu nanoMuse Cloud hochgeladen noch mit deinen anderen Geräten synchronisiert."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "The clip stays on this phone. It is not uploaded to nanoMuse Cloud or synced to your other devices."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "El clip se queda en este teléfono. No se sube a nanoMuse Cloud ni se sincroniza con tus otros dispositivos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Le clip reste sur ce téléphone. Il n'est ni envoyé à nanoMuse Cloud ni synchronisé avec vos autres appareils."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "動画はこの端末にだけ保存されます。nanoMuse Cloud にアップロードされず、他の端末にも同期されません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "동영상은 이 휴대폰에만 남습니다. nanoMuse Cloud에 올라가지 않고 다른 기기와 동기화되지도 않습니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Клип остаётся на этом телефоне. Он не загружается в nanoMuse Cloud и не синхронизируется с другими вашими устройствами."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "视频只保存在这台手机上,不会上传到 nanoMuse Cloud,也不会同步到你的其他设备。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "影片只保存在這支手機上,不會上傳到 nanoMuse Cloud,也不會同步到你的其他裝置。"
+ }
+ }
+ }
+ },
+ "This is how it plays as the face: muted, in a loop, cropped to the circle." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "So läuft er als Gesicht: stumm, in einer Schleife, auf den Kreis zugeschnitten."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "This is how it plays as the face: muted, in a loop, cropped to the circle."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Así se reproduce como cara: sin sonido, en bucle, recortado al círculo."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voici comment il joue comme visage : sans le son, en boucle, recadré dans le cercle."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "顔として再生されるとこうなります: 無音、ループ、円形に切り抜き。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "얼굴로 재생되면 이렇게 보입니다. 음소거, 반복, 원형으로 잘라서."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Так он играет как лицо: без звука, по кругу, обрезанный до круга."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "作为形象时就是这样播放的:静音、循环、裁成圆形。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "作為形象時就是這樣播放的:靜音、循環、裁成圓形。"
+ }
+ }
+ }
+ },
+ "Use" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verwenden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usar"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utiliser"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使う"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "사용"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Использовать"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "使用"
+ }
+ }
+ }
+ },
"Voice Prompt" : {
"localizations" : {
"de" : {
@@ -3198,6 +3965,183 @@
}
}
},
+ "Your clip is on and plays for every mood; the drawn stills still show where a still is used. Default face removes it." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dein Clip ist aktiv und läuft für jede Stimmung; wo ein Standbild gezeigt wird, bleiben die gezeichneten Bilder. „Standardgesicht“ entfernt ihn."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Your clip is on and plays for every mood; the drawn stills still show where a still is used. Default face removes it."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tu clip está activo y se reproduce en todos los estados; donde se muestra una imagen fija siguen usándose las dibujadas. «Cara predeterminada» lo quita."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre clip est actif et joue pour chaque humeur ; là où une image fixe est montrée, les images dessinées restent. « Visage par défaut » le retire."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "自分の動画が有効で、すべての状態で再生されます。静止画が使われる場所では描かれた画像がそのまま使われます。「標準の顔」で取り除けます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "내 동영상이 켜져 있고 모든 상태에서 재생됩니다. 정지 화면이 쓰이는 곳에서는 그린 그림이 그대로 쓰입니다. “기본 얼굴”로 제거할 수 있습니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ваш клип включён и играет в каждом состоянии; там, где показывается неподвижная картинка, по-прежнему используются нарисованные. «Лицо по умолчанию» убирает его."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你的视频已启用,所有状态都播放它;需要静态图的地方仍用画好的图片。「默认形象」可移除它。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你的影片已啟用,所有狀態都播放它;需要靜態圖的地方仍用畫好的圖片。「預設形象」可移除它。"
+ }
+ }
+ }
+ },
+ "Your own clip" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dein eigener Clip"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Your own clip"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tu propio clip"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre propre clip"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "自分の動画"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "내 동영상"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ваш собственный клип"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你自己的视频"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你自己的影片"
+ }
+ }
+ }
+ },
+ "Your own clip…" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dein eigener Clip…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Your own clip…"
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tu propio clip…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre propre clip…"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "自分の動画…"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "내 동영상…"
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ваш собственный клип…"
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你自己的视频…"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "你自己的影片…"
+ }
+ }
+ }
+ },
"chat" : {
"extractionState" : "manual",
"localizations" : {
@@ -6933,7 +7877,7 @@
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "거의 다 썼습니다: 무료 사용량 ¥%@ 중 ¥%@ 남았습니다. 직접 추가한 키나 이미 결제 중인 요금제로 계속 쓸 수 있습니다. 설정 → nanoMuse Cloud에서."
+ "value" : "거의 다 썼습니다: 무료 사용량 ¥%2$@ 중 ¥%1$@ 남았습니다. 직접 추가한 키나 이미 결제 중인 요금제로 계속 쓸 수 있습니다. 설정 → nanoMuse Cloud에서."
}
},
"ru" : {
@@ -114103,64 +115047,6 @@
}
}
},
- "%@ · %@ of the allowance left" : {
- "localizations" : {
- "de" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · %@ des Guthabens übrig"
- }
- },
- "en" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · %@ of the allowance left"
- }
- },
- "es" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · queda %@ del saldo"
- }
- },
- "fr" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · %@ du crédit restants"
- }
- },
- "ja" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · 無料枠の残り %@"
- }
- },
- "ko" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · 사용량 %@ 남음"
- }
- },
- "ru" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%@ · осталось %@ лимита"
- }
- },
- "zh-Hans" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%1$@ · 额度还剩 %2$@"
- }
- },
- "zh-Hant" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "%1$@ · 額度還剩 %2$@"
- }
- }
- }
- },
"Signed in" : {
"localizations" : {
"de" : {
@@ -118621,6 +119507,242 @@
}
}
},
+ "That password is too long." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dieses Passwort ist zu lang."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "That password is too long."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Esa contraseña es demasiado larga."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ce mot de passe est trop long."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "パスワードが長すぎます。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "비밀번호가 너무 깁니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Этот пароль слишком длинный."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密码太长了。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密碼太長了。"
+ }
+ }
+ }
+ },
+ "Enter the current password first." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gib zuerst das aktuelle Passwort ein."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enter the current password first."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Escribe primero la contraseña actual."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Saisissez d’abord le mot de passe actuel."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "まず現在のパスワードを入力してください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "먼저 현재 비밀번호를 입력하세요."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Сначала введите текущий пароль."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "请先输入当前密码。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "請先輸入目前的密碼。"
+ }
+ }
+ }
+ },
+ "Too many wrong attempts. Wait a few minutes, or sign in with a code." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zu viele falsche Versuche. Warte ein paar Minuten oder melde dich mit einem Code an."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Too many wrong attempts. Wait a few minutes, or sign in with a code."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demasiados intentos fallidos. Espera unos minutos o inicia sesión con un código."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Trop de tentatives incorrectes. Attendez quelques minutes ou connectez-vous avec un code."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "間違いが続きました。数分待つか、コードでサインインしてください。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "틀린 시도가 너무 많습니다. 몇 분 기다리거나 코드로 로그인하세요."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Слишком много неверных попыток. Подождите несколько минут или войдите по коду."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "错误次数太多。请过几分钟再试,或改用验证码登录。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "錯誤次數太多。請過幾分鐘再試,或改用驗證碼登入。"
+ }
+ }
+ }
+ },
+ "Wrong phone number, e-mail or password." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefonnummer, E-Mail oder Passwort falsch."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wrong phone number, e-mail or password."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Teléfono, correo o contraseña incorrectos."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Numéro de téléphone, e-mail ou mot de passe incorrect."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話番号、メールアドレスまたはパスワードが違います。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "전화번호, 이메일 또는 비밀번호가 틀렸습니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Неверный номер телефона, e-mail или пароль."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "手机号、邮箱或密码不对。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "手機號碼、信箱或密碼不對。"
+ }
+ }
+ }
+ },
"This account has no password yet. Sign in with a code and set one under nanoMuse Cloud." : {
"extractionState" : "manual",
"localizations" : {
@@ -118798,6 +119920,65 @@
}
}
},
+ "Use a few different characters, not the address itself." : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verwende ein paar verschiedene Zeichen; die Adresse selbst geht nicht als Passwort."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Use a few different characters, not the address itself."
+ }
+ },
+ "es" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Usa varios caracteres distintos; la propia dirección no vale como contraseña."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utilisez quelques caractères différents ; l’adresse elle-même ne peut pas servir de mot de passe."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "いくつか異なる文字を使ってください。アドレスそのものはパスワードにできません。"
+ }
+ },
+ "ko" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "서로 다른 문자를 몇 가지 섞어 주세요. 주소 자체는 비밀번호로 쓸 수 없습니다."
+ }
+ },
+ "ru" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Используйте несколько разных символов; сам адрес паролем быть не может."
+ }
+ },
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密码要含几种不同的字符,也不能是手机号或邮箱本身。"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "密碼要含幾種不同的字元,也不能是手機號碼或信箱本身。"
+ }
+ }
+ }
+ },
"Use eight characters or more." : {
"extractionState" : "manual",
"localizations" : {
@@ -119554,13 +120735,13 @@
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "画好了 %d 个状态里的 %d 个,其余先用主图。"
+ "value" : "画好了 %2$d 个状态里的 %1$d 个,其余先用主图。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
- "value" : "畫好了 %d 個狀態裡的 %d 個,其餘先用主圖。"
+ "value" : "畫好了 %2$d 個狀態裡的 %1$d 個,其餘先用主圖。"
}
}
}
@@ -121866,61 +123047,61 @@
}
}
},
- "In a chat, just ask: “on my Mac, list the downloads folder”, “tell my PC to build the project and send me the log”. Approvals show on this phone." : {
+ "In a chat, just ask: “on my Mac, list the downloads folder”, “tell my PC to build the project and send me the log”. A step that needs permission is approved on that device, not here." : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Bitte einfach im Chat: „auf meinem Mac den Downloads-Ordner auflisten“, „sag meinem PC, er soll das Projekt bauen und mir das Log schicken“. Freigaben erscheinen auf diesem Telefon."
+ "value" : "Bitte einfach im Chat: „auf meinem Mac den Downloads-Ordner auflisten“, „sag meinem PC, er soll das Projekt bauen und mir das Log schicken“. Ein Schritt, der eine Freigabe braucht, wird auf dem jeweiligen Gerät freigegeben, nicht hier."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
- "value" : "In a chat, just ask: “on my Mac, list the downloads folder”, “tell my PC to build the project and send me the log”. Approvals show on this phone."
+ "value" : "In a chat, just ask: “on my Mac, list the downloads folder”, “tell my PC to build the project and send me the log”. A step that needs permission is approved on that device, not here."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
- "value" : "En un chat, solo pídelo: “en mi Mac, lista la carpeta de descargas”, “dile a mi PC que compile el proyecto y me mande el registro”. Las aprobaciones se muestran en este teléfono."
+ "value" : "En un chat, solo pídelo: “en mi Mac, lista la carpeta de descargas”, “dile a mi PC que compile el proyecto y me mande el registro”. Un paso que necesita permiso se aprueba en ese dispositivo, no aquí."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Dans une discussion, demandez simplement : « sur mon Mac, liste le dossier Téléchargements », « dis à mon PC de compiler le projet et de m’envoyer le journal ». Les approbations s’affichent sur ce téléphone."
+ "value" : "Dans une discussion, demandez simplement : « sur mon Mac, liste le dossier Téléchargements », « dis à mon PC de compiler le projet et de m’envoyer le journal ». Une étape qui demande une autorisation s’approuve sur cet appareil-là, pas ici."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "チャットでこう頼むだけです: 「Mac でダウンロードフォルダの一覧を出して」「PC にプロジェクトをビルドさせてログを送って」。承認はこの端末に表示されます。"
+ "value" : "チャットでこう頼むだけです: 「Mac でダウンロードフォルダの一覧を出して」「PC にプロジェクトをビルドさせてログを送って」。許可が要る手順はその端末側で承認します。この端末には表示されません。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
- "value" : "채팅에서 그냥 말하세요. “내 Mac에서 다운로드 폴더 목록 보여 줘”, “내 PC에 프로젝트를 빌드하고 로그를 보내라고 해 줘”. 승인은 이 휴대전화에 표시됩니다."
+ "value" : "채팅에서 그냥 말하세요. “내 Mac에서 다운로드 폴더 목록 보여 줘”, “내 PC에 프로젝트를 빌드하고 로그를 보내라고 해 줘”. 허가가 필요한 단계는 그 기기에서 승인합니다. 여기에는 표시되지 않습니다."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
- "value" : "В чате просто попросите: «на моём Mac покажи папку загрузок», «скажи моему ПК собрать проект и прислать мне лог». Подтверждения показываются на этом телефоне."
+ "value" : "В чате просто попросите: «на моём Mac покажи папку загрузок», «скажи моему ПК собрать проект и прислать мне лог». Шаг, которому нужно разрешение, подтверждается на том устройстве, а не здесь."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
- "value" : "在聊天里直接说就行:「在我 Mac 上列一下下载文件夹」「让电脑把项目编译一遍,把日志发给我」。需要确认的事会在这台手机上弹卡片。"
+ "value" : "在聊天里直接说就行:「在我 Mac 上列一下下载文件夹」「让电脑把项目编译一遍,把日志发给我」。需要确认的步骤要在那台设备上批准,这里不会弹卡片。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
- "value" : "在聊天裡直接說就好:「在我的 Mac 上列出下載資料夾」「讓電腦把專案編譯一遍,把日誌傳給我」。需要確認的事會在這支手機上跳出卡片。"
+ "value" : "在聊天裡直接說就好:「在我的 Mac 上列出下載資料夾」「讓電腦把專案編譯一遍,把日誌傳給我」。需要確認的步驟要在那台裝置上核准,這裡不會跳出卡片。"
}
}
}
diff --git a/android/src/ios/Minis.xcodeproj/project.pbxproj b/android/src/ios/Minis.xcodeproj/project.pbxproj
index af8916d5..81e68d6e 100644
--- a/android/src/ios/Minis.xcodeproj/project.pbxproj
+++ b/android/src/ios/Minis.xcodeproj/project.pbxproj
@@ -3191,7 +3191,7 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -3200,7 +3200,7 @@
);
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.MinisUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -3215,12 +3215,12 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.MinisUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -3236,7 +3236,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -3252,7 +3252,7 @@
"$(SRCROOT)/Vendor/cppjieba/include",
);
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.MinisTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -3269,7 +3269,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -3281,7 +3281,7 @@
"$(SRCROOT)/Vendor/cppjieba/include",
);
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.MinisTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -3430,7 +3430,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Minis.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
@@ -3460,7 +3460,7 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(SRCROOT)/../../deps/libs";
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
OTHER_LDFLAGS = (
"-lish_emu",
"-lish",
@@ -3485,7 +3485,7 @@
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGN_ENTITLEMENTS = Minis.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_ASSET_PATHS = "\"Preview Content\"";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
@@ -3515,7 +3515,7 @@
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = "$(SRCROOT)/../../deps/libs";
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
OTHER_LDFLAGS = (
"-lish_emu",
"-lish",
@@ -3536,7 +3536,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
@@ -3548,7 +3548,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.app.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -3563,7 +3563,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
@@ -3575,7 +3575,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.app.ShareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -3590,7 +3590,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = FileProvider/FileProvider.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FileProvider/Info.plist;
@@ -3602,7 +3602,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.app.FileProvider;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -3617,7 +3617,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = FileProvider/FileProvider.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FileProvider/Info.plist;
@@ -3629,7 +3629,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.app.FileProvider;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -3644,7 +3644,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = AgentWidget/AgentWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AgentWidget/Info.plist;
@@ -3656,7 +3656,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.app.AgentWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -3671,7 +3671,7 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = AgentWidget/AgentWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 45;
+ CURRENT_PROJECT_VERSION = 46;
DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AgentWidget/Info.plist;
@@ -3683,7 +3683,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0.2;
+ MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = io.github.nanomuse.app.AgentWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/android/src/ios/NanoMuse/NanoMuseAccountData.swift b/android/src/ios/NanoMuse/NanoMuseAccountData.swift
index d06d175e..67fc0bf1 100644
--- a/android/src/ios/NanoMuse/NanoMuseAccountData.swift
+++ b/android/src/ios/NanoMuse/NanoMuseAccountData.swift
@@ -342,6 +342,7 @@ final class NanoMuseAccountData {
NanoMuseFeedStore.shared.reload()
NanoMuseGoalStore.shared.reload()
NanoMuseFaceStore.shared.reload()
+ NanoMuseOwnClip.shared.rescan()
SoulStore.refreshCache()
}
diff --git a/android/src/ios/NanoMuse/NanoMuseAgentPage.swift b/android/src/ios/NanoMuse/NanoMuseAgentPage.swift
index dc8569b6..a0787f6b 100644
--- a/android/src/ios/NanoMuse/NanoMuseAgentPage.swift
+++ b/android/src/ios/NanoMuse/NanoMuseAgentPage.swift
@@ -3,7 +3,7 @@
// nanoMuse
//
// What opens when the face is tapped: the agent's own page. The face,
-// large, with a pen badge (Change avatar / Edit name / Avatar studio…),
+// large, with a pen badge (Change avatar / Edit name / Your own clip… / Avatar studio…),
// the name, "online", and four panes — Activity (the last two days as a
// log), Approvals (what is always allowed), Daily (the routines), Soul &
// memory (the files). Plus the share cards for a new look.
@@ -35,6 +35,8 @@ struct NanoMuseAgentPage: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject private var faces = NanoMuseFaceStore.shared
@ObservedObject private var flow = NanoMuseAvatarFlow.shared
+ @ObservedObject private var own = NanoMuseOwnClip.shared
+ @StateObject private var clipPicker = NanoMuseOwnClipPicker()
@State private var pane = 0
@State private var showShare = false
@State private var showEditName = false
@@ -82,6 +84,7 @@ struct NanoMuseAgentPage: View {
.sheet(isPresented: $showEditName) {
NavigationStack { SoulSettingsView() }
}
+ .nanoMuseOwnClipHost(clipPicker)
.onReceive(NotificationCenter.default.publisher(for: .soulMdChanged)) { _ in
soulName = SoulStore.cachedMetadata.name
}
@@ -100,6 +103,11 @@ struct NanoMuseAgentPage: View {
onPrefillChat(AppLocalized("Change your avatar to "))
} label: { Label(AppLocalized("Change avatar"), systemImage: "paintbrush") }
Button { showEditName = true } label: { Label(AppLocalized("Edit name"), systemImage: "pencil") }
+ // A clip of the person's own as the face (#324), and the way back.
+ Button { clipPicker.choose() } label: { Label(AppLocalized("Your own clip…"), systemImage: "film") }
+ if own.clip != nil {
+ Button { own.clear() } label: { Label(AppLocalized("Default face"), systemImage: "arrow.uturn.backward") }
+ }
NavigationLink { NanoMuseAvatarStudioView(embedded: true) } label: { Label(AppLocalized("Avatar studio…"), systemImage: "wand.and.stars") }
} label: {
Image(systemName: "pencil")
diff --git a/android/src/ios/NanoMuse/NanoMuseAvatarStudio.swift b/android/src/ios/NanoMuse/NanoMuseAvatarStudio.swift
index 1d92741f..f88a3778 100644
--- a/android/src/ios/NanoMuse/NanoMuseAvatarStudio.swift
+++ b/android/src/ios/NanoMuse/NanoMuseAvatarStudio.swift
@@ -363,6 +363,8 @@ struct NanoMuseAvatarStudioView: View {
@ObservedObject private var studio = NanoMuseAvatarStudioModel.shared
@ObservedObject private var faces = NanoMuseFaceStore.shared
@ObservedObject private var motion = NanoMuseAvatarMotion.shared // nanoMuse: clips
+ @ObservedObject private var own = NanoMuseOwnClip.shared
+ @StateObject private var clipPicker = NanoMuseOwnClipPicker()
@Environment(\.dismiss) private var dismiss
@State private var askCost = false
@State private var confirmReset = false
@@ -385,6 +387,7 @@ struct NanoMuseAvatarStudioView: View {
private var content: some View {
Form {
currentSection
+ ownClipSection
switch studio.phase {
case .describe:
describeSection
@@ -443,6 +446,28 @@ struct NanoMuseAvatarStudioView: View {
Button(AppLocalized("Back to the built-in face"), role: .destructive) { studio.reset() }
Button(AppLocalized("Cancel"), role: .cancel) {}
}
+ .nanoMuseOwnClipHost(clipPicker)
+ }
+
+ /// A clip of the person's own as the face (#324): choose, replace, or back to the default.
+ private var ownClipSection: some View {
+ Section {
+ Button(own.clip == nil ? AppLocalized("Choose a clip") : AppLocalized("Replace the clip")) {
+ clipPicker.choose()
+ }
+ if own.clip != nil {
+ Button(AppLocalized("Default face")) { own.clear() }
+ }
+ } header: {
+ Text(AppLocalized("Your own clip"))
+ } footer: {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(own.clip != nil
+ ? AppLocalized("Your clip is on and plays for every mood; the drawn stills still show where a still is used. Default face removes it.")
+ : AppLocalized("A short video of yours plays as the face at rest, and the other moods fall back to it. MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB; it plays muted in a loop."))
+ Text(AppLocalized("The clip stays on this phone. It is not uploaded to nanoMuse Cloud or synced to your other devices."))
+ }
+ }
}
private var currentSection: some View {
diff --git a/android/src/ios/NanoMuse/NanoMuseChatCards.swift b/android/src/ios/NanoMuse/NanoMuseChatCards.swift
index 57fa1c0e..9d11acd2 100644
--- a/android/src/ios/NanoMuse/NanoMuseChatCards.swift
+++ b/android/src/ios/NanoMuse/NanoMuseChatCards.swift
@@ -62,10 +62,10 @@ struct NanoMuseChatCardsHost: View {
// MARK: - The agent's bubble
-/// Muse's transcript: the agent's prose sits in a grey bubble (Android:
-/// NmAssistantBubble, MuseTones.bubble, radius 20, at most 340 wide); a block
-/// that is a code fence, a table or raw HTML stays bare, as does everything
-/// in the classic layout.
+/// Muse's transcript: the agent's prose sits in a pill a shade off the canvas
+/// (Android: NmAssistantBubble, ChatColors.assistantBubble, radius 20, at most
+/// 340 wide), in the theme's text colour; a block that is a code fence, a table
+/// or raw HTML stays bare, as does everything in the classic layout.
struct NanoMuseAssistantBubble: ViewModifier {
var content: String
@@ -82,7 +82,7 @@ struct NanoMuseAssistantBubble: ViewModifier {
view
.padding(.horizontal, 14)
.padding(.vertical, 8)
- .background(NanoMuseTones.bubble, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
+ .background(NanoMuseTones.agentPill, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.frame(maxWidth: 340, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
diff --git a/android/src/ios/NanoMuse/NanoMuseCloud.swift b/android/src/ios/NanoMuse/NanoMuseCloud.swift
index c3057343..2846abbd 100644
--- a/android/src/ios/NanoMuse/NanoMuseCloud.swift
+++ b/android/src/ios/NanoMuse/NanoMuseCloud.swift
@@ -48,6 +48,21 @@ struct NanoMuseCloudAccount: Codable, Equatable, Sendable {
return min(max(Double(remaining) / Double(granted), 0), 1)
}
+ /// What is left, as the Settings card and the providers row say it: in yuan on a relay that
+ /// prices in money (the account page's headline figure), in tokens on an older relay; nil
+ /// when the relay runs without a ceiling, where there is nothing to count down.
+ var leftLine: String? {
+ if unlimited { return nil }
+ if let left = poolLeftCny {
+ let whole = left.rounded() == left
+ return String(format: AppLocalized("%@ left"), "¥" + left.formatted(.number.precision(.fractionLength(whole ? 0 : 2))))
+ }
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ let left = formatter.string(from: NSNumber(value: remaining)) ?? "\(remaining)"
+ return String(format: AppLocalized("%@ left"), left)
+ }
+
init(channel: String, hint: String, granted: Int64, used: Int64, usedToday: Int64, dailyCap: Int64, checkedAt: Date, unlimited: Bool = false, id: String = "", grantCny: Double? = nil, leftCny: Double? = nil) {
self.channel = channel
self.hint = hint
@@ -345,6 +360,10 @@ enum NanoMuseCloud {
store.updateInstance(existing)
}
ProviderKeychainHelper.saveAPIKey(apiKey, instanceId: existing.id)
+ // C12: the relay's key is this phone's alone, never in iCloud Keychain; upstream's
+ // helper writes a synchronizable item, so it is rewritten device-only at once,
+ // before the model fetch below gives iCloud time to pick it up.
+ NanoMuseAccountData.saveRelayKey(apiKey, instanceId: existing.id)
inst = existing
} else {
let fresh = ProviderInstance(
@@ -356,6 +375,7 @@ enum NanoMuseCloud {
)
// Key first: addInstance fetches the model list on its own right away.
ProviderKeychainHelper.saveAPIKey(apiKey, instanceId: fresh.id)
+ NanoMuseAccountData.saveRelayKey(apiKey, instanceId: fresh.id) // C12, as above
store.addInstance(fresh)
inst = fresh
}
@@ -374,8 +394,6 @@ enum NanoMuseCloud {
let parsed = parseAccount(reply)
account = parsed
- // C12: the relay's key is this phone's alone — never in iCloud Keychain
- NanoMuseAccountData.saveRelayKey(apiKey, instanceId: inst.id)
NanoMuseNudges.shared.absorb(me: reply) // nanoMuse: contract C1 — the star policy rides along
// C12: whoever was here is put aside, and this account's own set comes back (or starts empty)
let after = NanoMuseAccountData.shared.current
@@ -486,7 +504,8 @@ enum NanoMuseCloud {
}
return AppLocalized("The model provider is busy right now. Try again in a moment.")
case "locked":
- return AppLocalized("Too many requests at once. Try again shortly.")
+ // the password endpoints: too many wrong passwords in a row, not too many requests
+ return AppLocalized("Too many wrong attempts. Wait a few minutes, or sign in with a code.")
case "model_not_offered":
return AppLocalized("nanoMuse Cloud no longer offers that model. Pick another under Settings → nanoMuse Cloud.")
case "service_paused":
@@ -497,12 +516,25 @@ enum NanoMuseCloud {
return AppLocalized("The device hub is paused on this relay for now; each device keeps working on its own.")
case "upstream":
return AppLocalized("nanoMuse Cloud did not answer. Try again in a moment.")
+ // The relay's password codes (cloud/nanomuse_cloud/service.py): a 401 `bad_credentials`
+ // at sign-in for a wrong address or password, `password_wrong` when the current password
+ // given while changing or removing it is not the one set, `no_password` for an account
+ // that has none yet, `password_required` when the current one was left out, and
+ // `password_short`, `password_long`, `password_weak` from the rules for a new one.
+ case "bad_credentials":
+ return AppLocalized("Wrong phone number, e-mail or password.")
case "password_wrong":
return AppLocalized("That password is not right.")
- case "password_required", "no_password":
+ case "no_password":
return AppLocalized("This account has no password yet. Sign in with a code and set one under nanoMuse Cloud.")
- case "password_weak":
+ case "password_required":
+ return AppLocalized("Enter the current password first.")
+ case "password_short":
return AppLocalized("Use eight characters or more.")
+ case "password_weak":
+ return AppLocalized("Use a few different characters, not the address itself.")
+ case "password_long":
+ return AppLocalized("That password is too long.")
case "invite_bad":
return AppLocalized("That invite code is not one we know. Check it, or leave it empty.")
case "signup_closed":
diff --git a/android/src/ios/NanoMuse/NanoMuseCloudView.swift b/android/src/ios/NanoMuse/NanoMuseCloudView.swift
index 0ca62eee..d49836d2 100644
--- a/android/src/ios/NanoMuse/NanoMuseCloudView.swift
+++ b/android/src/ios/NanoMuse/NanoMuseCloudView.swift
@@ -479,9 +479,10 @@ struct NanoMuseCloudRow: View {
VStack(alignment: .leading, spacing: 2) {
Text(NanoMuseCloud.label)
if let account {
- let hint = account.hint
- let left = account.fraction.formatted(.percent.precision(.fractionLength(0)))
- Text(String(format: AppLocalized("%@ · %@ of the allowance left"), hint, left))
+ // Who is signed in, and what is left by the same rule as the Settings card: the
+ // yuan on a relay that prices in money, tokens on an older one, nothing to count
+ // on a relay without a ceiling (where a token fraction would read 0 percent).
+ Text(account.leftLine.map { "\(account.hint) · \($0)" } ?? account.hint)
.font(.caption)
.foregroundStyle(.secondary)
} else if NanoMuseCloud.isSignedIn {
diff --git a/android/src/ios/NanoMuse/NanoMuseComposer.swift b/android/src/ios/NanoMuse/NanoMuseComposer.swift
index 617cb4a2..03cb2418 100644
--- a/android/src/ios/NanoMuse/NanoMuseComposer.swift
+++ b/android/src/ios/NanoMuse/NanoMuseComposer.swift
@@ -5,7 +5,9 @@
// Muse's composer pill, used by AIChatView when the Muse shell is on: one
// row on a flat grey capsule — plus on the left, the field in the middle,
// the mic on the right while there is nothing to send, the send arrow once
-// there is, the stop glyph while the agent answers. Bare glyphs, no discs.
+// there is, the stop square while the agent answers. Bare glyphs for plus
+// and mic; send and stop sit on the chat's ink disc (black in light, white
+// in dark, the glyph in the canvas colour), as Android's send since #331.
// Android: the `nmPill` branch of ChatScreen.kt.
//
@@ -37,8 +39,11 @@ struct NanoMuseComposerPill: View {
@ViewBuilder var field: () -> Field
@State private var showAttachMenu = false
+ @Environment(\.colorScheme) private var scheme
private let glyph: CGFloat = 40
+ /// The send disc's diameter, the size the `arrow.up.circle.fill` glyph had.
+ private let disc: CGFloat = 30
var body: some View {
HStack(alignment: .bottom, spacing: 2) {
@@ -97,33 +102,40 @@ struct NanoMuseComposerPill: View {
// MARK: Mic · send · stop
+ /// A glyph on the ink disc: the send arrow, and the stop square while the agent answers.
+ /// Enabled: the disc in the ink colour, the glyph in the canvas colour. Disabled (nothing
+ /// sendable yet): the disc a faint wash of the ink and the glyph at 40 percent, Android's
+ /// `nmSendBg` / `nmSendFg`.
+ private func inkDisc(_ symbol: String, enabled: Bool) -> some View {
+ ZStack {
+ Circle()
+ .fill(enabled ? NanoMuseTones.ink : NanoMuseTones.ink.opacity(scheme == .dark ? 0.15 : 0.08))
+ Image(systemName: symbol)
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(enabled ? NanoMuseTones.onInk : Color.primary.opacity(0.4))
+ }
+ .frame(width: disc, height: disc)
+ .frame(width: glyph, height: glyph)
+ }
+
@ViewBuilder
private var trailingButton: some View {
if isProcessing && canEnqueue {
Button(action: onEnqueue) {
- Image(systemName: "arrow.up.circle.fill")
- .font(.system(size: 30))
- .foregroundStyle(Color.primary)
- .frame(width: glyph, height: glyph)
+ inkDisc("arrow.up", enabled: true)
}
.buttonStyle(.plain)
.keyboardShortcut(.return, modifiers: .command)
.accessibilityLabel(Text("Add to queue", comment: "VoiceOver label for the send button while a reply is generating"))
} else if isProcessing {
Button(action: onStop) {
- Image(systemName: "stop.circle.fill")
- .font(.system(size: 30))
- .foregroundStyle(Color.primary)
- .frame(width: glyph, height: glyph)
+ inkDisc("stop.fill", enabled: true)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Stop generating", comment: "VoiceOver label for the stop button"))
} else if hasContent {
Button(action: onSend) {
- Image(systemName: "arrow.up.circle.fill")
- .font(.system(size: 30))
- .foregroundStyle(canSend ? Color.primary : Color.secondary.opacity(0.5))
- .frame(width: glyph, height: glyph)
+ inkDisc("arrow.up", enabled: canSend)
}
.buttonStyle(.plain)
.disabled(!canSend)
diff --git a/android/src/ios/NanoMuse/NanoMuseFaces.swift b/android/src/ios/NanoMuse/NanoMuseFaces.swift
index 5f242fb9..b7016281 100644
--- a/android/src/ios/NanoMuse/NanoMuseFaces.swift
+++ b/android/src/ios/NanoMuse/NanoMuseFaces.swift
@@ -149,22 +149,31 @@ final class NanoMuseFaceStore: ObservableObject {
load()
}
- /// Back to the dragon.
+ /// Back to the dragon. The stills and the meta go; a clip of the person's own
+ /// (NanoMuseOwnClip, in the same folder) is not the drawn face's and stays.
func reset(sync: Bool = true) {
NanoMuseAvatarMotion.shared.clear()
- try? FileManager.default.removeItem(at: Self.directory)
+ removeStills()
custom = [:]
meta = Meta()
if sync { NanoMuseProfileSync.shared.faceChanged() }
}
+ private func removeStills() {
+ let fm = FileManager.default
+ for mood in NanoMuseMood.allCases {
+ try? fm.removeItem(at: Self.file(mood))
+ }
+ try? fm.removeItem(at: Self.metaFile)
+ }
+
/// Wear a face that another device drew (pulled from the profile). Clips are per device:
/// the old ones go, and the caller asks NanoMuseAvatarMotion for new ones when enabled.
func wear(remote stills: [NanoMuseMood: UIImage], faceId: String, description: String, style: String) {
guard stills[.idle] != nil else { return }
NanoMuseAvatarMotion.shared.clear()
let fm = FileManager.default
- try? fm.removeItem(at: Self.directory)
+ removeStills()
try? fm.createDirectory(at: Self.directory, withIntermediateDirectories: true)
var kept: [NanoMuseMood: UIImage] = [:]
for (mood, image) in stills {
@@ -240,6 +249,7 @@ final class NanoMuseFaceStore: ObservableObject {
/// Otherwise the still, with the small life the Android header gives it: a
/// slow breath when idle, a quicker bob while working, a tilt while
/// waiting, a pop when happy and a shake on error. Android: AgentAvatar.kt.
+/// A clip of the person's own (NanoMuseOwnClip, #324) plays instead for every mood.
struct NanoMuseFaceView: View {
var mood: NanoMuseMood
var size: CGFloat
@@ -247,17 +257,32 @@ struct NanoMuseFaceView: View {
@ObservedObject private var faces = NanoMuseFaceStore.shared
@ObservedObject private var motion = NanoMuseAvatarMotion.shared
+ @ObservedObject private var own = NanoMuseOwnClip.shared
@Environment(\.scenePhase) private var scenePhase
+ /// Reduce Motion on: the still keeps still (no breath, bob, tilt, pop or shake); the mood
+ /// still changes with its crossfade, and a clip the person chose still plays.
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var breath = false
@State private var popped = false
@State private var shake: CGFloat = 0
- /// The clip for this mood, when there is one for the face that is worn.
+ /// The clip for this mood: the person's own, when one is set (#324), stands in for every
+ /// mood; else the drawn face's clip for this mood, or the dragon's.
private var clip: URL? {
+ if let ownClip = own.clip { return ownClip }
if faces.hasCustomFace { return motion.clips[mood] }
return NanoMuseAvatarMotion.dragonClip(mood)
}
+ private var clipVersion: Int { own.clip != nil ? own.version : motion.clipVersion }
+
+ /// The still under the clip, and the face itself when there is no clip: the drawn one for
+ /// this mood, or the own clip's first frame when nothing was drawn, else the dragon.
+ private var still: UIImage? {
+ if !faces.hasCustomFace, own.clip != nil, let poster = own.poster { return poster }
+ return faces.image(for: mood)
+ }
+
var body: some View {
ZStack {
if showsRing {
@@ -266,15 +291,15 @@ struct NanoMuseFaceView: View {
if let clip {
ZStack {
// The still underneath covers the first frames while the player warms up.
- if let img = faces.image(for: mood) {
+ if let img = still {
Image(uiImage: img).resizable().scaledToFill()
}
- NanoMuseLoopingClipView(url: clip, version: motion.clipVersion)
+ NanoMuseLoopingClipView(url: clip, version: clipVersion)
}
.frame(width: size, height: size)
.clipShape(Circle())
.transition(.opacity)
- } else if let img = faces.image(for: mood) {
+ } else if let img = still {
Image(uiImage: img)
.resizable()
.scaledToFill()
@@ -296,13 +321,14 @@ struct NanoMuseFaceView: View {
.offset(x: shake, y: bob)
.animation(.easeInOut(duration: 0.35), value: mood)
.onAppear {
- startBreathing()
+ if !reduceMotion { startBreathing() }
NanoMuseClipPlayers.shared.setSuspended(scenePhase != .active)
}
.nmOnChange(of: scenePhase) { phase in
NanoMuseClipPlayers.shared.setSuspended(phase != .active)
}
.nmOnChange(of: mood) { newMood in
+ guard !reduceMotion else { return }
if newMood == .happy {
popped = true
withAnimation(.spring(response: 0.35, dampingFraction: 0.45)) { popped = false }
@@ -316,8 +342,9 @@ struct NanoMuseFaceView: View {
.accessibilityLabel(Text(AppLocalized("Agent face")))
}
- /// The still's own motion; a clip brings its own and stays at rest.
- private var animatesStill: Bool { clip == nil }
+ /// The still's own motion; a clip brings its own and stays at rest, and so does the still
+ /// under Reduce Motion.
+ private var animatesStill: Bool { clip == nil && !reduceMotion }
private var scale: CGFloat {
if popped { return 1.12 }
@@ -459,6 +486,18 @@ enum NanoMuseTones {
static let hairline = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.227, green: 0.227, blue: 0.235, alpha: 1) : UIColor(red: 0.898, green: 0.898, blue: 0.918, alpha: 1) })
static let canvas = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? .black : UIColor(red: 0.953, green: 0.953, blue: 0.961, alpha: 1) })
static let action = Color(red: 0.039, green: 0.4, blue: 0.894)
+ /// The chat's ink (Android's `ChatColors.userBubble` and `sendButton` since #331): your
+ /// message pill, the send disc and the scroll-to-latest disc. Black on the white canvas in
+ /// light, white on the black canvas in dark; `onInk` is what sits on it, the canvas colour.
+ static let ink = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? .white : .black })
+ static let onInk = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? .black : .white })
+ /// The agent's message pill, a shade off the canvas (Android's `ChatColors.assistantBubble`:
+ /// #F2F2F7 in light, #1C1C1E in dark). The composer pill keeps `bubble`.
+ static let agentPill = Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? UIColor(red: 0.11, green: 0.11, blue: 0.118, alpha: 1) : UIColor(red: 0.949, green: 0.949, blue: 0.969, alpha: 1) })
+ /// The pill bar's indicator disc and the glyph on it: the same ink as the chat, black with
+ /// a white glyph in light and white with a black glyph in dark, as Android's MuseBottomBar.
+ static let tabIndicator = ink
+ static let tabIndicatorGlyph = onInk
}
/// Whether the UI runs in Chinese right now (the in-app override wins).
diff --git a/android/src/ios/NanoMuse/NanoMuseHeader.swift b/android/src/ios/NanoMuse/NanoMuseHeader.swift
index c809b4ae..323b625a 100644
--- a/android/src/ios/NanoMuse/NanoMuseHeader.swift
+++ b/android/src/ios/NanoMuse/NanoMuseHeader.swift
@@ -89,6 +89,91 @@ final class NanoMuseMoodModel: ObservableObject {
}
}
+// MARK: - Header scrim
+
+/// What stands behind the Muse header over the main chat. Not a bar: a scrim, as Android's
+/// header wrapper since #331. A gradient of the canvas colour from 80 percent at the top edge
+/// through 35 percent a little past the middle to clear at the bottom, so the conversation
+/// scrolls under the header and dissolves into the canvas instead of stopping at a line; and,
+/// while the list is scrolled away from the latest message, a second fade in the full canvas
+/// colour from the top edge (Android's `nmTopFade`), so the older messages melt away under the
+/// face rather than show through it. The scrim takes the taps the material took before it, so
+/// a tap on the header never reaches a message under it.
+struct NanoMuseHeaderScrim: View {
+ /// The list is scrolled up, away from the latest message.
+ var scrolledUp: Bool
+
+ var body: some View {
+ ZStack {
+ LinearGradient(
+ stops: [
+ .init(color: ChatColors.background.opacity(0.8), location: 0),
+ .init(color: ChatColors.background.opacity(0.35), location: 0.55),
+ .init(color: ChatColors.background.opacity(0), location: 1),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ LinearGradient(
+ colors: [ChatColors.background, ChatColors.background.opacity(0)],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .opacity(scrolledUp ? 1 : 0)
+ .animation(.easeInOut(duration: 0.25), value: scrolledUp)
+ }
+ .contentShape(Rectangle())
+ }
+}
+
+/// Finds a conversation's view model in the cache. AIChatView puts it there as it appears, so
+/// the header and the scrim look a few times, then give up quietly.
+enum NanoMuseChatLookup {
+ @MainActor
+ static func viewModel(for liveId: String?) async -> AIChatViewModel? {
+ guard let liveId else { return nil }
+ for _ in 0..<20 {
+ if let found = ViewModelCache.shared.get(for: liveId) { return found }
+ try? await Task.sleep(for: .milliseconds(150))
+ if Task.isCancelled { return nil }
+ }
+ return nil
+ }
+}
+
+/// The scrim bound to the main chat's view model once it is in the cache (the same wait as
+/// `NanoMuseChatHeaderHost`): the second fade follows the list's `isNearBottom`. Until the
+/// model exists there is nothing to scroll, so the scrim stands still.
+struct NanoMuseChatScrimHost: View {
+ /// The id the view model is cached under (nil for an unsent draft).
+ var liveId: String?
+
+ @State private var vm: AIChatViewModel?
+
+ var body: some View {
+ Group {
+ if let vm {
+ NanoMuseLiveScrim(vm: vm)
+ } else {
+ NanoMuseHeaderScrim(scrolledUp: false)
+ }
+ }
+ .task(id: liveId) {
+ vm = nil
+ vm = await NanoMuseChatLookup.viewModel(for: liveId)
+ }
+ }
+}
+
+/// The scrim watching one conversation's list position.
+struct NanoMuseLiveScrim: View {
+ @ObservedObject var vm: AIChatViewModel
+
+ var body: some View {
+ NanoMuseHeaderScrim(scrolledUp: !vm.messages.isEmpty && !vm.isNearBottom)
+ }
+}
+
// MARK: - Header title (navigation bar principal item)
/// Face · name · status line, sized for the 44 pt navigation band. Observes
diff --git a/android/src/ios/NanoMuse/NanoMuseOwnClip.swift b/android/src/ios/NanoMuse/NanoMuseOwnClip.swift
new file mode 100644
index 00000000..82ddec2d
--- /dev/null
+++ b/android/src/ios/NanoMuse/NanoMuseOwnClip.swift
@@ -0,0 +1,394 @@
+//
+// NanoMuseOwnClip.swift
+// nanoMuse
+//
+// A clip of the person's own as the face (#324). One short video from the
+// photo library plays muted in a loop wherever the face moves: for the mood
+// at rest, and for every other mood too, since there is only the one clip.
+// The stills keep their place where a still is shown: a drawn face's mood
+// pictures sit under the clip until its first frame is on screen; with no
+// drawn face the clip's own first frame is the still.
+//
+// The clip lives next to the stills as `avatar/own-clip.mp4` with its first
+// frame in `avatar/own-clip.png`, and nowhere else: it is never part of the
+// profile the relay holds (NanoMuseProfileSync reads NanoMuseFaceStore only)
+// and does not follow the account to another device. It survives a new drawn
+// face and a reset to the dragon; only "Default face" removes it.
+// Android: avatar/OwnClip.kt, ui/avatar/OwnClipChooser.kt.
+//
+
+import AVFoundation
+import PhotosUI
+import SwiftUI
+import UIKit
+import UniformTypeIdentifiers
+
+@MainActor
+final class NanoMuseOwnClip: ObservableObject {
+ static let shared = NanoMuseOwnClip()
+
+ /// The longest clip taken, in whole seconds; a clip that rounds down to this is fine.
+ nonisolated static let maxSeconds = 15
+ /// The largest file taken.
+ nonisolated static let maxBytes: Int64 = 30 * 1024 * 1024
+ /// The video codecs the system player decodes here, as the four-character codes of the format description.
+ nonisolated static let codecs: Set = ["avc1", "hvc1", "hev1"]
+ /// The containers taken: MP4 and QuickTime (MOV), as uniform type identifiers.
+ nonisolated static let containers: Set = [UTType.mpeg4Movie.identifier, UTType.quickTimeMovie.identifier]
+
+ enum Refusal: Equatable {
+ case format, tooLong, tooLarge
+ }
+
+ /// What a look at a picked file found; a nil field is one that could not be read.
+ struct Probe: Equatable {
+ var container: String?
+ var codec: String?
+ var durationMs: Int64?
+ var bytes: Int64?
+ }
+
+ /// A clip that passed the checks and was copied in, waiting for "Use" or "Cancel".
+ struct Pending: Identifiable {
+ let id = UUID()
+ let url: URL
+ let poster: UIImage?
+ let durationMs: Int64
+ let bytes: Int64
+ }
+
+ enum Outcome {
+ case ready(Pending)
+ case refused(Refusal, Probe)
+ case failed(String)
+ }
+
+ /// The rule, apart from the phone: the format first (an unreadable file is a format problem,
+ /// whatever its size), then the length, then the size. Nil means the clip is taken.
+ nonisolated static func check(_ p: Probe) -> Refusal? {
+ guard let codec = p.codec, let duration = p.durationMs else { return .format }
+ if !codecs.contains(codec) { return .format }
+ if let container = p.container, !containers.contains(container) { return .format }
+ if duration >= Int64(maxSeconds + 1) * 1000 { return .tooLong }
+ if let bytes = p.bytes, bytes > maxBytes { return .tooLarge }
+ return nil
+ }
+
+ // MARK: State
+
+ /// The clip on disk, or nil for none.
+ @Published private(set) var clip: URL?
+ /// The clip's first frame: the still under it, and in its place when there is no drawn face.
+ @Published private(set) var poster: UIImage?
+ /// Bumped whenever the clip is written or removed, so a player on the same path is rebuilt.
+ @Published private(set) var version = 0
+
+ private init() {
+ // a copy that was never used or cancelled (the app died in between) is of no use now
+ try? FileManager.default.removeItem(at: Self.pendingFile)
+ rescan()
+ }
+
+ // MARK: Paths
+
+ nonisolated static var clipFile: URL { NanoMuseFaceStore.directory.appendingPathComponent("own-clip.mp4") }
+ nonisolated static var posterFile: URL { NanoMuseFaceStore.directory.appendingPathComponent("own-clip.png") }
+ nonisolated static var pendingFile: URL { NanoMuseFaceStore.directory.appendingPathComponent("own-clip.pending.mp4") }
+
+ nonisolated private static func size(of url: URL) -> Int64? {
+ ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? NSNumber)?.int64Value
+ }
+
+ /// Reads the folder again: after an import, "Default face", or an account change (C12).
+ func rescan() {
+ let url = Self.clipFile
+ if let size = Self.size(of: url), size > 0 {
+ clip = url
+ poster = (try? Data(contentsOf: Self.posterFile)).flatMap { UIImage(data: $0) }
+ } else {
+ clip = nil
+ poster = nil
+ }
+ }
+
+ /// "Use": the pending copy becomes the clip, its first frame the still.
+ func adopt(_ pending: Pending) {
+ let fm = FileManager.default
+ try? fm.createDirectory(at: NanoMuseFaceStore.directory, withIntermediateDirectories: true)
+ try? fm.removeItem(at: Self.clipFile)
+ do {
+ try fm.moveItem(at: pending.url, to: Self.clipFile)
+ } catch {
+ try? fm.copyItem(at: pending.url, to: Self.clipFile)
+ try? fm.removeItem(at: pending.url)
+ }
+ if let poster = pending.poster, let png = poster.pngData() {
+ try? png.write(to: Self.posterFile, options: .atomic)
+ } else {
+ try? fm.removeItem(at: Self.posterFile)
+ }
+ version += 1
+ rescan()
+ }
+
+ /// "Cancel" in the preview: the copy goes.
+ nonisolated func discard(_ pending: Pending) {
+ try? FileManager.default.removeItem(at: pending.url)
+ }
+
+ /// "Default face": back to the drawn face, or the dragon.
+ func clear() {
+ let fm = FileManager.default
+ try? fm.removeItem(at: Self.clipFile)
+ try? fm.removeItem(at: Self.posterFile)
+ try? fm.removeItem(at: Self.pendingFile)
+ version += 1
+ rescan()
+ }
+
+ // MARK: Looking at a file
+
+ nonisolated private static func fourCC(_ code: FourCharCode) -> String {
+ let bytes: [UInt8] = [
+ UInt8((code >> 24) & 0xff), UInt8((code >> 16) & 0xff), UInt8((code >> 8) & 0xff), UInt8(code & 0xff),
+ ]
+ return String(bytes: bytes, encoding: .ascii) ?? String(code)
+ }
+
+ /// Looks at the file without copying it: the size from the file system, the container from
+ /// its type, the length and the codec of the first video track from AVFoundation.
+ nonisolated static func probe(_ url: URL) async -> Probe {
+ let bytes = size(of: url)
+ let container = UTType(filenameExtension: url.pathExtension)?.identifier
+ let asset = AVURLAsset(url: url)
+ var durationMs: Int64?
+ if let duration = try? await asset.load(.duration), duration.isNumeric {
+ durationMs = Int64((duration.seconds * 1000).rounded())
+ }
+ var codec: String?
+ if let tracks = try? await asset.loadTracks(withMediaType: .video), let track = tracks.first,
+ let descriptions = try? await track.load(.formatDescriptions), let description = descriptions.first {
+ codec = fourCC(CMFormatDescriptionGetMediaSubType(description))
+ }
+ return Probe(container: container, codec: codec, durationMs: durationMs, bytes: bytes)
+ }
+
+ /// The clip's first frame, no larger than 512 px on a side.
+ nonisolated static func firstFrame(_ url: URL) async -> UIImage? {
+ let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
+ generator.appliesPreferredTrackTransform = true
+ generator.maximumSize = CGSize(width: 512, height: 512)
+ guard let result = try? await generator.image(at: .zero) else { return nil }
+ return UIImage(cgImage: result.image)
+ }
+
+ /// The whole import: a look first, and a clip the rule refuses never reaches the avatar
+ /// folder; one it takes is moved there as a pending file, its first frame decoded, and
+ /// handed back for the preview. `url` is the picker's temporary copy; it is gone afterwards.
+ nonisolated static func importClip(from url: URL) async -> Outcome {
+ let probe = await probe(url)
+ if let why = check(probe) {
+ try? FileManager.default.removeItem(at: url)
+ return .refused(why, probe)
+ }
+ let fm = FileManager.default
+ let dst = pendingFile
+ do {
+ try fm.createDirectory(at: NanoMuseFaceStore.directory, withIntermediateDirectories: true)
+ try? fm.removeItem(at: dst)
+ do {
+ try fm.moveItem(at: url, to: dst)
+ } catch {
+ try fm.copyItem(at: url, to: dst)
+ try? fm.removeItem(at: url)
+ }
+ } catch {
+ try? fm.removeItem(at: url)
+ return .failed(error.localizedDescription)
+ }
+ let poster = await firstFrame(dst)
+ return .ready(Pending(url: dst, poster: poster, durationMs: probe.durationMs ?? 0, bytes: probe.bytes ?? size(of: dst) ?? 0))
+ }
+}
+
+// MARK: - Picking
+
+/// The picked video as a file: the photo library hands over a copy, kept in the temporary
+/// folder with its own extension until the import has looked at it.
+struct NanoMuseOwnClipFile: Transferable {
+ let url: URL
+
+ static var transferRepresentation: some TransferRepresentation {
+ FileRepresentation(contentType: .movie) { file in
+ SentTransferredFile(file.url)
+ } importing: { received in
+ let name = "own-clip-" + String(UUID().uuidString.prefix(8))
+ let ext = received.file.pathExtension
+ let tmp = FileManager.default.temporaryDirectory
+ .appendingPathComponent(ext.isEmpty ? name : name + "." + ext)
+ try? FileManager.default.removeItem(at: tmp)
+ try FileManager.default.copyItem(at: received.file, to: tmp)
+ return Self(url: tmp)
+ }
+ }
+}
+
+/// Picking a clip of one's own: the photo library's video picker, the checks, and the preview
+/// sheet with the clip playing in the face's own frame, "Use" and "Cancel". A screen calls
+/// `choose()` from a button or a menu and wears `.nanoMuseOwnClipHost(picker)` once.
+@MainActor
+final class NanoMuseOwnClipPicker: ObservableObject {
+ @Published var presented = false
+ @Published var item: PhotosPickerItem?
+ @Published var pending: NanoMuseOwnClip.Pending?
+ @Published var message: String?
+ @Published private(set) var busy = false
+
+ /// Open the picker.
+ func choose() { presented = true }
+
+ /// The picker returned: look at the file, refuse or preview.
+ func handle(_ picked: PhotosPickerItem?) {
+ guard let picked else { return }
+ item = nil
+ busy = true
+ Task { @MainActor [weak self] in
+ guard let self else { return }
+ defer { self.busy = false }
+ guard let file = try? await picked.loadTransferable(type: NanoMuseOwnClipFile.self) else {
+ self.message = AppLocalized("The clip could not be read. Try another one.")
+ return
+ }
+ switch await NanoMuseOwnClip.importClip(from: file.url) {
+ case .ready(let next):
+ if let old = self.pending { NanoMuseOwnClip.shared.discard(old) }
+ self.pending = next
+ case .refused(let why, let probe):
+ self.message = Self.refusalText(why, probe)
+ case .failed:
+ self.message = AppLocalized("The clip could not be read. Try another one.")
+ }
+ }
+ }
+
+ /// The one sentence a refused clip gets, with the limits in it.
+ nonisolated static func refusalText(_ why: NanoMuseOwnClip.Refusal, _ probe: NanoMuseOwnClip.Probe) -> String {
+ switch why {
+ case .format:
+ return AppLocalized("That file cannot be used here. Pick an MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB.")
+ case .tooLong:
+ return String(format: AppLocalized("That clip runs %d seconds. The limit is 15 seconds, and 30 MB."), Int((probe.durationMs ?? 0) / 1000))
+ case .tooLarge:
+ return String(format: AppLocalized("That clip is %@. The limit is 30 MB, and 15 seconds."),
+ ByteCountFormatter.string(fromByteCount: probe.bytes ?? 0, countStyle: .file))
+ }
+ }
+}
+
+/// The picker, the preview sheet and the refusal alert, placed once on the screen that offers the clip.
+struct NanoMuseOwnClipHost: ViewModifier {
+ @ObservedObject var picker: NanoMuseOwnClipPicker
+
+ func body(content: Content) -> some View {
+ content
+ .photosPicker(isPresented: $picker.presented, selection: $picker.item, matching: .videos)
+ .nmOnChange(of: picker.item) { picked in picker.handle(picked) }
+ .sheet(item: $picker.pending) { pending in
+ NanoMuseOwnClipPreview(
+ pending: pending,
+ onUse: {
+ NanoMuseOwnClip.shared.adopt(pending)
+ picker.pending = nil
+ },
+ onCancel: {
+ NanoMuseOwnClip.shared.discard(pending)
+ picker.pending = nil
+ }
+ )
+ .presentationDetents([.large])
+ }
+ .alert(
+ AppLocalized("Your own clip"),
+ isPresented: Binding(get: { picker.message != nil }, set: { if !$0 { picker.message = nil } })
+ ) {
+ Button(AppLocalized("OK"), role: .cancel) {}
+ } message: {
+ Text(picker.message ?? "")
+ }
+ .overlay {
+ if picker.busy {
+ ProgressView()
+ .padding(20)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ }
+ }
+ }
+}
+
+extension View {
+ func nanoMuseOwnClipHost(_ picker: NanoMuseOwnClipPicker) -> some View {
+ modifier(NanoMuseOwnClipHost(picker: picker))
+ }
+}
+
+/// The preview: the clip playing in the same round frame the face uses, its length and size,
+/// "Use" and "Cancel", and the sentence that it stays on this phone.
+struct NanoMuseOwnClipPreview: View {
+ let pending: NanoMuseOwnClip.Pending
+ var onUse: () -> Void
+ var onCancel: () -> Void
+ @State private var decided = false
+
+ var body: some View {
+ VStack(spacing: 14) {
+ Text(AppLocalized("Preview")).font(.title3.weight(.semibold))
+ Text(AppLocalized("This is how it plays as the face: muted, in a loop, cropped to the circle."))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ ZStack {
+ Circle().fill(NanoMuseTones.disc)
+ // The first frame underneath covers the moment before the player has a picture.
+ if let poster = pending.poster {
+ Image(uiImage: poster).resizable().scaledToFill()
+ }
+ NanoMuseLoopingClipView(url: pending.url, version: 0)
+ }
+ .frame(width: 148, height: 148)
+ .clipShape(Circle())
+ .padding(.top, 8)
+ .accessibilityLabel(Text(AppLocalized("Preview of your clip")))
+ Text(String(format: AppLocalized("%d s · %@"),
+ Int((pending.durationMs + 500) / 1000),
+ ByteCountFormatter.string(fromByteCount: pending.bytes, countStyle: .file)))
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 0)
+ Button {
+ decided = true
+ onUse()
+ } label: {
+ Text(AppLocalized("Use"))
+ .font(.body.weight(.semibold))
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(NanoMuseTones.action)
+ Button(AppLocalized("Cancel")) {
+ decided = true
+ onCancel()
+ }
+ .frame(maxWidth: .infinity)
+ Text(AppLocalized("The clip stays on this phone. It is not uploaded to nanoMuse Cloud or synced to your other devices."))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(22)
+ .onDisappear {
+ // Swiped away: the same as "Cancel".
+ if !decided { onCancel() }
+ }
+ }
+}
diff --git a/android/src/ios/NanoMuse/NanoMuseReach.swift b/android/src/ios/NanoMuse/NanoMuseReach.swift
index 6f05ebb4..7b1c56d4 100644
--- a/android/src/ios/NanoMuse/NanoMuseReach.swift
+++ b/android/src/ios/NanoMuse/NanoMuseReach.swift
@@ -38,7 +38,9 @@ struct NanoMuseReachSection: View {
} header: {
Text(AppLocalized("Reach a device"))
} footer: {
- Text(AppLocalized("In a chat, just ask: “on my Mac, list the downloads folder”, “tell my PC to build the project and send me the log”. Approvals show on this phone."))
+ // A step the other Muse must ask about is declined from here and left to that device
+ // (NanoMuseDeviceMention); the footer says so rather than promising a card.
+ Text(AppLocalized("In a chat, just ask: “on my Mac, list the downloads folder”, “tell my PC to build the project and send me the log”. A step that needs permission is approved on that device, not here."))
}
.sheet(item: $target) { device in
NanoMuseReachSheet(device: device)
diff --git a/android/src/ios/NanoMuse/NanoMuseSettings.swift b/android/src/ios/NanoMuse/NanoMuseSettings.swift
index 48aec1ed..b67ab493 100644
--- a/android/src/ios/NanoMuse/NanoMuseSettings.swift
+++ b/android/src/ios/NanoMuse/NanoMuseSettings.swift
@@ -92,15 +92,7 @@ struct NanoMuseSettingsHomeView: View {
private var cloudLine: String {
_ = store.instances
guard NanoMuseCloud.isSignedIn, let account = NanoMuseCloud.account else { return AppLocalized("Sign in") }
- if account.unlimited { return account.hint }
- if let left = account.poolLeftCny {
- let whole = left.rounded() == left
- return String(format: AppLocalized("%@ left"), "¥" + left.formatted(.number.precision(.fractionLength(whole ? 0 : 2))))
- }
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- let left = formatter.string(from: NSNumber(value: account.remaining)) ?? "\(account.remaining)"
- return String(format: AppLocalized("%@ left"), left)
+ return account.leftLine ?? account.hint
}
// MARK: Card 2 — the agent
diff --git a/android/src/ios/NanoMuse/NanoMuseShell.swift b/android/src/ios/NanoMuse/NanoMuseShell.swift
index 7a6a7100..453e1f53 100644
--- a/android/src/ios/NanoMuse/NanoMuseShell.swift
+++ b/android/src/ios/NanoMuse/NanoMuseShell.swift
@@ -379,24 +379,35 @@ struct NanoMuseHomeView: View {
}
var body: some View {
- // The bottom bar is a row under the rooms, as on Android — not a `safeAreaInset`.
- // 0.1.36–0.1.39 had it as one, and an inset on the NavigationStack's ancestor reaches
- // the chat inside it through UIKit's safe area. What the device showed (iPad, 0.1.39;
- // the maintainer's iPhone, 0.1.36–0.1.38): the chat respected the bar's 56 pt at
- // launch, and once the bar had left for the keyboard and come back it no longer did —
- // the chat ran under the bar and its composer, bottom-aligned, sat behind it ("the
- // input field shows until I dismiss the keyboard, then never again"; the sliver of a
- // composer's top edge above the bar in the 0.1.36 screenshot). A VStack row is plain
- // layout: the stack above is as tall as what is left over the bar — and over the
- // keyboard, which shortens the whole VStack — whether the bar is there or not.
- VStack(spacing: 0) {
+ // The chat never gets the bottom bar as a `safeAreaInset`. 0.1.36 to 0.1.39 had it as
+ // one, and an inset on the NavigationStack's ancestor reaches the chat inside it through
+ // UIKit's safe area. What the device showed (iPad, 0.1.39; the maintainer's iPhone,
+ // 0.1.36 to 0.1.38): the chat respected the bar's 56 pt at launch, and once the bar had
+ // left for the keyboard and come back it no longer did: the chat ran under the bar and
+ // its composer, bottom-aligned, sat behind it ("the input field shows until I dismiss
+ // the keyboard, then never again"; the sliver of a composer's top edge above the bar in
+ // the 0.1.36 screenshot). 0.1.40 made the bar a VStack row under the rooms, plain layout:
+ // the chat is as tall as what is left over the bar, and over the keyboard, whether the
+ // bar is there or not. The floating pill keeps that for the chat as bottom padding of
+ // the bar's height, the same layout by another name. The rooms are plain SwiftUI
+ // scroll views with no NavigationStack, so they take the bar's height as a safe-area
+ // inset instead: their content scrolls under the pill and the glass has something to
+ // blur. The pill itself lies over both, aligned to the bottom, inside the safe area.
+ ZStack(alignment: .bottom) {
ZStack {
chatLayer
+ .padding(.bottom, bottomBarHidden ? 0 : NanoMuseBottomBar.height)
+ .background(ChatColors.background.ignoresSafeArea())
.opacity(tab == .chat ? 1 : 0)
.allowsHitTesting(tab == .chat)
.accessibilityHidden(tab != .chat)
if tab != .chat {
roomLayer
+ .safeAreaInset(edge: .bottom, spacing: 0) {
+ Color.clear
+ .frame(height: bottomBarHidden ? 0 : NanoMuseBottomBar.height)
+ .allowsHitTesting(false)
+ }
.transition(.opacity)
}
}
@@ -586,7 +597,8 @@ struct NanoMuseHomeView: View {
}
// Android: the header floats over the transcript, which scrolls under it. A safe-area
// inset (not a VStack row) so the message list keeps its full height and only its
- // content inset moves; the blur is the material, there is no divider line.
+ // content inset moves; behind it a scrim of the canvas colour fading to clear, not a
+ // blur and not a bar, and there is no divider line (NanoMuseHeaderScrim).
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
// MuseHeader over the main chat — the face, the name pill, the drawer and ••• discs.
@@ -628,8 +640,7 @@ struct NanoMuseHomeView: View {
}
}
.background {
- Rectangle()
- .fill(.ultraThinMaterial)
+ NanoMuseChatScrimHost(liveId: main.liveId)
.ignoresSafeArea(edges: .top)
}
}
@@ -796,31 +807,75 @@ private struct NanoMuseClassicCover: View {
// MARK: - Bottom bar
+/// Android's MuseBottomBar: a floating frosted-glass pill a little above the bottom edge, five
+/// glyphs and no labels. The pill is `.ultraThinMaterial` under the shell's surface tone at 55
+/// percent, with a 1 pt hairline; the current tab shows its filled glyph on a soft indicator
+/// capsule that springs across to the tab tapped next, the others stay outlined and muted. The
+/// pill is as wide as the composer above it (the same 16 pt margins), capped on a wide screen.
struct NanoMuseBottomBar: View {
@Binding var selected: NanoMuseTab
var onPick: (NanoMuseTab) -> Void
+ @Namespace private var indicator
+ /// Reduce Motion on: the indicator moves to the tapped tab without the spring.
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ /// The indicator capsule behind the selected glyph, centred in its slot; the five slots share
+ /// the pill's width equally.
+ static let tabSize = CGSize(width: 60, height: 46)
+ /// The pill's padding above and below the slots.
+ static let pillPadding: CGFloat = 6
+ /// Between the pill and the bottom safe area.
+ static let bottomGap: CGFloat = 12
+ /// The pill's distance from the screen's sides: the composer's margins in the shell
+ /// (AIChatView, `nmPill`), so the two capsules stack with one edge.
+ static let inset: CGFloat = 16
+ /// A wide screen gets a centred pill of this width, not a bar across the window.
+ static let maxWidth: CGFloat = 420
+ /// How much of the screen the bar takes above the safe area: what the rooms and the chat
+ /// leave free under their content so nothing lies behind the pill.
+ static var height: CGFloat { tabSize.height + pillPadding * 2 + bottomGap }
var body: some View {
- HStack(spacing: 0) {
+ HStack(spacing: 2) {
ForEach(NanoMuseTab.allCases) { tab in
+ let isSelected = selected == tab
Button {
onPick(tab)
} label: {
- Image(systemName: selected == tab ? tab.selectedSymbol : tab.symbol)
- .font(.system(size: 22, weight: selected == tab ? .semibold : .regular))
- .foregroundStyle(selected == tab ? Color.primary : Color.secondary)
- .frame(maxWidth: .infinity, minHeight: 56)
+ Image(systemName: isSelected ? tab.selectedSymbol : tab.symbol)
+ .font(.system(size: 22, weight: isSelected ? .semibold : .regular))
+ .foregroundStyle(isSelected ? NanoMuseTones.tabIndicatorGlyph : Color.secondary)
+ // The capsule keeps its 60 by 46 (narrower only when the slot is).
+ .frame(maxWidth: Self.tabSize.width)
+ .frame(height: Self.tabSize.height)
+ .background {
+ if isSelected {
+ Capsule()
+ .fill(NanoMuseTones.tabIndicator)
+ .matchedGeometryEffect(id: "indicator", in: indicator)
+ }
+ }
+ // The slot: an equal share of the pill, all of it tappable.
+ .frame(maxWidth: .infinity)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text(tab.title))
- .accessibilityAddTraits(selected == tab ? .isSelected : [])
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
- .background(.bar)
- .overlay(alignment: .top) {
- NanoMuseTones.hairline.frame(height: 0.5)
+ .padding(.horizontal, 14)
+ .padding(.vertical, Self.pillPadding)
+ .background {
+ Capsule().fill(.ultraThinMaterial)
+ Capsule().fill(NanoMuseTones.surface.opacity(0.55))
}
+ .overlay(Capsule().strokeBorder(NanoMuseTones.hairline, lineWidth: 1))
+ .animation(reduceMotion ? nil : Animation.spring(response: 0.35, dampingFraction: 0.8), value: selected)
+ .frame(maxWidth: Self.maxWidth)
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, Self.inset)
+ .padding(.bottom, Self.bottomGap)
}
}
@@ -921,15 +976,7 @@ struct NanoMuseChatHeaderHost: View {
/// AIChatView puts the view model in the cache as it appears; look a few times, then give up quietly.
private func bind() async {
vm = nil
- guard let liveId else { return }
- for _ in 0..<20 {
- if let found = ViewModelCache.shared.get(for: liveId) {
- vm = found
- return
- }
- try? await Task.sleep(for: .milliseconds(150))
- if Task.isCancelled { return }
- }
+ vm = await NanoMuseChatLookup.viewModel(for: liveId)
}
}
diff --git a/android/src/ios/Views/Chat/AIChatView.swift b/android/src/ios/Views/Chat/AIChatView.swift
index d6975d8f..7ee2bc55 100644
--- a/android/src/ios/Views/Chat/AIChatView.swift
+++ b/android/src/ios/Views/Chat/AIChatView.swift
@@ -2725,7 +2725,7 @@ struct AIChatView: View {
Button {
vm.forceScrollToBottom.send()
} label: {
- scrollFloatingButtonLabel("chevron.down")
+ scrollFloatingButtonLabel("chevron.down", ink: NanoMuseShellPrefs.shell) // nanoMuse: in the shell the scroll-to-latest disc is the chat's ink, as the send disc (Android #331)
}
.transition(.opacity.combined(with: .scale(scale: 0.8)))
}
@@ -2760,16 +2760,16 @@ struct AIChatView: View {
/// Shared label style for the floating scroll buttons (up / down), matching
/// the original scroll-to-bottom button look.
- private func scrollFloatingButtonLabel(_ systemName: String) -> some View {
+ private func scrollFloatingButtonLabel(_ systemName: String, ink: Bool = false) -> some View { // nanoMuse: `ink` draws the disc in NanoMuseTones.ink with the glyph in the canvas colour
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
- .foregroundStyle(.secondary)
+ .foregroundStyle(ink ? AnyShapeStyle(NanoMuseTones.onInk) : AnyShapeStyle(HierarchicalShapeStyle.secondary)) // nanoMuse:
.frame(width: 36, height: 36)
- .background { ScrollToBottomBackground().clipShape(Circle()) }
+ .background { if ink { NanoMuseTones.ink.clipShape(Circle()) } else { ScrollToBottomBackground().clipShape(Circle()) } } // nanoMuse:
// Border + shadow give the near-opaque disc its edge on a white
// page — at 0.25/0.12 the button had no readable outline over
// plain reply text. [T-ios-scrollbtn-invisible-lightmode]
- .overlay(Circle().stroke(Color.gray.opacity(0.35), lineWidth: 0.5))
+ .overlay(Circle().stroke(Color.gray.opacity(ink ? 0 : 0.35), lineWidth: 0.5)) // nanoMuse: the ink disc needs no edge
.shadow(color: .black.opacity(0.18), radius: 5, y: 2)
}
diff --git a/android/src/ios/Views/Chat/ChatMessageViews.swift b/android/src/ios/Views/Chat/ChatMessageViews.swift
index 46b33734..fa9567c5 100644
--- a/android/src/ios/Views/Chat/ChatMessageViews.swift
+++ b/android/src/ios/Views/Chat/ChatMessageViews.swift
@@ -147,8 +147,8 @@ private struct UserBubbleSurface: ViewModifier {
.strokeBorder(style: StrokeStyle(lineWidth: 1.5, dash: [6, 4]))
.foregroundStyle(ChatColors.secondaryText.opacity(0.5))
)
- } else if NanoMuseShellPrefs.shell { // nanoMuse: Muse's user bubble — flat grey, 18 pt, no glass (Android: MuseTones.bubble)
- content.background(shape.fill(NanoMuseTones.bubble)) // nanoMuse:
+ } else if NanoMuseShellPrefs.shell { // nanoMuse: Muse's user pill: the ink colour, black in light and white in dark, 18 pt, no glass (Android: ChatColors.userBubble)
+ content.background(shape.fill(NanoMuseTones.ink)) // nanoMuse:
} else if #available(iOS 26.0, *) {
content.glassEffect(.regular, in: shape)
} else {
@@ -383,7 +383,8 @@ struct ChatMessageRow: View {
HStack(spacing: 6) {
Text(userDisplayText)
.font(.system(size: FontSettings.shared.scaledMessage(16.5)))
- .foregroundStyle(message.isQueued ? ChatColors.secondaryText : ChatColors.primaryText)
+ // nanoMuse: on the ink pill the text takes the canvas colour (white on black in light, black on white in dark); a queued line keeps upstream's grey (Android: ChatColors.onUserBubble)
+ .foregroundStyle(message.isQueued ? ChatColors.secondaryText : (NanoMuseShellPrefs.shell ? NanoMuseTones.onInk : ChatColors.primaryText)) // nanoMuse:
.padding(.horizontal, 14)
.padding(.vertical, 10)
.modifier(UserBubbleSurface(isQueued: message.isQueued))
diff --git a/cloud/.dockerignore b/cloud/.dockerignore
new file mode 100644
index 00000000..703864d3
--- /dev/null
+++ b/cloud/.dockerignore
@@ -0,0 +1,18 @@
+# What the image build never needs. The Dockerfile copies pyproject.toml, README.md and
+# nanomuse_cloud/ only, but without this file the whole directory, the database under
+# data/ and .env included, is sent to the Docker daemon as the build context. A pattern
+# without `**/` matches at the root only (Go's filepath.Match, not gitignore), so the caches
+# under nanomuse_cloud/ need the prefix or they ride along in the COPY.
+.env
+.env.*
+*.env
+data/
+deploy/
+tests/
+.venv
+**/__pycache__
+**/*.pyc
+**/.pytest_cache
+**/.ruff_cache
+**/.mypy_cache
+**/*.egg-info
diff --git a/cloud/Dockerfile b/cloud/Dockerfile
index 0d4a7685..2f0a5a1d 100644
--- a/cloud/Dockerfile
+++ b/cloud/Dockerfile
@@ -5,7 +5,13 @@ WORKDIR /srv/nanomuse-cloud
COPY pyproject.toml README.md ./
COPY nanomuse_cloud ./nanomuse_cloud
-RUN pip install --no-cache-dir . && mkdir -p /srv/nanomuse-cloud/data
+# The relay runs as `relay`, uid 10001, not root. The uid is fixed so a bind-mounted data
+# directory can be given to it on the host (chown -R 10001:10001) before the first start.
+RUN pip install --no-cache-dir . \
+ && groupadd --gid 10001 relay \
+ && useradd --uid 10001 --gid 10001 --no-create-home --home-dir /srv/nanomuse-cloud --shell /usr/sbin/nologin relay \
+ && mkdir -p /srv/nanomuse-cloud/data \
+ && chown -R relay:relay /srv/nanomuse-cloud
ENV CLOUD_DB=/srv/nanomuse-cloud/data/cloud.db
VOLUME ["/srv/nanomuse-cloud/data"]
@@ -13,4 +19,5 @@ EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8787/healthz', timeout=3).status == 200 else 1)"
+USER relay
CMD ["nanomuse-cloud", "--host", "0.0.0.0", "--port", "8787"]
diff --git a/cloud/README.md b/cloud/README.md
index a9a14eb2..5454d21a 100644
--- a/cloud/README.md
+++ b/cloud/README.md
@@ -124,6 +124,17 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
RELAY_BIND=0.0.0.0 docker compose -f docker-compose.yml -f docker-compose.local.yml up -d # reachable from the LAN
```
+The image runs the relay as `relay`, uid 10001, not as root. Both compose files
+bind-mount `./data` as the data directory (`CLOUD_DB`, the GeoIP file beside it),
+and Docker creates a missing bind-mount source owned by root, which uid 10001
+cannot write; so `data/` must belong to that uid on the host before the first
+start, and once more when upgrading from an image that ran as root, whose
+`cloud.db` is root's: `sudo chown -R 10001:10001 data` (or, without sudo,
+`docker compose run --rm --no-deps --user 0 relay chown -R 10001:10001
+/srv/nanomuse-cloud/data`). `scripts/self-host.sh` and the production
+`deploy.sh` do this themselves; a relay that cannot write its data directory
+stops at start with a sentence saying so instead of a traceback.
+
Back up `data/cloud.db` together with `CLOUD_SECRET`: the hashes are useless
without the secret, and the secret alone is useless without the database.
@@ -171,7 +182,11 @@ for the full list. The ones that matter:
| `DAILY_CAP_TOKENS` | 0 (off) | tokens per account per day |
| `PER_MINUTE_REQUESTS` | 30 | per account — what stops a runaway loop |
| `MAX_IN_FLIGHT` | 4 | requests of one account under way at the same time (0 = off); each holds a reservation against the allowance while it runs — see *Money* |
+| `UPSTREAM_TIMEOUT_S` | 180 | how long one call to the provider may take (connecting has 20 s of its own); a reservation that outlives it by two minutes is dropped |
+| `TRUSTED_PROXIES` | empty | whose `X-Forwarded-For` to believe, comma-separated addresses or networks; empty = every loopback, private, link-local and shared address (Caddy on this box or the compose network, a tailnet) |
+| `PASSWORD_MIN_LEN` | 8 | the shortest password the relay accepts (`400 password_short` below it) |
| `PASSWORD_MAX_ATTEMPTS`, `LOCKOUT_S` | 5, 900 | wrong passwords before an account is locked for that long (a code still works) |
+| `PASSWORD_RESET_WINDOW_S` | 1800 | after a sign-in with a code, how long a new password may be set without the old one (the way back in when it is forgotten) |
| `LOGIN_FAIL_PER_IP_HOUR` | 30 | wrong passwords from one network address per hour across all accounts — a list of numbers tried once each never trips the per-account lock, this does (0.12); 0 = off |
| `CODE_TTL_S`, `CODE_MAX_ATTEMPTS` | 600, 5 | how long a sign-in code is good for, and how many wrong guesses it survives |
| `CODE_PER_IDENTIFIER_10M`, `CODE_PER_IP_HOUR` | 3, 10 | codes one number or address may request per ten minutes, and one network address per hour; past either the request is answered `429 code_too_often` |
@@ -193,6 +208,7 @@ for the full list. The ones that matter:
| `GITHUB_COLLECT` | `1` | `0` turns the collector off (a relay without a way out, tests) |
| `GITHUB_API` | `https://api.github.com` | where the collector reads; a mirror for a relay that cannot reach GitHub |
| `ADMIN_EMAIL` | empty | where a threshold rule's *notify* goes (0.22, *Controls*), through the `SMTP_*` settings; empty = the rule writes its audit line and the log says there was nobody to tell |
+| `NANOMUSE_CLOUD_BASE` | `PUBLIC_BASE`, else `http://127.0.0.1:8787` | read by the admin CLI only (`nanomuse-cloud admin …`, below): the address of the relay it talks to when `--base` is not given; the relay itself does not read it |
The default menu (0.17): `deepseek-v4.1-flash` (the chat model — text and
images in, ¥2 / ¥8 per million tokens, charged at 0.7×; it thinks before it
@@ -522,6 +538,15 @@ Identifiers are kept AES-GCM-encrypted with a key derived from
`CLOUD_SECRET`. A person can also remove themselves: `POST /v1/auth/delete`
with their key deletes the account, its keys, ledger and devices.
+The *Invites* view (`/v1/admin/invites`) shows who invited whom: the
+inviters in order of the accounts their code brought, each with how many of
+those ever made a request, how many were seen from one of the inviter's own
+addresses (what one person padding their own pool looks like), what they
+spent, what the inviter's pool got from them and when the first and the last
+signed up; a row unfolds to the newest of the people it brought, and the
+account drawer names an account's inviter and lists everyone it brought. The
+accounts table marks an inviter with the count and sorts by it.
+
Two more panels come from `/v1/admin/series` and `/v1/admin/traffic`: the
relay's own numbers by day (sign-ins, new and active accounts, sign-ups
through an invite, data switches turned on, calls, refusals, upstream errors, and the
@@ -711,8 +736,8 @@ curl -X PUT -H "X-Admin-Token: $CLOUD_ADMIN_TOKEN" -H 'Content-Type: application
-d '{"star":{"moments":{"tasks":[5,20]},"cooldown_days":14,"text":"A star on GitHub helps others find nanoMuse.","text_zh":"在 GitHub 点个 star,让更多人找到 nanoMuse。"}}' \
https://$CLOUD_DOMAIN/v1/admin/nudges
# the switches (0.22): what is on, flip one with a note for the audit log, read the log —
-# from the relay's own shell (the CLI reads CLOUD_ADMIN_TOKEN and PUBLIC_BASE from the
-# environment, or --token / --base; --actor names you in the log, else cli:)
+# from the relay's own shell (the CLI reads CLOUD_ADMIN_TOKEN and NANOMUSE_CLOUD_BASE, else
+# PUBLIC_BASE, from the environment, or --token / --base; --actor names you in the log, else cli:)
nanomuse-cloud admin controls list
nanomuse-cloud admin controls set free_allowance off --note "upstream bill" --actor lgy
nanomuse-cloud admin controls set free_allowance on
diff --git a/cloud/deploy/nanomuse-hk/README.md b/cloud/deploy/nanomuse-hk/README.md
index 7d4a182c..e7135fea 100644
--- a/cloud/deploy/nanomuse-hk/README.md
+++ b/cloud/deploy/nanomuse-hk/README.md
@@ -9,7 +9,7 @@ relay itself is [`cloud/`](../../README.md).
|---|---|
| host | `nanomuse-hk` (SSH alias), Ubuntu 24.04, Docker |
| relay | `/opt/nanomuse/relay/` — `docker-compose.yml`, `.env`, `src/`, `data/cloud.db` |
-| container | `nanomuse-relay`, on the `showcase_edge` network, no published ports |
+| container | `nanomuse-relay`, on the `showcase_edge` network, no published ports; runs as uid 10001, which owns `data/` on the host (`deploy.sh` keeps it so) |
| TLS / vhost | `demo/showcase/sites.d/cloud.nanomuse.cn.caddy` → `nanomuse-relay:8787` |
| backups | `/opt/nanomuse/backups/cloud-*.db.gz`, daily 04:10 UTC, 30 days (`backup.sh`, systemd timer) |
| self-check | `selfcheck.sh` every 10 min (systemd timer): the public `/healthz`, the relay's `/v1/admin/health` (aggregates only: requests under way, hub counters, the hour's upstream errors, content-check refusals and refusals, the database), free disk, and the age of the newest `cloud-*.db.gz` under `/opt/nanomuse/backups` (a problem when it is older than 48 hours, `BACKUP_MAX_H`, or there is none) — one line in the journal (`journalctl -t nanomuse-selfcheck`); with `ALERT_URL` in `.env` (a webhook taking `{"text": …}`) a problem is posted there, once an hour per problem |
@@ -132,10 +132,16 @@ the *Free allowance* switch says it better to the apps.
```bash
ssh nanomuse-hk 'cd /opt/nanomuse/relay && docker compose stop \
- && gunzip -c /opt/nanomuse/backups/cloud-YYYYMMDD-HHMMSS.db.gz > data/cloud.db \
- && rm -f data/cloud.db-wal data/cloud.db-shm && docker compose start'
+ && sudo sh -c "gunzip -c /opt/nanomuse/backups/cloud-YYYYMMDD-HHMMSS.db.gz > data/cloud.db \
+ && rm -f data/cloud.db-wal data/cloud.db-shm && chown 10001:10001 data/cloud.db" \
+ && docker compose start'
```
+`data/` belongs to uid 10001, the user the relay runs as, so the restored file
+is written with `sudo` and handed to that uid; a `cloud.db` left to the
+deploying user would stop the relay at start with the sentence about the
+directory not being writable.
+
A backup is only readable with the `CLOUD_SECRET` it was written under;
`backup.sh` keeps a copy of `.env` next to the database copies for that
reason (`env.latest`, 0600).
diff --git a/cloud/deploy/nanomuse-hk/deploy.sh b/cloud/deploy/nanomuse-hk/deploy.sh
index c9ab9294..aa2a81de 100755
--- a/cloud/deploy/nanomuse-hk/deploy.sh
+++ b/cloud/deploy/nanomuse-hk/deploy.sh
@@ -7,7 +7,8 @@
# SKIP_CADDY=1 cloud/deploy/nanomuse-hk/deploy.sh # everything but the Caddy site
#
# What it does, idempotently:
-# 1. rsync cloud/ → /opt/nanomuse/relay/src (tests, caches and .env left out)
+# 1. rsync cloud/ → /opt/nanomuse/relay/src (tests, caches and .env left out); the data
+# directory is given to uid 10001, the user the relay runs as in its container
# 2. put this directory's compose file, backup and self-check scripts and systemd
# units in place; enable the backup and self-check timers
# 3. drop cloud.nanomuse.cn.caddy into the showcase's sites.d/ and reload Caddy
@@ -28,6 +29,9 @@ build=1
[ "${1:-}" = "--no-build" ] && build=
ssh "$host" "sudo mkdir -p $remote/src $remote/data /opt/nanomuse/backups && sudo chown -R \$(id -u):\$(id -g) $remote /opt/nanomuse/backups"
+# The relay runs as uid 10001 inside its container (cloud/Dockerfile), so the bind-mounted
+# data directory, cloud.db and its WAL files included, must belong to that uid on the host.
+ssh "$host" "sudo chown -R 10001:10001 $remote/data"
rsync -az --delete \
--exclude '.env' --exclude 'data/' --exclude 'tests/' --exclude '__pycache__/' \
diff --git a/cloud/nanomuse_cloud/__init__.py b/cloud/nanomuse_cloud/__init__.py
index b8c2af23..c0727230 100644
--- a/cloud/nanomuse_cloud/__init__.py
+++ b/cloud/nanomuse_cloud/__init__.py
@@ -7,4 +7,4 @@
the token counts. See README.md in this folder.
"""
-__version__ = "0.24.1"
+__version__ = "0.24.2"
diff --git a/cloud/nanomuse_cloud/__main__.py b/cloud/nanomuse_cloud/__main__.py
index cdc89713..91f8469e 100644
--- a/cloud/nanomuse_cloud/__main__.py
+++ b/cloud/nanomuse_cloud/__main__.py
@@ -2,8 +2,8 @@
0.22: `nanomuse-cloud admin controls list|set on|off` talks to a running relay's
admin API (so its in-memory switches change at once), with `--base` / `--token` or
-PUBLIC_BASE / CLOUD_ADMIN_TOKEN from the environment (inside the container they are
-already set from `.env`; from a shell, source it first).
+NANOMUSE_CLOUD_BASE (else PUBLIC_BASE) / CLOUD_ADMIN_TOKEN from the environment (inside the
+container they are already set from `.env`; from a shell, source it first).
"""
from __future__ import annotations
@@ -31,6 +31,7 @@ def _serve(args: argparse.Namespace) -> None:
if settings.dev_mode and settings.sender != "log":
# identifiers would be hashed with a public fixed key while real codes go out
raise SystemExit("CLOUD_SECRET is not set; a relay that sends codes (CODE_SENDER != log) must have one")
+ check_data_dir(settings.database)
app = create_app(settings)
uvicorn.run(
app,
@@ -48,6 +49,33 @@ def _serve(args: argparse.Namespace) -> None:
)
+def check_data_dir(database: str) -> None:
+ """Stop with a plain sentence when the database's directory cannot be written.
+
+ The Docker image runs the relay as uid 10001, and a bind-mounted data directory that
+ Docker or an earlier root-running image created belongs to root, so the first start
+ would otherwise die on sqlite's "unable to open database file".
+ """
+ if database == ":memory:":
+ return
+ folder = os.path.dirname(os.path.abspath(database))
+ try:
+ os.makedirs(folder, exist_ok=True)
+ except OSError:
+ pass
+ writable = os.path.isdir(folder) and os.access(folder, os.W_OK | os.X_OK)
+ if writable and os.path.exists(database):
+ writable = os.access(database, os.W_OK)
+ if writable:
+ return
+ uid = os.getuid() if hasattr(os, "getuid") else "?"
+ raise SystemExit(
+ f"the data directory {folder} is not writable by uid {uid}; the Docker image runs the relay "
+ f"as uid 10001, so a bind-mounted data directory must belong to it: on the host, "
+ f"sudo chown -R 10001:10001 , then start the relay again"
+ )
+
+
def _admin_client(args: argparse.Namespace) -> httpx.Client:
settings = Settings()
base = (args.base or os.environ.get("NANOMUSE_CLOUD_BASE") or settings.public_base or "http://127.0.0.1:8787").rstrip("/")
@@ -97,7 +125,7 @@ def main(argv: list[str] | None = None) -> None:
p.add_argument("--port", type=int, default=8787)
p.add_argument("--log-level", default="info")
admin = sub.add_parser("admin", help="talk to a running relay's admin API")
- admin.add_argument("--base", help="the relay's URL (default: PUBLIC_BASE or http://127.0.0.1:8787)")
+ admin.add_argument("--base", help="the relay's URL (default: NANOMUSE_CLOUD_BASE, else PUBLIC_BASE, else http://127.0.0.1:8787)")
admin.add_argument("--token", help="the admin token (default: CLOUD_ADMIN_TOKEN)")
admin.add_argument("--actor", help="who is doing this, for the audit log (default: cli:)")
asub = admin.add_subparsers(dest="area")
diff --git a/cloud/nanomuse_cloud/api.py b/cloud/nanomuse_cloud/api.py
index bc003d54..78654725 100644
--- a/cloud/nanomuse_cloud/api.py
+++ b/cloud/nanomuse_cloud/api.py
@@ -51,6 +51,7 @@
GET /v1/admin/accounts/{id}/ledger X-Admin-Token ?limit=&before= → the account's statement, every line, page by page (before = last id shown)
GET /v1/admin/accounts/{id}/events X-Admin-Token ?limit=&before= → the account's timeline, every entry, page by page
GET /v1/admin/address X-Admin-Token ?ip=… → the accounts seen from one address
+ GET /v1/admin/invites X-Admin-Token ?limit=200&per=20 → who invited whom: the funnel and the inviters ranked by the accounts their code brought, with the newest `per` of them
GET /v1/admin/usage X-Admin-Token ?days=14 → charged tokens and yuan per day and kind
GET /v1/admin/overview X-Admin-Token ?days=30 → the dashboard: accounts, today / week / period by kind and model, signals, events
GET /v1/admin/events X-Admin-Token ?limit=200&kind=… → the timeline across accounts (never message content)
@@ -534,6 +535,7 @@ async def me_sessions(caller: Caller = Depends(caller_dep)) -> dict:
@app.delete("/v1/me/sessions/{prefix}", status_code=204)
async def me_revoke_session(prefix: str, caller: Caller = Depends(caller_dep)) -> Response:
cloud.revoke_session(caller, prefix)
+ await _drop_signed_out(caller.account_id)
return Response(status_code=204)
@app.get("/v1/me/events")
@@ -543,8 +545,16 @@ async def me_events(limit: int = 50, caller: Caller = Depends(caller_dep)) -> di
@app.post("/v1/auth/sign-out", status_code=204)
async def sign_out(caller: Caller = Depends(caller_dep)) -> Response:
cloud.sign_out(caller)
+ await _drop_signed_out(caller.account_id)
return Response(status_code=204)
+ async def _drop_signed_out(account_id: str) -> None:
+ # a key that was just revoked may still hold a hub socket (the device signed out
+ # from elsewhere); the hub closes it with 4001 now rather than at the next reconnect
+ hub = getattr(app.state, "hub", None)
+ if hub is not None:
+ await hub.drop_signed_out(account_id)
+
@app.post("/v1/auth/session-key")
async def session_key(request: Request, caller: Caller = Depends(caller_dep)) -> dict:
"""A key for this account that expires on its own: {"device", "ttl_s"} → {"api_key",
@@ -563,6 +573,7 @@ async def sign_out_all(request: Request, caller: Caller = Depends(caller_dep)) -
"""Every other device; {"all": true} takes this one too."""
body = await _json(request)
n = cloud.sign_out_all(caller, keep_current=not bool(body.get("all")))
+ await _drop_signed_out(caller.account_id)
return {"signed_out": n}
@app.post("/v1/auth/delete", status_code=204)
@@ -1132,7 +1143,9 @@ async def settle_video_tasks(limit: int = 5) -> int:
cloud.db.note_video_check(task_id)
done += 1
if charged is not None:
- log.info("video task %s settled by the relay for %s: %s, charged %d", task_id[:12], str(task["account_id"])[:8], status, charged)
+ log.info(
+ "video task %s settled by the relay for %s: %s, charged %d", task_id[:12], str(task["account_id"])[:8], status, charged
+ )
return done
app.state.settle_video_tasks = settle_video_tasks # the tests call it directly
@@ -1191,7 +1204,10 @@ async def hub_socket(ws: WebSocket) -> None:
def hub_dep(caller: Caller = Depends(caller_dep)) -> Caller:
if not cloud.controls.on("hub"):
raise CloudError(
- 503, "hub_paused", "The device hub is paused on this relay for now; each device keeps working on its own.", {"paused": True}
+ 503,
+ "hub_paused",
+ "The device hub is paused on this relay for now; each device keeps working on its own.",
+ {"paused": True},
)
return caller
@@ -1398,6 +1414,13 @@ async def admin_address(ip: str = "") -> dict:
out["place"] = place.as_dict() if place is not None else None
return with_places(out)
+ @app.get("/v1/admin/invites", dependencies=[Depends(admin_dep)])
+ async def admin_invites(limit: int = 200, per: int = 20) -> dict:
+ """The inviters in order of how many accounts their code brought, with what those
+ accounts did and the newest `per` of them, and the funnel; an account's detail
+ page lists its invitees in full."""
+ return with_places(cloud.admin_invites(max(1, min(limit, 1000)), max(0, min(per, 100))))
+
@app.get("/v1/admin/events", dependencies=[Depends(admin_dep)])
async def admin_events(limit: int = 200, kind: str = "") -> dict:
kinds = tuple(k.strip() for k in kind.split(",") if k.strip()) or None
diff --git a/cloud/nanomuse_cloud/config.py b/cloud/nanomuse_cloud/config.py
index ab835d2b..c29e8b93 100644
--- a/cloud/nanomuse_cloud/config.py
+++ b/cloud/nanomuse_cloud/config.py
@@ -490,7 +490,7 @@ class Settings:
# INVITE_URL is the link the apps offer to share; the code is appended.
# OWN_KEY_DOCS is the guide the apps open when the allowance is used up and
# the person wants to bring their own model key.
-# No default: the operator configures the relay's own URLs (empty hides the link).
+ # No default: the operator configures the relay's own URLs (empty hides the link).
invite_url: str = field(default_factory=lambda: _env("INVITE_URL", ""))
own_key_docs: str = field(default_factory=lambda: _env("OWN_KEY_DOCS", ""))
# OPENROUTER_URL is where someone outside mainland China gets a key (0.17): Alibaba
diff --git a/cloud/nanomuse_cloud/console/admin/admin.js b/cloud/nanomuse_cloud/console/admin/admin.js
index f2c8f6e4..b00eaa2a 100644
--- a/cloud/nanomuse_cloud/console/admin/admin.js
+++ b/cloud/nanomuse_cloud/console/admin/admin.js
@@ -96,7 +96,7 @@
catalog: "模型目录", catalogNote: "key 下能用的模型(菜单之外):relay 向每个模型各问一句话、再给一张品红色小图,确认它能回答、能不能看图;答案保存 7 天。客户端拿到的「能看图」就是这里的结果。", catalogOff: "模型目录未开启(CLOUD_CATALOG=0)。", catalogNames: "按名字推断,未逐个验证(CLOUD_CATALOG_PROBE=0)。", catalogProbing: "正在验证…", catalogPending: (n) => `${fmt(n)} 个待验证`, catalogFetched: (t) => `列表更新于 ${t}`, catalogError: (e) => `读取列表失败:${e}`,
cModel: "模型", cKind: "类型", cSees: "看图", cVerified: "已验证", cYes: "是", cNo: "否", cUnusable: "服务商拒绝的模型(不出现在客户端)", cNoUnusable: "没有被拒绝的模型。", cCount: (n, v) => `${fmt(n)} 个对话模型 · ${fmt(v)} 个能看图`,
// 0.14: the page in pages
- nav: { overview: "总览", people: "用户", places: "地区", money: "花费", activity: "动态", demo: "在线体验", data: "数据控制", site: "官网", models: "模型", health: "服务状态", stats: "统计", controls: "控制", settings: "配置" },
+ nav: { overview: "总览", people: "用户", invites: "邀请", places: "地区", money: "花费", activity: "动态", demo: "在线体验", data: "数据控制", site: "官网", models: "模型", health: "服务状态", stats: "统计", controls: "控制", settings: "配置" },
// 0.22: the Controls page, the Stats page, the GitHub series
ctl: {
title: "控制", note: "每个开关立刻生效、重启后保持;谁在何时改了什么记在下面的日志里。关闭前会先说明确切后果。",
@@ -135,7 +135,15 @@
stat: { member: "成员", disabled: "已停用", locked: "已锁定", password: "设了密码", nopassword: "未设密码", contributes: "开启「帮助改进」", exhausted: "额度用光", online: "有设备在线", new: "本期新注册" },
act: { today: "24 小时内", "7d": "7 天内", "30d": "30 天内", older: "更早", never: "从未使用" },
bucket: { 0: "¥0", lt1: "<¥1", "1to5": "¥1–5", "5to10": "¥5–10", ge10: "≥¥10" },
- sortBy: { active: "最近活跃", joined: "注册时间", spent: "累计花费", today: "今天花费", tokens: "tokens", requests: "请求数", left: "剩余额度", region: "地区" },
+ sortBy: { active: "最近活跃", joined: "注册时间", spent: "累计花费", today: "今天花费", tokens: "tokens", requests: "请求数", left: "剩余额度", region: "地区", invites: "邀请人数" },
+ // who invited whom: the Invites view and the account drawer's list
+ invNote: "谁邀请了谁。按邀请到的账号数排序;点一行展开被邀请的人,点名字打开账号页。", invSearch: "搜索邀请人…", invNone: "还没有人经邀请注册。", invNoMatch: "没有匹配的邀请人。",
+ invKpiCode: "生成过邀请码", invKpiInviters: "邀请成功的人", invKpiInvited: "经邀请注册", invKpiActive: "其中用过的", invKpiSameIp: "与邀请人同地址",
+ invThWho: "邀请人", invThInvited: "邀请到", invThActive: "其中用过", invThSameIp: "同一地址", invThSpent: "他们花了", invThEarned: "获得奖励", invThLast: "最近一次",
+ invSort: { invited: "邀请到", active: "其中用过", same_ip: "同一地址", spent: "他们花了", earned: "获得奖励", latest: "最近一次" },
+ invRecorded: (n) => `记录为 ${fmt(n)}`, invSameIp: "同地址", invGone: "已注销", invSeeRanking: "查看谁邀请了谁", invMore: (n) => `还有 ${fmt(n)} 个,打开账号页看全部`,
+ invSameIpNote: "「同一地址」指被邀请人的首次或最近地址与邀请人的相同,一个人给自己刷额度时常是这样;「记录为」是邀请计数器的值,被邀请人注销账号后计数不减。",
+ invitedList: (n) => `邀请的人 · ${fmt(n)}`, invPill: (n) => `邀请了 ${fmt(n)} 人`, invEarnedLine: (m) => `邀请奖励共 ${m}`, invNewest: (n, total) => `只列出最新 ${fmt(n)} 个,共 ${fmt(total)} 个`,
cSignups: "注册", cRegions: "地区分布", cRegionsIn: (c) => `${c} · 分布`, cSpend: "花费分布", cActivity: "活跃度", cChannel: "渠道", cClients: "客户端", cClickNote: "图上的每一块都是筛选条件:点一下只看它,再点一下取消。", up: "上一级",
thRegion: "地区", moreRows: (n) => `再显示 ${fmt(n)} 个`, exportCsv: "导出 CSV", csvNote: "导出的是当前筛选结果,和表格一样含完整的手机号 / 邮箱,文件只留在你手里。",
byDayLabel: "按天", byWeekLabel: "按周",
@@ -213,7 +221,7 @@
pCountry: "Country / region", pProvince: "Province / state", pAccounts: "Accounts", pNew: "New", pSignins: "Sign-ins", pRequests: "Requests", pDemo: "Demo visitors", pUnknown: "unknown", pLocal: "local network", pNone: "No visits recorded yet.", pAccountsNote: "accounts by their latest address",
catalog: "Model catalog", catalogNote: "The models under the key beyond the menu: the relay asks each one a one-word question and then shows it a small magenta picture, to learn whether it answers and whether it sees; the answers stand for seven days. The “sees pictures” the apps show is what is here.", catalogOff: "The catalog is off (CLOUD_CATALOG=0).", catalogNames: "Guessed from names, not checked one by one (CLOUD_CATALOG_PROBE=0).", catalogProbing: "Checking…", catalogPending: (n) => `${fmt(n)} to check`, catalogFetched: (t) => `list read ${t}`, catalogError: (e) => `The list could not be read: ${e}`,
cModel: "Model", cKind: "Kind", cSees: "Sees pictures", cVerified: "Checked", cYes: "yes", cNo: "no", cUnusable: "Models the provider refuses (not offered to the apps)", cNoUnusable: "None refused.", cCount: (n, v) => `${fmt(n)} chat models · ${fmt(v)} see pictures`,
- nav: { overview: "Overview", people: "People", places: "Places", money: "Spend", activity: "Activity", demo: "Demo", data: "Data", site: "Site", models: "Models", health: "Health", stats: "Stats", controls: "Controls", settings: "Settings" },
+ nav: { overview: "Overview", people: "People", invites: "Invites", places: "Places", money: "Spend", activity: "Activity", demo: "Demo", data: "Data", site: "Site", models: "Models", health: "Health", stats: "Stats", controls: "Controls", settings: "Settings" },
ctl: {
title: "Controls", note: "Each switch applies at once and survives a restart; who changed what, and when, is in the log below. A switch says exactly what turning it off does before it flips.",
names: { free_allowance: "Free allowance", signups: "Sign-ups", cloud_service: "Cloud service", sync: "Conversation sync", hub: "Device hub" },
@@ -251,7 +259,15 @@
stat: { member: "members", disabled: "disabled", locked: "locked", password: "with a password", nopassword: "without a password", contributes: "“help improve” on", exhausted: "allowance used up", online: "a device online", new: "new this period" },
act: { today: "last 24 h", "7d": "last 7 days", "30d": "last 30 days", older: "longer ago", never: "never used" },
bucket: { 0: "¥0", lt1: "<¥1", "1to5": "¥1–5", "5to10": "¥5–10", ge10: "≥¥10" },
- sortBy: { active: "last active", joined: "joined", spent: "spent", today: "spent today", tokens: "tokens", requests: "requests", left: "left", region: "region" },
+ sortBy: { active: "last active", joined: "joined", spent: "spent", today: "spent today", tokens: "tokens", requests: "requests", left: "left", region: "region", invites: "invites" },
+ // who invited whom: the Invites view and the account drawer's list
+ invNote: "Who brought whom. Inviters in order of the accounts their code brought; open a row for the people it brought, a name for the account page.", invSearch: "Search inviters…", invNone: "Nobody has come through an invite yet.", invNoMatch: "No inviter matches.",
+ invKpiCode: "made a code", invKpiInviters: "brought someone", invKpiInvited: "came through a code", invKpiActive: "of them used it", invKpiSameIp: "same address as the inviter",
+ invThWho: "Inviter", invThInvited: "Brought", invThActive: "Used it", invThSameIp: "Same address", invThSpent: "They spent", invThEarned: "Earned", invThLast: "Latest",
+ invSort: { invited: "brought", active: "used it", same_ip: "same address", spent: "they spent", earned: "earned", latest: "latest" },
+ invRecorded: (n) => `recorded ${fmt(n)}`, invSameIp: "same address", invGone: "deleted", invSeeRanking: "see who brought whom", invMore: (n) => `${fmt(n)} more; the account page lists them all`,
+ invSameIpNote: "“Same address” means an invitee's first or latest address is one of the inviter's own, which is what one person padding their own pool looks like; “recorded” is the invite counter, which does not go down when an invitee deletes their account.",
+ invitedList: (n) => `Brought · ${fmt(n)}`, invPill: (n) => `${fmt(n)} invited`, invEarnedLine: (m) => `${m} earned from invites`, invNewest: (n, total) => `the newest ${fmt(n)} of ${fmt(total)}`,
cSignups: "Sign-ups", cRegions: "By region", cRegionsIn: (c) => `${c} · by region`, cSpend: "By spend", cActivity: "By activity", cChannel: "By channel", cClients: "By client", cClickNote: "Every piece of a chart is a filter: click it to see only that, click again to let go.", up: "Up a level",
thRegion: "Region", moreRows: (n) => `Show ${fmt(n)} more`, exportCsv: "Export CSV", csvNote: "The current selection as the table shows it, phone numbers and addresses included; keep the file to yourself.",
byDayLabel: "by day", byWeekLabel: "by week",
@@ -300,6 +316,7 @@
const NAV_ICON = {
overview: svg(''),
people: ICON.person,
+ invites: svg(''),
places: svg(''),
money: svg(''),
activity: svg(''),
@@ -319,7 +336,7 @@
"control.switch": ["warn", ICON.warn], "control.rule": ["warn", ICON.warn],
};
const FILTERS = [["", "all"], ["sign_in.code,sign_in.password,sign_in.session,sign_in.failed,account.created", "signIns"], ["budget.refused", "refusals"], ["upstream.error", "errors"], ["content.rejected", "contentChecks"], ["call.ended", "calls"], ["password.set,password.changed,password.cleared", "passwords"]];
- const VIEWS = ["overview", "people", "places", "money", "activity", "demo", "data", "site", "models", "health", "stats", "controls", "settings"];
+ const VIEWS = ["overview", "people", "invites", "places", "money", "activity", "demo", "data", "site", "models", "health", "stats", "controls", "settings"];
const PAGE = 50;
// ── state ──────────────────────────────────────────────────────
@@ -385,6 +402,7 @@
data: (d) => `/v1/admin/data?days=${d}`,
demo: (d) => `/v1/admin/demo?days=${d}`,
places: (d) => `/v1/admin/places?days=${d}`,
+ invites: () => "/v1/admin/invites?limit=500&per=20",
catalog: () => "/v1/admin/catalog",
health: () => "/v1/admin/health",
nudges: () => "/v1/admin/nudges",
@@ -394,7 +412,7 @@
stats: (d) => `/v1/admin/stats?days=${d}`,
github: (d) => `/v1/admin/github?days=${d}`,
};
- const TIMELESS = new Set(["catalog", "health", "nudges", "sync", "controls"]);
+ const TIMELESS = new Set(["catalog", "health", "nudges", "sync", "controls", "invites"]);
const CACHE = new Map();
const cacheKey = (key) => `${key}:${TIMELESS.has(key) ? 0 : days}`;
/** The view's data, or null while it is on its way (the view draws again when it lands);
@@ -696,7 +714,7 @@
sparkRow(T.mRefused, rows, "budget_refusals", "warn", dayOf), sparkRow(T.mErrors, rows, "upstream_errors", "warn", dayOf), sparkRow(T.mContentRejected, rows, "content_rejected", "grey", dayOf)),
h("div", { class: "kv" },
h("b", {}, T.devices), h("span", {}, Object.keys(byKind).length ? Object.entries(byKind).map(([k, v]) => h("div", {}, h("span", { class: "pill", style: "margin-right:6px" }, `${T.devKinds[k] || k} ${fmt(v.n)}`), h("span", { class: "fine" }, v.os.join(" · ")))) : T.none),
- h("b", {}, T.invitesTitle), h("span", {}, T.invitesLine({ with_code: 0, inviters: 0, invited: 0, ...(sr.invites || {}) })),
+ h("b", {}, T.invitesTitle), h("span", {}, T.invitesLine({ with_code: 0, inviters: 0, invited: 0, ...(sr.invites || {}) }), " · ", h("a", { class: "more-link", href: "#invites" }, T.invSeeRanking)),
h("b", {}, T.webTitle), h("span", {}, sr.web && sr.web.enabled === false ? T.webOff : T.webLine(sr.web))));
}
function eventRow(e, withWho, accountId) {
@@ -897,7 +915,8 @@
h("div", {}, h("div", { class: "k" }, T.spendToday), h("div", { class: "v", title: usd(sp.today_cny, rate) }, money(sp.today_cny)), h("div", { class: "k", style: "margin-top:4px" }, T.tokens(a.used))),
h("div", {}, h("div", { class: "k" }, T.requests), h("div", { class: "v" }, fmt(sp.requests_total)), h("div", { class: "k", style: "margin-top:4px" }, s.unlimited ? T.noCap : `${T.grant}: ${fmt(a.granted)}`))),
h("div", { class: "fine", style: "padding:0 16px 10px" }, T.poolLine(money(a.grant_cny || 0), a.left_cny === null || a.left_cny === undefined ? null : money(a.left_cny), a.invites || 0, !!a.contribute_bonus_at),
- a.invited_by ? [" · ", T.invitedBy, " ", h("code", {}, String(a.invited_by).slice(0, 8))] : null),
+ // the inviter by name when the account is still there, the id's start when it is gone
+ a.invited_by ? [" · ", T.invitedBy, " ", a.inviter ? h("a", { class: "more-link", href: "#", onclick: (e) => { e.preventDefault(); openAccount(a.inviter.id); } }, who(a.inviter)) : [h("code", {}, String(a.invited_by).slice(0, 8)), ` (${T.invGone})`]] : null),
h("div", { class: "acts" },
s.unlimited ? null : h("button", { class: "btn quiet", onclick: () => doGrant(a) }, T.grant),
h("button", { class: "btn quiet", onclick: () => doCredit(a) }, T.credit),
@@ -905,6 +924,10 @@
a.listed ? null : h("button", { class: "btn quiet", onclick: () => doMember(a) }, a.unlimited ? T.unmakeMember : T.makeMember),
h("button", { class: "btn quiet", onclick: () => doDisable(a) }, a.disabled ? T.enable : T.disable),
h("button", { class: "btn danger", onclick: () => doDelete(a) }, T.remove))),
+ // the people this account's code brought, newest first; shown once there is one to show
+ ...((a.invited || []).length || a.invites ? [h("div", { class: "label" }, T.invitedList(a.invited_total || (a.invited || []).length), a.invite_earned_cny ? ` · ${T.invEarnedLine(money(a.invite_earned_cny))}` : ""),
+ h("div", { class: "card" }, ...(a.invited || []).map((i) => inviteeRow(i, a)), !(a.invited || []).length ? h("div", { class: "empty" }, T.none) : null,
+ (a.invited_total || 0) > (a.invited || []).length ? h("div", { class: "fine", style: "padding:6px 16px 10px" }, T.invNewest((a.invited || []).length, a.invited_total)) : null)] : []),
h("div", { class: "label" }, T.byKind), h("div", { class: "card" },
h("div", { class: "label", style: "margin:12px 16px 4px" }, T.usageToday), kindRows(u.today && u.today.by_kind, rate),
h("div", { class: "label", style: "margin:12px 16px 4px" }, T.usagePeriod(p.days || days)), kindRows(p.by_kind, rate),
@@ -993,6 +1016,7 @@
const SORTS = {
active: (a) => a.last_active_at || 0, joined: (a) => a.created_at || 0, spent: (a) => Number(a.spent_cny || 0), today: (a) => Number(a.spent_today_cny || 0), tokens: (a) => Number(a.used || 0),
requests: (a) => Number(a.requests || 0), left: (a) => (a.left_cny === null || a.left_cny === undefined ? -1 : Number(a.left_cny)), region: (a) => regionName(placeOfAccount(a)),
+ invites: (a) => Number(a.invites || 0),
};
const peopleFilters = () => {
const g = (k) => params.get(k) || "";
@@ -1095,7 +1119,8 @@
a.review ? h("span", { class: "pill", title: T.reviewNote }, T.review) : null,
a.has_password ? h("span", { class: "pill violet" }, T.password) : null,
a.disabled ? h("span", { class: "pill bad" }, T.disabled) : null,
- a.locked ? h("span", { class: "pill warn" }, T.locked) : null)))),
+ a.locked ? h("span", { class: "pill warn" }, T.locked) : null,
+ a.invites ? h("span", { class: "pill violet", title: T.invSeeRanking, onclick: (e) => { e.stopPropagation(); go("invites", { q: a.identifier || a.hint }); } }, T.invPill(a.invites)) : null)))),
h("td", { class: "hide-sm" }, dateOf(a.created_at)),
h("td", { class: "hide-sm" }, p ? h("span", { class: "place", title: p.isp || "" }, placeText(p)) : h("span", { class: "fine" }, T.pUnknown)),
h("td", { class: "num" }, money(a.spent_cny), h("span", { class: "sub" }, money(a.spent_today_cny)), cap > 0 ? h("div", { class: "meter sm", title: `${T.cap} ${money(cap)} · ${T.left} ${money(a.left_cny || 0)}` }, h("i", { class: frac >= 1 ? "bad" : frac >= 0.8 ? "warn" : "", style: `width:${Math.round(frac * 100)}%` })) : null),
@@ -1124,6 +1149,76 @@
download(new Blob(["\ufeff" + text], { type: "text/csv;charset=utf-8" }), `nanomuse-accounts-${new Date().toISOString().slice(0, 10)}.csv`);
}
+ // ── invites: who brought whom, the inviters that brought most first ──
+ const INV_SORTS = {
+ invited: (x) => x.invited || 0, active: (x) => x.invited_active || 0, same_ip: (x) => x.invited_same_ip || 0,
+ spent: (x) => Number(x.invited_spent_cny || 0), earned: (x) => Number(x.earned_cny || 0), latest: (x) => x.last_invite_at || 0,
+ };
+ const INV_OPEN = new Set(); // the inviters whose people are unfolded in the table
+ const inviteeLabel = (i) => identOf(i.id, i.hint);
+ /** One account that came through a code, as a row: who, when, whether it was used, from where. */
+ function inviteeRow(i, inviter, cls) {
+ const same = !!(inviter && [i.first_ip, i.last_ip].some((ip) => ip && (ip === inviter.first_ip || ip === inviter.last_ip)));
+ return h("div", { class: "row tap" + (cls ? " " + cls : ""), onclick: (e) => { e.stopPropagation(); openAccount(i.id); } },
+ h("span", { class: "tile" + (i.requests > 0 ? "" : " grey"), html: ICON.person }),
+ h("div", { class: "txt" }, h("div", { class: "t" }, inviteeLabel(i), " ", h("span", { class: "pill" }, i.channel === "phone" ? T.phone : T.email),
+ same ? [" ", h("span", { class: "pill warn" }, T.invSameIp)] : null, i.disabled ? [" ", h("span", { class: "pill bad" }, T.disabled)] : null),
+ h("div", { class: "s" }, `${T.thJoined} ${dateOf(i.created_at)} · ${T.lastSeenAt} ${i.last_seen_at ? ago(i.last_seen_at) : T.never} · ${T.thReqs} ${fmt(i.requests)} · ${money(i.spent_cny)}`,
+ i.last_ip ? [" · ", ipChip(i.last_ip, i.id)] : null)));
+ }
+ function invitesView() {
+ const iv = need("invites");
+ if (!iv) return loadingPanel(T.nav.invites);
+ if (iv._error) return failedPanel(T.nav.invites, "invites", iv._error);
+ const f = { q: params.get("q") || "", sort: INV_SORTS[params.get("sort")] ? params.get("sort") : "invited", dir: params.get("dir") || "desc", n: Number(params.get("n")) || PAGE };
+ const q = f.q.trim().toLowerCase();
+ const all = iv.leaders || [], fun = { with_code: 0, inviters: 0, invited: 0, ...(iv.funnel || {}) };
+ const shown = all.filter((x) => !q || (x.hint || "").toLowerCase().includes(q) || (x.identifier || "").toLowerCase().includes(q) || (x.id || "").startsWith(q) || (x.last_ip || "").includes(q));
+ const key = INV_SORTS[f.sort], sign = f.dir === "asc" ? 1 : -1;
+ shown.sort((a, b) => (key(a) - key(b)) * sign || (b.invited || 0) - (a.invited || 0) || (b.last_invite_at || 0) - (a.last_invite_at || 0));
+ const page = shown.slice(0, f.n);
+ const sum = (k) => all.reduce((t, x) => t + Number(x[k] || 0), 0);
+ const kpis = h("div", { class: "kpis in-panel" },
+ flatKpi(T.invKpiCode, fmt(fun.with_code)), flatKpi(T.invKpiInviters, fmt(fun.inviters)), flatKpi(T.invKpiInvited, fmt(fun.invited)),
+ flatKpi(T.invKpiActive, fmt(sum("invited_active"))), flatKpi(T.invKpiSameIp, fmt(sum("invited_same_ip"))));
+ const toolbar = h("div", { class: "toolbar" },
+ h("span", { class: "search" }, h("input", { type: "search", "data-focus": "invites-q", placeholder: T.invSearch, value: f.q, oninput: (e) => setParams({ q: e.target.value, n: "" }) })),
+ select(T.fSort, f.sort, Object.entries(T.invSort), (v) => setParams({ sort: v, n: "" })),
+ h("span", { class: "cnt" }, T.fCount(shown.length, all.length)));
+ const th = (label, key, cls) => h("th", { class: (cls || "") + (key ? " sort" : "") + (key === f.sort ? " on" : ""), onclick: key ? () => setParams({ sort: key, dir: f.sort === key && f.dir === "desc" ? "asc" : "desc", n: "" }) : null }, label, key === f.sort ? (f.dir === "asc" ? " ↑" : " ↓") : "");
+ const rows = [];
+ page.forEach((x, i) => {
+ const open = INV_OPEN.has(x.id), rank = shown.indexOf(x) + 1;
+ rows.push(h("tr", { class: open ? "country" : "", onclick: () => { if (open) INV_OPEN.delete(x.id); else INV_OPEN.add(x.id); draw(); } },
+ h("td", { class: "num fine" }, String(rank)),
+ h("td", {}, h("div", { class: "who" }, h("div", { class: "disc" }, initial(x)),
+ h("div", { style: "min-width:0" }, h("div", { class: "n", title: x.hint }, h("a", { class: "more-link", href: "#", onclick: (e) => { e.preventDefault(); e.stopPropagation(); openAccount(x.id); } }, who(x))),
+ h("div", { class: "tags" }, h("span", { class: "pill" }, x.channel === "phone" ? T.phone : T.email), x.disabled ? h("span", { class: "pill bad" }, T.disabled) : null,
+ h("span", { class: "fine" }, `${T.thJoined} ${dateOf(x.created_at)}`))))),
+ h("td", { class: "num" + (f.sort === "invited" ? " on" : "") }, fmt(x.invited), x.invites !== x.invited ? h("span", { class: "sub" }, T.invRecorded(x.invites)) : null),
+ h("td", { class: "num hide-sm" + (f.sort === "active" ? " on" : "") }, fmt(x.invited_active), x.invited ? h("span", { class: "sub" }, `${Math.round(100 * x.invited_active / x.invited)}%`) : null),
+ h("td", { class: "num hide-sm" + (f.sort === "same_ip" ? " on" : "") }, x.invited_same_ip ? h("span", { class: "pill warn" }, fmt(x.invited_same_ip)) : "0"),
+ h("td", { class: "num hide-sm" + (f.sort === "spent" ? " on" : "") }, money(x.invited_spent_cny)),
+ h("td", { class: "num hide-sm" + (f.sort === "earned" ? " on" : "") }, money(x.earned_cny)),
+ h("td", { class: "hide-sm" + (f.sort === "latest" ? " on" : "") }, x.last_invite_at ? ago(x.last_invite_at) : T.never, h("span", { class: "sub" }, `${T.firstSeen} ${dateOf(x.first_invite_at)}`)),
+ h("td", { class: "num fine" }, open ? "▾" : "▸")));
+ if (open) {
+ const list = x.invitees || [];
+ rows.push(h("tr", { class: "province" }, h("td", {}, ""), h("td", { colspan: 8, style: "padding:0" },
+ h("div", { class: "list" }, ...list.map((it) => inviteeRow(it, x)), !list.length ? h("div", { class: "empty sm" }, T.none) : null,
+ x.invited > list.length ? h("div", { class: "row tap", onclick: (e) => { e.stopPropagation(); openAccount(x.id); } }, h("span", { class: "tile grey", html: ICON.person }), h("div", { class: "txt" }, h("div", { class: "s" }, T.invMore(x.invited - list.length)))) : null))));
+ }
+ });
+ const table = rows.length ? h("table", { class: "places" },
+ h("thead", {}, h("tr", {}, th("#"), th(T.invThWho), th(T.invThInvited, "invited", "num"), th(T.invThActive, "active", "num hide-sm"), th(T.invThSameIp, "same_ip", "num hide-sm"),
+ th(T.invThSpent, "spent", "num hide-sm"), th(T.invThEarned, "earned", "num hide-sm"), th(T.invThLast, "latest", "hide-sm"), th(""))),
+ h("tbody", {}, ...rows)) : h("div", { class: "empty" }, all.length ? T.invNoMatch : T.invNone);
+ return [h("div", { class: "panel" }, h("h2", {}, T.nav.invites, h("span", { class: "pill" }, fmt(fun.inviters)), h("span", { class: "sp" }), h("span", { class: "fine" }, T.invNote)), kpis, toolbar),
+ h("div", { class: "panel" }, table,
+ shown.length > page.length ? h("div", { class: "more" }, h("button", { class: "btn quiet sm", onclick: () => setParams({ n: f.n + PAGE }) }, T.moreRows(Math.min(PAGE, shown.length - page.length))), h("span", { class: "fine" }, T.shown(page.length, shown.length))) : null,
+ h("div", { class: "fine", style: "padding:10px 16px 12px" }, T.invSameIpNote))];
+ }
+
// ── places: where people are, and a way into them ──────────────
const PLACE_METRICS = [["accounts", "pAccounts"], ["new_accounts", "pNew"], ["signins", "pSignins"], ["requests", "pRequests"], ["demo_visitors", "pDemo"]];
/** The places answer as a tree: countries with their provinces, every metric summed. */
@@ -1637,7 +1732,7 @@
h("div", {}, h("h3", {}, T.gh.releases), rankRows((latest.releases || []).map((r) => ({ name: r.tag, hits: r.downloads }))))),
h("div", { class: "fine", style: "padding:0 16px 12px" }, T.gh.note));
}
- const RENDER = { overview: overviewView, people: peopleView, places: placesView, money: moneyView, activity: activityView, demo: demoView, data: dataView, site: siteView, models: modelsView, health: healthView, stats: statsView, controls: controlsView, settings: settingsView };
+ const RENDER = { overview: overviewView, people: peopleView, invites: invitesView, places: placesView, money: moneyView, activity: activityView, demo: demoView, data: dataView, site: siteView, models: modelsView, health: healthView, stats: statsView, controls: controlsView, settings: settingsView };
// ── the shell: bar, nav, one view ──────────────────────────────
function drawMain() {
diff --git a/cloud/nanomuse_cloud/console/app.js b/cloud/nanomuse_cloud/console/app.js
index 4cf7ac8d..5fd8c2a2 100644
--- a/cloud/nanomuse_cloud/console/app.js
+++ b/cloud/nanomuse_cloud/console/app.js
@@ -5,7 +5,7 @@
(() => {
"use strict";
- const VERSION = "1.0.2";
+ const VERSION = "1.0.3";
// ── i18n ────────────────────────────────────────────────────────
const zh = (navigator.language || "").toLowerCase().startsWith("zh");
@@ -92,8 +92,19 @@
let ws = null, wsState = "off", hubPaused = false, backoff = 1000, devices = [], selected = params.get("device") || LS.getItem("nm.selected") || "";
const pending = new Map(); // call id → {onEvent, resolve, reject}
const chats = new Map(); // device id → {messages:[], busy:false, callId:null}
- const webId = LS.getItem("nm.webid") || ("web-" + Math.random().toString(36).slice(2, 12));
- LS.setItem("nm.webid", webId);
+ // One hub device per tab, not per browser: the id lives in sessionStorage, so a reload
+ // keeps it and a second tab gets its own. Two tabs sharing one id took turns replacing
+ // each other on the hub (close 4003) and reconnecting, for as long as both stayed open.
+ const webId = (() => {
+ let id = "";
+ try { id = window.sessionStorage.getItem("nm.webid") || ""; } catch (_) { /* storage denied */ }
+ if (!id) {
+ id = "web-" + Math.random().toString(36).slice(2, 12);
+ try { window.sessionStorage.setItem("nm.webid", id); } catch (_) { /* this tab's life only */ }
+ }
+ return id;
+ })();
+ LS.removeItem("nm.webid"); // the id of before, shared by every tab of this browser
const app = document.getElementById("app");
const h = (tag, attrs = {}, ...kids) => {
diff --git a/cloud/nanomuse_cloud/db.py b/cloud/nanomuse_cloud/db.py
index b3debc60..e5f52bd5 100644
--- a/cloud/nanomuse_cloud/db.py
+++ b/cloud/nanomuse_cloud/db.py
@@ -269,6 +269,13 @@
# account to come back online and hear the answer; short enough that a deletion is a deletion.
DELETED_KEYS_TTL_S = 90 * 24 * 3600
+# The per-account figures the invite views join in, requests made and money spent, in one
+# pass over the ledger (the same set of kinds as admin_accounts counts).
+USAGE_BY_ACCOUNT = """(SELECT account_id,
+ SUM(CASE WHEN kind IN ('chat','image','video','realtime') THEN 1 ELSE 0 END) AS requests,
+ SUM(CASE WHEN cost_uy>0 THEN cost_uy ELSE 0 END) AS spent_uy
+ FROM ledger GROUP BY account_id)"""
+
def utc_day(ts: int) -> int:
"""The UNIX time the UTC day of `ts` starts — the key of the `daily` table."""
@@ -376,6 +383,8 @@ def add(table: str, col: str, ddl: str) -> None:
add("devices", "ip", "TEXT NOT NULL DEFAULT ''")
self._conn.execute("CREATE INDEX IF NOT EXISTS ledger_ip ON ledger(ip) WHERE ip<>''")
self._conn.execute("CREATE INDEX IF NOT EXISTS api_keys_ip ON api_keys(ip) WHERE ip<>''")
+ # events is the largest of the three and accounts_at_address reads it by ip as well
+ self._conn.execute("CREATE INDEX IF NOT EXISTS events_ip ON events(ip) WHERE ip<>''")
# 0.15: the allowance each account was given (the part of grant_uy that is neither
# an invite nor the operator's credit), so a raised allowance can be applied to the
# accounts that got less. -1 = from before the column; the service fills it with
@@ -985,12 +994,56 @@ def invite_earned_uy(self, inviter_id: str) -> int:
return int(r[0] or 0)
def invitees(self, inviter_id: str, limit: int = 50) -> list[sqlite3.Row]:
+ """The accounts that signed up with this one's code, newest first, each with when it
+ came, whether it ever made a request and what it spent, where it was seen from and
+ when it was last seen. The person's own view keeps the hint and the date of these;
+ the operator's page shows the rest."""
with self._lock:
return self._conn.execute(
- "SELECT id, hint, created_at FROM accounts WHERE invited_by=? ORDER BY created_at DESC LIMIT ?",
+ f"""SELECT i.id, i.hint, i.channel, i.created_at, i.disabled, i.first_ip, i.last_ip, i.last_seen_at,
+ COALESCE(l.requests, 0) AS requests, COALESCE(l.spent_uy, 0) AS spent_uy
+ FROM accounts i
+ LEFT JOIN {USAGE_BY_ACCOUNT} l ON l.account_id=i.id
+ WHERE i.invited_by=? ORDER BY i.created_at DESC, i.rowid DESC LIMIT ?""",
(inviter_id, limit),
).fetchall()
+ def invitee_count(self, inviter_id: str) -> int:
+ """How many accounts here now signed up with this one's code (the ``invites`` column
+ is the counter as it was recorded; an invitee who deleted their account leaves it)."""
+ with self._lock:
+ r = self._conn.execute("SELECT COUNT(*) FROM accounts WHERE invited_by=?", (inviter_id,)).fetchone()
+ return int(r[0] or 0)
+
+ def invite_leaders(self, limit: int = 200) -> list[sqlite3.Row]:
+ """The accounts whose code brought someone, the ones that brought most first: how
+ many came (``invited``, the accounts here now; ``invites`` is the recorded counter),
+ how many of those ever made a request, how many were seen from one of the inviter's
+ own addresses, what they spent, what the inviter's pool got from them, and when the
+ first and the last of them signed up."""
+ with self._lock:
+ return self._conn.execute(
+ f"""SELECT a.id, a.hint, a.channel, a.created_at, a.disabled, a.invites, a.first_ip, a.last_ip, a.identifier_enc,
+ COUNT(i.id) AS invited,
+ SUM(CASE WHEN COALESCE(l.requests, 0) > 0 THEN 1 ELSE 0 END) AS invited_active,
+ SUM(CASE WHEN (i.first_ip<>'' AND i.first_ip IN (a.first_ip, a.last_ip))
+ OR (i.last_ip<>'' AND i.last_ip IN (a.first_ip, a.last_ip)) THEN 1 ELSE 0 END) AS invited_same_ip,
+ COALESCE(SUM(l.spent_uy), 0) AS invited_spent_uy,
+ COALESCE(e.earned_uy, 0) AS earned_uy,
+ MIN(i.created_at) AS first_invite_at,
+ MAX(i.created_at) AS last_invite_at
+ FROM accounts a
+ JOIN accounts i ON i.invited_by=a.id
+ LEFT JOIN {USAGE_BY_ACCOUNT} l ON l.account_id=i.id
+ LEFT JOIN (SELECT account_id, SUM(json_extract(extra, '$.credit_uy')) AS earned_uy
+ FROM ledger WHERE kind='credit' AND json_extract(extra, '$.from')='invite'
+ GROUP BY account_id) e ON e.account_id=a.id
+ GROUP BY a.id
+ ORDER BY invited DESC, last_invite_at DESC
+ LIMIT ?""",
+ (limit,),
+ ).fetchall()
+
# -- passwords -----------------------------------------------------------------
def set_password(self, account_id: str, password_hash: str) -> None:
diff --git a/cloud/nanomuse_cloud/hub.py b/cloud/nanomuse_cloud/hub.py
index 64b2794d..2eb17120 100644
--- a/cloud/nanomuse_cloud/hub.py
+++ b/cloud/nanomuse_cloud/hub.py
@@ -319,6 +319,22 @@ async def drop_account(self, account_id: str) -> None:
c.stop()
await c._close(4001, "account_gone")
+ async def drop_signed_out(self, account_id: str) -> int:
+ """After a sign-out (this device, every other device, one session revoked on the
+ account page): every socket of the account whose key no longer opens is closed with
+ 4001 bad_key, so a device signed out from elsewhere, a lost phone say, loses its live
+ line now and not at its next reconnect. Returns how many were closed."""
+ n = 0
+ for c in list(self.online.get(account_id, {}).values()):
+ if self.cloud.key_live(c.key_hash):
+ continue
+ await c._close(4001, "bad_key")
+ # off the account's list now, not when its receive loop notices the close: the
+ # other devices hear it left and a call it was carrying out fails as offline
+ await self._detach(c)
+ n += 1
+ return n
+
async def drop_all(self, reason: str = "hub_paused") -> int:
"""0.22: the operator turned the hub (or the whole service) off — every socket is
closed with 4003 and the reason; the apps reconnect with backoff and are refused
diff --git a/cloud/nanomuse_cloud/identifiers.py b/cloud/nanomuse_cloud/identifiers.py
index af88b2db..0709e952 100644
--- a/cloud/nanomuse_cloud/identifiers.py
+++ b/cloud/nanomuse_cloud/identifiers.py
@@ -23,7 +23,7 @@ class BadIdentifier(ValueError):
@dataclass(frozen=True)
class Identifier:
channel: str # phone | email
- value: str # +8613800138000 / user@example.com (normalised)
+ value: str # +8613800138000 / user@example.com (normalised)
@property
def hint(self) -> str:
diff --git a/cloud/nanomuse_cloud/providers.py b/cloud/nanomuse_cloud/providers.py
index 84ed29f4..cd1cbca6 100644
--- a/cloud/nanomuse_cloud/providers.py
+++ b/cloud/nanomuse_cloud/providers.py
@@ -102,7 +102,11 @@ def guidance(region: str, docs_url: str) -> dict[str, Any]:
# a vendor that wants a session id per conversation (`session_header`, OpenCode Go) is listed
# apart, under `session_providers`: the apps of before it (1.0.1 and older) read `providers`
# and do not send the id, the apps that do read both
- rest = [p for p in cat["providers"] if p["id"] not in first and p["id"] not in LOCAL and p["id"] != "custom" and not p.get("session_header") and _fits(p, region)]
+ rest = [
+ p
+ for p in cat["providers"]
+ if p["id"] not in first and p["id"] not in LOCAL and p["id"] != "custom" and not p.get("session_header") and _fits(p, region)
+ ]
session = [p for p in cat["providers"] if p.get("session_header") and _fits(p, region)]
# the rest grouped by what they can do: the fuller key first, the catalogue's order within
rest.sort(key=lambda p: -len(p["capabilities"]))
diff --git a/cloud/nanomuse_cloud/senders.py b/cloud/nanomuse_cloud/senders.py
index e48f5ea3..4c5a7752 100644
--- a/cloud/nanomuse_cloud/senders.py
+++ b/cloud/nanomuse_cloud/senders.py
@@ -65,7 +65,8 @@ def accepts(self, ident: Identifier) -> bool:
def send(self, ident: Identifier, code: str) -> None:
if ident.channel != "email":
raise SendError("this deployment sends codes by e-mail only")
- msg = compose_code_mail(self.s.smtp_from, ident.value, code, self.s.code_ttl_s // 60)
+ # the same rounding as the SMS template: a lifetime under a minute still reads as one
+ msg = compose_code_mail(self.s.smtp_from, ident.value, code, max(1, self.s.code_ttl_s // 60))
smtp_send(self.s, msg)
diff --git a/cloud/nanomuse_cloud/service.py b/cloud/nanomuse_cloud/service.py
index 13b5aa49..8bc20f22 100644
--- a/cloud/nanomuse_cloud/service.py
+++ b/cloud/nanomuse_cloud/service.py
@@ -1028,6 +1028,11 @@ def authenticate(self, bearer: str | None) -> Caller:
self.db.daily_add("active", caller.account_id)
return caller
+ def key_live(self, key_hash: str) -> bool:
+ """Whether a key still opens: not revoked, not past its time. What the hub asks after
+ a sign-out to find the sockets whose key just stopped working."""
+ return self._caller(key_hash) is not None
+
def sign_out(self, caller: Caller) -> None:
self.db.revoke_key(caller.key_hash)
self.db.add_event(caller.account_id, "sign_out")
@@ -1163,7 +1168,13 @@ def _way_facts(pid: str) -> dict:
p = catalogue_provider(pid)
if p is None:
return {}
- return {"name": p["name"], "name_zh": p["name_zh"], "key_url": p["key_url"], "covers": list(p["capabilities"]), "auth": list(p["auth"])}
+ return {
+ "name": p["name"],
+ "name_zh": p["name_zh"],
+ "key_url": p["key_url"],
+ "covers": list(p["capabilities"]),
+ "auth": list(p["auth"]),
+ }
def guidance(self, region: str) -> dict:
"""The own-key guidance for this person (0.21, contract C11): the providers for the
@@ -2174,9 +2185,7 @@ def totals(s: int) -> dict:
# 0.22: the console shows accounts in full (decrypted for the admin token only);
# the hint stays beside it for anything that leaves the console. Only the accounts
# on this page are read and decrypted, not every account the relay has.
- hints, idents = self._labels(
- [r["account_id"] for r in top_rows] + [r["account_id"] for r in event_rows if r["account_id"]]
- )
+ hints, idents = self._labels([r["account_id"] for r in top_rows] + [r["account_id"] for r in event_rows if r["account_id"]])
top = []
for r in top_rows:
top.append(
@@ -2360,7 +2369,12 @@ def admin_account(self, account_id: str, days: int = 30) -> dict:
a["locked"] = bool(row["locked_until"] and int(row["locked_until"]) > t)
spent_total = self.db.spent_since(account_id, 0)
self._pool_fields(a, spent_cny=self.s.uy_to_cny(spent_total))
- a["invited"] = [{"id": r["id"], "hint": r["hint"], "created_at": int(r["created_at"])} for r in self.db.invitees(account_id)]
+ # who this account brought (the newest 500, with the count of all of them), and who
+ # brought this account: the operator can follow an invite chain either way
+ a["invited"] = [self._invitee_fields(r) for r in self.db.invitees(account_id, limit=500)]
+ a["invited_total"] = self.db.invitee_count(account_id)
+ a["inviter"] = self._account_label(str(a.get("invited_by") or ""))
+ a["invite_earned_cny"] = self.s.uy_to_cny(self.db.invite_earned_uy(account_id))
a["contribute"] = bool(a.get("contribute"))
# turns kept while the switch was on stay until the person deletes them: count them
# whether or not it is on now, so the operator sees what is there
@@ -2424,6 +2438,63 @@ def admin_account(self, account_id: str, days: int = 30) -> dict:
"events": events["rows"],
}
+ def _account_label(self, account_id: str) -> dict | None:
+ """`id`, `hint` and the identifier in clear for one account, or None when there is
+ no such account (an inviter who has since deleted theirs, say)."""
+ rows = self.db.accounts_brief([account_id]) if account_id else []
+ if not rows:
+ return None
+ r = rows[0]
+ return {"id": r["id"], "hint": r["hint"], "identifier": self.crypto.decrypt(r["id"], r["identifier_enc"] or "") or ""}
+
+ def _invitee_fields(self, r: sqlite3.Row) -> dict:
+ """One account that came through an invite, as the operator's page lists it."""
+ return {
+ "id": r["id"],
+ "hint": r["hint"],
+ "channel": r["channel"],
+ "created_at": int(r["created_at"]),
+ "disabled": bool(r["disabled"]),
+ "first_ip": r["first_ip"] or "",
+ "last_ip": r["last_ip"] or "",
+ "last_seen_at": int(r["last_seen_at"]) if r["last_seen_at"] else None,
+ "requests": int(r["requests"] or 0),
+ "spent_cny": self.s.uy_to_cny(int(r["spent_uy"] or 0)),
+ }
+
+ def admin_invites(self, limit: int = 200, per: int = 20) -> dict:
+ """Who invited whom, for the operator: the funnel as it stands and the inviters in
+ order of how many accounts their code brought, each with how many of those ever
+ made a request, how many were seen from one of the inviter's own addresses, what
+ they spent and what the inviter's pool got from them, and the newest `per` of the
+ accounts themselves (the account page lists up to 500). An inviter with many
+ sign-ups from one address and nobody using anything is a pool being farmed; one
+ whose invitees go on to make requests brought people."""
+ leaders = []
+ for r in self.db.invite_leaders(limit):
+ leaders.append(
+ {
+ "invitees": [self._invitee_fields(i) for i in self.db.invitees(r["id"], limit=per)] if per > 0 else [],
+ "id": r["id"],
+ "hint": r["hint"],
+ "identifier": self.crypto.decrypt(r["id"], r["identifier_enc"] or "") or "",
+ "channel": r["channel"],
+ "created_at": int(r["created_at"]),
+ "disabled": bool(r["disabled"]),
+ "first_ip": r["first_ip"] or "",
+ "last_ip": r["last_ip"] or "",
+ "invites": int(r["invites"] or 0),
+ "invited": int(r["invited"] or 0),
+ "invited_active": int(r["invited_active"] or 0),
+ "invited_same_ip": int(r["invited_same_ip"] or 0),
+ "invited_spent_cny": self.s.uy_to_cny(int(r["invited_spent_uy"] or 0)),
+ "earned_cny": self.s.uy_to_cny(int(r["earned_uy"] or 0)),
+ "first_invite_at": int(r["first_invite_at"] or 0),
+ "last_invite_at": int(r["last_invite_at"] or 0),
+ }
+ )
+ return {"generated_at": now(), "funnel": self.db.invite_funnel(), "leaders": leaders}
+
def ledger_page(self, account_id: str, limit: int = 100, before_id: int = 0) -> dict:
"""One page of an account's statement, newest first, with the count of the whole
— the operator's page reads it to the first line, page by page."""
diff --git a/cloud/nanomuse_cloud/sync.py b/cloud/nanomuse_cloud/sync.py
index ac3db045..2be93778 100644
--- a/cloud/nanomuse_cloud/sync.py
+++ b/cloud/nanomuse_cloud/sync.py
@@ -273,9 +273,7 @@ def changes(self, account_id: str, since: int = 0, limit: int = DEFAULT_PAGE, sc
tail = max(0, min(int(tail or 0), MAX_TAIL)) if since == 0 else 0
# the scope as a SQL fragment over sync_conversations (sc) and sync_messages (sm)
conv_scope = " AND kind='main'" if scope == "main" else ""
- msg_scope = (
- " AND cid IN (SELECT cid FROM sync_conversations WHERE account_id=? AND kind='main')" if scope == "main" else ""
- )
+ msg_scope = " AND cid IN (SELECT cid FROM sync_conversations WHERE account_id=? AND kind='main')" if scope == "main" else ""
with self.db.tx() as c:
if not self._enabled(c, account_id):
raise CloudError(409, "sync_off", "Conversation sync is off for this account")
@@ -352,7 +350,9 @@ def set_working(self, account_id: str, cid: str, device: str, working: bool) ->
@staticmethod
def _live_conversations(c: sqlite3.Connection, account_id: str) -> int:
return int(
- c.execute("SELECT COUNT(*) FROM sync_conversations WHERE account_id=? AND kind='side' AND deleted=0", (account_id,)).fetchone()[0]
+ c.execute("SELECT COUNT(*) FROM sync_conversations WHERE account_id=? AND kind='side' AND deleted=0", (account_id,)).fetchone()[
+ 0
+ ]
)
def _enabled(self, c: sqlite3.Connection, account_id: str) -> bool:
diff --git a/cloud/pyproject.toml b/cloud/pyproject.toml
index a56aa011..120c8592 100644
--- a/cloud/pyproject.toml
+++ b/cloud/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "nanomuse-cloud"
-version = "0.24.1"
+version = "0.24.2"
description = "nanoMuse Cloud: sign up with a phone number or an e-mail address, get a key that works out of the box; an OpenAI-shaped relay with a token grant per account."
readme = "README.md"
license = { text = "GPL-3.0-or-later" }
diff --git a/cloud/tests/test_accounts.py b/cloud/tests/test_accounts.py
index af1fca8f..d7527fd8 100644
--- a/cloud/tests/test_accounts.py
+++ b/cloud/tests/test_accounts.py
@@ -242,7 +242,9 @@ async def test_the_accounts_list_is_one_grouped_query_with_the_old_numbers():
c = await sign_up(client, sender, "dev-b@example.com", "pc")
ha, hb = auth(a["api_key"]), auth(b["api_key"])
for _ in range(3):
- await client.post("/v1/chat/completions", json={"model": "qwen3.8-27b", "messages": [{"role": "user", "content": "hi"}]}, headers=ha)
+ await client.post(
+ "/v1/chat/completions", json={"model": "qwen3.8-27b", "messages": [{"role": "user", "content": "hi"}]}, headers=ha
+ )
await client.post("/v1/images/generations", json={"model": "qwen-image-3.0", "prompt": "a cat"}, headers=hb)
await client.post("/v1/auth/session-key", json={"device": "mac-2"}, headers=hb)
await client.delete("/v1/me/sessions/" + b["api_key"][:12], headers=hb)
@@ -520,3 +522,76 @@ async def join(identifier: str) -> None:
inv = (await client.get("/v1/me/invite", headers=auth(a["api_key"]))).json()
assert inv["invites"] == 2 and inv["bonus_cny"] == 1
assert inv["earned_cny"] == 4, "3 for the first friend plus 1 for the second, as the ledger has it"
+
+
+async def test_operator_sees_who_invited_whom_and_the_ranking():
+ """`GET /v1/admin/invites` ranks the inviters by the accounts their code brought, says how
+ many of those ever made a request and how many came from one of the inviter's own
+ addresses (a pool being farmed looks like many of the second and none of the first),
+ and the account page names the inviter and lists the people brought."""
+ app, client, sender, up, cloud, settings = make(allowance_cny=5, invite_bonus_cny=2)
+ admin = {"X-Admin-Token": "admin"}
+ a = await sign_up(client, sender, "dev-a@example.com", "pixel", headers={"X-Forwarded-For": "203.0.113.7"})
+ b = await sign_up(client, sender, "dev-b@example.com", "mac", headers={"X-Forwarded-For": "198.51.100.2"})
+ # every request says where it comes from: a request without the header would be seen from
+ # the test client's own address and make two unrelated accounts share one
+ from_ip = lambda key, ip: {**auth(key), "X-Forwarded-For": ip} # noqa: E731
+ code_a = (await client.get("/v1/me/invite", headers=from_ip(a["api_key"], "203.0.113.7"))).json()["code"]
+ code_b = (await client.get("/v1/me/invite", headers=from_ip(b["api_key"], "198.51.100.2"))).json()["code"]
+
+ async def join(identifier: str, code: str, ip: str) -> dict:
+ return await sign_up(client, sender, identifier, "mac", invite=code, headers={"X-Forwarded-For": ip})
+
+ # A's code brings three: two from A's own address that never use anything, one elsewhere who chats
+ await join("13900000001", code_a, "203.0.113.7")
+ await join("13900000002", code_a, "203.0.113.7")
+ c3 = await join("dev-c3@example.com", code_a, "192.0.2.9")
+ r = await client.post(
+ "/v1/chat/completions",
+ json={"model": "qwen3.8-flash", "messages": [{"role": "user", "content": "hi"}]},
+ headers=from_ip(c3["api_key"], "192.0.2.9"),
+ )
+ assert r.status_code == 200
+ # B's code brings one, from somewhere else
+ d = await join("dev-d@example.com", code_b, "192.0.2.10")
+
+ # nobody but the operator
+ assert (await client.get("/v1/admin/invites")).status_code == 401
+ view = (await client.get("/v1/admin/invites", headers=admin)).json()
+ # every account is given its code at sign-up, so six have one; two have brought someone
+ assert view["funnel"] == {"with_code": 6, "inviters": 2, "invited": 4, "contribute_bonuses": 0}
+ assert [x["identifier"] for x in view["leaders"]] == ["dev-a@example.com", "dev-b@example.com"]
+ top = view["leaders"][0]
+ assert top["invites"] == 3 and top["invited"] == 3 and top["invited_active"] == 1 and top["invited_same_ip"] == 2
+ assert top["earned_cny"] == 6 and top["invited_spent_cny"] > 0 and top["last_ip"] == "203.0.113.7"
+ assert top["first_invite_at"] <= top["last_invite_at"]
+ assert [i["hint"] for i in top["invitees"]][-1] == "139****0001", "newest first, with the hint and no identifier"
+ assert "identifier" not in top["invitees"][0] and top["invitees"][0]["requests"] == 1 and top["invitees"][0]["last_ip"] == "192.0.2.9"
+ second = view["leaders"][1]
+ assert second["invited"] == 1 and second["invited_same_ip"] == 0 and second["invitees"][0]["id"] == d["account"]["id"]
+ assert "places" in view
+ # the newest `per` of each inviter's people, and none when asked for none
+ few = (await client.get("/v1/admin/invites", params={"per": 1}, headers=admin)).json()
+ assert len(few["leaders"][0]["invitees"]) == 1 and few["leaders"][0]["invited"] == 3
+ assert (await client.get("/v1/admin/invites", params={"per": 0}, headers=admin)).json()["leaders"][0]["invitees"] == []
+ assert len((await client.get("/v1/admin/invites", params={"limit": 1}, headers=admin)).json()["leaders"]) == 1
+
+ # the account page: who brought this one, and whom this one brought
+ detail = (await client.get(f"/v1/admin/accounts/{c3['account']['id']}", headers=admin)).json()["account"]
+ assert detail["inviter"] == {"id": a["account"]["id"], "hint": "de***@example.com", "identifier": "dev-a@example.com"}
+ assert detail["invited"] == [] and detail["invited_total"] == 0 and detail["invite_earned_cny"] == 0
+ detail = (await client.get(f"/v1/admin/accounts/{a['account']['id']}", headers=admin)).json()["account"]
+ assert detail["inviter"] is None and detail["invited_total"] == 3 and detail["invite_earned_cny"] == 6
+ assert [i["hint"] for i in detail["invited"]] == ["de***@example.com", "139****0002", "139****0001"]
+ assert (
+ detail["invited"][0]["requests"] == 1
+ and detail["invited"][1]["requests"] == 0
+ and detail["invited"][1]["first_ip"] == "203.0.113.7"
+ )
+
+ # an inviter who deleted their account: the invitee still says who, by id, and the ranking lets them go
+ r = await client.post("/v1/admin/delete", headers=admin, json={"account_id": b["account"]["id"]})
+ assert r.status_code == 204, r.text
+ detail = (await client.get(f"/v1/admin/accounts/{d['account']['id']}", headers=admin)).json()["account"]
+ assert detail["invited_by"] == b["account"]["id"] and detail["inviter"] is None
+ assert [x["identifier"] for x in (await client.get("/v1/admin/invites", headers=admin)).json()["leaders"]] == ["dev-a@example.com"]
diff --git a/cloud/tests/test_cloud.py b/cloud/tests/test_cloud.py
index 8ec3784a..cd99f1f1 100644
--- a/cloud/tests/test_cloud.py
+++ b/cloud/tests/test_cloud.py
@@ -691,7 +691,7 @@ async def test_open_signup_members_unlimited_everyone_else_has_one_allowance():
# the 0.5 co-creation bonus is gone; the names stay for the apps of the time
"contribute_bonus_cny": 0,
"contribute_bonus_available": False,
-"own_key_docs": "https://relay.test/own-key",
+ "own_key_docs": "https://relay.test/own-key",
"openrouter_url": "https://openrouter.ai/keys",
# 0.17: a mainland phone account is pointed to Bailian first, then OpenRouter, then an invite;
# 0.21 adds the catalogue's facts to the two key ways (additive) and the whole card as `guidance`
@@ -1786,6 +1786,21 @@ def test_database_from_before_0_4_migrates(tmp_path):
assert db._conn.execute("SELECT invite_code, contribute FROM accounts WHERE id='a1'").fetchone()[0] == ""
+def test_the_three_tables_read_by_address_are_indexed_on_it():
+ """The operator's "accounts seen from this address" reads ledger, api_keys and events by
+ ip (db.accounts_at_address); each has the partial index, events included, which is the
+ largest of the three. Opening the database twice creates nothing twice."""
+ db = Database(":memory:")
+
+ def indexes() -> list[str]:
+ return sorted(r["name"] for r in db._conn.execute("SELECT name FROM sqlite_master WHERE type='index'").fetchall())
+
+ before = indexes()
+ assert {"ledger_ip", "api_keys_ip", "events_ip"} <= set(before)
+ db._migrate()
+ assert indexes() == before # a second run neither fails nor adds anything
+
+
def test_database_from_0_4_moves_to_the_lifetime_allowance(tmp_path):
"""0.4 kept a daily cap and credit beyond it; 0.5 has one pool. On the first open the
service seeds every account with what it had spent plus the allowance, plus any 0.4
@@ -2257,7 +2272,11 @@ async def test_me_says_where_the_person_is_and_orders_the_ways_on_by_it(tmp_path
)
)
app, client, sender, up, cloud = make_stack(
- geoip_db=str(db), catalog_probe=False, allowance_cny=0.002, invite_url="https://relay.test/web/?invite=", own_key_docs="https://relay.test/own-key"
+ geoip_db=str(db),
+ catalog_probe=False,
+ allowance_cny=0.002,
+ invite_url="https://relay.test/web/?invite=",
+ own_key_docs="https://relay.test/own-key",
)
assert app.state.geo.ready
@@ -2288,9 +2307,7 @@ def auth(data, ip):
assert [w["id"] for w in cn_ways] == ["bailian", "openrouter", "invite"]
assert cn_ways[0] == {"id": "bailian", "url": "https://relay.test/own-key", "mainland_only": True, **WAY_FACTS["bailian"]}
assert cn_ways[1] == {"id": "openrouter", "url": "https://openrouter.ai/keys", "mainland_only": False, **WAY_FACTS["openrouter"]}
- assert (
- cn_ways[2]["id"] == "invite" and cn_ways[2]["url"].startswith("https://relay.test/web/?invite=") and cn_ways[2]["bonus_cny"] == 5
- )
+ assert cn_ways[2]["id"] == "invite" and cn_ways[2]["url"].startswith("https://relay.test/web/?invite=") and cn_ways[2]["bonus_cny"] == 5
for who, ip in ((us, "8.8.8.8"), (nowhere, "9.9.9.9")):
ways = (await client.get("/v1/me", headers=auth(who, ip))).json()["spend"]["ways"]
assert [w["id"] for w in ways] == ["openrouter", "bailian", "invite"], who["region"]
diff --git a/cloud/tests/test_console.py b/cloud/tests/test_console.py
index d6a94e69..6aa59d1a 100644
--- a/cloud/tests/test_console.py
+++ b/cloud/tests/test_console.py
@@ -68,6 +68,18 @@ def test_web_console_translates_every_refusal_in_both_languages():
assert "!" not in re.sub(r"[!=]==?|!\w", "", zh + en) and "!" not in zh
+def test_web_console_is_one_hub_device_per_tab():
+ """The id the console says hello with lives in sessionStorage, so two tabs of one
+ browser are two devices on the hub. Shared through localStorage, as before, the two
+ took turns replacing each other (close 4003) and reconnecting for as long as both
+ stayed open."""
+ source = (CONSOLE / "app.js").read_text(encoding="utf-8")
+ assert 'sessionStorage.getItem("nm.webid")' in source and 'sessionStorage.setItem("nm.webid"' in source
+ assert 'LS.setItem("nm.webid"' not in source and 'LS.getItem("nm.webid")' not in source
+ # the hello carries that id; the hub keys its connections on it (hub.py _attach)
+ assert "device: { id: webId," in source
+
+
def test_console_copy_has_no_dashes_in_sentences():
"""The house rule: what a person reads carries no em dash. Checked on the two string
tables (`const T = zh ? {...} : {...}`) of each console; the one-character placeholder
@@ -93,3 +105,52 @@ def test_admin_console_names_the_star_card_text_in_both_languages():
assert "Empty: each app shows its own sentence. At most 200 characters." in source
panel = source[source.index("function nudgesPanel()") : source.index("function settingsView()")]
assert 'cardText("text")' in panel and 'cardText("text_zh"' in panel
+
+
+def test_admin_console_has_the_invites_view_in_both_languages():
+ """The Invites view (who brought whom, the inviters that brought most first) is in the
+ side navigation, reads `/v1/admin/invites`, and every string it draws is in the 简体中文
+ table and in the English one; the account drawer names the inviter and lists the people
+ brought."""
+ source = (CONSOLE / "admin" / "admin.js").read_text(encoding="utf-8")
+ assert '"invites"' in source[source.index("const VIEWS = ") : source.index("const PAGE = ")]
+ assert "invites: invitesView" in source and 'invites: () => "/v1/admin/invites?' in source
+ keys = (
+ "invNote",
+ "invSearch",
+ "invNone",
+ "invNoMatch",
+ "invKpiCode",
+ "invKpiInviters",
+ "invKpiInvited",
+ "invKpiActive",
+ "invKpiSameIp",
+ "invThWho",
+ "invThInvited",
+ "invThActive",
+ "invThSameIp",
+ "invThSpent",
+ "invThEarned",
+ "invThLast",
+ "invSort",
+ "invRecorded",
+ "invSameIp",
+ "invGone",
+ "invSeeRanking",
+ "invMore",
+ "invSameIpNote",
+ "invitedList",
+ "invEarnedLine",
+ "invNewest",
+ "invPill",
+ )
+ for key in keys:
+ assert source.count(f"{key}:") == 2, f"{key} is in one table only"
+ assert 'invites: "邀请"' in source and 'invites: "Invites"' in source
+ assert 'invThWho: "邀请人"' in source and 'invThWho: "Inviter"' in source
+ for key in keys:
+ assert f"T.{key}" in source, f"nothing draws {key}"
+ view = source[source.index("function invitesView()") : source.index("function placesView()")]
+ assert 'need("invites")' in view and "T.invSameIpNote" in view and "INV_OPEN" in view
+ drawer = source[source.index("function drawDrawer()") : source.index("function ledgerLine(")]
+ assert "a.inviter" in drawer and "T.invitedList(" in drawer and "inviteeRow(i, a)" in drawer
diff --git a/cloud/tests/test_data_dir.py b/cloud/tests/test_data_dir.py
new file mode 100644
index 00000000..f403e50b
--- /dev/null
+++ b/cloud/tests/test_data_dir.py
@@ -0,0 +1,32 @@
+"""The relay's image runs as uid 10001; a data directory it cannot write stops the start with
+a sentence that names the directory and the chown, not with sqlite's traceback."""
+
+from __future__ import annotations
+
+import os
+import stat
+from pathlib import Path
+
+import pytest
+
+from nanomuse_cloud.__main__ import check_data_dir
+
+
+def test_a_writable_directory_passes_and_is_created(tmp_path: Path):
+ db = tmp_path / "data" / "cloud.db"
+ check_data_dir(str(db))
+ assert db.parent.is_dir()
+ check_data_dir(":memory:")
+
+
+@pytest.mark.skipif(not hasattr(os, "getuid") or os.getuid() == 0, reason="root writes anywhere; the check is for an unprivileged user")
+def test_a_directory_owned_by_someone_else_stops_the_start(tmp_path: Path):
+ data = tmp_path / "data"
+ data.mkdir()
+ data.chmod(stat.S_IRUSR | stat.S_IXUSR)
+ try:
+ with pytest.raises(SystemExit) as exc:
+ check_data_dir(str(data / "cloud.db"))
+ assert str(data) in str(exc.value) and "chown -R 10001:10001" in str(exc.value)
+ finally:
+ data.chmod(stat.S_IRWXU)
diff --git a/cloud/tests/test_hub.py b/cloud/tests/test_hub.py
index 0f60a15b..ba74fc26 100644
--- a/cloud/tests/test_hub.py
+++ b/cloud/tests/test_hub.py
@@ -352,6 +352,56 @@ def test_reconnect_replaces_the_older_socket(client):
assert all(len(f["devices"]) == 1 for f in frames)
+def test_a_sign_out_closes_the_hub_socket_of_the_key_it_revoked(client):
+ """Sign out everywhere is what to do after losing a phone. The lost phone's key stops
+ opening at once; its hub socket, open before the sign-out, must go too (4001 bad_key,
+ docs/hub.md), or the phone keeps a live line to the other devices until it next
+ reconnects. The device that asked keeps its socket; a session revoked by prefix from
+ the account page and a device's own sign-out close the same way."""
+ phone_key = sign_up(client, "13800138000")
+ pc_key = sign_up(client, "13800138000")
+ with connect(client, phone_key) as phone, connect(client, pc_key) as pc:
+ phone.send_json(hello("phone", "phone-1", "Pixel"))
+ phone.receive_json()
+ phone.receive_json()
+ pc.send_json(hello("computer", "pc-1", "desk"))
+ pc.receive_json()
+ pc.receive_json()
+ phone.receive_json() # the pc arrived
+ # every other device: the phone's key is revoked, its socket closed; the pc stays
+ r = client.post("/v1/auth/sign-out-all", headers={"Authorization": f"Bearer {pc_key}"}, json={})
+ assert r.status_code == 200 and r.json()["signed_out"] == 1
+ with pytest.raises(WebSocketDisconnect) as closed:
+ phone.receive_json()
+ assert closed.value.code == 4001 and closed.value.reason == "bad_key"
+ pc.send_json({"type": "devices"})
+ assert pc.receive_json()["type"] == "devices" # the pc's line is still open
+ r = client.get("/v1/devices", headers={"Authorization": f"Bearer {pc_key}"})
+ assert {d["id"]: d["online"] for d in r.json()["devices"]} == {"phone-1": False, "pc-1": True}
+ # a session revoked by prefix from the account page
+ key_a = sign_up(client, "dev-a@example.com")
+ key_b = sign_up(client, "dev-a@example.com")
+ with connect(client, key_b) as other:
+ other.send_json(hello("computer", "pc-2", "laptop"))
+ other.receive_json()
+ other.receive_json()
+ sessions = client.get("/v1/me/sessions", headers={"Authorization": f"Bearer {key_a}"}).json()["sessions"]
+ prefix = next(s["prefix"] for s in sessions if not s["current"])
+ assert client.delete(f"/v1/me/sessions/{prefix}", headers={"Authorization": f"Bearer {key_a}"}).status_code == 204
+ with pytest.raises(WebSocketDisconnect) as closed:
+ other.receive_json()
+ assert closed.value.code == 4001
+ # a device's own sign-out
+ with connect(client, key_a) as own:
+ own.send_json(hello("computer", "pc-3", "desk"))
+ own.receive_json()
+ own.receive_json()
+ assert client.post("/v1/auth/sign-out", headers={"Authorization": f"Bearer {key_a}"}).status_code == 204
+ with pytest.raises(WebSocketDisconnect) as closed:
+ own.receive_json()
+ assert closed.value.code == 4001
+
+
def test_the_profile_is_shared_and_announced(client):
"""The agent's name and look live on the relay so every device of the account wears
the same one: a PUT from one device is a ``profile`` frame on the other's socket, and
diff --git a/cloud/tests/test_providers.py b/cloud/tests/test_providers.py
index a478fede..cadce19e 100644
--- a/cloud/tests/test_providers.py
+++ b/cloud/tests/test_providers.py
@@ -25,7 +25,26 @@ def test_catalogue_shape():
cat = pv.catalogue()
ids = [p["id"] for p in cat["providers"]]
assert len(ids) == len(set(ids))
- for must in ("bailian", "openrouter", "openai", "anthropic", "gemini", "deepseek", "moonshot", "zhipu", "siliconflow", "volcengine", "minimax", "xai", "groq", "mistral", "ollama", "lm-studio", "vllm", "custom"):
+ for must in (
+ "bailian",
+ "openrouter",
+ "openai",
+ "anthropic",
+ "gemini",
+ "deepseek",
+ "moonshot",
+ "zhipu",
+ "siliconflow",
+ "volcengine",
+ "minimax",
+ "xai",
+ "groq",
+ "mistral",
+ "ollama",
+ "lm-studio",
+ "vllm",
+ "custom",
+ ):
assert must in ids
for p in cat["providers"]:
assert "chat" in p["capabilities"]
diff --git a/cloud/tests/test_senders.py b/cloud/tests/test_senders.py
index 86ec75d2..ce5f74d5 100644
--- a/cloud/tests/test_senders.py
+++ b/cloud/tests/test_senders.py
@@ -115,6 +115,24 @@ def test_make_sender_both_routes_phones_to_sms_and_addresses_to_mail(monkeypatch
assert mailed == [("dev-a@example.com", "654321")] and len(fake.urls) == 1
+def test_the_code_mail_states_the_lifetime_in_whole_minutes_like_the_sms(monkeypatch):
+ """Both templates say how long the code is good for in minutes, rounded the same way:
+ CODE_TTL_S=600 reads as ten, and a lifetime under a minute still reads as one, never
+ as zero. The mail is composed with the settings' figure, in both languages."""
+ sent = []
+ monkeypatch.setattr(senders, "smtp_send", lambda s, msg: sent.append(msg))
+ mail = SmtpSender(aliyun_settings(sender="smtp", smtp_host="smtp.test", smtp_from="nanoMuse "))
+ mail.send(parse("dev-a@example.com"), "482913")
+ body = sent[-1].get_body(("plain",)).get_content()
+ assert "482913" in body and "10 分钟内有效" in body and "expires in 10 minutes" in body
+ short = SmtpSender(aliyun_settings(sender="smtp", smtp_host="smtp.test", smtp_from="nanoMuse ", code_ttl_s=30))
+ short.send(parse("dev-a@example.com"), "482913")
+ body = sent[-1].get_body(("plain",)).get_content()
+ assert "1 分钟内有效" in body and "expires in 1 minutes" in body and "0 分钟" not in body
+ sms = AliyunSmsSender(aliyun_settings(code_ttl_s=30))
+ assert json.loads(sms.params(parse("13800138000"), "482913")["TemplateParam"]) == {"code": "482913", "min": "1"}
+
+
def test_unknown_api_and_missing_settings_are_refused_at_startup():
with pytest.raises(ValueError):
AliyunSmsSender(aliyun_settings(aliyun_sms_api="carrier-pigeon"))
diff --git a/cloud/tests/test_sync.py b/cloud/tests/test_sync.py
index 288fbd20..f8f07603 100644
--- a/cloud/tests/test_sync.py
+++ b/cloud/tests/test_sync.py
@@ -122,7 +122,9 @@ async def test_a_renamed_conversation_rides_along_with_its_messages():
app, client, sender, up, cloud, settings = make()
key = await signed_in(client, sender)
cid, m1 = uid(), uid()
- await client.post("/v1/sync/changes", json={"device": "d1", "conversations": [conv(cid)], "messages": [msg(m1, cid)]}, headers=auth(key))
+ await client.post(
+ "/v1/sync/changes", json={"device": "d1", "conversations": [conv(cid)], "messages": [msg(m1, cid)]}, headers=auth(key)
+ )
await client.post(
"/v1/sync/changes", json={"device": "d1", "conversations": [conv(cid, title="Renamed", updated_at=1738000500)]}, headers=auth(key)
)
@@ -444,12 +446,18 @@ async def test_scope_main_delivers_the_main_conversation_only():
key = await signed_in(client, sender)
# no main yet: an empty page whose cursor is the account's counter
side, s1 = uid(), uid()
- await client.post("/v1/sync/changes", json={"device": "a", "conversations": [conv(side, "side", "Side")], "messages": [msg(s1, side)]}, headers=auth(key))
+ await client.post(
+ "/v1/sync/changes",
+ json={"device": "a", "conversations": [conv(side, "side", "Side")], "messages": [msg(s1, side)]},
+ headers=auth(key),
+ )
r = await client.get("/v1/sync/changes?scope=main", headers=auth(key))
assert r.status_code == 200 and r.json() == {"cursor": 2, "more": False, "conversations": [], "messages": []}
main, m1 = uid(), uid()
await client.post(
- "/v1/sync/changes", json={"device": "a", "conversations": [conv(main, "main", "Main")], "messages": [msg(m1, main)]}, headers=auth(key)
+ "/v1/sync/changes",
+ json={"device": "a", "conversations": [conv(main, "main", "Main")], "messages": [msg(m1, main)]},
+ headers=auth(key),
)
out = (await client.get("/v1/sync/changes?scope=main", headers=auth(key))).json()
assert [c["cid"] for c in out["conversations"]] == [main] and [m["mid"] for m in out["messages"]] == [m1]
@@ -482,7 +490,11 @@ async def test_tail_is_the_newest_messages_with_their_conversations():
cid = main if i % 2 == 0 else old_side
mid = uid()
mids.append((cid, mid))
- await client.post("/v1/sync/changes", json={"device": "a", "messages": [msg(mid, cid, text=f"m{i}", created_at=1738000000 + i)]}, headers=auth(key))
+ await client.post(
+ "/v1/sync/changes",
+ json={"device": "a", "messages": [msg(mid, cid, text=f"m{i}", created_at=1738000000 + i)]},
+ headers=auth(key),
+ )
newest = uid() # seq 10, in the side chat nobody else wrote in
await client.post("/v1/sync/changes", json={"device": "a", "messages": [msg(newest, new_side, text="newest")]}, headers=auth(key))
# the two newest messages, in seq order, with the rows of their conversations and nothing else
@@ -622,8 +634,12 @@ async def test_deleting_the_account_purges_everything_and_the_next_sign_in_start
headers=auth(key),
)
assert r.status_code == 200 and r.json()["accepted"] == 2
- assert (await client.put("/v1/me/profile", json={"device": "phone-1", "name": "kwai", "avatar": "dragon"}, headers=auth(key))).status_code == 200
- assert (await client.post("/v1/sync/working", json={"cid": cid, "working": True, "device": "phone-1"}, headers=auth(key))).status_code == 204
+ assert (
+ await client.put("/v1/me/profile", json={"device": "phone-1", "name": "kwai", "avatar": "dragon"}, headers=auth(key))
+ ).status_code == 200
+ assert (
+ await client.post("/v1/sync/working", json={"cid": cid, "working": True, "device": "phone-1"}, headers=auth(key))
+ ).status_code == 204
assert app.state.sync.presence.working(account_id)
assert (await client.post("/v1/auth/delete", headers=auth(key))).status_code == 204
@@ -635,7 +651,9 @@ async def test_deleting_the_account_purges_everything_and_the_next_sign_in_start
r = await client.post("/v1/chat/completions", json={"model": "x", "messages": []}, headers=auth(key))
assert r.status_code == 401 and r.json()["error"]["code"] == "account_deleted"
assert cloud.db._conn.execute("SELECT COUNT(*) FROM deleted_keys").fetchone()[0] == 1
- assert cloud.db._conn.execute("SELECT * FROM deleted_keys").fetchone().keys() == ["key_hash", "deleted_at"], "a dead key's hash names nobody"
+ assert cloud.db._conn.execute("SELECT * FROM deleted_keys").fetchone().keys() == ["key_hash", "deleted_at"], (
+ "a dead key's hash names nobody"
+ )
# every table that names the account is empty of it
for table, column in (
diff --git a/config/config.example.toml b/config/config.example.toml
index f61e8de2..7b98bf0f 100644
--- a/config/config.example.toml
+++ b/config/config.example.toml
@@ -319,7 +319,7 @@ token = "" # empty → generated once, kept in /server_t
approval_timeout = 3600 # seconds an approval card waits before it counts as "deny"
cors_origins = [] # only for the Vite dev server of the web app, e.g. ["http://localhost:5173"]
max_upload_mb = 25 # largest file the app may attach to a message
-update_check = true # say under Settings → About when a newer release is out (GitHub Releases, every 6 h; nothing is sent)
+update_check = true # say under Settings → About when a newer release is out (the download index, then GitHub Releases; once a day; nothing is sent)
start_grace = 4 # seconds the tools/MCP servers/cloud account may take before the app answers anyway (/api/health names the step still running)
# ------------------------------------------------------------------------------
diff --git a/demo/mobilegym/apps/nanoMuse/pages/MusePage.tsx b/demo/mobilegym/apps/nanoMuse/pages/MusePage.tsx
index a3d32605..74577aa6 100644
--- a/demo/mobilegym/apps/nanoMuse/pages/MusePage.tsx
+++ b/demo/mobilegym/apps/nanoMuse/pages/MusePage.tsx
@@ -100,5 +100,5 @@ export default function MusePage() {
// The web app's own palette (web/src/index.css: --om-bg, --om-surface). It picks light or dark
// by its own setting, not the simulator's theme, and tells its host which — the strips above
// and below its frame follow that, the way the Android app's status and gesture bars do.
-const WEB_APP_LIGHT = { bg: '#fcfcfc' };
+const WEB_APP_LIGHT = { bg: '#ffffff' };
const WEB_APP_DARK = { bg: '#181819' };
diff --git a/demo/showcase/.env.example b/demo/showcase/.env.example
index af33fd61..af1299d5 100644
--- a/demo/showcase/.env.example
+++ b/demo/showcase/.env.example
@@ -20,8 +20,9 @@ CLOUDFLARE_API_TOKEN=
# ---- the demo models (our key; visitors may bring their own instead) -------------------------
# 阿里云百炼, OpenAI-compatible. Two lanes, two models, the same defaults every nanoMuse client
-# has: DeepSeek V4.1 Flash talks to the visitor (it reads pictures too; V4 Pro and V4 Flash do
-# not), Qwen 3.8 27B operates the phone. Empty = the default.
+# has: DeepSeek V4.1 Flash talks to the visitor (it reads pictures too, as do deepseek-flash
+# and deepseek-v4-flash, which it serves; V4 Pro does not), Qwen 3.8 27B operates the phone.
+# Empty = the default.
MAIN_PROVIDER=openai
MAIN_MODEL=deepseek-v4.1-flash
MAIN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
@@ -134,6 +135,7 @@ CONTAINER_PIDS=256
# ---- images ----------------------------------------------------------------------------------
# Caddy (with the built phone inside) and the gateway are published by .github/workflows/showcase.yml;
# `docker compose pull` gets them. To build them here instead: `docker compose build`, with
+# the two settings below saying which tag to name the images and which MobileGym to put in.
SHOWCASE_TAG=latest
# Which MobileGym to build the phone from (branch or tag).
MOBILEGYM_REF=main
diff --git a/demo/showcase/Caddyfile b/demo/showcase/Caddyfile
index 36447316..c1cc5e9f 100644
--- a/demo/showcase/Caddyfile
+++ b/demo/showcase/Caddyfile
@@ -1,7 +1,8 @@
# The showcase's front door. Two names:
#
# SITE_HOST the page around the phone (/, with MobileGym + the nanoMuse app at
-# /phone.html), plus /api/demo/* and, when on, nanoMuse Web at /web/
+# /phone.html), plus /api/demo/*, /api/trial* and /llm/trial/*; nanoMuse
+# Web's /web/ and /api/web/* are the project site's (mirror/nanomuse.cn.caddy)
# *.SESSION_DOMAIN one hostname per visitor session, everything relayed to the gateway
#
# Certificates come from Let's Encrypt through Cloudflare's DNS API for both names: the wildcard
diff --git a/demo/showcase/README.md b/demo/showcase/README.md
index dd2a1d21..1fda1d9b 100644
--- a/demo/showcase/README.md
+++ b/demo/showcase/README.md
@@ -223,7 +223,8 @@ httpx's) carries the value: it is rewritten to `token=[redacted]` before it is w
providers), never to an address inside the server's network. The model they name is the chat
model; the hands get their own on the same key (`qwen/qwen3.8-27b` on OpenRouter,
`qwen3.8-27b` on 阿里云百炼), and on any other provider the one model does both lanes (a
- text-only model there, DeepSeek's own API say, leaves the hands with nothing to look with).
+ text-only model there, `deepseek-v4-pro` on DeepSeek's own API say, leaves the hands with
+ nothing to look with; `deepseek-flash`, the key page's default, sees).
Two lanes, two models, both set by the operator in the gateway's `.env` (`.env.example` lists
the names; `docker-compose.yml` pins none): `main` (the model that talks to the visitor) is
@@ -233,9 +234,10 @@ on 阿里云百炼 at the time of writing); `gui` (the one that reads screens an
calls with a screenshot each) is `GUI_MODEL`, and empty means `qwen3.8-27b` on the main lane's
host and key, a fixed default not derived from the chat model; set `GUI_*` to change it or to
split the lanes across keys or providers. A chat model that takes no images (DeepSeek before
-V4.1: `deepseek-v4-pro`, `deepseek-v4-flash`) is kept away from screenshots
-(`NANOMUSE_LLM_VISION=off`); the operator lane still looks. What a running showcase actually
-uses is what `GET /api/demo/info` says (`demo_model`, `gui_model`), not this page.
+V4.1: `deepseek-v4-pro`, `deepseek-chat`; not `deepseek-flash` or `deepseek-v4-flash`, which
+V4.1 Flash serves, the same rule as the runtime's `nanomuse/llm/vision.py`) is kept away from
+screenshots (`NANOMUSE_LLM_VISION=off`); the operator lane still looks. What a running showcase
+actually uses is what `GET /api/demo/info` says (`demo_model`, `gui_model`), not this page.
**The capsule decides.** While the hands work on the simulated phone, an approval the run
raises (a tap on *Pay*, *Send*, *Delete*…) is shown on the capsule over the operated app with
diff --git a/demo/showcase/docker-compose.yml b/demo/showcase/docker-compose.yml
index 5c9d9a74..00e469fb 100644
--- a/demo/showcase/docker-compose.yml
+++ b/demo/showcase/docker-compose.yml
@@ -14,6 +14,14 @@
# The gateway reaches Docker through `docker-proxy`, which lets through what starting and
# stopping containers takes and nothing else of the daemon (no exec, images, volumes, build).
+# The three services' own logs (`docker compose logs`): the gateway writes a line per request
+# and per session, so a file that is rolled, not one that grows for as long as the box runs.
+x-logging: &logging
+ driver: json-file
+ options:
+ max-size: "20m"
+ max-file: "5"
+
services:
caddy:
build:
@@ -26,6 +34,7 @@ services:
GOPROXY: ${GOPROXY:-https://proxy.golang.org,direct}
image: ghcr.io/nano-muse/showcase-caddy:${SHOWCASE_TAG:-latest}
restart: unless-stopped
+ logging: *logging
ports:
- "80:80"
- "443:443"
@@ -54,6 +63,7 @@ services:
build: ./gateway
image: ghcr.io/nano-muse/showcase-gateway:${SHOWCASE_TAG:-latest}
restart: unless-stopped
+ logging: *logging
env_file: .env
environment:
INTERNAL_URL: http://gateway:8000
@@ -77,6 +87,7 @@ services:
docker-proxy:
image: tecnativa/docker-socket-proxy:0.3
restart: unless-stopped
+ logging: *logging
environment:
CONTAINERS: "1"
POST: "1"
diff --git a/demo/showcase/gateway/showcase_gateway/config.py b/demo/showcase/gateway/showcase_gateway/config.py
index 456a13a4..a954533e 100644
--- a/demo/showcase/gateway/showcase_gateway/config.py
+++ b/demo/showcase/gateway/showcase_gateway/config.py
@@ -80,17 +80,20 @@ def text_only(model: str) -> bool:
"""Whether a model takes no images.
Only the DeepSeek family is text-only, and not all of it: ``deepseek-v4.1-flash`` and
- later read pictures, as does anything whose id says ``vision`` or ``ocr``; ``deepseek-v4``,
- ``v4-pro``, ``v4-flash``, ``deepseek-chat`` and ``deepseek-reasoner`` do not. On Model
- Studio's compatible mode a message with a picture in it does not fail for those — it
- comes back as an empty reply — so the rule is by name rather than found out at the first
- screenshot.
+ later read pictures, as does anything whose id says ``vision`` or ``ocr``, and so do
+ ``deepseek-flash`` and ``deepseek-v4-flash*``, which DeepSeek serves with V4.1 Flash (the
+ runtime's rule, ``nanomuse/llm/vision.py``); ``deepseek-v4``, ``v4-pro``, ``deepseek-chat``
+ and ``deepseek-reasoner`` do not. On Model Studio's compatible mode a message with a
+ picture in it does not fail for those, it comes back as an empty reply, so the rule is
+ by name rather than found out at the first screenshot.
"""
- family = model.strip().lower()
+ family = model.strip().lower().rsplit("/", 1)[-1]
if not family.startswith("deepseek"):
return False
if "vision" in family or "ocr" in family:
return False
+ if family == "deepseek-flash" or family.startswith("deepseek-v4-flash"):
+ return False
m = _DEEPSEEK_VERSION.search(family)
if m is None:
return True
diff --git a/demo/showcase/gateway/showcase_gateway/logs.py b/demo/showcase/gateway/showcase_gateway/logs.py
index 816a35f8..e6dbdc35 100644
--- a/demo/showcase/gateway/showcase_gateway/logs.py
+++ b/demo/showcase/gateway/showcase_gateway/logs.py
@@ -22,7 +22,9 @@
# where a request line with its query is written: uvicorn's access log for HTTP, its error
# logger for the WebSocket handshake ("WebSocket /ws" [accepted] / 403 / ), and httpx
-# (with httpcore underneath) for what the gateway itself asks of a container
+# (with httpcore underneath) for what the gateway itself asks of a container; httpx 2 ships as
+# the httpx2 package and logs under that name (Starlette's test client prefers it when it is
+# installed), so httpx2 and its httpcore2 are covered too
REQUEST_LOGGERS = ("uvicorn.access", "uvicorn.error", "httpx", "httpcore", "httpx2", "httpcore2")
diff --git a/demo/showcase/gateway/tests/test_gateway.py b/demo/showcase/gateway/tests/test_gateway.py
index 4772198f..5a031338 100644
--- a/demo/showcase/gateway/tests/test_gateway.py
+++ b/demo/showcase/gateway/tests/test_gateway.py
@@ -405,13 +405,16 @@ def test_usage_parsing():
def test_which_deepseek_models_see():
- # the rule shared by every client (contract C4): DeepSeek is text-only before v4.1, and
- # anything whose id says vision or ocr sees
+ # the rule shared by every client (contract C4, nanomuse/llm/vision.py): DeepSeek is
+ # text-only before v4.1, except the Flash line, which V4.1 Flash serves under its older
+ # names too; anything whose id says vision or ocr sees
assert text_only("deepseek-v4-pro")
- assert text_only("DeepSeek-V4-Flash")
assert text_only("deepseek-v4")
assert text_only("deepseek-chat") and text_only("deepseek-reasoner")
+ assert text_only("deepseek/deepseek-v4-pro")
assert not text_only("deepseek-v4.1-flash")
+ assert not text_only("deepseek-flash") and not text_only("DeepSeek-V4-Flash")
+ assert not text_only("deepseek-ai/DeepSeek-V4-Flash")
assert not text_only("deepseek-v4.2-pro") and not text_only("deepseek-v5")
assert not text_only("deepseek-vl-vision") and not text_only("deepseek-ocr")
# every other family is taken as sighted (the runtime finds out for itself)
diff --git a/demo/showcase/mirror/traffic.py b/demo/showcase/mirror/traffic.py
index 0b809d40..24cb81c2 100755
--- a/demo/showcase/mirror/traffic.py
+++ b/demo/showcase/mirror/traffic.py
@@ -51,9 +51,7 @@
# this mirror is not deployed by this fork, so the operator sets TRAFFIC_HOSTS for the
# names their own box serves. Empty means "no known own hosts".
OWN_HOSTS = {
- h.strip().lower()
- for h in (os.environ.get("TRAFFIC_HOSTS") or "").split(",")
- if h.strip()
+ h.strip().lower() for h in (os.environ.get("TRAFFIC_HOSTS") or "").split(",") if h.strip()
}
DAY_OFFSET_S = int(os.environ.get("TRAFFIC_DAY_OFFSET_H") or "8") * 3600
TOKEN = os.environ.get("GITHUB_TOKEN", "")
diff --git a/demo/showcase/site/compose.mjs b/demo/showcase/site/compose.mjs
index a0ea51b9..bd671789 100644
--- a/demo/showcase/site/compose.mjs
+++ b/demo/showcase/site/compose.mjs
@@ -89,13 +89,17 @@ cpSync(pageDir, join(dist, "page"), { recursive: true });
writeFileSync(join(dist, "index.html"), page);
writeFileSync(join(dist, "page", "index.html"), page);
-// their stylesheet and scripts, as they are — but the phone is at /phone.html here
+// their stylesheet and scripts, as they are, except that the phone is at /phone.html here, and
+// only there: upstream's page takes the frame's address from ?sim= (for a local preview against a
+// separate dev server), which on a public origin would let a crafted link put any page, or a
+// javascript: URL running as this origin, inside the phone. The showcase has no such preview,
+// so the address is fixed.
cpSync(join(web, "styles.css"), join(dist, "page", "mobilegym.css"));
cpSync(join(web, "scripts", "boot-hero.js"), join(dist, "page", "boot-hero.js"));
let builder = readFileSync(join(web, "scripts", "state-builder.js"), "utf8");
const simDefault = "new URLSearchParams(location.search).get('sim') || '/'";
if (!builder.includes(simDefault)) throw new Error("state-builder.js: the simulator's default address moved; update compose.mjs");
-builder = builder.replace(simDefault, "new URLSearchParams(location.search).get('sim') || '/phone.html'");
+builder = builder.replace(simDefault, "'/phone.html'");
writeFileSync(join(dist, "page", "state-builder.js"), builder);
if (existsSync(join(web, "figures", "icons"))) cpSync(join(web, "figures", "icons"), join(dist, "page", "icons"), { recursive: true });
console.log(`page composed: ${join(dist, "index.html")} (MobileGym's chrome from ${web})`);
diff --git a/docker-compose.yml b/docker-compose.yml
index 4cb1eb46..d840a695 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,6 +18,9 @@ services:
stdin_open: true
tty: true
command: ["chat"]
+ # the image's health check asks the server on :8787; chat and daemon run none
+ healthcheck:
+ disable: true
volumes:
- nanomuse-data:/data
- ./workspace:/workspace
@@ -35,6 +38,8 @@ services:
stdin_open: false
tty: false
command: ["serve", "--host", "0.0.0.0", "--no-qr"]
+ healthcheck:
+ disable: false
ports:
- "8787:8787"
restart: unless-stopped
diff --git a/docs/android.md b/docs/android.md
index a6203c6c..37e9374a 100644
--- a/docs/android.md
+++ b/docs/android.md
@@ -1,10 +1,22 @@
# The Android app
`android/` is nanoMuse for the phone: [OpenMinis](https://github.com/OpenMinis/OpenMinis)
-1.13 (GPL-3.0) — a native app whose agent runs **on the phone**, with a Linux shell, a
-browser, MCP servers, skills and scheduled tasks inside the APK — with nanoMuse's own
+1.13 (GPL-3.0): a native app whose agent runs **on the phone**, with a Linux shell, a
+browser, MCP servers, skills and scheduled tasks inside the APK, with nanoMuse's own
identity, design and features on top. Everything of nanoMuse's lives in
`io.github.nanomuse.*`; edits inside OpenMinis files are marked `// nanoMuse:`. The
+application id is `io.github.nanomuse.app` and the URL scheme the system knows the app by is
+`nanomuse://`, so a phone that also has OpenMinis installed never has two apps claiming one
+scheme; inside the app, links stay `minis://…` (upstream's vocabulary, shared with the sandbox,
+the system prompt and the model), a `nanomuse://` link arriving from the system is read as its
+`minis://` form, and every link of the app's own that the app opens itself is addressed to
+`MainActivity` by name, never resolved by the system
+(`io.github.nanomuse.deeplink.OwnScheme`). The sandbox's offload socket, the abstract socket
+proot talks to the app through, is the app's own too: `io.github.nanomuse.app.native-offload`
+rather than upstream's `native-offload`, since abstract sockets are one namespace for every
+app on the device and whichever app bound the shared name second would die at start; when
+even that name is still held by a previous process of ours, the server binds a per-process
+name and tells proot that one (`io.github.nanomuse.sandbox.OffloadSocketName`). The
history of this choice is in [roadmap.md](roadmap.md); what came before it (a WebView
around a Python server, two APK flavours) is kept as a design record in
[archive/android-python-line.md](archive/android-python-line.md) and is not what you
@@ -13,8 +25,8 @@ download today.
One APK, one architecture: `nanoMuse--arm64.apk` (arm64-v8a, Android 8.0 / API 26
or newer, `targetSdk` 35). Every release is signed with the same key, so a newer APK
installs over the old one and keeps its data. No Play Store listing: *Settings → Version* shows
-the installed build next to the newest release (this fork's GitHub releases first,
-repository's GitHub Releases second — *Latest 0.1.x — you have it* / *0.1.x is out — tap to
+the installed build next to the newest release (this
+repository's GitHub Releases first, then a configured mirror index only: *Latest 0.1.x, you have it* / *0.1.x is out, tap to
update*) and offers the download.
## Install
@@ -24,9 +36,9 @@ update*) and offers the download.
`sha256sum -c nanoMuse--arm64.apk.sha256` if you like.
2. Open it. Android asks once to allow installs from your browser or file manager.
3. **Sign in.** The first screen is the account: a phone number or an e-mail address,
- a code — or a password once you have set one. It is what lets your devices work as one
+ a code, or a password once you have set one. It is what lets your devices work as one
([hub.md](hub.md)) and brings a model to start with ([cloud.md](cloud.md): a free pool per
- account — ¥10 at the time of writing — and more for each friend invited, and for them;
+ account (¥10 at the time of writing) and more for each friend invited, and for them;
the app prints the relay's current figures; then [your own key](own-key.md)). Then choose which model answers: the account's
own, or a key of your own (any OpenAI-compatible endpoint, or the OAuth sign-ins the
app ships with). Four models are set, not one, and they live on one page, **Settings →
@@ -45,9 +57,22 @@ update*) and offers the download.
(*Applies to the main chat and to new chats; a side chat keeps its model.*); a side
chat already open keeps its model. The other three pickers
open with *Automatic* (*Currently nanoMuse Cloud · qwen3.8-27b*), which forgets a choice
- made there and lets the row follow the order below again. Running everything yourself
- with no account at all is the runtime's `cloud.required = false`; the phone app asks for
- the account.
+ made there and lets the row follow the order below again. Signed in, the page opens with
+ one switch, *Use nanoMuse Cloud models*: off, the account leaves every picker and the
+ automatic order, no side call (titles, memory, pictures, clips) runs on it, and only the
+ *Use nanoMuse Cloud this time* button on a refused turn does; the sign-in stays for sync and
+ your devices. If the chat model is still the account's at that moment, the Chat row moves
+ to your first own chat model (the catalogue's default for that provider), as every client
+ does, and the page says *Applies to the main chat and to new chats*. Switching off is
+ refused with one sentence while the chat model is still the account's and you have no
+ chat model of your own, since nothing else could answer: the switch stays on and *Add a
+ provider of your own first; with nanoMuse Cloud off, nothing else could answer.* appears
+ under it. The Cloud provider's own page (Settings → *Manage Providers*) follows the same
+ rule; a provider of your own switched off there while the chat ran on it hands the chat
+ to the next source, and when none is left a send says *No model can answer right now.
+ Choose one under Settings › Models.* A phone with a key of
+ its own works signed out: the welcome page offers *Use your own API key instead*. Running
+ everything yourself with no account at all is the runtime's `cloud.required = false`.
4. Optional: the two permissions that let the agent use your phone's apps (skippable and
revocable). *Settings → Hands* keeps its row for the screen model, which opens the same
picker; *More picture and clip options* at the foot of the Models page is the old *Image &
@@ -77,7 +102,7 @@ you run yourself ([cloud.md](cloud.md), `cloud/docker-compose.yml`), *Check* ask
across launches. `https://` is required for anything outside your own network; plain
`http://` is accepted for an address on your own network, judged by the same rule as a
model server's (below: the private ranges as parsed addresses, `localhost`, a name without
-a dot, a `.local`, `.lan`, `.home`, `.internal`, `.home.arpa` or `.ts.net` name). Settings → Account shows *Server: ``* with *Change*, which signs the phone out first — the key it holds belongs to the
+a dot, a `.local`, `.lan`, `.home`, `.internal`, `.home.arpa`, `.localdomain` or `.ts.net` name). Settings → Account shows *Server: ``* with *Change*, which signs the phone out first: the key it holds belongs to the
server that issued it. Everything the app does with the account (sign-in, the hub, sync,
usage, the model menu) goes to that address.
@@ -86,14 +111,35 @@ usage, the model menu) goes to that address.
| | |
| --- | --- |
| **The agent, on the phone** | A sandboxed Alpine Linux, a real shell, files, a browser, MCP servers, skills in the Agent Skills format, memory, scheduled tasks; approvals before deleting, sending or paying. The model is the account's or your own; nothing you say passes through the project's relay when you use your own key. |
-| **Hands** (`hands/`) | With *Hands* on and the accessibility service enabled, the agent looks at the phone's screen and taps, types and swipes in its apps — the last rung after APIs, fetches and the browser. A stage over the app shows where it is about to tap and a capsule with the step and **Stop** — a glow breathing along the screen's edges (blue working, amber waiting; 2.4 s in, 2.4 s out, like every working light on every client since round 9: nothing runs round the rim or slides down the screen, and the capsule's ring and bars breathe at the same pace; everything holds still under the system's reduce-motion setting); when a tap would send, post or delete, the capsule itself asks — **Allow** / **Deny**, the same request the chat card and the notification show — so you are not pulled back into nanoMuse (money is confirmed with the screen lock in the chat card; the capsule offers *Open* for that one); the capsule only ever answers its own request, never a shell or browser card of another conversation, and **Stop** while a card is waiting denies it and ends the run at once. Passwords and codes are always yours to type. Every step's screenshot is written next to the run's trace under the session's `attachments/hands//`; the ten newest runs keep all of theirs, an older run keeps its trace and the last screen (the one the chat shows), and past 200 runs the oldest is removed (`HandsTraces`). [gui.md](gui.md) is the design record; the app's own hands are in `io.github.nanomuse.hands`. |
-| **Reach** (`reach/`) | Your computers, from the phone: "say it on the phone, it gets done there" — a shell command, a file, the computer's screen, or a whole task for the nanoMuse running there. The way in is the hub (next row): install nanoMuse Desktop on the computer and sign in with the same account, and it is under *Account → Devices* within seconds, on any network — the only way since 0.1.24 (the local-network host script is gone). *Settings → Computers* lists the account's computers and says which account the phone uses, since a missing computer has nearly always signed in with another one. Approvals are decided on the phone before anything is sent. [every-device.md](every-device.md). |
+| **Hands** (`hands/`) | With *Hands* on and the accessibility service enabled, the agent looks at the phone's screen and taps, types and swipes in its apps: the last rung after APIs, fetches and the browser. A stage over the app shows where it is about to tap and a capsule with the step and **Stop**; a glow breathes along the screen's edges (blue working, amber waiting; 2.4 s in, 2.4 s out, like every working light on every client since round 9: nothing runs round the rim or slides down the screen, and the capsule's ring and bars breathe at the same pace; everything holds still under the system's reduce-motion setting); when a tap would send, post or delete, the capsule itself asks (**Allow** / **Deny**, the same request the chat card and the notification show), so you are not pulled back into nanoMuse (money is confirmed with the screen lock in the chat card; the capsule offers *Open* for that one); the capsule only ever answers its own request, never a shell or browser card of another conversation, and **Stop** while a card is waiting denies it and ends the run at once. Passwords and codes are always yours to type. The same action a third time on a screen that did not change gets a line in the history so the model changes tack, and a sixth ends the run as *infeasible* with the action named (`HandsLoopWatch`, the floor the runtime has too), instead of one model call per step until the thirty-minute limit. Every step's screenshot is written next to the run's trace under the session's `attachments/hands//`; the ten newest runs keep all of theirs, an older run keeps its trace and the last screen (the one the chat shows), and past 200 runs the oldest is removed (`HandsTraces`). [gui.md](gui.md) is the design record; the app's own hands are in `io.github.nanomuse.hands`. |
+| **Reach** (`reach/`) | Your computers, from the phone: "say it on the phone, it gets done there" (a shell command, a file, the computer's screen, or a whole task for the nanoMuse running there). The way in is the hub (next row): install nanoMuse Desktop on the computer and sign in with the same account, and it is under *Account → Devices* within seconds, on any network, the only way since 0.1.24 (the local-network host script is gone). *Settings → Computers* lists the account's computers and says which account the phone uses, since a missing computer has nearly always signed in with another one. Approvals are decided on the phone before anything is sent. [every-device.md](every-device.md). |
| **The hub** (`hub/`) | Every signed-in device of the account meets on the relay's hub: the phone sees your computers, asks them to do things, gets their approvals as cards, and can be asked by them. A foreground service keeps it reachable in the background (Android 13+ asks for the notification permission for that). [hub.md](hub.md). |
| **Coding agents** (`ui/coding/`) | The Cursor, Codex and Claude Code sessions on your computers, seen and steered from the phone. [coding-agents.md](coding-agents.md). |
-| **Account** (`ui/cloud/`) | Who is signed in, the password, every device holding a key, usage by kind and by model, the ways out. When the free pool is spent or past 80 %, the account page and the refused turn show the ways on: your own key — the providers of the catalogue, the region's first (Alibaba Cloud Bailian for people in mainland China: the UI in simplified Chinese, a phone-number sign-in, or the relay saying so; OpenRouter and OpenAI everywhere else), each saying what it covers — a subscription you already pay for (ChatGPT, Claude, Kimi, OpenRouter sign in instead of a key), and an invitation. See [Your own key](#your-own-key) below. A phone shows and syncs the signed-in account's conversations only — since 0.1.40 every chat has an owner, synced or not, and a sign-out asks *Keep this account's chats on this device* (off by default: the account's chats, memory, feed, goals, routines and face leave the phone; on: put aside until it returns); deleting the account removes all of it; a key the relay refuses (`401 bad_key`) puts the account's data aside as *Keep* would and the sign-in page says so until the next sign-in — only `401 account_deleted` removes it; signed out, only the chats made while signed out show ([sync.md](sync.md)). |
-| **The browser handed over** (`browser/`) | A page that needs you — a login, a verification code, a payment, a CAPTCHA — is handed over instead of described: `browser_use`'s `hand_over` action opens the agent's own tab (same WebView, same session) in the browser sheet with the agent's hold released, a **Your turn** card above the composer says what the page asks of you with *Open the page* and *Done, continue*, the sheet carries the same line and button, and the tool call waits for *Done* (fifteen minutes at most) before the agent goes on from the page as it is. `io.github.nanomuse.browser.BrowserHandOver`. |
-| **Connectors** (`connectors/`, `ui/connectors/`) | The services the agent can be let into — the desktop's catalogue of 75 remote MCP servers (Notion, Linear, GitHub, GitLab, Slack, Stripe, Miro, …), shipped as `assets/nanomuse/connectors.json`. The only Settings row for all of this is *Connectors*; the upstream MCP editor (by address, by command, imported JSON) is *Your own servers* at the end of the page. Open servers add with a tap; a key service takes the key; an OAuth service runs the MCP authorization flow (discovery, dynamic client registration, PKCE in a Custom Tab) and the token goes into the entry's `Authorization` header for the in-guest MCP client, refreshed at app start; a service whose authorization server registers no clients (`clientIdRequired` — GitHub, Slack, Discord, HubSpot, Render, Bitrise, PagerDuty, Box) asks for an OAuth client id and secret made at the vendor's developer page with the app's callback address, which the sheet shows and copies. A connected service is an MCP server entry under the same id — *Your own servers* manages it too. What is connected is shared with the account's other devices through the relay profile — the entries (id, label, address, kind of auth, which device, when), never a token — so the page also lists *On your other devices*: a service connected on the desktop shows as *Connected on — sign in here to use it on this phone*, one tap into the same sheet. |
-| **The chat, plain** | While the agent works, the line under its avatar names the step under way — *nanoMuse is using Shell*, *Writing the reply*, *On it: book the table* — never a state of mind. The tool pills, the Computer sheet and the floating step bar are **on by default** since 0.1.37; *Settings → Appearance → Show the agent's steps* turns them off. With them on, a finished step reads *nanoMuse used Shell · Done*, and its sheet closes on ×, swipe or Back. |
+| **Account** (`ui/cloud/`) | Who is signed in, the password, every device holding a key, usage by kind and by model, the ways out. When the free pool is spent or past 80 %, the account page and the refused turn show the ways on: your own key, that is, the providers of the catalogue, the region's first (Alibaba Cloud Bailian for people in mainland China: the UI in simplified Chinese, a phone-number sign-in, or the relay saying so; OpenRouter and OpenAI everywhere else), each saying what it covers; a subscription you already pay for (ChatGPT, Claude, Kimi, OpenRouter sign in instead of a key); and an invitation. See [Your own key](#your-own-key) below. A phone shows and syncs the signed-in account's conversations only: since 0.1.40 every chat has an owner, synced or not, and a sign-out asks *Keep this account's chats on this device* (off by default: the account's chats, memory, feed, goals, routines and face leave the phone; on: put aside until it returns); deleting the account removes all of it; a key the relay refuses (`401 bad_key`) puts the account's data aside as *Keep* would and the sign-in page says so until the next sign-in; only `401 account_deleted` removes it; signed out, only the chats made while signed out show ([sync.md](sync.md)). |
+| **The browser handed over** (`browser/`) | A page that needs you (a login, a verification code, a payment, a CAPTCHA) is handed over instead of described: `browser_use`'s `hand_over` action opens the agent's own tab (same WebView, same session) in the browser sheet with the agent's hold released, a **Your turn** card above the composer says what the page asks of you with *Open the page* and *Done, continue*, the sheet carries the same line and button, and the tool call waits for *Done* (fifteen minutes at most) before the agent goes on from the page as it is. `io.github.nanomuse.browser.BrowserHandOver`. |
+| **Connectors** (`connectors/`, `ui/connectors/`) | The services the agent can be let into: the desktop's catalogue of 75 remote MCP servers (Notion, Linear, GitHub, GitLab, Slack, Stripe, Miro, …), shipped as `assets/nanomuse/connectors.json`. The only Settings row for all of this is *Connectors*; the upstream MCP editor (by address, by command, imported JSON) is *Your own servers* at the end of the page. Open servers add with a tap; a key service takes the key; an OAuth service runs the MCP authorization flow (discovery, dynamic client registration, PKCE in a Custom Tab) and the token goes into the entry's `Authorization` header for the in-guest MCP client, refreshed at app start; a service whose authorization server registers no clients (`clientIdRequired`: GitHub, Slack, Discord, HubSpot, Render, Bitrise, PagerDuty, Box) asks for an OAuth client id and secret made at the vendor's developer page with the app's callback address, which the sheet shows and copies. A connected service is an MCP server entry under the same id; *Your own servers* manages it too. What is connected is shared with the account's other devices through the relay profile, the entries (id, label, address, kind of auth, which device, when), never a token, so the page also lists *On your other devices*: a service connected on the desktop shows as *Connected on . Sign in here to use it on this phone*, one tap into the same sheet. |
+| **The chat, plain** | While the agent works, the line under its avatar names the step under way (*nanoMuse is using Shell*, *Writing the reply*, *On it: book the table*), never a state of mind. The tool pills, the Computer sheet and the floating step bar are **on by default** since 0.1.37; *Settings → Appearance → Show the agent's steps* turns them off. With them on, a finished step reads *nanoMuse used Shell · Done*, and its sheet closes on ×, swipe or Back. |
+
+The chat sits on the same canvas as the pill bar, the menus and the drawer: pure black in the
+dark theme, pure white in the light one. Your messages are white pills with black text (black
+pills with white text in light), the agent's replies sit in a pill a shade off the canvas
+(`#1C1C1E` / `#F2F2F7`), and the send button and the scroll-to-latest disc are white with a
+black arrow (inverted in light); there is no blue in the conversation apart from links. The
+header is not a bar but a scrim: the conversation scrolls under it and dissolves into the
+canvas, and a second fade appears at the top edge while you read older messages. The palette
+is upstream's `com/openminis/app/ui/theme/ChatColors.kt`, written by `scripts/rebrand.py`.
+
+The message composer stays above the floating navigation pill, and the pill is as wide as the
+composer (the same 16 dp margins; 420 dp at most, centred on a wider screen), so the two
+capsules stack with one edge. While the agent uses tools,
+the step bar sits above the composer; the latest message and tool pills can scroll fully
+above both. The conversation still scrolls behind the navigation pill for its frosted blur.
+The spacing follows the actual footer height when the composer expands or a tool preview
+or approval card appears. When reading older messages, the fade above the composer
+continues behind its padding and rounded corners without a gap. It stays hidden while
+the tool step bar, browser panel or browser hand-over controls are visible. With the keyboard
+open the composer sits on the keyboard and the pill is behind it. The Feed, Ideas, Goals and
+Library lists end above the pill as well, so their last row is never under it.
The web console of the same account is at the relay (`/app`), the desktop app in
[desktop.md](desktop.md); the phone, the desktop and the web share the design language
@@ -101,20 +147,20 @@ described in [brand.md](brand.md).
## Your own key
-The vendors the app knows are one file, `assets/nanomuse/providers.json` — a copy of the
+The vendors the app knows are one file, `assets/nanomuse/providers.json`, a copy of the
runtime's `nanomuse/llm/providers.json` written by `node scripts/providers-json.mjs`, the same
catalogue every client reads ([own-key.md](own-key.md)). Each entry says where the endpoint is,
where a key is made, which sign-ins it has, where it signs people up (`cn`, `global`) and what
its models can do: `chat`, `vision` (the hands' screen), `image` (pictures), `video` (clips).
**The card.** When the allowance is spent or nearly, *Use your own model key* lists the
-catalogue, the region's vendors first — Alibaba Cloud Bailian on the mainland (one key covers
-all four; it signs up accounts from mainland China only), OpenRouter and OpenAI elsewhere —
+catalogue, the region's vendors first: Alibaba Cloud Bailian on the mainland (one key covers
+all four; it signs up accounts from mainland China only), OpenRouter and OpenAI elsewhere;
then the rest, three at a time behind *More providers*. Every row says what the vendor covers
(*chat · screen · pictures · clips*); *Add* opens the provider form pre-filled with its name,
endpoint and `/v1` setting (`minis://settings/providers/add?preset=`), *Get a key* opens
the vendor's key page. No vendor is recommended. *A subscription you already pay for* lists
-the vendors whose plan signs in instead of a key — ChatGPT (OpenAI's Codex OAuth), Claude,
+the vendors whose plan signs in instead of a key: ChatGPT (OpenAI's Codex OAuth), Claude,
Kimi (a device code) and OpenRouter; *Sign in* opens the same form on the sign-in button
(`?preset=:oauth`), which runs OpenMinis' own `OpenAIOAuthManager`, `ClaudeOAuthManager`,
`KimiOAuthManager` or `OpenRouterOAuthManager`. The ChatGPT row carries the line that
@@ -128,7 +174,7 @@ OpenAI-compatible form.
what it covers, the plans one can sign in with and which clients they are for, the local
servers, the docs link and the honest line about the ChatGPT sign-in. `cloud/Guidance.kt`
parses it, `Ways.resolve` prefers it, and the card and Settings → nanoMuse Cloud read the
-bundled catalogue only when the relay sent none (an older relay) — so a vendor added on the
+bundled catalogue only when the relay sent none (an older relay), so a vendor added on the
relay shows before the app is updated, and a provider the relay no longer lists is gone the same
day. A `429 allowance_exhausted` carries the same block beside the figures.
@@ -136,16 +182,16 @@ day. A `429 allowance_exhausted` carries the same block beside the figures.
pictures (Settings → Models → Making pictures, the avatar studio, `nanomuse-media image`) pick a
provider only among those whose vendor has `image`; clips among those with `video` that the
app can drive (Bailian's video API); the screen's model and its picker list only models of
-vendors with `vision`. A ChatGPT plan signed in through Codex is chat and vision — the Codex
-backend has no image or video endpoints — so it is never offered for pictures. A vendor the
+vendors with `vision`. A ChatGPT plan signed in through Codex is chat and vision (the Codex
+backend has no image or video endpoints), so it is never offered for pictures. A vendor the
catalogue does not know (a gateway, a relay of your own, nanoMuse Cloud) is taken at its
models' word, as before. When no configured provider has a capability, the page says so in one
-sentence — *Pictures need a provider with image models: Bailian, OpenAI, Gemini or
-OpenRouter.* — instead of failing.
+sentence (*Pictures need a provider with image models: Bailian, OpenAI, Gemini or
+OpenRouter.*) instead of failing.
**When the provider cannot be reached** (`net/ProviderReach.kt`, `ui/chat/ProviderReachCard.kt`).
-A failed turn whose error is the transport's — OkHttp's *failed to connect to chatgpt.com/… (port
-443)*, *Unable to resolve host*, a TLS or timeout line — or OpenAI's *region not supported* is
+A failed turn whose error is the transport's (OkHttp's *failed to connect to chatgpt.com/… (port
+443)*, *Unable to resolve host*, a TLS or timeout line) or OpenAI's *region not supported* is
shown as a card instead of the red banner: what happened, what helps (a VPN on this phone, the
proxy under *Settings → Network*, another provider with a key of your own), *Try again*, and the
raw line behind *Details*. The 401/403/429 whose bodies upstream's `mapHttpError` drops are
@@ -156,22 +202,28 @@ OpenAI's own sentence and the reset time; a plain *Invalid API key* on a key pro
upstream's. The relay's `daily_cap` 429 is now read like `allowance_exhausted` and the card
says today's share is spent and comes back with the day. The allowance card also lists a model
of your own (Ollama, LM Studio, vLLM on a computer you own) and, for a signed-in account, your
-computer — *@* and its name in the chat runs the turn there.
+computer: *@* and its name in the chat runs the turn there.
**When the relay refuses a turn** (`cloud/RelayRefusal.kt`, `ui/chat/RelayRefusalCard.kt`).
Every refusal nanoMuse Cloud sends is one plain sentence in the phone's language and the button
-that fits — never a status code, the relay's JSON or upstream's *Rate limited*. The reply is
+that fits, never a status code, the relay's JSON or upstream's *Rate limited*. The reply is
read where the body is still whole (`AllowanceSignal.noteHttpError`, through the same
`// nanoMuse:` spot in `OpenAIProvider`), stored in the message as a canonical `nm_relay:` line
and drawn by the card: `413 too_large` (also a proxy's plain 413 from the relay's host) → *That
message is too large for the model. Shorten it, leave out some attachments, or start a new
-chat.* with *New chat*; `401 bad_key` / `account_deleted` → *Sign in*; `403 not_invited` /
+chat.* with *New chat*; `400 content_rejected` (the provider's content check declined the
+words; also a provider's own `DataInspectionFailed` on a key of one's own) → *The model
+provider's content check declined this request. Try different words, or start a new chat if it
+keeps happening: the words it objects to can be earlier in the conversation.* with *New chat*
+and *Try again*, and the compaction's split-retry (`ChatViewModel.shouldSplitOnError`) leaves it
+alone, since the same words come back refused however small the request; `401 bad_key` /
+`account_deleted` → *Sign in*; `403 not_invited` /
`account_disabled` / `signup_closed` → *Open Settings*; `429 too_many_in_flight` / `locked` /
`rate_limited` and `provider_busy` (with the wait from `retry_after`) → *Try again*; `404
model_not_offered` → *Open Settings*; `503 service_paused`, `sync_paused`, `hub_paused` and any
5xx or empty answer → *Try again*. `429 allowance_exhausted` and `daily_cap` keep the allowance
card; with `paused: true` (relay 0.22) its lead says the allowance is *paused on this relay for
-now — not used up* and that what is left stays as it is. The sentences are
+now, not used up* and that what is left stays as it is. The sentences are
`NanoMuseCloud.describe`'s, the same ones the sign-in and account pages use, in all 17 locales
(`nm_cloud_err_*`). Unit tests: `RelayRefusalTest`, `GuidanceTest`.
@@ -181,7 +233,7 @@ and password, off by default, in `SharedPreferences` on this phone only. It is i
`MinisApp.onCreate` as the process's default `ProxySelector`, so every OkHttp client asks it per
request; it answers the proxy for the catalogue's hosts, `chatgpt.com`, `auth.openai.com` and
the custom base URLs of the provider instances (never the relay's, never a LAN address), and the
-system's answer for every other host — the hub, the relay, the sandbox mirrors are untouched.
+system's answer for every other host: the hub, the relay, the sandbox mirrors are untouched.
Provider clients carry `OwnProviderProxy.authenticator` for a proxy that asks for a password.
*Test* fetches `https://chatgpt.com/` through the proxy as entered and reports the status and the
milliseconds. Unit tests: `ProviderReachTest`, `OwnProviderProxyTest`.
@@ -189,27 +241,28 @@ milliseconds. Unit tests: `ProviderReachTest`, `OwnProviderProxyTest`.
## Privacy and permissions
The relay keeps an account id, a masked identifier, usage counts and the agent's name and
-look (so your devices match) — and message content only as conversation sync, on by default
+look (so your devices match), and message content only as conversation sync, on by default
when signed in and switched off under *Data controls* ([privacy.md](privacy.md),
-[sync.md](sync.md)); the first conversation says so in its fourth line. On the phone, API keys, the account key and Reach
-pairing tokens are in `EncryptedSharedPreferences`; since 0.1.40 the app takes no part in
-the device backup at all (`allowBackup="false"` — chats, memory and keys never go to Google,
+[sync.md](sync.md)); the first conversation says so in its fourth line. On the phone, API keys and the account key
+are in `EncryptedSharedPreferences` (the proxy password of *Settings → Network* is in plain
+preferences, on this phone only); since 0.1.40 the app takes no part in
+the device backup at all (`allowBackup="false"`: chats, memory and keys never go to Google,
a reinstall starts empty and the account's chats come back through sync; the two rule files
under `res/xml/` are kept for the day the switch is turned on again). Hands needs the accessibility service
and the overlay permission, both optional and both revocable from the same screen (the first
run's Hands page asks for them under the app's mark, as the sign-in does); a
-step that sends something — a tap on *Send*, or Enter in a message field of a messenger —
+step that sends something (a tap on *Send*, or Enter in a message field of a messenger)
is approved one at a time, and *for this chat* answers stay bound to the app or address
they were given for. Another device of your account that wants to run, read or write
something on the phone (hub `shell`, `files`, `open`, `screen`…) is approved by the person
-holding the phone first — *once* or *always for that device*, revocable under Permissions.
+holding the phone first: *once* or *always for that device*, revocable under Permissions.
A path another device or the agent names (hub `files`, `nanomuse-media`, `nanomuse-pc put`)
is resolved inside the sandbox only (`io.github.nanomuse.sandbox.SandboxPaths`): `..` and a
symlink out of the rootfs lead nowhere, so the app's own private files stay out of reach.
The app allows plain `http://` only for addresses on your own network (the private ranges
`10/8`, `172.16/12`, `192.168/16`, the carrier-grade `100.64/10` Tailscale hands out, link-local,
IPv6 ULA, `localhost`, a name without a dot, and the suffixes `.local`, `.lan`, `.home`,
-`.internal`, `.home.arpa`, `.localdomain`, `.ts.net`) — a model server or a computer of your own —
+`.internal`, `.home.arpa`, `.localdomain`, `.ts.net`), a model server or a computer of your own,
and refuses it at the provider URL field for anything else (`io.github.nanomuse.net.LanOnly`).
The address is parsed before it is judged, so a public name such as `10.foo.example.com` is
not taken for a private address. The same rule decides whether a relay of your own may be
@@ -228,6 +281,10 @@ cd android/src/android
./gradlew :app:assembleRelease # signed with android/keystore.properties when present
```
+`rclone.aar` is built with Go, which fetches its modules through Go's default proxy
+(`https://proxy.golang.org,direct`); on a mainland-China network set
+`export GOPROXY=https://goproxy.cn,direct` first, and `env.sh` keeps whatever you exported.
+
`scripts/rebrand.py` carries the version (`VERSION_NAME`, `VERSION_CODE`) and applies
nanoMuse's naming to the OpenMinis tree; `android/BUILDING.md` covers the native pieces.
Without a signing key the release build is signed with the debug key, which installs but
@@ -260,10 +317,10 @@ like the Python package's version does.
| --- | --- |
| `cloud/NanoMuseCloud.kt` | The account: sign-in with a code or a password, the key in the encrypted store, `/v1/me`, usage, sessions, the localized error sentences; the relay's menu and its two defaults (`for: chat` / `for: gui`) |
| `cloud/Region.kt`, `cloud/ProviderCatalogue.kt`, `cloud/Capabilities.kt`, `cloud/OwnKeyPresets.kt`, `cloud/ProfileSync.kt` | Which region's vendors come first (mainland → Bailian, else OpenRouter and OpenAI); the own-key catalogue read from `assets/nanomuse/providers.json` and which vendor a configured provider is; what a provider covers and the one-sentence "unavailable" lines; the pre-filled provider forms (`?preset=[:oauth]`); the relay profile (name, look, connectors) pulled and pushed |
-| `sync/` | Conversation sync (contracts C7–C10): `SyncEngine` (what goes up and comes down; `owner` per mapping, the account's only), `ConversationSync` (when; `hidden` — the chats of another account, left out of `ChatRepository.observeSessions()`), `LocalChats`, the Room store `nanomuse_sync.db` (with the `session_owners` table of C12) |
-| `account/` | Contract C12 (0.1.40, [sync.md](sync.md)): `AccountScope` (the rules — whose chat a session is, the account's key, what the lists leave out, what a refused key keeps (`keepOnRefusedKey`: everything, unless the relay says `account_deleted`); unit-tested), `AccountData` (applies them: every chat an owner row, `leave` puts the account's chats, memory, feed, goals, routines, face and preferences aside or deletes them, `enter` brings an account's back). The Library tab follows the same rule: it lists the workspaces of the sessions the chat list shows, so another account's files stay off a shared phone (`library/LibraryIndex.shows`, unit-tested) |
+| `sync/` | Conversation sync (contracts C7–C10): `SyncEngine` (what goes up and comes down; `owner` per mapping, the account's only), `ConversationSync` (when; `hidden`: the chats of another account, left out of `ChatRepository.observeSessions()`), `LocalChats`, the Room store `nanomuse_sync.db` (with the `session_owners` table of C12) |
+| `account/` | Contract C12 (0.1.40, [sync.md](sync.md)): `AccountScope` (the rules: whose chat a session is, the account's key, what the lists leave out, what a refused key keeps (`keepOnRefusedKey`: everything, unless the relay says `account_deleted`); unit-tested), `AccountData` (applies them: every chat an owner row, `leave` puts the account's chats, memory, feed, goals, routines, face and preferences aside or deletes them, `enter` brings an account's back). The Library tab follows the same rule: it lists the workspaces of the sessions the chat list shows, so another account's files stay off a shared phone (`library/LibraryIndex.shows`, unit-tested) |
| `hub/` | `Hub` (state, device identity, settings), `HubClient` (the socket with backoff; a refused key is retried once a minute and shown as such, a replaced connection waits 30 s, a hub the operator paused waits two minutes and is shown as such), `HubService` (the foreground service), `HubActions` (what other devices may ask this phone; `stop {call | conversation}` ends a task the asking device started here, bookkept in `HubTasks`, unit-tested), `HubErrors` (failures in words) |
-| `reach/` | `Computers` (paired computers, tokens in the encrypted store), the offload handler that sends work to a computer |
+| `reach/` | `Computers` (the account's devices as the hub lists them, and the calls to them; no pairing and no tokens of its own since 0.1.24), the offload handler that sends work to a computer |
| `models/` | Settings → Models behind the screens: `ModelSlots` (the four slots, what each is set to and where that is kept, the groups a picker shows, `followPick` for the chat default, `applyProvider` for the *Use it for* card), `SlotOrder` (the resolution order and the catalogue default, pure Kotlin, unit-tested), `PickerList` (the picker's eight-row groups, their order and the search filter, pure Kotlin, unit-tested) |
| `ui/models/` | The Models page, the picker behind each row, the *Use it for* card (`minis://settings/models`) |
| `chat/CloudRetry.kt` | *Use nanoMuse Cloud this time*: whether the card offers it and the one-turn provider `ChatViewModel.retryLast` uses |
diff --git a/docs/app.md b/docs/app.md
index 8325bc6b..c7600755 100644
--- a/docs/app.md
+++ b/docs/app.md
@@ -1,10 +1,10 @@
# The app
> **Design record of the Python line** (`nanomuse/` + `web/`, tag `pre-openminis`): this is
-> the web app `nanomuse serve` serves — today the front door of the desktop app, and of
+> the web app `nanomuse serve` serves, today the front door of the desktop app, and of
> a runtime opened in a browser tab (upstream's hosted site is the demo on a simulated phone; this fork has no hosted demo). The phone app in `android/` is OpenMinis-based and described in
> [android.md](android.md); its first run starts with the account sign-in, then the model.
-> The face everywhere is the bundled **dragon** — or one drawn for you in the
+> The face everywhere is the bundled **dragon**, or one drawn for you in the
> [avatar studio](avatar.md). The red panda and the dolls of earlier versions are gone
> (a profile that still names one wears the dragon).
@@ -24,7 +24,7 @@ nanomuse serve --port 9000 --no-qr
-The look follows Meta Muse: near-white and near-black surfaces, one blue accent, grey bubbles for the agent and light blue for you, a small dragon at the top of the chat that changes pose with what the agent is doing, and a floating bar with five icons. The font is [Figtree](https://github.com/erikdkennedy/figtree) (OFL), bundled. The study behind these choices is in [design.md](design.md).
+The look is monochrome: near-white and near-black surfaces, blue for links only, a light grey pill for the agent's messages and, for yours, a pill in the chat's own ink (white on the black canvas, black on the white one, see [android.md](android.md)), a small dragon at the top of the chat that changes pose with what the agent is doing, and a floating frosted-glass pill with five icons. The font is [Figtree](https://github.com/erikdkennedy/figtree) (OFL), bundled. The study behind these choices is in [design.md](design.md).
## Getting it onto your phone
@@ -40,72 +40,72 @@ The token is generated once and stored in `/server_token`; set `server
-On a fresh data directory the app opens with setup instead of the chat. First three points about what it is — it does things for you; it keeps working when the app is closed; it asks you first where it matters — and a line saying there is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you. Then a checklist, ticked as it is done:
+On a fresh data directory the app opens with setup instead of the chat. First three points about what it is (it does things for you; it keeps working when the app is closed; it asks you first where it matters) and a line saying there is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you. Then a checklist, ticked as it is done:
-1. **Add a model** — providers grouped by protocol (OpenAI-compatible Chat Completions · Responses API · local or your own endpoint) with vendor subtitles: DeepSeek, Kimi, Qwen, GLM, 豆包, MiniMax, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint. The key is masked with a reveal toggle and each vendor has a *Get a key* link; it goes into the vault on the server and the model never sees it. A Base URL without a path gains `/v1`; Ollama and custom endpoints may have no key. The endpoint's own `/models` fills the list (a catalogue stands in when it cannot be reached); a model you typed is never replaced.
-2. **Connect mail, calendar, contacts** — optional.
-3. **Start** — opens the chat, where the agent speaks first, asks your name and picks its own with you: the first conversation ([web.md](web.md#the-first-run-and-the-chats-opening)).
+1. **Add a model**: providers grouped by protocol (OpenAI-compatible Chat Completions · Responses API · local or your own endpoint) with vendor subtitles: DeepSeek, Kimi, Qwen, GLM, 豆包, MiniMax, OpenAI, OpenRouter, Ollama, or any OpenAI-compatible endpoint. The key is masked with a reveal toggle and each vendor has a *Get a key* link; it goes into the vault on the server and the model never sees it. A Base URL without a path gains `/v1`; Ollama and custom endpoints may have no key. The endpoint's own `/models` fills the list (a catalogue stands in when it cannot be reached); a model you typed is never replaced.
+2. **Connect mail, calendar, contacts**: optional.
+3. **Start**: opens the chat, where the agent speaks first, asks your name and picks its own with you, the first conversation ([web.md](web.md#the-first-run-and-the-chat-s-opening)).
*Start* stays locked until a model is saved; *Skip setup* is always there and skips the first conversation too. A reload keeps the ticks. The name, face, tagline and tone can be changed later under the avatar (*Settings* has the identity form). Setup does not reappear once finished, or once a conversation exists. *Start* marks setup done (`POST /api/firstrun/start`, the same as `POST /api/onboarded`); when a conversation already exists at that point, or when the first conversation ends, the Feed's first day is written in the background, so the room is not empty the first time it opens; the Feed otherwise opens on its intro card and says when its daily routine runs.
## What is on the screen
-Five tabs — Chat, Feed, Ideas, Goals, Library — and a menu behind the avatar.
+Five tabs (Chat, Feed, Ideas, Goals, Library) and a menu behind the avatar. At phone width the five sit in a rounded pill that floats 12 px above the bottom edge and is as wide as the chat's composer (420 px at most, centred on a wider screen), icons only and no labels, the same bar the Android app draws: the pill is translucent over a blur of what is behind it and has a hairline edge; the current tab shows its filled glyph on an ink indicator, black in the light theme and white in the dark one, the others stay outlined in grey. The screens and the chat's composer keep clear of it. On a wide window, and in the desktop app at any width, a sidebar takes the bar's place.
-**Chat.** One main conversation plus side chats (the *Chats* button top right). Messages stream in as they are generated. Tool calls appear as chips: tap one for the arguments and output. Files the agent writes show up as artifact cards that open in the app. When Sentinel needs a decision an approval card appears; when the agent needs information a question card appears. The composer stays open while the agent works; anything you send is folded into the running turn before the next model call.
+**Chat.** One main conversation plus side chats (the round *Chats* button top left opens the drawer that lists them). Messages stream in as they are generated. Tool calls appear as chips: tap one for the arguments and output. Files the agent writes show up as artifact cards that open in the app. When Sentinel needs a decision an approval card appears; when the agent needs information a question card appears. The composer stays open while the agent works; anything you send is folded into the running turn before the next model call.
-*Attachments.* The paperclip (or a paste) attaches photos and files — up to ten per message, 25 MB each (`server.max_upload_mb`) — from the camera, the photo library or the phone's files. They upload as you pick them, show as thumbnails and chips you can remove, and a message may be attachments alone. Each file lands in the workspace under `attachments//` (so it is in the Library and any tool can use it) and the message lists them for the agent with what they are. Pictures go to the model as images when it takes them: `llm.vision = auto` sends them and, if the endpoint refuses (DeepSeek and most text-only models do), sends the text only from then on and tells you once — the picture stays as a file, the agent knows its name and says it cannot see it rather than guessing; `off` never sends pictures, `on` insists. Phone photos are scaled to 1568 px before they are encoded, so a 10 MB picture costs what a small one does. PDFs, spreadsheets and documents the agent reads with `files` — PDF text is extracted page by page (a scanned PDF says it has no text layer). In the chat, tap a picture or a file chip to open it.
+*Attachments.* The + button (or a paste) attaches photos and files, up to ten per message, 25 MB each (`server.max_upload_mb`), from the camera, the photo library or the phone's files. They upload as you pick them, show as thumbnails and chips you can remove, and a message may be attachments alone. Each file lands in the workspace under `attachments//` (so it is in the Library and any tool can use it) and the message lists them for the agent with what they are. Pictures go to the model as images when it takes them: `llm.vision = auto` sends them and, if the endpoint refuses (DeepSeek and most text-only models do), sends the text only from then on and tells you once (the picture stays as a file, the agent knows its name and says it cannot see it rather than guessing); `off` never sends pictures, `on` insists. Phone photos are scaled to 1568 px before they are encoded, so a 10 MB picture costs what a small one does. PDFs, spreadsheets and documents the agent reads with `files`; PDF text is extracted page by page (a scanned PDF says it has no text layer). In the chat, tap a picture or a file chip to open it.
-*Browser view.* When the agent uses the browser tool, a browser card appears with what it is looking at: a picture of the page after every action, the page title, where it is, and what it just did ("Opened example.com", "Clicked 'Sign in'"). The card is LIVE while the run goes on and stays in the chat afterwards. Tap it for the full view; *Take over* puts you at the controls — tap the picture to click there, type into the focused field, press Enter, open a URL — and *Hand back* returns the page to the agent, which is told what you did and continues from there. That is how a login happens: the agent stops at the form and asks, you sign in, it carries on. Passwords you type go to the website, never through the model. Frames are kept in memory on the server for the current session only (the last dozen per chat); after a restart old cards show a placeholder. Needs the browser tool (`pip install "nanomuse[browser]" && playwright install chromium`, or the `-browser` Docker image) — or the Android app, whose own WebView is the agent's browser whenever the app is connected (the card says which: `backend`). In the app, *Take over* on such a card slides the real page up as a sheet rather than a picture of it; sign in with the phone's keyboard and autofill, tap **Done**, and the agent carries on. The whole story, including what cannot log in inside a WebView: [browser.md](browser.md).
+*Browser view.* When the agent uses the browser tool, a browser card appears with what it is looking at: a picture of the page after every action, the page title, where it is, and what it just did ("Opened example.com", "Clicked 'Sign in'"). The card is LIVE while the run goes on and stays in the chat afterwards. Tap it for the full view; *Take over* puts you at the controls (tap the picture to click there, type into the focused field, press Enter, open a URL) and *Hand back* returns the page to the agent, which is told what you did and continues from there. That is how a login happens: the agent stops at the form and asks, you sign in, it carries on. Passwords you type go to the website, never through the model. Frames are kept in memory on the server for the current session only (the last dozen per chat); after a restart old cards show a placeholder. Needs the browser tool (`pip install "nanomuse[browser]" && playwright install chromium`, or the `-browser` Docker image), or the Android app, whose own WebView is the agent's browser whenever the app is connected (the card says which: `backend`). In the app, *Take over* on such a card slides the real page up as a sheet rather than a picture of it; sign in with the phone's keyboard and autofill, tap **Done**, and the agent carries on. The whole story, including what cannot log in inside a WebView: [browser.md](browser.md).
-**Feed.** Two things. At the top, *Feed instructions*: tell it what you want to read about ("keep me up to date on cycling and Rust, one recipe a week"), and once a day — or when you tap *New posts* — the agent writes three short posts for you from those instructions, your goals and what it remembers. Each post has *Ask {name}*, which starts a conversation about it. Below that, *While you were away*: one entry per background pass (its final word, and any file it made), every approval or question still waiting for you in any chat, today's calendar, and a *Next up* card that says when the next pass runs and which goal is in line. Unseen entries are counted on the tab.
+**Feed.** Two things. At the top, *Feed instructions*: tell it what you want to read about ("keep me up to date on cycling and Rust, one recipe a week"), and once a day, or when you tap *New posts*, the agent writes three short posts for you from those instructions, your goals and what it remembers. Each post has *Ask {name}*, which starts a conversation about it. Below that, *While you were away*: one entry per background pass (its final word, and any file it made), every approval or question still waiting for you in any chat, today's calendar, and a *Next up* card that says when the next pass runs and which goal is in line. Unseen entries are counted on the tab.
-**Ideas.** Suggestions generated from your goals, memory and recent conversation, grouped by area — planning, research, goals, money, health, home, learning, people, files, fun. Tap one to send it as a message; *Refresh* regenerates.
+**Ideas.** Suggestions generated from your goals, memory and recent conversation, grouped by area: planning, research, goals, money, health, home, learning, people, files, fun. Tap one to send it as a message; *Refresh* regenerates.
-**Goals.** Two lists. *Tracking* is what gets checked on a schedule (a goal with a check-in cadence); *Goals* is the rest, done step by step. Each row has a check circle that fills as the plan progresses, one line of status (next check-in, last update, due date) and the count of steps done. Goals are created by the agent or by you (the `+` button), filed under an area of life — health, finance, career, learning, relationships, family, home, travel, creative — with an optional target date and an optional check-in cadence. Each goal has a plan; steps are pending, in progress, done or blocked, with notes. *Work on it now* runs one background pass on that goal and posts the result to the main chat; *Check in with me now* sends the reminder message right away.
+**Goals.** Two lists. *Tracking* is what gets checked on a schedule (a goal with a check-in cadence); *Goals* is the rest, done step by step. Each row has a check circle that fills as the plan progresses, one line of status (next check-in, last update, due date) and the count of steps done. Goals are created by the agent or by you (the `+` button), filed under an area of life (health, finance, career, learning, relationships, family, home, travel, creative) with an optional target date and an optional check-in cadence. Each goal has a plan; steps are pending, in progress, done or blocked, with notes. *Work on it now* runs one background pass on that goal and posts the result to the main chat; *Check in with me now* sends the reminder message right away.
- *Target date.* Cards say "Due in 5 days" / "Was due Sep 12"; overdue goals go first when the background pass picks what to work on, and the agent is told about them.
-- *Check-ins.* "Every day at 08:00", "Weekdays at 07:30", "Mondays at 09:00", "Monthly on the 1st": at that time the agent sends one short message — what the goal is about, what the next small step is, how is it going — and does no work. Check-ins arrive at any proactivity level (you asked for them) but wait out quiet hours. The *Upcoming* view lists the next ones.
-- *Plan changes.* When the agent learns the plan no longer fits, it does not edit it; it proposes a revised set of remaining steps with a reason. The proposal shows as a card at the top of Goals and inside the goal — *Use nanoMuse's plan* keeps the finished steps and swaps the rest, *Keep my plan* leaves everything as it is. Either way a note lands on the goal.
+- *Check-ins.* "Every day at 08:00", "Weekdays at 07:30", "Mondays at 09:00", "Monthly on the 1st": at that time the agent sends one short message (what the goal is about, what the next small step is, how is it going) and does no work. Check-ins arrive at any proactivity level (you asked for them) but wait out quiet hours. The *Upcoming* view lists the next ones.
+- *Plan changes.* When the agent learns the plan no longer fits, it does not edit it; it proposes a revised set of remaining steps with a reason. The proposal shows as a card at the top of Goals and inside the goal: *Use nanoMuse's plan* keeps the finished steps and swaps the rest, *Keep my plan* leaves everything as it is. Either way a note lands on the goal.
-**Reminders and routines.** Say it in chat — "remind me at six to call mum", "every weekday at 07:30 give me a one-line weather check" — or add one under *Upcoming*. A *reminder* is one short message at the time you named, in the chat you set it from, and nothing else; a *routine* is a task the agent does at that time with its tools (read the inbox, check a page, run a script) and then reports on. Cadences are the check-in grammar: `daily 08:00`, `weekdays 07:30`, `weekly mon 09:00`, `monthly 1 09:00`. A time you named is kept whatever the proactivity level or the quiet hours; if the chat is busy the message queues behind the conversation rather than being skipped. Fired items stay listed for a week under *finished recently*. The same list is available from the terminal: `nanomuse reminders list | add | cancel`.
+**Reminders and routines.** Say it in chat ("remind me at six to call mum", "every weekday at 07:30 give me a one-line weather check") or add one under *Upcoming*. A *reminder* is one short message at the time you named, in the chat you set it from, and nothing else; a *routine* is a task the agent does at that time with its tools (read the inbox, check a page, run a script) and then reports on. Cadences are the check-in grammar: `daily 08:00`, `weekdays 07:30`, `weekly mon 09:00`, `monthly 1 09:00`. A time you named is kept whatever the proactivity level or the quiet hours; if the chat is busy the message queues behind the conversation rather than being skipped. Fired items stay listed for a week under *finished recently*. The same list is available from the terminal: `nanomuse reminders list | add | cancel`.
-**Triggers — when something happens.** The other half of reminders: work that starts from the world instead of the clock. Say it in chat — "when the landlord writes back, summarise it and draft a reply", "half an hour before any meeting with *review* in the title, put together a one-page brief", "when my deploy script calls you, check that the site is up" — or add one under *Upcoming → When something happens*. Three kinds: **new mail** (a message whose sender or subject contains every word you named arrives; the inbox is looked at every five minutes while such a trigger exists, and connecting a mailbox never replays old mail), **before an event** (a calendar event whose title or place matches is *N* minutes from starting), and **webhook** (a URL with a key; anything that can make an HTTP request — a CI job, a home-automation rule, a cron line with `curl` — `POST`s to it and the body becomes the context). Each time it fires the agent does the work in the chat the trigger was set from, with the mail, the event or the request in front of it, and the result lands in the Feed as *New mail: …*, *Coming up: …* or *Webhook: …*. The same thing never fires twice (a mail's UID, an event's start), a webhook refuses deliveries closer together than ten seconds, and a wrong key looks exactly like a wrong URL. Mail and events are your private data: from then on the session is tainted, so sending anything to a host that is not allowlisted needs an approval — and the model is told to treat what arrived as data, never as instructions. *Run now* fires one with a sample occurrence to see what it does; the list shows how often each fired and when the inbox was last read. From the terminal: `nanomuse triggers list | add | cancel`.
+**Triggers: when something happens.** The other half of reminders: work that starts from the world instead of the clock. Say it in chat ("when the landlord writes back, summarise it and draft a reply", "half an hour before any meeting with *review* in the title, put together a one-page brief", "when my deploy script calls you, check that the site is up") or add one under *Upcoming → When something happens*. Three kinds: **new mail** (a message whose sender or subject contains every word you named arrives; the inbox is looked at every five minutes while such a trigger exists, and connecting a mailbox never replays old mail), **before an event** (a calendar event whose title or place matches is *N* minutes from starting), and **webhook** (a URL with a key; anything that can make an HTTP request (a CI job, a home-automation rule, a cron line with `curl`) `POST`s to it and the body becomes the context). Each time it fires the agent does the work in the chat the trigger was set from, with the mail, the event or the request in front of it, and the result lands in the Feed as *New mail: …*, *Coming up: …* or *Webhook: …*. The same thing never fires twice (a mail's UID, an event's start), a webhook refuses deliveries closer together than ten seconds, and a wrong key looks exactly like a wrong URL. Mail and events are your private data: from then on the conversation is tainted, so sending anything to a host that is not allowlisted needs an approval, and the model is told to treat what arrived as data, never as instructions. *Run now* fires one with a sample occurrence to see what it does; the list shows how often each fired and when the inbox was last read. From the terminal: `nanomuse triggers list | add | cancel`.
-**Library.** Every file in the agent's workspace, newest first, filtered by kind (pages, documents, images, data, code) and searchable. Files open in the app: pages render live, Markdown is formatted, CSV becomes a table, images and PDFs display inline. A page the agent wrote runs in a sandboxed frame with an opaque origin — it cannot read the access token or call the API — and the server sends `Content-Security-Policy: sandbox` with every HTML file for the same reason.
+**Library.** Every file in the agent's workspace, newest first, filtered by kind (pages, documents, images, data, code) and searchable. Files open in the app: pages render live, Markdown is formatted, CSV becomes a table, images and PDFs display inline. A page the agent wrote runs in a sandboxed frame with an opaque origin (it cannot read the access token or call the API) and the server sends `Content-Security-Policy: sandbox` with every HTML file for the same reason.
-**Avatar.** The dragon sits at the top of the chat with a status line under it — what the agent is doing right now in this chat, or that it is waiting for you. It has a still for each state (`web/public/avatars/dragon-*.webp`): idle; typing at a small laptop with headphones on while it works; gazing into a crystal ball while it waits for you; hugging a star when a run finishes; a sweat drop when a tool call fails or is refused. Tap it and it is pleased about it. A face drawn in the [avatar studio](avatar.md) has the same five stills; Settings offers an emoji instead, which moves as a whole (breathe, sway, hop). All of it stops under `prefers-reduced-motion`. The chat tab carries a badge with the approvals waiting anywhere. Tap the avatar for the status sheet: the avatar large, the same status line, a *Stop* button while something runs (the run ends, pending cards in that chat close, and the conversation stays usable), the model and the Sentinel mode, then:
+**Avatar.** The dragon sits at the top of the chat with a status line under it: what the agent is doing right now in this chat, or that it is waiting for you. It has a still for each state (`web/public/avatars/dragon-*.webp`): idle; typing at a small laptop with headphones on while it works; gazing into a crystal ball while it waits for you; hugging a star when a run finishes; a sweat drop when a tool call fails or is refused. Tap it and it is pleased about it. A face drawn in the [avatar studio](avatar.md) has the same five stills; Settings offers an emoji instead, which moves as a whole (breathe, sway, hop). On the Android app a short clip of your own can play as the face instead, kept on that phone only ([avatar.md](avatar.md#your-own-clip)). All of it stops under `prefers-reduced-motion`. The chat tab carries a badge with the approvals waiting anywhere. Tap the avatar for the status sheet: the avatar large, the same status line, a *Stop* button while something runs (the run ends, pending cards in that chat close, and the conversation stays usable), the model and the Sentinel mode, then:
-- *Approvals* — the queue of cards waiting for you across all chats, answerable right there. Opens first when something is pending.
-- *Activity* — the audit trail: every tool call, decision and approval, including refused ones.
-- *Permissions* — the Sentinel mode, and every standing permission you granted with a revoke button on each.
-- *Upcoming* — the background-work switch, the next pass time, the goals in line with a *run now* button, the next check-ins, your reminders and routines, and your triggers (*When something happens*), each with *+ Add*, *run now* and cancel; a webhook has a copy button for its URL.
-- *Memory* — everything the agent has remembered about you, by category, plus an entry box. *Forget* deletes an item; the agent will not see it again. Each turn, the memories that fit the message go into the agent's prompt: by keyword, and — with an embedding endpoint set up under *Connections → Recall by meaning* — by meaning too, so "写邮件给房东" brings up "the landlord is Bob Li" though they share no word. *Tidy up* runs one pass of the housekeeping the agent also does on its own (after every eight new lines, or weekly, at any proactivity level but Off): lines that say the same thing are merged into one, a fact that changed keeps the newer version, and one-off requests that were never facts about you are dropped. The model proposes; nanoMuse checks that a merged line adds no words that were not there, refuses to drop anything you wrote yourself, and takes out at most a fifth of the store per pass. *Recent changes* lists every merge, drop and update with the text it replaced, each with *Undo*. A tidy-up that changed something is one entry in the Feed.
-- *Skills* — how a job is done, written down once: a weekly review, a trip plan, an inbox triage. See [Skills](#skills) below.
-- *Connections* — what the agent can reach, plugged in and out from the phone. **Model**: provider presets (Alibaba Cloud Bailian, OpenRouter, DeepSeek, OpenAI, Ollama, any OpenAI-compatible endpoint), model name, tool-calling mode, and the API key — which is written to the vault as `LLM_API_KEY` and swapped in for every thread on the spot; *Test* asks the model for a one-word reply. With a nanoMuse Cloud account the chat model is `deepseek-v4.1-flash`. **Hands model**: the model the browser and computer hands send their screenshots to, a setting of its own — `qwen3.8-27b` on the Cloud; under your own key, a model that sees images (of DeepSeek's, only ids with `v4.1`, `vision` or `ocr`). **Recall by meaning**: whether memories are also recalled by what they mean, through an OpenAI-compatible `/embeddings` endpoint — *Auto* (the model's endpoint when it has one, keyword recall when it does not), *On*, *Off*; the model's own endpoint or another one (Ollama with `qwen3-embedding:0.6b` next to DeepSeek, which has none — the key goes to the vault as `EMBEDDINGS_API_KEY`); the embedding model, blank for the endpoint's default. *Test* makes one embeddings call and, when it works, indexes every memory on the spot; the card's line says how many are indexed. **Web search**: who answers `web_search` — DuckDuckGo (nothing to set up, but scraped and rate-limited now and then), Brave Search or Tavily with a key (vault: `SEARCH_API_KEY`, with a link to where keys come from), or a SearXNG instance by URL; *Test* runs one search and shows the provider, the count and the time. Whatever is picked, a failed search falls back to DuckDuckGo with a note. **Email**: presets for common providers, address and app password (vault: `EMAIL_ADDRESS`, `EMAIL_PASSWORD`), IMAP/SMTP servers; *Connect* saves and signs in to both servers to prove it works; *Disconnect* removes the credentials and the tools. **Calendar**: add any private `.ics` link (the screen says where Google, Outlook, iCloud and Fastmail hide theirs) or a file path; the link goes to the vault as `CALENDAR_`, the feed is read on the spot and its event count shown; working hours for *free time*; *Read again* re-fetches every feed. Today's events appear in the Feed under *Today* (tomorrow's once today is over), and the agent sees them in its prompt. An event the agent drafts opens as a card with *Add to calendar*. **Contacts**: upload a `.vcf` export from the phone (Google Contacts, iCloud, Outlook, the phone's own contacts app — the screen says where each hides the export), or give a path or a link (the link goes to the vault as `CONTACTS_`); each address book shows how many people it has; *My contacts* is the book the agent fills from chat; a search field looks people up exactly as the agent does. **Browser**: on/off, with the install hint when Playwright is missing. **MCP servers**: add a server by command (stdio) or URL, choose the risk level of its tools, remove it again; servers from `config.toml` are listed read-only. **Vault**: the names of every stored secret, add or delete one. Only names ever leave the server.
-- *Settings* — the agent's name, avatar, colour and personality, and what it calls you; the Sentinel mode (Balanced = `ask`, Cautious = `strict`, Hands-off = `auto`) and whether commands run in the [sandbox](sentinel.md#the-sandbox); proactivity (the Off / Low / Default / High dial, the check-in interval, quiet hours — see [Background work](#background-work)); notifications (below); on the Android app, *Keep it running* — the battery, overlay and alarm permissions, start after a reboot, log export ([archive/android-python-line.md](archive/android-python-line.md#keeping-it-running)); *show thinking*; app language; reply language; and two version lines that are always there — the installed version and the newest release (*Latest 0.1.x — you have it* / *0.1.x is out — Update* / *Could not check — tap to try again*; this fork's GitHub releases first, then a configured mirror only).
+- *Approvals*: the queue of cards waiting for you across all chats, answerable right there. Opens first when something is pending.
+- *Activity*: the audit trail, every tool call, decision and approval, including refused ones.
+- *Permissions*: the Sentinel mode, and every standing permission you granted with a revoke button on each.
+- *Upcoming*: the background-work switch, the next pass time, the goals in line with a *run now* button, the next check-ins, your reminders and routines, and your triggers (*When something happens*), each with *+ Add*, *run now* and cancel; a webhook has a copy button for its URL.
+- *Memory*: everything the agent has remembered about you, by category, plus an entry box. *Forget* deletes an item; the agent will not see it again. Each turn, the memories that fit the message go into the agent's prompt: by keyword, and (with an embedding endpoint set up under *Connections → Recall by meaning*) by meaning too, so "写邮件给房东" brings up "the landlord is Bob Li" though they share no word. *Tidy up* runs one pass of the housekeeping the agent also does on its own (after every eight new lines, or weekly, at any proactivity level but Off): lines that say the same thing are merged into one, a fact that changed keeps the newer version, and one-off requests that were never facts about you are dropped. The model proposes; nanoMuse checks that a merged line adds no words that were not there, refuses to drop anything you wrote yourself, and takes out at most a fifth of the store per pass. *Recent changes* lists every merge, drop and update with the text it replaced, each with *Undo*. A tidy-up that changed something is one entry in the Feed.
+- *Skills*: how a job is done, written down once (a weekly review, a trip plan, an inbox triage). See [Skills](#skills) below.
+- *Connections*: what the agent can reach, plugged in and out from the phone. **Model**: provider presets (Alibaba Cloud Bailian, OpenRouter, DeepSeek, OpenAI, Ollama, any OpenAI-compatible endpoint), model name, tool-calling mode, and the API key. What each provider covers and the ChatGPT sign-in are in [web.md](web.md); the key is written to the vault as `LLM_API_KEY` and swapped in for every thread on the spot; *Test* asks the model for a one-word reply. With a nanoMuse Cloud account the chat model is `deepseek-v4.1-flash`. **Hands model**, **Making pictures**, **Making clips**: the other three rows of the Models page the phones and the desktop have under *Settings → Models*, here as cards under the chat model; each opens with *Automatic* (the chat model's provider when it can do the job, else the account's model when signed in) and says *Currently provider · model*. The hands send the screenshots of the browser and the computer to a model that sees images: `qwen3.8-27b` on the Cloud; of DeepSeek's, only ids with `v4.1`, `vision` or `ocr`. **Recall by meaning**: whether memories are also recalled by what they mean, through an OpenAI-compatible `/embeddings` endpoint: *Auto* (the model's endpoint when it has one, keyword recall when it does not), *On*, *Off*; the model's own endpoint or another one (Ollama with `qwen3-embedding:0.6b` next to DeepSeek, which has none; the key goes to the vault as `EMBEDDINGS_API_KEY`); the embedding model, blank for the endpoint's default. *Test* makes one embeddings call and, when it works, indexes every memory on the spot; the card's line says how many are indexed. **Web search**: who answers `web_search`: DuckDuckGo (nothing to set up, but scraped and rate-limited now and then), Brave Search or Tavily with a key (vault: `SEARCH_API_KEY`, with a link to where keys come from), or a SearXNG instance by URL; *Test* runs one search and shows the provider, the count and the time. Whatever is picked, a failed search falls back to DuckDuckGo with a note. **Email**: presets for common providers, address and app password (vault: `EMAIL_ADDRESS`, `EMAIL_PASSWORD`), IMAP/SMTP servers; *Connect* saves and signs in to both servers to prove it works; *Disconnect* removes the credentials and the tools. **Calendar**: add any private `.ics` link (the screen says where Google, Outlook, iCloud and Fastmail hide theirs) or a file path; the link goes to the vault as `CALENDAR_`, the feed is read on the spot and its event count shown; working hours for *free time*; *Read again* re-fetches every feed. Today's events appear in the Feed under *Today* (tomorrow's once today is over), and the agent sees them in its prompt. An event the agent drafts opens as a card with *Add to calendar*. **Contacts**: upload a `.vcf` export from the phone (Google Contacts, iCloud, Outlook, the phone's own contacts app; the screen says where each hides the export), or give a path or a link (the link goes to the vault as `CONTACTS_`); each address book shows how many people it has; *My contacts* is the book the agent fills from chat; a search field looks people up exactly as the agent does. **Browser**: on/off, with the install hint when Playwright is missing. **MCP servers**: add a server by command (stdio) or URL, choose the risk level of its tools, remove it again; servers from `config.toml` are listed read-only. **Vault**: the names of every stored secret, add or delete one. Only names ever leave the server.
+- *Settings*: the agent's name, avatar, colour and personality, and what it calls you; the Sentinel mode (Balanced = `ask`, Cautious = `strict`, Hands-off = `auto`) and whether commands run in the [sandbox](sentinel.md#the-sandbox); proactivity (the Off / Low / Default / High dial, the check-in interval, quiet hours; see [Background work](#background-work)); notifications (below); on the Android app, *Keep it running*: the battery, overlay and alarm permissions, start after a reboot, log export ([archive/android-python-line.md](archive/android-python-line.md#keeping-it-running)); *show thinking*; app language; reply language; and two version lines that are always there: the installed version and the newest release (*Latest 0.1.x; you have it* / *0.1.x is out · Update* / *Could not check · Check now*; this fork's GitHub releases first, then a configured mirror only).
-The app speaks English and 简体中文. *Settings → App language* is a device setting (stored in the browser, not on the server): *Auto* follows the browser's language, otherwise pick one; dates and relative times follow it too. It is separate from *Reply language*, which is what the agent writes in. Strings live in `web/src/i18n/` — the English text is the key, `zh-CN.ts` the translation, and a unit test fails when a string in the app has no translation.
+The app speaks English and 简体中文. *Settings → App language* is a device setting (stored in the browser, not on the server): *Auto* follows the browser's language, otherwise pick one; dates and relative times follow it too. It is separate from *Reply language*, which is what the agent writes in. Strings live in `web/src/i18n/`: the English text is the key, `zh-CN.ts` the translation, and a unit test fails when a string in the app has no translation.
### Skills
-A skill is a recipe: a folder with a `SKILL.md` — a name and a one-line description up top, then the steps in Markdown — in the [Agent Skills](https://agentskills.io) format, so a skill written for another agent works here and yours work there. Eleven ship with the app (`weekly-review`, `trip-plan`, `inbox-triage`, `compare-options`, `meeting-prep`, `phone-messages`, and the service skills `feishu`, `tencent-meeting`, `amap`, `kuaidi100`, `train-tickets`); yours live in `/skills//`, and one with the same name as a built-in replaces it.
+A skill is a recipe: a folder with a `SKILL.md` (a name and a one-line description up top, then the steps in Markdown) in the [Agent Skills](https://agentskills.io) format, so a skill written for another agent works here and yours work there. Eleven ship with the app (`weekly-review`, `trip-plan`, `inbox-triage`, `compare-options`, `meeting-prep`, `phone-messages`, and the service skills `feishu`, `tencent-meeting`, `amap`, `kuaidi100`, `train-tickets`); yours live in `/skills//`, and one with the same name as a built-in replaces it.
-The model sees the index — every enabled skill's name and description — in its system prompt and picks one when a request fits ("plan me a week in Kyoto" reaches for `trip-plan`), reading the full steps with the `skills` tool before it starts. You can also name one yourself: type `/` in the composer and the enabled skills come up (`Tab` completes the first match); `/trip-plan Kyoto, 5 days in November` sends the skill's instructions with your text as the task.
+The model sees the index (every enabled skill's name and description) in its system prompt and picks one when a request fits ("plan me a week in Kyoto" reaches for `trip-plan`), reading the full steps with the `skills` tool before it starts. You can also name one yourself: type `/` in the composer and the enabled skills come up (`Tab` completes the first match); `/trip-plan Kyoto, 5 days in November` sends the skill's instructions with your text as the task.
-Three ways to get a new one. Write it in *Skills → New* (a template appears once the name is set), paste a link — a raw `SKILL.md`, or a GitHub folder or file page — and *Fetch*, or, after a job went well, tell the agent "save this as a skill": it writes down what it did as steps and asks you first (`skills` action=save is a sensitive call, so it needs your approval even in `auto` mode, and so does removing one). Switch a skill off to keep it out of the model's list; *Make your own copy* on a built-in opens it in the editor under your name. A skill's own scripts and reference files are visible read-only inside the [sandbox](sentinel.md#the-sandbox). From the terminal: `nanomuse skills list | show | add | new | remove | enable | disable`.
+Three ways to get a new one. Write it in *Skills → New* (a template appears once the name is set), paste a link (a raw `SKILL.md`, or a GitHub folder or file page) and *Fetch*, or, after a job went well, tell the agent "save this as a skill": it writes down what it did as steps and asks you first (`skills` action=save is a sensitive call, so it needs your approval even in `auto` mode, and so does removing one). Switch a skill off to keep it out of the model's list; *Make your own copy* on a built-in opens it in the editor under your name. A skill's own scripts and reference files are visible read-only inside the [sandbox](sentinel.md#the-sandbox). From the terminal: `nanomuse skills list | show | add | new | remove | enable | disable`.
### Notifications
-*Settings → Notifications → Let nanoMuse notify this device.* Standard Web Push through the browser's own push service, no account with anyone: the server generates a VAPID key pair once (`/push-vapid.json`) and keeps the subscriptions of your devices (`push-subscriptions.json`). You get a notification when the agent needs your approval, asks a question, finished a background pass that had something to report, or it is check-in time on a goal. Quiet passes and step-by-step narration never leave the app, and nothing is shown while the app is on screen — the card is already there. Tapping a notification opens the right chat. On a phone with the app on the home screen, the icon carries a badge with the number of cards waiting for you.
+*Settings → Notifications → Let nanoMuse notify this device.* Standard Web Push through the browser's own push service, no account with anyone: the server generates a VAPID key pair once (`/push-vapid.json`) and keeps the subscriptions of your devices (`push-subscriptions.json`). You get a notification when the agent needs your approval, asks a question, finished a background pass that had something to report, or it is check-in time on a goal. Quiet passes and step-by-step narration never leave the app, and nothing is shown while the app is on screen: the card is already there. Tapping a notification opens the right chat. On a phone with the app on the home screen, the icon carries a badge with the number of cards waiting for you.
-Push needs a secure context: `https://` or `localhost`. Over plain `http://` on your LAN the rest of the app works and the toggle explains why this part is off — see [deployment](deployment.md#reaching-it-from-outside-your-network) for a TLS setup. Embedded browsers (the kind inside another app) usually have no push service at all; use Chrome, Edge, Firefox or Safari 16.4+ (iOS: home-screen apps only).
+Push needs a secure context: `https://` or `localhost`. Over plain `http://` on your LAN the rest of the app works and the toggle explains why this part is off; see [deployment](deployment.md#reaching-it-from-outside-your-network) for a TLS setup. Embedded browsers (the kind inside another app) usually have no push service at all; use Chrome, Edge, Firefox or Safari 16.4+ (iOS: home-screen apps only).
-Non-secret choices made in Connections are stored in `/app-settings.json` and layered over `config.toml` on every start — for the CLI too — so a phone-only setup never needs a file edited. Secrets are only ever referenced from there as `{{vault:NAME}}`.
+Non-secret choices made in Connections are stored in `/app-settings.json` and layered over `config.toml` on every start, for the CLI too, so a phone-only setup never needs a file edited. Secrets are only ever referenced from there as `{{vault:NAME}}`.
### Opening the app on something
-The page takes a few query parameters, which the Android app uses and a bookmark can too: `?thread=` opens a conversation, `?tab=goals` (feed, ideas, library, connections) a tab; `&draft=` puts text in the composer without sending it; `&attach=` — the JSON list of `{path, name, mime, size}` that `POST /api/files/upload` returned — adds files already in the workspace as attachment chips. They are read once and removed from the address bar. This is how **Share → nanoMuse** works on the phone: the app uploads the shared files (`POST /api/files/upload`), starts a thread (`POST /api/threads`) named after the subject or the first file, and opens it with the text as a draft and the files attached, waiting for what to do with them. On the phone the agent also has the phone's own capabilities as tools and the workspace appears in the Files app — [device.md](device.md).
+The page takes a few query parameters, which the Android app uses and a bookmark can too: `?thread=` opens a conversation, `?tab=goals` (feed, ideas, library, connections) a tab; `&draft=` puts text in the composer without sending it; `&attach=` (the JSON list of `{path, name, mime, size}` that `POST /api/files/upload` returned) adds files already in the workspace as attachment chips. They are read once and removed from the address bar. This is how **Share → nanoMuse** works on the phone: the app uploads the shared files (`POST /api/files/upload`), starts a thread (`POST /api/threads`) named after the subject or the first file, and opens it with the text as a draft and the files attached, waiting for what to do with them. On the phone the agent also has the phone's own capabilities as tools and the workspace appears in the Files app: [device.md](device.md).
## Background work
@@ -118,7 +118,7 @@ Meta Muse "does things on its own, but not too much". The *Proactivity* dial in
| Default | every interval | when there is real progress or something you would want to know |
| High | every ½ interval | always, including "still on track" |
-On each pass the service picks one active goal with a pending step, runs it in the main chat with the Sentinel in `auto` mode (explicit deny rules and dangerous-call warnings still apply — those turn into cards in the Feed), and lets the model decide whether the result is worth your attention. A pass with nothing to say begins its summary with `[quiet]`: the chat shows one muted line ("Checked on *goal* — nothing new", tap to expand), the Feed lists it as a one-liner, and nothing is badged. Anything else is a normal message from your nanoMuse.
+On each pass the service picks one active goal with a pending step, runs it in the main chat with the Sentinel in `auto` mode (explicit deny rules and dangerous-call warnings still apply; those turn into cards in the Feed), and lets the model decide whether the result is worth your attention. A pass with nothing to say begins its summary with `[quiet]`: the chat shows one muted line ("Checked on *goal*: nothing new", tap to expand), the Feed lists it as a one-liner, and nothing is badged. Anything else is a normal message from your nanoMuse.
*Quiet hours* ("22:00–08:00", server local time) hold background work; a pass that would fall inside the window runs when it ends. The *Upcoming* view and the Feed's *Next up* card show the level, the effective interval and the next pass time, or the end of the current quiet window.
@@ -126,7 +126,7 @@ Turn the dial to Off for goals that need your judgement at every step, or leave
## API
-Everything the app does goes through this API, so another front-end (a Telegram bot, a desktop widget) can drive the same agent. All requests need `Authorization: Bearer ` unless `server.auth = false`. (`?token=` is refused since 0.1.33 — a token in a URL lands in every access log on the way; a request that still sends one gets a 401 that says so, a socket an `error` frame with `code: "legacy_token"` and a 4401 close.) The bytes the app shows inline — `/api/files/*`, the browser frames — also open with a link the client signed with its token, `?exp=&sig=\n")[:32]>`: a logged link opens that one path for a few hours and nothing else.
+Everything the app does goes through this API, so another front-end (a Telegram bot, a desktop widget) can drive the same agent. All requests need `Authorization: Bearer ` unless `server.auth = false`. (`?token=` is refused since 0.1.33: a token in a URL lands in every access log on the way; a request that still sends one gets a 401 that says so, a socket an `error` frame with `code: "legacy_token"` and a 4401 close.) The bytes the app shows inline (`/api/files/*`, the browser frames) also open with a link the client signed with its token, `?exp=&sig=\n")[:32]>`: a logged link opens that one path for a few hours and nothing else.
| Method | Path | Purpose |
|---|---|---|
@@ -136,10 +136,10 @@ Everything the app does goes through this API, so another front-end (a Telegram
| PATCH / DELETE | `/api/threads/{id}` | rename / delete |
| POST | `/api/threads/{id}/clear` | clear the conversation |
| GET | `/api/threads/{id}/events?limit=&before=` | timeline events |
-| POST | `/api/threads/{id}/send` `{text, files?, language?}` | queue a message; returns immediately. `files`: workspace paths from the upload below, ten at most; text may be empty when there are files. `language` (since 0.1.42): the BCP-47 tag of the client's screens (`en`, `zh-CN`); the reply is written in that language unless *Reply language* fixes one. Without it the script of the message decides, as before |
+| POST | `/api/threads/{id}/send` `{text, files?, language?, via?}` | queue a message; returns immediately. `files`: workspace paths from the upload below, ten at most; text may be empty when there are files. `language` (since 1.0.0): the BCP-47 tag of the client's screens (`en`, `zh-CN`); the reply is written in that language unless *Reply language* fixes one. Without it the script of the message decides, as before. `via: "cloud"` is *Use nanoMuse Cloud this time*: this one turn runs on the account's recommended chat model under the account's key, the next is back on the configured model, and `[llm]` does not move; the user bubble, the reply and a failure of that turn carry `model_used`. `401 signed_out` when no account is signed in |
| POST | `/api/threads/{id}/stop` | stop the run in that chat: the queue is dropped, pending approval and question cards there expire, the conversation stays usable. `{ok: false}` when nothing was running |
| POST | `/api/files/upload?name=` (body: the bytes) | a file to attach: lands in `attachments//` under a safe version of `name`; returns `{path, name, size, kind, mime}` for `files`. 413 above `server.max_upload_mb` |
-| POST | `/api/approvals/{id}` `{approved, scope, reason}` | answer a card; `scope` is one of the card's `grant_options` (`once`, `task`, `session`, `24h`, `always`) |
+| POST | `/api/approvals/{id}` `{approved, scope, reason}` | answer a card; `scope` is one of the card's `grant_options` (`once`, `conversation`, `session`, `24h`, `always`; `task` is still read as `conversation`) |
| DELETE | `/api/approvals` | forget every granted permission |
| DELETE | `/api/approvals/grants/{key}` | revoke one permission (`key` as listed by `/api/activity`, e.g. `shell:git`) |
| GET / POST | `/api/goals[?status=&category=]` | list / create `{title, description, steps[], category, due (YYYY-MM-DD), check_in ("daily 08:00", "weekdays 07:30", "weekly mon 09:00", "monthly 1 09:00")}` |
@@ -155,22 +155,26 @@ Everything the app does goes through this API, so another front-end (a Telegram
| GET | `/api/activity` | audit tail, granted permissions (`grants[]` with `key`, `tool`, `target`, `scope`, `expires_at`), taint flag |
| GET | `/api/feed?limit=` | *While you were away*: `{id, ts, kind (background · artifact · approval · question), title, text, thread, thread_title, path}` newest first |
| GET | `/api/feed/posts` | the posts written for you: `{instructions, daily, time, generated_at, posts[{id, ts, title, body, area, prompt}], error?}` |
-| PUT | `/api/feed/instructions` `{instructions?, daily?, time?}` | what the posts should be about (≤ 2000 characters), and the daily routine — on or off, and when (`HH:MM`, 08:00 at first) |
+| PUT | `/api/feed/instructions` `{instructions?, daily?, time?}` | what the posts should be about (≤ 2000 characters), and the daily routine: on or off, and when (`HH:MM`, 08:00 at first) |
| POST | `/api/feed/posts/refresh` | write new posts now; `error` says why not when the model refused |
| DELETE | `/api/feed/posts/{id}` | remove one post |
-| GET | `/api/upcoming` | `{proactivity, proactive, interval_minutes, effective_interval_minutes, quiet_hours, quiet_until, next_pass_at, next_wake_at, queue[{goal_id, title, category, due, overdue, next_step, progress}], check_ins[{goal_id, title, at, cadence}], reminders[], triggers{items[], available{mail, event, hook}, mail_checked_at, mail_error, mail_poll_minutes}, busy}` — `next_wake_at` is the earliest moment anything scheduled is due (a reminder, a check-in, the next pass), for a host that can set an alarm ([local-runtime.md](local-runtime.md#waking-up)) |
-| POST | `/api/tick` | wake the scheduler now — it looks at what is due and runs it; `{ok, next_wake_at}`. The Android app calls it when its alarm fires |
+| GET | `/api/upcoming` | `{proactivity, proactive, interval_minutes, effective_interval_minutes, quiet_hours, quiet_until, next_pass_at, next_wake_at, queue[{goal_id, title, category, due, overdue, next_step, progress}], check_ins[{goal_id, title, at, cadence}], reminders[], triggers{items[], available{mail, event, hook}, mail_checked_at, mail_error, mail_poll_minutes}, busy}`: `next_wake_at` is the earliest moment anything scheduled is due (a reminder, a check-in, the next pass), for a host that can set an alarm ([local-runtime.md](local-runtime.md#waking-up)) |
+| POST | `/api/tick` | wake the scheduler now: it looks at what is due and runs it; `{ok, next_wake_at}`. The Android app calls it when its alarm fires |
| GET / POST | `/api/reminders[?all=1]` | active (or all recent) reminders / create `{text, at ("YYYY-MM-DD HH:MM") or repeat ("daily 08:00", …), kind (remind · task), thread}` |
| POST / DELETE | `/api/reminders/{id}/fire`, `/api/reminders/{id}` | deliver it now in its chat / cancel |
-| GET / POST | `/api/triggers` | triggers with which kinds have their connector / create `{kind (mail · event · hook), text, match, lead_minutes, thread}` — a hook comes back with its `url` |
+| GET / POST | `/api/triggers` | triggers with which kinds have their connector / create `{kind (mail · event · hook), text, match, lead_minutes, thread}`; a hook comes back with its `url` |
| POST / DELETE | `/api/triggers/{id}/fire`, `/api/triggers/{id}` | run it now with a sample occurrence / cancel |
-| POST | `/api/hooks/{id}?key=` | a trigger's webhook — no app token, the key is the credential (also accepted as `X-Hook-Key`); the body (≤ 64 KB, JSON pretty-printed) is the context. 404 for a wrong id or key, 409 once cancelled, 429 when deliveries come too close |
+| POST | `/api/hooks/{id}?key=` | a trigger's webhook: no app token, the key is the credential (also accepted as `X-Hook-Key`); the body (≤ 64 KB, JSON pretty-printed) is the context. 404 for a wrong id or key, 409 once cancelled, 429 when deliveries come too close |
| GET | `/api/files` · `/api/files/{path}?download=1` | list / read workspace files (HTML and SVG are served with `Content-Security-Policy: sandbox`) |
| GET / PUT | `/api/settings` | view / change `{profile{name (≤ 20), avatar, emoji, color, tagline (≤ 60), tone, communication, style, user_name, proactive, goal_interval_minutes}, sentinel_mode, show_thinking, language}`; the view includes `onboarded` and `llm_ready` |
-| GET | `/api/connections` | model (`key_source`: vault · config · missing · none), provider presets, email, browser, MCP servers (`connected`, `tools`, `from_app`), vault names, `onboarded` |
-| PUT | `/api/connections/llm` `{provider, model, base_url, tool_mode, api_key}` | change the model; `api_key` set → stored in the vault, `""` → no key, omitted → unchanged. Takes effect immediately in every thread |
+| GET | `/api/connections` | model (`key_source`: vault · config · missing · none; `endpoint` when it is one of the list), provider presets, `endpoints` (the custom OpenAI-compatible endpoints of the list, each `{id, label, base_url, key_source, models, slots}`, never a key), email, browser, MCP servers (`connected`, `tools`, `from_app`), vault names, `onboarded` |
+| PUT | `/api/connections/llm` `{provider, model, base_url, tool_mode, api_key}` or `{endpoint, model, tool_mode}` | change the model; `api_key` set → stored in the vault, `""` → no key, omitted → unchanged. `endpoint` names one of the list, which lends the slot its address and its own key by reference. Takes effect immediately in every thread |
| POST | `/api/connections/llm/test` | one short round trip to the model: `{ok, reply, ms}` or `{ok: false, error}` |
-| POST | `/api/llm/models` `{preset, base_url, api_key}` | the models an endpoint offers: its `/models` (then `/v1/models`) with the given key — or, without one, the key in the vault — `{models, source: "live"}`; when it cannot be reached, the preset's catalogue with `source: "catalogue"` and `error`. Saves nothing |
+| POST | `/api/llm/models` `{preset, base_url, api_key}` or `{endpoint}` | the models an endpoint offers: its `/models` (then `/v1/models`) with the given key (or, without one, the key in the vault), returning `{models, source: "live"}`; when it cannot be reached, the preset's catalogue with `source: "catalogue"` and `error`, or, for an `endpoint` of the list, the models saved with it. Saves nothing |
+| POST | `/api/connections/endpoints` `{label, base_url, api_key, models}` | add a custom OpenAI-compatible endpoint to the list (#294): `label` optional (the host otherwise), `api_key` optional (vault: `ENDPOINT__API_KEY`), `models` the ids it serves, comma-separated, when it does not list them (400 with a sentence when neither is there). Answers the entry; the address of nanoMuse Cloud or of a catalogue preset is refused |
+| PUT | `/api/connections/endpoints/{id}` `{label, base_url, api_key, models}` | change an endpoint: a new name, address, key (`""` removes it) or typed models; omitted fields stay. Every slot that uses the endpoint follows. 404 for an unknown id |
+| DELETE | `/api/connections/endpoints/{id}` | remove an endpoint and its vault key; hands, pictures or clips set on it go back to automatic. 409 while the chat model answers through it |
+| POST | `/api/connections/endpoints/{id}/models` | ask the endpoint for its models again and save them: the entry plus `source: "live"`, or `"kept"` with `error` when it did not answer and the saved list stands |
| PUT | `/api/connections/embeddings` `{mode, model, base_url, api_key}` | recall by meaning: `mode` auto/on/off; `base_url` `""` → the model's endpoint; `model` `""` → the endpoint's default; `api_key` set → vault (`EMBEDDINGS_API_KEY`), `""` → the model's key, omitted → unchanged. Rebuilds the embedder on the spot |
| POST | `/api/connections/embeddings/test` | one embeddings call, then every memory indexed: `{ok, model, dims, indexed, ms}` or `{ok: false, error}` |
| PUT | `/api/connections/search` `{provider, api_key, base_url}` | web search: `provider` duckduckgo/brave/tavily/searxng; `api_key` set → vault (`SEARCH_API_KEY`), `""` removes it, omitted → unchanged; `base_url` the SearXNG instance. Applies to the next search |
@@ -178,58 +182,84 @@ Everything the app does goes through this API, so another front-end (a Telegram
| PUT / DELETE | `/api/connections/email` | save `{address, password, imap_host, imap_port, smtp_host, smtp_port, smtp_starttls, enabled}` (address and password go to the vault) / disconnect and forget the credentials |
| POST | `/api/connections/email/test` | sign in to IMAP and SMTP: `{ok, inbox}` or `{ok: false, error}` |
| PUT | `/api/connections/calendar` `{enabled, day_start, day_end, refresh_minutes}` | working hours and refresh; 400 for a malformed time |
-| POST · DELETE | `/api/connections/calendar/feeds` `{name, url}` · `/api/connections/calendar/feeds/{name}` | add or replace a feed (the link goes to the vault as `CALENDAR_`; read at once — `error` says why not) / remove one added from the app |
+| POST · DELETE | `/api/connections/calendar/feeds` `{name, url}` · `/api/connections/calendar/feeds/{name}` | add or replace a feed (the link goes to the vault as `CALENDAR_`; read at once; `error` says why not) / remove one added from the app |
| POST | `/api/connections/calendar/test` | re-read every feed: `{ok, events, feeds}` or `{ok: false, error}` |
-| GET | `/api/calendar` | `{configured, today, events[{uid, summary, start, end, all_day, location, description, calendar}], feeds[], fetched_at, stale}` — today's and tomorrow's events |
+| GET | `/api/calendar` | `{configured, today, events[{uid, summary, start, end, all_day, location, description, calendar}], feeds[], fetched_at, stale}`: today's and tomorrow's events |
| PUT | `/api/connections/contacts` `{enabled}` | turn the contacts tool on or off |
-| POST · DELETE | `/api/connections/contacts/sources` `{name, url}` · `/api/connections/contacts/sources/{name}` | add or replace an address book — a `.vcf` path, or a link kept in the vault as `CONTACTS_` (read at once; `error` says why not) / remove one added from the app |
-| POST | `/api/connections/contacts/import?name=` | upload a `.vcf` (the request body is the file) — kept under `/contacts/` and added as a source |
+| POST · DELETE | `/api/connections/contacts/sources` `{name, url}` · `/api/connections/contacts/sources/{name}` | add or replace an address book: a `.vcf` path, or a link kept in the vault as `CONTACTS_` (read at once; `error` says why not) / remove one added from the app |
+| POST | `/api/connections/contacts/import?name=` | upload a `.vcf` (the request body is the file), kept under `/contacts/` and added as a source |
| POST | `/api/connections/contacts/test` | re-read every book: `{ok, contacts, sources}` or `{ok: false, error}` |
| GET | `/api/contacts?q=&limit=` | look people up as the agent does: `[{id, name, emails[], phones[], org, …}]` |
| PUT | `/api/connections/browser` `{enabled}` | turn the browser tool on or off |
-| PUT | `/api/connections/gui` `{enabled, provider, model, base_url, api_key}` | operating the phone ([gui.md](gui.md)): the switch adds or removes the phone tools at once; the model fields are the operator's own model (`""` → the main model's); `api_key` set → vault (`GUI_API_KEY`), `""` → the main key, omitted → unchanged |
+| PUT | `/api/connections/gui` `{enabled, provider, model, base_url, api_key}` | operating the phone ([gui.md](gui.md)): the switch adds or removes the phone tools at once; the model fields are the operator's own model (`""` → the main model's); `api_key` set → vault (`GUI_API_KEY`), `""` → the main key, omitted → unchanged. `endpoint` instead of `provider` / `base_url` / `api_key` names an endpoint of the list, as for the chat model; so do `PUT /api/connections/image` and `/video` |
| POST | `/api/connections/gui/test` | one short round trip to the operator's model: `{ok, reply, ms}` or `{ok: false, error}` |
-| GET | `/api/phone` | `{connected, device{id, name, platform, gui, apps, width, height}, gui_enabled, last_screen, screen}` — which phone is connected and the last screen it sent |
+| GET | `/api/phone` | `{connected, device{id, name, platform, gui, apps, width, height}, gui_enabled, last_screen, screen}`: which phone is connected and the last screen it sent |
+| GET | `/api/hands` | this computer's own screen and hands ([gui.md](gui.md)): `{enabled, available, backend, reason, mode, coords, screen_size, picture_size, window{available, reason, active, app, title, frame}, device, task_active, task_text, last_screen}`; on a phone `{enabled: false, available: false, reason}` |
+| PUT | `/api/connections/hands` `{enabled, backend (auto · desktop · pyautogui · xdotool), mode (auto · screen · window)}` | the switch for this computer's hands and which backend drives them; omitted fields stay; 400 for an unknown backend or mode. Answers the view above |
+| POST | `/api/hands/stop` | *Stop* on the Hands card: the task on this computer's screen ends at its next step; `{stopped}` |
| POST · DELETE | `/api/connections/mcp` `{name, command, args[], env{}, url, risk}` · `/api/connections/mcp/{name}` | connect a server now (502 if it does not come up) / disconnect and remove one added from the app |
| GET | `/api/skills` | `{count, built_in, yours, dir, errors{}, skills[{name, description, source (built-in · yours), enabled, path, files[], metadata{}, channel, updated_at}]}` |
-| GET / PUT / DELETE | `/api/skills/{name}` | one skill with `body` and `content` (the whole `SKILL.md`) / write it from `{content}` — a built-in name creates your copy / delete one of yours |
+| GET / PUT / DELETE | `/api/skills/{name}` | one skill with `body` and `content` (the whole `SKILL.md`) / write it from `{content}` (a built-in name creates your copy) / delete one of yours |
| POST | `/api/skills/{name}/enabled` `{enabled}` | switch a skill on or off (remembered in `app-settings.json`) |
-| POST | `/api/skills/import` `{url}` | fetch a `SKILL.md` — a raw link, or a GitHub folder or file page — and save it |
+| POST | `/api/skills/import` `{url}` | fetch a `SKILL.md` (a raw link, or a GitHub folder or file page) and save it |
| GET · PUT · DELETE | `/api/vault` · `/api/vault/{name}` `{value}` | list secret names / store / delete. Values are never returned |
| POST | `/api/onboarded` `{done}` | mark first-run setup as finished; with a model configured the feed's first day is written in the background right then (`feed_started`) |
-| GET | `/api/nudges` (`?refresh=1`) | when the app may ask for a star on GitHub: the relay's policy (`/v1/nudges`, read once a day and on sign-in) over the built-in defaults — `{star{enabled, url, moments{signed_in, tasks[], new_look, exhausted, days_used[], goal_done}, cooldown_days, max_asks}, version, source}` |
-| GET | `/api/update` (`?refresh=1`) | `{current, enabled, latest, newer, url, download_url, checked_at, source (github · mirror), error}` — the newest release from this fork's GitHub `releases/latest`, then a configured mirror index only when one is set; a day's cache |
-| GET / PUT | `/api/sync/state` · `{enabled}` | conversations synced between the account's devices ([every-device.md](every-device.md#the-same-conversations-everywhere)): `{enabled, available, paused, cursor, last_pull_at, last_push_at, error, relay}` — `available` is "signed in and the setting allows it", `paused` is set after a 401 until the next sign-in, `relay` is the relay's own `{enabled, cursor, counts{conversations, messages}, limits}` or `null` when it could not be asked. `PUT {enabled: false}` turns it off here and deletes what the relay holds for the account (the other devices' switches follow at their next push or pull, which the relay refuses with `sync_off`); `PUT {enabled: true}` turns it on again and pushes the chats here. A relay refusal comes back as its status and code (`409 sync_off`) |
+| GET | `/api/firstrun` (`?lang=`) | the first conversation ([web.md](web.md#the-first-run-and-the-chat-s-opening)): `{phase (none · ask_user_name · ask_agent_name · named · done), session_id, user_address, suggestions, chips, chosen, lang, running, started_at, finished_at, intro[]}`; `chips` is what the chooser offers, `intro` the three opening lines in `lang` |
+| POST | `/api/firstrun/start` `{lang}` | *Start*: binds the conversation to the main chat, marks setup finished, moves `none → ask_user_name`; with a conversation already there the ritual is skipped (`done`). Answers the view with `intro` |
+| POST | `/api/firstrun/pick` `{name}` | a chip was picked while `ask_agent_name`: the name is saved to the profile, the phase is `named`; 409 when no pick is due, 400 for an empty name |
+| POST | `/api/firstrun/dismiss` | the chooser goes and the ritual is over (`done`) |
+| GET | `/api/nudges` (`?refresh=1`) | when the app may ask for a star on GitHub: the relay's policy (`/v1/nudges`, read once a day and on sign-in) over the built-in defaults, as `{star{enabled, url, moments{signed_in, tasks[], new_look, exhausted, days_used[], goal_done}, cooldown_days, max_asks, text, text_zh}, version, source}`; `text` / `text_zh` is the sentence the star card shows when the relay sets one (200 characters at most, else the app's own words) |
+| GET | `/api/update` (`?refresh=1`) | `{current, enabled, latest, newer, url, download_url, checked_at, source (github · mirror), error}`: the newest release from GitHub's `releases/latest`, then a configured mirror index; a day's cache |
+| GET / PUT | `/api/sync/state` · `{enabled}` | conversations synced between the account's devices ([every-device.md](every-device.md#the-same-conversations-everywhere)): `{enabled, available, paused, cursor, last_pull_at, last_push_at, error, relay}`; `available` is "signed in and the setting allows it", `paused` is set after a 401 until the next sign-in, `relay` is the relay's own `{enabled, cursor, counts{conversations, messages}, limits}` or `null` when it could not be asked. `PUT {enabled: false}` turns it off here and deletes what the relay holds for the account (the other devices' switches follow at their next push or pull, which the relay refuses with `sync_off`); `PUT {enabled: true}` turns it on again and pushes the chats here. A relay refusal comes back as its status and code (`409 sync_off`). Since 0.1.38 the state also carries `side_chats` (this device's *Also sync side chats*, off by default: only the main conversation is pushed and pulled) and `working` (`[{thread, cid, device, device_name, at}]`: the other devices answering right now, from the hub's `working` frames, each entry dropped after ten minutes); `PUT {side_chats: true}` sends this device's side chats up and pulls the account's once from the start, `false` keeps them local from then on |
| POST | `/api/sync/delete` | delete every synced conversation on the relay, switch left as it is; returns the state |
| POST | `/api/sync/pull` | fetch and apply the other devices' changes now: `{applied, …state}` |
+| GET | `/api/cloud` | the nanoMuse Cloud account as the app shows it: `{base_url, signed_in, required, models, hint, channel, signed_in_at, has_password, account_id, member, any_model, region, is_model}` ([cloud.md](cloud.md)). Every `/api/cloud/*` route below answers a relay refusal with the relay's status, its sentence, and the code in the `X-Nanomuse-Code` header; 401 `bad_key` when no account is signed in |
+| GET | `/api/cloud/config` | the relay's public figures (the allowance a new account gets, the invite bonus, whether sign-up is open, the repository to star); `{}` from an older relay or none |
+| POST | `/api/cloud/code` `{identifier}` | send a sign-in code to an e-mail address or a phone number; `{ok}` |
+| POST | `/api/cloud/verify` `{identifier, code, invite?}` | sign in with the code (an invite code on a first sign-up); answers the relay's `/v1/me` and refreshes the nudges policy |
+| POST | `/api/cloud/login` `{identifier, password}` | sign in with the account's password; answers as `verify` does |
+| POST | `/api/cloud/password` `{password, current?}` | set or change the password (`current` when one is set, unless the key came from a code sign-in just now; an empty `password` with `current` removes it) |
+| GET | `/api/cloud/me` | the account as the relay sees it now (`/v1/me`), nudges policy refreshed on the way |
+| GET · DELETE | `/api/cloud/sessions` · `/api/cloud/sessions/{prefix}` | the account's sign-ins on every device / revoke one by the prefix the list shows; both answer `{sessions}` |
+| POST | `/api/cloud/sign-out` · `/api/cloud/sign-out-all` `{all}` | sign this device out / revoke every other device's sign-in (`all: true` this one too, `{signed_out}` with the account view) |
+| GET | `/api/cloud/events?limit=` | the account's activity log from the relay: `{events}` |
+| POST | `/api/cloud/models` `{on}` | *Use nanoMuse Cloud models*: off, the account's models leave the automatic order of the hands, pictures and clips and the providers' listing; the sign-in stays. 409 `chat_on_cloud` while the chat model is the account's |
+| POST | `/api/cloud/use-as-model` `{model?}` | make the account the chat model: the recommended model unless one is named, the Cloud key from the vault; switches the account's models back on. Answers the connections view's `llm` |
+| POST · DELETE | `/api/cloud/contribute` `{on}` · `/api/cloud/samples` | data controls: *Help improve nanoMuse's AI models* on or off (the relay keeps chat turns only while it is on) / delete what it kept, `{deleted}` |
+| POST | `/api/cloud/delete` | delete the account on the relay and sign out here; answers the account view |
+| GET / PUT | `/api/hub` · `{enabled, remote_control, name}` | this device on the account's hub ([hub.md](hub.md)): `{enabled, remote_control, state, detail, device{id, name, kind, actions}, devices[], account}` / join or leave, allow or refuse remote control, rename this device (60 characters at most) |
+| POST | `/api/hub/join` · `/api/hub/leave` · `/api/hub/refresh` | join (401 when no account is signed in) / leave / ask the relay for the device list again; each answers the hub view |
+| DELETE | `/api/hub/devices/{device_id}` | forget a device of the account; 502 when the hub is not connected |
+| POST | `/api/hub/ask` `{device, text?}` | the side chat addressed to a device (by id or name; created if needed) and, with `text`, the first message in it, which runs on that device: `{thread, event}`; 404 for no such device |
+| GET | `/api/bridge/tools` | for a command the agent runs, not the app: the device tools `nanomuse-device list` prints, `{ok, tools[{name, description}]}`. No app token; the call token the shell tool minted comes in `X-Nanomuse-Bridge` (401 otherwise), as for `POST /api/bridge/{kind}` ([local-runtime.md](local-runtime.md)) |
| GET | `/api/browser/{thread}/frames/{id}.jpg` | a browser frame (JPEG, in memory for the current session) |
| POST | `/api/browser/{thread}/control` `{action: click(x,y as 0–1 fractions) · type(text) · key(key) · scroll(dy) · navigate(url) · look · handed_back}` | you drive the agent's browser; `handed_back` is what the Android app sends when you close its take-over sheet (a fresh frame, the agent told); `409` when the browser is not open (open a URL first) |
-| GET | `/api/push` | `{available, public_key, subscriptions, devices[]}` — the VAPID public key to subscribe with |
+| GET | `/api/push` | `{available, public_key, subscriptions, devices[]}`: the VAPID public key to subscribe with |
| POST | `/api/push/subscribe` `{subscription}`, `/api/push/unsubscribe` `{endpoint}` | register / drop this device's `PushSubscription` |
| POST | `/api/push/test` | send a test notification to every subscribed device |
| WS | `/ws` | live events; the first frame is `{"kind": "auth", "token": …}` |
### WebSocket
-The client's first frame is `{"kind": "auth", "token": "…"}` (within ten seconds, or the socket closes with 4401 — as it does for a wrong token). Then the server sends `{"kind": "hello", "state": …}` (the same payload as `/api/state`). Then:
+The client's first frame is `{"kind": "auth", "token": "…"}` (within ten seconds, or the socket closes with 4401, as it does for a wrong token). Then the server sends `{"kind": "hello", "state": …}` (the same payload as `/api/state`). Then:
| Server → client | Meaning |
|---|---|
-| `event` | a new timeline event (`user`, `assistant`, `tool`, `approval`, `question`, `artifact`, `browser`, `notice`). An `approval` carries `summary`, `purpose` (what you asked for), `target`, `grant_key`, `grant_options`, `risk`, `warnings`, `args`. A `browser` card carries `url`, `title`, `action`, `frame` (id of the latest picture), `frames`, `status` (`live` / `done`), `by_user`, `backend` (`playwright` or `device`) and the frame's `width` / `height` in CSS pixels. Events produced during a background pass carry `source: "background"` and `about` (the pass label). The `assistant` bubble that ends a run carries `final: true` (set on emit, or as an `update` when the bubble was already on screen); the step-by-step narration before it does not — a client that mirrors background results into notifications should key off that flag |
+| `event` | a new timeline event (`user`, `assistant`, `tool`, `approval`, `question`, `artifact`, `browser`, `notice`). An `approval` carries `summary`, `purpose` (what you asked for), `target`, `grant_key`, `grant_options`, `risk`, `warnings`, `args`. A `browser` card carries `url`, `title`, `action`, `frame` (id of the latest picture), `frames`, `status` (`live` / `done`), `by_user`, `backend` (`playwright` or `device`) and the frame's `width` / `height` in CSS pixels. Events produced during a background pass carry `source: "background"` and `about` (the pass label). The `assistant` bubble that ends a run carries `final: true` (set on emit, or as an `update` when the bubble was already on screen); the step-by-step narration before it does not; a client that mirrors background results into notifications should key off that flag |
| `update` | fields changed on an existing event (a tool finished, an approval was decided, a browser card got a new frame) |
-| `event_removed` | `{thread, id}` — an event left the timeline: a message deleted on another device of the account (conversation sync) |
-| `working` | `{thread, cid, device, device_name, working, at}` — another device of the account started (`true`) or finished (`false`) a turn in a synced conversation; the app shows *{device} is working…* under the last message while `true`, and drops it when the reply arrives or after ten minutes. The `hello` state lists the live ones under `working` |
+| `event_removed` | `{thread, id}`; an event left the timeline: a message deleted on another device of the account (conversation sync) |
+| `working` | `{thread, cid, device, device_name, working, at}`: another device of the account started (`true`) or finished (`false`) a turn in a synced conversation; the app shows *{device} is working…* under the last message while `true`, and drops it when the reply arrives or after ten minutes. The `hello` state lists the live ones under `working` |
| `stream_start` / `delta` / `stream_end` | the assistant reply being generated; `stream_end` carries `discard: true` when what streamed turned out not to be a reply (a prompt-mode tool call, a quiet background pass) |
| `status` | idle / working / waiting, with a short detail line |
| `thread`, `thread_cleared`, `thread_deleted` | thread list changes |
-| `firstrun` | `{firstrun}` — the first conversation moved on (*Start*, the model's `nanomuse-naming` block, a pick, a dismissal): the same view as `GET /api/firstrun` without `intro`; the chat redraws its chooser. The `hello` state carries it under `firstrun` |
+| `firstrun` | `{firstrun}`; the first conversation moved on (*Start*, the model's `nanomuse-naming` block, a pick, a dismissal): the same view as `GET /api/firstrun` without `intro`; the chat redraws its chooser. The `hello` state carries it under `firstrun` |
| `goals`, `memory`, `ideas`, `feed_posts`, `profile`, `settings`, `connections`, `skills`, `approvals_reset` | refresh hints for the tabs |
| `phone` | a phone connected or left: the `/api/phone` view |
| `schedule` | `next_wake_at` changed (a reminder was set or fired, a goal's cadence moved, the quiet hours ended): `{next_wake_at}` or `null` when nothing is due. The Android runtime re-arms its alarm from it |
| `device_ack`, `device_request` | to a connected phone: the answer to its announcement, and a request for its screen or an action ([gui.md](gui.md#the-device-protocol)) |
| `error`, `pong` | replies to client messages |
-Client → server: `{"kind": "send", "thread": "main", "text": "…", "language": "en"}` (`language` optional, as on `POST /api/threads/{id}/send`), `{"kind": "approval", "id": "…", "approved": true, "scope": "once"}`, `{"kind": "ping"}`. A phone that lets the agent operate it also sends `{"kind": "device", …}` once and `{"kind": "device_result", …}` in answer to each request.
+Client → server: `{"kind": "send", "thread": "main", "text": "…", "language": "en", "via": ""}` (`language` and `via` optional, as on `POST /api/threads/{id}/send`; a refused `via: "cloud"` comes back as `{"kind": "error", "error": "…", "code": "signed_out"}`), `{"kind": "approval", "id": "…", "approved": true, "scope": "once"}`, `{"kind": "ping"}`. A phone that lets the agent operate it also sends `{"kind": "device", …}` once and `{"kind": "device_result", …}` in answer to each request.
Timeline events are persisted per thread in `/threads/.json`, so the history survives restarts.
diff --git a/docs/avatar.md b/docs/avatar.md
index 26e2202a..7cbd8ae1 100644
--- a/docs/avatar.md
+++ b/docs/avatar.md
@@ -58,3 +58,27 @@ With a nanoMuse Cloud account the face follows you to every device ([docs/cloud.
the stills go to the relay once, the other devices wear them from `avatar/sync-/`
with the description and style in their `face.json`, and a device keeps the face it drew,
clips and all, when the account's pictures are the ones it already wears.
+
+## Your own clip
+
+A short video of your own can take the place of the clips. On the phone, pick one under the
+pen on the face in the profile (*Your own clip…*) or in the *Your own clip* section of the
+avatar studio; on the desktop, in the avatar studio or the profile drawer. The clip plays
+muted in a loop wherever the face moves: for the mood at rest, and for every other mood too,
+since there is only the one clip. Where a still is shown (under the clip until its first frame
+is on screen, in the share cards, in the tiles) the stills of a studio face keep their place,
+and with no studio face the clip's first frame is the still. Before anything changes, a
+preview plays the clip in the face's own round frame, with *Use* and *Cancel*; *Default face*
+removes the clip, and the studio face or the dragon shows again.
+
+The limits: MP4 or MOV with H.264 video (on iOS also HEVC, which the system decodes), at most
+15 seconds and 30 MB. A longer or larger clip, or another format, is refused with a sentence
+that states the limits; nothing is trimmed or re-encoded, and the sound is never played.
+
+The clip stays on the device that chose it. It is not uploaded to nanoMuse Cloud and does not
+sync to your other devices, unlike the stills of a studio face; each device chooses its own.
+On Android it lives under `minis-global/nanomuse/avatar/own-clip.mp4` with its first frame
+beside it as `own-clip.png`, filed with the signed-in account's other files; on iOS under the
+app's `Application Support/nanomuse/avatar/own-clip.mp4`; on the desktop under
+`$DSH_HOME/nanomuse/avatar/own-clip.mp4`. It survives a new studio face and a reset to the
+built-in one.
diff --git a/docs/brand.md b/docs/brand.md
index c0b07d60..c27fa326 100644
--- a/docs/brand.md
+++ b/docs/brand.md
@@ -45,23 +45,31 @@ Measured from the icon.
| Token | Value | Contrast | Use |
|---|---|---|---|
-| `primary` | `#015CFB` | 5.35 on white | links, primary buttons (white text), active tab, send button, status line |
-| `primary.container` | `#E5F0FF` | — | user bubble, selected rows, chips |
+| `primary` | `#015CFB` | 5.35 on white | links, primary buttons (white text), status line |
+| `primary.container` | `#E5F0FF` | — | selected rows, chips |
+| `ink` | `#000000` | 21 on white | your message pill (white text), the send button and the scroll-to-latest disc (white arrow), the selected tab's disc |
+| `pill` | `#F2F2F7` | | the agent's message pill |
| `primary.tint` | `#EEF5FF` | — | subtle highlighted surfaces |
-| `bg.grouped` | `#F2F2F7` | — | page background (unchanged from OpenMinis) |
+| `bg` | `#FFFFFF` | | the chat, the pill bar, the menus and the drawer |
+| `bg.grouped` | `#F2F2F7` | — | the settings pages (unchanged from OpenMinis) |
| `surface` | `#FFFFFF` | — | cards, input |
### Dark theme
| Token | Value | Contrast | Use |
|---|---|---|---|
-| `primary` | `#58A6FF` | 8.3 on `#000`, 6.7 on `#1C1C1E` | links, buttons (black text), active tab, status line |
-| `primary.container` | `#1A2B4A` | — | user bubble |
+| `primary` | `#58A6FF` | 8.3 on `#000`, 6.7 on `#1C1C1E` | links, buttons (black text), status line |
+| `primary.container` | `#1A2B4A` | — | selected rows, chips |
+| `ink` | `#FFFFFF` | 21 on `#000` | your message pill (black text), the send button and the scroll-to-latest disc (black arrow), the selected tab's disc |
+| `pill` | `#1C1C1E` | | the agent's message pill |
| `bg` / `surface` / `surface.2` | `#000000` / `#1C1C1E` / `#2C2C2E` | — | unchanged from OpenMinis |
Rules: one accent hue only. Code blocks, thinking text and links that OpenMinis coloured with
-iOS blue, orange and green become `primary` plus neutrals. Rust orange from the red panda stays
-inside the avatar; it is not a UI colour.
+iOS blue, orange and green become `primary` plus neutrals. The conversation itself is
+monochrome: your pill, the send button and the selected tab are the ink colour on the
+canvas, and the blue is left to links and the status line (`ChatColors.kt`, written by
+`scripts/rebrand.py`). The dragon's own colours stay inside the avatar; they are not UI
+colours.
## Android mapping (done in S1)
diff --git a/docs/browser.md b/docs/browser.md
index 3f64b620..439700cf 100644
--- a/docs/browser.md
+++ b/docs/browser.md
@@ -1,5 +1,12 @@
# The browser
+> **Partly a design record of the Python line.** The Playwright backend, the actions, the
+> logged-in `fetch` and the testing below are the Python runtime's and still hold on a
+> computer. The phone half (the `device` backend, the virtual display, the protocol under
+> *How the phone's browser works*) belonged to the Python-line Android app; in the current
+> Android app the browser is OpenMinis's `browser_use` tool, and a page that needs you is
+> handed over through its `hand_over` action ([android.md](android.md#what-the-phone-does)).
+
The agent has one browser tool and two browsers to run it in: Chromium through Playwright on the computer the server runs on, and the phone's own WebView inside the nanoMuse app. The tool is the same either way (the same actions, the same numbered elements on the page, the same pictures in the chat, the same take-over), so a skill written against one works on the other, and a task can move from the desk to the phone without changing a line.
Where the browser sits in the order of things: the agent reaches a service through its API, MCP server or command-line tool first; then through a **logged-in `fetch`** (a request that carries the browser's cookies, no page rendered); then through the browser page itself; and only last through the screen of an app on the phone ([gui.md](gui.md)). The browser is rung two and three of that ladder.
diff --git a/docs/cli.md b/docs/cli.md
index c1358e09..c44df9c2 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -26,6 +26,8 @@ Interactive session in the terminal with approvals inline. Slash commands:
| `/tainted` | whether the conversation has read private data |
| `/permissions` | standing permissions you granted (key and lifetime) |
| `/revoke ` | take one back, e.g. `/revoke shell:git` |
+| `/forget-approvals` | clear every granted permission |
+| `/exit` | quit (also `/quit`, `/q`) |
`--resume` continues the most recent session (`/sessions/`).
diff --git a/docs/cloud.md b/docs/cloud.md
index bf17aeb2..61ced8af 100644
--- a/docs/cloud.md
+++ b/docs/cloud.md
@@ -326,7 +326,11 @@ the card from `guidance` and falls back to the two links on an older relay.
Other relays may set other rules
(`ALLOWANCE_CNY`, `INVITE_BONUS_CNY`, `SIGNUP_OPEN`, `ALLOWED_IDENTIFIERS` —
all three figures adjustable while the relay runs, relay 0.15; see
-[`cloud/README.md`](../cloud/README.md)).
+[`cloud/README.md`](../cloud/README.md)). The operator can see who invited
+whom: the admin page's *Invites* view ranks the inviters by the accounts their
+code brought, with how many of those ever made a request and how many were
+seen from one of the inviter's own addresses, and an account's page names its
+inviter and lists the people it brought (`GET /v1/admin/invites`).
nanoMuse asks for one thing in return: a star on
[GitHub](https://github.com/zeeshanhaque21/nanoMuse), which is what helps the
diff --git a/docs/configuration.md b/docs/configuration.md
index 2a624635..4ad97c8b 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -82,7 +82,7 @@ Provider recipes:
| vLLM / LM Studio | your served name | `http://localhost:8000/v1` | set `tool_mode = "prompt"` if the server ignores `tools` |
| Company gateway | as required | as required | use `extra_headers` / `extra_body`; pick the provider by the API shape the gateway speaks |
-The same presets are offered in the web console (*Connections → Chat model*, and on first run), each with a link to where its key comes from; the exact hostnames live in `PROVIDERS` in `nanomuse/server/connections.py`. A `base_url` saved from the app that has no path gains `/v1` (`http://host:8000` → `http://host:8000/v1`); the presets' own hosts are kept as they are. Ollama and custom endpoints may have no key.
+The same presets are offered in the web console (*Connections → Chat model*, and on first run), each with a link to where its key comes from; the exact hostnames live in `PROVIDERS` in `nanomuse/server/connections.py`. A `base_url` saved from the app that has no path gains `/v1` (`http://host:8000` → `http://host:8000/v1`); the presets' own hosts are kept as they are. Ollama and custom endpoints may have no key. Custom endpoints added from the web console are a list (`endpoints` in `app-settings.json`, each `{id, label, base_url, api_key, models}` with the key in the vault as `ENDPOINT__API_KEY`); a slot set on one of them carries its address and that vault reference, so this file never needs to know about the list ([web.md](web.md#where-it-shows)). A `[llm]` chat model set here on an unlisted host stays as it is; only one set from the app is moved into the list.
`provider` may also be an id from the provider catalogue, [`nanomuse/llm/providers.json`](../nanomuse/llm/providers.json) (`bailian`, `deepseek`, `moonshot`, `zhipu`, `openrouter`, `openai`, `gemini`, …), in which case `base_url` and, when empty, `model` fill in from the catalogue and only `api_key` is yours. The catalogue also says what each provider's key covers (chat, the hands, pictures, clips); `GET /api/providers` on the server reports which slots use which provider and what that leaves uncovered, and the app shows one sentence for a feature nothing covers ([own-key.md](own-key.md)).
@@ -442,13 +442,13 @@ token = "" # empty → generated once, stored in the nanoMuse desktop on DeepSeek Harness takes from it, leaves out, or does its own
> way. Interface only: no account, no conversation, no screen content from the session
> this was taken from. Muse is Meta's trademark; nanoMuse is a community project with no
-> affiliation — see [the README](../README.md#disclaimer).
+> affiliation; see [the README](../README.md#disclaimer).
The desktop app here is **DeepSeek Harness plus the `dsh-nanomuse` bundle**
([harness.md](harness.md)). dsh brings the loop, the tools and a complete web app with
seats a plugin can take; the bundle takes the seats that make the window look and
behave like Muse, hides what Muse does not have, and adds what the phone app has that
-Muse does not — the account on every device, Reach, an open model layer.
+Muse does not: the account on every device, Reach, an open model layer.
## The window
@@ -29,15 +29,21 @@ stylesheet over the harness's stable DOM hooks (`data-composer-card`,
- The rail, in Muse's order: Chats (a dot while the agent works), Search, Feed (a dot
for posts you have not seen), Ideas, Goals, Library, Devices; a spacer; the hamburger
at the foot (Schedules and the harness's other panels live in its menu). No face on
- the rail — as in Muse, the face is the pinned header and the profile
+ the rail; as in Muse, the face is the pinned header and the profile
panel. On macOS the window has no title bar: the traffic lights sit over the rail's
empty top, which is a drag handle, and full screen takes the clearance away. Windows
gets the same frameless window with the system's own caption buttons drawn over the
top right (`titleBarOverlay`, recoloured with the theme); Linux keeps the window
- manager's bar, since there is no portable overlay there.
+ manager's bar, since there is no portable overlay there. On Windows the pages whose
+ top bar has buttons at its right end (Feed, Goals, Library and its file viewer, the
+ document editor, the profile panel, the first run, Plugins, Schedules, the right-hand
+ dock) leave the caption buttons' width free there, so those buttons sit to the left of
+ them; a task's details, a narrow column at the top right, moves its tabs down below the
+ caption buttons instead. Full screen and the split pane, whose bars start lower, get no
+ extra space.
- The chats column: a *Search* field with a *···* menu (archived chats), **Main chat**
- — one session that stays at the top, the first one or the one you chose with *Make
- main chat* — and **Side chats** with a *+*: every other session, pinned first, each
+ (one session that stays at the top, the first one or the one you chose with *Make
+ main chat*) and **Side chats** with a *+*: every other session, pinned first, each
with a *···* for *Make main chat*, *Pin*, *Rename* (inline), *Archive*. A blank
session reads *New chat*; a dot marks a running one, a mark one that waits for you.
It collapses to the rail alone. The column shows the account that is signed in: a
@@ -52,29 +58,35 @@ stylesheet over the harness's stable DOM hooks (`data-composer-card`,
while the agent works and while it waits for an answer. Clicking the face opens the
profile panel. At the top right, *Invite* (with an account), then the harness's
right-sidebar toggle; the harness's own title row, tabs (*Conversation* / *Trace*)
- and session actions are hidden — the chat rows carry rename, pin and archive.
-- The conversation: your messages in bubbles toned from the accent on the right, the
- agent's in large-radius grey bubbles on the left, timestamps and the per-message
- actions on hover only; an empty chat has no greeting — the composer waits at the
- bottom, as in Muse, with the workspace and preset row above it.
+ and session actions are hidden: the chat rows carry rename, pin and archive.
+- The conversation is monochrome, as on the phone: your messages are ink pills on the
+ right (black with white text in the light theme, white with black text in the dark
+ one), the agent's are large-radius grey pills a step off the canvas on the left,
+ timestamps and the per-message actions on hover only; a link inside your pill keeps
+ the pill's text colour, underlined. The header is a scrim over the conversation rather
+ than a bar: messages scroll under the face and dissolve into the canvas, and no line
+ runs under it. An empty chat has no greeting: the composer waits at the bottom, as in
+ Muse, with the workspace and preset row above it.
- The composer is one pill: *+* (attach), *Message*, the send disc (the stop square
- while a turn runs). The harness's model picker, permission mode and plan toggle are
+ while a turn runs); the send disc and the harness's scroll-to-latest disc are the same
+ ink as your pills, with the glyph in the opposite colour. The harness's model picker, permission mode and plan toggle are
hidden from it (the models live in Settings → Models, dsh's own Models page is under
Advanced, and the default permission mode in General, as in Muse) and come back with
*Show DeepSeek Harness controls* under General. The microphone appears when the harness's voice input is switched on in
Plugins (Settings → Dictation says how).
- Theme: the harness's light and dark palettes are overridden to Muse's tones (`#f9f9f9`
- surfaces, `#e3e4e6` agent bubbles in the light; `#171717` with `#242424` bubbles and
- `#2b2b2b` fields in the dark) and the accent follows the face's colour on the account,
- so a face coloured on the phone colours the desktop too. The desktop shell paints the
+ surfaces with a plain white chat canvas, `#000000` user pills and `#f2f2f7` agent pills
+ in the light; `#171717` with `#ffffff` user pills, `#242424` agent pills and `#2b2b2b`
+ fields in the dark) and the accent follows the face's colour on the account, so a face
+ coloured on the phone colours the desktop's lights and links too. The desktop shell paints the
window in the same base colour, so a resize never flashes white in the dark.
## The first run
-**Muse.** Sign in (a phone number, an SMS code — "sign in or create an account"), a
+**Muse.** Sign in (a phone number, an SMS code: "sign in or create an account"), a
loading page, then three full-window pages with ‹ › top right and a dots pager: *Allow
Muse to use your computer?* (accessibility for clicking and typing; screen recording
-for screenshots — each row with an *Allow* button that becomes a green check, fine
+for screenshots; each row with an *Allow* button that becomes a green check, fine
print, *Skip* under the card that turns into a blue *Continue* once both are allowed),
*Allow Muse to access your files and local apps?* (a *Full Disk Access* status pill, a
list of local apps each with a read-only/… dropdown, *Continue*), *Turn on voice input*
@@ -84,56 +96,59 @@ the chat, a card to pick its name, and a connector recommendation.
**nanoMuse on dsh.** The phone's pages (`FirstRunSetup` on Android), full-window, in
the harness's `settings.onboarding` seat (we take the shipped step's id, so a fresh
install meets the agent instead of being asked for a DeepSeek key). The host keeps the
-state in `$DSH_HOME/nanomuse/firstrun.json` (`src/firstrun.ts`) — *done* once Start was
-pressed, the permissions page seen, which source answers — so the pages are never skipped
-on a fresh install and never shown twice:
-
-1. **Welcome** — the face, *Welcome to nanoMuse*, the three feature rows (chat, hands,
- reach), the community notice, one blue *Sign in* pill and *Use my own API key*. Sign-in
+state in `$DSH_HOME/nanomuse/firstrun.json` (`src/firstrun.ts`): *done* once Start was
+pressed, the permissions page seen, which source answers. The pages are therefore never
+skipped on a fresh install and never shown twice:
+
+1. **Welcome**: the app's mark (the avatar comes with the conversation, on the Meet page),
+ *Welcome to nanoMuse*, the three feature rows (chat, hands, reach), the community notice,
+ one blue *Sign in* pill and *Use your own API key instead*, which goes to the models page
+ with no account (the sign-in can come later; *Skip for now* there with no key saved is
+ Welcome again). Sign-in
is the phone's: *Phone number or e-mail* with the line that a mainland China number
gets an SMS and anything else an e-mail, the code boxes that verify themselves on the
sixth digit, *Try another way* for a password (`/v1/auth/code`, `/v1/auth/verify`,
`/v1/auth/login`).
2. **Set a password** (a fresh account only; *Later* skips it).
-3. **Which model answers** — the Cloud account's models, or your own key.
-4. **Choose models** (own key only) — the providers of the catalogue
+3. **Which model answers**: the Cloud account's models, or your own key.
+4. **Choose models** (own key only): the providers of the catalogue
(`assets/providers.json`, the same list as the phones' and the web app's), the ones for
- where you are first — Alibaba Cloud Bailian in mainland China, one key for chat, the
- hands, pictures and clips; OpenRouter and then OpenAI elsewhere — each row saying what it
+ where you are first (Alibaba Cloud Bailian in mainland China, one key for chat, the
+ hands, pictures and clips; OpenRouter and then OpenAI elsewhere), each row saying what it
covers (*chat · screen operation · pictures · clips*), with *Get a key* opening the vendor's
key page and *Add key* taking the key right there (it goes to the credential store under
`NANOMUSE_KEY_`; `cloud.json` keeps the name only). Under OpenAI, **Sign in with
ChatGPT**: a Plus, Pro or Team plan signed in through OpenAI's page, which covers chat and
- the hands only — no pictures, no clips — with the one honest line that OpenAI's terms
+ the hands only (no pictures, no clips), with the one honest line that OpenAI's terms
cover using a ChatGPT plan inside OpenAI's own Codex and other apps have had this access
cut off before. *More ways* folds out the rest of the catalogue, the servers on this
computer (Ollama, LM Studio, vLLM) and any OpenAI-compatible endpoint by hand. *Skip for
now* until a key is saved or a sign-in finished; *Continue* then.
-5. **Allow nanoMuse to use your computer?** (macOS only) — *Accessibility* and *Screen
+5. **Allow nanoMuse to use your computer?** (macOS only): *Accessibility* and *Screen
Recording* rows, each with *Allow* that asks the system through the desktop shell and
turns into a green check as the system grants it (polled, and again when the window gets
- focus or the page becomes visible — one hook, `permissions.ts`, behind every place that
+ focus or the page becomes visible; one hook, `permissions.ts`, behind every place that
shows a permission); once *Allow* has been pressed the button reads *Open System
Settings*. Only **nanoMuse Desktop** has to be switched on: macOS attributes the bundled
runtime's captures and events to the app that started it, so there is no second entry to
hunt for. For *Screen Recording* the shell makes one capture attempt before it opens the
pane, so nanoMuse is on the pane's list, and when the permission is granted while the app
runs a dialog says macOS applies it only to freshly started apps, with *Restart now*
- (`relaunch` over the bridge). *Try it* rows — a test screenshot, a mouse move — and the
+ (`relaunch` over the bridge). *Try it* rows (a test screenshot, a mouse move) and the
runtime row say whether it works (the row names the binary and, when its last start for
the hands failed, why, in the host's words), and a black capture gets its own notice with
*Relaunch*.
On Linux and Windows the page is skipped.
-6. **Meet ** — the face, *Start*.
+6. **Meet **: the face, *Start*.
Start binds the **main chat** (the agent's own folder, `~/nanoMuse`, registered as a
workspace so the composer is live) as the **first conversation**, the phone's
-`FirstConversation`: the app speaks first — three scripted lines, no tokens, never in the
-model's history — and asks what to call you. From there the model does the talking and
+`FirstConversation`: the app speaks first (three scripted lines, no tokens, never in the
+model's history) and asks what to call you. From there the model does the talking and
reports what happened in a small `nanomuse-naming` block at the end of its reply (the
address, its name suggestions, the name it got); the chat shows the suggestions as the
naming card (*NamingCard*, a tap or a typed name), and the phase moves on that block and
-on nothing else — a reply without one means the person talked about something else, the
+on nothing else: a reply without one means the person talked about something else, the
model helped and steered back. When the agent has its name the first run is over: the
Feed's first day is written, the daily routine is set.
@@ -147,25 +162,25 @@ pen (*change avatar* / *edit name*), tabs for the profile, the approval log (wha
allowed, when, "allowed for this task" / "always allowed"), activity, more. A new face
is drawn in the chat: four candidates in a grid, pick one, the agent confirms in words.
-**nanoMuse on dsh.** The face at the top opens the agent's page — the phone's
+**nanoMuse on dsh.** The face at the top opens the agent's page, the phone's
`AgentProfileScreen`, 310 px on the right beside the chat (the chat makes room; × or
-Escape closes it): the avatar at 86 px with a pen — **Change avatar** puts the phone's
+Escape closes it): the avatar at 86 px with a pen (**Change avatar** puts the phone's
words in the composer ("Change your avatar to …", the chat flow below), **Edit name**,
**Avatar studio…** (the dragon, an emoji on a colour, or a drawn face, written to the
-account and worn on every device) — the name, *online* / *Not signed in* / *Offline* or
+account and worn on every device)), the name, *online* / *Not signed in* / *Offline* or
what the agent is doing right now, a share button (a card with the face and the name, as
a picture to save or copy), and a segmented control of four tabs: **Activity** (the
sessions and the hub's notices, *Today* and *Earlier*), **Approvals** (the per-app
*Always allow* grants with **Revoke**, *Manage permissions* → Settings → Computer use,
then what you allowed or rejected on the approval cards, with when; kept on this
computer), **Daily** (the routines; *Manage routines* opens the harness's schedules
-panel), **Soul & memory** — what the agent remembers about you, one line each: a
+panel), **Soul & memory** (what the agent remembers about you, one line each): a
field to add one, × to forget one, *Import memory* (Muse's sheet: paste what another
assistant knew, one line per memory), and the agent's description when the account has
one. The agent writes memory itself with the `remember` tool when you tell it something
you will expect it to know next time, and every chat's prompt carries the list back
(`rooms.json`, this computer only). *Change look* has the dragon, an emoji on a colour,
-and **Draw one** — the avatar studio: describe the character, pick a style (Muse's
+and **Draw one**, the avatar studio: describe the character, pick a style (Muse's
vinyl-toy look by default), see what it costs against today's allowance, *Draw four*,
and the four candidates come up in a 2×2 grid with *Option 1–4*; *This one* draws the
other poses from the pick (working, waiting, happy, oops) and the new look is on, for
@@ -180,7 +195,7 @@ avatar to an orange cat with a scarf", "把你的形象换成一只橘猫", "bec
same regular expressions, `src/avatar-flow.ts`) are caught before they are sent; a card
above the composer says what four pictures will cost against today's allowance (the
phone's `FaceCostDialog`), *Draw* brings the four candidates up numbered in a 2×2 grid,
-and you pick by clicking or in words — "the second one", "第三个", "regenerate" — then
+and you pick by clicking or in words ("the second one", "第三个", "regenerate"), then
*Finalizing* draws the other poses, the look goes on for every device, *Share avatar*
offers the card. Once it is on, the host leaves the phone's memory line (`- : the
user changed my avatar to "…"`) and asks the agent to say so in one sentence and write
@@ -190,10 +205,10 @@ agent's own `draw_new_look` still opens the studio for anything it wants to prop
**The face moves.** Muse's avatar is animated per state; here the dragon's four states
(idle, working, waiting, happy) are the phone app's short clips
(`assets/dragon-.mp4`), played silently in a loop wherever the face is 44 px or
-larger — the header, the profile panel, the stage's cursor marker — with the still as
+larger (the header, the profile panel, the stage's cursor marker), with the still as
the poster, and the still alone below that size, when the system asks for reduced
motion, or where a list wants a quiet picture. A drawn face gets clips of its own, as on
-the phone: four 4-second clips — idle, working, waiting, happy — drawn from the face by
+the phone: four 4-second clips (idle, working, waiting, happy) drawn from the face by
`wan2.2-i2v-flash` through the relay or your own Bailian key (`src/video.ts`,
`src/motion.ts`; the phone's prompts word for word), kept under `avatar/motion/` on this
computer and served by the faces route (`/face//.webp|mp4`), played in the
@@ -208,7 +223,7 @@ works, a small hop when it waits for you.
**Muse.** The controlled screen is shown inside the window as a dimmed live stage: an ×
to stop top-left, take-over controls top-right, a caption bottom-left saying what was
just done ("typed · ", "pressed ↵ · "), the face as the cursor marker. Inline
-permission cards in the chat — *allow to take a screenshot?* / *write a file?* —
+permission cards in the chat (*allow to take a screenshot?* / *write a file?*)
with *Allow (this task)*, *Always allow*, *Deny*. (nanoMuse had a stage like that from
0.1.34 to 0.1.39 and dropped it in 0.1.40 for the trajectory below: a picture-in-picture
that follows the hands is one more thing moving, and it cannot be looked back at.)
@@ -216,12 +231,12 @@ that follows the hands is one more thing moving, and it cannot be looked back at
**nanoMuse on dsh.** Hands are the runtime's `computer_screen` / `computer_act` over
MCP, so what the model sees is the screenshot it asked for and the status chip says
*Hands · step N · click "Save"* while it works. The harness's approval card is restyled
-into Muse's permission card — a shield, the headline, the detail, *Allow once* in blue
-first and *Reject* in grey — and every answer is written to the agent page's
+into Muse's permission card (a shield, the headline, the detail, *Allow once* in blue
+first and *Reject* in grey), and every answer is written to the agent page's
*Approvals* tab; the Sentinel's reasons ([sentinel.md](sentinel.md)) are in the
-headline. The same question — *Allow once*, *Always in * (for the hands' tools; the
+headline. The same question, *Allow once*, *Always in * (for the hands' tools; the
app is the window title's first part), *Deny* (the host's `ApprovalDesk` sits first in
-the `approval/request` waterfall and answers for the card) — is on the **capsule**, the
+the `approval/request` waterfall and answers for the card), is on the **capsule**, the
small always-on-top pill the shell shows when the nanoMuse window is not in front, so
you can answer from wherever you are.
*Always in * writes a `computer_app:` grant: the hands' tools in that app are
@@ -229,53 +244,53 @@ allowed without asking until you revoke the grant, on the agent page or under Se
Computer use → *Always allowed*. Settings → **Computer use** shows the two macOS
permissions with *Allow* and *Open System Settings* (through the desktop shell; the
first time the hands are about to start, the shell asks for them in order with a word on
-why, the phone's and Codex's way — Accessibility, then Screen Recording for nanoMuse
+why, the phone's and Codex's way: Accessibility, then Screen Recording for nanoMuse
Desktop, which since 0.1.36 is the binary that captures and clicks; the bundled
`nanomuse` runtime only needs them when it runs without the shell), *Keep the screen
awake while it works* (the shell holds a power-save
blocker while a session runs) and the note that anything that sends, pays or deletes is
asked first.
-**Holds — whose turn it is.** When the agent needs you (a password, a captcha, a choice
+**Holds: whose turn it is.** When the agent needs you (a password, a captcha, a choice
it should not make) it calls `computer_act` with `action: "hand_over"` and a `reason`;
the host opens a *hold* (contract C1: `{type:"hold", tool:"computer", status:"on",
-by:"agent", reason}`), the trajectory card in the chat says **Your turn — —
+by:"agent", reason}`), the trajectory card in the chat says **Your turn · ·
Done**, the capsule says it outside the window (amber, the glow amber with it), and the
-tool call waits (up to ten minutes) until you press **Done**. **I'll take it** — on the
-trajectory card and on the capsule — opens a hold yourself (`POST /nanomuse/cloud/holds`)
+tool call waits (up to ten minutes) until you press **Done**. **I'll take it**, on the
+trajectory card and on the capsule, opens a hold yourself (`POST /nanomuse/cloud/holds`)
so the agent pauses before its next hands call while you use the mouse; *Done* resumes.
Questions keep *Open*. Stop (the square) still cancels the turn.
**The operator.** The hands themselves are the shell's (`src/operator.ts`, a port of
UI-TARS-desktop's `NutJSOperator` on `@computer-use/nut-js`): the screenshot through
Electron's `desktopCapturer` at the display's size (on a Mac, through the helper
-*nanoMuse Computer Use* and ScreenCaptureKit instead — [desktop.md](desktop.md#macos-permissions)),
+*nanoMuse Computer Use* and ScreenCaptureKit instead; see [desktop.md](desktop.md#macos-permissions)),
the pointer moved straight to the
point and left there 100 ms before the click, drags, scrolls, typing through the
clipboard for anything beyond ASCII, the hotkey table (`ctrl` is ⌘ on a Mac). The
runtime's `nanomuse mcp` reaches them over a loopback HTTP server the shell starts per
launch (`NANOMUSE_OPERATOR_URL` / `NANOMUSE_OPERATOR_TOKEN` in its environment, which
-the preset passes through to the MCP server), so the model's coordinates — pixels of
-the picture it was shown — are mapped once to the operator's screen pixels and land
+the preset passes through to the MCP server), so the model's coordinates (pixels of
+the picture it was shown) are mapped once to the operator's screen pixels and land
where it pointed, on a scaled display as on a plain one
([gui.md](gui.md#hands-on-the-computer-the-picture-is-the-unit)). Without the shell
(`nanomuse` run on its own) the runtime falls back to `pyautogui` / `xdotool`.
**Breathing, not flowing.** Since 0.1.40 nothing in the desktop app runs round a rim,
-sweeps across a surface or shimmers: every light that says "working" — the dot by a
+sweeps across a surface or shimmers: every light that says "working" (the dot by a
chat's name, the status dot in the header, the dot of another device's turn, the
thinking dots under a running turn, the microphone while it listens, the glow, the
-capsule's ring and bars, the trajectory's live dot — breathes at the phone's rhythm,
+capsule's ring and bars, the trajectory's live dot) breathes at the phone's rhythm,
2.4 s in and 2.4 s out, ease-in-out, between a dim and a full light. One-shot motions
stay (a card sliding in, the marker locking on, the two ripples of a click), and so do
the face's moods and the plain spinners. Under the system's *reduce motion* setting every
breathing light is a steady light and the marker's arc stands still.
-**The glow.** While the hands run, the shell puts a transparent, click-through,
+**The glow.** While the hands have the screen, the shell puts a transparent, click-through,
always-on-top window over the whole display (UI-TARS's ScreenMarker, in the phone's
-vocabulary — `HandsStage.kt` drawn for a desktop): a light breathing along the four
-edges, 7 % of the shorter side deep with a 1.5 px hairline at the rim — Muse's action blue
-while the hands work, amber while a hold is on — and at the exact point the operator
+vocabulary: `HandsStage.kt` drawn for a desktop): a light breathing along the four
+edges, 7 % of the shorter side deep with a 1.5 px hairline at the rim (Muse's action blue
+while the hands work, amber while a hold is on), and at the exact point the operator
acted on, the **marker**: a soft halo, a 12 px ring in the action blue with a cyan arc
turning round it, a dot with a white core at the centre, and the action's name in a dark
pill beside it (to its left near the right edge). It locks on in 220 ms from 1.8× and,
@@ -286,9 +301,14 @@ screen, write their name where the last ring was. The marker fades 1.6 s after t
action. It comes from the operator itself (its own fractions of the display), not from
the client's reading of the tool call, so it is where the click went; when another
backend acts (the runtime's own hands), the client's point stands in, without words. The
-glow cannot take focus or a click, it is gone 400 ms after the hands stop, and it, the
-capsule and the nanoMuse window itself while the hands run all have
-`setContentProtection(true)`, so none of them is in the screenshots the runtime takes —
+glow is up for the whole run, from the turn's first `computer_screen` or `computer_act`
+call to the turn's end, through the seconds the model thinks between two steps (the host
+says which session has the screen in `hands.run`; the glow and the capsule used to follow
+the calls in flight and went dark between every two), and it goes 400 ms after the turn
+ends, after *Stop*, or after five minutes without a call. The glow cannot take focus or a
+click, and it, the
+capsule and the nanoMuse window itself while the hands have the screen all have
+`setContentProtection(true)`, so none of them is in the screenshots the runtime takes;
on Linux, where content protection does nothing, the glow and the capsule step out of the
way for the instant of the capture instead. The phone's long-press ring has no desktop
equivalent (nothing is long-pressed); the phone's face follows the finger only inside its
@@ -296,14 +316,16 @@ capsule, so the desktop's face at the pointer (0.1.34–0.1.39) is gone.
**The capsule** is the phone's `HandsCapsule`, sized for a pointer: a pill at most 420 px
wide at the top centre of the work area (ink at 82 %, a white hairline), the agent's face
-in a 36 px ring of three hues — the action blue, violet, cyan — breathing with four small
-bars, **Step N** and what the hands are doing (*clicked "Save"*, *typed "hello"*, *looking
-at the screen*), **I'll take it** and a red **Stop**. It slides in over 320 ms when the
-hands start, is shown only while the nanoMuse window is not the one in front (the chat
+in a 36 px ring of three hues (the action blue, violet, cyan) breathing with four small
+bars, **Step N** and what the hands are doing (*looking at the screen*, *clicked "Save"*,
+*typed "hello"*; the last step's words stay while the model thinks about the next),
+**I'll take it** and a red **Stop**. It slides in over 320 ms when the
+hands start, stays for the whole run like the glow, is shown only while the nanoMuse
+window is not the one in front (the chat
shows the same in the trajectory card), and goes 220 ms after the run ends. When the
hands are about to click or drag under it, it moves to the bottom of the work area first
(and back up next time), the way the phone's capsule dodges the finger. A hold turns it
-amber with **Your turn — — Done**; a question the agent asked before a step
+amber with **Your turn · · Done**; a question the agent asked before a step
hangs under it as a card with *Allow once*, *Always in *, *Deny*. The shell feeds it
over IPC from the main window (`nanomuse:overlay`, `{hands: {active, held, step, title,
text, face, stop, take, x, y, kind}, cards}`) and its answers come back the same way.
@@ -311,19 +333,19 @@ text, face, stop, take, x, y, kind}, cards}`) and its answers come back the same
**The trajectory** is how a hands run is looked at, during and after it, in the chat: a
card under the run's last tool call (the chat's own row, so it scrolls with the thread)
with the step's screenshot and, drawn on it in the marker's hues, what the hands did
-there — the ring and dot of a click (two rings for a double click), the dashed path and
+there (the ring and dot of a click (two rings for a double click), the dashed path and
arrowhead of a drag, a chevron for a scroll, the typed text, the key chord or the app's
-name in the label pill when the action had no point — a caption in words (*Step 3 ·
+name in the label pill when the action had no point), a caption in words (*Step 3 ·
clicked "Save" · Finder*), the agent's words from just before the step (its short
reasoning, clamped to three lines on the card), a filmstrip of thumbnails, *previous* and
*next* (the ← and → keys when the card has focus, Home and End for the ends), *Open
large* (the step in a sheet with the whole text) and *Copy this step*. While the run is
on the card follows the newest step with a breathing dot and offers **I'll take it** and
-**Stop**; a hold shows *Your turn — * with **Done**. Pictures are loaded lazily
+**Stop**; a hold shows *Your turn · * with **Done**. Pictures are loaded lazily
and only the chosen step's at full size. The host keeps the run in memory
(`src/trajectory.ts`): a *run* opens with the first hands call of a turn and closes when
the turn ends or the hands rest for ten minutes; a *step* is a frame the model was shown
-(`computer_screen` returns one, `computer_act` the screen after the action — a
+(`computer_screen` returns one, `computer_act` the screen after the action; a
`tools/execute` middleware takes it out of the MCP result with the window title and size
from the first line), with the action the model then took on it and the words of its last
assistant message before acting. The caps: **40 steps per run** (older ones drop off the
@@ -335,7 +357,7 @@ sessionId, source, device, startedAt, endedAt, firstCall, lastCall, dropped, ste
seq, at, width, height, title, action, words, callId}]}]}`, no bytes) and `GET
/nanomuse/cloud/stage/frame?seq=N` for a picture (immutable, cached an hour); the live
state's `trajectory: {rev, sessions}` says when to read again. When the agent looks at a
-*phone* through Reach (`device_screen`), the run is a *device* run with the phone's name —
+*phone* through Reach (`device_screen`), the run is a *device* run with the phone's name:
that is what the phone app shows while its own Hands work ([gui.md](gui.md)), seen from
here.
@@ -350,29 +372,31 @@ control and browser automation, blocked apps) · File system access · Dictation
import memory, download your data, reset) · Help · Legal · Sign out.
**nanoMuse on dsh.** The same shape, from the harness's `sidebar.settings` seat with its
-stock General plugin switched off in the bundle layer:
+stock General plugin switched off in the bundle layer. A link on one page that opens
+another (*Add a provider* on Models opens Account's ways on; the account card on General
+opens Account; *The sandbox* on Files opens Agents) leaves the way back at the top of the
+page it opened, named for the page you came from; a pick in the sidebar starts fresh.
-- **General** — in Muse's order: the **account card** (the masked identifier or *Sign
- in*, opening the Account page), **Usage** (the plan and its bar — *Member* /
+- **General**, in Muse's order: the **account card** (the masked identifier or *Sign
+ in*, opening the Account page), **Usage** (the plan and its bar: *Member* /
*Unlimited*, or the free allowance with what is left and *Upgrade*), **Language** (the
harness's own row in our card), **Appearance** (the harness's light / dark / system
- switch; the accent follows the face), **App behavior** when the desktop shell is there —
+ switch; the accent follows the face), **App behavior** when the desktop shell is there:
*Open at login* (a login item; a freedesktop autostart entry on Linux), *Show in the
menu bar* / *system tray* (an icon with *Open nanoMuse*, *New chat*, *Quit*), *Quick
chat with ⌥ Space* (Ctrl+Alt+Space elsewhere: the window comes up with a fresh chat
- and the composer focused; pressed while it is in front, it steps aside) — kept in
- `desktop.json` under the app's home; **Shortcuts** (*Quick Chat* with its combination —
+ and the composer focused; pressed while it is in front, it steps aside), all kept in
+ `desktop.json` under the app's home; **Shortcuts** (*Quick Chat* with its combination:
*Change* records the next one you press, Esc cancels, *Default* puts the platform's back,
- and a line says so when another app already holds it — and the keyboard reference); **About** (nanoMuse, built on DeepSeek Harness, the licence, and two version
- lines that are always there: *nanoMuse Desktop · harness — installed*
- and *Latest 0.1.x — you have it* / *0.1.x is out — Update* / *Could not check — tap to
- try again*; the check reads this fork's GitHub releases first and, only when one is
-configured, a mirror index
- second, once a day); and *Developer*: *Show DeepSeek
+ and a line says so when another app already holds it; and the keyboard reference); **About** (nanoMuse, built on DeepSeek Harness, the licence, and two version
+ lines that are always there: *nanoMuse Desktop · harness : installed*
+ and *Latest 0.1.x: you have it* / *0.1.x is out · Update* / *Could not check · tap to
+ try again*; the check reads this fork's GitHub releases first, then a configured mirror
+ only, once a day); and *Developer*: *Show DeepSeek
Harness controls* (the model picker, the modes, the workspace browser in place of the
chats column). The harness's other rows (permission presets, font size, link opening,
Enter to send, performance, session log) live under *Advanced → Harness*.
-- **Models** (right after General, since 0.1.41) — one row per thing a model does for
+- **Models** (right after General, since 0.1.41), one row per thing a model does for
you: **Chat** (*The model that talks with you.*), **Operating the screen** (*Looks at the
screen and acts for you. Needs a model that can see images.*), **Making pictures**
(*Portraits of your Muse and the pictures you ask for.*) and **Making clips** (*Short
@@ -383,83 +407,88 @@ configured, a mirror index
effect at the hands' next step (the plugin restarts `nanomuse mcp` with the new model,
no cold restart). The hands speak OpenAI's shape only, so an Anthropic or native Gemini
key is named in one sentence under the row and not listed. A row nothing can do shows
- the gate's sentence — *Pictures need a provider with image models: Alibaba Cloud
- Bailian, Zhipu GLM, SiliconFlow, …* — and *Add a provider*; the page ends with *Add a
+ the gate's sentence (*Pictures need a provider with image models: Alibaba Cloud
+ Bailian, Zhipu GLM, SiliconFlow, …*) and *Add a provider*; the page ends with *Add a
provider*, which opens the ways on under Account. When you have not chosen, a row
follows the provider new chats answer through (its catalogue default for the job) when
that is one of your own, else nanoMuse Cloud while signed in, else the first of your
providers that can.
-- **Account** (under *Advanced*) — the nanoMuse account: sign in or the masked
+- **Account** (under *Advanced*), the nanoMuse account: sign in or the masked
identifier, the allowance, the look, *Open Devices*, a line that opens Settings → Models
with what chat and the hands use now, and the block the own keys share with the account:
- - **Ways on** — your account's row first while signed in (what it covers right now, from
+ - **Ways on**: your account's row first while signed in (what it covers right now, from
the relay's model list), then the catalogue in the first run's groups: the region's
providers, the ChatGPT sign-in, the rest ordered by how much they cover, the local
- servers, a custom endpoint. Each row says what it covers, takes or changes a key inline,
+ servers, the custom endpoints. Each row says what it covers, takes or changes a key inline,
links to the vendor's key page and has *Remove*; removing a row drops its credential and
- any chat or hands choice that pointed at it. The ChatGPT row runs the bundled runtime's
+ any chat or hands choice that pointed at it. The custom endpoints are one row each (#294):
+ a saved endpoint reads its name over its host and has *Rename* beside *Change key* and
+ *Remove*; the *Add an endpoint* row under them adds another with a key of its own
+ (`NANOMUSE_KEY_CUSTOM`, `NANOMUSE_KEY_CUSTOM_2`, ...) without touching the others, and
+ each stands as a group of its own in the Models page's pickers. An endpoint saved before
+ this stays the first row. The ChatGPT row runs the bundled runtime's
sign-in: *Sign in* opens OpenAI's page, the row waits with *Cancel* (and *Open the
sign-in page* again), then reads *Signed in as ChatGPT Plus* and offers *Sign out*; the
sign-in covers chat and the hands only, not pictures or clips, and the line about
OpenAI's terms sits under it. Without the runtime the row says so and stays disabled. A
sign-in on file comes back after a restart, with its local bridge. Signed out of the
account, the same block is under the sign-in form, so a key can be changed without an
- account. Once a key is saved, a card asks **Use it for** — *Pick what this key should
- handle. nanoMuse Cloud keeps the rest.* — with a toggle per thing the key can handle
+ account. Once a key is saved, a card asks **Use it for** (*Pick what this key should
+ handle. nanoMuse Cloud keeps the rest.*) with a toggle per thing the key can handle
(chat, the screen, pictures, clips), all on, each naming the model it would get; *Use
it* switches those rows on the Models page to the provider, *Not now* changes nothing,
and a footnote says *You can change this any time under Settings › Models.*
-- **Media** — the models that draw the face and its clips: the same two pickers as
+- **Media**, the models that draw the face and its clips: the same two pickers as
Settings → Models' *Making pictures* and *Making clips*, nanoMuse Cloud first while
signed in, then each own provider's models (the video picker has *Off* too). Pictures
- through your own key go to that provider directly — Model Studio's native image API,
+ through your own key go to that provider directly (Model Studio's native image API,
OpenRouter's image API, the OpenAI shape for OpenAI, Zhipu, SiliconFlow, Volcengine and
- xAI — and the studio says *Drawn with · . Nothing is billed to your
+ xAI), and the studio says *Drawn with · . Nothing is billed to your
nanoMuse Cloud account.* in place of the estimate; a Google key draws only through the
OpenAI-compatible layer. When nothing configured covers pictures or clips the row says who could,
- where you are, with the way to Settings → nanoMuse Cloud — the same sentence the avatar studio
- shows in place of the estimate — and with only the ChatGPT sign-in, that pictures and
+ where you are, with the way to Settings → nanoMuse Cloud (the same sentence the avatar studio
+ shows in place of the estimate), and with only the ChatGPT sign-in, that pictures and
clips are not covered by it.
Below: *Animate the avatar after a change*, *Make / Redo clips* with the state of each
of the four, and the cost note. When clips through your own key failed and you are
signed in, the failure line offers *Use nanoMuse Cloud this time*: that one run through
the account, the picker untouched; the studio offers the same after a failed round.
-- **Models**, **Agents** — the harness's pages, unchanged, under *Advanced*.
-- **Connectors** — Muse's catalogue shape: a search field, category chips (*All*,
+- **Models**, **Agents**: the harness's pages, unchanged, under *Advanced*.
+- **Connectors**, Muse's catalogue shape: a search field, category chips (*All*,
*Built in*, *Work*, *Talk*, *Files*, *Developer*, *Data*, *Design*, *Money*,
*Search*, *Infrastructure*…), **Connected** rows first and then the catalogue, each row
with the service's brand mark on a white tile (the marks are
[simple-icons](https://simpleicons.org/), CC0, written into
`src/client/brand-marks.ts` by `scripts/brand-marks.mjs`; a letter tile where there is
none) and *Connect* at the
- end; a detail per connector with what it can do — its tools as the server describes
- them, **one switch per tool** — what gates it (the permission preset, the Sentinel for
+ end; a detail per connector with what it can do (its tools as the server describes
+ them, **one switch per tool**), what gates it (the permission preset, the Sentinel for
the hands and the mailbox), the vendor's documentation, and *Disconnect* (asked
twice). Three kinds of row:
- - **Built in** is what the agent can really reach: *Hands — this computer* (on when
+ - **Built in** is what the agent can really reach: *Hands: this computer* (on when
the runtime answered; when it did not, the reason: no runtime, a path that leads
nowhere, or a runtime that did not start, with the start's own error), *Mailbox*, *Calendar*,
*Address book* (the runtime's connectors, on when their tools arrive through
- `nanomuse mcp`), *Reach — your other devices*, *Web*, *Files*, *Terminal*, the
+ `nanomuse mcp`), *Reach: your other devices*, *Web*, *Files*, *Terminal*, the
rooms, *Schedule*. *Connect* on the mailbox, the calendar or the address book opens a
- **consent sheet** in Muse's manner — what the agent gets, who decides, where the
- credentials live — followed by the steps (`nanomuse vault set …`, the `config.toml`
+ **consent sheet** in Muse's manner (what the agent gets, who decides, where the
+ credentials live), followed by the steps (`nanomuse vault set …`, the `config.toml`
lines, restart) with copy buttons; there is no sign-in button on purpose, the
credentials stay in your own vault.
- - **Services** — the catalogue (`src/connectors-catalogue.ts`, seventy-five remote
+ - **Services**: the catalogue (`src/connectors-catalogue.ts`, seventy-five remote
MCP servers: Notion, Linear, Atlassian, Asana, GitHub, GitLab, Sentry, Vercel,
Cloudflare, Supabase, Stripe, PayPal, QuickBooks, Figma, Miro, Canva, Dropbox, Box,
Hugging Face, DeepWiki…), each
with its address and how it lets a client in. *Connect* asks the server itself
(`initialize`): an **open** server is connected at once; a server that speaks **MCP
- authorization** opens the sign-in page in the system browser — protected-resource
+ authorization** opens the sign-in page in the system browser (protected-resource
and authorization-server metadata, dynamic client registration (RFC 7591), PKCE,
`resource` (RFC 8707), the loopback callback at `127.0.0.1:38417/oauth/callback`,
- refresh before expiry — and the sheet waits with *Open again*, *Try again* and
+ refresh before expiry), and the sheet waits with *Open again*, *Try again* and
*Start over* (a fresh registration, for a server that forgot ours); a server that
wants a **key** asks for it in a sheet that says where the vendor hands it out. A
service whose authorization server registers no clients (GitHub, Slack, Discord,
- HubSpot, Render, Bitrise, PagerDuty, Box — `clientIdRequired` in the catalogue, and
+ HubSpot, Render, Bitrise, PagerDuty, Box; `clientIdRequired` in the catalogue, and
any address that turns out that way) gets a sheet of its own: make an OAuth app at
the vendor's developer page (linked), give it our callback address (shown, copy
button), paste the client id and, if there is one, the secret; the id is kept like a
@@ -473,73 +502,79 @@ configured, a mirror index
adds the token or key, refreshes it, filters `tools/list` to the switched-on tools
and refuses `tools/call` on the rest; the tools appear as `mcp____`
and the preset row `nanomuse-connectors-tools` mounts one client per connection and
- follows the page (connect, switch, disconnect — no restart). A connection whose
+ follows the page (connect, switch, disconnect; no restart). A connection whose
refresh is refused turns to *Needs sign-in* with *Sign in again*; a key is replaced
in place.
- - **Preset** — every MCP server configured in the agent preset by hand
+ - **Preset**: every MCP server configured in the agent preset by hand
(`@deepseek-ai/dsh-mcp-client` rows), read-only here, with *Add a connector*
opening the preset.
What Muse has and this does not: the per-action permission dropdowns (ours is the
permission preset plus the tool switches), and services whose MCP server wants a
- client registered with the vendor beforehand (Zapier, Heroku…) — those are in the
+ client registered with the vendor beforehand (Zapier, Heroku…); those are in the
catalogue as *key* or *address* rows, not as a sign-in.
-- **Computer use** — the system permissions (macOS), keep awake, the risk note.
-- **File system access** — the folders the agent uses (home, the Library, Downloads,
- nanoMuse's own files; each opens in the file manager), the rules (reading anything you
- name; writing under the chat's permission preset, asking outside its folder; the
- sandbox), and *Full Disk Access* on macOS.
-- **Dictation** — the harness's own voice input (local SenseVoice; switched on in
+- **Computer use**: the system permissions (macOS), keep awake, the risk note.
+- **File system access**: the folders the agent uses (home, the Library, Downloads,
+ nanoMuse's own files; each opens in the file manager, the page grants nothing), the
+ rules (reading anything you name; writing under the chat's permission preset, which is
+ *workspace-write* unless the Agents page says otherwise: the chat's folder freely, the
+ main chat's being `~/nanoMuse`, and outside it a change is refused unless the agent asks
+ you for it and you allow it once; *read-only* asks before every change, *full access*
+ asks nothing; the sandbox), and *Full Disk Access* on macOS, which is only about reading
+ Desktop, Documents and Downloads. The home folder, Downloads and the app's own files are
+ outside the main chat's folder on purpose; a chat opened from the Library or on a folder
+ of your own writes there.
+- **Dictation**: the harness's own voice input (local SenseVoice; switched on in
Plugins, a microphone appears beside Send, audio stays on the computer), the microphone
permission on macOS, and how to use the system's dictation into the composer.
-- **Devices** — this computer (its name, the remote-control switch that lets the phone
+- **Devices**: this computer (its name, the remote-control switch that lets the phone
run things here) and the other devices on the account, online dots, *Forget*.
-- **Permissions** — one page that says what the agent may touch (computer use, files,
+- **Permissions**: one page that says what the agent may touch (computer use, files,
microphone, connectors, other devices with the remote-control state), each row opening
the page with the switch; how it asks (the permission presets, from the chip above
the composer); the recent approvals.
-- **Wallet**, **Secure storage**, **Message channels** — Muse's pages, with what is true
+- **Wallet**, **Secure storage**, **Message channels** (Muse's pages), with what is true
here: no payment method is held and nothing is bought from a chat (the agent stops at a
checkout and the usage lives under General); which files on this computer hold what
- (the account's login, the agent's profile, the rooms and documents, the faces — each
+ (the account's login, the agent's profile, the rooms and documents, the faces; each
with its path, *Show the folder*) and what is never kept (the system keychain, your
passwords); which channels reach the agent (this desktop, the browser, the invite link
when signed in, your other devices) and that no third-party messenger is wired in.
-- **Data controls** — *We take your privacy seriously* with the privacy policy, *Sync
+- **Data controls**: *We take your privacy seriously* with the privacy policy, *Sync
conversations between my devices* (the account's switch, with how many chats and
messages the relay keeps and *Delete synced conversations*) and under it *Also sync
- side chats* (同时同步旁聊; this computer's own switch, off by default — *Off: side
+ side chats* (同时同步旁聊; this computer's own switch, off by default; *Off: side
chats stay on this device. On: this device's side chats go to the account and the
other devices' side chats come here*; see [every-device.md](every-device.md)), *Help
improve nanoMuse's AI models* (the relay's switch, with how many turns it kept and
*Delete*), as on every other app; then *On this computer*: **Import memory**,
- **Download your agent data** (a zip in Downloads — the account snapshot without the
+ **Download your agent data** (a zip in Downloads: the account snapshot without the
sign-in token, the look and faces, the rooms, memory, and the chats as the harness
keeps them; shown in the file manager) and **Reset** (memory, the rooms and the local
look go, the account signs out; the chats stay).
-- **Help & support** — the docs, the site, discussions, report an issue, the version.
-- **Legal** — the licence, the Meta trademark notice, the acknowledgements (DeepSeek
- Harness, OpenMinis) and the privacy policy (`docs/privacy.md`, the page every
- client links).
-- **Advanced** — every page another plugin registers (the harness's plugin manager,
+- **Help & support**: the docs, the site, discussions, report an issue, the version.
+- **Legal**: the licence, the Meta trademark notice, the acknowledgements (DeepSeek
+ Harness, OpenMinis) and the privacy policy, the page every
+ client links.
+- **Advanced**: every page another plugin registers (the harness's plugin manager,
archived sessions…), grouped at the bottom so they are there and out of the way.
- **Sign out** at the foot while signed in. Signing out (or *Reset*) takes the window
back to the welcome sheet, the way a fresh install starts.
-Wallet, secure storage and message channels are not there: the Cloud has no wallet —
-members have an allowance ([cloud.md](cloud.md#allowance)) — nanoMuse keeps no
+Wallet, secure storage and message channels are not there: the Cloud has no wallet;
+members have an allowance ([cloud.md](cloud.md#allowance)). nanoMuse keeps no
passwords for the agent, and messages reach you through the phone app's notifications
rather than a messenger.
-## The rail's other rooms — Feed, Ideas, Goals, Library
+## The rail's other rooms: Feed, Ideas, Goals, Library
**Muse.** Four more rooms on the rail: a *Feed* of posts the agent makes (digests with
pictures, like / discuss, a text that steers future posts); *Ideas*, proactive
suggestions by life area, each a card with why, what it includes, how it works, *start
now*; *Goals* with categories, running automations (cron-like) and a dated timeline;
-*Library*, everything generated — documents (a markdown editor), web artefacts, images,
+*Library*, everything generated: documents (a markdown editor), web artefacts, images,
videos, podcasts, system files.
**nanoMuse on dsh.** The four rooms are on the rail, in Muse's order (Chats, Search,
@@ -548,42 +583,42 @@ so the chat column stays where it is and a room opens in its place. One host ser
(`nanomuse-rooms`, `src/rooms.ts`) keeps them in `$DSH_HOME/nanomuse/rooms.json`,
serves `/nanomuse/rooms/*` and streams changes to the browser half:
-- **Feed** — posts the agent writes for you in a hidden chat, from your *feed
+- **Feed**, posts the agent writes for you in a hidden chat, from your *feed
instructions*, your goals and the profile: title, Markdown body with links inline, a
picture when the page it read had one, ♡ and **Discuss** (a side chat opened on the
- post). The room opens on the phone's intro card (*About the feed* — what the posts are
+ post). The room opens on the phone's intro card (*About the feed*: what the posts are
made of, *Edit preferences*, *Got it*); while it is empty the words say when the daily
routine runs (*Every day at 08:00 I read what I remember about you …*) with **Write it
now**; the first day is written when the first conversation ends, and the daily routine
(08:00, a schedule you can move or switch off under the sliders) is created the first
time the room opens. A dot on the rail marks posts newer than your last visit. The agent
can also post from any chat with `feed_post`.
-- **Ideas** — the phone's curated list (`assets/ideas.{en,zh}.json`, the same 24 items
+- **Ideas**: the phone's curated list (`assets/ideas.{en,zh}.json`, the same 24 items
in six sections: Travel, Work, Everyday, Learning, Family & friends, More ideas), each
row an emoji, a first-person pitch and a line of detail; a row opens a sheet that says
- what trying it creates — *Starts a conversation*, *Creates a routine · 08:00*,
- *Creates a goal* — with **Send to chat** (the idea's words in the composer, for you to
+ what trying it creates (*Starts a conversation*, *Creates a routine · 08:00*,
+ *Creates a goal*), with **Send to chat** (the idea's words in the composer, for you to
finish), **Create routine** (a daily schedule at the idea's time in a chat of its own;
- *Routine created — set the time and details*) or **Start goal** (the goal
+ *Routine created. Set the time and details*) or **Start goal** (the goal
conversation, seeded with the idea); tried ideas get a green check, ⋯ hides one.
-- **Goals** — *● Tracking* list with categories (health, relationships, money, career,
+- **Goals**: *● Tracking* list with categories (health, relationships, money, career,
interests, productivity), the agent's one-line status, a progress bar once it has
reported one and a *Needs attention* flag. **Create goal › ** is the
phone's flow: "I'd like to create a Health goal." goes into the chat you are in (a
fresh one when it is busy) with the phone's shaping note; the agent asks at most three
- short questions, one at a time — what, why and by when, how often to check — and then
+ short questions, one at a time (what, why and by when, how often to check) and then
writes one `nanomuse-goal` fence, which the chat shows as a *Goal created* card (title,
why, steps, *Checks daily at 09:00* / *every N hours*, *See in Goals*) and the host
turns into the goal: that chat becomes its home, and a check is scheduled there with
the harness's schedule plugin (*Time to check on this goal: …*, *First time round: …*).
- Every check — and any turn where progress changed — ends with a `nanomuse-goal-update`
+ Every check, and any turn where progress changed, ends with a `nanomuse-goal-update`
fence (`goal_id`, `progress`, `status` on_track / attention / done, `note`): a *Goal
update* card in the chat with the bar and the note, the goal's status, progress and
timeline updated; the agent may also call `goals_room_update`. *Type it out* in the
menu still creates a goal from your own words; *Check in* runs a check now.
-- **Library** — shelves (All, Documents, Web; Media: Images, Videos, Podcasts; System
+- **Library**: shelves (All, Documents, Web; Media: Images, Videos, Podcasts; System
files at the foot), *Select*, *+ Create…* (a brief → a chat that writes the file under
- `~/nanoMuse/Library` — `构件` in Chinese — with the workspace-write preset), *Recent*
+ `~/nanoMuse/Library`, `构件` in Chinese, with the workspace-write preset), *Recent*
and a card grid; a viewer and a Markdown editor for text (restoring IDENTITY.md or SOUL.md
to the stock text asks first), pictures and media inline, *Open* / *Show in folder* for the rest. Everything the agent delivers with `present`
lands here, and `library_add` lists a file without delivering it.
@@ -602,11 +637,11 @@ when a newer release is out), **Update to 0.1.x** when there is one (opens the r
page), **About nanoMuse** (the version line *nanoMuse Desktop · harness *,
the licence, GitHub / Releases / Docs / Issues), Keyboard shortcuts, Schedules and
the harness's other panels (Plugins and whatever else is installed), collapse / expand
-the chats column, *Report a problem* — under the desktop shell a screenshot of the
+the chats column, *Report a problem*: under the desktop shell a screenshot of the
window goes to Downloads and the GitHub issue page opens with the build's facts filled
in (a toast says which file to drag in); in a browser it opens the issues page.
-Everything dsh has that Muse does not — the plugin manager, the panel list, archived
-sessions, developer tools — is reachable from here or from Settings → Advanced and
+Everything dsh has that Muse does not (the plugin manager, the panel list, archived
+sessions, developer tools) is reachable from here or from Settings → Advanced and
nowhere else.
## Not in Muse, in nanoMuse
@@ -627,7 +662,7 @@ nowhere else.
model when the turn ends, the Models page as it was. Nothing falls back on its own.
- **Connectors on every device.** What you connected here (service, label, URL for a
custom MCP, how it signs in, which device) is published to the account's profile
- (contract C3; never a token or a key — the host refuses any field named like one), so
+ (contract C3; never a token or a key: the host refuses any field named like one), so
the phone and the other computers list it under *On your other devices* with
**Connect**, and this computer lists theirs.
- **Ways to get a model, by region.** With a Chinese UI, a phone sign-in or a relay
@@ -635,8 +670,10 @@ nowhere else.
elsewhere the other way round, each with *Get a key*.
- **Updates.** Once a day, and on *Check for updates* in About, the host asks GitHub's
releases API — this fork's own releases, with an optional configured mirror as fallback —
-and compares versions; a
- newer one shows as a dot, an *Update to 0.1.x* menu item and a *Download* button for
- this platform's asset in About.
+ and compares
+ versions; a newer one shows as a dot, an *Update to 0.1.x* menu item and a *Download*
+ button for this platform's asset in About (on Linux the `.deb`, or the AppImage when
+ the running app is one). The result is kept for a day and read against the version
+ that runs now, so an update installed in the meantime is not offered to itself.
- **Open source, no closed component.** The bundle is GPL-3.0-or-later; the harness is
MIT and travels with its notice; the About row says so.
diff --git a/docs/desktop.md b/docs/desktop.md
index faa4241a..601641d6 100644
--- a/docs/desktop.md
+++ b/docs/desktop.md
@@ -44,6 +44,12 @@ speaking as nanoMuse through the `nanomuse` preset, with:
usage by kind and by model, the password, the devices holding a key, the timeline, deletion),
read through the host's pass-through routes (`/nanomuse/cloud/me`, `/sessions`,
`/account-events`, `/password`, `/sign-out-all`, `/delete-account`, `/config`);
+- **a clip of your own as the face**: *Your own clip…* under the pen on the agent's page
+ and in the avatar studio takes an MP4 or MOV with H.264 video of up to 15 seconds and
+ 30 MB, previews it in the face's round frame, and plays it muted in a loop for every mood;
+ it stays on this computer as `$DSH_HOME/nanomuse/avatar/own-clip.mp4`, is never sent to
+ nanoMuse Cloud, and *Default face* removes it (the fallback rule is in
+ [avatar.md](avatar.md));
- **Hands** on this computer: `nanomuse mcp` from the bundled runtime over stdio, the
runtime's `computer_screen` and `computer_act` tools with their approvals, so "what is
on my screen?" and "open the settings and turn the volume down" work out of the box.
diff --git a/docs/every-device.md b/docs/every-device.md
index c8a7378d..167562ca 100644
--- a/docs/every-device.md
+++ b/docs/every-device.md
@@ -17,7 +17,7 @@ Android app is the reference; the others follow its shape, not its pixels:
|---|---|---|
| A face with a name pill and a status line at the top of the conversation | `HomeShell`, `AgentAvatarDisc` | the web app's chat header, dragon by default |
| One main chat, side chats in a drawer | drawer | drawer on narrow windows, a sidebar on wide ones |
-| Feed · Ideas · Goals · Library | bottom bar | bottom bar / sidebar |
+| Feed · Ideas · Goals · Library | floating frosted-glass pill bar, icons only | the same pill at phone width (web and iOS alike), a sidebar on a wide window and in the desktop app |
| Approval cards in the chat, three tiers, *remember* pills | `RiskApprovalCard`, `RiskTier` | the Sentinel's card with the same labels |
| Settings → Permissions: what was remembered, by tier | `GrantsSection` | Permissions page, grants by tier |
| Settings → Hands: the screen as a hand, off by default | `Hands` | Settings → Hands (this computer's screen) |
diff --git a/docs/harness.md b/docs/harness.md
index cf5cde1b..12977a39 100644
--- a/docs/harness.md
+++ b/docs/harness.md
@@ -206,9 +206,11 @@ screen in [desktop-muse.md](desktop-muse.md):
observer records each decision), schedule, memory.
- **The conversation.** A stylesheet under `html[data-nm-muse]` over the harness's
stable hooks: `[data-composer-card]` becomes one pill (*+*, *Message*, the send
- disc), `[data-chat-flow-kind="user"]` the accent bubble, the assistant step the grey
- bubble, `[data-approval-key]` Muse's permission card (shield, headline, *Allow once*
- in blue, *Reject*), the hero greeting hidden with the composer docked at the bottom;
+ disc), `[data-chat-flow-kind="user"]` the ink pill (black with white text in the light
+ theme, white with black in the dark), the assistant step the grey pill, the header a
+ scrim the conversation scrolls under, `[data-approval-key]` Muse's permission card
+ (shield, headline, *Allow once* in blue, *Reject*), the hero greeting hidden with the
+ composer docked at the bottom;
the harness's model, mode and plan controls are hidden from the composer and come
back with the Developer switch. The harness's own DOM is not touched, only styled.
- **Settings.** The `sidebar.settings` seat too (the stock `ui-settings-general` row
@@ -356,7 +358,7 @@ a community project with no affiliation) apply unchanged.
project of its own), but a small Electron app that does what that one does at its
core: starts the harness's Host as a child process with its own binary in Node mode
(`ELECTRON_RUN_AS_NODE`, `--expose-internals`; the harness's `require-builtin` addon
- fingerprints the Electron it was built for, so the shell pins `44.0.0`), from a `dsh`
+ fingerprints the Electron it was built for, so the shell pins one exact Electron version), from a `dsh`
that npm installed under `resources/dsh` together with the bundle at build time,
against a profile of its own under `~/.nanomuse/desktop` that names the bundle and
links it from the copy inside the app. The runtime for the hands rides along as a
diff --git a/docs/hub.md b/docs/hub.md
index 7017aec0..6b899977 100644
--- a/docs/hub.md
+++ b/docs/hub.md
@@ -175,7 +175,12 @@ the key in `hello` instead).
Close codes: `4000` hello expected (no hello within 15 s, or a frame that is
not a hello first; a binary frame counts), `4001` bad key (reason `bad_key`
or `account_deleted`; `account_gone` when the account was deleted or disabled
-under a live socket), `4002` bad device, `4003` replaced
+under a live socket; from the relay release after 0.24.2 also `bad_key`
+under a live socket whose key was just revoked, by *Sign out everywhere*
+from another device, a
+session revoked on the account page or the device's own sign-out, so a lost
+phone loses its line at once and the other devices see it offline), `4002`
+bad device, `4003` replaced
by a newer connection of the same device id, or, with the reason
`hub_paused` (relay 0.22), the operator switched the hub or the whole service
off: a client waits and reconnects later instead of retrying at once
diff --git a/docs/index.md b/docs/index.md
index a282feb9..be506f9e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -23,20 +23,25 @@ hero:
| | |
|---|---|
-| **Android** 8.0+, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk) · [how to install](/android) |
-| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc) — a beta; the link delivers the build once Apple's beta review has passed · [iOS](/ios) |
-| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg) · [the desktop app](/desktop) |
-| **Windows** 10+ | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe) |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **Android** 8.0+, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk) · [how to install](/android) |
+| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): a beta that passed Apple's beta review; the link installs it · [iOS](/ios) |
+| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg) · [the desktop app](/desktop) |
+| **Windows** 10+ | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe) |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` for your own relay; `docker compose up -d app` for the web app · [run it yourself](/self-hosting) |
-Every download is on the [latest release](https://github.com/zeeshanhaque21/nanoMuse/releases/latest).
+Every download is on the [latest release](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)
-[What changed in 1.0.2 Trim](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1) · [What each client can do](/parity) · [Where to start contributing](/roadmap) · [What the relay keeps](/privacy) · [What stays on a phone, and whose it is](/sync)
+[What changed in 1.0.3 Helm](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1) · [What each client can do](/parity) · [Where to start contributing](/roadmap) · [What the relay keeps](/privacy) · [What stays on a phone, and whose it is](/sync)
## The film
-74 seconds: what nanoMuse does on your phone, your computer and the web..
+
+74 seconds: what nanoMuse does on your phone, your computer and the web.
+
+::: info nanoMuse keeps evolving
+A personal agent should not live only in someone else's cloud, so nanoMuse is the open-source, low-cost alternative: on your own devices, with a model you choose and a relay you can host yourself. We want to build it with developers and users everywhere, so that each person truly owns a personal agent. Open an [issue](https://github.com/zeeshanhaque21/nanoMuse/issues/new/choose) or send a [pull request](../CONTRIBUTING.md); either one helps nanoMuse get better.
+:::
## Citation
diff --git a/docs/ios.md b/docs/ios.md
index 21c723d7..3b13b7ec 100644
--- a/docs/ios.md
+++ b/docs/ios.md
@@ -6,10 +6,10 @@ the TestFlight pipeline needs, and what is still to be ported from the Android a
**Status.** The tree, the branding, the nanoMuse Cloud sign-in, the hub client and the pipeline
were written on a Linux machine. The app **builds, signs and is on TestFlight**: the first build,
-0.1.31 (2), went through the *iOS · TestFlight* workflow on 2026-10-03; 0.1.41 is build 14, with
-the internal testers and submitted to Apple's beta review for the public link
-(`https://testflight.apple.com/join/ZHexbDqc`, which delivers the build once the review has
-passed — *Where it stands* below). The testers' reports have driven the fixes in the release
+0.1.31 (2), went through the *iOS · TestFlight* workflow on 2026-10-03; 1.0.0 is build 16, which
+passed Apple's beta review, so the public link
+(`https://testflight.apple.com/join/ZHexbDqc`) installs it; 1.0.1 is build 18, 1.0.2 is build 19 and 1.0.3 is build 22, all three submitted to the review (2026-10-10,
+2026-10-11 and 2026-10-12; *Where it stands* below). Builds 20 and 21 (1.0.2) went to the internal testers only. The testers' reports have driven the fixes in the release
notes (the composer, onboarding, sign-out); the Devices section and notifications from other
devices still have the fewest hours on a real device. The first archive taught the pipeline
that automatic signing wants a registered device, which is why it signs manually now.
@@ -49,8 +49,13 @@ Applied by `python scripts/rebrand.py` (idempotent; run after every upstream pul
`.FileProvider`), the app group `group.io.github.nanomuse.app`, the iCloud container
`iCloud.io.github.nanomuse.app`, the background-task, UTType and URL-scheme ids that carry the
bundle id. Unlike Android there is no source-package constraint on iOS, so the whole family
- moves. The `minis://` and `minis-mcp://` schemes stay: they are upstream's contract with its
- own sandbox.
+ moves. The URL schemes the system knows the app by are `nanomuse` (the share extension opens
+ `nanomuse://share`) and `nanomuse-mcp`, so a phone that also has OpenMinis installed never has
+ two apps claiming one scheme. Inside the app, links stay `minis://…`: that is upstream's
+ contract with its own sandbox, the system prompt and the model; a `nanomuse://` link arriving
+ from the system is read as its `minis://` form (`NanoMuse/NanoMuseOwnScheme.swift`), and a
+ button of ours outside the chat opens such a link through the deep-link router, never through
+ the system.
- **Names.** Display name nanoMuse, "Share to nanoMuse", "nanoMuse Files"; "Minis" → "nanoMuse"
in Swift string literals, in `Localizable.xcstrings` (keys renamed, all nine translations
updated) and in the Info.plist usage descriptions in every language. The OpenRouter
@@ -69,13 +74,12 @@ Ours, in `NanoMuse/`:
e-mail address, a code, and the relay ([cloud.md](cloud.md)) is an ordinary OpenAI-compatible
provider in the app, with a model group of its own that becomes the default when there is none.
Same wire format and the same rules as the Android client (`io.github.nanomuse.cloud`): one
- instance per relay, nothing of the user's own replaced, a 401 on refresh removes the provider.
-A *Relay server* field points the app at another relay; it is empty by default and must be set
- before any relay call, so the app never silently reaches a backend this fork does not talk to.
- Debug builds can point at another relay. Since 0.1.32 the page is the whole account — the
+ instance per relay, nothing of the user's own replaced, a 401 on refresh removes the provider
+ (and keeps the account's data aside, Contract C12 below).
+ Any build can point at another relay (*Use a different server*, below). Since 0.1.32 the page is the whole account: the
password as the other way in, a friend's invite code, the pool in yuan with the ways on when it
runs low (your own key, an invitation, a star once), usage by kind and by model, the devices
- holding a key, the timeline, deletion — the same sections as the phone's `CloudAccountScreen`.
+ holding a key, the timeline, deletion, the same sections as the phone's `CloudAccountScreen`.
- **The agent's steps, the status line.** *Settings → Chat → Steps* keeps the tool capsules out of
finished messages unless asked for; the typing line reads *〈name〉 is on it*, not *is thinking*.
Both are one-line edits in upstream's `AssistantBlockView` / `ContentView`, marked `// nanoMuse:`.
@@ -84,21 +88,27 @@ A *Relay server* field points the app at another relay; it is empty by default a
name in the chat header*), and the OpenMinis layout stays one tap away in the drawer. The chat's
navigation title becomes **face · name · status line** (`NanoMuseHeader.swift`): the status
reads *waiting for you* while a question or an approval is up, then the running tool's
- `tool_title` — the model's own words for the step, *打开携程网站* — then *writing the reply*,
+ `tool_title` (the model's own words for the step, *打开携程网站*), then *writing the reply*,
then *On it: 〈brief〉*, and the model's name when idle; while a new face is being drawn it
reads the avatar flow's line. The face (`NanoMuseFaces.swift`, the drawn face from Application
Support or the dragon from the bundle, five moods with the breath, bob, tilt, pop and shake of
- the other clients) is tapped into **the agent's page**. The drawer holds the sessions, search,
+ the other clients; with Reduce Motion on the still keeps still and only the mood's crossfade
+ remains) is tapped into **the agent's page**. A short clip of your own can play as the
+ face instead (`NanoMuseOwnClip.swift`: *Your own clip…* under the pen on the agent's page and in
+ the avatar studio; MP4 or MOV with H.264 or HEVC video, up to 15 seconds and 30 MB, previewed
+ before it is used, kept on that phone only under `Application Support/nanomuse/avatar/own-clip.mp4`,
+ and *Default face* removes it; the section in [avatar.md](avatar.md) has the fallback rule).
+ The drawer holds the sessions, search,
a new side chat, *Pin as the main chat*, *All chats* and the nanoMuse settings; it opens from
the round button and, on the main chat, from a swipe in at the left edge
(`NanoMuseEdgeSwipe.swift`, a screen-edge pan on the window, as Android's drawer on the Chat
tab; a pushed side chat keeps the system's back swipe).
- **The agent's page (0.1.34)** (`NanoMuseAgentPage.swift`, Android `ui/profile`): the big face
with the pen badge (*Change avatar* puts "Change your avatar to " in the main chat's composer,
- *Edit name*, *Avatar studio*), the name, *online*, and four panes — **Activity** (what you asked
+ *Edit name*, *Avatar studio*), the name, *online*, and four panes, **Activity** (what you asked
and what the agent did, from the last two days of sessions), **Approvals** (the standing
"always allow" answers, revocable), **Daily** (the routines and the goal check-ins with their
- next run, *Manage routines*), **Soul & memory** (SOUL.md and GLOBAL.md, editable) — and the
+ next run, *Manage routines*), **Soul & memory** (SOUL.md and GLOBAL.md, editable), and the
share sheet (`NanoMuseAvatarShareSheet`, Android `ui/avatar/AvatarShareSheet.kt`).
- **The avatar, from the chat (0.1.34)** (`NanoMuseAvatarFlow.swift`, Android `avatar/AvatarFlow.kt`):
"换成一只橘猫" / "change your avatar to a red panda" is read before the model sees it (the same
@@ -108,12 +118,12 @@ A *Relay server* field points the app at another relay; it is empty by default a
relay with a cost card first (`/v1/estimate`), or through the person's own Bailian key
(`NanoMuseImageGen.swift`, DashScope's native image endpoint) when the phone has one; the studio
(`NanoMuseAvatarStudio.swift`) uses the same path and lets the image model be picked.
-- **The scheduler (0.1.34)** (`NanoMuseScheduler.swift`): routines — a label, a prompt, a time,
- daily / weekdays / once or every N hours — run as a headless turn in a conversation of their own
+- **The scheduler (0.1.34)** (`NanoMuseScheduler.swift`): routines (a label, a prompt, a time,
+ daily / weekdays / once or every N hours) run as a headless turn in a conversation of their own
(`NanoMuseHeadless`). Honestly: the iPhone runs them when the app is open (every due one on
becoming active), asks iOS for a `BGAppRefreshTask` (`io.github.nanomuse.app.scheduler`) when
it goes to the background and runs what is due if iOS grants it, and posts a local notification
- at each due time (*Check-in: 〈goal〉 — open to run it*) whose tap opens the conversation. Every
+ at each due time (*Check-in: 〈goal〉. Open to run it*) whose tap opens the conversation. Every
piece of copy about routines says so. *Settings → nanoMuse → Scheduled tasks* lists them all,
the goal check-ins and the feed's included.
- **Goals (0.1.34)** (`NanoMuseGoals.swift`, Android `goals/`): *Create a goal › category* sends
@@ -129,8 +139,8 @@ A *Relay server* field points the app at another relay; it is empty by default a
editor opens) and *Start goal* beside *Send to chat*.
- **First run and settings (0.1.34)** (`NanoMuseFirstRun.swift`, `NanoMuseSettings.swift`,
`NanoMuseCoding.swift`, `NanoMuseSystemFiles.swift`): the four pages of the Android first run
- (welcome → sign in — free → a password once for a fresh account → which model answers → meet
- 〈name〉), with *I have my own API key* opening the own-key sheet (the whole catalogue —
+ (welcome → sign in · free → a password once for a fresh account → which model answers → meet
+ 〈name〉), with *I have my own API key* opening the own-key sheet (the whole catalogue;
see *Your own key* below), and the first conversation afterwards (the
opening lines, "what should I call you?", the name chooser from the model's ` ```nanomuse-naming `
block, GLOBAL.md and SOUL.md written). *Settings → nanoMuse*: the account, **Coding agents**
@@ -142,11 +152,11 @@ A *Relay server* field points the app at another relay; it is empty by default a
(the one marked recommended *for chat*; `qwen3.8-27b` is the hands' model and never the chat
default); a pick made in the chat's picker moves to the front of the Cloud group so new chats
follow it, as Android's `followPick`. DeepSeek ids are text-only unless they name `v4.1` or
- later, `vision` or `ocr` — a `// nanoMuse:` step at the top of `LLMModel.withInferredModality()`.
+ later, `vision` or `ocr`: a `// nanoMuse:` step at the top of `LLMModel.withInferredModality()`.
- **Models as four slots (0.1.41)** (`NanoMuseModelSlots.swift`, `NanoMuseModelsView.swift`;
Android `ui/models/ModelsScreen.kt`): the first card of *Settings → nanoMuse* is *Models* and
- opens four rows — *Chat*, *Operating the screen* (disabled on iPhone, *Not on iPhone*, *Your
- computer uses its own setting.*), *Making pictures*, *Making clips* — each showing
+ opens four rows: *Chat*, *Operating the screen* (disabled on iPhone, *Not on iPhone*, *Your
+ computer uses its own setting.*), *Making pictures*, *Making clips*, each showing
` · ` and opening a picker grouped *nanoMuse Cloud* first (the relay's
recommended one marked *Recommended*) and then one group per provider of your own that can do
it; an empty row says what is missing and offers *Add a provider*, and so does the page's last
@@ -158,9 +168,9 @@ A *Relay server* field points the app at another relay; it is empty by default a
new chat, so on 0.1.41 it stayed on nanoMuse Cloud after a key of your own was chosen while
side chats answered through the key); the page says *Applies to the main chat and to new
chats; a side chat keeps its model.*; a pick made in a chat's own picker sticks the same way,
- for any provider, not only Cloud. Pictures and clips resolve in one order — your choice, else the chat
+ for any provider, not only Cloud. Pictures and clips resolve in one order (your choice, else the chat
provider's own default when it is a Model Studio key that draws, else nanoMuse Cloud when
- signed in, else the first key that can — so Cloud is no longer passed over for a Bailian key
+ signed in, else the first key that can), so Cloud is no longer passed over for a Bailian key
you did not pick; the image and video models come from the catalogue's `defaults`, not from
names in the code. The pictures and clips pickers open with an *Automatic* entry (*Currently
〈provider · model〉*) that forgets the stored choice and lets the slot follow that order
@@ -183,7 +193,7 @@ A *Relay server* field points the app at another relay; it is empty by default a
`NanoMuseConnectors.swift`): the profile's `connectors` list says which device connected what
(id, label, address without the query string, how it signs in, enabled, when, the device) and
never a token; this phone puts its own entries, reads the others' back, and the Connectors page
- lists them under *On your other devices* — *Connected on 〈device〉 — sign in here to use it on
+ lists them under *On your other devices*: *Connected on 〈device〉. Sign in here to use it on
this phone*. The ways-on card in the account follows the region: Bailian first in mainland
China, *Sign in with OpenRouter* first elsewhere.
- **Connectors** (`NanoMuseConnectors.swift`): the desktop's catalogue from the bundled
@@ -195,30 +205,38 @@ A *Relay server* field points the app at another relay; it is empty by default a
its end is upstream's MCP page.
- **Data controls** (`NanoMuseDataControls.swift`): the relay's switch, the kept-turns count, the
privacy page, deletion with a confirmation. **Reach** (`NanoMuseReach.swift`): a sheet per device
- of the account — open a link, send a note, a shell line, a screenshot — over the hub.
+ of the account (open a link, send a note, a shell line, a screenshot) over the hub.
- **When the provider cannot be reached** (`NanoMuseProviderReach.swift`,
- `NanoMuseProviderReachCard.swift`): a failed turn whose error is the transport's — *Could not
+ `NanoMuseProviderReachCard.swift`): a failed turn whose error is the transport's (*Could not
connect to the server*, *A server with the specified hostname could not be found*, a TLS or
- timeout line — or OpenAI's *region not supported* shows a card instead of the red banner: what
+ timeout line) or OpenAI's *region not supported* shows a card instead of the red banner: what
happened, what helps (a VPN, the proxy under *Settings → Network*, another provider with a key
of your own), *Try again*, the raw line behind *Details*. `LLMError` appends the failing host
as `[host: …]` to a network error so the card can name it; the 401/403/429 whose bodies
`mapHTTPError` drops are kept for a few seconds by `NanoMuseReachSignal` and written into the
- message as a canonical `nm_reach:` line by `friendlyErrorMessage` — an expired ChatGPT
+ message as a canonical `nm_reach:` line by `friendlyErrorMessage`: an expired ChatGPT
sign-in reads *Sign in again*, a spent plan window *The ChatGPT plan has nothing left for now*
with OpenAI's sentence and the reset time. The same kinds, lines and rules as Android's
`ProviderReach`.
- **When the relay refuses a turn** (`NanoMuseRelayRefusal.swift`,
`NanoMuseRelayRefusalCard.swift`): every refusal nanoMuse Cloud sends is one plain sentence in
- the phone's language and the button that fits — never a status code, the relay's JSON or
+ the phone's language and the button that fits, never a status code, the relay's JSON or
upstream's *Rate limited*. `NanoMuseReachSignal` hands each non-2xx reply of a model call to
`NanoMuseRelaySignal` first (the same existing `// nanoMuse:` spot in `mapHTTPError`, nothing
- new in upstream's files); a reply with `type: nanomuse_cloud` or one of the relay's codes — or a
- bare 413 / 401 / 5xx from the relay's host — becomes a canonical `nm_relay:` line in the
+ new in upstream's files); a reply with `type: nanomuse_cloud` or one of the relay's codes (or a
+ bare 413 / 401 / 5xx from the relay's host) becomes a canonical `nm_relay:` line in the
message, which `NanoMuseProviderReachCard` draws as the refusal card. The sentence is
`NanoMuseCloud.describe`'s, the same the sign-in and account pages use, in all nine locales:
`413 too_large` → *That message is too large for the model. Shorten it, leave out some
- attachments, or start a new chat.* with *New chat*; `401 bad_key` / `account_deleted` → *Sign
+ attachments, or start a new chat.* with *New chat*; `400 content_rejected` (the provider's
+ content check declined the words; also a provider's own `DataInspectionFailed` on a key of
+ one's own) → *The model provider's content check declined this request. Try different words,
+ or start a new chat if it keeps happening: the words it objects to can be earlier in the
+ conversation.* with *New chat* and *Try again*, and the compaction's split-retry
+ (`isSegmentRetryableError`) leaves it alone, since the same words come back refused however
+ small the request; an error the relay puts inside a stream (`data: {"error": …}` on a 200)
+ now ends the turn with its card instead of an empty reply (a `// nanoMuse:` spot in
+ `OpenAIProvider`'s stream loop); `401 bad_key` / `account_deleted` → *Sign
in*; `403 not_invited` / `account_disabled` / `signup_closed` → *Open Settings*; `429
too_many_in_flight` / `locked` / `rate_limited` and `provider_busy` (with the wait from
`retry_after`) → *Try again*; `404 model_not_offered` → *Open Settings*; `503 service_paused`,
@@ -226,32 +244,32 @@ A *Relay server* field points the app at another relay; it is empty by default a
- **The allowance in the chat** (`NanoMuseAllowance.swift`, `NanoMuseAllowanceCard.swift`;
Android `AllowanceSignal` + `AllowanceWaysCard`): a turn refused with `429 allowance_exhausted`
or `daily_cap` pins a card under the chat header the way the star card is pinned (the shell's
- top inset, no new link on `AIChatView.body`): the lead — *The free allowance is used up.*,
- *Today's share …*, or with `paused: true` (relay 0.22) *paused on this relay for now — not
- used up* and that what is left stays as it is — then the ways on and *Try again* (the open
+ top inset, no new link on `AIChatView.body`): the lead, which is *The free allowance is used up.*,
+ *Today's share …*, or with `paused: true` (relay 0.22) *paused on this relay for now, not
+ used up* and that what is left stays as it is; then the ways on and *Try again* (the open
chat's last turn once more). At 80 % of the pool (`spend.warn` from `/v1/me`) one line sits
- above the composer in `NanoMuseChatCardsHost` — *Nearly used up: ¥… of ¥… left …* — once per
+ above the composer in `NanoMuseChatCardsHost`, *Nearly used up: ¥… of ¥… left …*, once per
pool size (a new grant says it again), waved away with the ×; the card replaces it.
- **Your own key** (`NanoMuseCatalogue.swift`, `NanoMuseVendorSheet.swift`,
`NanoMuseOwnKeySheet` in `NanoMuseModels.swift`; [own-key.md](own-key.md)): the vendors are
the bundled `NanoMuse/Resources/providers.json` (`node scripts/providers-json.mjs` keeps it
- current, `--check` in CI) — and the relay's `spend.guidance` first when it sent one (relay
+ current, `--check` in CI), and the relay's `spend.guidance` first when it sent one (relay
0.21; also beside a `429 allowance_exhausted`), kept in `UserDefaults` between runs, so a
vendor added on the relay shows before the app is updated. `NanoMuseWaysList` is the one list
- on the pinned card, Settings → nanoMuse Cloud and the own-key sheet: *Use your own model key*
- — the region's lead first (Alibaba Cloud Bailian on the mainland, OpenRouter and OpenAI
+ on the pinned card, Settings → nanoMuse Cloud and the own-key sheet: *Use your own model key*:
+ the region's lead first (Alibaba Cloud Bailian on the mainland, OpenRouter and OpenAI
elsewhere), three at a time behind *More providers*, every row saying what it covers (*chat ·
screen · pictures · clips*), *Get a key* opening the vendor's key page, *Add* opening
`NanoMuseVendorSheet` on that vendor (the key pasted, the instance added with the vendor's
endpoint and `/v1` setting, its models fetched, a group named after it the default when there
- was none); *A subscription you already pay for* — ChatGPT through upstream's `CodexOAuthManager`,
- Claude, OpenRouter, Kimi's device code through `KimiDeviceLoginSheet` — with the line under the
+ was none); *A subscription you already pay for*: ChatGPT through upstream's `CodexOAuthManager`,
+ Claude, OpenRouter, Kimi's device code through `KimiDeviceLoginSheet`, with the line under the
ChatGPT row that OpenAI's terms cover a plan inside OpenAI's own Codex, that other apps have had
this access cut off before (OpenCode, January 2026), and that an API key works if it stops (the
- relay's own wording when it sent one); *On a computer of your own* — Ollama, LM Studio, vLLM —
+ relay's own wording when it sent one); *On a computer of your own* (Ollama, LM Studio, vLLM)
takes the address. No vendor is recommended.
- **Network** (`NanoMuseProxy.swift`, `NanoMuseNetworkView.swift`, *Settings → nanoMuse →
- Network*): the HTTP proxy for own providers — host, port, optional user name and password, off
+ Network*): the HTTP proxy for own providers: host, port, optional user name and password, off
by default, in `UserDefaults` on this phone only. `URLSession` has no per-host proxy switch, so
the proxy reaches a session as a proxy auto-configuration script in
`connectionProxyDictionary` that answers the proxy for the catalogue's hosts, `chatgpt.com`,
@@ -261,18 +279,18 @@ A *Relay server* field points the app at another relay; it is empty by default a
sign-in and the model list. The credentials go to the shared `URLCredentialStorage` for the
proxy's protection space. *Test* fetches `https://chatgpt.com/` through the proxy as entered.
- **The phone's chrome (0.1.35)** (`NanoMuseChrome.swift`, `NanoMuseAppearance.swift`, Android
- `ui/chat/MuseHeader.kt` and `ui/settings`): the Muse header — the face disc, the name pill with
- the live status line under it, round drawer and ••• buttons — on the chat and on Feed, Ideas,
+ `ui/chat/MuseHeader.kt` and `ui/settings`): the Muse header (the face disc, the name pill with
+ the live status line under it, round drawer and ••• buttons) on the chat and on Feed, Ideas,
Goals and Library; the side drawer after Android's; the agent's grey bubble for its replies
(`NanoMuseAssistantBubble` in `NanoMuseChatCards.swift`); the agent page's toolbar as round
buttons; *Settings → nanoMuse* rebuilt as Muse cards in the Android order
- (`NanoMuseSettingsHomeView`: Image & video models, Avatar, Computers, Appearance — avatar size,
- the model under the name, the steps, the theme — Notifications, Account, Coding, Scheduled
+ (`NanoMuseSettingsHomeView`: Image & video models, Avatar, Computers, Appearance (avatar size,
+ the model under the name, the steps, the theme), Notifications, Account, Coding, Scheduled
tasks, Shared folders, Chat files, System files, Version). A `nmOnChange` helper keeps
`onChange` on iOS 16, the app's deployment target.
- **Motion clips (0.1.35)** (`NanoMuseVideoGen.swift`, `NanoMuseAvatarMotion.swift`,
`NanoMuseMediaModels.swift`; Android `avatar/VideoGen.kt`, `avatar/AvatarMotion.kt`): a drawn
- face gets four 4-second clips — idle, working, waiting, happy — from `wan2.2-i2v-flash`
+ face gets four 4-second clips (idle, working, waiting, happy) from `wan2.2-i2v-flash`
(DashScope's async API through the relay, or your own Bailian key), the phone's prompts word
for word, kept per device under `avatar/motion/`; the face plays them with an `AVQueuePlayer`
and an `AVPlayerLooper` (suspended in the background), the dragon's clips come from the bundle.
@@ -281,31 +299,33 @@ A *Relay server* field points the app at another relay; it is empty by default a
- **Version, star asks, the feed's first day (0.1.35)** (`NanoMuseUpdateCheck.swift`,
`NanoMuseNudges.swift`): the Version row shows the installed build and the latest release
(this fork's GitHub releases first, then a configured mirror only, a day's cache — *Latest 0.1.x — you have it*
- / *0.1.x is out* / *Could not check — tap to try again*). The star asks follow the relay's
+ / *0.1.x is out* / *Could not check, tap to try again*). The star asks follow the relay's
policy (`/v1/nudges`, `nudges` in `/v1/me`, the same defaults built in): never in the first
conversation, then the 3rd / 10th / 30th task (`NanoMuseStarWatch`: a person-started session
leaving `activeSessions` without an error), the 7th / 30th day, a goal reached, a new look,
- sign-in, the allowance spent — seven days apart, four per phone — as a card pinned under the
+ sign-in, the allowance spent (seven days apart, four per phone), as a card pinned under the
header (the message list is a UICollectionView, so nothing can be placed under the last
message); the card's sentence is the one set in the relay's console (`star.text`, `star.text_zh`
- for a Chinese UI) when there is one, else the app's own line for the moment — the title and the
+ for a Chinese UI) when there is one, else the app's own line for the moment; the title and the
buttons stay the app's. The Feed opens on the intro card, says when the daily routine runs while it is
empty, and writes its first day after the first conversation; a Notifications page joined the
first run.
- **The composer (0.1.38, 0.1.40)** (`NanoMuseShell.swift` → `NanoMuseHomeView.body`,
`NanoMuseChatModifiers.swift`, `NanoMuseComposerField.swift`, `NanoMuseComposerWatch.swift`,
`NanoMuseComposerCheck.swift`, the `// nanoMuse:` lines in `Views/Chat/AIChatView.swift`). Four
- releases reported a chat with no input field — 0.1.36 and 0.1.37 on the maintainer's iPhone after
- the first conversation's naming, 0.1.39 on the iPad — and the 0.1.37 watchdog, the 0.1.38 native
+ releases reported a chat with no input field (0.1.36 and 0.1.37 on the maintainer's iPhone after
+ the first conversation's naming, 0.1.39 on the iPad), and the 0.1.37 watchdog, the 0.1.38 native
field and the 0.1.38 fail-safe (a second host when the overlay reported nothing) were all written
against screenshots of a composer that was, in fact, there. What the maintainer saw in the end:
the field shows when the app opens and is gone for good once the keyboard has been dismissed; the
0.1.36 screenshot has the top edge of the composer peeking out above the bottom bar. The shell's
bottom bar was a `safeAreaInset` on the `NavigationStack`'s ancestor, hidden while the keyboard is
up; the chat respected that inset at launch and no longer after the bar had left and come back,
- so the whole chat ran under the bar and the composer — bottom-aligned, laid out, healthy by every
- measure the watchdog had — sat behind it. Since 0.1.40 the **bar is a row under the rooms** (a
- `VStack`, as on Android), and the composer column (the cards, the tool strip, the input bar) is
+ so the whole chat ran under the bar and the composer, bottom-aligned, laid out, healthy by every
+ measure the watchdog had, sat behind it. Since 0.1.40 the **bar is laid out under the chat by
+ plain layout, never as an inset of the chat's ancestor** (a `VStack` row in 0.1.40; since the
+ floating pill bar, below, bottom padding of the bar's height on the chat layer, which is the same
+ layout), and the composer column (the cards, the tool strip, the input bar) is
likewise **a row under the message list**: `NanoMuseComposerHost` is a plain `VStack`, the list is
passed a bottom inset of 0, the `/` and `@` popup is an overlay of the list's bottom edge and so
stands on the column's top edge by layout. Nothing lies over the UIKit list and there is no
@@ -313,9 +333,9 @@ A *Relay server* field points the app at another relay; it is empty by default a
while the chat is on screen is rebuilt through `.id(rebuildTick)`, at most twice per appearance so
a mis-measured healthy composer cannot take the keyboard away repeatedly), and it keeps what it
sees: **Settings → Appearance → Composer check** draws a red frame around the column (the probe
- view in its background) and writes a report — the window and its safe area, the column's frame,
+ view in its background) and writes a report (the window and its safe area, the column's frame,
every UIKit ancestor of the probe with its frame, hidden flag and alpha, the text fields and
- collection views in the window, the watch's events — with a *Copy* row for a bug report. That
+ collection views in the window, the watch's events) with a *Copy* row for a bug report. That
page is the view hierarchy a device can give without a Mac. The pill's field is SwiftUI's own
`TextField(axis: .vertical)` with `@FocusState` and `lineLimit(1...6)`, not upstream's
`UIViewRepresentable` text view. What the representable did and the native field does not: an
@@ -332,7 +352,7 @@ A *Relay server* field points the app at another relay; it is empty by default a
an update the old one can show a few more times, a reinstall shows the new one at once.
- **Side chats and presence (0.1.38, Contract C9)** (`NanoMuseSync.swift`, `NanoMusePresence.swift`,
`NanoMuseSyncSettings.swift`, `NanoMuseFromDeviceCaption.swift`): *Data controls → Also sync
- side chats* is per device and off by default — only the main conversation goes up and the pull
+ side chats* is per device and off by default: only the main conversation goes up and the pull
asks `scope=main`; side rows that still arrive are ignored. Turning it on pulls once from
`since=0&scope=all&tail=300` (idempotent by `mid`) and the side chats go up with the next push;
turning it off narrows the traffic again and deletes nothing. A table's first pull is
@@ -354,15 +374,15 @@ A *Relay server* field points the app at another relay; it is empty by default a
`100.64/10` Tailscale hands out, loopback, link-local, IPv6 ULA, `localhost`, a name without a
dot and the suffixes `.local`, `.lan`, `.home`, `.internal`, `.home.arpa`, `.localdomain`,
`.ts.net` count as one's own. Signed in, the account page shows *Server:
- 〈host〉* with *Change*, which signs this phone out first — a key belongs to the relay that
+ 〈host〉* with *Change*, which signs this phone out first: a key belongs to the relay that
issued it.
- **One account's conversations, not the last person's (0.1.39, Contract C10)**
(`NanoMuseSync.swift`, `NanoMuseShell.swift`): a conversation belongs to the account that first
- pushed or pulled it — the sync table that maps it is that account's, and every account that has
+ pushed or pulled it: the sync table that maps it is that account's, and every account that has
synced on the phone keeps its table (`nanomuse-sync-accounts.json`, keyed by the relay's opaque
`account.id`, never the phone number or e-mail). Signed in, the chat lists and the Chat tab show
the account's own conversations and the ones no account has synced yet; another account's stay
- on the phone, hidden, and are never pushed under the signed-in account — an unowned one becomes
+ on the phone, hidden, and are never pushed under the signed-in account; an unowned one becomes
the account's with its first push. Signing in as a different account than last time starts
that account's cursor over (the `tail=300` pull) and forgets presence; signing back in as the
first account brings its conversations and its main chat back. Signed out, everything on the
@@ -372,15 +392,15 @@ A *Relay server* field points the app at another relay; it is empty by default a
`NanoMuseSignOutSheet.swift`): every chat has an owner row (`nanomuse-owners.json`), synced or
not, and the lists, the Chat tab, the Library, *Today's chats*, the Siri shortcuts and the
push show the signed-in owner's only; signed out, only the chats made while signed out. A
- sign-out — here, everywhere, *Use a different server* — is a sheet with one switch, *Keep this
+ sign-out (here, everywhere, *Use a different server*) is a sheet with one switch, *Keep this
account's chats on this device*, off by default: off, the account's chats, memory, feed, goals,
routines and face leave the phone (its sync table too, so a later sign-in never tombstones
them on its other devices); on, they are put aside under `MinisConfig/nanomuse/accounts//`
and come back with the account. Signing in as another account goes through the same path.
*Delete the account* removes all of it with no question. A key the relay refuses on a refresh
- (`401 bad_key` — revoked from another device, a relay reset) takes the *keep* path instead:
+ (`401 bad_key`: revoked from another device, a relay reset) takes the *keep* path instead:
the data goes aside, the key goes, and the sign-in form says *Your sign-in on this phone was
- ended — sign in again to continue; your chats are kept on this device until then* until the
+ ended. Sign in again to continue; your chats are kept on this device until then* until the
next sign-in (`NanoMuseCloud.signInEnded`); only `401 account_deleted` deletes
(`NanoMuseAccountData.keepOnRefusedKey`, tested in `NanoMuseAccountsTests`). The relay's key is saved on this
device only (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, never iCloud Keychain); a
@@ -391,7 +411,7 @@ A *Relay server* field points the app at another relay; it is empty by default a
sign-in sheet opens with the app's mark above the field, the field says *Mainland China phone
number or e-mail*, and a number with another country code gets *Text-message codes reach
mainland-China numbers only…* under the field while it is typed, not after a tap; the footer
- carries Android's fine print (what the relay keeps — an account id, a masked identifier, usage
+ carries Android's fine print (what the relay keeps: an account id, a masked identifier, usage
counts, the agent's name and look) and links the privacy policy, not the GitHub page. The
Notifications page of the first run shows the mark, as the welcome and sign-in do. After a
new face is picked with a video model set, the reply says the four clips follow in the
@@ -401,11 +421,11 @@ A *Relay server* field points the app at another relay; it is empty by default a
whether the setup is due, so a fresh install never shows the shell for a frame before the
welcome page. The first conversation's fourth line no longer says the messages go *only* to
the model: signed in, the main conversation also follows the person to their other devices,
- and *Data controls* switches that off — the same words on Android, in every language.
+ and *Data controls* switches that off, the same words on Android, in every language.
- **The audit's second pass (round 10)** (`NanoMuseHub.swift`, `NanoMuseDevicesSection.swift`,
`NanoMuseScheduler.swift`, `NanoMuseSync.swift`, `NanoMuseAccountData.swift`,
`NanoMuseProxy.swift`, `NanoMuseImageGen.swift`, `NanoMuseVideoGen.swift`): the hub client
- reads the relay's close codes ([hub.md](hub.md)) — 4001 and 4002 end the attempts until a new
+ reads the relay's close codes ([hub.md](hub.md)): 4001 and 4002 end the attempts until a new
sign-in and the Devices row says *Sign in again*; 4003 waits 30 s; a close with the reason
`hub_paused` waits two minutes and the row says *Paused by the relay*; everything else keeps
the 1 → 30 s backoff and reads *Reconnecting…*. An `open` call from another device opens
@@ -417,8 +437,8 @@ A *Relay server* field points the app at another relay; it is empty by default a
the store waits for the next call rather than starting empty and writing that over every
account's table. The image and video generators go through the Network proxy like a chat
turn on the same provider (`NanoMuseProxy.SessionSlot`), and their failures are sentences in
- every language — *The provider refused this key (HTTP 401)*, *The video took longer than 12
- minutes* — with the vendor's own words after *The provider says:* when it sent some.
+ every language, *The provider refused this key (HTTP 401)*, *The video took longer than 12
+ minutes*, with the vendor's own words after *The provider says:* when it sent some.
- **The audit's third pass (round 11)** (`NanoMuseHubTasks.swift`, `NanoMuseHub.swift`): a
`stop` from another device finds the run by the task frame's id (`stop {call}`, what the
desktop sends) as well as by conversation, only the device that asked may stop it, and the
@@ -461,6 +481,122 @@ A *Relay server* field points the app at another relay; it is empty by default a
plan's 429 reaches the reach card, so *Resets in …* shows for a ChatGPT or Claude plan. A
post's detail rows are a `Grid` whose label column takes the longest label; the avatar size
is a menu, not five segments.
+- **The third audit: the version row (1.0.x)** (`NanoMuseUpdateCheck.swift`,
+ `MinisTests/NanoMuseLogicTests.swift`): the download index at a configured mirror's `index.json` is
+ `{"releases": [{"tag": "v1.0.0", …}, …]}`, newest first, and that is what the row reads now,
+ as Android's `UpdateCheck.parseIndex` does; the shapes the row used to look for (`ios.version`,
+ `latest`) were never written, so on a phone that cannot reach GitHub the row always said *Could
+ not check*. A release found through the index leads to the mirror's download page; one found through
+ GitHub leads to its release page. `parseIndex` and `parseGitHubLatest` are pure and tested.
+- **The third audit: the sign-in sheet (1.0.x)** (`NanoMuseFirstRun.swift`): *Sign in · free* on
+ the welcome page opens the account page as a sheet; once the code or password goes through, the
+ sheet closes on its own (`nmOnChange` on the signed-in state) and the setup is on its next page,
+ the password or the source. Before, the sheet stayed open on the signed-in account page, with
+ its Devices and sync rows, and the person had to swipe it away to find the setup had moved on.
+- **The third audit: Chinese words (1.0.x)** (`Localizable.xcstrings`, `NanoMuseShell.swift`,
+ `NanoMuseAccountView.swift`, `MinisTests/NanoMuseCopyTests.swift`): the Chinese copy says what
+ Android says. The agent is 智能体 (繁體 智慧體) in the 19 keys where it was still "agent" in
+ English (the system files' notes, the routines, *About the agent*, the avatar's failure line) or
+ 代理, which in Settings → Network means the proxy; the relay is 中继 / 中繼 in the seven
+ relay sentences that said 中转 / 中轉 (*The relay says*, *Paused by the relay*, the paused
+ sign-ups, allowance, sync and hub, the private relay); *Hands* stays in English as Android's
+ 设置 → Hands does; the chat tab has its own key, `tab.chat` (聊天, Android's `nm_tab_chat`),
+ instead of upstream's *Chat* (闲聊), and the account page's usage rows say 对话 for the chat
+ kind (`models.slot.chat`, Android's `nm_usage_kind_chat`). `NanoMuseCopyTests` fails on
+ "agent" in a Chinese value, 代理 where the English says agent, 中转 and 双手.
+- **The third audit: tasks from other devices (1.0.x)** (`NanoMuseHubTasks.swift`,
+ `MinisTests/NanoMuseHubTasksTests.swift`): a `task` frame carries the asking device's screen
+ language (`language`, [hub.md](hub.md)); the iPhone's Muse is now told to answer in it, where
+ before it went by the text alone, so a person typing `@iPhone` on an English desktop about a
+ Chinese page gets the answer in English. `NanoMuseHubTasks.prompt` is pure and tested. The
+ phone's own `@device` mentions have sent the tag since 1.0.0.
+- **The third audit: the Cloud switch and the chat (1.0.x)** (`NanoMuseModelSlots.swift`,
+ `NanoMuseCloud.swift`, `NanoMuseModelsView.swift`, the `// nanoMuse:` spots of
+ `ProviderInstanceDetailView.swift`, `MinisTests/NanoMuseModelSlotsTests.swift`): *Use nanoMuse
+ Cloud models* off (Settings › Models, or *Enabled* on the Cloud provider's own page) while new
+ chats answer through the account now hands the Chat slot and the main chat to the first provider
+ of your own that chats, on the catalogue's default for it (`NanoMuseSlotResolver.nextChat`,
+ Android's `ModelSlots.nextChat`; `NanoMuseModelSlots.chatLeaves` does the move through
+ `useForChat`). With no own chat model the switch is refused: `NanoMuseCloud.setModelsOn` returns
+ false, the switch stays on, and *Add a provider of your own first; with nanoMuse Cloud off,
+ nothing else could answer.* appears under it, inside the card on the Models page and as the
+ Status section's footer on the provider page. Before, the switch went off, the Chat row kept
+ naming the account, and the main chat stopped with upstream's *No model configured* error; the
+ *Not set* state is no longer reachable through this switch. A provider of your own switched off
+ on its page keeps upstream's path: the chat re-resolves through the default group and, when
+ nothing is left, says *No model configured. Add a provider in Settings.*
+- **The bottom bar is a floating pill** (`NanoMuseShell.swift` → `NanoMuseBottomBar`,
+ `NanoMuseTones.tabIndicator` / `tabIndicatorGlyph` in `NanoMuseFaces.swift`; Android's
+ `MuseBottomBar`, #329 by @Ichiro786): the five tabs sit in a `Capsule` 12 pt above the bottom
+ safe area, as wide as the composer above it (the same 16 pt margins; 420 pt at most, centred on
+ a wider screen), icons only, with the same SF symbols, accessibility labels and selected trait as
+ before. The pill is `.ultraThinMaterial` under the shell's surface tone at 55 percent opacity (the
+ tint carries the contrast, the material carries the blur, as Android's 55 percent surface over its
+ backdrop blur) with a 1 pt `NanoMuseTones.hairline` edge; the old full-width strip and its hairline
+ on top are gone. The five tabs share the pill's width equally, each slot tappable across; the
+ current one shows the filled symbol in `tabIndicatorGlyph` on a 60 by 46 pt capsule of
+ `tabIndicator` (the chat's ink: black with a white symbol in light, white with a black symbol
+ in dark, as Android's `MuseBottomBar`) that slides to the tab tapped next with a spring
+ (`matchedGeometryEffect`; with Reduce Motion on it moves without the spring); the others show
+ the outlined symbol in the secondary colour. The bar
+ still leaves while the keyboard or the chat's voice panel is up. Layout: the pill lies over the
+ rooms and the chat in a `ZStack` aligned to the bottom. The chat keeps the 0.1.40 rule above
+ (bottom padding of the bar's height, 70 pt: 46 plus 6 above and below plus the 12 pt gap, so the
+ composer sits above the pill and nothing is an inset of the `NavigationStack`'s ancestor); the
+ rooms, plain SwiftUI scroll views, take the same height as a `safeAreaInset`, so their content
+ scrolls under the pill and the glass has something to blur. Written on Linux without a Mac: the
+ iOS check compiles it, and no device has shown it yet.
+- **The chat is monochrome** (`NanoMuseTones.ink` / `onInk` / `agentPill` in `NanoMuseFaces.swift`,
+ `NanoMuseHeaderScrim` in `NanoMuseHeader.swift`, `NanoMuseComposer.swift`,
+ `NanoMuseChatCards.swift`, and `// nanoMuse:` lines in `Views/Chat/ChatMessageViews.swift` and
+ `Views/Chat/AIChatView.swift`; Android's #331 by @Ichiro786). With the Muse shell on, your
+ messages are pills in the ink colour with text in the canvas colour: black with white text on
+ the white canvas in light, white with black text on the black canvas in dark, no blue and no
+ grey. The agent's replies sit in a pill a shade off
+ the canvas, `#F2F2F7` in light and `#1C1C1E` in dark, in the theme's text colour; code, tables
+ and raw markup stay bare as before. The send arrow in the composer pill is a 30 pt disc of the
+ same ink with the arrow in the canvas colour (a faint wash of the ink and a 40 percent arrow
+ while there is nothing to send), and the stop square while the agent answers sits on the same
+ disc; the composer pill itself keeps its grey. The scroll-to-latest
+ disc under the list is the same ink with the chevron in the canvas colour; the walk-back
+ button above it keeps its light disc. The header over the main chat is no longer a blur: a
+ scrim of the canvas colour from 80 percent at the top edge, 35 percent a little past the
+ middle, to clear at the bottom, so the conversation scrolls under the face and dissolves
+ instead of stopping at a line, and while the list is scrolled away from the latest message a
+ second fade in the full canvas colour comes in over 250 ms from the top edge (the shell's
+ `safeAreaInset` background, bound to the main chat's `isNearBottom` the way the header binds
+ its mood). A queued message keeps upstream's dashed outline and grey text, the *From {device}*
+ caption and the attachments stay outside the pill, and the long-press preview is the opaque
+ card it was. Side chats pushed from the drawer keep the system bar on its material; the rooms
+ keep their canvas. Written on Linux without a Mac: the iOS check compiles it, and no device
+ has shown it yet.
+
+## Voice prompts from the Action Button
+
+*Voice Prompt* is a Shortcut the app registers (`NanoMuse/VoicePromptIntent.swift`): it opens
+nanoMuse and starts listening, so a prompt can be spoken instead of typed. Bind it once in the
+Shortcuts app, to the Action Button (Settings › Action Button › Shortcut), to Back Tap or to a
+widget; one press and the composer is in voice mode with the mic running, and what you say
+appears in the composer as it is recognised. Sending stays a tap on the send button: a sentence
+the recogniser misheard must not start the agent on a pause. The Shortcut takes an optional
+*Session*; left empty, the prompt goes to the chat that is open, which in the Muse layout is the
+main chat (a side chat pushed over it is popped first). The first press asks for the microphone
+and for speech recognition the way the mic button does; a refusal shows in the panel, nothing is
+recorded.
+
+How it is built: the intent cannot drive the voice panel itself (on a cold start it runs before
+the window exists, and SwiftUI owns the panel), so it leaves a request in `NanoMuseVoicePrompt`
+and, when a session is named, routes to it the way a notification tap does
+(`NotificationNavigationStore`, `.openSessionFromIntent`). The chat's hooks
+(`NanoMuseChatHooks`) take the request addressed to their chat, switch the composer into the
+inline voice panel on the mic button's path and press the panel's mic once it is up, through
+`VoiceInputViewModel.handleMainButtonTap` and its permission flow. One recording path, one
+transcription stack; the transcript reaches `inputText` the way it always does. A request older
+than 30 seconds is dropped. iOS gives an App Intent no way to take free-form speech, which is
+why this is a voice entry rather than "ask nanoMuse something" in one breath, and why the app
+opens: a microphone running with nothing on screen would read as a fault. The Siri phrases are
+in the three `AppShortcuts.strings` tables (English, 简体中文, 繁體中文), the intent's own strings
+in `Localizable.xcstrings` (#296, #298).
## Building on a Mac
@@ -479,16 +615,16 @@ open src/ios/Minis.xcodeproj
Pick the **Minis** scheme, set your team under *Signing & Capabilities* (the project ships with an
empty `DEVELOPMENT_TEAM`), build for a device: the native libraries are device-only, so the
-simulator does not link — see the troubleshooting section of BUILDING.md.
+simulator does not link; see the troubleshooting section of BUILDING.md.
One rule of the chat screen, learnt from build 9 of 0.1.38: **nothing new goes on the end of
`AIChatView.body`'s modifier chain.** The body is one expression of some sixty chained
modifiers; its getter keeps copies of the growing value on the stack, and the four links 0.1.38
added were enough to overflow the main thread's 1 MB on an iPad the moment the chat appeared
-after onboarding — a crash on every launch, in `AIChatView.body.getter` →
+after onboarding: a crash on every launch, in `AIChatView.body.getter` →
`__swift_instantiateConcreteTypeFromMangledNameV2`. Chat-wide behaviour of ours lives in
`NanoMuse/NanoMuseChatModifiers.swift` (`NanoMuseChatHooks`, `NanoMuseComposerHost`): add to
-those, or add a third modifier — one link. The composer stack is an `AnyView` there on purpose.
+those, or add a third modifier, one link. The composer stack is an `AnyView` there on purpose.
`MinisTests/NanoMuseRound6Tests.swift` measures the body's value size and type depth and fails
when either grows past its ceiling.
@@ -521,17 +657,20 @@ Set up on 2026-10-03, all of it under the account holder's developer account (te
one);
- **the first build**: 0.1.31 (2), archived and uploaded by the workflow on 2026-10-03, processed
by App Store Connect (`VALID`, export compliance answered by the Info.plist key) and in beta
- testing with the internal group — the first thing that can be installed from TestFlight;
+ testing with the internal group: the first thing that can be installed from TestFlight;
- the test information an external group needs, in English and Simplified Chinese: the beta app
description, the feedback address, the marketing and privacy-policy links, and *What to Test*
on each build (none of it names other products); the external group *nanoMuse Beta* with its
public link, `https://testflight.apple.com/join/ZHexbDqc`; the beta-review contact and a
review account on the relay in *Beta App Review Information*;
-- **build 14 (0.1.41)** is with the internal testers and was added to *nanoMuse Beta* and
- submitted to Apple's beta review (build 13, 0.1.40, went in on 2026-10-06); the public link delivers a build only once a
- review has passed, so until then it shows the TestFlight page without an app. Each later
- version repeats the step (the review is per version). Nothing towards the App Store: the app
- is not going there.
+- **build 16 (1.0.0)** was added to *nanoMuse Beta* and submitted to Apple's beta review on
+ 2026-10-09 and passed it, so the public link installs it (build 14, 0.1.41, was what the link
+ delivered before); build 17, 1.0.0 with the fixes merged after the tag, went to the internal
+ testers only. **Build 18 (1.0.1)** was added to *nanoMuse Beta* and submitted to the review on
+ 2026-10-10, **build 19 (1.0.2)** on 2026-10-11 and **build 22 (1.0.3)** on 2026-10-12; the link installs build 16 until one passes. The public link delivers a build only
+ once a review has passed, and each new version repeats the step (the review is per version;
+ later builds of an approved version need none).
+ Nothing towards the App Store: the app is not going there.
### Once, in App Store Connect
@@ -566,12 +705,12 @@ Set up on 2026-10-03, all of it under the account holder's developer account (te
Xcode's automatic signing (`-allowProvisioningUpdates` with the key) was the first plan and does
not work for a team like this one: an archive is signed with an *iOS App Development* profile
before the export re-signs it for the store, and a development profile has to list at least one
-device — a team that only ships through TestFlight has registered none, so the archive stops at
+device; a team that only ships through TestFlight has registered none, so the archive stops at
*Your team has no devices from which to generate a provisioning profile*. The lane therefore
signs manually with the store's own material, which needs no devices: the Apple Distribution
certificate from the two secrets above, and the four *App Store* provisioning profiles (the app
and its three extensions) that `get_provisioning_profile` downloads from the account with the key
-at the start of every run — and repairs there if the certificate they name is not the one in the
+at the start of every run, and repairs there if the certificate they name is not the one in the
keychain.
The certificate was made without a Mac, and can be made again the same way when it expires or
@@ -598,13 +737,20 @@ certificate); the lane makes them again if they are missing.
### Does it compile?
-*Actions → iOS · build check → Run workflow* (`.github/workflows/ios-check.yml`) builds the app
-for a device on a Mac runner with signing turned off — no Apple account, no secrets. It shares
-the native-dependency cache with the TestFlight workflow, so run it first: a compile error costs
-minutes there, not an upload. The full `xcodebuild` log is attached to the run. The run fails
+*iOS · build check* (`.github/workflows/ios-check.yml`) builds the app for a device on a Mac
+runner with signing turned off: no Apple account, no secrets. It runs on every pull request
+that touches `android/src/ios/`, and *Actions → iOS · build check → Run workflow* starts it by
+hand for anything else (a change under `android/deps/`, a branch without a pull request). It
+shares the native-dependency cache with the TestFlight workflow, so let it go green first: a
+compile error costs minutes there, not an upload. The full `xcodebuild` log is attached to the run. The run fails
when a warning is reported in a file under `NanoMuse/` (upstream's files are not held to this),
so the zero-warnings rule above is checked, not just asked for.
+The strings catalogue has a check of its own that runs on every pull request, on Linux:
+`tests/test_ios_strings.py` parses `Localizable.xcstrings` with every key pair in hand and fails
+when a key is listed twice (a JSON parser keeps one of the two entries and drops the other
+without a word, Xcode included, so the translations that ship would depend on which one won).
+
### Running it
*Actions → iOS · TestFlight → Run workflow*, or push a tag `ios-`. The job builds the
@@ -621,25 +767,25 @@ The runner is `macos-26`; if the label is not available on your GitHub plan, `ma
## What follows
The Android app is where nanoMuse's shape lives; since 0.1.34 the iPhone carries the same shape
-over OpenMinis — the agent's page, the chat-driven avatar, goals, feed, routines, the first run —
+over OpenMinis (the agent's page, the chat-driven avatar, goals, feed, routines, the first run),
with iOS's limits on background work spelled out in the copy. Where the Android code is, and
what became of it here:
| Android (`io.github.nanomuse.*`) | On iOS |
|---|---|
-| `cloud` — relay client, sign-in, account | Done: `NanoMuse/NanoMuseCloud*.swift`, `NanoMuseAccount*.swift` (0.1.32: password, invite code, the allowance in yuan, usage, sessions, timeline, delete) |
-| `ui.onboarding` — the first run with *Sign in — free* | Done (0.1.34): `NanoMuseFirstRun.swift`, the first conversation included; 0.1.35 the Notifications page; no Hands page on iOS |
-| `community.StarPrompt`, `community.Nudges` — the star asks from the relay's policy | Done (0.1.35): `NanoMuseNudges.swift`, `NanoMuseStar` — the moments, the cooldown and the cap from `/v1/nudges` |
-| `community.UpdateCheck`, the Version row | Done (0.1.35): `NanoMuseUpdateCheck.swift` — installed and latest, this fork's GitHub releases then a configured mirror |
-| `avatar.VideoGen`, `avatar.AvatarMotion` — the motion clips | Done (0.1.35): `NanoMuseVideoGen.swift`, `NanoMuseAvatarMotion.swift`, `NanoMuseMediaModels.swift` |
-| `nm.show_steps` — the agent's steps, on by default since 0.1.37 | 0.1.32: `NanoMuseSteps.swift`; finished messages keep to the conversation, a running one shows its steps |
-| `connectors` — the catalogue, `SharedConnectors` | Done: `NanoMuseConnectors.swift` (0.1.33 the catalogue, 0.1.34 the other devices' entries) |
-| `ui.home`, `ui.chat`, `ui.settings`, `ui.profile` — the shell, header, agent page | Done: `NanoMuseShell.swift`, `NanoMuseHeader.swift`, `NanoMuseAgentPage.swift`, `NanoMuseSettings.swift`; 0.1.35 the Muse header on every room, the settings as Muse cards (`NanoMuseChrome.swift`, `NanoMuseAppearance.swift`) |
-| `avatar` — the drawn face, the chat-driven change, the studio | Done (0.1.34): `NanoMuseAvatarFlow.swift`, `NanoMuseAvatarStudio.swift`, `NanoMuseImageGen.swift` |
-| `goals`, `feed`, the scheduler | Done (0.1.34) as far as iOS allows: foreground catch-up, `BGAppRefreshTask`, local notifications. No alarm-exact runs while the app is asleep — the copy says so |
-| `coding` — the computers' coding agents over the hub | Done (0.1.34): `NanoMuseCoding.swift`; the computer must run nanoMuse signed in with the same account |
-| `reach` — the phone drives the computer | Done: `NanoMuseReach.swift` over the hub |
-| `hands` — the phone's own screen | No equivalent: iOS does not let an app drive another. App Intents / Shortcuts are the door there |
+| `cloud`: relay client, sign-in, account | Done: `NanoMuse/NanoMuseCloud*.swift`, `NanoMuseAccount*.swift` (0.1.32: password, invite code, the allowance in yuan, usage, sessions, timeline, delete) |
+| `ui.onboarding`: the first run with *Sign in · free* | Done (0.1.34): `NanoMuseFirstRun.swift`, the first conversation included; 0.1.35 the Notifications page; no Hands page on iOS |
+| `community.StarPrompt`, `community.Nudges`: the star asks from the relay's policy | Done (0.1.35): `NanoMuseNudges.swift`, `NanoMuseStar` (the moments, the cooldown and the cap from `/v1/nudges`) |
+| `community.UpdateCheck`, the Version row | Done (0.1.35): `NanoMuseUpdateCheck.swift` (installed and latest, this fork's GitHub releases then a configured mirror) |
+| `avatar.VideoGen`, `avatar.AvatarMotion`: the motion clips | Done (0.1.35): `NanoMuseVideoGen.swift`, `NanoMuseAvatarMotion.swift`, `NanoMuseMediaModels.swift` |
+| `nm.show_steps`: the agent's steps, on by default since 0.1.37 | 0.1.32: `NanoMuseSteps.swift`; finished messages keep to the conversation, a running one shows its steps |
+| `connectors`: the catalogue, `SharedConnectors` | Done: `NanoMuseConnectors.swift` (0.1.33 the catalogue, 0.1.34 the other devices' entries) |
+| `ui.home`, `ui.chat`, `ui.settings`, `ui.profile`: the shell, header, agent page | Done: `NanoMuseShell.swift`, `NanoMuseHeader.swift`, `NanoMuseAgentPage.swift`, `NanoMuseSettings.swift`; 0.1.35 the Muse header on every room, the settings as Muse cards (`NanoMuseChrome.swift`, `NanoMuseAppearance.swift`) |
+| `avatar`: the drawn face, the chat-driven change, the studio | Done (0.1.34): `NanoMuseAvatarFlow.swift`, `NanoMuseAvatarStudio.swift`, `NanoMuseImageGen.swift` |
+| `goals`, `feed`, the scheduler | Done (0.1.34) as far as iOS allows: foreground catch-up, `BGAppRefreshTask`, local notifications. No alarm-exact runs while the app is asleep; the copy says so |
+| `coding`: the computers' coding agents over the hub | Done (0.1.34): `NanoMuseCoding.swift`; the computer must run nanoMuse signed in with the same account |
+| `reach`: the phone drives the computer | Done: `NanoMuseReach.swift` over the hub |
+| `hands`: the phone's own screen | No equivalent: iOS does not let an app drive another. App Intents / Shortcuts are the door there |
| Widgets | Upstream's `AgentWidget` as it is |
Still to check on a device, in order: the first run end to end with a fresh account; a chat-driven
diff --git a/docs/own-key.md b/docs/own-key.md
index f4705f84..55931346 100644
--- a/docs/own-key.md
+++ b/docs/own-key.md
@@ -267,7 +267,8 @@ Ollama, LM Studio and vLLM on your own computer work too: pick the preset
(`http://127.0.0.1:11434/v1`, `:1234`, `:8000`) and no key is needed. They
count as *chat*; the hands need a model that sees, so run a multimodal one
locally (Ollama's `qwen3-vl`, say) and pick it under *Operating the screen*
-(web console: type its id on the *Hands model* row). Pictures and clips need
+(web console: the *Hands model* row under *Chat model* lists what the endpoint
+serves; pick it there). Pictures and clips need
one of the providers in the table.
## Any other OpenAI-compatible endpoint
@@ -282,3 +283,16 @@ Anything that speaks the OpenAI API works; choose *Custom*:
What a custom endpoint covers is yours to say: nanoMuse takes it for chat, and
the hands, picture and clip models count once their ids are filled in.
+
+**Several at once.** On the desktop app and in the web console the custom
+endpoints are a list, not one slot (#294): each one you add keeps its own key
+and its own models, and adding a second never replaces the first. On the
+desktop, *Settings → Account → Any OpenAI-compatible endpoint* shows one row
+per endpoint (its name over its host, with *Change key*, *Rename* and
+*Remove*) and an *Add an endpoint* row under them; the model pickers on
+*Settings → Models* list each endpoint as a group of its own. In the web
+console, *Connections → Chat model* shows one tile per endpoint under *Local or
+your own endpoint* with *Add an endpoint* last; the hands, pictures and clips
+pickers list the endpoints too ([web.md](web.md#where-it-shows)). A custom
+endpoint set before the list existed becomes its first entry, key and all,
+the next time the app starts. The phones keep one slot per provider.
diff --git a/docs/privacy.md b/docs/privacy.md
index 85252e0b..38618f9f 100644
--- a/docs/privacy.md
+++ b/docs/privacy.md
@@ -15,7 +15,7 @@ The agent runs inside the app: on the phone, in a Linux root file system in the
- **Synced conversations: on by default when you are signed in (relay 0.19 on).** The text of your conversations is stored on nanoMuse Cloud so every device of your account shows the same chats. Files and images are not uploaded. *Settings → Data controls* turns it off and deletes what is stored; deleting a chat on one device deletes it on all of them. Nobody but your devices can read it; the operator sees counts, not text. (What is stored, exactly: each conversation's title and which device started it, each message's role, text, time and device, and the names and sizes of attached files; never the files, never a picture, never what a tool returned. Chats addressed to another device, or run on this one for another, are not synced. The relay keeps at most 20 000 messages per account and cuts a message at 16 KB. [docs/cloud.md](cloud.md#conversation-sync) has the details.)
Since 0.1.38 only the **main** conversation is synced by default: side chats stay on the device that made them, and other devices' side chats do not arrive, unless you turn on *Also sync side chats* on that device. While one of your devices is answering, the others are told so (*kwai is working…*); that note goes through the relay's memory, is never stored, and is forgotten after ten minutes.
- **The web, when the agent uses it.** Web search, page fetches, the in-app browser, MCP servers and command-line tools reach the sites and services they are for. What the agent sends is what you asked it to do.
-- **Update check.** *Settings → About* (on the phone, *Check for updates*; the desktop app's tray; a runtime you run yourself (`nanomuse serve`, Docker) once every six hours, `server.update_check = false` to stop it) asks the GitHub API for the latest release of `nano-muse/nanoMuse`. Nothing is sent besides the request itself, and nothing is downloaded on its own.
+- **Update check.** *Settings → About* (on the phone, *Check for updates*; the desktop app's tray; a runtime you run yourself (`nanomuse serve`, Docker) once a day, `server.update_check = false` to stop it) asks for the latest release of `nano-muse/nanoMuse`: first the project's own download index at `nanomuse.cn/dl/index.json`, and the GitHub API when that does not answer. Nothing is sent besides the request itself (its `User-Agent` names the app and its version), and nothing is downloaded on its own.
- **Backups.** If you back up to a remote destination (SMB, WebDAV, SFTP, S3, FTP), the backup goes there, encrypted with the password you chose.
## Permissions
diff --git a/docs/readme/README_es.md b/docs/readme/README_es.md
index d8c9324b..d4582944 100644
--- a/docs/readme/README_es.md
+++ b/docs/readme/README_es.md
@@ -30,7 +30,7 @@
**nanoMuse es un agente personal de código abierto para todos tus dispositivos.** Un solo agente con nombre y aspecto propios, al estilo del [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) de Meta: hace cosas en vez de responder preguntas, sigue trabajando con la app cerrada, se acuerda de ti y se detiene a preguntar antes de cualquier cosa que no podrías deshacer.
-*nano* significa el conjunto completo, lo bastante pequeño para que lo ejecutes y lo despliegues tú mismo: la app del teléfono, la de escritorio, la consola web y el relay que las une están en este repositorio, bajo GPL-3.0-or-later. **[Gratuito, de código abierto y sin ánimo de lucro. Construyámoslo juntos.](../../CONTRIBUTING.md)** Al iniciar sesión recibes un crédito gratuito de uso de modelos en el relay de la comunidad (lo paga el desarrollador); cuando se agota, [usa tu propia clave](../own-key.md). El mismo relay funciona en un servidor tuyo, así que nada tiene que salir de casa. Última versión: **1.0.2 Trim**, [notas de la versión](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano* significa el conjunto completo, lo bastante pequeño para que lo ejecutes y lo despliegues tú mismo: la app del teléfono, la de escritorio, la consola web y el relay que las une están en este repositorio, bajo GPL-3.0-or-later. **[Gratuito, de código abierto y sin ánimo de lucro. Construyámoslo juntos.](../../CONTRIBUTING.md)** Al iniciar sesión recibes un crédito gratuito de uso de modelos en el relay de la comunidad (lo paga el desarrollador); cuando se agota, [usa tu propia clave](../own-key.md). El mismo relay funciona en un servidor tuyo, así que nada tiene que salir de casa. Última versión: **1.0.3 Helm**, [notas de la versión](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse sigue evolucionando**
@@ -53,11 +53,11 @@ Todas las versiones: [releases](https://github.com/zeeshanhaque21/nanoMuse/relea
| | |
|---|---|
-| **Android** 8.0+, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk): todas las versiones se firman con la misma clave y se instalan sobre la anterior |
+| **Android** 8.0+, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk): todas las versiones se firman con la misma clave y se instalan sobre la anterior |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): una beta que pasó la revisión beta de Apple; el enlace la instala · [iOS](../ios.md) |
-| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). Sin notarizar: la primera vez, clic derecho → *Abrir* |
-| **Windows** 10+ | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe): pulsa *Ejecutar de todos modos* una vez |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). Sin notarizar: la primera vez, clic derecho → *Abrir* |
+| **Windows** 10+ | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe): pulsa *Ejecutar de todos modos* una vez |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` para tu propio relay; `docker compose up -d app` para la app web en un servidor tuyo; véase [autoalojamiento](../self-hosting.md) |
Todas las descargas vienen de la [última versión](https://github.com/zeeshanhaque21/nanoMuse/releases/latest). Abre la app, inicia sesión con un correo o un número de teléfono de China continental, y el agente ya tiene un modelo con el que pensar. El teléfono, el escritorio y la web comparten una cuenta y muestran las mismas conversaciones.
@@ -130,9 +130,11 @@ Todas las personas con un cambio integrado en `main`, según la lista de colabor
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse es un proyecto comunitario independiente, no afiliado a Meta Platforms,
[GPL-3.0-or-later](../../LICENSE). La app del teléfono se basa en OpenMinis 1.13 (GPL-3.0), modificada desde el 2026-09-24; véase [NOTICE](../../NOTICE). Las versiones anteriores de la línea Python eran MIT (etiqueta `pre-openminis`).
- ¡Gracias por visitar ✨ nanoMuse!
+ Gracias por visitar ✨ nanoMuse.
diff --git a/docs/readme/README_fr.md b/docs/readme/README_fr.md
index 75c5c59a..977af744 100644
--- a/docs/readme/README_fr.md
+++ b/docs/readme/README_fr.md
@@ -30,7 +30,7 @@
**nanoMuse est un agent personnel open source pour chacun de vos appareils.** Un seul agent, avec un nom et une apparence à lui, dans l'esprit du [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) de Meta : il agit au lieu de répondre à des questions, continue de travailler quand l'app est fermée, se souvient de vous, et s'arrête pour demander avant tout ce que vous ne pourriez pas annuler.
-*nano* veut dire l'ensemble complet, assez petit pour que vous le fassiez tourner et le déployiez vous-même : l'app du téléphone, l'app de bureau, la console web et le relais qui les relie sont tous dans ce dépôt, sous GPL-3.0-or-later. **[Gratuit, open source, à but non lucratif. Construisons-le ensemble.](../../CONTRIBUTING.md)** Connectez-vous et vous recevez un crédit gratuit d'utilisation des modèles sur le relais de la communauté (payé par le développeur) ; quand il est épuisé, [utilisez votre propre clé](../own-key.md). Le même relais tourne sur un serveur à vous, et rien n'a besoin de sortir de chez vous. Dernière version : **1.0.2 Trim**, [notes de version](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano* veut dire l'ensemble complet, assez petit pour que vous le fassiez tourner et le déployiez vous-même : l'app du téléphone, l'app de bureau, la console web et le relais qui les relie sont tous dans ce dépôt, sous GPL-3.0-or-later. **[Gratuit, open source, à but non lucratif. Construisons-le ensemble.](../../CONTRIBUTING.md)** Connectez-vous et vous recevez un crédit gratuit d'utilisation des modèles sur le relais de la communauté (payé par le développeur) ; quand il est épuisé, [utilisez votre propre clé](../own-key.md). Le même relais tourne sur un serveur à vous, et rien n'a besoin de sortir de chez vous. Dernière version : **1.0.3 Helm**, [notes de version](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse continue d'évoluer**
@@ -53,11 +53,11 @@ Toutes les versions : [releases](https://github.com/zeeshanhaque21/nanoMuse/rele
| | |
|---|---|
-| **Android** 8.0+, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk) : chaque version est signée avec la même clé et s'installe par-dessus la précédente |
+| **Android** 8.0+, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk) : chaque version est signée avec la même clé et s'installe par-dessus la précédente |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc) : une bêta validée par la revue bêta d'Apple ; le lien l'installe · [iOS](../ios.md) |
-| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). Non notarisé : clic droit → *Ouvrir* la première fois |
-| **Windows** 10+ | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe) : cliquez une fois sur *Exécuter quand même* |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). Non notarisé : clic droit → *Ouvrir* la première fois |
+| **Windows** 10+ | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe) : cliquez une fois sur *Exécuter quand même* |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` pour votre propre relais ; `docker compose up -d app` pour l'app web sur un serveur à vous ; voir [auto-hébergement](../self-hosting.md) |
Tous les téléchargements viennent de la [dernière version](https://github.com/zeeshanhaque21/nanoMuse/releases/latest). Ouvrez l'app, connectez-vous avec une adresse e-mail ou un numéro de téléphone de Chine continentale, et l'agent a un modèle pour réfléchir. Le téléphone, le bureau et le web partagent un compte et montrent les mêmes conversations.
@@ -130,9 +130,11 @@ Toutes les personnes dont un changement a été intégré dans `main`, d'après
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse est un projet communautaire indépendant, sans lien avec Meta Platforms
[GPL-3.0-or-later](../../LICENSE). L'app du téléphone est basée sur OpenMinis 1.13 (GPL-3.0), modifiée depuis le 2026-09-24 ; voir [NOTICE](../../NOTICE). Les versions antérieures de la ligne Python étaient sous MIT (tag `pre-openminis`).
- Merci de votre visite ✨ nanoMuse !
+ Merci de votre visite ✨ nanoMuse.
diff --git a/docs/readme/README_id.md b/docs/readme/README_id.md
index ddfa6375..cd56bd22 100644
--- a/docs/readme/README_id.md
+++ b/docs/readme/README_id.md
@@ -30,7 +30,7 @@
**nanoMuse adalah agen pribadi sumber terbuka untuk setiap perangkat yang kamu miliki.** Satu agen dengan nama dan rupa sendiri, seperti [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) dari Meta: ia mengerjakan sesuatu alih-alih sekadar menjawab pertanyaan, terus bekerja saat aplikasinya ditutup, mengingatmu, dan berhenti untuk bertanya sebelum melakukan apa pun yang tidak bisa kamu batalkan.
-*nano* berarti satu paket lengkap yang cukup kecil untuk kamu jalankan dan pasang sendiri: aplikasi ponsel, aplikasi desktop, konsol web, dan relay yang menghubungkan semuanya ada di repositori ini, di bawah GPL-3.0-or-later. **[Gratis, sumber terbuka, nirlaba. Mari bangun bersama.](../../CONTRIBUTING.md)** Masuk dan kamu mendapat jatah gratis pemakaian model di relay komunitas (pengembang yang membayarnya); kalau habis, [pakai kunci sendiri](../own-key.md). Relay yang sama bisa berjalan di servermu, jadi tidak ada data yang harus keluar dari rumahmu. Versi terbaru: **1.0.2 Trim**, [catatan rilis](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano* berarti satu paket lengkap yang cukup kecil untuk kamu jalankan dan pasang sendiri: aplikasi ponsel, aplikasi desktop, konsol web, dan relay yang menghubungkan semuanya ada di repositori ini, di bawah GPL-3.0-or-later. **[Gratis, sumber terbuka, nirlaba. Mari bangun bersama.](../../CONTRIBUTING.md)** Masuk dan kamu mendapat jatah gratis pemakaian model di relay komunitas (pengembang yang membayarnya); kalau habis, [pakai kunci sendiri](../own-key.md). Relay yang sama bisa berjalan di servermu, jadi tidak ada data yang harus keluar dari rumahmu. Versi terbaru: **1.0.3 Helm**, [catatan rilis](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse terus berkembang**
@@ -53,11 +53,11 @@ Semua versi: [releases](https://github.com/zeeshanhaque21/nanoMuse/releases).
| | |
|---|---|
-| **Android** 8.0+, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk): setiap versi ditandatangani dengan kunci yang sama dan dipasang menimpa versi sebelumnya |
+| **Android** 8.0+, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk): setiap versi ditandatangani dengan kunci yang sama dan dipasang menimpa versi sebelumnya |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): versi beta yang sudah lolos tinjauan beta Apple; pasang dari tautan ini · [iOS](../ios.md) |
-| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). Belum dinotarisasi: klik kanan → *Open* saat pertama kali |
-| **Windows** 10+ | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe): klik *Run anyway* sekali |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). Belum dinotarisasi: klik kanan → *Open* saat pertama kali |
+| **Windows** 10+ | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe): klik *Run anyway* sekali |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` untuk relay sendiri; `docker compose up -d app` untuk aplikasi web di servermu; lihat [self-hosting](../self-hosting.md) |
Semua unduhan berasal dari [rilis terbaru](https://github.com/zeeshanhaque21/nanoMuse/releases/latest). Buka aplikasinya, masuk dengan e-mail atau nomor ponsel Tiongkok daratan, dan agen langsung punya model untuk berpikir. Ponsel, desktop, dan web memakai satu akun dan menampilkan percakapan yang sama.
@@ -130,9 +130,11 @@ Semua orang yang perubahannya sudah masuk ke `main`, dari daftar kontributor Git
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse adalah proyek komunitas independen, tidak berafiliasi dengan atau diduk
[GPL-3.0-or-later](../../LICENSE). Aplikasi ponsel berbasis OpenMinis 1.13 (GPL-3.0), dimodifikasi sejak 2026-09-24; lihat [NOTICE](../../NOTICE). Versi lebih awal dari jalur Python berlisensi MIT (tag `pre-openminis`).
- Terima kasih sudah berkunjung ✨ nanoMuse!
+ Terima kasih sudah berkunjung ✨ nanoMuse.
diff --git a/docs/readme/README_ja.md b/docs/readme/README_ja.md
index e5f6d9b9..17877e52 100644
--- a/docs/readme/README_ja.md
+++ b/docs/readme/README_ja.md
@@ -30,7 +30,7 @@
**nanoMuse は、あなたのすべてのデバイスのためのオープンソースのパーソナルエージェントです。** 名前と姿を持つひとりのエージェント。Meta の [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) と同じ種類のもので、質問に答えるのではなく実際に手を動かし、アプリを閉じても働き続け、あなたのことを覚えていて、取り消せない操作の前には立ち止まって尋ねます。
-*nano* は、自分で動かして自分でデプロイできるほど小さな、ひとそろいのセットという意味です。スマホアプリ、デスクトップアプリ、ウェブコンソール、そしてそれらをつなぐリレーが、すべてこのリポジトリに GPL-3.0-or-later で入っています。**[無料、オープンソース、非営利。いっしょに作っていきましょう。](../../CONTRIBUTING.md)** サインインするとコミュニティのリレーでモデルを使える無料枠がもらえます。費用は開発者が負担しています。使い切ったら[自分のキーに切り替えて](../own-key.md)ください。同じリレーは自分のサーバーでも動くので、データを外に出さないこともできます。最新版は **1.0.2 Trim**、[リリースノート](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1)。
+*nano* は、自分で動かして自分でデプロイできるほど小さな、ひとそろいのセットという意味です。スマホアプリ、デスクトップアプリ、ウェブコンソール、そしてそれらをつなぐリレーが、すべてこのリポジトリに GPL-3.0-or-later で入っています。**[無料、オープンソース、非営利。いっしょに作っていきましょう。](../../CONTRIBUTING.md)** サインインするとコミュニティのリレーでモデルを使える無料枠がもらえます。費用は開発者が負担しています。使い切ったら[自分のキーに切り替えて](../own-key.md)ください。同じリレーは自分のサーバーでも動くので、データを外に出さないこともできます。最新版は **1.0.3 Helm**、[リリースノート](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1)。
> [!NOTE]
> **nanoMuse は進化しつづけます**
@@ -53,11 +53,11 @@ https://github.com/user-attachments/assets/c7694d5a-9450-4f99-9254-5d70560d7565
| | |
|---|---|
-| **Android** 8.0 以上、arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk):すべてのバージョンが同じ鍵で署名され、上書きインストールできます |
+| **Android** 8.0 以上、arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk):すべてのバージョンが同じ鍵で署名され、上書きインストールできます |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc):Apple のベータ審査を通過したベータ版。このリンクからインストールできます · [iOS](../ios.md) |
-| **macOS** 12 以上 | [Apple シリコン](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg):公証なし。初回は右クリック → 「開く」 |
-| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe):一度だけ「実行」を押してください |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12 以上 | [Apple シリコン](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg):公証なし。初回は右クリック → 「開く」 |
+| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe):一度だけ「実行」を押してください |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` で自分のリレーを、`docker compose up -d app` で自分のサーバーにウェブアプリを。[セルフホスティング](../self-hosting.md) |
ダウンロードはすべて [GitHub の最新リリース](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)から。アプリを開き、メールアドレスか中国本土の携帯番号でサインインすれば、エージェントが使うモデルが用意されます。スマホ、デスクトップ、ウェブはひとつのアカウントを共有し、同じ会話が見えます。
@@ -130,9 +130,11 @@ VPS 1 台、1 時間:[docs/self-hosting.md](../self-hosting.md)。三つの道
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse は独立したコミュニティプロジェクトで、Meta Platforms
[GPL-3.0-or-later](../../LICENSE)。スマホアプリは OpenMinis 1.13(GPL-3.0)をもとに 2026-09-24 以降改変しています。[NOTICE](../../NOTICE) を参照。それ以前の Python 系統のバージョンは MIT でした(タグ `pre-openminis`)。
- ご覧いただきありがとうございます ✨ nanoMuse!
+ ご覧いただきありがとうございます ✨ nanoMuse。
diff --git a/docs/readme/README_ko.md b/docs/readme/README_ko.md
index e4897d69..f31c2df2 100644
--- a/docs/readme/README_ko.md
+++ b/docs/readme/README_ko.md
@@ -30,7 +30,7 @@
**nanoMuse는 당신의 모든 기기를 위한 오픈소스 개인 에이전트입니다.** 이름과 모습을 가진 하나의 에이전트로, Meta의 [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/)와 같은 종류입니다. 질문에 답하는 대신 직접 일을 하고, 앱을 닫아도 계속 일하고, 당신을 기억하며, 되돌릴 수 없는 일을 하기 전에는 멈춰서 묻습니다.
-*nano*는 직접 돌리고 직접 배포할 수 있을 만큼 작은, 완전한 한 세트라는 뜻입니다. 휴대폰 앱, 데스크톱 앱, 웹 콘솔, 그리고 이들을 잇는 릴레이가 모두 이 저장소에 GPL-3.0-or-later로 들어 있습니다. **[무료, 오픈 소스, 비영리. 함께 만들어 갑시다.](../../CONTRIBUTING.md)** 로그인하면 커뮤니티 릴레이에서 모델을 쓸 수 있는 무료 사용량을 받습니다. 비용은 개발자가 부담합니다. 다 쓰면 [자신의 키로 바꾸세요](../own-key.md). 같은 릴레이를 자신의 서버에서 돌릴 수도 있어, 데이터가 집 밖으로 나갈 필요가 없습니다. 최신 버전: **1.0.2 Trim**, [릴리스 노트](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano*는 직접 돌리고 직접 배포할 수 있을 만큼 작은, 완전한 한 세트라는 뜻입니다. 휴대폰 앱, 데스크톱 앱, 웹 콘솔, 그리고 이들을 잇는 릴레이가 모두 이 저장소에 GPL-3.0-or-later로 들어 있습니다. **[무료, 오픈 소스, 비영리. 함께 만들어 갑시다.](../../CONTRIBUTING.md)** 로그인하면 커뮤니티 릴레이에서 모델을 쓸 수 있는 무료 사용량을 받습니다. 비용은 개발자가 부담합니다. 다 쓰면 [자신의 키로 바꾸세요](../own-key.md). 같은 릴레이를 자신의 서버에서 돌릴 수도 있어, 데이터가 집 밖으로 나갈 필요가 없습니다. 최신 버전: **1.0.3 Helm**, [릴리스 노트](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse는 계속 진화합니다**
@@ -53,11 +53,11 @@ https://github.com/user-attachments/assets/c7694d5a-9450-4f99-9254-5d70560d7565
| | |
|---|---|
-| **Android** 8.0 이상, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk): 모든 버전이 같은 키로 서명되어 있어 덮어 설치하면 됩니다 |
+| **Android** 8.0 이상, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk): 모든 버전이 같은 키로 서명되어 있어 덮어 설치하면 됩니다 |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): Apple의 베타 심사를 통과한 베타. 이 링크로 설치합니다 · [iOS](../ios.md) |
-| **macOS** 12 이상 | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). 공증되지 않음: 처음 한 번 우클릭 → 열기 |
-| **Windows** 10 이상 | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe): 실행을 한 번 눌러 주세요 |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12 이상 | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). 공증되지 않음: 처음 한 번 우클릭 → 열기 |
+| **Windows** 10 이상 | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe): 실행을 한 번 눌러 주세요 |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local`로 자신의 릴레이를, `docker compose up -d app`으로 자신의 서버에 웹 앱을. [직접 호스팅](../self-hosting.md) |
모든 다운로드는 [GitHub 최신 릴리스](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)에 있습니다. 앱을 열고 이메일이나 중국 본토 휴대폰 번호로 로그인하면 에이전트가 쓸 모델이 준비됩니다. 휴대폰, 데스크톱, 웹은 하나의 계정을 공유하고 같은 대화를 보여 줍니다.
@@ -130,9 +130,11 @@ VPS 한 대, 한 시간: [docs/self-hosting.md](../self-hosting.md). 세 가지
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse는 독립적인 커뮤니티 프로젝트로, Meta Platforms, Inc.와
[GPL-3.0-or-later](../../LICENSE). 휴대폰 앱은 OpenMinis 1.13(GPL-3.0)을 바탕으로 2026-09-24부터 수정했습니다. [NOTICE](../../NOTICE) 참고. 그 이전 Python 계열 버전은 MIT였습니다(태그 `pre-openminis`).
- 방문해 주셔서 감사합니다 ✨ nanoMuse!
+ 방문해 주셔서 감사합니다 ✨ nanoMuse.
diff --git a/docs/readme/README_ru.md b/docs/readme/README_ru.md
index 33dcdde6..62e6a8a7 100644
--- a/docs/readme/README_ru.md
+++ b/docs/readme/README_ru.md
@@ -30,7 +30,7 @@
**nanoMuse — открытый персональный агент для всех ваших устройств.** Один агент со своим именем и обликом, того же рода, что [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) от Meta: он делает дела, а не отвечает на вопросы, продолжает работать при закрытом приложении, помнит вас и останавливается спросить перед тем, что нельзя отменить.
-*nano* значит полный комплект, достаточно небольшой, чтобы вы запустили и развернули его сами: приложение для телефона, приложение для компьютера, веб-консоль и реле, которое их связывает: всё это лежит в этом репозитории, под GPL-3.0-or-later. **[Бесплатно, открыто, некоммерчески. Давайте строить вместе.](../../CONTRIBUTING.md)** После входа вы получаете бесплатный лимит на модели через реле сообщества (его оплачивает разработчик); когда он кончится, [подключите свой ключ](../own-key.md). То же реле работает на вашем собственном сервере, и тогда ничего не покидает дом. Последняя версия: **1.0.2 Trim**, [заметки о выпуске](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano* значит полный комплект, достаточно небольшой, чтобы вы запустили и развернули его сами: приложение для телефона, приложение для компьютера, веб-консоль и реле, которое их связывает: всё это лежит в этом репозитории, под GPL-3.0-or-later. **[Бесплатно, открыто, некоммерчески. Давайте строить вместе.](../../CONTRIBUTING.md)** После входа вы получаете бесплатный лимит на модели через реле сообщества (его оплачивает разработчик); когда он кончится, [подключите свой ключ](../own-key.md). То же реле работает на вашем собственном сервере, и тогда ничего не покидает дом. Последняя версия: **1.0.3 Helm**, [заметки о выпуске](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse продолжает развиваться**
@@ -53,11 +53,11 @@ https://github.com/user-attachments/assets/c7694d5a-9450-4f99-9254-5d70560d7565
| | |
|---|---|
-| **Android** 8.0+, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk): все версии подписаны одним ключом и ставятся поверх предыдущей |
+| **Android** 8.0+, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk): все версии подписаны одним ключом и ставятся поверх предыдущей |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): бета, прошедшая бета-проверку Apple; ссылка устанавливает её · [iOS](../ios.md) |
-| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). Без нотаризации: в первый раз правый клик → «Открыть» |
-| **Windows** 10+ | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe): один раз нажмите «Выполнить в любом случае» |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12+ | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). Без нотаризации: в первый раз правый клик → «Открыть» |
+| **Windows** 10+ | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe): один раз нажмите «Выполнить в любом случае» |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` для своего реле; `docker compose up -d app` для веб-приложения на своём сервере; см. [самостоятельный хостинг](../self-hosting.md) |
Все загрузки — из [последнего выпуска на GitHub](https://github.com/zeeshanhaque21/nanoMuse/releases/latest). Откройте приложение, войдите по e-mail или по номеру телефона материкового Китая, и у агента есть модель, чтобы думать. Телефон, компьютер и веб делят один аккаунт и показывают одни и те же разговоры.
@@ -130,9 +130,11 @@ https://github.com/user-attachments/assets/c7694d5a-9450-4f99-9254-5d70560d7565
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse — независимый проект сообщества, не св
[GPL-3.0-or-later](../../LICENSE). Приложение для телефона основано на OpenMinis 1.13 (GPL-3.0) и изменяется с 2026-09-24; см. [NOTICE](../../NOTICE). Более ранние версии линии Python были под MIT (тег `pre-openminis`).
- Спасибо, что заглянули ✨ nanoMuse!
+ Спасибо, что заглянули ✨ nanoMuse.
diff --git a/docs/readme/README_vi.md b/docs/readme/README_vi.md
index 0bc27534..a5c0eb0a 100644
--- a/docs/readme/README_vi.md
+++ b/docs/readme/README_vi.md
@@ -30,7 +30,7 @@
**nanoMuse là một trợ lý cá nhân mã nguồn mở cho mọi thiết bị của bạn.** Một trợ lý duy nhất có tên và hình dáng riêng, cùng loại với [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) của Meta: nó làm việc thay vì chỉ trả lời câu hỏi, tiếp tục làm khi ứng dụng đã đóng, nhớ bạn, và dừng lại hỏi trước bất cứ việc gì bạn không thể hoàn tác.
-*nano* nghĩa là trọn bộ nhưng đủ nhỏ để bạn tự chạy và tự triển khai: ứng dụng điện thoại, ứng dụng máy tính, bảng điều khiển web và relay nối chúng lại đều nằm trong kho này, theo giấy phép GPL-3.0-or-later. **[Miễn phí, mã nguồn mở, phi lợi nhuận. Cùng nhau xây dựng.](../../CONTRIBUTING.md)** Đăng nhập là bạn có một khoản miễn phí để dùng mô hình qua relay của cộng đồng (do người phát triển trả); dùng hết thì [chuyển sang khóa của riêng bạn](../own-key.md). Cùng relay đó chạy được trên máy chủ của bạn, nên dữ liệu không cần rời khỏi nhà bạn. Phiên bản mới nhất: **1.0.2 Trim**, [ghi chú phát hành](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1).
+*nano* nghĩa là trọn bộ nhưng đủ nhỏ để bạn tự chạy và tự triển khai: ứng dụng điện thoại, ứng dụng máy tính, bảng điều khiển web và relay nối chúng lại đều nằm trong kho này, theo giấy phép GPL-3.0-or-later. **[Miễn phí, mã nguồn mở, phi lợi nhuận. Cùng nhau xây dựng.](../../CONTRIBUTING.md)** Đăng nhập là bạn có một khoản miễn phí để dùng mô hình qua relay của cộng đồng (do người phát triển trả); dùng hết thì [chuyển sang khóa của riêng bạn](../own-key.md). Cùng relay đó chạy được trên máy chủ của bạn, nên dữ liệu không cần rời khỏi nhà bạn. Phiên bản mới nhất: **1.0.3 Helm**, [ghi chú phát hành](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1).
> [!NOTE]
> **nanoMuse không ngừng phát triển**
@@ -53,11 +53,11 @@ Mọi phiên bản: [releases](https://github.com/zeeshanhaque21/nanoMuse/releas
| | |
|---|---|
-| **Android** 8.0 trở lên, arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk): mọi phiên bản ký cùng một khóa, cài đè lên bản cũ là được |
+| **Android** 8.0 trở lên, arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk): mọi phiên bản ký cùng một khóa, cài đè lên bản cũ là được |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc): bản beta đã qua vòng duyệt beta của Apple; cài từ liên kết này · [iOS](../ios.md) |
-| **macOS** 12 trở lên | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg). Chưa được công chứng: lần đầu nhấp chuột phải → *Mở* |
-| **Windows** 10 trở lên | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe): bấm *Vẫn chạy* một lần |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12 trở lên | [Apple Silicon](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg). Chưa được công chứng: lần đầu nhấp chuột phải → *Mở* |
+| **Windows** 10 trở lên | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe): bấm *Vẫn chạy* một lần |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` để có relay của riêng bạn; `docker compose up -d app` để chạy ứng dụng web trên máy chủ của bạn; xem [tự lưu trữ](../self-hosting.md) |
Mọi bản tải đều từ [bản phát hành mới nhất trên GitHub](https://github.com/zeeshanhaque21/nanoMuse/releases/latest). Mở ứng dụng, đăng nhập bằng e-mail hoặc số điện thoại Trung Quốc đại lục, và trợ lý đã có sẵn một mô hình để làm việc. Điện thoại, máy tính và web dùng chung một tài khoản và hiển thị cùng những cuộc trò chuyện.
@@ -130,9 +130,11 @@ Tất cả những ai có thay đổi đã vào `main`, theo danh sách người
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse là dự án cộng đồng độc lập, không liên kết với Meta
[GPL-3.0-or-later](../../LICENSE). Ứng dụng điện thoại dựa trên OpenMinis 1.13 (GPL-3.0), được sửa đổi từ ngày 2026-09-24; xem [NOTICE](../../NOTICE). Các phiên bản trước đó của nhánh Python theo giấy phép MIT (tag `pre-openminis`).
- Cảm ơn bạn đã ghé thăm ✨ nanoMuse!
+ Cảm ơn bạn đã ghé thăm ✨ nanoMuse.
diff --git a/docs/readme/README_zh-TW.md b/docs/readme/README_zh-TW.md
index d84f98da..d3c0b0f1 100644
--- a/docs/readme/README_zh-TW.md
+++ b/docs/readme/README_zh-TW.md
@@ -30,7 +30,7 @@
**nanoMuse 是一個開源的個人智慧體,面向你的每一台裝置。** 一個有名字、有自己形象的智慧體,和 Meta 的 [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) 是同一類東西:不只回答問題,而是動手做事;App 關了也繼續做;記得你;遇到無法復原的操作會先停下來問你。
-*nano* 的意思是完整的一套,小到你自己就能跑、能部署:手機 App、桌面 App、網頁主控台,還有把它們連在一起的中繼,都在這個儲存庫裡,GPL-3.0-or-later。**[免費、開源、非營利。一起把它做好。](../../CONTRIBUTING.md)** 登入就有一份免費的模型額度,走社群中繼,錢是開發者出的;用完可以[換成自己的 key](../own-key.md)。同一套中繼也能跑在你自己的伺服器上,資料不必出門。最新版本:**1.0.2 Trim**,[發行說明](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1)。
+*nano* 的意思是完整的一套,小到你自己就能跑、能部署:手機 App、桌面 App、網頁主控台,還有把它們連在一起的中繼,都在這個儲存庫裡,GPL-3.0-or-later。**[免費、開源、非營利。一起把它做好。](../../CONTRIBUTING.md)** 登入就有一份免費的模型額度,走社群中繼,錢是開發者出的;用完可以[換成自己的 key](../own-key.md)。同一套中繼也能跑在你自己的伺服器上,資料不必出門。最新版本:**1.0.3 Helm**,[發行說明](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1)。
> [!NOTE]
> **nanoMuse 在持續進化**
@@ -53,14 +53,14 @@ https://github.com/user-attachments/assets/bf0ec2ae-59aa-4358-9dd0-c9d045ee7bee
| | |
|---|---|
-| **Android** 8.0 以上,arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk):每個版本都用同一把金鑰簽署,直接覆蓋安裝即可升級 |
+| **Android** 8.0 以上,arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk):每個版本都用同一把金鑰簽署,直接覆蓋安裝即可升級 |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc):測試版,已通過 Apple 的 beta 審核;從這個連結安裝 · [iOS](../ios.md) |
-| **macOS** 12 以上 | [Apple 晶片](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg)。未經公證:第一次右鍵 → 打開 |
-| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe):點一次「仍要執行」 |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12 以上 | [Apple 晶片](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg)。未經公證:第一次右鍵 → 打開 |
+| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe):點一次「仍要執行」 |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` 架自己的中繼;`docker compose up -d app` 在自己的伺服器上跑網頁版;見[自己部署](../self-hosting.md) |
-下載都來自 [GitHub 最新版本](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)。打開 App,用電子郵件或中國大陸手機號登入,它就有模型可用。手機、桌面和網頁共用一個帳號,看到的是同樣的對話。
+下載都來自 [GitHub 最新版本](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)打開 App,用電子郵件或中國大陸手機號登入,它就有模型可用。手機、桌面和網頁共用一個帳號,看到的是同樣的對話。
## 能做什麼
@@ -130,9 +130,11 @@ https://github.com/user-attachments/assets/bf0ec2ae-59aa-4358-9dd0-c9d045ee7bee
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse 是獨立的社群專案,與 Meta Platforms, Inc. 無關,也未獲
[GPL-3.0-or-later](../../LICENSE)。手機 App 基於 OpenMinis 1.13(GPL-3.0),自 2026-09-24 起修改,見 [NOTICE](../../NOTICE)。Python 這一支更早的版本採 MIT 授權(tag `pre-openminis`)。
- 感謝來訪 ✨ nanoMuse!
+ 感謝來訪 ✨ nanoMuse。
diff --git a/docs/readme/README_zh.md b/docs/readme/README_zh.md
index 1264dd5c..94523bf4 100644
--- a/docs/readme/README_zh.md
+++ b/docs/readme/README_zh.md
@@ -30,7 +30,7 @@
**nanoMuse 是一个开源的个人智能体,面向你的每一台设备。** 一个有名字、有自己形象的智能体,和 Meta 的 [Muse](https://about.fb.com/news/2026/09/introducing-muse-personal-ai-agent/) 是同一类东西:不只回答问题,而是动手做事;App 关了也继续干;记得你;遇到无法撤销的操作会先停下来问你。
-*nano* 的意思是完整的一套,小到你自己就能跑、能部署:手机 App、桌面 App、网页控制台,还有把它们连在一起的中继,都在这个仓库里,GPL-3.0-or-later。**[免费、开源、非营利。一起把它做好。](../../CONTRIBUTING.md)** 登录就有一份免费的模型额度,走社区中继,钱是开发者出的;用完可以[换自己的 key](../own-key.md)。同一套中继也能跑在你自己的服务器上,数据不用出门。最新版本:**1.0.2 Trim**,[发布说明](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.2-fork.1)。
+*nano* 的意思是完整的一套,小到你自己就能跑、能部署:手机 App、桌面 App、网页控制台,还有把它们连在一起的中继,都在这个仓库里,GPL-3.0-or-later。**[免费、开源、非营利。一起把它做好。](../../CONTRIBUTING.md)** 登录就有一份免费的模型额度,走社区中继,钱是开发者出的;用完可以[换自己的 key](../own-key.md)。同一套中继也能跑在你自己的服务器上,数据不用出门。最新版本:**1.0.3 Helm**,[发布说明](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1)。
> [!NOTE]
> **nanoMuse 在持续进化**
@@ -53,14 +53,14 @@ https://github.com/user-attachments/assets/bf0ec2ae-59aa-4358-9dd0-c9d045ee7bee
| | |
|---|---|
-| **Android** 8.0 以上,arm64 | [nanoMuse-1.0.2-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-1.0.2-arm64.apk):每个版本同一把签名,覆盖安装即可升级 |
+| **Android** 8.0 以上,arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk):每个版本同一把签名,覆盖安装即可升级 |
| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc):测试版,已通过 Apple 的 beta 审核;从这个链接安装 · [iOS](../ios.md) |
-| **macOS** 12 以上 | [Apple 芯片](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-mac-x64.dmg)。没有公证:第一次右键 → 打开 |
-| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.2-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-win-x64.exe):点一次「仍要运行」 |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.2-fork.1/nanoMuse-Desktop-1.0.2-linux-x64.tar.gz) |
+| **macOS** 12 以上 | [Apple 芯片](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg)。没有公证:第一次右键 → 打开 |
+| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe):点一次「仍要运行」 |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` 搭自己的中继;`docker compose up -d app` 在自己的服务器上跑网页版;见[自己部署](../self-hosting.md) |
-下载都来自 [GitHub 最新版本](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)。打开 App,用邮箱或中国大陆手机号登录,它就有模型可用。手机、桌面和网页共用一个账号,看到的是同样的对话。
+下载都来自 [GitHub 最新版本](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)打开 App,用邮箱或中国大陆手机号登录,它就有模型可用。手机、桌面和网页共用一个账号,看到的是同样的对话。
## 能做什么
@@ -130,9 +130,11 @@ https://github.com/user-attachments/assets/bf0ec2ae-59aa-4358-9dd0-c9d045ee7bee
+
-
+
+
@@ -171,6 +173,6 @@ nanoMuse 是独立的社区项目,与 Meta Platforms, Inc. 无关,也未获
[GPL-3.0-or-later](../../LICENSE)。手机 App 基于 OpenMinis 1.13(GPL-3.0),自 2026-09-24 起修改,见 [NOTICE](../../NOTICE)。Python 线更早的版本是 MIT(tag `pre-openminis`)。
- 感谢来访 ✨ nanoMuse!
+ 感谢来访 ✨ nanoMuse。
diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md
index 1c0a5641..5425309c 100644
--- a/docs/release-notes-template.md
+++ b/docs/release-notes-template.md
@@ -5,7 +5,7 @@
Codenames so far: Foundation, Identity, Home, Guardrails, Memory, Avatar, Welcome, Polish,
Portrait, Motion, Hatch, Hands, Reach, Home, Stage, Palette, Open, Ensemble, Presence,
Footing, Commons, Welcome, Signal, Mirror, Window, Ledger, Harness, Likeness, Rooms, Locks,
- Union, Steps, Turns, Accord, Thread, Weave, Loom, Keys, Clear, Choice, Keel, Ballast, Trim (CHANGELOG.md has the list). One word, capitalised,
+ Union, Steps, Turns, Accord, Thread, Weave, Loom, Keys, Clear, Choice, Keel, Ballast, Trim, Helm (CHANGELOG.md has the list). One word, capitalised,
not on that list (Home and Welcome were each used twice early on; do not add a third).
Voice: plain and specific, the maintainer talking to one person. Facts over promises; no
@@ -49,7 +49,7 @@ nanoMuse is open source, free and non-profit. Sign in with a phone number or an
A stable version is not the last word: nanoMuse keeps evolving, and the [roadmap](https://github.com/zeeshanhaque21/nanoMuse/blob/main/docs/roadmap.md) says what comes next. We use it every day and know where it is rough; tell us where it broke for you and what you want it to do. Thanks to everyone who tried a build and reported what broke. [Open an issue](https://github.com/zeeshanhaque21/nanoMuse/issues/new/choose) · [send a pull request](https://github.com/zeeshanhaque21/nanoMuse/blob/main/CONTRIBUTING.md) · [Discussions](https://github.com/zeeshanhaque21/nanoMuse/discussions) · [Discord](https://discord.gg/bkTySmm28X) · [star the repo](https://github.com/zeeshanhaque21/nanoMuse); if it is useful to you, a star helps others find it.
-Based on [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13 (GPL-3.0), modified since 2026-09-24. The complete corresponding source of this build is tag `v` plus the `android/deps/proot` submodule ([nano-muse/proot](https://github.com/nano-muse/proot)). The whole repository is GPL-3.0-or-later. nanoMuse Harness carries DeepSeek Harness unmodified, with its licence files inside; *harness* in the name is the word, not DeepSeek's. nanoMuse is not affiliated with Meta; Muse is a trademark of Meta Platforms, Inc. The desktop follows what Muse's app looks like and does, written down from using it; none of Meta's assets, code or content is in it.
+Based on [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13 (GPL-3.0), modified since 2026-09-24. The complete corresponding source of this build is tag `v` plus the `android/deps/proot` submodule ([nano-muse/proot](https://github.com/nano-muse/proot)). The whole repository is GPL-3.0-or-later. nanoMuse Desktop carries DeepSeek Harness unmodified, with its licence files inside; *harness* in the name is the word, not DeepSeek's. nanoMuse is not affiliated with Meta; Muse is a trademark of Meta Platforms, Inc. The desktop follows what Muse's app looks like and does, written down from using it; none of Meta's assets, code or content is in it.
@@ -89,6 +89,6 @@ nanoMuse 开源、免费、非营利。手机号或邮箱登录后有一份起
稳定版也不是最后一句话:nanoMuse 在持续进化,接下来做什么写在[路线图](https://github.com/zeeshanhaque21/nanoMuse/blob/main/docs/zh/roadmap.md)里。我们自己每天在用,知道哪些地方还糙;哪里坏了、想要什么,直接提 issue。谢谢每一位装过、试过、报过问题的人。[提 issue](https://github.com/zeeshanhaque21/nanoMuse/issues/new/choose) · [发 PR](https://github.com/zeeshanhaque21/nanoMuse/blob/main/CONTRIBUTING.md) · [Discussions](https://github.com/zeeshanhaque21/nanoMuse/discussions) · [Discord](https://discord.gg/bkTySmm28X) · [点个 Star](https://github.com/zeeshanhaque21/nanoMuse);觉得有用,点个 Star,让更多人看到。
-基于 [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13(GPL-3.0),自 2026-09-24 起修改;本版本的完整对应源码是 tag `v` 加子模块 `android/deps/proot`([nano-muse/proot](https://github.com/nano-muse/proot))。整个仓库以 GPL-3.0-or-later 发布。nanoMuse Harness 原样携带 DeepSeek Harness 及其许可证文件;名字里的 *harness* 是普通词,不是 DeepSeek 的名字。nanoMuse 与 Meta 无关,Muse 是 Meta Platforms, Inc. 的商标。桌面端照着 Muse 应用用起来的样子做,是用过之后写下来的;里面没有 Meta 的任何素材、代码或内容。
+基于 [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13(GPL-3.0),自 2026-09-24 起修改;本版本的完整对应源码是 tag `v` 加子模块 `android/deps/proot`([nano-muse/proot](https://github.com/nano-muse/proot))。整个仓库以 GPL-3.0-or-later 发布。nanoMuse 桌面版原样携带 DeepSeek Harness 及其许可证文件;名字里的 *harness* 是普通词,不是 DeepSeek 的名字。nanoMuse 与 Meta 无关,Muse 是 Meta Platforms, Inc. 的商标。桌面端照着 Muse 应用用起来的样子做,是用过之后写下来的;里面没有 Meta 的任何素材、代码或内容。
diff --git a/docs/releases/v1.0.3.md b/docs/releases/v1.0.3.md
new file mode 100644
index 00000000..baada399
--- /dev/null
+++ b/docs/releases/v1.0.3.md
@@ -0,0 +1,102 @@
+# nanoMuse 1.0.3 · Helm
+
+🐉 **nanoMuse `1.0.3` “Helm”**: the chat looks the same on every device now, a video of your own can be the face, several custom endpoints work side by side, and a day of reports from one careful tester is answered.
+
+1.0.2 went out on 2026-10-11. The same day @Ichiro786 finished a redesign of the Android app in three pull requests: the bottom bar became a floating frosted-glass pill, the chats drawer and the chat itself moved onto the theme's own canvas with black and white pills instead of blue, and the header became a soft scrim the conversation slides under ([#329](https://github.com/nano-muse/nanoMuse/pull/329), [#330](https://github.com/nano-muse/nanoMuse/pull/330), [#331](https://github.com/nano-muse/nanoMuse/pull/331)). We took that look as the reference and brought it to the iPhone, the web console and the desktop, so the five clients read as one app. @Mola-maker read the Android code and the tests and filed ten reports in an afternoon, from an own Anthropic key that could not send to a debug log that held raw API keys ([#332](https://github.com/nano-muse/nanoMuse/issues/332) to [#340](https://github.com/nano-muse/nanoMuse/issues/340), [#347](https://github.com/nano-muse/nanoMuse/issues/347)); every one of them is fixed here. Two requests became features: a clip of your own as the agent's face ([#324](https://github.com/nano-muse/nanoMuse/issues/324)) and more than one OpenAI-compatible endpoint at a time on the desktop and in the web console ([#294](https://github.com/nano-muse/nanoMuse/issues/294)). Nothing moves for a person upgrading: the Android package installs over 1.0.2 and keeps everything, the desktop keeps its settings and its one custom endpoint becomes the first of the list, and a 1.0.2 client works against the relay (0.24.2) unchanged.
+
+## Highlights
+
+- **One look on every device.** The bottom bar on Android, iPhone and the web's phone layout is a floating frosted-glass pill, as wide as the composer above it (420 at most, centred on a wider screen), icons only; the current tab shows its filled glyph on an ink disc, black in the light theme and white in the dark one, that springs across to the tab you tap. The chat is monochrome on all four clients: your messages are black pills with white text in light and white pills with black text in dark, the agent's replies sit in a pill a shade off the canvas, the send button and the scroll-to-latest disc are the same ink, the light canvas is plain white, and the header is a scrim the conversation scrolls under instead of a bar with a line. On Android the chats drawer and the menus sit on the same canvas, with a hairline edge instead of a tinted sheet ([#329](https://github.com/nano-muse/nanoMuse/pull/329), [#330](https://github.com/nano-muse/nanoMuse/pull/330), [#331](https://github.com/nano-muse/nanoMuse/pull/331) by @Ichiro786; iPhone, web and desktop follow them).
+- **A clip of your own as the face.** On Android, iPhone and the desktop the pen on the face and the avatar studio offer *Your own clip…*: the system's video picker, then a preview of the clip playing in the face's round frame with *Use* and *Cancel*. One clip, MP4 or MOV with H.264 (the iPhone also takes HEVC), at most 15 seconds and 30 MB; it plays muted in a loop for the mood at rest and stands in for every other mood, the drawn stills keep their place where a still is shown, and with no drawn face the clip's first frame is the still. The clip stays on the device that chose it, is never sent to nanoMuse Cloud and does not sync; *Default face* takes it off again. A longer, larger or differently encoded clip is refused with a sentence that states the limits ([#324](https://github.com/nano-muse/nanoMuse/issues/324), requested by @Yicheng-1218).
+- **Several custom endpoints at once.** The desktop and the web console used to hold one OpenAI-compatible endpoint, so a second one could only replace the first. The custom row is a list now: each endpoint has a name, an address, its own key and its models, the picker groups the models by endpoint, and any of them can take a job. An existing single endpoint becomes the first entry without asking. The phones already worked this way ([#294](https://github.com/nano-muse/nanoMuse/issues/294), requested by @Alasco123).
+- **Ten reports on the Android app, all fixed.** An own Anthropic key sends again: the provider read the Claude Code OAuth identifier on every request, and the public build ships it empty, so every message on an own key stopped before it left the phone ([#333](https://github.com/nano-muse/nanoMuse/issues/333)). A carriage return in tool output overwrites instead of erasing, so `AAAA\rBB` reads `BBAA` and a progress line no longer truncates the result the model sees ([#334](https://github.com/nano-muse/nanoMuse/issues/334)). Leaving the sign-in page or the network page cancels its request instead of holding the connection to the timeout ([#336](https://github.com/nano-muse/nanoMuse/issues/336)). A new set of avatar candidates no longer shows the old set's failures ([#337](https://github.com/nano-muse/nanoMuse/issues/337)). Stop right after the first message no longer saves the welcome lines as the model's reply, and puts your text back in the composer ([#340](https://github.com/nano-muse/nanoMuse/issues/340)). The debug request log masks credential headers and keeps its own copy ([#347](https://github.com/nano-muse/nanoMuse/issues/347)). Icon-only controls (the password eye, the add buttons, Like, the avatar tiles) have names for a screen reader, in all 17 languages ([#338](https://github.com/nano-muse/nanoMuse/issues/338)). And the tests: the on-device test set compiles again and CI keeps it so, the upstream provider tests feed the streams the provider actually reads, CI runs the OpenMinis provider and sandbox tests beside ours, and the runtime's web tests no longer depend on the machine's resolver ([#332](https://github.com/nano-muse/nanoMuse/issues/332), [#335](https://github.com/nano-muse/nanoMuse/issues/335), [#339](https://github.com/nano-muse/nanoMuse/issues/339)). All reported by @Mola-maker.
+- **A voice prompt from the Action Button (iPhone).** A *Voice Prompt* Shortcut opens the app and starts listening, so a prompt can be spoken instead of typed: bind it to the Action Button, Back Tap or a widget, press, speak, and the words appear in the composer; sending stays a tap. Written by @cypggs ([#298](https://github.com/nano-muse/nanoMuse/pull/298)).
+- **OpenCode Go on every client.** Go, the subscription beside Zen on the same host, asks a client to name itself and send one id per conversation; the runtime, the phones and the desktop now do, so Go is back in the own-key catalogue, and the relay's guidance lists it apart so that a 1.0.1 phone, which does not send the id, never offers it ([#325](https://github.com/nano-muse/nanoMuse/pull/325) by @PovedaAqui).
+- **A freshly installed desktop update is no longer offered to itself.** The daily update check kept its result for a day and the file outlived the update, so right after installing 1.0.2 the sidebar still said *Update to 1.0.2*; the cached result is now read against the version that runs ([#341](https://github.com/nano-muse/nanoMuse/issues/341), reported by @jiangkaiqi2005).
+- **Smaller things.** The relay's Docker image runs as an unprivileged user (uid 10001) and says in one sentence what to do when its data directory is not its own; the runtime's image reports its health to `docker ps`; the unused `POST /api/avatar/estimate` is gone; every field on the web console's Connections screen is named for a screen reader; the feed's and a goal's daily time is written in plain digits on every phone; every pull request carries its platform and kind labels, and the contributor guide says plainly that an issue stays open until a maintainer closes it.
+- **TestFlight.** Build 16 (1.0.0) is what the public link installs today; builds 18 (1.0.1) and 19 (1.0.2) are still in Apple's beta review; 1.0.3 goes up as build 22 and is submitted to the same review. The link installs a build once its review has passed, the internal testers before that.
+
+## Upgrade Notes
+
+- **Android installs over 1.0.2 and keeps your data.** `nanoMuse-1.0.3-arm64.apk` (Android 8.0+, arm64; versionCode 46) is signed with the same key as every version before it. Verify with `sha256sum -c nanoMuse-1.0.3-arm64.apk.sha256`. The bottom bar, the drawer and the chat look different; nothing else moved.
+- **iPhone and iPad come through TestFlight.** The public link installs build 16 (1.0.0), which passed Apple's beta review; builds 18 and 19 are still in that review, and build 22 (1.0.3) is submitted when it is uploaded; a build reaches the link once its review has passed, the internal testers before that.
+- **The desktop installers are built by CI from `main` one commit after the tag `v1.0.3`** (the tag's Windows build failed on a test that read line endings, fixed in [#376](https://github.com/nano-muse/nanoMuse/pull/376); the desktop sources are those of the tag). The Mac packages are not notarized: the first time, open the app from *System Settings → Privacy & Security → Open Anyway* (or right-click → *Open*). The Windows package is not signed: click *More info* and *Run anyway* once. On Linux prefer the `.deb`; the AppImage needs FUSE (`--appimage-extract-and-run` without). They arrive a little after the APK, and `SHA256SUMS-desktop.txt` lists every checksum. Your one custom endpoint becomes the first entry of the new list; the key stays where it was.
+- **The relay is 0.24.2; nothing changes on the wire.** A 1.0.2 client works against it unchanged. Its Docker image now runs as uid 10001, so a bind-mounted data directory must belong to that uid: `sudo chown -R 10001:10001 data` once (`scripts/self-host.sh` does it for you), and a relay that cannot write the directory says so at start instead of printing a traceback; [docs/self-hosting.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/self-hosting.md) has the steps.
+- **The runtime's HTTP API lost one route.** `POST /api/avatar/estimate` had no caller; everything else is as it was, and `config.toml` keeps every key it had.
+- **Where to get it.**
+
+ | | |
+ |---|---|
+ | Browser | [demo.nanomuse.dev](https://demo.nanomuse.dev/), a nanoMuse on a simulated phone, after a sign-in; a demo, the apps below are the real thing |
+ | Android 8.0+, arm64 | `nanoMuse-1.0.3-arm64.apk` |
+ | iPhone, iPad | [TestFlight](https://testflight.apple.com/join/ZHexbDqc), a beta that passed Apple's review; the link installs build 16 (1.0.0) today, builds 18 (1.0.1), 19 (1.0.2) and 22 (1.0.3) once their review has passed |
+ | Windows 10+ | `nanoMuse-Desktop-1.0.3-win-x64.exe` (the desktop app, on DeepSeek Harness; not signed) |
+ | macOS 12+ | `nanoMuse-Desktop-1.0.3-mac-arm64.dmg` / `-mac-x64.dmg` (or the `.zip`: unzip, drag to Applications; not notarized) |
+ | Linux x64 | `nanoMuse-Desktop-1.0.3-linux-x64.deb` (preferred) / `.AppImage` (needs FUSE; `--appimage-extract-and-run` without) / `.tar.gz` (unpack anywhere, run `./nanomuse-desktop`) |
+ | DeepSeek Harness Desktop you already run | `dsh-nanomuse-1.0.3.tgz`, `dsh plugin --profile desktop add …` ([how](https://github.com/nano-muse/nanoMuse/blob/main/harness/README.md)) |
+ | Docker | `ghcr.io/nano-muse/nanomuse:1.0.3` (also `:latest`), the runtime with the web console on port 8787 |
+ | Your own relay | `bash scripts/self-host.sh`, [docs/self-hosting.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/self-hosting.md) |
+
+ In our measurements GitHub's own downloads are quick from China as well; if they fail where you are, the same files are on [nanomuse.cn/dl/v1.0.3/](https://nanomuse.cn/dl/v1.0.3/) within fifteen minutes.
+
+## Community
+
+nanoMuse is open source, free and non-profit. Sign in with a phone number or an e-mail and you get a starting allowance; the developer pays for it. The account page shows what is left and how to add more. When it is gone, use your own key or a plan you already pay for ([how](https://github.com/nano-muse/nanoMuse/blob/main/docs/own-key.md)); the key you add can take over any of the four jobs, and the account can be switched off as a model source while you keep it for sync and your devices. With an account, the text of your main conversation is kept on nanoMuse Cloud so that your devices show the same chat, side chats only if you say so, and one switch in Data controls turns it off and deletes it; nothing is sold ([privacy policy](https://nanomuse.cn/privacy/)); delete the account whenever you want. The same relay runs on a server of yours: [docs/self-hosting.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/self-hosting.md).
+
+This version is mostly other people's work and other people's eyes. Thanks to @Ichiro786 for the Android redesign ([#329](https://github.com/nano-muse/nanoMuse/pull/329), [#330](https://github.com/nano-muse/nanoMuse/pull/330), [#331](https://github.com/nano-muse/nanoMuse/pull/331)), to @cypggs for the Action Button ([#298](https://github.com/nano-muse/nanoMuse/pull/298)), to @PovedaAqui for OpenCode Go ([#325](https://github.com/nano-muse/nanoMuse/pull/325)), to @Mola-maker for the ten reports ([#332](https://github.com/nano-muse/nanoMuse/issues/332) to [#340](https://github.com/nano-muse/nanoMuse/issues/340), [#347](https://github.com/nano-muse/nanoMuse/issues/347)), and to @Yicheng-1218, @Alasco123 and @jiangkaiqi2005 for [#324](https://github.com/nano-muse/nanoMuse/issues/324), [#294](https://github.com/nano-muse/nanoMuse/issues/294) and [#341](https://github.com/nano-muse/nanoMuse/issues/341). The [roadmap](https://github.com/nano-muse/nanoMuse/blob/main/docs/roadmap.md) says where help is wanted, and [docs/parity.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/parity.md) says what each client has. [Open an issue](https://github.com/nano-muse/nanoMuse/issues/new/choose) · [send a pull request](https://github.com/nano-muse/nanoMuse/blob/main/CONTRIBUTING.md) · [Discussions](https://github.com/nano-muse/nanoMuse/discussions) · [Discord](https://discord.gg/bkTySmm28X) · [star the repo](https://github.com/nano-muse/nanoMuse); if it is useful to you, a star helps others find it.
+
+Based on [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13 (GPL-3.0), modified since 2026-09-24. The complete corresponding source of this build is tag `v1.0.3` plus the `android/deps/proot` submodule ([nano-muse/proot](https://github.com/nano-muse/proot)); the desktop installers and the Docker image are built by CI from the same tag. The whole repository is GPL-3.0-or-later. nanoMuse Harness carries DeepSeek Harness unmodified, with its licence files inside; *harness* in the name is the word, not DeepSeek's. The chat apps follow [nanobot](https://github.com/HKUDS/nanobot)'s design (MIT); the desktop's operator is ported from [UI-TARS-desktop](https://github.com/bytedance/UI-TARS-desktop) (Apache-2.0; `THIRD_PARTY_NOTICES.md`). nanoMuse is not affiliated with Meta; Muse is a trademark of Meta Platforms, Inc. The desktop follows what Muse's app looks like and does, written down from using it; none of Meta's assets, code or content is in it.
+
+
+
+
+简体中文
+
+🐉 **nanoMuse `1.0.3`「Helm」**:聊天在每台设备上长得一样了,可以用自己的一段视频当形象,多个自定义端点能并排用,还有一位细心测试者一天里报来的问题都有了答复。
+
+1.0.2 在 2026-10-11 发出。同一天 @Ichiro786 用三个 PR 做完了 Android App 的改版:底栏变成悬浮的毛玻璃胶囊,聊天抽屉和聊天本身搬到主题自己的底色上、用黑白药丸代替蓝色,顶栏变成一层柔和的遮罩,对话从它底下滑过去([#329](https://github.com/nano-muse/nanoMuse/pull/329)、[#330](https://github.com/nano-muse/nanoMuse/pull/330)、[#331](https://github.com/nano-muse/nanoMuse/pull/331))。我们把这个样子当作参照,带到了 iPhone、网页控制台和桌面端,五个客户端读起来是同一个 App。@Mola-maker 读了 Android 的代码和测试,一个下午报了十个问题,从发不出去的自有 Anthropic key 到存着原始 API key 的调试日志([#332](https://github.com/nano-muse/nanoMuse/issues/332) 到 [#340](https://github.com/nano-muse/nanoMuse/issues/340)、[#347](https://github.com/nano-muse/nanoMuse/issues/347));这里每一个都修了。两个请求做成了功能:用自己的一段视频当智能体的脸([#324](https://github.com/nano-muse/nanoMuse/issues/324)),以及桌面端和网页控制台同时配置多个 OpenAI 兼容端点([#294](https://github.com/nano-muse/nanoMuse/issues/294))。升级的人什么都不用挪:Android 安装包覆盖安装 1.0.2、数据全部保留,桌面端保留设置、原来那一个自定义端点成为列表里的第一项,1.0.2 的客户端对着新中继(0.24.2)照常工作。
+
+### 亮点
+
+- **每台设备上同一个样子。** Android、iPhone 和网页的手机布局里,底栏是一枚悬浮的毛玻璃胶囊,和上面的输入框一样宽(最宽 420,更宽的屏幕上居中),只有图标;当前标签页的填充图形垫在一块墨色圆盘上,浅色主题黑、深色主题白,点下一个标签时它弹过去。四个客户端的聊天都是单色的:你的消息在浅色下是黑底白字的药丸、深色下是白底黑字,智能体的回复在一块比底色略深一级的药丸里,发送键和「回到最新」圆盘用同一种墨色,浅色底是纯白,顶栏是对话从底下滑过去的一层遮罩,而不是带一条线的横条。Android 上聊天抽屉和菜单也在同一个底色上,用一圈细线代替染色的面板([#329](https://github.com/nano-muse/nanoMuse/pull/329)、[#330](https://github.com/nano-muse/nanoMuse/pull/330)、[#331](https://github.com/nano-muse/nanoMuse/pull/331),@Ichiro786;iPhone、网页和桌面端跟着改)。
+- **用自己的一段视频当脸。** Android、iPhone 和桌面端上,脸上的那支笔和形象工作室多了「自己的视频…」:系统的视频选择器,然后在脸的圆框里预览播放,有「使用」和「取消」。一段视频,MP4 或 MOV、H.264 编码(iPhone 也收 HEVC),最长 15 秒、最大 30 MB;它静音循环播放,用于待机状态,也顶替其他每种情绪,画出来的静态图在需要静态图的地方照旧,没有画过脸时用视频的第一帧当静态图。视频只留在选它的那台设备上,不上传 nanoMuse Cloud,也不同步;「默认形象」把它拿掉。更长、更大或编码不同的视频会被拒绝,并用一句话说明限制([#324](https://github.com/nano-muse/nanoMuse/issues/324),@Yicheng-1218 提出)。
+- **多个自定义端点同时用。** 桌面端和网页控制台以前只放得下一个 OpenAI 兼容端点,第二个只能顶掉第一个。现在自定义那一行是一个列表:每个端点有名字、地址、自己的 key 和模型,选择器按端点分组列出模型,任何一个都能接活。已有的那一个端点会直接成为第一项,不用再问。手机 App 本来就是这样([#294](https://github.com/nano-muse/nanoMuse/issues/294),@Alasco123 提出)。
+- **Android 的十个报告,全部修好。** 自有 Anthropic key 又能发了:provider 在每个请求上都去读 Claude Code 的 OAuth 标识,而公开构建里这个值是空的,所以用自有 key 的每条消息还没离开手机就停了([#333](https://github.com/nano-muse/nanoMuse/issues/333))。工具输出里的回车是覆盖而不是抹掉,`AAAA\rBB` 读出来是 `BBAA`,进度行不再截断模型看到的结果([#334](https://github.com/nano-muse/nanoMuse/issues/334))。离开登录页或网络页会取消它的请求,而不是把连接挂到超时([#336](https://github.com/nano-muse/nanoMuse/issues/336))。新一组形象候选不再显示上一组的失败([#337](https://github.com/nano-muse/nanoMuse/issues/337))。刚发出第一条消息就点停止,不再把欢迎语存成模型的回复,并把你的文字放回输入框([#340](https://github.com/nano-muse/nanoMuse/issues/340))。调试请求日志打码凭据头并保存自己的副本([#347](https://github.com/nano-muse/nanoMuse/issues/347))。只有图标的控件(密码的眼睛、添加按钮、点赞、形象格子)给读屏软件加了名字,十七种语言都有([#338](https://github.com/nano-muse/nanoMuse/issues/338))。还有测试:真机测试集又能编译了、CI 会一直盯着,上游 provider 的测试喂的是 provider 真正读的流,CI 在我们的测试之外也跑 OpenMinis 的 provider 和沙箱测试,运行时的网页工具测试不再依赖机器的 DNS([#332](https://github.com/nano-muse/nanoMuse/issues/332)、[#335](https://github.com/nano-muse/nanoMuse/issues/335)、[#339](https://github.com/nano-muse/nanoMuse/issues/339))。都是 @Mola-maker 报的。
+- **操作按钮发起语音提示(iPhone)。** 一个「语音提示」快捷指令打开 App 并开始听,于是提示可以说而不是打:把它绑到操作按钮、轻点背面或小组件上,按下、说话,文字出现在输入框里;发送仍然是点一下。@cypggs 写的([#298](https://github.com/nano-muse/nanoMuse/pull/298))。
+- **OpenCode Go 在每个客户端上可用。** Go 是和 Zen 同一域名上的订阅,要求客户端表明身份并给每个对话发一个 id;运行时、手机和桌面端现在都这么做,所以 Go 回到了自有 key 目录,中继的指引把它单独列出,让不发 id 的 1.0.1 手机永远不会看到它([#325](https://github.com/nano-muse/nanoMuse/pull/325),@PovedaAqui)。
+- **刚装好的桌面端更新不再提示更新到自己。** 每日更新检查的结果会保留一天,文件在更新后还在,所以刚装完 1.0.2 侧栏仍然说「更新到 1.0.2」;现在缓存的结果会对照正在运行的版本读取([#341](https://github.com/nano-muse/nanoMuse/issues/341),@jiangkaiqi2005 报告)。
+- **小的事情。** 中继的 Docker 镜像以非特权用户(uid 10001)运行,数据目录不属于它时会用一句话说明该怎么做;运行时的镜像向 `docker ps` 报告健康状态;没人调用的 `POST /api/avatar/estimate` 删掉了;网页控制台连接页的每个字段都给读屏软件起了名字;动态和目标的每日时间在每部手机上都用普通数字写;每个 PR 都带上平台和类型标签,贡献指南明说 issue 由维护者决定何时关闭、默认留着。
+- **TestFlight。** 公开链接今天装到的是第 16 个构建(1.0.0);第 18 个(1.0.1)和第 19 个构建(1.0.2)还在 Apple 的测试版审核中;1.0.3 以第 22 个构建上传并提交同一审核。审核通过的构建就会给到链接,内部测试员在那之前就能用。
+
+### 升级说明
+
+- **Android 覆盖安装 1.0.2,数据保留。** `nanoMuse-1.0.3-arm64.apk`(Android 8.0+,arm64;versionCode 46)和之前每个版本用同一把签名。校验:`sha256sum -c nanoMuse-1.0.3-arm64.apk.sha256`。底栏、抽屉和聊天的样子变了,其他什么都没挪。
+- **iPhone 和 iPad 通过 TestFlight。** 公开链接装到的是通过了 Apple 测试版审核的第 16 个构建(1.0.0);第 18 和第 19 个构建还在审核中,第 22 个构建(1.0.3)上传后送审;哪个构建审核通过,链接就给到哪个,内部测试员在那之前就能用。
+- **桌面安装包由 CI 从 tag `v1.0.3` 之后一个提交的 `main` 构建**(tag 上的 Windows 构建卡在一个读换行符的测试上,已在 [#376](https://github.com/nano-muse/nanoMuse/pull/376) 修好;桌面端的源码和 tag 的一样)。 Mac 的包没有经过公证:第一次在「系统设置 → 隐私与安全性」里点「仍要打开」(或右键 → 「打开」)。Windows 的包没有签名:点一次「更多信息」和「仍要运行」。Linux 上优先用 `.deb`;AppImage 需要 FUSE(没有就加 `--appimage-extract-and-run`)。它们比 APK 晚一点到,`SHA256SUMS-desktop.txt` 列出了所有校验值。你原来那一个自定义端点成为新列表的第一项,key 留在原处。
+- **中继是 0.24.2,线上什么都没变。** 1.0.2 的客户端对着它照常工作。它的 Docker 镜像现在以 uid 10001 运行,所以绑定挂载的数据目录必须属于这个 uid:执行一次 `sudo chown -R 10001:10001 data`(`scripts/self-host.sh` 会替你做),写不了目录的中继会在启动时说明,而不是打印一段 traceback;步骤见 [docs/self-hosting.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/zh/self-hosting.md)。
+- **运行时的 HTTP API 少了一条路由。** `POST /api/avatar/estimate` 没有人调用;其他一切照旧,`config.toml` 里的每个键都保持原样。
+- **去哪下载。**
+
+ | | |
+ |---|---|
+ | 浏览器 | [demo.nanomuse.dev](https://demo.nanomuse.dev/),模拟手机里的一个 nanoMuse,登录后体验;这是演示,下面的应用才是正式版 |
+ | Android 8.0+,arm64 | `nanoMuse-1.0.3-arm64.apk` |
+ | iPhone、iPad | [TestFlight](https://testflight.apple.com/join/ZHexbDqc),通过了 Apple 审核的测试版;链接今天装到第 16 个构建(1.0.0),第 18 个(1.0.1)、第 19 个(1.0.2)和第 22 个构建(1.0.3)审核通过后即可安装 |
+ | Windows 10+ | `nanoMuse-Desktop-1.0.3-win-x64.exe`(桌面 App,基于 DeepSeek Harness;未签名) |
+ | macOS 12+ | `nanoMuse-Desktop-1.0.3-mac-arm64.dmg` / `-mac-x64.dmg`(或 `.zip`:解压后拖进「应用程序」;未公证) |
+ | Linux x64 | `nanoMuse-Desktop-1.0.3-linux-x64.deb`(优先)/ `.AppImage`(需要 FUSE;没有就加 `--appimage-extract-and-run`)/ `.tar.gz`(解压到任意位置,运行 `./nanomuse-desktop`)|
+ | 已在用 DeepSeek Harness Desktop | `dsh-nanomuse-1.0.3.tgz`,`dsh plugin --profile desktop add …`([怎么装](https://github.com/nano-muse/nanoMuse/blob/main/harness/README.md)) |
+ | Docker | `ghcr.io/nano-muse/nanomuse:1.0.3`(也是 `:latest`),运行时加 8787 端口上的网页控制台 |
+ | 自己的中继 | `bash scripts/self-host.sh`,[docs/self-hosting.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/zh/self-hosting.md) |
+
+ 实测从国内直接下 GitHub 也不慢;万一下不动,同样的文件十五分钟内会出现在 [nanomuse.cn/dl/v1.0.3/](https://nanomuse.cn/dl/v1.0.3/)。
+
+### 社区
+
+nanoMuse 开源、免费、非营利。手机号或邮箱登录后有一份起始额度,钱是开发者出的;剩多少、怎么加,账号页里有数。用完可以换成自己的 key,或者用你已经在付费的套餐登录([教程](https://github.com/nano-muse/nanoMuse/blob/main/docs/zh/own-key.md));你加的 key 可以接手四件事里的任何一件,账号也可以作为模型来源关掉,只留着做同步和管设备。登录后,主聊天的文字会保存在 nanoMuse Cloud,让你的几台设备看到同样的对话,旁聊只在你说要的时候才上去;「数据控制」里一个开关就能关掉并删除;数据不卖([隐私政策](https://nanomuse.cn/privacy/));账号想删就删。同一个中继也能跑在你自己的服务器上:[docs/self-hosting.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/zh/self-hosting.md)。
+
+这个版本大半是别人的手和别人的眼睛。谢谢 @Ichiro786 的 Android 改版([#329](https://github.com/nano-muse/nanoMuse/pull/329)、[#330](https://github.com/nano-muse/nanoMuse/pull/330)、[#331](https://github.com/nano-muse/nanoMuse/pull/331)),谢谢 @cypggs 做的操作按钮([#298](https://github.com/nano-muse/nanoMuse/pull/298)),谢谢 @PovedaAqui 的 OpenCode Go([#325](https://github.com/nano-muse/nanoMuse/pull/325)),谢谢 @Mola-maker 的十个报告([#332](https://github.com/nano-muse/nanoMuse/issues/332) 到 [#340](https://github.com/nano-muse/nanoMuse/issues/340)、[#347](https://github.com/nano-muse/nanoMuse/issues/347)),也谢谢 @Yicheng-1218、@Alasco123 和 @jiangkaiqi2005 提出 [#324](https://github.com/nano-muse/nanoMuse/issues/324)、[#294](https://github.com/nano-muse/nanoMuse/issues/294) 和 [#341](https://github.com/nano-muse/nanoMuse/issues/341)。[路线图](https://github.com/nano-muse/nanoMuse/blob/main/docs/zh/roadmap.md)写着哪里需要帮手,[docs/parity.md](https://github.com/nano-muse/nanoMuse/blob/main/docs/zh/parity.md) 写着每个客户端有什么。[提 issue](https://github.com/nano-muse/nanoMuse/issues/new/choose) · [发 PR](https://github.com/nano-muse/nanoMuse/blob/main/CONTRIBUTING.md) · [Discussions](https://github.com/nano-muse/nanoMuse/discussions) · [Discord](https://discord.gg/bkTySmm28X) · [点个 Star](https://github.com/nano-muse/nanoMuse);觉得有用,点个 Star,让更多人看到。
+
+基于 [OpenMinis](https://github.com/OpenMinis/OpenMinis) 1.13(GPL-3.0),自 2026-09-24 起修改;本版本的完整对应源码是 tag `v1.0.3` 加子模块 `android/deps/proot`([nano-muse/proot](https://github.com/nano-muse/proot));桌面安装包和 Docker 镜像由 CI 从同一个 tag 构建。整个仓库以 GPL-3.0-or-later 发布。nanoMuse Harness 原样携带 DeepSeek Harness 及其许可证文件;名字里的 *harness* 是普通词,不是 DeepSeek 的名字。聊天入口照 [nanobot](https://github.com/HKUDS/nanobot) 的设计(MIT);桌面端的操作器移植自 [UI-TARS-desktop](https://github.com/bytedance/UI-TARS-desktop)(Apache-2.0;`THIRD_PARTY_NOTICES.md`)。nanoMuse 与 Meta 无关,Muse 是 Meta Platforms, Inc. 的商标。桌面端照着 Muse 应用用起来的样子做,是用过之后写下来的;里面没有 Meta 的任何素材、代码或内容。
+
+
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 1dc34ec7..7d35ae2b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -100,3 +100,4 @@ Every version is a GitHub release built from its tag, titled `nanoMuse
| 1.0.0 | Keel | The first stable version: the phone app for Android and the iPhone, the desktop app for the Mac, Windows and Linux, the web console and the relay, one account across them; nanoMuse Cloud can be switched off as a model source and a device with a key of its own works signed out; the main chat follows the chat model you pick; folded, searchable model pickers; a thinking level for your own key on the desktop; *I already starred*; the desktop waits for a slow Windows start; the reply follows the language of the app; `auto` asks before a tainted send and on a warning; the hub and the relay hardened; no dashes in anything a person reads |
| 1.0.1 | Ballast | The phone app has its own URL scheme, so it no longer collides with OpenMinis; a content check saying no is one sentence on every client and counted apart on the relay's Health page; a chat the desktop received from another device scrolls to its start, the glow stays up for a whole hands run and the face moves on Windows; one turn on nanoMuse Cloud on request from the web; the TestFlight beta passed Apple's review |
| 1.0.2 | Trim | A pass over every part of the project: the Sentinel's taint rule holds for `shell` and `send_email` in auto mode; the Android app starts beside OpenMinis; the desktop's permission card comes up for the hands' refusals, a picture on Linux no longer kills the harness and the Windows caption buttons cover nothing; the memory tidy-up refuses what it cannot judge; `doctor` names a config key nothing reads; clearer errors in the web console and the CLI; the iPhone's pages in all nine languages; the docs without em dashes |
+| 1.0.3 | Helm | The chat reads the same on every device: a floating frosted-glass bottom bar as wide as the composer, black and white pills on the theme's canvas and a scrim for a header, from @Ichiro786's Android redesign to the iPhone, the web console and the desktop; a clip of your own as the face on Android, the iPhone and the desktop; several custom OpenAI-compatible endpoints at once on the desktop and in the web console; ten Android reports from one afternoon fixed, an own Anthropic key sending again among them; a voice prompt from the iPhone's Action Button; OpenCode Go on every client; the relay's image runs as an unprivileged user |
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index ef87fafc..54edbe19 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -4,13 +4,13 @@ nanoMuse is one agent on your own devices, and nothing in it has to go through a
| | What you run | What it gives you |
|---|---|---|
-| [1. No server, your own key](#1-no-server-your-own-key) | nothing | the apps talk to a model provider directly; the community relay is still used for sign-in and for the devices to find each other |
-| [2. Your own relay](#2-your-own-relay) | nanoMuse Cloud on a small VPS or a home server | your own accounts, sign-in codes, hub and conversation sync; nothing about your account touches the public relay |
-| [3. A runtime of your own](#3-a-runtime-of-your-own-for-the-web-app) | the Python runtime in Docker | the web app and the hands on a computer that is always on |
+| [1. No server, your own key](#_1-no-server-your-own-key) | nothing | the apps talk to a model provider directly; the community relay is still used for sign-in and for the devices to find each other |
+| [2. Your own relay](#_2-your-own-relay) | nanoMuse Cloud on a small VPS or a home server | your own accounts, sign-in codes, hub and conversation sync; nothing about your account touches the public relay |
+| [3. A runtime of your own](#_3-a-runtime-of-your-own-for-the-web-app) | the Python runtime in Docker | the web app and the hands on a computer that is always on |
## 1. No server, your own key
-The free allowance on the community relay runs out. When it does, or before, put a key of your own in the app — Alibaba Model Studio (百炼) inside mainland China, OpenRouter or any OpenAI-compatible endpoint elsewhere — and the model requests go from your device to that provider; the relay only sees the sign-in and the hub frames between your devices. [own-key.md](own-key.md) has the steps for every app. Nothing to install, nothing to keep running.
+The free allowance on the community relay runs out. When it does, or before, put a key of your own in the app: Alibaba Model Studio (百炼) inside mainland China, OpenRouter or any OpenAI-compatible endpoint elsewhere. The model requests then go from your device to that provider; the relay only sees the sign-in and the hub frames between your devices. [own-key.md](own-key.md) has the steps for every app. Nothing to install, nothing to keep running.
## 2. Your own relay
@@ -18,9 +18,9 @@ nanoMuse Cloud is the relay the apps and the desktop sign in to: sign-in by e-ma
### What you need
-- **A machine.** A VPS with 1 vCPU and 1 GB of memory is enough for a family; the relay idles at a few tens of megabytes and the model work happens at the provider. Such a server costs on the order of ¥30–60 a month inside mainland China or US$4–6 outside it, from any provider — the relay does not care which. A home server or a spare laptop works for a LAN-only relay.
+- **A machine.** A VPS with 1 vCPU and 1 GB of memory is enough for a family; the relay idles at a few tens of megabytes and the model work happens at the provider. Such a server costs on the order of ¥30–60 a month inside mainland China or US$4–6 outside it, from any provider; the relay does not care which. A home server or a spare laptop works for a LAN-only relay.
- **A domain** pointing at the machine (an A record), with ports 80 and 443 reachable. Caddy, which runs next to the relay, fetches and renews the Let's Encrypt certificate on its own. Skip this for a LAN-only relay.
-- **A model provider key.** The relay spends *your* money on *your* provider: everyone who signs in on your relay is billed to that key, at the provider's prices, within the allowance you set (`ALLOWANCE_CNY`, `SIGNUP_OPEN`, members in `ALLOWED_IDENTIFIERS` — [cloud/README.md](../cloud/README.md#settings)). Without a key, sign-in and the hub work and chat does not.
+- **A model provider key.** The relay spends *your* money on *your* provider: everyone who signs in on your relay is billed to that key, at the provider's prices, within the allowance you set (`ALLOWANCE_CNY`, `SIGNUP_OPEN`, members in `ALLOWED_IDENTIFIERS`; [cloud/README.md](../cloud/README.md#settings)). Without a key, sign-in and the hub work and chat does not.
- **Docker** with the compose plugin, and `curl`.
### In one command
@@ -30,22 +30,22 @@ git clone https://github.com/zeeshanhaque21/nanoMuse.git && cd nanoMuse
bash scripts/self-host.sh
```
-The script asks five things — the domain (or `local`), an e-mail for Let's Encrypt, how sign-in codes go out, the provider's URL and key, the admin password — writes `cloud/.env` with a fresh random `CLOUD_SECRET`, starts `docker compose`, waits for `/healthz` and prints the console and admin URLs. Running it again keeps the secrets and the accounts and only re-asks, with the current values as defaults. For this machine only, no domain and no TLS:
+The script asks five things: the domain (or `local`), an e-mail for Let's Encrypt, how sign-in codes go out, the provider's URL and key, the admin password. It writes `cloud/.env` with a fresh random `CLOUD_SECRET`, starts `docker compose`, waits for `/healthz` and prints the console and admin URLs. Running it again keeps the secrets and the accounts and only re-asks, with the current values as defaults. For this machine only, no domain and no TLS:
```bash
bash scripts/self-host.sh --local # http://127.0.0.1:8787, codes in the relay's log
bash scripts/self-host.sh --local --bind 0.0.0.0 # reachable from the phones on the same network
```
-By hand instead of the script: `cd cloud && cp .env.example .env`, fill in `CLOUD_DOMAIN`, `PUBLIC_BASE`, `ACME_EMAIL`, `CLOUD_SECRET`, `CLOUD_ADMIN_TOKEN`, `UPSTREAM_KEY` and the sender settings, then `docker compose up -d` — or, without Caddy, `docker compose -f docker-compose.yml -f docker-compose.local.yml up -d`.
+By hand instead of the script: `cd cloud && cp .env.example .env`, fill in `CLOUD_DOMAIN`, `PUBLIC_BASE`, `ACME_EMAIL`, `CLOUD_SECRET`, `CLOUD_ADMIN_TOKEN`, `UPSTREAM_KEY` and the sender settings, then `docker compose up -d`; or, without Caddy, `docker compose -f docker-compose.yml -f docker-compose.local.yml up -d`. The relay runs as uid 10001 inside its container, and `cloud/data`, the bind-mounted data directory, must belong to that uid on the host: `mkdir -p data && sudo chown -R 10001:10001 data` before the first start, and once more when upgrading from a relay whose image ran as root (the script does this for you; a relay that cannot write the directory says so at start and stops).
### Sign-in codes
A code goes out every time someone signs in. `CODE_SENDER` says how:
-- `log` — printed to the relay's log only: `cd cloud && docker compose logs relay` shows `verification code for …`. Fine for a family: you read the code out. This is what `--local` uses.
-- `smtp` — by e-mail through a mailbox you own (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`). Any mailbox with SMTP works; a free one is enough for a few people.
-- `aliyun` — by SMS to mainland-China numbers through Aliyun Dysmsapi; needs a signed template. `both` uses SMTP for addresses and Aliyun for numbers. SMS reaches mainland numbers only; everyone else signs in by e-mail.
+- `log`: printed to the relay's log only (`cd cloud && docker compose logs relay` shows `verification code for …`). Fine for a family: you read the code out. This is what `--local` uses.
+- `smtp`: by e-mail through a mailbox you own (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`). Any mailbox with SMTP works; a free one is enough for a few people.
+- `aliyun`: by SMS to mainland China numbers through Alibaba Cloud: 号码认证服务 by default (`ALIYUN_SMS_API=dypns`, its ready-made templates), or 短信服务 with a signed template of your own (`ALIYUN_SMS_API=dysms`); the variables are in `cloud/.env.example`. `both` uses SMTP for addresses and Aliyun for numbers. SMS reaches mainland numbers only; everyone else signs in by e-mail.
### The admin page
@@ -55,9 +55,9 @@ A code goes out every time someone signs in. `CODE_SENDER` says how:
An app signs in against the relay its `base_url` names; with none set it does not sign in until you name one. The devices of one account must all point at the same relay — the hub and the conversations live there.
-- **Android** (0.1.38). *Use a different server* under the sign-in form takes the relay's address; *Check* asks its `/healthz` and shows the version, *Use this server* keeps the address across launches. `https://` is required outside your own network; plain `http://` is accepted for a private address (`10.x`, `172.16–31.x`, `192.168.x`, `localhost`, a `.local` or `.ts.net` name). Settings → Account shows the server with *Change*, which signs the phone out first ([android.md](android.md)).
+- **Android** (0.1.38). *Use a different server* under the sign-in form takes the relay's address; *Check* asks its `/healthz` and shows the version, *Use this server* keeps the address across launches. `https://` is required outside your own network; plain `http://` is accepted for a private address (`10.x`, `172.16-31.x`, `192.168.x`, `localhost`, a `.local` or `.ts.net` name). Settings → Account shows the server with *Change*, which signs the phone out first ([android.md](android.md)).
- **iPhone / iPad.** The same link on the sign-in sheet, with the same rules.
-- **nanoMuse Desktop.** The relay is the `baseURL` of the plugin's cloud row. Add this to `cordis.patch.yml` in the desktop's profile — `~/.nanomuse/desktop/profiles/nanomuse/` (`%USERPROFILE%\.nanomuse\desktop\profiles\nanomuse\` on Windows; `NANOMUSE_DESKTOP_HOME` moves it) — and restart the app:
+- **nanoMuse Desktop.** The relay is the `baseURL` of the plugin's cloud row. Add this to `cordis.patch.yml` in the desktop's profile, `~/.nanomuse/desktop/profiles/nanomuse/` (`%USERPROFILE%\.nanomuse\desktop\profiles\nanomuse\` on Windows; `NANOMUSE_DESKTOP_HOME` moves it), and restart the app:
```yaml
- id: nanomuse-cloud
@@ -70,7 +70,7 @@ An app signs in against the relay its `base_url` names; with none set it does no
Setting `NANOMUSE_CLOUD_URL=https://cloud.example.com` in the environment the app starts in does the same without editing the file ([desktop.md](desktop.md)).
- **The terminal runtime and the hosted web app** (`nanomuse …`, the root `docker-compose.yml`): `NANOMUSE_CLOUD_BASE_URL=https://cloud.example.com` in the environment or `.env`, or `[cloud] base_url` in `config/config.toml` ([configuration.md](configuration.md)).
-- **The web console** is the relay's own `/app` — `https:///app/` — and needs no pointing.
+- **The web console** is the relay's own `/app` (`https:///app/`) and needs no pointing.
Invitations from your relay link to its console (`INVITE_URL`, set by the script), so a friend you invite lands on your relay.
@@ -86,22 +86,32 @@ The database schema migrates forward on start. Read the release notes before a j
### Backing up
-Everything the relay keeps is `cloud/data/cloud.db` (SQLite) plus `CLOUD_SECRET` in `cloud/.env`. The database is useless without the secret — the identifiers are hashed and encrypted with it — and the secret is useless without the database; back them up together, and never rotate the secret unless you mean to start over:
+Everything the relay keeps is `cloud/data/cloud.db` (SQLite) plus `CLOUD_SECRET` in `cloud/.env`. The database is useless without the secret (the identifiers are hashed and encrypted with it, and so is a sign-in code still waiting to be used, which is only six digits), and the secret is useless without the database; back them up together, and never rotate the secret unless you mean to start over:
```bash
-cd cloud && docker compose exec relay sqlite3 /srv/nanomuse-cloud/data/cloud.db ".backup /srv/nanomuse-cloud/data/backup.db" \
- || cp data/cloud.db data/backup.db # when the container has no sqlite3: stop the relay first for a clean copy
+cd cloud && docker compose exec -T relay python - > cloud-$(date -u +%Y%m%d).db <<'EOF'
+import os, shutil, sqlite3, sys
+src = sqlite3.connect("/srv/nanomuse-cloud/data/cloud.db")
+dst = sqlite3.connect("/tmp/backup.db")
+src.backup(dst)
+dst.close(); src.close()
+with open("/tmp/backup.db", "rb") as f:
+ shutil.copyfileobj(f, sys.stdout.buffer)
+os.remove("/tmp/backup.db")
+EOF
```
+The copy is taken with SQLite's own backup API inside the running container (the image has no `sqlite3` command) and streamed out, so it is consistent while the relay keeps writing and lands as a file of yours, outside the data directory that belongs to uid 10001. A plain `cp data/cloud.db` is not consistent: under WAL the newest writes sit in `cloud.db-wal`, and a copy of the main file alone misses them.
+
Caddy's certificates live in the `caddy_data` volume and are fetched again if lost.
### What your relay stores
-Hashed and encrypted identifiers, per-request counts and prices, the devices on the hub, and — only while *Sync conversations between my devices* is on — the text of the conversations so the other devices can show them. No files, no screenshots, no passwords. The full list, and what each switch does, is [privacy.md](privacy.md); as the operator you are the one it now describes.
+Hashed and encrypted identifiers, per-request counts and prices, the devices on the hub, and, only while *Sync conversations between my devices* is on, the text of the conversations so the other devices can show them. No files, no screenshots, no passwords. The full list, and what each switch does, is [privacy.md](privacy.md); as the operator you are the one it now describes.
## 3. A runtime of your own, for the web app
-The phone and the desktop carry their own agent. The web app does not — it talks to the Python runtime, which in the upstream showcase runs in a container per visitor. You can run that runtime on a machine of your own, in Docker, and open the web app on it from any browser:
+The phone and the desktop carry their own agent. The web app does not; it talks to the Python runtime, which in the upstream showcase runs in a container per visitor. You can run that runtime on a machine of your own, in Docker, and open the web app on it from any browser:
```bash
cp .env.example .env && $EDITOR .env # a model key, NANOMUSE_SERVER_TOKEN; NANOMUSE_CLOUD_BASE_URL for your own relay
@@ -109,4 +119,4 @@ nanomuse config init # or copy config/config.example.toml t
docker compose up -d app # the web app on http://:8787, the URL with its token in the logs
```
-The container sees only its data volume and `./workspace`; capabilities are dropped. Keeping it running, a published image for arm64, and reaching it from outside your network are in [deployment.md](deployment.md). The runtime signs in to a relay like any other device — yours, from section 2, if you set `NANOMUSE_CLOUD_BASE_URL` — and then the phone can hand it tasks over the hub like a computer that is always on ([every-device.md](every-device.md)).
+The container sees only its data volume and `./workspace`; capabilities are dropped. Keeping it running, a published image for arm64, and reaching it from outside your network are in [deployment.md](deployment.md). The runtime signs in to a relay like any other device (yours, from section 2, if you set `NANOMUSE_CLOUD_BASE_URL`), and then the phone can hand it tasks over the hub like a computer that is always on ([every-device.md](every-device.md)).
diff --git a/docs/sentinel.md b/docs/sentinel.md
index 807b8454..88413717 100644
--- a/docs/sentinel.md
+++ b/docs/sentinel.md
@@ -15,8 +15,8 @@ Every tool declares a static `risk`:
| Level | Meaning | Examples |
|---|---|---|
-| `safe` | reversible, local | `files` (inside the workspace), `web_search`, `remember`, `goals`, `contacts`, `skills` (list, use), `phone_screen` |
-| `moderate` | reaches outside or changes state | `web_fetch`, `python_execute`, `read_emails`, `browser`, `forget`, `phone_act` |
+| `safe` | reversible, local | `files` (inside the workspace), `web_search`, `remember`, `goals`, `reminders` and `triggers` (list, cancel, bookkeeping), `contacts`, `skills` (list, use), `phone_screen` |
+| `moderate` | reaches outside or changes state | `web_fetch`, `python_execute`, `read_emails`, `browser`, `forget`, `phone_act`, `goals`, `reminders` and `triggers` (create: standing work the model takes up later on its own, so `strict` mode asks before it is set up) |
| `sensitive` | hard to undo or externally visible | `shell`, `send_email`, `skills` (save, remove; a skill is standing instructions the model will follow later, so writing one always asks) |
A tool can raise the level for a particular call in `assess()`: `shell` attaches a warning on patterns such as `rm -rf`, `sudo`, `curl | sh`; `python_execute` is `moderate` for plain computation and files in the workspace (inside a working sandbox; without one, on macOS, Windows or a Linux box without bubblewrap, every script is `sensitive`, since the static check is then the only wall) and `sensitive` with the reason on the card when the code reaches the network, starts other programs, reads environment variables, deletes files or touches paths outside the workspace; `web_fetch` refuses private and loopback addresses outright, on every redirect hop; `phone_act` is `sensitive` with a warning when the `label` of a tap (the operator's words for what is under the finger) contains 确认支付, 转账, 提交订单, 发送, 删除 or another of `[gui] sensitive_words`, or when a step types and submits at once; a tap with no label carries a warning of its own ([gui.md](gui.md)). Tools also declare `reads_private_data` (taints the conversation) and `egress` with an optional `egress_target` (the host a call sends data to; unknown for `shell` and `python_execute`; the app's id for the phone), used by taint tracking.
@@ -99,7 +99,7 @@ nanomuse vault list # names only
nanomuse vault delete EMAIL_PASSWORD
```
-Config values (the model's key, mail, calendar and contact links, the search key) reference secrets as `{{vault:NAME}}`; the placeholder is what the model sees, and the connector is built with the real value. Tool arguments do **not** carry secrets: no production tool opts in (`accepts_secrets`), so a `{{vault:NAME}}` in a `shell` or `python_execute` call is refused with the reason rather than run with the literal text (a tool of your own may opt in; Sentinel then substitutes immediately before execution). If a secret shows up in any tool's output it is replaced with `{{vault:NAME}}` before the model reads it. The key is `/vault.key` (created on first use, mode 600) or `NANOMUSE_VAULT_KEY`.
+Config values (the model's key, mail, calendar and contact links, the search key) reference secrets as `{{vault:NAME}}`; the placeholder is what the model sees, and the connector is built with the real value. Tool arguments do **not** carry secrets: no production tool opts in (`accepts_secrets`), so a `{{vault:NAME}}` in a `shell` or `python_execute` call is refused with the reason rather than run with the literal text (a tool of your own may opt in; Sentinel then substitutes immediately before execution). If a secret shows up in any tool's output it is replaced with `[REDACTED:NAME]` before the model reads it. The key is `/vault.key` (created on first use, mode 600) or `NANOMUSE_VAULT_KEY`.
## Audit log
@@ -119,7 +119,7 @@ Meta Muse runs each user's agent in its own cloud VM with the Sentinel outside i
**Covered**
- *The model acting beyond what you asked.* Every tool call goes through the policy; sensitive and dangerous calls stop for a decision; permissions are scoped to a tool and a target and can be revoked.
-- *Prompt injection that tries to exfiltrate.* Once private data has been read, egress to any host not on the allowlist asks first, with the destination on the card. `web_fetch` cannot be pointed at loopback, link-local, private or cloud-metadata addresses, including through redirects.
+- *Prompt injection that tries to exfiltrate.* Once private data has been read, egress to any host not on the allowlist asks first, with the destination on the card. `web_fetch` cannot be pointed at loopback, link-local, private, carrier-grade NAT (100.64.0.0/10, a Tailscale network) or cloud-metadata addresses, in IPv4 or IPv6, including through redirects.
- *The model seeing your secrets.* Keys and passwords live in the encrypted vault and enter tool calls as placeholders; they are substituted after approval and redacted from results. Subprocesses do not inherit credential-looking environment variables, and in the sandbox cannot read the vault, the data directory or anything else under your home. The model never sees the access token of the app.
- *Not knowing what happened.* Everything is in `audit.jsonl` and the Activity view.
diff --git a/docs/showcase.md b/docs/showcase.md
index aca3ade0..5c528a7f 100644
--- a/docs/showcase.md
+++ b/docs/showcase.md
@@ -65,7 +65,7 @@ The second rung (a page with your login, in the app) before any screen: 京东 h
Third rung: a public site, no login, browsed in the background while you do something else. The agent does not book; "只看不订" is also what the `browser` tool's Sentinel rules enforce for order and payment buttons.
-**Trace:** see the note at the end of this page for the run on 2026-09-24.
+**Trace:** *pending*: tried on the computer on 2026-09-24, where 去哪儿 sent the headless desktop Chromium to its homepage and rendered only the footer (bot detection); 21 tool calls, then stopped. To be run through the phone's WebView, the backend meant for it; the [launch checklist](launch-checklist.md#9-chinese-services-and-the-showcase-p0) tracks it.
## 5. Book a meeting and tell the group
@@ -82,7 +82,7 @@ Two CLIs, two approvals, no app opened. **Trace:** *pending*: 腾讯会议 and
## 6. Clipboard → calendar + alarm
-> (copies a message: 「周五上午10点,海淀区中关村大街1号3层会议室,带上合同」)把剪贴板里的这个安排加到日历,提前一小时给我设个闹钟。
+> (copies a message: 「周五上午 10 点,海淀区中关村大街 1 号 3 层会议室,带上合同」)把剪贴板里的这个安排加到日历,提前一小时给我设个闹钟。
| Hand | What it does |
|---|---|
@@ -118,7 +118,7 @@ Four more screen cases are written up in the `train-tickets` and `phone-messages
## Never in public material
-The agent can read 微信 on the phone's screen when you ask it to, and some people will. It does not appear in the showcase, the site or the film, nor do 支付宝 statements, 医保 or 个税: services whose terms forbid automation or whose data should not be in a demo.
+The agent can read 微信 on the phone's screen when you ask it to, and some people will. The real app does not appear in the showcase, the site or the film (the hosted demo's phone is a simulator, and its 微信 and 铁路12306 are re-creations with seeded data), nor do 支付宝 statements, 医保 or 个税: services whose terms forbid automation or whose data should not be in a demo.
## Reading the trail
diff --git a/docs/trial.md b/docs/trial.md
index 1bdbd109..9f54b953 100644
--- a/docs/trial.md
+++ b/docs/trial.md
@@ -106,8 +106,9 @@ device (section 1 and 2 above). Nobody there, and the caller hears that it was n
## 6. What follows you
-Signed in, the main conversation and the side chats follow you to your other devices; *Data
-controls* on each device switches that off ([sync.md](sync.md)). The agent's name and face
+Signed in, the main conversation follows you to your other devices, and the side chats too
+once *Also sync side chats* is on; *Data controls* on each device holds both switches
+([sync.md](sync.md)). The agent's name and face
are the account's. A connector signed in on one device shows on the others as *Connected
on 〈device〉*, one tap to sign in there too ([hub.md](hub.md)). Models are chosen per device:
each one's *Settings → Models* is its own.
diff --git a/docs/web.md b/docs/web.md
index 7cf21851..f0717790 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -36,6 +36,38 @@ requests only (`[llm] proxy`; nanoMuse Cloud never goes through it, a SOCKS
address needs `httpx[socks]` on the runtime); a saved password comes back as
dots, and an emptied field clears it.
+**Connections → Chat model → Your own endpoints.** A server that speaks the
+OpenAI API but is not in the catalogue (a gateway, a relay, vLLM or LM Studio
+on another machine) is an *endpoint* of the runtime's list, and the list holds
+as many as you use (#294). Under *Local or your own endpoint* each saved
+endpoint is a tile, reading its name over its host, and the last tile is *Add
+an endpoint*: a name (optional; the host otherwise), the base URL, the API key
+(or *No key*), and the ids of its models when it does not list them at
+`/models` (the form says so when it does not). The key goes into the vault
+under the endpoint's own name (`ENDPOINT__API_KEY`); adding a second
+endpoint never touches the first's. With an endpoint's tile chosen, a card
+under the tiles says where its key is, how many models it serves and which
+slots answer through it, with *Rename*, *Change key*, *Edit models*, *Ask for
+its models* and *Remove*; the chat model picker lists the endpoint's models,
+and *Save* sets the chat slot by the endpoint's id (`PUT /api/connections/llm
+{"endpoint": "", "model": ...}`), never by retyping its address or key.
+Renaming, rekeying or moving an endpoint reaches every slot that uses it. The
+*Hands model* picker gains one group per other endpoint of the list; a model
+picked there sends the hands' screenshots through that endpoint with its key
+(`PUT /api/connections/gui {"endpoint": "", "model": ...}`), and the
+*Making pictures* and *Making clips* selects list the endpoints under *Your
+endpoints* the same way (`PUT /api/connections/image` / `/video` with
+`endpoint` and `model`). Removing an endpoint is refused while the chat model
+answers through it (pick another chat model first); hands, pictures or clips
+set on it go back to *Automatic*, and its key leaves the vault. A chat model
+set on *Other OpenAI-compatible endpoint* before the list existed becomes
+endpoint number one on the runtime's next start, its key copied to the
+endpoint's own vault entry; nothing is asked. The list is `GET
+/api/connections` → `endpoints`, each `{id, label, base_url, key_source,
+models, slots}`, never a key; the routes are in [app.md](app.md#api). A
+runtime without the list offers the single *Other OpenAI-compatible endpoint*
+tile as before.
+
**Connections → Making pictures, Making clips.** The two media rows of the
Models contract (0.1.41), one card each under the chat model. The value is
`provider · model` as the runtime resolves it (`GET /api/connections` →
diff --git a/docs/zh/android.md b/docs/zh/android.md
index e2cab1a8..4d218ecc 100644
--- a/docs/zh/android.md
+++ b/docs/zh/android.md
@@ -1,16 +1,26 @@
# Android App
`android/` 是手机上的 nanoMuse:[OpenMinis](https://github.com/OpenMinis/OpenMinis)
-1.13(GPL-3.0)——一个原生 App,智能体**在手机上**运行,Linux shell、浏览器、MCP 服务器、
-技能和定时任务都装在 APK 里——再加上 nanoMuse 自己的身份、设计和功能。属于 nanoMuse 的一切
-都在 `io.github.nanomuse.*` 下;OpenMinis 文件里的改动用 `// nanoMuse:` 标出。这个选择的来龙去脉
+1.13(GPL-3.0):一个原生 App,智能体**在手机上**运行,Linux shell、浏览器、MCP 服务器、
+技能和定时任务都装在 APK 里,再加上 nanoMuse 自己的身份、设计和功能。属于 nanoMuse 的一切
+都在 `io.github.nanomuse.*` 下;OpenMinis 文件里的改动用 `// nanoMuse:` 标出。应用 id 是
+`io.github.nanomuse.app`,系统认得这个 App 的 URL scheme 是 `nanomuse://`,这样同一部手机上也装了
+OpenMinis 时,不会有两个 App 抢同一个 scheme;App 内部的链接仍是 `minis://…`(上游的词汇,沙箱、
+系统提示词和模型都用它),从系统进来的 `nanomuse://` 链接按它的 `minis://` 形式读,App 自己打开
+自己的链接时一律按名字指向 `MainActivity`,不交给系统去解析
+(`io.github.nanomuse.deeplink.OwnScheme`)。沙箱的 offload 套接字(proot 和 App 之间通信的
+抽象套接字)也是 App 自己的:叫 `io.github.nanomuse.app.native-offload`,不用上游的
+`native-offload`,因为抽象套接字在整台设备上只有一个命名空间,两个 App 用同一个名字,后启动的那个
+会在启动时直接死掉;如果连这个名字都还被我们自己上一个进程占着,服务端就改绑一个带进程号的名字,
+并把它告诉 proot(`io.github.nanomuse.sandbox.OffloadSocketName`)。这个选择的来龙去脉
在 [roadmap.md](roadmap.md);它之前的方案(包着一个 Python 服务器的 WebView,两个 APK 变体)
作为设计记录保留在 [archive/android-python-line.md](../archive/android-python-line.md),不是你今天
下载到的东西。
一个 APK,一种架构:`nanoMuse--arm64.apk`(arm64-v8a,Android 8.0 / API 26 及以上,
`targetSdk` 35)。每个版本都用同一把签名密钥,所以新 APK 直接覆盖旧的,数据保留。没有上架
-Play 商店:「设置 → 版本」把已安装的构建和最新版本并排显示(查这个仓库的 GitHub Releases——「最新 0.1.x,你用的就是它」/「0.1.x 已发布,点此更新」),
+Play 商店:「设置 → 版本」把已安装的构建和最新版本并排显示(先查这个仓库的 GitHub Releases,
+再查配置过的镜像索引:「最新 0.1.x,你用的就是它」/「0.1.x 已发布,点此更新」),
并提供下载。
## 安装 {#install}
@@ -19,9 +29,9 @@ Play 商店:「设置 → 版本」把已安装的构建和最新版本并排
`nanoMuse--arm64.apk`。想核对的话,运行
`sha256sum -c nanoMuse--arm64.apk.sha256`。
2. 打开它。Android 会问一次,是否允许从你的浏览器或文件管理器安装。
-3. **登录。** 第一屏就是账号:手机号或邮箱,一个验证码——设过密码之后也可以用密码。账号让
+3. **登录。** 第一屏就是账号:手机号或邮箱,一个验证码,设过密码之后也可以用密码。账号让
你的设备能连成一体([hub.md](hub.md)),也带来一个起步用的模型([cloud.md](cloud.md):每个
- 账号一份免费额度——写这页时是 ¥10——每邀请一位朋友,你和对方都再得一些;App 会显示中继
+ 账号一份免费额度(写这页时是 ¥10),每邀请一位朋友,你和对方都再得一些;App 会显示中继
当前的数字;之后可以[换自己的 key](own-key.md))。然后选哪个模型来回答:账号自带的,或者
你自己的 key(任何 OpenAI 兼容端点,或 App 内置的那几种 OAuth 登录)。设的是四个模型,
不是一个,都在同一页「**设置 → 模型**」里(设置顶部那张卡片打开它):**对话**,和你说话
@@ -35,8 +45,18 @@ Play 商店:「设置 → 版本」把已安装的构建和最新版本并排
去「添加服务商」再回来之后都还在。在这里或在对话的 `•••` 菜单里
选的对话模型,就是新对话的默认,主要聊天也跟着换(「对主要聊天和新对话生效;旁聊保留自己的模型。」);已经打开的旁聊保留它的模型。另外三个
选择页第一项是「自动」(「当前为 nanoMuse Cloud · qwen3.8-27b」),选它会忘掉在这里做过的
- 选择,让这一行重新按下面的顺序走。完全不要账号、一切自己跑,是运行时的
- `cloud.required = false`;手机 App 则要求有账号。
+ 选择,让这一行重新按下面的顺序走。登录后这一页最上面有一个开关「使用 nanoMuse Cloud
+ 模型」:关掉后,账号从所有选择页和自动顺序里退出,附带调用(标题、记忆、图片、视频)都不再
+ 走它,只有被拒绝那一轮上的「这次改用 nanoMuse Cloud」按钮例外;登录本身保留,同步和你的
+ 设备照常。如果关掉那一刻对话模型还是账号的,「对话」那一行会换到你自己的第一个对话模型
+ (目录里该服务商的默认),每个客户端都这么做,页面上写着「对主要聊天和新对话生效」。对话
+ 模型还是账号的、而你又没有自己的对话模型时,关闭会被拒绝并给出一句话:开关保持打开,下面
+ 出现「先添加一个自己的服务商;关掉 nanoMuse Cloud 后没有别的模型能回答。」,因为没有别的
+ 东西能回答。Cloud 这个服务商自己的页面(「设置 → 管理提供商」)遵守同一条规则;在那里关掉
+ 一个正在回答对话的自己的服务商,对话会交给下一个来源,一个都不剩时发送会提示「现在没有
+ 能回答的模型。请在『设置 › 模型』里选一个。」。有自己 key 的手机不登录也能用:欢迎页提供
+ 「改用自己的 API key」。
+ 完全不要账号、一切自己跑,是运行时的 `cloud.required = false`。
4. 可选:让智能体能使用你手机上其他 App 的那两个权限(可跳过、可撤销)。「设置 → Hands」
仍保留屏幕模型那一行,点开是同一个选择页;「模型」页底部的「更多图片和视频选项」就是原来的
「图像与视频模型」页(手动输入模型名、缺了哪个模型会停下什么、重新检查一把 key 能用哪些
@@ -60,8 +80,8 @@ Play 商店:「设置 → 版本」把已安装的构建和最新版本并排
`/healthz` 并显示它回答的版本,「使用这个服务器」把地址记住,下次启动仍然有效。自己网络之外的
地址必须是 `https://`;自己网络内的地址可以用明文 `http://`,判断规则与模型服务器地址相同
(见下文:按解析后的地址判断私有网段,`localhost`、不带点的名字,以及 `.local`、`.lan`、`.home`、
-`.internal`、`.home.arpa`、`.ts.net` 后缀)。「设置 → 账号」显示「服务器:``」和「更改」,更改会先把
-手机退出登录——它持有的 key 属于签发它的那台服务器。App 对账号做的一切(登录、hub、同步、
+`.internal`、`.home.arpa`、`.localdomain`、`.ts.net` 后缀)。「设置 → 账号」显示「服务器:``」和「更改」,更改会先把
+手机退出登录:它持有的 key 属于签发它的那台服务器。App 对账号做的一切(登录、hub、同步、
用量、模型菜单)都发往这个地址。
## 手机能做什么 {#what-the-phone-does}
@@ -69,32 +89,44 @@ Play 商店:「设置 → 版本」把已安装的构建和最新版本并排
| | |
| --- | --- |
| **智能体,在手机上** | 一个沙箱里的 Alpine Linux、一个真正的 shell、文件、浏览器、MCP 服务器、Agent Skills 格式的技能、记忆、定时任务;删除、发送、付款之前先审批。模型是账号的或你自己的;用自己的 key 时,你说的话一个字都不经过项目的中继。 |
-| **手(Hands)**(`hands/`) | 打开「Hands」并启用无障碍服务之后,智能体会看手机的屏幕,在各个 App 里点、输入、滑动——这是 API、抓取和浏览器之后的最后一级。盖在 App 上的舞台显示它即将点哪里,一个胶囊显示当前步骤和**停止**——屏幕边缘有一圈呼吸的光(干活时蓝色,等你时琥珀色;亮 2.4 s、暗 2.4 s,第 9 轮起每个客户端上每一处「在干活」的灯都是这个节奏:没有东西绕着边缘跑,也没有东西从屏幕上扫下来,胶囊的环和竖条按同样的节奏呼吸;系统设了「减弱动态效果」时全部静止);当一次点击会发送、发布或删除时,胶囊自己就会问——**允许** / **拒绝**,和聊天卡片、通知显示的是同一个请求——所以你不用被拉回 nanoMuse(付款在聊天卡片里用锁屏确认;对这一种,胶囊提供「打开」);胶囊只回答它自己的请求,不会替另一段对话的命令行或浏览器卡片作答,卡片等待期间按**停止**会直接拒绝它并立刻结束这次运行。密码和验证码永远由你自己输。每一步的截图写在会话的 `attachments/hands//` 下、与这次运行的轨迹放在一起;最新的十次运行保留全部截图,更早的运行只保留轨迹和最后一张屏幕(聊天里显示的那张),超过 200 次之后最早的一次整个删除(`HandsTraces`)。[gui.md](gui.md) 是设计记录;App 自己的手在 `io.github.nanomuse.hands`。 |
-| **Reach**(`reach/`) | 从手机使用你的电脑:「手机上说一句,电脑上执行」——一条 shell 命令、一个文件、电脑的屏幕,或者把整个任务交给那边运行的 nanoMuse。入口是 hub(下一行):在电脑上装 nanoMuse 桌面版,用同一个账号登录,几秒钟内它就出现在「账号 → 设备」下,不限网络——从 0.1.24 起这是唯一的方式(局域网的 host 脚本已经去掉)。「设置 → 电脑」列出账号下的电脑,并说明手机用的是哪个账号,因为电脑不见了,几乎总是因为它登的是另一个账号。审批在手机上决定之后,才会发出任何东西。[every-device.md](every-device.md)。 |
+| **手(Hands)**(`hands/`) | 打开「Hands」并启用无障碍服务之后,智能体会看手机的屏幕,在各个 App 里点、输入、滑动:这是 API、抓取和浏览器之后的最后一级。盖在 App 上的舞台显示它即将点哪里,一个胶囊显示当前步骤和**停止**;屏幕边缘有一圈呼吸的光(干活时蓝色,等你时琥珀色;亮 2.4 s、暗 2.4 s,第 9 轮起每个客户端上每一处「在干活」的灯都是这个节奏:没有东西绕着边缘跑,也没有东西从屏幕上扫下来,胶囊的环和竖条按同样的节奏呼吸;系统设了「减弱动态效果」时全部静止);当一次点击会发送、发布或删除时,胶囊自己就会问(**允许** / **拒绝**,和聊天卡片、通知显示的是同一个请求),所以你不用被拉回 nanoMuse(付款在聊天卡片里用锁屏确认;对这一种,胶囊提供「打开」);胶囊只回答它自己的请求,不会替另一段对话的命令行或浏览器卡片作答,卡片等待期间按**停止**会直接拒绝它并立刻结束这次运行。密码和验证码永远由你自己输。屏幕没有变化而同一个动作做到第三次,历史里会记一笔,好让模型换个法子;做到第六次,这一轮就以「做不到」结束并写明是哪个动作(`HandsLoopWatch`,运行时也有同样的底线),而不是每一步都调用一次模型,直到三十分钟上限。每一步的截图写在会话的 `attachments/hands//` 下、与这次运行的轨迹放在一起;最新的十次运行保留全部截图,更早的运行只保留轨迹和最后一张屏幕(聊天里显示的那张),超过 200 次之后最早的一次整个删除(`HandsTraces`)。[gui.md](gui.md) 是设计记录;App 自己的手在 `io.github.nanomuse.hands`。 |
+| **Reach**(`reach/`) | 从手机使用你的电脑:「手机上说一句,电脑上执行」,一条 shell 命令、一个文件、电脑的屏幕,或者把整个任务交给那边运行的 nanoMuse。入口是 hub(下一行):在电脑上装 nanoMuse 桌面版,用同一个账号登录,几秒钟内它就出现在「账号 → 设备」下,不限网络;从 0.1.24 起这是唯一的方式(局域网的 host 脚本已经去掉)。「设置 → 电脑」列出账号下的电脑,并说明手机用的是哪个账号,因为电脑不见了,几乎总是因为它登的是另一个账号。审批在手机上决定之后,才会发出任何东西。[every-device.md](every-device.md)。 |
| **hub**(`hub/`) | 账号下每台登录的设备都在中继的 hub 上相遇:手机看得到你的电脑,能拜托它们做事,把它们的审批当作卡片收到,也能被它们拜托。一个前台服务让它在后台也保持可达(Android 13 及以上为此会请求通知权限)。[hub.md](hub.md)。 |
| **编程助手**(`ui/coding/`) | 你电脑上的 Cursor、Codex 和 Claude Code 会话,在手机上看、在手机上指挥。[coding-agents.md](coding-agents.md)。 |
-| **账号**(`ui/cloud/`) | 谁登录了、密码、每台持有 key 的设备、按类别和按模型的用量、退出的方式。免费额度用完或超过 80 % 时,账号页和被拒绝的那一轮都会显示接下来的路:自己的 key——目录里的服务商,所在地区的排前面(中国大陆的人看到阿里云百炼:判断依据是界面语言为简体中文、用手机号登录,或者中继这么说;其他地方是 OpenRouter 和 OpenAI),每一家都写明它覆盖什么——你已经在付费的套餐(ChatGPT、Claude、Kimi、OpenRouter 用登录代替 key),还有邀请。见下面的[自己的 key](#your-own-key)。手机只显示、只同步当前登录账号的对话——从 0.1.40 起每个聊天都有一个归属者,同步与否都是;退出登录时会问「在这台设备上保留这个账号的聊天」(默认关:这个账号的聊天、记忆、动态、目标、例程和形象都离开手机;开:先收起来,等它回来);删除账号会把这些全部删除;中继拒绝的 key(`401 bad_key`)会像「保留」那样把账号的数据收起来,登录页会一直说明,直到下次登录——只有 `401 account_deleted` 会删除;退出登录状态下,只显示退出后产生的聊天([sync.md](sync.md))。 |
-| **交到你手里的浏览器**(`browser/`) | 需要你亲自处理的页面——登录、验证码、付款、人机验证——会直接交到你手里,而不是用文字描述:`browser_use` 的 `hand_over` 动作把智能体自己的标签页(同一个 WebView、同一个会话)在浏览器面板里打开,智能体松手;输入框上方的**轮到你了**卡片说明页面要你做什么,带「打开页面」和「完成,继续」,面板上是同一句话和同一个按钮,工具调用会等到「完成」(最多十五分钟),然后智能体从页面当前的样子接着往下做。`io.github.nanomuse.browser.BrowserHandOver`。 |
-| **连接器**(`connectors/`、`ui/connectors/`) | 智能体可以被放进去的那些服务——桌面版那份 75 个远程 MCP 服务器的目录(Notion、Linear、GitHub、GitLab、Slack、Stripe、Miro……),以 `assets/nanomuse/connectors.json` 随 App 发布。设置里管这一切的只有一行:「连接器」;上游的 MCP 编辑器(按地址、按命令、导入 JSON)是页面末尾的「你自己的服务器」。开放的服务点一下就加上;要 key 的服务填 key;OAuth 服务走 MCP 授权流程(发现、动态客户端注册、在 Custom Tab 里做 PKCE),令牌进入该条目的 `Authorization` 头,供沙箱内的 MCP 客户端使用,App 启动时刷新;授权服务器不接受注册客户端的服务(`clientIdRequired`——GitHub、Slack、Discord、HubSpot、Render、Bitrise、PagerDuty、Box)会要你在厂商的开发者页面用 App 的回调地址创建一对 OAuth client id 和 secret,面板会显示并复制这个回调地址。连接好的服务就是同一个 id 下的一个 MCP 服务器条目——「你自己的服务器」也能管它。连接了什么,会通过中继的 profile 分享给账号的其他设备——只有条目(id、名称、地址、认证方式、哪台设备、何时),从不包含令牌——所以页面还会列出「在你的其他设备上」:在桌面上连接的服务显示为「已在 上连接——在这里登录即可在这台手机上使用」,点一下进入同一个面板。 |
-| **聊天,不花哨** | 智能体工作时,形象下面那一行写的是正在进行的步骤——「nanoMuse 正在用 Shell」「正在写回复」「在做了:订餐桌」——从不写心情。工具小标签、「电脑」面板和浮动的步骤条从 0.1.37 起**默认打开**;「设置 → 外观 → 显示智能体的步骤」可以关掉它们。打开时,完成的步骤显示为「nanoMuse 用过 Shell · 完成」,它的面板点 ×、滑动或返回键都能关。 |
+| **账号**(`ui/cloud/`) | 谁登录了、密码、每台持有 key 的设备、按类别和按模型的用量、退出的方式。免费额度用完或超过 80 % 时,账号页和被拒绝的那一轮都会显示接下来的路:自己的 key,也就是目录里的服务商,所在地区的排前面(中国大陆的人看到阿里云百炼:判断依据是界面语言为简体中文、用手机号登录,或者中继这么说;其他地方是 OpenRouter 和 OpenAI),每一家都写明它覆盖什么;你已经在付费的套餐(ChatGPT、Claude、Kimi、OpenRouter 用登录代替 key);还有邀请。见下面的[自己的 key](#your-own-key)。手机只显示、只同步当前登录账号的对话:从 0.1.40 起每个聊天都有一个归属者,同步与否都是;退出登录时会问「在这台设备上保留这个账号的聊天」(默认关:这个账号的聊天、记忆、动态、目标、例程和形象都离开手机;开:先收起来,等它回来);删除账号会把这些全部删除;中继拒绝的 key(`401 bad_key`)会像「保留」那样把账号的数据收起来,登录页会一直说明,直到下次登录;只有 `401 account_deleted` 会删除;退出登录状态下,只显示退出后产生的聊天([sync.md](sync.md))。 |
+| **交到你手里的浏览器**(`browser/`) | 需要你亲自处理的页面(登录、验证码、付款、人机验证)会直接交到你手里,而不是用文字描述:`browser_use` 的 `hand_over` 动作把智能体自己的标签页(同一个 WebView、同一个会话)在浏览器面板里打开,智能体松手;输入框上方的**轮到你了**卡片说明页面要你做什么,带「打开页面」和「完成,继续」,面板上是同一句话和同一个按钮,工具调用会等到「完成」(最多十五分钟),然后智能体从页面当前的样子接着往下做。`io.github.nanomuse.browser.BrowserHandOver`。 |
+| **连接器**(`connectors/`、`ui/connectors/`) | 智能体可以被放进去的那些服务:桌面版那份 75 个远程 MCP 服务器的目录(Notion、Linear、GitHub、GitLab、Slack、Stripe、Miro……),以 `assets/nanomuse/connectors.json` 随 App 发布。设置里管这一切的只有一行:「连接器」;上游的 MCP 编辑器(按地址、按命令、导入 JSON)是页面末尾的「你自己的服务器」。开放的服务点一下就加上;要 key 的服务填 key;OAuth 服务走 MCP 授权流程(发现、动态客户端注册、在 Custom Tab 里做 PKCE),令牌进入该条目的 `Authorization` 头,供沙箱内的 MCP 客户端使用,App 启动时刷新;授权服务器不接受注册客户端的服务(`clientIdRequired`:GitHub、Slack、Discord、HubSpot、Render、Bitrise、PagerDuty、Box)会要你在厂商的开发者页面用 App 的回调地址创建一对 OAuth client id 和 secret,面板会显示并复制这个回调地址。连接好的服务就是同一个 id 下的一个 MCP 服务器条目;「你自己的服务器」也能管它。连接了什么,会通过中继的 profile 分享给账号的其他设备,只有条目(id、名称、地址、认证方式、哪台设备、何时),从不包含令牌,所以页面还会列出「在你的其他设备上」:在桌面上连接的服务显示为「已在 上连接,在这里登录即可在这台手机上使用」,点一下进入同一个面板。 |
+| **聊天,不花哨** | 智能体工作时,形象下面那一行写的是正在进行的步骤(「nanoMuse 正在用 Shell」「正在写回复」「在做了:订餐桌」),从不写心情。工具小标签、「电脑」面板和浮动的步骤条从 0.1.37 起**默认打开**;「设置 → 外观 → 显示智能体的步骤」可以关掉它们。打开时,完成的步骤显示为「nanoMuse 用过 Shell · 完成」,它的面板点 ×、滑动或返回键都能关。 |
+
+聊天和胶囊栏、菜单、抽屉在同一块底色上:深色主题是纯黑,浅色主题是纯白。你的消息是白色胶囊、黑色文字(浅色主题下反过来,黑色胶囊、白色文字),
+智能体的回复放在比底色略浅一级的胶囊里(`#1C1C1E` / `#F2F2F7`),发送按钮和「回到最新」圆盘是白底黑箭头(浅色主题下反过来);
+除了链接,对话里没有蓝色。顶栏不是一条横条,而是一层渐隐:对话从它下面滚过去,渐渐融进底色;往回翻旧消息时,顶边还会多出一层渐隐。
+调色板在上游的 `com/openminis/app/ui/theme/ChatColors.kt`,由 `scripts/rebrand.py` 写入。
+
+消息输入框始终位于浮动导航胶囊上方,胶囊栏和输入框一样宽(同样的 16 dp 边距;最宽 420 dp,更宽的屏幕上居中),两枚胶囊的边缘对齐。智能体使用工具时,步骤条在输入框上方;
+最新消息和工具小标签都可以完整滚动到它们上方。对话仍会滚过导航胶囊背后,
+保留磨砂模糊效果。输入框展开、工具预览或审批卡片出现时,间距跟着底部区域的实际高度调整。
+查看较早的消息时,输入框上方的渐隐效果一直延伸到它的内边距和圆角背后,不留缝隙。
+工具步骤条、浏览器面板或「轮到你了」操作卡片显示时,这层渐隐会隐藏。键盘弹出时,输入框贴在键盘上,胶囊栏在键盘后面。
+动态、点子、目标和资源库四个页面的列表也在胶囊栏上方结束,最后一行不会被它挡住。
同一个账号的网页控制台在中继上(`/app`),桌面版见 [desktop.md](desktop.md);手机、桌面和网页共用
[brand.md](brand.md) 里描述的设计语言。
## 自己的 key {#your-own-key}
-App 认识的厂商在一个文件里,`assets/nanomuse/providers.json`——运行时的
+App 认识的厂商在一个文件里,`assets/nanomuse/providers.json`,运行时的
`nanomuse/llm/providers.json` 的一份拷贝,由 `node scripts/providers-json.mjs` 写出,每个客户端读的
都是这同一份目录([own-key.md](own-key.md))。每个条目写明端点在哪、在哪里创建 key、有哪些登录方式、
在哪里接受注册(`cn`、`global`),以及它的模型能做什么:`chat`、`vision`(手看屏幕)、`image`(图片)、
`video`(短视频)。
-**卡片。** 额度用完或快用完时,「使用自己的模型 key」列出目录,所在地区的厂商在前——大陆是
+**卡片。** 额度用完或快用完时,「使用自己的模型 key」列出目录,所在地区的厂商在前:大陆是
阿里云百炼(一把 key 覆盖全部四项;它只接受中国大陆的账号注册),其他地方是 OpenRouter 和
-OpenAI——然后是其余的,每次三家,收在「更多服务商」后面。每一行都写明这家覆盖什么
+OpenAI;然后是其余的,每次三家,收在「更多服务商」后面。每一行都写明这家覆盖什么
(「聊天 · 屏幕 · 图片 · 短视频」);「添加」打开服务商表单,预填好它的名字、端点和 `/v1` 设置
(`minis://settings/providers/add?preset=`),「获取 key」打开厂商的 key 页面。不推荐任何一家。
-「你已经在付费的套餐」列出用套餐登录、不用 key 的厂商——ChatGPT(OpenAI 的 Codex OAuth)、
+「你已经在付费的套餐」列出用套餐登录、不用 key 的厂商:ChatGPT(OpenAI 的 Codex OAuth)、
Claude、Kimi(设备码)和 OpenRouter;「登录」打开同一个表单并停在登录按钮上(`?preset=:oauth`),
走的是 OpenMinis 自己的 `OpenAIOAuthManager`、`ClaudeOAuthManager`、`KimiOAuthManager` 或
`OpenRouterOAuthManager`。ChatGPT 那一行附一句话:OpenAI 的条款只允许 ChatGPT 套餐在 OpenAI
@@ -105,21 +137,21 @@ Claude、Kimi(设备码)和 OpenRouter;「登录」打开同一个表单
**列表从哪来。** 中继的 `/v1/me` 带着 `spend.guidance`(中继 0.21,[cloud.md](cloud.md)):这个人所在
地区的服务商,按中继的顺序,每家覆盖什么、可以登录的套餐以及它们面向哪些客户端、本地服务器、
文档链接,还有关于 ChatGPT 登录的那句实话。`cloud/Guidance.kt` 解析它,`Ways.resolve` 优先用它,
-卡片和「设置 → nanoMuse Cloud」只在中继没发(旧中继)时才读内置目录——所以中继上新加的厂商
+卡片和「设置 → nanoMuse Cloud」只在中继没发(旧中继)时才读内置目录,所以中继上新加的厂商
不用等 App 更新就能出现,中继不再列出的服务商当天就消失。`429 allowance_exhausted` 在数字旁边
带着同一块内容。
**各家覆盖什么。** 能力决定 App 提供什么(`cloud/Capabilities.kt`):图片(「设置 → 模型 → 生成图片」、
形象工作室、`nanomuse-media image`)只在厂商有 `image` 的服务商里挑;短视频只在有 `video` 且 App
能驱动的里面挑(百炼的视频 API);屏幕模型和它的选择页只列出有 `vision` 的厂商的模型。通过
-Codex 登录的 ChatGPT 套餐只有聊天和视觉——Codex 后端没有图片或视频端点——所以从不会被拿来
+Codex 登录的 ChatGPT 套餐只有聊天和视觉(Codex 后端没有图片或视频端点),所以从不会被拿来
画图。目录不认识的厂商(一个网关、你自己的中继、nanoMuse Cloud)按它模型自己的说法算,和以前
-一样。没有任何已配置的服务商具备某项能力时,页面用一句话说明——「画图需要有图像模型的
-提供方:百炼、OpenAI、Gemini 或 OpenRouter。」——而不是报错。
+一样。没有任何已配置的服务商具备某项能力时,页面用一句话说明(「画图需要有图像模型的
+提供方:百炼、OpenAI、Gemini 或 OpenRouter。」),而不是报错。
**连不上服务商的时候**(`net/ProviderReach.kt`、`ui/chat/ProviderReachCard.kt`)。失败的一轮,如果
-错误来自传输层——OkHttp 的「failed to connect to chatgpt.com/…(port 443)」「Unable to resolve host」、
-一条 TLS 或超时信息——或者是 OpenAI 的「region not supported」,就显示为一张卡片而不是红色横幅:
+错误来自传输层(OkHttp 的「failed to connect to chatgpt.com/…(port 443)」「Unable to resolve host」、
+一条 TLS 或超时信息),或者是 OpenAI 的「region not supported」,就显示为一张卡片而不是红色横幅:
发生了什么、什么有用(这台手机上的 VPN、「设置 → 网络」下的代理、另一家用你自己 key 的服务商)、
「重试」,以及收在「详情」后面的原始信息。上游的 `mapHttpError` 会丢掉 401/403/429 的响应体,
`ReachSignal`(`OpenAIProvider` 里一处 `// nanoMuse:` 改动)把它们留几秒钟,以规范的 `nm_reach:` 行
@@ -127,19 +159,23 @@ Codex 登录的 ChatGPT 套餐只有聊天和视觉——Codex 后端没有图
剩余了」,附 OpenAI 自己的那句话和重置时间;key 服务商上普通的「Invalid API key」则保持上游的
样子。中继的 `daily_cap` 429 现在和 `allowance_exhausted` 一样读取,卡片会说今天的份额用完了,
隔天回来。额度卡片还列出你自己的模型(你自己电脑上的 Ollama、LM Studio、vLLM),以及对登录
-账号而言,你的电脑——聊天里打「@」加它的名字,这一轮就在那边跑。
+账号而言,你的电脑:聊天里打「@」加它的名字,这一轮就在那边跑。
**中继拒绝一轮的时候**(`cloud/RelayRefusal.kt`、`ui/chat/RelayRefusalCard.kt`)。nanoMuse Cloud 发来的
-每一种拒绝都是手机语言的一句大白话,加上一个合适的按钮——从不显示状态码、中继的 JSON 或上游的
+每一种拒绝都是手机语言的一句大白话,加上一个合适的按钮,从不显示状态码、中继的 JSON 或上游的
「Rate limited」。回复在响应体还完整时读取(`AllowanceSignal.noteHttpError`,经由 `OpenAIProvider` 里
同一处 `// nanoMuse:` 改动),以规范的 `nm_relay:` 行存进消息,由卡片绘制:`413 too_large`(也包括
中继主机上的代理返回的普通 413)→「这条消息对模型来说太大了。缩短一点、去掉一些附件,或者开
-一个新聊天。」加「新聊天」;`401 bad_key` / `account_deleted` →「登录」;`403 not_invited` /
+一个新聊天。」加「新聊天」;`400 content_rejected`(服务商的内容审核没有放行这些内容;用自己的
+key 时服务商自己的 `DataInspectionFailed` 也算)→「模型服务商的内容审核没有放行这次请求。换个说法
+试试;如果反复出现,就新开一个对话,被拦下的可能是对话里更早的内容。」加「新聊天」和「重试」,
+压缩的对半重试(`ChatViewModel.shouldSplitOnError`)不碰它,因为请求再小,同样的内容回来还是被拒;
+`401 bad_key` / `account_deleted` →「登录」;`403 not_invited` /
`account_disabled` / `signup_closed` →「打开设置」;`429 too_many_in_flight` / `locked` /
`rate_limited` 和 `provider_busy`(附 `retry_after` 的等待时间)→「重试」;`404
model_not_offered` →「打开设置」;`503 service_paused`、`sync_paused`、`hub_paused` 以及任何 5xx
或空响应 →「重试」。`429 allowance_exhausted` 和 `daily_cap` 保留额度卡片;带 `paused: true`
-(中继 0.22)时,卡片开头说额度「在这个中继上暂停了——不是用完了」,剩下的保持不变。这些句子
+(中继 0.22)时,卡片开头说额度「在这个中继上暂停了,不是用完了」,剩下的保持不变。这些句子
来自 `NanoMuseCloud.describe`,和登录页、账号页用的是同一批,17 种语言都有(`nm_cloud_err_*`)。
单元测试:`RelayRefusalTest`、`GuidanceTest`。
@@ -147,28 +183,28 @@ model_not_offered` →「打开设置」;`503 service_paused`、`sync_paused`
给自己的服务商用的 HTTP 代理:主机、端口、可选的用户名和密码,默认关闭,只存在这台手机的
`SharedPreferences` 里。它在 `MinisApp.onCreate` 中被装为进程默认的 `ProxySelector`,所以每个 OkHttp
客户端每次请求都会问它;对目录里的主机、`chatgpt.com`、`auth.openai.com` 和服务商实例的自定义
-base URL(从不包括中继的,从不包括局域网地址)它回答代理,对其他所有主机回答系统的设置——hub、
+base URL(从不包括中继的,从不包括局域网地址)它回答代理,对其他所有主机回答系统的设置:hub、
中继、沙箱镜像都不受影响。服务商客户端带着 `OwnProviderProxy.authenticator`,应付要密码的代理。
「测试」按填写的代理抓取 `https://chatgpt.com/`,报告状态码和毫秒数。单元测试:`ProviderReachTest`、
`OwnProviderProxyTest`。
## 隐私与权限 {#privacy-and-permissions}
-中继保存账号 id、一个打码的标识、用量计数,以及智能体的名字和外观(让你的设备保持一致)——
+中继保存账号 id、一个打码的标识、用量计数,以及智能体的名字和外观(让你的设备保持一致);
消息内容只以对话同步的形式保存:登录后默认开启,在*数据控制*里关掉([privacy.md](privacy.md)、
-[sync.md](sync.md));第一次对话的第四句话会这么说。在手机上,API key、账号 key 和 Reach 配对令牌放在
-`EncryptedSharedPreferences` 里;从 0.1.40 起 App 完全不参与设备备份(`allowBackup="false"`——
+[sync.md](sync.md));第一次对话的第四句话会这么说。在手机上,API key 和账号 key 放在
+`EncryptedSharedPreferences` 里(*设置 → 网络* 的代理密码放在普通偏好里,只在这台手机上);从 0.1.40 起 App 完全不参与设备备份(`allowBackup="false"`:
聊天、记忆和 key 从不上传到 Google,重装后从空白开始,账号的聊天通过同步回来;`res/xml/` 下的
两个规则文件留着,等哪天开关再打开时用)。手需要无障碍服务和悬浮窗权限,两者都可选、都能在
-同一屏撤销(首次运行的「手」那一页在 App 标志下面请求这两项,和登录页一样);会发送东西的步骤——点「发送」,或在聊天软件的消息框里按回车——一次一次地审批,
+同一屏撤销(首次运行的「手」那一页在 App 标志下面请求这两项,和登录页一样);会发送东西的步骤(点「发送」,或在聊天软件的消息框里按回车)一次一次地审批,
「本次对话」的答复也只对当初授权的那个 App 或地址有效。账号下的另一台设备想在手机上运行、读取
-或写入什么(hub 的 `shell`、`files`、`open`、`screen`……),要先经拿着手机的人批准——「一次」或
+或写入什么(hub 的 `shell`、`files`、`open`、`screen`……),要先经拿着手机的人批准:「一次」或
「对这台设备总是允许」,可在「权限」里撤销。另一台设备或智能体说出的路径(hub 的 `files`、
`nanomuse-media`、`nanomuse-pc put`)只在沙箱内解析(`io.github.nanomuse.sandbox.SandboxPaths`):
`..` 和指向 rootfs 之外的符号链接都无处可去,App 自己的私有文件始终够不着。App 只对你自己网络内的地址(私有网段 `10/8`、`172.16/12`、
`192.168/16`、Tailscale 分配的 `100.64/10`、链路本地地址、IPv6 ULA、`localhost`、不带点的名字,
-以及 `.local`、`.lan`、`.home`、`.internal`、`.home.arpa`、`.localdomain`、`.ts.net` 后缀)——一台模型
-服务器或你自己的电脑——允许明文 `http://`,其他地址在服务商 URL 字段里一律拒绝
+以及 `.local`、`.lan`、`.home`、`.internal`、`.home.arpa`、`.localdomain`、`.ts.net` 后缀),也就是一台模型
+服务器或你自己的电脑,允许明文 `http://`,其他地址在服务商 URL 字段里一律拒绝
(`io.github.nanomuse.net.LanOnly`)。地址先解析再判断,所以 `10.foo.example.com` 这样的公网名字
不会被当成私有地址。自己的中继能否用 `http://` 也按同一条规则判断;nanoMuse Cloud 以及到它的
hub 只走 TLS。App 内的
@@ -186,6 +222,9 @@ cd android/src/android
./gradlew :app:assembleRelease # signed with android/keystore.properties when present
```
+`rclone.aar` 用 Go 构建,模块走 Go 自己的默认代理(`https://proxy.golang.org,direct`);在中国大陆网络下先
+`export GOPROXY=https://goproxy.cn,direct`,`env.sh` 会沿用你导出的值。
+
`scripts/rebrand.py` 带着版本号(`VERSION_NAME`、`VERSION_CODE`),把 nanoMuse 的命名套到
OpenMinis 树上;`android/BUILDING.md` 讲原生部分。没有签名密钥时,release 构建用 debug 密钥签名,
能安装,但不能升级一个正式签名的构建。要签名,先创建一次密钥,放在仓库之外:
@@ -214,10 +253,10 @@ keytool -genkeypair -keystore ~/.nanomuse-release/nanomuse.jks -alias nanomuse \
| --- | --- |
| `cloud/NanoMuseCloud.kt` | 账号:用验证码或密码登录、加密存储里的 key、`/v1/me`、用量、会话、本地化的错误句子;中继的菜单和它的两个默认值(`for: chat` / `for: gui`) |
| `cloud/Region.kt`、`cloud/ProviderCatalogue.kt`、`cloud/Capabilities.kt`、`cloud/OwnKeyPresets.kt`、`cloud/ProfileSync.kt` | 哪个地区的厂商排前面(大陆 → 百炼,否则 OpenRouter 和 OpenAI);从 `assets/nanomuse/providers.json` 读出的自带 key 目录,以及某个已配置的服务商对应哪家厂商;一个服务商覆盖什么和那些一句话的「不可用」提示;预填的服务商表单(`?preset=[:oauth]`);拉取和推送的中继 profile(名字、外观、连接器) |
-| `sync/` | 对话同步(契约 C7–C10):`SyncEngine`(什么上传、什么下载;每个映射带 `owner`,只有本账号的)、`ConversationSync`(何时同步;`hidden`——另一个账号的聊天,从 `ChatRepository.observeSessions()` 里排除)、`LocalChats`、Room 存储 `nanomuse_sync.db`(含 C12 的 `session_owners` 表) |
-| `account/` | 契约 C12(0.1.40,[sync.md](sync.md)):`AccountScope`(规则——一个会话是谁的聊天、账号的 key、列表排除什么、被拒绝的 key 保留什么(`keepOnRefusedKey`:全部保留,除非中继说 `account_deleted`);有单元测试)、`AccountData`(执行规则:每个聊天一行归属记录,`leave` 把账号的聊天、记忆、动态、目标、例程、形象和偏好收起来或删掉,`enter` 把一个账号的东西带回来)。「库」标签遵循同一条规则:只列出聊天列表会显示的那些会话的工作区,共用一台手机时另一个账号的文件不会出现(`library/LibraryIndex.shows`,有单元测试) |
+| `sync/` | 对话同步(契约 C7–C10):`SyncEngine`(什么上传、什么下载;每个映射带 `owner`,只有本账号的)、`ConversationSync`(何时同步;`hidden`:另一个账号的聊天,从 `ChatRepository.observeSessions()` 里排除)、`LocalChats`、Room 存储 `nanomuse_sync.db`(含 C12 的 `session_owners` 表) |
+| `account/` | 契约 C12(0.1.40,[sync.md](sync.md)):`AccountScope`(规则:一个会话是谁的聊天、账号的 key、列表排除什么、被拒绝的 key 保留什么(`keepOnRefusedKey`:全部保留,除非中继说 `account_deleted`);有单元测试)、`AccountData`(执行规则:每个聊天一行归属记录,`leave` 把账号的聊天、记忆、动态、目标、例程、形象和偏好收起来或删掉,`enter` 把一个账号的东西带回来)。「库」标签遵循同一条规则:只列出聊天列表会显示的那些会话的工作区,共用一台手机时另一个账号的文件不会出现(`library/LibraryIndex.shows`,有单元测试) |
| `hub/` | `Hub`(状态、设备身份、设置)、`HubClient`(带退避的 socket;key 被拒绝时每分钟重试一次并如实显示,连接被替换时等 30 秒,运营者暂停 hub 时等两分钟并如实显示)、`HubService`(前台服务)、`HubActions`(其他设备可以让这台手机做什么;`stop {call | conversation}` 结束发起设备在这里启动的任务,由 `HubTasks` 记账,有单元测试)、`HubErrors`(用文字描述的失败) |
-| `reach/` | `Computers`(已配对的电脑,令牌在加密存储里)、把工作转交给电脑的处理器 |
+| `reach/` | `Computers`(账号的设备,按 hub 列出的样子,以及对它们的调用;0.1.24 起不再有配对,也没有自己的令牌)、把工作转交给电脑的处理器 |
| `models/` | 「设置 → 模型」背后的逻辑:`ModelSlots`(四个槽位、各自设成了什么、存在哪里、选择页列出的分组、对话默认的 `followPick`、「用它来做什么」卡片的 `applyProvider`),`SlotOrder`(解析顺序和目录默认,纯 Kotlin,有单元测试),`PickerList`(选择页每组 8 行的折叠、排序和搜索过滤,纯 Kotlin,有单元测试) |
| `ui/models/` | 「模型」页、每一行背后的选择页、「用它来做什么」卡片(`minis://settings/models`) |
| `chat/CloudRetry.kt` | 「这次改用 nanoMuse Cloud」:卡片何时提供它,以及 `ChatViewModel.retryLast` 这一轮用的服务商 |
diff --git a/docs/zh/app.md b/docs/zh/app.md
index 61bb57a3..6b687b71 100644
--- a/docs/zh/app.md
+++ b/docs/zh/app.md
@@ -1,10 +1,10 @@
# 应用
> **Python 线的设计记录**(`nanomuse/` + `web/`,标签 `pre-openminis`):这里讲的是
-> `nanomuse serve` 提供的网页版——今天它是桌面版的门面,也是在浏览器标签页里打开的
+> `nanomuse serve` 提供的网页版,今天它是桌面版的门面,也是在浏览器标签页里打开的
> 运行时的门面(上游的网页演示跑在一台模拟手机上,这个 fork 不托管它)。`android/` 里的手机 App 基于 OpenMinis,
> 在 [android.md](android.md) 里讲;它第一次打开先是账号登录,然后才是模型。
-> 各处的面孔都是内置的**小龙**——或者在[形象工作室](avatar.md)里为你画的那一个。
+> 各处的面孔都是内置的**小龙**,或者在[形象工作室](avatar.md)里为你画的那一个。
> 早期版本的小熊猫和毛绒玩偶已经没有了(配置里仍写着其中一个名字的,显示的也是小龙)。
`nanomuse serve` 跑一个常驻的智能体,并在同一个进程里把手机用的应用一起提供出来。应用是一个 React 单页应用,构建到 `nanomuse/server/static/`,随 Python 包一起发布;服务端是 FastAPI,实时事件走一条 WebSocket。
@@ -22,7 +22,7 @@ nanomuse serve --port 9000 --no-qr
-外观跟着 Meta 的 Muse 走:接近纯白和接近纯黑的底色,一种蓝色作强调,智能体的气泡是灰色、你的是浅蓝色,聊天顶部有一只小龙,姿势跟着智能体正在做的事变,底下是一条带五个图标的悬浮栏。字体是 [Figtree](https://github.com/erikdkennedy/figtree)(OFL),已内置。这些选择背后的研究在 [design.md](design.md)。
+外观是单色的:接近纯白和接近纯黑的底色,蓝色只用于链接,智能体的消息是浅灰色胶囊,你的消息是对话墨色的胶囊(黑底上是白色、白底上是黑色,见 [android.md](android.md)),聊天顶部有一只小龙,姿势跟着智能体正在做的事变,底下是一枚带五个图标的毛玻璃悬浮胶囊栏。字体是 [Figtree](https://github.com/erikdkennedy/figtree)(OFL),已内置。这些选择背后的研究在 [design.md](design.md)。
## 装到手机上 {#getting-it-onto-your-phone}
@@ -38,72 +38,72 @@ nanomuse serve --port 9000 --no-qr
-数据目录是全新的时候,应用打开的是设置流程,不是聊天。先用三句话说它是什么——替你做事;App 关了也继续干;要紧的地方先问你——再加一句:不用填表,有了模型之后它会在聊天里介绍自己,并问你希望怎么称呼你。然后是一张清单,做完一项打一个勾:
+数据目录是全新的时候,应用打开的是设置流程,不是聊天。先用三句话说它是什么(替你做事;App 关了也继续干;要紧的地方先问你),再加一句:不用填表,有了模型之后它会在聊天里介绍自己,并问你希望怎么称呼你。然后是一张清单,做完一项打一个勾:
-1. **添加模型**——服务商按协议分组(OpenAI 兼容的 Chat Completions · Responses API · 本地或你自己的端点),每家带一行厂商小字:DeepSeek、Kimi、Qwen、GLM、豆包、MiniMax、OpenAI、OpenRouter、Ollama,或者任何 OpenAI 兼容端点。key 默认遮住,有个开关可以显示出来,每家厂商都有一个「去申请 key」的链接;key 进的是服务器上的保险库,模型永远看不到它。Base URL 没有路径时会补上 `/v1`;Ollama 和自定义端点可以没有 key。模型列表由端点自己的 `/models` 填上(连不上时用一份内置目录顶替);你手动输入的模型永远不会被替换。
-2. **连接邮箱、日历、通讯录**——可选。
-3. **开始**——打开聊天,智能体先开口,问你的名字,再和你一起给自己起名:第一次对话([web.md](web.md#the-first-run-and-the-chats-opening))。
+1. **添加模型**:服务商按协议分组(OpenAI 兼容的 Chat Completions · Responses API · 本地或你自己的端点),每家带一行厂商小字:DeepSeek、Kimi、Qwen、GLM、豆包、MiniMax、OpenAI、OpenRouter、Ollama,或者任何 OpenAI 兼容端点。key 默认遮住,有个开关可以显示出来,每家厂商都有一个「去申请 key」的链接;key 进的是服务器上的保险库,模型永远看不到它。Base URL 没有路径时会补上 `/v1`;Ollama 和自定义端点可以没有 key。模型列表由端点自己的 `/models` 填上(连不上时用一份内置目录顶替);你手动输入的模型永远不会被替换。
+2. **连接邮箱、日历、通讯录**:可选。
+3. **开始**:打开聊天,智能体先开口,问你的名字,再和你一起给自己起名,也就是第一次对话([web.md](web.md#the-first-run-and-the-chat-s-opening))。
「开始」在存下一个模型之前一直锁着;「跳过设置」始终都在,它也跳过第一次对话。刷新页面不会丢掉已经打的勾。名字、形象、标语和语气以后都能在形象下面改(「设置」里有身份表单)。设置流程完成以后,或者已经有了对话以后,就不会再出现。「开始」把设置标记为完成(`POST /api/firstrun/start`,等同于 `POST /api/onboarded`);那时已经有对话,或者第一次对话结束时,动态的第一天会在后台写好,这样第一次打开那个房间时不是空的;否则动态打开的是它的介绍卡片,告诉你它的每日例程什么时候跑。
## 屏幕上有什么 {#what-is-on-the-screen}
-五个标签页——聊天、动态、点子、目标、资源库——加上形象后面的一个菜单。
+五个标签页(聊天、动态、点子、目标、资源库)加上形象后面的一个菜单。手机宽度下,这五个放在一枚圆角胶囊里,悬浮在底边上方 12 px 处,和聊天的输入框一样宽(最宽 420 px,更宽的屏幕上居中),只有图标、没有文字,和 Android 应用画的是同一条栏:胶囊半透明,把身后的内容模糊掉,边上有一圈细线;当前标签页显示填充的图形,底下垫一块墨色的指示块(浅色主题黑、深色主题白),其余的保持灰色描边。各个页面和聊天的输入框都会让开它。宽窗口下,以及桌面应用在任何宽度下,由侧栏代替这条栏。
-**聊天。** 一个主要聊天,加上旁聊(右上角的「对话」按钮)。消息一边生成一边流进来。工具调用显示成小标签:点一下看参数和输出。智能体写的文件显示成构件卡片,在应用里就能打开。哨兵(Sentinel)需要你拿主意时出现一张审批卡片;智能体需要信息时出现一张提问卡片。智能体干活时输入框一直开着;你这时发的任何东西都会在下一次调用模型之前并进正在进行的这一轮。
+**聊天。** 一个主要聊天,加上旁聊(左上角的圆形「对话」按钮打开列出它们的抽屉)。消息一边生成一边流进来。工具调用显示成小标签:点一下看参数和输出。智能体写的文件显示成构件卡片,在应用里就能打开。哨兵(Sentinel)需要你拿主意时出现一张审批卡片;智能体需要信息时出现一张提问卡片。智能体干活时输入框一直开着;你这时发的任何东西都会在下一次调用模型之前并进正在进行的这一轮。
-*附件。* 回形针(或者粘贴)可以附上照片和文件——每条消息最多十个,每个 25 MB(`server.max_upload_mb`)——来源是相机、相册或者手机里的文件。选中就开始上传,显示成缩略图和可以去掉的小标签,一条消息也可以只有附件。每个文件都落到工作区的 `attachments//` 下(所以资源库里有它,任何工具都能用它),消息里会给智能体列出这些文件和它们是什么。模型接得住的时候,图片以图像形式发给模型:`llm.vision = auto` 会发,如果端点拒绝(DeepSeek 和大多数纯文本模型都会拒绝),从那以后就只发文字,并告诉你一次——图片仍然作为文件留着,智能体知道它的文件名,会说自己看不见它,而不是瞎猜;`off` 从不发图片,`on` 坚持发。手机照片在编码前会缩到 1568 px,所以一张 10 MB 的照片花的钱和一张小图一样。PDF、电子表格和文档智能体用 `files` 读——PDF 的文字按页提取(扫描版 PDF 会说它没有文字层)。在聊天里,点图片或文件小标签就能打开。
+*附件。* 「+」按钮(或者粘贴)可以附上照片和文件,每条消息最多十个,每个 25 MB(`server.max_upload_mb`),来源是相机、相册或者手机里的文件。选中就开始上传,显示成缩略图和可以去掉的小标签,一条消息也可以只有附件。每个文件都落到工作区的 `attachments//` 下(所以资源库里有它,任何工具都能用它),消息里会给智能体列出这些文件和它们是什么。模型接得住的时候,图片以图像形式发给模型:`llm.vision = auto` 会发,如果端点拒绝(DeepSeek 和大多数纯文本模型都会拒绝),从那以后就只发文字,并告诉你一次(图片仍然作为文件留着,智能体知道它的文件名,会说自己看不见它,而不是瞎猜);`off` 从不发图片,`on` 坚持发。手机照片在编码前会缩到 1568 px,所以一张 10 MB 的照片花的钱和一张小图一样。PDF、电子表格和文档智能体用 `files` 读;PDF 的文字按页提取(扫描版 PDF 会说它没有文字层)。在聊天里,点图片或文件小标签就能打开。
-*浏览器视图。* 智能体用浏览器工具时,会出现一张浏览器卡片,显示它正在看的东西:每做一个动作之后页面的一张图、页面标题、它在哪儿、刚做了什么(「打开了 example.com」「点击了『Sign in』」)。运行期间卡片是 LIVE 的,之后留在聊天里。点一下看完整视图;「接管」把控制权交到你手上——点图片就在那个位置点击,往聚焦的输入框里打字,按回车,打开一个 URL——「交还」把页面还给智能体,它会被告知你做了什么,然后从那里接着干。登录就是这么发生的:智能体停在表单那儿问你,你登录,它继续。你输入的密码直接去网站,从不经过模型。画面帧只在当前会话期间留在服务器内存里(每个聊天最近十几帧);重启之后旧卡片显示一个占位图。需要浏览器工具(`pip install "nanomuse[browser]" && playwright install chromium`,或者 `-browser` 的 Docker 镜像)——或者 Android 应用:只要它连着,它自己的 WebView 就是智能体的浏览器(卡片会说明是哪一种:`backend`)。在应用里,这种卡片上的「接管」滑上来的是真实页面的一层弹出面板,而不是页面的一张图;用手机的键盘和自动填充登录,点**完成**,智能体接着干。完整的故事,包括哪些东西没法在 WebView 里登录:[browser.md](browser.md)。
+*浏览器视图。* 智能体用浏览器工具时,会出现一张浏览器卡片,显示它正在看的东西:每做一个动作之后页面的一张图、页面标题、它在哪儿、刚做了什么(「打开了 example.com」「点击了『Sign in』」)。运行期间卡片是 LIVE 的,之后留在聊天里。点一下看完整视图;「接管」把控制权交到你手上(点图片就在那个位置点击,往聚焦的输入框里打字,按回车,打开一个 URL),「交还」把页面还给智能体,它会被告知你做了什么,然后从那里接着干。登录就是这么发生的:智能体停在表单那儿问你,你登录,它继续。你输入的密码直接去网站,从不经过模型。画面帧只在当前会话期间留在服务器内存里(每个聊天最近十几帧);重启之后旧卡片显示一个占位图。需要浏览器工具(`pip install "nanomuse[browser]" && playwright install chromium`,或者 `-browser` 的 Docker 镜像),或者 Android 应用:只要它连着,它自己的 WebView 就是智能体的浏览器(卡片会说明是哪一种:`backend`)。在应用里,这种卡片上的「接管」滑上来的是真实页面的一层弹出面板,而不是页面的一张图;用手机的键盘和自动填充登录,点**完成**,智能体接着干。完整的故事,包括哪些东西没法在 WebView 里登录:[browser.md](browser.md)。
-**动态。** 两部分。顶上是*动态偏好*:告诉它你想读什么(「帮我跟进骑行和 Rust 的消息,每周一个菜谱」),每天一次——或者你点「写几条新的」的时候——智能体根据这些偏好、你的目标和它记得的事,为你写三条短文。每条都有「问问 {name}」,点了就围绕它开一段对话。下面是*你离开期间*:每一轮后台工作一条(它最后说的话,以及它做出的文件),任何聊天里还在等你的每一个审批或提问,今天的日历,还有一张*接下来*卡片,说下一轮什么时候跑、哪个目标排在前面。没看过的条目数显示在标签页上。
+**动态。** 两部分。顶上是*动态偏好*:告诉它你想读什么(「帮我跟进骑行和 Rust 的消息,每周一个菜谱」),每天一次,或者你点「写几条新的」的时候,智能体根据这些偏好、你的目标和它记得的事,为你写三条短文。每条都有「问问 {name}」,点了就围绕它开一段对话。下面是*你离开期间*:每一轮后台工作一条(它最后说的话,以及它做出的文件),任何聊天里还在等你的每一个审批或提问,今天的日历,还有一张*接下来*卡片,说下一轮什么时候跑、哪个目标排在前面。没看过的条目数显示在标签页上。
-**点子。** 根据你的目标、记忆和最近的对话生成的建议,按领域分组——规划、调研、目标、金钱、健康、居家、学习、人物、文件、娱乐。点一条就把它当消息发出去;「刷新」重新生成。
+**点子。** 根据你的目标、记忆和最近的对话生成的建议,按领域分组:规划、调研、目标、金钱、健康、居家、学习、人物、文件、娱乐。点一条就把它当消息发出去;「刷新」重新生成。
-**目标。** 两张列表。*追踪*是按计划定期查看的(设了跟进节奏的目标);*目标*是其余的,一步一步做。每一行有一个随计划推进逐渐填满的圆圈、一行状态(下次跟进、上次更新、到期日)和已完成的步骤数。目标可以由智能体建,也可以由你建(`+` 按钮),归到生活的某个领域下——健康、财务、职业、学习、人际、家庭、居家、旅行、创作——可以选填目标日期和跟进节奏。每个目标有一份计划;步骤分待办、进行中、已完成、受阻,可以带备注。「现在就推进」对这个目标跑一轮后台工作,把结果发到主要聊天;「现在跟我确认进度」马上发出那条提醒消息。
+**目标。** 两张列表。*追踪*是按计划定期查看的(设了跟进节奏的目标);*目标*是其余的,一步一步做。每一行有一个随计划推进逐渐填满的圆圈、一行状态(下次跟进、上次更新、到期日)和已完成的步骤数。目标可以由智能体建,也可以由你建(`+` 按钮),归到生活的某个领域下(健康、财务、职业、学习、人际、家庭、居家、旅行、创作),可以选填目标日期和跟进节奏。每个目标有一份计划;步骤分待办、进行中、已完成、受阻,可以带备注。「现在就推进」对这个目标跑一轮后台工作,把结果发到主要聊天;「现在跟我确认进度」马上发出那条提醒消息。
- *目标日期。* 卡片上写「5 天后到期」/「原定 9 月 12 日到期」;后台那一轮挑活干的时候逾期的目标排在前面,智能体也会被告知它们逾期了。
-- *跟进。* 「每天 08:00」「工作日 07:30」「每周一 09:00」「每月 1 日」:到点智能体发一条短消息——这个目标是关于什么的,下一小步是什么,进展如何——不做别的。跟进在任何主动性档位下都会来(是你要的),但会等免打扰时段结束。*日程*视图列出接下来的几次。
-- *计划变更。* 智能体发现计划不再合适时,不会直接改;它会提出一套修订后的剩余步骤,并说明理由。提议显示成一张卡片,在目标页顶部和目标内部都有——「采用 nanoMuse 的计划」保留已完成的步骤、换掉其余的,「保留我的计划」什么都不动。两种选择都会在目标上留一条备注。
+- *跟进。* 「每天 08:00」「工作日 07:30」「每周一 09:00」「每月 1 日」:到点智能体发一条短消息(这个目标是关于什么的,下一小步是什么,进展如何),不做别的。跟进在任何主动性档位下都会来(是你要的),但会等免打扰时段结束。*日程*视图列出接下来的几次。
+- *计划变更。* 智能体发现计划不再合适时,不会直接改;它会提出一套修订后的剩余步骤,并说明理由。提议显示成一张卡片,在目标页顶部和目标内部都有:「采用 nanoMuse 的计划」保留已完成的步骤、换掉其余的,「保留我的计划」什么都不动。两种选择都会在目标上留一条备注。
-**提醒和例程。** 在聊天里说一句——「六点提醒我给妈妈打电话」「每个工作日 07:30 给我一句话的天气」——或者在*日程*下面加一条。*提醒*是在你说的时间、在你设它的那个聊天里发一条短消息,别的什么都不做;*例程*是智能体在那个时间用它的工具做一件事(读收件箱、看一个页面、跑一个脚本),然后汇报。节奏用的是跟进那套写法:`daily 08:00`、`weekdays 07:30`、`weekly mon 09:00`、`monthly 1 09:00`。你说定的时间不受主动性档位和免打扰时段影响;聊天正忙时消息排在对话后面,不会被跳过。已触发的条目在*最近完成*下面留一周。终端里有同一张列表:`nanomuse reminders list | add | cancel`。
+**提醒和例程。** 在聊天里说一句(「六点提醒我给妈妈打电话」「每个工作日 07:30 给我一句话的天气」),或者在*日程*下面加一条。*提醒*是在你说的时间、在你设它的那个聊天里发一条短消息,别的什么都不做;*例程*是智能体在那个时间用它的工具做一件事(读收件箱、看一个页面、跑一个脚本),然后汇报。节奏用的是跟进那套写法:`daily 08:00`、`weekdays 07:30`、`weekly mon 09:00`、`monthly 1 09:00`。你说定的时间不受主动性档位和免打扰时段影响;聊天正忙时消息排在对话后面,不会被跳过。已触发的条目在*最近完成*下面留一周。终端里有同一张列表:`nanomuse reminders list | add | cancel`。
-**触发器——当某事发生时。** 提醒的另一半:由外界而不是时钟启动的工作。在聊天里说一句——「房东回信了就帮我总结一下,起草一封回复」「标题里带 *review* 的会议开始前半小时,整理一页简报」「我的部署脚本调用你的时候,检查一下网站是不是正常」——或者在*日程 → 当某事发生时*下面加一条。三种:**新邮件**(到了一封发件人或主题包含你给的每一个词的邮件;只要有这样的触发器存在,收件箱每五分钟看一次,连接邮箱从不回放旧邮件),**日程开始前**(标题或地点匹配的日历事件还有 *N* 分钟开始),**webhook**(一个带 key 的 URL;任何能发 HTTP 请求的东西——CI 任务、家庭自动化规则、带 `curl` 的 cron 行——向它 `POST`,请求体就是上下文)。每次触发,智能体都在设它的那个聊天里做事,面前摆着那封邮件、那个事件或那个请求,结果以「新邮件:…」「即将开始:…」或「Webhook:…」的形式落到动态里。同一件事从不触发两次(邮件按 UID、事件按开始时间),webhook 拒绝间隔不到十秒的投递,错的 key 看起来和错的 URL 一模一样。邮件和事件是你的私人数据:从那以后这个会话就带上污点标记,再往不在白名单里的主机发任何东西都需要一次审批——模型也被告知,把收到的东西当数据,绝不当指令。「现在运行」用一个样例事件触发一次,看看它会做什么;列表显示每条触发过几次、收件箱上次什么时候读的。终端里:`nanomuse triggers list | add | cancel`。
+**触发器:当某事发生时。** 提醒的另一半:由外界而不是时钟启动的工作。在聊天里说一句(「房东回信了就帮我总结一下,起草一封回复」「标题里带 *review* 的会议开始前半小时,整理一页简报」「我的部署脚本调用你的时候,检查一下网站是不是正常」),或者在*日程 → 当某事发生时*下面加一条。三种:**新邮件**(到了一封发件人或主题包含你给的每一个词的邮件;只要有这样的触发器存在,收件箱每五分钟看一次,连接邮箱从不回放旧邮件),**日程开始前**(标题或地点匹配的日历事件还有 *N* 分钟开始),**webhook**(一个带 key 的 URL;任何能发 HTTP 请求的东西(CI 任务、家庭自动化规则、带 `curl` 的 cron 行)向它 `POST`,请求体就是上下文)。每次触发,智能体都在设它的那个聊天里做事,面前摆着那封邮件、那个事件或那个请求,结果以「新邮件:…」「即将开始:…」或「Webhook:…」的形式落到动态里。同一件事从不触发两次(邮件按 UID、事件按开始时间),webhook 拒绝间隔不到十秒的投递,错的 key 看起来和错的 URL 一模一样。邮件和事件是你的私人数据:从那以后这段对话就带上污点标记,再往不在白名单里的主机发任何东西都需要一次审批,模型也被告知,把收到的东西当数据,绝不当指令。「现在运行」用一个样例事件触发一次,看看它会做什么;列表显示每条触发过几次、收件箱上次什么时候读的。终端里:`nanomuse triggers list | add | cancel`。
-**资源库。** 智能体工作区里的每一个文件,最新的在前,可以按类型筛(页面、文档、图片、数据、代码),可以搜索。文件在应用里就能打开:页面实时渲染,Markdown 排好版,CSV 变成表格,图片和 PDF 内嵌显示。智能体写的页面跑在一个沙箱化的、来源不透明(opaque origin)的 iframe 里——它读不到访问令牌,也调不了 API——出于同样的原因,服务器给每个 HTML 文件都带上 `Content-Security-Policy: sandbox`。
+**资源库。** 智能体工作区里的每一个文件,最新的在前,可以按类型筛(页面、文档、图片、数据、代码),可以搜索。文件在应用里就能打开:页面实时渲染,Markdown 排好版,CSV 变成表格,图片和 PDF 内嵌显示。智能体写的页面跑在一个沙箱化的、来源不透明(opaque origin)的 iframe 里(它读不到访问令牌,也调不了 API);出于同样的原因,服务器给每个 HTML 文件都带上 `Content-Security-Policy: sandbox`。
-**形象。** 小龙坐在聊天顶部,下面一行状态——智能体此刻在这个聊天里做什么,或者它在等你。每种状态各有一张静态图(`web/public/avatars/dragon-*.webp`):待命;干活时戴着耳机在一台小笔记本上打字;等你回复时盯着一只水晶球;一轮跑完抱着一颗星星;工具调用失败或被拒绝时冒一滴汗。点它一下,它会很高兴。在[形象工作室](avatar.md)里画出来的面孔有同样的五张静态图;「设置」里也可以换成一个 emoji,整体做动作(呼吸、摇摆、蹦一下)。在 `prefers-reduced-motion` 下这些全部停止。聊天标签页上有一个角标,是任何地方等着你的审批数。点形象打开状态面板:大图的形象、同一行状态、有东西在跑时的一个「停止」按钮(这一轮结束,那个聊天里待处理的卡片关闭,对话照常能用)、模型和哨兵模式,然后是:
+**形象。** 小龙坐在聊天顶部,下面一行状态:智能体此刻在这个聊天里做什么,或者它在等你。每种状态各有一张静态图(`web/public/avatars/dragon-*.webp`):待命;干活时戴着耳机在一台小笔记本上打字;等你回复时盯着一只水晶球;一轮跑完抱着一颗星星;工具调用失败或被拒绝时冒一滴汗。点它一下,它会很高兴。在[形象工作室](avatar.md)里画出来的面孔有同样的五张静态图;「设置」里也可以换成一个 emoji,整体做动作(呼吸、摇摆、蹦一下)。Android 应用里还可以用一段你自己的短视频当形象,只留在那台手机上([avatar.md](avatar.md#你自己的视频))。在 `prefers-reduced-motion` 下这些全部停止。聊天标签页上有一个角标,是任何地方等着你的审批数。点形象打开状态面板:大图的形象、同一行状态、有东西在跑时的一个「停止」按钮(这一轮结束,那个聊天里待处理的卡片关闭,对话照常能用)、模型和哨兵模式,然后是:
-- *审批*——所有聊天里等着你的卡片队列,当场就能回答。有待处理的时候先打开它。
-- *活动*——审计记录:每一次工具调用、判定和审批,包括被拒绝的。
-- *权限*——哨兵模式,以及你授予的每一条长期权限,每条都带一个撤销按钮。
-- *日程*——后台工作的开关、下一轮的时间、排队的目标和一个「现在运行」按钮、接下来的跟进、你的提醒和例程、你的触发器(*当某事发生时*),各自带「+ 添加」「现在运行」和取消;webhook 有一个复制 URL 的按钮。
-- *记忆*——智能体记住的关于你的一切,按类别分,加一个输入框。「忘掉」删除一条;智能体不会再看到它。每一轮对话,和消息相关的记忆会进入智能体的提示词:按关键词,以及——在*连接 → 按含义召回*下面设好向量(embedding)端点之后——按含义,所以「写邮件给房东」能带出「the landlord is Bob Li」,哪怕它们一个词都不重合。「整理」跑一遍智能体自己也会做的整理工作(每新增八行,或每周一次,除「关闭」外的任何主动性档位都做):说同一件事的几行合成一行,变了的事实留新版本,从来不是关于你的事实的一次性请求被丢掉。模型提议;nanoMuse 检查合并后的那一行没有多出原本没有的词,拒绝丢掉任何你亲手写的,每遍最多拿掉存量的五分之一。*最近的改动*列出每一次合并、丢弃和更新,以及被替换掉的原文,每条都有「撤销」。改动了东西的一次整理,在动态里是一条。
-- *技能*——一件事怎么做,写下来一次:每周回顾、旅行计划、收件箱分拣。见下面的[技能](#skills)。
-- *连接*——智能体够得着什么,在手机上就能接上和拔掉。**模型**:服务商预设(阿里云百炼、OpenRouter、DeepSeek、OpenAI、Ollama、任何 OpenAI 兼容端点)、模型名、工具调用模式和 API key——每家服务商覆盖什么、ChatGPT 登录怎么回事,在 [web.md](web.md);key 以 `LLM_API_KEY` 写进保险库,并当场换到每一个会话里;「测试」让模型回一个词。用 nanoMuse Cloud 账号时,聊天模型是 `deepseek-v4.1-flash`。**手的模型**、**生成图片**、**生成视频**:手机和桌面版在 设置 → 模型 里的另外三行,这里是聊天模型下面的几张卡片;每张打开时都是「自动」(对话模型所在的服务商能做这件事就用它,否则登录时用账号的模型),并写着「当前为 服务商 · 模型」。手把浏览器和电脑的截图发给一个看得见图像的模型:Cloud 上是 `qwen3.8-27b`;DeepSeek 家的,只有 id 带 `v4.1`、`vision` 或 `ocr` 的才行。**按含义召回**:记忆是否也按含义召回,走一个 OpenAI 兼容的 `/embeddings` 端点——*自动*(模型的端点有就用,没有就按关键词召回)、*开*、*关*;用模型自己的端点或者另一个(比如 DeepSeek 旁边放一个带 `qwen3-embedding:0.6b` 的 Ollama,因为 DeepSeek 没有向量端点——key 以 `EMBEDDINGS_API_KEY` 进保险库);向量模型名,留空用端点的默认值。「测试」调一次向量接口,成功了就当场把每一条记忆建好索引;卡片上那一行写着已索引多少条。**网页搜索**:谁来回答 `web_search`——DuckDuckGo(不用设置,但是抓取来的,偶尔会被限流)、带 key 的 Brave Search 或 Tavily(保险库:`SEARCH_API_KEY`,附一个申请 key 的链接),或者按 URL 填一个 SearXNG 实例;「测试」跑一次搜索,显示服务商、结果数和耗时。不管选的是谁,搜索失败都会退回 DuckDuckGo 并加一句说明。**邮件**:常见服务商的预设、地址和应用专用密码(保险库:`EMAIL_ADDRESS`、`EMAIL_PASSWORD`)、IMAP/SMTP 服务器;「连接」保存并登录两台服务器证明能用;「断开」删掉凭据和对应的工具。**日历**:加任何私密 `.ics` 链接(页面上写着 Google、Outlook、iCloud 和 Fastmail 各自把它藏在哪儿)或者一个文件路径;链接以 `CALENDAR_` 进保险库,日历源当场读取并显示事件数;*空闲时间*用的工作时间;「重新读取」把每个源再抓一遍。今天的事件出现在动态的*今天*下面(今天过完就显示明天的),智能体的提示词里也能看到。智能体起草的事件打开是一张卡片,带「加入日历」。**通讯录**:上传从手机导出的 `.vcf`(Google 通讯录、iCloud、Outlook、手机自带的通讯录应用——页面上写着每家把导出藏在哪儿),或者给一个路径或链接(链接以 `CONTACTS_` 进保险库);每本通讯录显示有多少人;*我的联系人*是智能体从聊天里填起来的那一本;一个搜索框,按智能体查人的方式查人。**浏览器**:开 / 关,Playwright 没装时带安装提示。**MCP 服务器**:按命令(stdio)或 URL 添加一个服务器,选它的工具的风险等级,再移除;`config.toml` 里的服务器只读列出。**保险库**:每一个已存密钥的名字,添加或删除一个。离开服务器的永远只有名字。
-- *设置*——智能体的名字、形象、颜色和性格,它怎么称呼你;哨兵模式(平衡 = `ask`,谨慎 = `strict`,放手 = `auto`)以及命令是否在[沙箱](sentinel.md#the-sandbox)里跑;主动性(关闭 / 低 / 默认 / 高的档位、跟进间隔、免打扰时段——见[后台工作](#background-work));通知(见下文);在 Android 应用上还有「保持运行」——电池、悬浮窗和闹钟权限、重启后自动启动、导出日志([archive/android-python-line.md](../archive/android-python-line.md#keeping-it-running));「显示思考过程」;应用语言;回复语言;以及始终都在的两行版本信息——已安装的版本和最新的发布(「最新版 0.1.x,你已经在用了」/「0.1.x 已发布 · 更新」/「没能检查到最新版本 · 现在检查」;查 GitHub)。
+- *审批*:所有聊天里等着你的卡片队列,当场就能回答。有待处理的时候先打开它。
+- *活动*:审计记录,每一次工具调用、判定和审批,包括被拒绝的。
+- *权限*:哨兵模式,以及你授予的每一条长期权限,每条都带一个撤销按钮。
+- *日程*:后台工作的开关、下一轮的时间、排队的目标和一个「现在运行」按钮、接下来的跟进、你的提醒和例程、你的触发器(*当某事发生时*),各自带「+ 添加」「现在运行」和取消;webhook 有一个复制 URL 的按钮。
+- *记忆*:智能体记住的关于你的一切,按类别分,加一个输入框。「忘掉」删除一条;智能体不会再看到它。每一轮对话,和消息相关的记忆会进入智能体的提示词:按关键词,以及(在*连接 → 按含义召回*下面设好向量(embedding)端点之后)按含义,所以「写邮件给房东」能带出「the landlord is Bob Li」,哪怕它们一个词都不重合。「整理」跑一遍智能体自己也会做的整理工作(每新增八行,或每周一次,除「关闭」外的任何主动性档位都做):说同一件事的几行合成一行,变了的事实留新版本,从来不是关于你的事实的一次性请求被丢掉。模型提议;nanoMuse 检查合并后的那一行没有多出原本没有的词,拒绝丢掉任何你亲手写的,每遍最多拿掉存量的五分之一。*最近的改动*列出每一次合并、丢弃和更新,以及被替换掉的原文,每条都有「撤销」。改动了东西的一次整理,在动态里是一条。
+- *技能*:一件事怎么做,写下来一次(每周回顾、旅行计划、收件箱分拣)。见下面的[技能](#skills)。
+- *连接*:智能体够得着什么,在手机上就能接上和拔掉。**模型**:服务商预设(阿里云百炼、OpenRouter、DeepSeek、OpenAI、Ollama、任何 OpenAI 兼容端点)、模型名、工具调用模式和 API key。每家服务商覆盖什么、ChatGPT 登录怎么回事,在 [web.md](web.md);key 以 `LLM_API_KEY` 写进保险库,并当场换到每一个会话里;「测试」让模型回一个词。用 nanoMuse Cloud 账号时,聊天模型是 `deepseek-v4.1-flash`。**手的模型**、**生成图片**、**生成视频**:手机和桌面版在 设置 → 模型 里的另外三行,这里是聊天模型下面的几张卡片;每张打开时都是「自动」(对话模型所在的服务商能做这件事就用它,否则登录时用账号的模型),并写着「当前为 服务商 · 模型」。手把浏览器和电脑的截图发给一个看得见图像的模型:Cloud 上是 `qwen3.8-27b`;DeepSeek 家的,只有 id 带 `v4.1`、`vision` 或 `ocr` 的才行。**按含义召回**:记忆是否也按含义召回,走一个 OpenAI 兼容的 `/embeddings` 端点:*自动*(模型的端点有就用,没有就按关键词召回)、*开*、*关*;用模型自己的端点或者另一个(比如 DeepSeek 旁边放一个带 `qwen3-embedding:0.6b` 的 Ollama,因为 DeepSeek 没有向量端点;key 以 `EMBEDDINGS_API_KEY` 进保险库);向量模型名,留空用端点的默认值。「测试」调一次向量接口,成功了就当场把每一条记忆建好索引;卡片上那一行写着已索引多少条。**网页搜索**:谁来回答 `web_search`:DuckDuckGo(不用设置,但是抓取来的,偶尔会被限流)、带 key 的 Brave Search 或 Tavily(保险库:`SEARCH_API_KEY`,附一个申请 key 的链接),或者按 URL 填一个 SearXNG 实例;「测试」跑一次搜索,显示服务商、结果数和耗时。不管选的是谁,搜索失败都会退回 DuckDuckGo 并加一句说明。**邮件**:常见服务商的预设、地址和应用专用密码(保险库:`EMAIL_ADDRESS`、`EMAIL_PASSWORD`)、IMAP/SMTP 服务器;「连接」保存并登录两台服务器证明能用;「断开」删掉凭据和对应的工具。**日历**:加任何私密 `.ics` 链接(页面上写着 Google、Outlook、iCloud 和 Fastmail 各自把它藏在哪儿)或者一个文件路径;链接以 `CALENDAR_` 进保险库,日历源当场读取并显示事件数;*空闲时间*用的工作时间;「重新读取」把每个源再抓一遍。今天的事件出现在动态的*今天*下面(今天过完就显示明天的),智能体的提示词里也能看到。智能体起草的事件打开是一张卡片,带「加入日历」。**通讯录**:上传从手机导出的 `.vcf`(Google 通讯录、iCloud、Outlook、手机自带的通讯录应用;页面上写着每家把导出藏在哪儿),或者给一个路径或链接(链接以 `CONTACTS_` 进保险库);每本通讯录显示有多少人;*我的联系人*是智能体从聊天里填起来的那一本;一个搜索框,按智能体查人的方式查人。**浏览器**:开 / 关,Playwright 没装时带安装提示。**MCP 服务器**:按命令(stdio)或 URL 添加一个服务器,选它的工具的风险等级,再移除;`config.toml` 里的服务器只读列出。**保险库**:每一个已存密钥的名字,添加或删除一个。离开服务器的永远只有名字。
+- *设置*:智能体的名字、形象、颜色和性格,它怎么称呼你;哨兵模式(平衡 = `ask`,谨慎 = `strict`,放手 = `auto`)以及命令是否在[沙箱](sentinel.md#the-sandbox)里跑;主动性(关闭 / 低 / 默认 / 高的档位、跟进间隔、免打扰时段;见[后台工作](#background-work));通知(见下文);在 Android 应用上还有「保持运行」:电池、悬浮窗和闹钟权限、重启后自动启动、导出日志([archive/android-python-line.md](../archive/android-python-line.md#keeping-it-running));「显示思考过程」;应用语言;回复语言;以及始终都在的两行版本信息:已安装的版本和最新的发布(「最新版 0.1.x,你已经在用了」/「0.1.x 已发布 · 更新」/「没能检查到最新版本 · 现在检查」;先查这个仓库的 GitHub Releases,再查配置过的镜像索引)。
-应用会说 English 和简体中文。*设置 → 应用语言*是设备级的设置(存在浏览器里,不在服务器上):*自动*跟着浏览器的语言走,不然就自己选一个;日期和相对时间也跟着它。它和*回复语言*是两回事,后者是智能体用什么语言写。字符串放在 `web/src/i18n/`——英文原文就是 key,`zh-CN.ts` 是译文,应用里有哪条字符串没有译文,单元测试就会失败。
+应用会说 English 和简体中文。*设置 → 应用语言*是设备级的设置(存在浏览器里,不在服务器上):*自动*跟着浏览器的语言走,不然就自己选一个;日期和相对时间也跟着它。它和*回复语言*是两回事,后者是智能体用什么语言写。字符串放在 `web/src/i18n/`:英文原文就是 key,`zh-CN.ts` 是译文,应用里有哪条字符串没有译文,单元测试就会失败。
### 技能 {#skills}
-技能是一份做法:一个文件夹,里面有一个 `SKILL.md`——开头是名字和一行描述,然后是 Markdown 写的步骤——用的是 [Agent Skills](https://agentskills.io) 的格式,所以给别的智能体写的技能在这里能用,你写的在那边也能用。应用自带十一个(`weekly-review`、`trip-plan`、`inbox-triage`、`compare-options`、`meeting-prep`、`phone-messages`,以及对接服务的 `feishu`、`tencent-meeting`、`amap`、`kuaidi100`、`train-tickets`);你自己的放在 `/skills//`,和内置技能同名的会替换掉内置的。
+技能是一份做法:一个文件夹,里面有一个 `SKILL.md`(开头是名字和一行描述,然后是 Markdown 写的步骤),用的是 [Agent Skills](https://agentskills.io) 的格式,所以给别的智能体写的技能在这里能用,你写的在那边也能用。应用自带十一个(`weekly-review`、`trip-plan`、`inbox-triage`、`compare-options`、`meeting-prep`、`phone-messages`,以及对接服务的 `feishu`、`tencent-meeting`、`amap`、`kuaidi100`、`train-tickets`);你自己的放在 `/skills//`,和内置技能同名的会替换掉内置的。
-模型在系统提示词里看到的是索引——每个启用技能的名字和描述——请求对得上就挑一个(「帮我规划在京都的一周」会去拿 `trip-plan`),开始之前用 `skills` 工具读完整的步骤。你也可以自己点名:在输入框里打 `/`,启用的技能就冒出来(`Tab` 补全第一个匹配);`/trip-plan Kyoto, 5 days in November` 把这个技能的指令连同你的文字作为任务一起发出去。
+模型在系统提示词里看到的是索引(每个启用技能的名字和描述),请求对得上就挑一个(「帮我规划在京都的一周」会去拿 `trip-plan`),开始之前用 `skills` 工具读完整的步骤。你也可以自己点名:在输入框里打 `/`,启用的技能就冒出来(`Tab` 补全第一个匹配);`/trip-plan Kyoto, 5 days in November` 把这个技能的指令连同你的文字作为任务一起发出去。
-新技能有三条来路。在*技能 → 新建*里自己写(名字填好后出现一份模板);粘贴一个链接——原始的 `SKILL.md`,或者 GitHub 的文件夹页或文件页——然后「获取」;或者在一件事办得顺利之后,告诉智能体「把这个存成技能」:它把自己做过的事写成步骤,并先问过你(`skills` 的 action=save 是一次敏感调用,所以即使在 `auto` 模式下也需要你审批,删除技能也一样)。把一个技能关掉,它就不进模型的列表;内置技能上的「复制一份改成你的」会在编辑器里以你的名义打开它。技能自带的脚本和参考文件在[沙箱](sentinel.md#the-sandbox)里以只读方式可见。终端里:`nanomuse skills list | show | add | new | remove | enable | disable`。
+新技能有三条来路。在*技能 → 新建*里自己写(名字填好后出现一份模板);粘贴一个链接(原始的 `SKILL.md`,或者 GitHub 的文件夹页或文件页),然后「获取」;或者在一件事办得顺利之后,告诉智能体「把这个存成技能」:它把自己做过的事写成步骤,并先问过你(`skills` 的 action=save 是一次敏感调用,所以即使在 `auto` 模式下也需要你审批,删除技能也一样)。把一个技能关掉,它就不进模型的列表;内置技能上的「复制一份改成你的」会在编辑器里以你的名义打开它。技能自带的脚本和参考文件在[沙箱](sentinel.md#the-sandbox)里以只读方式可见。终端里:`nanomuse skills list | show | add | new | remove | enable | disable`。
### 通知 {#notifications}
-*设置 → 通知 → 让 nanoMuse 通知这台设备。* 标准的 Web Push,走浏览器自己的推送服务,不需要在任何人那里开账号:服务器生成一次 VAPID 密钥对(`/push-vapid.json`),并保存你各台设备的订阅(`push-subscriptions.json`)。智能体需要你审批、问你问题、跑完一轮有东西要汇报的后台工作,或者某个目标到了跟进时间,你就会收到一条通知。安静的轮次和一步一步的叙述从不离开应用,应用在屏幕上时也什么都不显示——卡片已经在那儿了。点通知打开对应的聊天。手机上把应用放在主屏幕后,图标上的角标是等着你的卡片数。
+*设置 → 通知 → 让 nanoMuse 通知这台设备。* 标准的 Web Push,走浏览器自己的推送服务,不需要在任何人那里开账号:服务器生成一次 VAPID 密钥对(`/push-vapid.json`),并保存你各台设备的订阅(`push-subscriptions.json`)。智能体需要你审批、问你问题、跑完一轮有东西要汇报的后台工作,或者某个目标到了跟进时间,你就会收到一条通知。安静的轮次和一步一步的叙述从不离开应用,应用在屏幕上时也什么都不显示:卡片已经在那儿了。点通知打开对应的聊天。手机上把应用放在主屏幕后,图标上的角标是等着你的卡片数。
-推送需要安全上下文:`https://` 或 `localhost`。在局域网里走明文 `http://` 时,应用的其余部分照常工作,开关会解释为什么这一项关着——TLS 的搭法见[部署](deployment.md#reaching-it-from-outside-your-network)。内嵌浏览器(嵌在别的应用里的那种)通常根本没有推送服务;用 Chrome、Edge、Firefox 或 Safari 16.4+(iOS 上只有主屏幕应用可以)。
+推送需要安全上下文:`https://` 或 `localhost`。在局域网里走明文 `http://` 时,应用的其余部分照常工作,开关会解释为什么这一项关着;TLS 的搭法见[部署](deployment.md#reaching-it-from-outside-your-network)。内嵌浏览器(嵌在别的应用里的那种)通常根本没有推送服务;用 Chrome、Edge、Firefox 或 Safari 16.4+(iOS 上只有主屏幕应用可以)。
-在「连接」里做的非密钥选择存在 `/app-settings.json`,每次启动时叠在 `config.toml` 之上——命令行也一样——所以只用手机的配置方式从来不需要改文件。密钥在那里永远只以 `{{vault:NAME}}` 的形式被引用。
+在「连接」里做的非密钥选择存在 `/app-settings.json`,每次启动时叠在 `config.toml` 之上(命令行也一样),所以只用手机的配置方式从来不需要改文件。密钥在那里永远只以 `{{vault:NAME}}` 的形式被引用。
### 带着东西打开应用 {#opening-the-app-on-something}
-页面接受几个查询参数,Android 应用在用,书签也可以用:`?thread=` 打开一段对话,`?tab=goals`(feed、ideas、library、connections)打开一个标签页;`&draft=` 把文字放进输入框但不发送;`&attach=`——`POST /api/files/upload` 返回的 `{path, name, mime, size}` 的 JSON 列表——把已经在工作区里的文件加成附件小标签。它们只读一次,然后从地址栏里去掉。手机上的**分享 → nanoMuse** 就是这么做的:应用上传分享来的文件(`POST /api/files/upload`),开一个以主题或第一个文件命名的会话(`POST /api/threads`),然后带着文字作为草稿、文件作为附件打开它,等你说要拿它们做什么。在手机上,智能体还把手机自己的能力当工具用,工作区也会出现在「文件」应用里——[device.md](device.md)。
+页面接受几个查询参数,Android 应用在用,书签也可以用:`?thread=` 打开一段对话,`?tab=goals`(feed、ideas、library、connections)打开一个标签页;`&draft=` 把文字放进输入框但不发送;`&attach=`(`POST /api/files/upload` 返回的 `{path, name, mime, size}` 的 JSON 列表)把已经在工作区里的文件加成附件小标签。它们只读一次,然后从地址栏里去掉。手机上的**分享 → nanoMuse** 就是这么做的:应用上传分享来的文件(`POST /api/files/upload`),开一个以主题或第一个文件命名的会话(`POST /api/threads`),然后带着文字作为草稿、文件作为附件打开它,等你说要拿它们做什么。在手机上,智能体还把手机自己的能力当工具用,工作区也会出现在「文件」应用里:[device.md](device.md)。
## 后台工作 {#background-work}
@@ -116,7 +116,7 @@ Meta 的 Muse「自己会做事,但不会做太多」。「设置」里的*主
| 默认 | 每个间隔一轮 | 有实际进展或者有你会想知道的事的时候 |
| 高 | 每 ½ 间隔一轮 | 总是,包括「仍在正轨上」 |
-每一轮,服务挑一个还有待办步骤的活跃目标,在主要聊天里以哨兵的 `auto` 模式跑它(明确的拒绝规则和危险调用警告仍然有效——这些会变成动态里的卡片),然后让模型自己判断结果值不值得打扰你。没什么可说的一轮,总结以 `[quiet]` 开头:聊天里显示一行灰字(「已检查*目标*——没有新进展」,点开可展开),动态里列成一行,不打任何角标。其余的就是你的 nanoMuse 发来的一条普通消息。
+每一轮,服务挑一个还有待办步骤的活跃目标,在主要聊天里以哨兵的 `auto` 模式跑它(明确的拒绝规则和危险调用警告仍然有效;这些会变成动态里的卡片),然后让模型自己判断结果值不值得打扰你。没什么可说的一轮,总结以 `[quiet]` 开头:聊天里显示一行灰字(「已检查*目标*:没有新进展」,点开可展开),动态里列成一行,不打任何角标。其余的就是你的 nanoMuse 发来的一条普通消息。
*免打扰时段*(「22:00–08:00」,服务器本地时间)会按住后台工作;落在这个窗口里的一轮等窗口结束再跑。*日程*视图和动态的*接下来*卡片显示档位、实际间隔和下一轮的时间,或者当前免打扰窗口什么时候结束。
@@ -124,7 +124,7 @@ Meta 的 Muse「自己会做事,但不会做太多」。「设置」里的*主
## API {#api}
-应用做的每一件事都走这套 API,所以另一个前端(Telegram 机器人、桌面小组件)可以驱动同一个智能体。除非 `server.auth = false`,所有请求都需要 `Authorization: Bearer `。(从 0.1.33 起 `?token=` 被拒绝——URL 里的令牌会落进一路上的每一份访问日志;仍然这么发的请求会得到一个说明原因的 401,socket 则收到一帧 `code: "legacy_token"` 的 `error` 和一个 4401 关闭。)应用内嵌显示的那些字节——`/api/files/*`、浏览器画面帧——也可以用客户端拿自己的令牌签过名的链接打开,`?exp=&sig=\n")[:32]>`:被记进日志的链接在几小时内只能打开那一个路径,别的什么都打不开。
+应用做的每一件事都走这套 API,所以另一个前端(Telegram 机器人、桌面小组件)可以驱动同一个智能体。除非 `server.auth = false`,所有请求都需要 `Authorization: Bearer `。(从 0.1.33 起 `?token=` 被拒绝:URL 里的令牌会落进一路上的每一份访问日志;仍然这么发的请求会得到一个说明原因的 401,socket 则收到一帧 `code: "legacy_token"` 的 `error` 和一个 4401 关闭。)应用内嵌显示的那些字节(`/api/files/*`、浏览器画面帧)也可以用客户端拿自己的令牌签过名的链接打开,`?exp=&sig=\n")[:32]>`:被记进日志的链接在几小时内只能打开那一个路径,别的什么都打不开。
| 方法 | 路径 | 用途 |
|---|---|---|
@@ -134,10 +134,10 @@ Meta 的 Muse「自己会做事,但不会做太多」。「设置」里的*主
| PATCH / DELETE | `/api/threads/{id}` | 重命名 / 删除 |
| POST | `/api/threads/{id}/clear` | 清空对话 |
| GET | `/api/threads/{id}/events?limit=&before=` | 时间线事件 |
-| POST | `/api/threads/{id}/send` `{text, files?, language?}` | 把一条消息排进队列;立即返回。`files`:下面那个上传接口返回的工作区路径,最多十个;有文件时文字可以为空。`language`(0.1.42 起):客户端界面语言的 BCP-47 标签(`en`、`zh-CN`);除非「回复语言」固定了一种,回复就用这种语言写。不带它时照旧按消息的文字书写系统判断 |
+| POST | `/api/threads/{id}/send` `{text, files?, language?, via?}` | 把一条消息排进队列;立即返回。`files`:下面那个上传接口返回的工作区路径,最多十个;有文件时文字可以为空。`language`(1.0.0 起):客户端界面语言的 BCP-47 标签(`en`、`zh-CN`);除非「回复语言」固定了一种,回复就用这种语言写。不带它时照旧按消息的文字书写系统判断。`via: "cloud"` 是「这次改用 nanoMuse Cloud」:这一个回合用账号的 key 在账号推荐的聊天模型上跑,下一回合回到配置的模型,`[llm]` 不动;用户气泡、回复和这一回合的失败提示都带 `model_used`。没有登录账号时回 `401 signed_out` |
| POST | `/api/threads/{id}/stop` | 停掉那个聊天里正在跑的一轮:队列丢弃,那里待处理的审批和提问卡片过期,对话照常能用。没有东西在跑时返回 `{ok: false}` |
| POST | `/api/files/upload?name=` (请求体:文件字节) | 一个要附上的文件:落到 `attachments//` 下,文件名是 `name` 的安全版本;返回给 `files` 用的 `{path, name, size, kind, mime}`。超过 `server.max_upload_mb` 返回 413 |
-| POST | `/api/approvals/{id}` `{approved, scope, reason}` | 回答一张卡片;`scope` 是这张卡片 `grant_options` 中的一个(`once`、`task`、`session`、`24h`、`always`) |
+| POST | `/api/approvals/{id}` `{approved, scope, reason}` | 回答一张卡片;`scope` 是这张卡片 `grant_options` 中的一个(`once`、`conversation`、`session`、`24h`、`always`;`task` 仍按 `conversation` 读) |
| DELETE | `/api/approvals` | 忘掉所有已授予的权限 |
| DELETE | `/api/approvals/grants/{key}` | 撤销一条权限(`key` 按 `/api/activity` 列出的来,比如 `shell:git`) |
| GET / POST | `/api/goals[?status=&category=]` | 列出 / 创建 `{title, description, steps[], category, due (YYYY-MM-DD), check_in ("daily 08:00", "weekdays 07:30", "weekly mon 09:00", "monthly 1 09:00")}` |
@@ -153,22 +153,26 @@ Meta 的 Muse「自己会做事,但不会做太多」。「设置」里的*主
| GET | `/api/activity` | 审计记录尾部、已授予的权限(`grants[]`,含 `key`、`tool`、`target`、`scope`、`expires_at`)、污点标记 |
| GET | `/api/feed?limit=` | *你离开期间*:`{id, ts, kind (background · artifact · approval · question), title, text, thread, thread_title, path}`,最新的在前 |
| GET | `/api/feed/posts` | 写给你的那些短文:`{instructions, daily, time, generated_at, posts[{id, ts, title, body, area, prompt}], error?}` |
-| PUT | `/api/feed/instructions` `{instructions?, daily?, time?}` | 短文该写些什么(≤ 2000 字符),以及每日例程——开或关,什么时候(`HH:MM`,初始 08:00) |
+| PUT | `/api/feed/instructions` `{instructions?, daily?, time?}` | 短文该写些什么(≤ 2000 字符),以及每日例程:开或关,什么时候(`HH:MM`,初始 08:00) |
| POST | `/api/feed/posts/refresh` | 现在就写几条新的;模型拒绝时 `error` 说明原因 |
| DELETE | `/api/feed/posts/{id}` | 删掉一条短文 |
-| GET | `/api/upcoming` | `{proactivity, proactive, interval_minutes, effective_interval_minutes, quiet_hours, quiet_until, next_pass_at, next_wake_at, queue[{goal_id, title, category, due, overdue, next_step, progress}], check_ins[{goal_id, title, at, cadence}], reminders[], triggers{items[], available{mail, event, hook}, mail_checked_at, mail_error, mail_poll_minutes}, busy}`——`next_wake_at` 是任何已排定事项(一条提醒、一次跟进、下一轮)最早到期的那一刻,给能设闹钟的宿主用([local-runtime.md](local-runtime.md#waking-up)) |
-| POST | `/api/tick` | 现在就唤醒调度器——它看看什么到期了就跑什么;`{ok, next_wake_at}`。Android 应用的闹钟响时会调它 |
+| GET | `/api/upcoming` | `{proactivity, proactive, interval_minutes, effective_interval_minutes, quiet_hours, quiet_until, next_pass_at, next_wake_at, queue[{goal_id, title, category, due, overdue, next_step, progress}], check_ins[{goal_id, title, at, cadence}], reminders[], triggers{items[], available{mail, event, hook}, mail_checked_at, mail_error, mail_poll_minutes}, busy}`:`next_wake_at` 是任何已排定事项(一条提醒、一次跟进、下一轮)最早到期的那一刻,给能设闹钟的宿主用([local-runtime.md](local-runtime.md#waking-up)) |
+| POST | `/api/tick` | 现在就唤醒调度器:它看看什么到期了就跑什么;`{ok, next_wake_at}`。Android 应用的闹钟响时会调它 |
| GET / POST | `/api/reminders[?all=1]` | 活跃的(或者最近的全部)提醒 / 创建 `{text, at ("YYYY-MM-DD HH:MM") or repeat ("daily 08:00", …), kind (remind · task), thread}` |
| POST / DELETE | `/api/reminders/{id}/fire`、`/api/reminders/{id}` | 现在就在它的聊天里送达 / 取消 |
-| GET / POST | `/api/triggers` | 触发器列表,并说明哪几种已经有了对应的连接器 / 创建 `{kind (mail · event · hook), text, match, lead_minutes, thread}`——hook 会连着它的 `url` 一起返回 |
+| GET / POST | `/api/triggers` | 触发器列表,并说明哪几种已经有了对应的连接器 / 创建 `{kind (mail · event · hook), text, match, lead_minutes, thread}`;hook 会连着它的 `url` 一起返回 |
| POST / DELETE | `/api/triggers/{id}/fire`、`/api/triggers/{id}` | 用一个样例事件现在就跑一次 / 取消 |
-| POST | `/api/hooks/{id}?key=` | 触发器的 webhook——不要应用令牌,key 就是凭据(也接受 `X-Hook-Key` 头);请求体(≤ 64 KB,JSON 会格式化)就是上下文。id 或 key 错了返回 404,已取消返回 409,投递太密返回 429 |
+| POST | `/api/hooks/{id}?key=` | 触发器的 webhook:不要应用令牌,key 就是凭据(也接受 `X-Hook-Key` 头);请求体(≤ 64 KB,JSON 会格式化)就是上下文。id 或 key 错了返回 404,已取消返回 409,投递太密返回 429 |
| GET | `/api/files` · `/api/files/{path}?download=1` | 列出 / 读取工作区文件(HTML 和 SVG 带着 `Content-Security-Policy: sandbox` 返回) |
| GET / PUT | `/api/settings` | 查看 / 修改 `{profile{name (≤ 20), avatar, emoji, color, tagline (≤ 60), tone, communication, style, user_name, proactive, goal_interval_minutes}, sentinel_mode, show_thinking, language}`;查看时还包含 `onboarded` 和 `llm_ready` |
-| GET | `/api/connections` | 模型(`key_source`:vault · config · missing · none)、服务商预设、邮件、浏览器、MCP 服务器(`connected`、`tools`、`from_app`)、保险库里的名字、`onboarded` |
-| PUT | `/api/connections/llm` `{provider, model, base_url, tool_mode, api_key}` | 换模型;`api_key` 给了 → 存进保险库,`""` → 不用 key,没给 → 不变。在每个会话里立即生效 |
+| GET | `/api/connections` | 模型(`key_source`:vault · config · missing · none;是列表里的接口时带 `endpoint`)、服务商预设、`endpoints`(列表里的自定义 OpenAI 兼容接口,每个是 `{id, label, base_url, key_source, models, slots}`,从不带 key)、邮件、浏览器、MCP 服务器(`connected`、`tools`、`from_app`)、保险库里的名字、`onboarded` |
+| PUT | `/api/connections/llm` `{provider, model, base_url, tool_mode, api_key}` 或 `{endpoint, model, tool_mode}` | 换模型;`api_key` 给了 → 存进保险库,`""` → 不用 key,没给 → 不变。`endpoint` 指向列表里的一个接口,槽位借用它的地址和它自己的 key(按引用)。在每个会话里立即生效 |
| POST | `/api/connections/llm/test` | 和模型来一次简短往返:`{ok, reply, ms}` 或 `{ok: false, error}` |
-| POST | `/api/llm/models` `{preset, base_url, api_key}` | 一个端点提供的模型:查它的 `/models`(然后 `/v1/models`),用给定的 key,没给就用保险库里的——`{models, source: "live"}`;连不上时返回预设的内置目录,带 `source: "catalogue"` 和 `error`。什么都不保存 |
+| POST | `/api/llm/models` `{preset, base_url, api_key}` 或 `{endpoint}` | 一个端点提供的模型:查它的 `/models`(然后 `/v1/models`),用给定的 key,没给就用保险库里的,返回 `{models, source: "live"}`;连不上时返回预设的内置目录,带 `source: "catalogue"` 和 `error`;对列表里的 `endpoint` 则返回随它保存的模型。什么都不保存 |
+| POST | `/api/connections/endpoints` `{label, base_url, api_key, models}` | 往列表里添加一个自定义 OpenAI 兼容接口(#294):`label` 可选(不填就用主机名),`api_key` 可选(保险库:`ENDPOINT__API_KEY`),`models` 是它提供的模型 id,逗号分隔,在它列不出模型时填(两者都没有时回 400 和一句话)。返回这个条目;nanoMuse Cloud 或目录预设的地址会被拒绝 |
+| PUT | `/api/connections/endpoints/{id}` `{label, base_url, api_key, models}` | 修改一个接口:新名字、新地址、新 key(`""` 删掉)或手填的模型;没给的字段不变。用到这个接口的每个槽位一起跟着变。id 不存在回 404 |
+| DELETE | `/api/connections/endpoints/{id}` | 移除一个接口和它在保险库里的 key;设在它上面的双手、图片或短片回到自动。对话模型正通过它回答时回 409 |
+| POST | `/api/connections/endpoints/{id}/models` | 重新向接口要一遍模型并保存:返回条目加 `source: "live"`,它没回答时是 `"kept"` 和 `error`,沿用已保存的列表 |
| PUT | `/api/connections/embeddings` `{mode, model, base_url, api_key}` | 按含义召回:`mode` 为 auto/on/off;`base_url` 为 `""` → 用模型的端点;`model` 为 `""` → 用端点的默认值;`api_key` 给了 → 保险库(`EMBEDDINGS_API_KEY`),`""` → 用模型的 key,没给 → 不变。当场重建向量模型 |
| POST | `/api/connections/embeddings/test` | 调一次向量接口,然后给每条记忆建索引:`{ok, model, dims, indexed, ms}` 或 `{ok: false, error}` |
| PUT | `/api/connections/search` `{provider, api_key, base_url}` | 网页搜索:`provider` 为 duckduckgo/brave/tavily/searxng;`api_key` 给了 → 保险库(`SEARCH_API_KEY`),`""` 删掉它,没给 → 不变;`base_url` 是 SearXNG 实例。从下一次搜索起生效 |
@@ -176,62 +180,84 @@ Meta 的 Muse「自己会做事,但不会做太多」。「设置」里的*主
| PUT / DELETE | `/api/connections/email` | 保存 `{address, password, imap_host, imap_port, smtp_host, smtp_port, smtp_starttls, enabled}`(地址和密码进保险库)/ 断开并忘掉凭据 |
| POST | `/api/connections/email/test` | 登录 IMAP 和 SMTP:`{ok, inbox}` 或 `{ok: false, error}` |
| PUT | `/api/connections/calendar` `{enabled, day_start, day_end, refresh_minutes}` | 工作时间和刷新频率;时间格式不对返回 400 |
-| POST · DELETE | `/api/connections/calendar/feeds` `{name, url}` · `/api/connections/calendar/feeds/{name}` | 添加或替换一个日历源(链接以 `CALENDAR_` 进保险库;马上读取——`error` 说明为什么读不了)/ 移除一个从应用里添加的 |
+| POST · DELETE | `/api/connections/calendar/feeds` `{name, url}` · `/api/connections/calendar/feeds/{name}` | 添加或替换一个日历源(链接以 `CALENDAR_` 进保险库;马上读取;`error` 说明为什么读不了)/ 移除一个从应用里添加的 |
| POST | `/api/connections/calendar/test` | 把每个源重读一遍:`{ok, events, feeds}` 或 `{ok: false, error}` |
-| GET | `/api/calendar` | `{configured, today, events[{uid, summary, start, end, all_day, location, description, calendar}], feeds[], fetched_at, stale}`——今天和明天的事件 |
+| GET | `/api/calendar` | `{configured, today, events[{uid, summary, start, end, all_day, location, description, calendar}], feeds[], fetched_at, stale}`:今天和明天的事件 |
| PUT | `/api/connections/contacts` `{enabled}` | 打开或关闭通讯录工具 |
-| POST · DELETE | `/api/connections/contacts/sources` `{name, url}` · `/api/connections/contacts/sources/{name}` | 添加或替换一本通讯录——一个 `.vcf` 路径,或者一个以 `CONTACTS_` 存在保险库里的链接(马上读取;`error` 说明为什么读不了)/ 移除一本从应用里添加的 |
-| POST | `/api/connections/contacts/import?name=` | 上传一个 `.vcf`(请求体就是文件)——存在 `/contacts/` 下并作为一个来源加入 |
+| POST · DELETE | `/api/connections/contacts/sources` `{name, url}` · `/api/connections/contacts/sources/{name}` | 添加或替换一本通讯录:一个 `.vcf` 路径,或者一个以 `CONTACTS_` 存在保险库里的链接(马上读取;`error` 说明为什么读不了)/ 移除一本从应用里添加的 |
+| POST | `/api/connections/contacts/import?name=` | 上传一个 `.vcf`(请求体就是文件),存在 `/contacts/` 下并作为一个来源加入 |
| POST | `/api/connections/contacts/test` | 把每本通讯录重读一遍:`{ok, contacts, sources}` 或 `{ok: false, error}` |
| GET | `/api/contacts?q=&limit=` | 按智能体的方式查人:`[{id, name, emails[], phones[], org, …}]` |
| PUT | `/api/connections/browser` `{enabled}` | 打开或关闭浏览器工具 |
-| PUT | `/api/connections/gui` `{enabled, provider, model, base_url, api_key}` | 操作手机([gui.md](gui.md)):这个开关立刻添加或移除手机工具;模型字段是操作器自己的模型(`""` → 用主模型的);`api_key` 给了 → 保险库(`GUI_API_KEY`),`""` → 用主 key,没给 → 不变 |
+| PUT | `/api/connections/gui` `{enabled, provider, model, base_url, api_key}` | 操作手机([gui.md](gui.md)):这个开关立刻添加或移除手机工具;模型字段是操作器自己的模型(`""` → 用主模型的);`api_key` 给了 → 保险库(`GUI_API_KEY`),`""` → 用主 key,没给 → 不变。用 `endpoint` 代替 `provider` / `base_url` / `api_key` 时指向列表里的一个接口,和对话模型一样;`PUT /api/connections/image` 和 `/video` 也是 |
| POST | `/api/connections/gui/test` | 和操作器的模型来一次简短往返:`{ok, reply, ms}` 或 `{ok: false, error}` |
-| GET | `/api/phone` | `{connected, device{id, name, platform, gui, apps, width, height}, gui_enabled, last_screen, screen}`——哪台手机连着,以及它最后发来的屏幕 |
+| GET | `/api/phone` | `{connected, device{id, name, platform, gui, apps, width, height}, gui_enabled, last_screen, screen}`:哪台手机连着,以及它最后发来的屏幕 |
+| GET | `/api/hands` | 这台电脑自己的屏幕和双手([gui.md](gui.md)):`{enabled, available, backend, reason, mode, coords, screen_size, picture_size, window{available, reason, active, app, title, frame}, device, task_active, task_text, last_screen}`;在手机上是 `{enabled: false, available: false, reason}` |
+| PUT | `/api/connections/hands` `{enabled, backend (auto · desktop · pyautogui · xdotool), mode (auto · screen · window)}` | 这台电脑双手的开关,以及由哪个后端驱动;省略的字段不变;未知的后端或模式返回 400。回答上面的视图 |
+| POST | `/api/hands/stop` | 双手卡片上的「停止」:这台电脑屏幕上的任务在下一步结束;`{stopped}` |
| POST · DELETE | `/api/connections/mcp` `{name, command, args[], env{}, url, risk}` · `/api/connections/mcp/{name}` | 现在就连接一个服务器(起不来返回 502)/ 断开并移除一个从应用里添加的 |
| GET | `/api/skills` | `{count, built_in, yours, dir, errors{}, skills[{name, description, source (built-in · yours), enabled, path, files[], metadata{}, channel, updated_at}]}` |
-| GET / PUT / DELETE | `/api/skills/{name}` | 一个技能,带 `body` 和 `content`(整个 `SKILL.md`)/ 从 `{content}` 写入——用内置技能的名字会创建你的副本 / 删除你自己的一个 |
+| GET / PUT / DELETE | `/api/skills/{name}` | 一个技能,带 `body` 和 `content`(整个 `SKILL.md`)/ 从 `{content}` 写入(用内置技能的名字会创建你的副本)/ 删除你自己的一个 |
| POST | `/api/skills/{name}/enabled` `{enabled}` | 打开或关闭一个技能(记在 `app-settings.json` 里) |
-| POST | `/api/skills/import` `{url}` | 抓取一个 `SKILL.md`——原始链接,或者 GitHub 的文件夹页或文件页——然后保存 |
+| POST | `/api/skills/import` `{url}` | 抓取一个 `SKILL.md`(原始链接,或者 GitHub 的文件夹页或文件页),然后保存 |
| GET · PUT · DELETE | `/api/vault` · `/api/vault/{name}` `{value}` | 列出密钥名 / 存入 / 删除。值永远不会返回 |
| POST | `/api/onboarded` `{done}` | 标记第一次打开的设置已完成;配好了模型的话,动态的第一天当时就在后台写好(`feed_started`) |
-| GET | `/api/firstrun`(`?lang=`) | 第一次对话([web.md](web.md#the-first-run-and-the-chats-opening)):`{phase (none · ask_user_name · ask_agent_name · named · done), session_id, user_address, suggestions, chips, chosen, lang, running, started_at, finished_at, intro[]}`——`chips` 是起名卡片提供的名字,`intro` 是用 `lang` 说的三句开场白 |
+| GET | `/api/firstrun`(`?lang=`) | 第一次对话([web.md](web.md#the-first-run-and-the-chat-s-opening)):`{phase (none · ask_user_name · ask_agent_name · named · done), session_id, user_address, suggestions, chips, chosen, lang, running, started_at, finished_at, intro[]}`;`chips` 是起名卡片提供的名字,`intro` 是用 `lang` 说的三句开场白 |
| POST | `/api/firstrun/start` `{lang}` | 「开始」:把对话绑定到主聊天,标记设置已完成,`none → ask_user_name`;已经有对话时跳过这套开场(`done`)。返回带 `intro` 的视图 |
| POST | `/api/firstrun/pick` `{name}` | 在 `ask_agent_name` 阶段点了一个名字:名字立刻存进资料,阶段变为 `named`;不该选的时候 409,名字为空 400 |
| POST | `/api/firstrun/dismiss` | 撤掉起名卡片,开场结束(`done`) |
-| GET | `/api/nudges`(`?refresh=1`) | 应用什么时候可以请你在 GitHub 上点个 star:中继的策略(`/v1/nudges`,每天读一次,登录时也读)叠在内置默认值之上——`{star{enabled, url, moments{signed_in, tasks[], new_look, exhausted, days_used[], goal_done}, cooldown_days, max_asks, text, text_zh}, version, source}`;`text` / `text_zh` 是中继设置时 Star 卡片显示的那句话(最多 200 个字符,否则用应用自己的话) |
-| GET | `/api/update`(`?refresh=1`) | `{current, enabled, latest, newer, url, download_url, checked_at, source (github), error}`——最新的发布,查 GitHub 的 `releases/latest`;缓存一天 |
-| GET / PUT | `/api/sync/state` · `{enabled}` | 账号各设备之间同步的对话([every-device.md](every-device.md#the-same-conversations-everywhere)):`{enabled, available, paused, cursor, last_pull_at, last_push_at, error, relay}`——`available` 是「已登录且设置允许」,`paused` 在收到 401 后置上,直到下一次登录,`relay` 是中继自己的 `{enabled, cursor, counts{conversations, messages}, limits}`,问不到时为 `null`。`PUT {enabled: false}` 在这里关掉同步,并删掉中继为这个账号保存的内容(其他设备的开关会在它们下一次推送或拉取时跟着关,中继会以 `sync_off` 拒绝);`PUT {enabled: true}` 重新打开,并把这里的聊天推上去。中继的拒绝会以它的状态码和代码原样返回(`409 sync_off`)。从 0.1.38 起,状态里还带 `side_chats`(这台设备的*同时同步旁聊*,默认关闭:只推送和拉取主要聊天)和 `working`(`[{thread, cid, device, device_name, at}]`——此刻正在回答的其他设备,来自 hub 的 `working` 帧,每条十分钟后丢弃);`PUT {side_chats: true}` 把这台设备的旁聊发上去,并从头把账号的旁聊拉一次,`false` 则从此留在本地 |
+| GET | `/api/nudges`(`?refresh=1`) | 应用什么时候可以请你在 GitHub 上点个 star:中继的策略(`/v1/nudges`,每天读一次,登录时也读)叠在内置默认值之上,即 `{star{enabled, url, moments{signed_in, tasks[], new_look, exhausted, days_used[], goal_done}, cooldown_days, max_asks, text, text_zh}, version, source}`;`text` / `text_zh` 是中继设置时 Star 卡片显示的那句话(最多 200 个字符,否则用应用自己的话) |
+| GET | `/api/update`(`?refresh=1`) | `{current, enabled, latest, newer, url, download_url, checked_at, source (github · mirror), error}`:最新的发布,先查 GitHub 的 `releases/latest`,再查配置过的镜像索引;缓存一天 |
+| GET / PUT | `/api/sync/state` · `{enabled}` | 账号各设备之间同步的对话([every-device.md](every-device.md#the-same-conversations-everywhere)):`{enabled, available, paused, cursor, last_pull_at, last_push_at, error, relay}`;`available` 是「已登录且设置允许」,`paused` 在收到 401 后置上,直到下一次登录,`relay` 是中继自己的 `{enabled, cursor, counts{conversations, messages}, limits}`,问不到时为 `null`。`PUT {enabled: false}` 在这里关掉同步,并删掉中继为这个账号保存的内容(其他设备的开关会在它们下一次推送或拉取时跟着关,中继会以 `sync_off` 拒绝);`PUT {enabled: true}` 重新打开,并把这里的聊天推上去。中继的拒绝会以它的状态码和代码原样返回(`409 sync_off`)。从 0.1.38 起,状态里还带 `side_chats`(这台设备的*同时同步旁聊*,默认关闭:只推送和拉取主要聊天)和 `working`(`[{thread, cid, device, device_name, at}]`:此刻正在回答的其他设备,来自 hub 的 `working` 帧,每条十分钟后丢弃);`PUT {side_chats: true}` 把这台设备的旁聊发上去,并从头把账号的旁聊拉一次,`false` 则从此留在本地 |
| POST | `/api/sync/delete` | 删掉中继上每一段已同步的对话,开关保持原样;返回状态 |
| POST | `/api/sync/pull` | 现在就抓取并应用其他设备的改动:`{applied, …state}` |
+| GET | `/api/cloud` | 应用所显示的 nanoMuse Cloud 账号:`{base_url, signed_in, required, models, hint, channel, signed_in_at, has_password, account_id, member, any_model, region, is_model}`([cloud.md](cloud.md))。下面每条 `/api/cloud/*` 路由遇到中继拒绝时,都以中继的状态码、它的那句话作答,代码放在 `X-Nanomuse-Code` 头里;没有登录账号时是 401 `bad_key` |
+| GET | `/api/cloud/config` | 中继的公开数字(新账号的额度、邀请奖励、是否开放注册、要加星的仓库);旧中继或没有中继时是 `{}` |
+| POST | `/api/cloud/code` `{identifier}` | 向一个邮箱地址或手机号发送登录验证码;`{ok}` |
+| POST | `/api/cloud/verify` `{identifier, code, invite?}` | 用验证码登录(首次注册可带邀请码);回答中继的 `/v1/me`,顺带刷新提示策略 |
+| POST | `/api/cloud/login` `{identifier, password}` | 用账号密码登录;回答与 `verify` 相同 |
+| POST | `/api/cloud/password` `{password, current?}` | 设置或更改密码(已有密码时要带 `current`,刚用验证码登录的除外;空的 `password` 加 `current` 表示取消密码) |
+| GET | `/api/cloud/me` | 中继此刻看到的账号(`/v1/me`),顺带刷新提示策略 |
+| GET · DELETE | `/api/cloud/sessions` · `/api/cloud/sessions/{prefix}` | 账号在各设备上的登录 / 按列表显示的前缀撤销一个;两者都回答 `{sessions}` |
+| POST | `/api/cloud/sign-out` · `/api/cloud/sign-out-all` `{all}` | 退出这台设备 / 撤销其他所有设备的登录(`all: true` 时连这台一起,`{signed_out}` 加账号视图) |
+| GET | `/api/cloud/events?limit=` | 中继上这个账号的活动记录:`{events}` |
+| POST | `/api/cloud/models` `{on}` | 「使用 nanoMuse Cloud 模型」:关掉后,账号的模型退出双手、图片和短片的自动顺序以及提供方列表;登录保留。聊天模型正在用账号时返回 409 `chat_on_cloud` |
+| POST | `/api/cloud/use-as-model` `{model?}` | 让账号当聊天模型:不点名就用推荐模型,Cloud 的 key 来自保险库;同时把账号的模型重新打开。回答连接视图里的 `llm` |
+| POST · DELETE | `/api/cloud/contribute` `{on}` · `/api/cloud/samples` | 数据控制:「帮助改进 nanoMuse 的 AI 模型」开或关(只有开着时中继才保留聊天回合)/ 删除它保留的内容,`{deleted}` |
+| POST | `/api/cloud/delete` | 在中继上删除账号并在这里退出;回答账号视图 |
+| GET / PUT | `/api/hub` · `{enabled, remote_control, name}` | 这台设备在账号枢纽上的状态([hub.md](hub.md)):`{enabled, remote_control, state, detail, device{id, name, kind, actions}, devices[], account}` / 加入或离开、允许或拒绝远程控制、给这台设备改名(最多 60 个字符) |
+| POST | `/api/hub/join` · `/api/hub/leave` · `/api/hub/refresh` | 加入(没有登录账号时 401)/ 离开 / 再向中继要一次设备列表;各自回答枢纽视图 |
+| DELETE | `/api/hub/devices/{device_id}` | 忘掉账号下的一台设备;枢纽没连上时 502 |
+| POST | `/api/hub/ask` `{device, text?}` | 对着某台设备(按 id 或名字;没有就新建)的侧聊,带 `text` 时作为其中第一条消息,在那台设备上运行:`{thread, event}`;没有这台设备时 404 |
+| GET | `/api/bridge/tools` | 给代理运行的命令用,不是给应用用:`nanomuse-device list` 打印的设备工具,`{ok, tools[{name, description}]}`。不走应用令牌;shell 工具签发的调用令牌放在 `X-Nanomuse-Bridge` 头里(否则 401),与 `POST /api/bridge/{kind}` 相同([local-runtime.md](local-runtime.md)) |
| GET | `/api/browser/{thread}/frames/{id}.jpg` | 一帧浏览器画面(JPEG,当前会话期间留在内存里) |
| POST | `/api/browser/{thread}/control` `{action: click(x,y as 0–1 fractions) · type(text) · key(key) · scroll(dy) · navigate(url) · look · handed_back}` | 你来开智能体的浏览器;`handed_back` 是 Android 应用在你关掉它的接管面板时发的(取一帧新画面,告知智能体);浏览器没打开时返回 `409`(先打开一个 URL) |
-| GET | `/api/push` | `{available, public_key, subscriptions, devices[]}`——用来订阅的 VAPID 公钥 |
+| GET | `/api/push` | `{available, public_key, subscriptions, devices[]}`:用来订阅的 VAPID 公钥 |
| POST | `/api/push/subscribe` `{subscription}`、`/api/push/unsubscribe` `{endpoint}` | 注册 / 删掉这台设备的 `PushSubscription` |
| POST | `/api/push/test` | 给每台已订阅的设备发一条测试通知 |
| WS | `/ws` | 实时事件;第一帧是 `{"kind": "auth", "token": …}` |
### WebSocket {#websocket}
-客户端的第一帧是 `{"kind": "auth", "token": "…"}`(十秒之内,否则 socket 以 4401 关闭——令牌错了也一样)。然后服务器发 `{"kind": "hello", "state": …}`(和 `/api/state` 同样的内容)。接下来:
+客户端的第一帧是 `{"kind": "auth", "token": "…"}`(十秒之内,否则 socket 以 4401 关闭,令牌错了也一样)。然后服务器发 `{"kind": "hello", "state": …}`(和 `/api/state` 同样的内容)。接下来:
| 服务器 → 客户端 | 含义 |
|---|---|
-| `event` | 一条新的时间线事件(`user`、`assistant`、`tool`、`approval`、`question`、`artifact`、`browser`、`notice`)。`approval` 带 `summary`、`purpose`(你当初要的是什么)、`target`、`grant_key`、`grant_options`、`risk`、`warnings`、`args`。`browser` 卡片带 `url`、`title`、`action`、`frame`(最新一张图的 id)、`frames`、`status`(`live` / `done`)、`by_user`、`backend`(`playwright` 或 `device`)以及这帧画面以 CSS 像素计的 `width` / `height`。后台轮次里产生的事件带 `source: "background"` 和 `about`(这一轮的标签)。结束一轮的那条 `assistant` 气泡带 `final: true`(发出时就设上,或者气泡已经在屏幕上时以一条 `update` 补上);在它之前一步一步的叙述不带——把后台结果映射成通知的客户端应该以这个标志为准 |
+| `event` | 一条新的时间线事件(`user`、`assistant`、`tool`、`approval`、`question`、`artifact`、`browser`、`notice`)。`approval` 带 `summary`、`purpose`(你当初要的是什么)、`target`、`grant_key`、`grant_options`、`risk`、`warnings`、`args`。`browser` 卡片带 `url`、`title`、`action`、`frame`(最新一张图的 id)、`frames`、`status`(`live` / `done`)、`by_user`、`backend`(`playwright` 或 `device`)以及这帧画面以 CSS 像素计的 `width` / `height`。后台轮次里产生的事件带 `source: "background"` 和 `about`(这一轮的标签)。结束一轮的那条 `assistant` 气泡带 `final: true`(发出时就设上,或者气泡已经在屏幕上时以一条 `update` 补上);在它之前一步一步的叙述不带;把后台结果映射成通知的客户端应该以这个标志为准 |
| `update` | 已有事件的字段变了(一个工具跑完了、一张审批有了结果、一张浏览器卡片有了新的一帧) |
-| `event_removed` | `{thread, id}`——一条事件离开了时间线:在账号的另一台设备上删掉了一条消息(对话同步) |
-| `working` | `{thread, cid, device, device_name, working, at}`——账号的另一台设备在一段同步的对话里开始(`true`)或结束(`false`)了一轮;为 `true` 期间应用在最后一条消息下面显示「{device} 正在处理…」,回复到了或者十分钟后撤掉。`hello` 的状态在 `working` 下列出正在进行的那些 |
+| `event_removed` | `{thread, id}`;一条事件离开了时间线:在账号的另一台设备上删掉了一条消息(对话同步) |
+| `working` | `{thread, cid, device, device_name, working, at}`:账号的另一台设备在一段同步的对话里开始(`true`)或结束(`false`)了一轮;为 `true` 期间应用在最后一条消息下面显示「{device} 正在处理…」,回复到了或者十分钟后撤掉。`hello` 的状态在 `working` 下列出正在进行的那些 |
| `stream_start` / `delta` / `stream_end` | 正在生成的助手回复;流出来的内容最后发现不是回复时(提示词模式的工具调用、安静的后台轮次),`stream_end` 带 `discard: true` |
| `status` | idle / working / waiting,附一行简短说明 |
| `thread`、`thread_cleared`、`thread_deleted` | 会话列表的变化 |
-| `firstrun` | `{firstrun}`——第一次对话往前走了一步(「开始」、模型的 `nanomuse-naming` 代码块、选了名字、撤掉卡片):和 `GET /api/firstrun` 同样的视图,不带 `intro`;聊天据此重画起名卡片。`hello` 的状态在 `firstrun` 下带着它 |
+| `firstrun` | `{firstrun}`;第一次对话往前走了一步(「开始」、模型的 `nanomuse-naming` 代码块、选了名字、撤掉卡片):和 `GET /api/firstrun` 同样的视图,不带 `intro`;聊天据此重画起名卡片。`hello` 的状态在 `firstrun` 下带着它 |
| `goals`、`memory`、`ideas`、`feed_posts`、`profile`、`settings`、`connections`、`skills`、`approvals_reset` | 给各标签页的刷新提示 |
| `phone` | 一台手机连上或离开了:`/api/phone` 的视图 |
| `schedule` | `next_wake_at` 变了(设了或触发了一条提醒、某个目标的节奏变了、免打扰时段结束了):`{next_wake_at}`,没有到期事项时为 `null`。Android 运行时据此重设它的闹钟 |
| `device_ack`、`device_request` | 发给连着的手机:对它的通告的回应,以及要它的屏幕或要它做一个动作的请求([gui.md](gui.md#the-device-protocol)) |
| `error`、`pong` | 对客户端消息的答复 |
-客户端 → 服务器:`{"kind": "send", "thread": "main", "text": "…", "language": "en"}`(`language` 可选,含义同 `POST /api/threads/{id}/send`)、`{"kind": "approval", "id": "…", "approved": true, "scope": "once"}`、`{"kind": "ping"}`。允许智能体操作自己的手机还会发一次 `{"kind": "device", …}`,并对每个请求回一条 `{"kind": "device_result", …}`。
+客户端 → 服务器:`{"kind": "send", "thread": "main", "text": "…", "language": "en", "via": ""}`(`language` 和 `via` 可选,含义同 `POST /api/threads/{id}/send`;被拒绝的 `via: "cloud"` 以 `{"kind": "error", "error": "…", "code": "signed_out"}` 返回)、`{"kind": "approval", "id": "…", "approved": true, "scope": "once"}`、`{"kind": "ping"}`。允许智能体操作自己的手机还会发一次 `{"kind": "device", …}`,并对每个请求回一条 `{"kind": "device_result", …}`。
时间线事件按会话持久化在 `/threads/.json`,所以历史在重启后还在。
diff --git a/docs/zh/avatar.md b/docs/zh/avatar.md
index 1bcdc009..2afd6aba 100644
--- a/docs/zh/avatar.md
+++ b/docs/zh/avatar.md
@@ -44,3 +44,21 @@
有 nanoMuse Cloud 账号时,这张脸跟着你到每一台设备([docs/cloud.md](cloud.md)):静态图上传到中继
一次,其他设备从 `avatar/sync-/` 穿上它,描述和风格写在它们各自的 `face.json` 里;如果账号上
的图片正是某台设备已经在穿的,那台设备保留自己画的那张脸,连短视频一起。
+
+## 你自己的视频
+
+一段你自己的短视频可以顶替那几段短视频。手机上,在资料页形象上的那支笔下面选(「你自己的视频…」),
+或者在形象工作室的「你自己的视频」一节里选;桌面版在形象工作室或资料抽屉里选。只要形象在动的地方,
+这段视频就静音循环播放:待命状态用它,其他状态也用它,因为只有这一段。需要静态图的地方(视频第一帧
+出现之前垫在下面的那张、分享卡片、磁贴)仍用工作室画的那套静态图;没有工作室形象时,视频的第一帧就是
+静态图。改动之前先预览:视频在形象自己的圆框里播放,下面是「使用」和「取消」;「默认形象」移除这段
+视频,工作室的脸或小龙重新出现。
+
+限制:MP4 或 MOV,H.264 视频(iOS 上也接受系统能解码的 HEVC),最长 15 秒、最大 30 MB。更长、更大
+或别的格式会被拒绝,并用一句话说明限制;不裁剪、不转码,声音永远不播。
+
+视频留在选择它的那台设备上。它不上传到 nanoMuse Cloud,也不同步到你的其他设备,这一点和工作室形象的
+静态图不同;每台设备各选各的。Android 上它在 `minis-global/nanomuse/avatar/own-clip.mp4`,第一帧
+在旁边的 `own-clip.png`,和当前登录账号的其他文件归在一起;iOS 上在应用的
+`Application Support/nanomuse/avatar/own-clip.mp4`;桌面版在 `$DSH_HOME/nanomuse/avatar/own-clip.mp4`。
+换一张工作室的脸或者退回内置形象,它都还在。
diff --git a/docs/zh/brand.md b/docs/zh/brand.md
index c2903320..fca622ea 100644
--- a/docs/zh/brand.md
+++ b/docs/zh/brand.md
@@ -41,22 +41,27 @@ OpenManus 和 Manus 押韵,却是另一个手势。标志刻意做得抽象:
| 令牌 | 值 | 对比度 | 用途 |
|---|---|---|---|
-| `primary` | `#015CFB` | 白底上 5.35 | 链接、主按钮(白字)、当前标签、发送按钮、状态行 |
-| `primary.container` | `#E5F0FF` | — | 用户气泡、选中的行、标签 |
+| `primary` | `#015CFB` | 白底上 5.35 | 链接、主按钮(白字)、状态行 |
+| `primary.container` | `#E5F0FF` | — | 选中的行、标签 |
+| `ink` | `#000000` | 白底上 21 | 你的消息胶囊(白字)、发送按钮和「回到最新」圆盘(白箭头)、当前标签的圆盘 |
+| `pill` | `#F2F2F7` | | 智能体的消息胶囊 |
| `primary.tint` | `#EEF5FF` | — | 轻微高亮的表面 |
-| `bg.grouped` | `#F2F2F7` | — | 页面背景(沿用 OpenMinis,没有改) |
+| `bg` | `#FFFFFF` | | 聊天、胶囊栏、菜单和抽屉 |
+| `bg.grouped` | `#F2F2F7` | — | 设置页面(沿用 OpenMinis,没有改) |
| `surface` | `#FFFFFF` | — | 卡片、输入框 |
### 深色主题 {#dark-theme}
| 令牌 | 值 | 对比度 | 用途 |
|---|---|---|---|
-| `primary` | `#58A6FF` | `#000` 上 8.3,`#1C1C1E` 上 6.7 | 链接、按钮(黑字)、当前标签、状态行 |
-| `primary.container` | `#1A2B4A` | — | 用户气泡 |
+| `primary` | `#58A6FF` | `#000` 上 8.3,`#1C1C1E` 上 6.7 | 链接、按钮(黑字)、状态行 |
+| `primary.container` | `#1A2B4A` | — | 选中的行、标签 |
+| `ink` | `#FFFFFF` | `#000` 上 21 | 你的消息胶囊(黑字)、发送按钮和「回到最新」圆盘(黑箭头)、当前标签的圆盘 |
+| `pill` | `#1C1C1E` | | 智能体的消息胶囊 |
| `bg` / `surface` / `surface.2` | `#000000` / `#1C1C1E` / `#2C2C2E` | — | 沿用 OpenMinis,没有改 |
规则:强调色只有一个色相。OpenMinis 用 iOS 的蓝、橙、绿着色的代码块、思考文字和链接,一律改成
-`primary` 加中性色。小熊猫身上的铁锈橙留在形象里;它不是界面颜色。
+`primary` 加中性色。对话本身是单色的:你的胶囊、发送按钮和当前标签都是底色上的墨色,蓝色只留给链接和状态行(`ChatColors.kt`,由 `scripts/rebrand.py` 写入)。小龙自己的颜色留在形象里;它们不是界面颜色。
## Android 映射(S1 已完成) {#android-mapping-done-in-s1}
diff --git a/docs/zh/browser.md b/docs/zh/browser.md
index 255b69e8..860aaa6f 100644
--- a/docs/zh/browser.md
+++ b/docs/zh/browser.md
@@ -1,5 +1,10 @@
# 浏览器
+> **部分是 Python 线路的设计记录。** 下文的 Playwright 后端、动作、带登录态的 `fetch` 和测试都是
+> Python 运行时的,在电脑上仍然成立。手机那一半(`device` 后端、虚拟显示、「手机浏览器是怎么工作的」
+> 下面的协议)属于 Python 线路的 Android 应用;在当前的 Android 应用里,浏览器是 OpenMinis 的
+> `browser_use` 工具,需要你的页面通过它的 `hand_over` 动作交到你手上([android.md](android.md#what-the-phone-does))。
+
智能体有一个浏览器工具,和两个可以跑它的浏览器:服务器所在电脑上经 Playwright 驱动的 Chromium,以及 nanoMuse 手机 App 里手机自己的 WebView。两边的工具是同一个(同样的动作、页面上同样编号的元素、聊天里同样的画面、同样的接管方式),所以针对一边写的技能在另一边也能用,一项任务从书桌挪到手机上,一行都不用改。
浏览器在整个顺序里的位置:智能体先通过服务的 API、MCP 服务器或命令行工具去够它;然后是**带登录态的 `fetch`**(带着浏览器 cookie 的一次请求,不渲染页面);再然后才是浏览器页面本身;最后才是手机上某个 App 的屏幕([gui.md](gui.md))。浏览器是这条阶梯的第二级和第三级。
diff --git a/docs/zh/channels.md b/docs/zh/channels.md
index 6de6b8f9..cf616a14 100644
--- a/docs/zh/channels.md
+++ b/docs/zh/channels.md
@@ -36,7 +36,7 @@ Docker 镜像带全了这些。没装 SDK 时,应用会显示要安装的那
### 飞书 / Lark {#feishu-lark}
-最快的办法:在应用里按「扫码创建机器人」(或运行 `nanomuse channels login feishu`,国际版加 `--lark`),用飞书扫码,确认,完成;App ID 和 Secret 已经保存,这个入口随即打开。这是飞书自己为个人智能体提供的扫码建应用流程;它创建出来的应用已经带上机器人能力和长连接事件。
+简便的办法:在应用里按「扫码创建机器人」(或运行 `nanomuse channels login feishu`,国际版加 `--lark`),用飞书扫码,确认,完成;App ID 和 Secret 已经保存,这个入口随即打开。这是飞书自己为个人智能体提供的扫码建应用流程;它创建出来的应用已经带上机器人能力和长连接事件。
手动的话,到 (Lark:):
diff --git a/docs/zh/cli.md b/docs/zh/cli.md
index a3c412e3..5d765d1d 100644
--- a/docs/zh/cli.md
+++ b/docs/zh/cli.md
@@ -26,6 +26,8 @@ nanomuse chat [--resume] [--show-thinking] [--auto]
| `/tainted` | 这段对话有没有读过私密数据 |
| `/permissions` | 你授予的长期权限(键和有效期) |
| `/revoke ` | 收回一项,例如 `/revoke shell:git` |
+| `/forget-approvals` | 清掉所有已授予的权限 |
+| `/exit` | 退出(`/quit`、`/q` 也可以) |
`--resume` 接着最近一次会话继续(`/sessions/`)。
diff --git a/docs/zh/cloud.md b/docs/zh/cloud.md
index c253dee7..a30574bf 100644
--- a/docs/zh/cloud.md
+++ b/docs/zh/cloud.md
@@ -1,48 +1,48 @@
# nanoMuse Cloud
-「现在就开始」这条路:用手机号(中国大陆;验证码走短信)或邮箱注册,拿一份免费额度,不用自己的 API key 就能用 nanoMuse。用自己的 key 的方式和以前完全一样——这只是多了一个服务商,不是替代。
+「现在就开始」这条路:用手机号(中国大陆;验证码走短信)或邮箱注册,拿一份免费额度,不用自己的 API key 就能用 nanoMuse。用自己的 key 的方式和以前完全一样:这只是多了一个服务商,不是替代。
## 在应用里 {#in-the-app}
-0.1.20 起,账号是每个 App 的起点:第一屏要你填手机号或邮箱,发一个六位验证码——或者,设过密码的话,直接输密码——然后问由哪个模型来回答:账号自带的(Cloud),还是你自己的 key。短信验证码只发中国大陆号码(号码认证服务不往别处发);香港、台湾或海外号码会立刻被告知(`phone_region`),改用邮箱登录。退出登录会回到那一屏(自己部署的:在运行时上设 `[cloud] required = false` 或 `NANOMUSE_CLOUD_REQUIRED=0`)。那一屏是门,不是墙:点「改用自己的 API key」就能越过它进入设置里的自备 key 路径;有了自己的 key,App 不登录也能用(聊天、动手、图片、短片),登录入口留在「连接」里(手机上是「设置 → nanoMuse Cloud」),用于 Cloud 模型、同步和你的设备。输完验证码后,App 有了:
+0.1.20 起,账号是每个 App 的起点:第一屏要你填手机号或邮箱,发一个六位验证码(或者,设过密码的话,直接输密码),然后问由哪个模型来回答:账号自带的(Cloud),还是你自己的 key。短信验证码只发中国大陆号码(号码认证服务不往别处发);香港、台湾或海外号码会立刻被告知(`phone_region`),改用邮箱登录。退出登录会回到那一屏(自己部署的:在运行时上设 `[cloud] required = false` 或 `NANOMUSE_CLOUD_REQUIRED=0`)。那一屏是门,不是墙:点「改用自己的 API key」就能越过它进入设置里的自备 key 路径;有了自己的 key,App 不登录也能用(聊天、动手、图片、短片),登录入口留在「连接」里(手机上是「设置 → nanoMuse Cloud」),用于 Cloud 模型、同步和你的设备。输完验证码后,App 有了:
- *设置 → 服务商* 下一个叫 **nanoMuse Cloud** 的服务商,一个普通的 OpenAI 兼容服务商,它的 key 就是中继发的 token;
- *设置 → 模型*(网页控制台:「连接」页)的四行在你没选过时落在账号的模型上:推荐的聊天模型、推荐的手的模型(读截图、操作手机或电脑的那个;中继菜单上每个模型都写明它是给这两者中的哪一个用的)、图像模型和视频模型。你没设过的一行按一个顺序走:对话模型所在的服务商是你自己的 key、而且能做这件事时用它,否则登录时用 nanoMuse Cloud,否则用你第一把能做这件事的 key;那一行的「自动」写着它现在给的是什么,以后再加一把 key 会先问「用它来做什么」,然后才会动任何一行([own-key.md](own-key.md#which-model-does-what))。
-*设置 → nanoMuse Cloud*(「账号」页)显示谁登录了(一个打码的提示,从不显示号码)、从什么时候起;设置、修改或移除**密码**(八个字符以上;中继上用 scrypt;连续输错会锁一段时间,用验证码重新登录一次就解开);今天花了多少额度,以 ¥ 和 $ 计,配一个仪表;**按类型的用量**——聊天、图片、视频——今天和累计,以及**按模型**的用量;**登录记录**——每一台持有 key 的设备、它怎么登录的(验证码还是密码)、最后一次使用的时间——每条都可以吊销;账号自己的**历史**(登录、改密码、被拒绝;从不包含消息内容);还有几条退路:在这台设备「退出登录」、「所有设备退出登录」、「删除账号」。额度属于那个地址:再次登录,不管在这台手机还是另一台,拿到的是同一个账号的新 key,不会多给一份额度。
+*设置 → nanoMuse Cloud*(「账号」页)显示谁登录了(一个打码的提示,从不显示号码)、从什么时候起;设置、修改或移除**密码**(八个字符以上;中继上用 scrypt;连续输错会锁一段时间,用验证码重新登录一次就解开);今天花了多少额度,以 ¥ 和 $ 计,配一个仪表;**按类型的用量**(聊天、图片、视频)今天和累计,以及**按模型**的用量;**登录记录**:每一台持有 key 的设备、它怎么登录的(验证码还是密码)、最后一次使用的时间,每条都可以吊销;账号自己的**历史**(登录、改密码、被拒绝;从不包含消息内容);还有几条退路:在这台设备「退出登录」、「所有设备退出登录」、「删除账号」。额度属于那个地址:再次登录,不管在这台手机还是另一台,拿到的是同一个账号的新 key,不会多给一份额度。
-**退出登录在手机上留下什么**(0.1.40,契约 C12)。退出登录——在这台设备、在所有设备,或者为了换一个服务器——只问一个问题:「把这个账号的聊天留在这台设备上」,默认关。关着,账号的聊天、记忆、动态、目标、例程和形象会从手机上移除(同步开着的话,中继那里还留着聊天,下次登录能拿回来);开着,它们被收起来,等账号再次登录时回来。改用另一个账号登录也要经过同样的退出流程。「删除账号」在中继删掉账号,也删掉手机上属于它的一切,不再多问;之后用同一个地址登录是一个全新的空账号。中继拒绝的 key——`401 bad_key`:另一台设备上点了「所有设备退出登录」、中继重置、中继的 bug——是一次手机上没人能回答的退出,所以手机把账号的数据像「保留」那样收起来,移除 key,登录页写着「这台手机上的登录已结束——重新登录即可继续;在那之前,你的聊天会留在这台设备上」;下次以同一个账号登录就恢复,换一个账号则什么也看不到。只有 `401 account_deleted`——0.1.40 起中继对账号已删除的 key 的回答——才让手机把数据删掉,因为已经没有可以回去的地方了。每一样状态存在哪里、每个事件发生时它怎么样,是 [sync.md](sync.md) 里的那张表。协议上唯一的新增是那个错误码;旧版中继两种情况都答 `bad_key`,手机就保留数据。
+**退出登录在手机上留下什么**(0.1.40,契约 C12)。退出登录(在这台设备、在所有设备,或者为了换一个服务器)只问一个问题:「把这个账号的聊天留在这台设备上」,默认关。关着,账号的聊天、记忆、动态、目标、例程和形象会从手机上移除(同步开着的话,中继那里还留着聊天,下次登录能拿回来);开着,它们被收起来,等账号再次登录时回来。改用另一个账号登录也要经过同样的退出流程。「删除账号」在中继删掉账号,也删掉手机上属于它的一切,不再多问;之后用同一个地址登录是一个全新的空账号。中继拒绝的 key(`401 bad_key`:另一台设备上点了「所有设备退出登录」、中继重置、中继的 bug)是一次手机上没人能回答的退出,所以手机把账号的数据像「保留」那样收起来,移除 key,登录页写着「这台手机上的登录已结束。重新登录即可继续;在那之前,你的聊天会留在这台设备上」;下次以同一个账号登录就恢复,换一个账号则什么也看不到。只有 `401 account_deleted`(0.1.40 起中继对账号已删除的 key 的回答)才让手机把数据删掉,因为已经没有可以回去的地方了。每一样状态存在哪里、每个事件发生时它怎么样,是 [sync.md](sync.md) 里的那张表。协议上唯一的新增是那个错误码;旧版中继两种情况都答 `bad_key`,手机就保留数据。
-在它旁边再登录第二个服务商——你自己的百炼 key、DeepSeek、一台本地服务器——一如既往可行;中继的模型可以和你的放在同一个模型组里混用。
+在它旁边再登录第二个服务商(你自己的百炼 key、DeepSeek、一台本地服务器)一如既往可行;中继的模型可以和你的放在同一个模型组里混用。
## 中继保存什么 {#what-the-relay-keeps}
中继就是 [`cloud/`](../../cloud/README.md) 里的代码。它存:
-- 邮箱地址的加盐哈希(HMAC-SHA256)(0.1.18–0.1.21 的手机号账号继续可用)、一个打码的提示如 `so***@example.com`,以及地址本身的加密副本(AES-GCM,密钥由中继的 secret 派生),让运营者能在管理页上看到账号属于谁——单看数据库文件什么也看不出来;
+- 邮箱地址或中国大陆手机号的加盐哈希(HMAC-SHA256)、一个打码的提示如 `so***@example.com`,以及地址本身的加密副本(AES-GCM,密钥由中继的 secret 派生),让运营者能在管理页上看到账号属于谁(单看数据库文件什么也看不出来);
- 每个已发 key 的哈希,连同你登录时的设备名、方式(验证码还是密码)和最后使用时间;吊销的 key 保留它那一行,登录记录列表才能说明这一点;
-- 密码,如果你设了,以 scrypt 哈希保存——从不保存密码本身;
+- 密码,如果你设了,以 scrypt 哈希保存,从不保存密码本身;
- 每次请求:类型(聊天、图片、视频)、模型、token 数和扣费金额;
-- 一条账号事件时间线——登录、登录失败、设置或修改密码、退出登录、因预算被拒、上游错误——细节是设备名、模型或错误码,从不是消息内容;
-- 每个视频任务的 id,这样只有发起它的账号能轮询它;
-- 智能体的名字和形象(`/v1/me/profile`):它用的是哪一张——小龙、某个颜色底上的 emoji,或者形象工作室里画的一张,连同那张形象的五张小 WebP 图——好让账号的每台设备显示同一个。0.1.34 起,同一份 profile 还列出账号的**连接器**:哪台设备连接了哪个服务(一个标签、登录方式——OAuth、key 或开放——以及时间),这样另一台设备能说「已在你的 Mac 上连接」。凭据本身留在持有它的设备上;中继拒绝任何带着像凭据一样命名的字段的项。从不存 key 或设置。
-- 中继 0.19 起,**同步的对话**(「在我的设备之间同步对话」,默认开):账号聊天的文本——每个对话的标题、类型和由哪台设备发起,每条消息的角色、文本、时间和设备,以及附件的名字和大小,从不存文件本身——好让每台设备显示同样的对话。关掉开关会删掉全部;删除账号也一样。下面的[对话同步](#conversation-sync)一节有它的形状。
+- 一条账号事件时间线(登录、登录失败、设置或修改密码、退出登录、因预算被拒、上游错误),细节是设备名、模型或错误码,从不是消息内容;
+- 每个视频任务的 id,连同它的价格和服务商的最终答复,这样只有发起它的账号能轮询它,短片也只收一次费(这些行三天后删除);
+- 智能体的名字和形象(`/v1/me/profile`):它用的是哪一张(小龙、某个颜色底上的 emoji,或者形象工作室里画的一张,连同那张形象的五张小 WebP 图),好让账号的每台设备显示同一个。0.1.34 起,同一份 profile 还列出账号的**连接器**:哪台设备连接了哪个服务(一个标签;登录方式:OAuth、key 或开放;以及时间),这样另一台设备能说「已在你的 Mac 上连接」。凭据本身留在持有它的设备上;中继拒绝任何带着像凭据一样命名的字段的项。从不存 key 或设置。
+- 中继 0.19 起,**同步的对话**(「在我的设备之间同步对话」,默认开):账号聊天的文本(每个对话的标题、类型和由哪台设备发起,每条消息的角色、文本、时间和设备,以及附件的名字和大小,从不存文件本身),好让每台设备显示同样的对话。关掉开关会删掉全部;删除账号也一样。下面的[对话同步](#conversation-sync)一节有它的形状。
-图片和工具结果从不保存,消息内容只按下面「数据控制」和「对话同步」两节所说的方式保存——两个开关都关时,什么都不存:请求转发给上游模型(阿里云百炼),回复流式传回。每个响应都带一个 `X-Nanomuse-Request` id,这样一份问题报告可以对上账本里的一行,而不用记录任何内容。删除账号(带账号 key 的 `POST /v1/auth/delete`)会移除全部——账号、key、设备、profile、账本、事件、保留的对话、视频任务、同步的对话和游标、hub 连接,以及实时的「正在处理」提示;中继的测试会在之后检查每一张表里是否还有这个账号的痕迹,并确认同一个地址再注册是一个全新的空账号。见 [privacy.md](privacy.md)。
+图片和工具结果从不保存,消息内容只按下面「数据控制」和「对话同步」两节所说的方式保存;两个开关都关时,什么都不存:请求转发给上游模型(阿里云百炼),回复流式传回。每个响应都带一个 `X-Nanomuse-Request` id,这样一份问题报告可以对上账本里的一行,而不用记录任何内容。删除账号(带账号 key 的 `POST /v1/auth/delete`)会移除全部:账号、key、设备、profile、账本、事件、保留的对话、视频任务、同步的对话和游标、hub 连接,以及实时的「正在处理」提示;中继的测试会在之后检查每一张表里是否还有这个账号的痕迹,并确认同一个地址再注册是一个全新的空账号。见 [privacy.md](privacy.md)。
## 数据控制 {#data-controls}
-*设置 → 数据控制 → 帮助改进 nanoMuse 的 AI 模型* 是一个属于每个人自己的开关,在手机、网页版、桌面版和控制台上是同一个。关着,中继转发聊天请求,什么都不留。开着,每一轮会作为训练视图保留:你写的、模型答的、它选择的工具调用,连同模型、token 数,以及请求头里 App 的平台和语言,只和账号 id 关联——从不保留系统提示词(你的记忆、SOUL 和指令),从不保留工具返回的内容(你的文件、你的屏幕、别的 App 显示的东西),从不保留图片、短视频或语音(原位置留一个标记)。目的是给社区自己的开放模型准备一份训练集。页面显示保留了多少轮;随时可以关掉开关(之后不再保留),点一下就能删掉已保留的内容;删除账号也会一并删掉。开关开或关都不会换来任何额度。在上游的公共中继(`cloud.nanomuse.cn`)上,这个开关**对中继 0.9 之后创建的账号默认开启,直到这个人把它关掉**——隐私政策这么写,页面在开关旁边也这么写,之前的账号保持它们当初的选择;自己部署的中继用 `IMPROVE_DEFAULT` 定自己的默认值。运营者在管理页的「数据控制」面板看到保留的轮次(多少账号开着、按天、按模型和按 App 的轮次数、最新的轮次、每个账号保留的完整对话),并以 JSON lines 导出,不带账号 id 或地址(`GET /v1/admin/samples/export`,`?account_id=` 导出某一个账号的)。中继收不到位置——各个 App 从不发送——但从中继 0.10 起,它随每次登录、请求、事件和设备记录网络地址和客户端软件(`User-Agent`:Android App 及其版本、Windows / macOS / Linux 上的运行时、某个浏览器),并保留账号的第一个和最后一个地址;管理页按账号、按地址显示它们(`GET /v1/admin/address?ip=`),随账号一起删除。从中继 0.11 起,管理页还会说一个地址在哪里——国家、省、市——查的是中继自己磁盘上一份 ip2region 数据库的离线副本;不问任何第三方,也不多存任何东西(地点在页面绘制时才计算)。
+*设置 → 数据控制 → 帮助改进 nanoMuse 的 AI 模型* 是一个属于每个人自己的开关,在手机、网页版、桌面版和控制台上是同一个。关着,中继转发聊天请求,什么都不留。开着,每一轮会作为训练视图保留:你写的、模型答的、它选择的工具调用,连同模型、token 数,以及请求头里 App 的平台和语言,只和账号 id 关联,从不保留系统提示词(你的记忆、SOUL 和指令),从不保留工具返回的内容(你的文件、你的屏幕、别的 App 显示的东西),从不保留图片、短视频或语音(原位置留一个标记)。目的是给社区自己的开放模型准备一份训练集。页面显示保留了多少轮;随时可以关掉开关(之后不再保留),点一下就能删掉已保留的内容;删除账号也会一并删掉。开关开或关都不会换来任何额度。在上游的公共中继(`cloud.nanomuse.cn`)上,这个开关**对中继 0.9 之后创建的账号默认开启,直到这个人把它关掉**:隐私政策这么写,页面在开关旁边也这么写,之前的账号保持它们当初的选择;自己部署的中继用 `IMPROVE_DEFAULT` 定自己的默认值。运营者在管理页的「数据控制」面板看到保留的轮次(多少账号开着、按天、按模型和按 App 的轮次数、最新的轮次、每个账号保留的完整对话),并以 JSON lines 导出,不带账号 id 或地址(`GET /v1/admin/samples/export`,`?account_id=` 导出某一个账号的)。中继收不到位置(各个 App 从不发送),但从中继 0.10 起,它随每次登录、请求、事件和设备记录网络地址和客户端软件(`User-Agent`:Android App 及其版本、Windows / macOS / Linux 上的运行时、某个浏览器),并保留账号的第一个和最后一个地址;管理页按账号、按地址显示它们(`GET /v1/admin/address?ip=`),随账号一起删除。从中继 0.11 起,管理页还会说一个地址在哪里(国家、省、市),查的是中继自己磁盘上一份 ip2region 数据库的离线副本;不问任何第三方,也不多存任何东西(地点在页面绘制时才计算)。
## 对话同步 {#conversation-sync}
-*设置 → 数据控制 → 在我的设备之间同步对话*(0.1.36,中继 0.19)是那一页上的另一个开关,登录账号**默认开启**。开着时,每台设备把自己各轮的文本推到中继,并拉取别的设备推上来的,于是手机、电脑和网页版显示同样的聊天:每个对话的标题、谁发起的,每条消息的角色、文本、时间和设备。文件和图片不上传——同步的消息只带附件的名字和大小,文件留在生成它们的设备上。在一台设备上删除一个聊天,所有设备上都删除;重命名也一样。发给另一台设备的聊天,或替某台设备跑的聊天(「来自 Pixel 8」),完全不同步。
+*设置 → 数据控制 → 在我的设备之间同步对话*(0.1.36,中继 0.19)是那一页上的另一个开关,登录账号**默认开启**。开着时,每台设备把自己各轮的文本推到中继,并拉取别的设备推上来的,于是手机、电脑和网页版显示同样的聊天:每个对话的标题、谁发起的,每条消息的角色、文本、时间和设备。文件和图片不上传;同步的消息只带附件的名字和大小,文件留在生成它们的设备上。在一台设备上删除一个聊天,所有设备上都删除;重命名也一样。发给另一台设备的聊天,或替某台设备跑的聊天(「来自 Pixel 8」),完全不同步。
-**一条线**(0.1.37)。一个账号只有一个主要聊天,每台设备的主要聊天*就是*它:第一台推送的设备定下它的 id,其他设备沿用(`main_exists` → `cid_main`,登录时先拉取),每台设备的主要聊天显示所有设备上说过的话的并集,按时间排序(时间相同时本地消息在前;消息以 `mid` 识别,所以不会重复显示,设备自己的消息传回来也会被忽略)。在另一台设备上写的消息是一个只读气泡,下面标着「来自 Pixel 8」,模型会把它和对话的其余部分一起读。从中继拉下来的旁聊立刻就是这台设备上的一个聊天,带着它的标题和时间,并在同一个对话 id 下继续。这个人的消息一发出就上传——其他设备实时看到,在回复之前——助手的最终文本则在这一轮结束时上传;登录或打开开关时,设备会把自己全部符合条件的历史发上去,从最旧的开始,每次请求 200 条。muse 的名字是账号 profile 的一部分:在任何设备上改名,包括第一个对话里的起名,下次拉取时就会传到其他设备。
+**一条线**(0.1.37)。一个账号只有一个主要聊天,每台设备的主要聊天*就是*它:第一台推送的设备定下它的 id,其他设备沿用(`main_exists` → `cid_main`,登录时先拉取),每台设备的主要聊天显示所有设备上说过的话的并集,按时间排序(时间相同时本地消息在前;消息以 `mid` 识别,所以不会重复显示,设备自己的消息传回来也会被忽略)。在另一台设备上写的消息是一个只读气泡,下面标着「来自 Pixel 8」,模型会把它和对话的其余部分一起读。从中继拉下来的旁聊立刻就是这台设备上的一个聊天,带着它的标题和时间,并在同一个对话 id 下继续。这个人的消息一发出就上传(其他设备实时看到,在回复之前),助手的最终文本则在这一轮结束时上传;登录或打开开关时,设备会把自己全部符合条件的历史发上去,从最旧的开始,每次请求 200 条。muse 的名字是账号 profile 的一部分:在任何设备上改名,包括第一个对话里的起名,下次拉取时就会传到其他设备。
-**主要聊天优先**(0.1.38,中继 0.20)。默认只有主要聊天在设备之间走:旁聊留在创建它的设备上,设备拉取时带 `scope=main`,别的设备的旁聊永远不会到来。「同时同步旁聊」(「数据控制」下的第二个开关,默认关,**按设备**——中继没有账号级的设置)把打开它的那台设备反过来:它的旁聊上传,其他设备的旁聊下载,切换后的第一次拉取从零重新开始。全新登录的第一次拉取请求的是**尾部**——最新的 300 条消息及其所属对话——这样很长的历史能立刻打开,不用从头翻页;跳过的更早消息留在中继上,不再拉取。一台设备正在回答时,其他设备在最后一条消息下面显示「kwai 正在处理…」:这是一条 `working` 提示,经过中继的内存和 hub,从不保存,设备一直不说完成的话,十分钟后就消失。
+**主要聊天优先**(0.1.38,中继 0.20)。默认只有主要聊天在设备之间走:旁聊留在创建它的设备上,设备拉取时带 `scope=main`,别的设备的旁聊永远不会到来。「同时同步旁聊」(「数据控制」下的第二个开关,默认关,**按设备**;中继没有账号级的设置)把打开它的那台设备反过来:它的旁聊上传,其他设备的旁聊下载,切换后的第一次拉取从零重新开始。全新登录的第一次拉取请求的是**尾部**(最新的 300 条消息及其所属对话),这样很长的历史能立刻打开,不用从头翻页;跳过的更早消息留在中继上,不再拉取。一台设备正在回答时,其他设备在最后一条消息下面显示「kwai 正在处理…」:这是一条 `working` 提示,经过中继的内存和 hub,从不保存,设备一直不说完成的话,十分钟后就消失。
-中继每个账号最多保留 20 000 条消息(最老的对话的消息先删,标题保留)、2 000 个未删除的旁聊(中继 0.23:超出后新的旁聊被拒绝,原因 `conversation_limit`,留在设备上;删掉一个就腾出一个位置),每条消息 16 384 字节(更长的文本被截断并标记 `truncated`)。在任何一台设备上关掉开关都会通知中继,中继删掉保存的全部内容,并用 `sync_off` 拒绝其他设备,直到开关再次打开——它们的开关跟着变。同一页上的「删除已同步的对话」清空存储,开关保持原样。除了账号的设备没人能读这个存储:运营者的管理页只显示数量——开着和关着的账号数、多少对话和消息、占多大——从不显示文本,也不显示是哪个账号(`GET /v1/admin/sync`)。拉取由 hub 的 `sync` 帧触发([hub.md](hub.md#frames)),在启动时以及每分钟一次。
+中继每个账号最多保留 20 000 条消息(最老的对话的消息先删,标题保留)、2 000 个未删除的旁聊(中继 0.23:超出后新的旁聊被拒绝,原因 `conversation_limit`,留在设备上;删掉一个就腾出一个位置),每条消息 16 384 字节(更长的文本被截断并标记 `truncated`)。在任何一台设备上关掉开关都会通知中继,中继删掉保存的全部内容,并用 `sync_off` 拒绝其他设备,直到开关再次打开;它们的开关跟着变。同一页上的「删除已同步的对话」清空存储,开关保持原样。除了账号的设备没人能读这个存储:运营者的管理页只显示数量(开着和关着的账号数、多少对话和消息、占多大),从不显示文本,也不显示是哪个账号(`GET /v1/admin/sync`)。拉取由 hub 的 `sync` 帧触发([hub.md](hub.md#frames)),在启动时以及每分钟一次。
API,全部在账号的 key 之下(没有 key 是 401;开关关着时读写都是 409 `sync_off`):
@@ -56,39 +56,39 @@ DELETE /v1/sync/changes → the state, counts at zero everyt
DELETE /v1/sync/conversations/{cid} → {cursor, deleted: true} a tombstone the other devices apply; 404 no_conversation
```
-`scope`(中继 0.20)不写就是 `all`;`main` 只返回主要聊天及其消息,还没有主要聊天的账号得到一个空页,其 `cursor` 是账号的计数器。`tail=K`(K ≤ 500,只在 `since=0` 时生效)按 `seq` 顺序返回最新的 K 条消息、它们所属的对话、位于账号计数器的 `cursor`、`more: false`,以及 `skipped`——略过了多少条更早的消息。其他任何 `scope` 都是 400 `bad_scope`。`POST /v1/sync/working` 表示 `device`(或 `X-Nanomuse-Device`)里指名的设备正在 `cid` 里回答(`working: true`)或已经结束(`false`);中继把活跃的那些在内存里保留十分钟——从不进数据库,所以重启就忘——在状态的 `working` 下列出(`[{cid, from, device_name, working, at}]`),并向账号的其他套接字发一个 `working` 帧([hub.md](hub.md#frames))。请求正文超过 `MAX_REQUEST_BYTES`(0.20 起默认 16 MiB)是 413 `too_large`,附一句「请求正文 N MB;这个中继最多接受 M MB」。
+`scope`(中继 0.20)不写就是 `all`;`main` 只返回主要聊天及其消息,还没有主要聊天的账号得到一个空页,其 `cursor` 是账号的计数器。`tail=K`(K ≤ 500,只在 `since=0` 时生效)按 `seq` 顺序返回最新的 K 条消息、它们所属的对话、位于账号计数器的 `cursor`、`more: false`,以及 `skipped`(略过了多少条更早的消息)。其他任何 `scope` 都是 400 `bad_scope`。`POST /v1/sync/working` 表示 `device`(或 `X-Nanomuse-Device`)里指名的设备正在 `cid` 里回答(`working: true`)或已经结束(`false`);中继把活跃的那些在内存里保留十分钟(从不进数据库,所以重启就忘),在状态的 `working` 下列出(`[{cid, from, device_name, working, at}]`),并向账号的其他套接字发一个 `working` 帧([hub.md](hub.md#frames))。请求正文超过 `MAX_REQUEST_BYTES`(0.20 起默认 16 MiB)是 413 `too_large`,附一句「请求正文 N MB;这个中继最多接受 M MB」。
-一个对话是 `{cid, kind: main | side, title, device, device_name, created_at, updated_at, deleted, seq}`,一条消息是 `{mid, cid, seq, device, device_name, role: user | assistant, text, truncated, attachments[{name, mime, size}], created_at, deleted}`;`cid` 和 `mid` 是设备生成的 UUID(4–64 个字符,取自 `a-z 0-9 . _ : -`,折叠为小写),时间是 Unix 秒。每个被接受的变更拿到账号的下一个 `seq`;设备记住自己拉到过的最高 `cursor`,下次用 `since=` 它来请求。推送是幂等的——已知的 `mid` 原样不动,除非新行是墓碑记录;已知的 `cid` 取较新的标题——而且一次最多 200 条消息或对话(413 `too_many_messages`)。一页按 `seq` 顺序列出它的对话和消息,中继还会把页里每条消息所属的对话一并加上,即使那个对话自己的 `seq` 在更后面(重命名会把它挪后),所以客户端先应用页里的对话、再应用消息,永远不会拿到一个没有归属的孤儿。拒绝会指名那一行:第二个 `main` 被推上来时是 `main_exists` 加 `cid_main`——设备随后改用 `cid_main` 重发——还有 `conversation_limit`(中继 0.23,账号的旁聊已到上限)、`unknown_cid`、`conversation_deleted`、`bad_cid`、`bad_mid`、`bad_kind`、`bad_role`。墓碑记录保留 30 天,然后清扫掉。推送被接受或删除之后,hub 用一个 `sync` 帧通知账号的其他设备(控制台发出的 `DELETE` 在 `X-Nanomuse-Device` 里带着执行删除的设备,好让它跳过自己的回声)。
+一个对话是 `{cid, kind: main | side, title, device, device_name, created_at, updated_at, deleted, seq}`,一条消息是 `{mid, cid, seq, device, device_name, role: user | assistant, text, truncated, attachments[{name, mime, size}], created_at, deleted}`;`cid` 和 `mid` 是设备生成的 UUID(4–64 个字符,取自 `a-z 0-9 . _ : -`,折叠为小写),时间是 Unix 秒。每个被接受的变更拿到账号的下一个 `seq`;设备记住自己拉到过的最高 `cursor`,下次用 `since=` 它来请求。推送是幂等的(已知的 `mid` 原样不动,除非新行是墓碑记录;已知的 `cid` 取较新的标题),而且一次最多 200 条消息或对话(413 `too_many_messages`)。一页按 `seq` 顺序列出它的对话和消息,中继还会把页里每条消息所属的对话一并加上,即使那个对话自己的 `seq` 在更后面(重命名会把它挪后),所以客户端先应用页里的对话、再应用消息,永远不会拿到一个没有归属的孤儿。拒绝会指名那一行:第二个 `main` 被推上来时是 `main_exists` 加 `cid_main`(设备随后改用 `cid_main` 重发),还有 `conversation_limit`(中继 0.23,账号的旁聊已到上限)、`unknown_cid`、`conversation_deleted`、`bad_cid`、`bad_mid`、`bad_kind`、`bad_role`。墓碑记录保留 30 天,然后清扫掉。推送被接受或删除之后,hub 用一个 `sync` 帧通知账号的其他设备(控制台发出的 `DELETE` 在 `X-Nanomuse-Device` 里带着执行删除的设备,好让它跳过自己的回声)。
## 额度 {#allowance}
-nanoMuse 是社区项目,不收费。上游的公共中继(`cloud.nanomuse.cn`,这个 fork 不默认配置它)由开发者出钱,所以每个账号有一个可以取用的池子——按账号的整个生命周期算,不按天(中继 0.5):
+nanoMuse 是社区项目,不收费。上游的公共中继(`cloud.nanomuse.cn`,这个 fork 不默认配置它)由开发者出钱,所以每个账号有一个可以取用的池子,按账号的整个生命周期算,不按天(中继 0.5):
| | 上游的公共中继 |
|---|---|
| 注册 | 任何有中国大陆手机号或邮箱的人都可以 |
-| 免费额度 | 写这篇时是**每个账号 ¥10**,聊天、图片和短视频共用;不重置。数字由中继决定(可以不更新 App 就上调——App 显示的是中继说的数,`/v1/config`),账号页始终显示当前的 |
-| 邀请 | 每有一个*新*人用你的邀请码注册,你的池子加 **¥5**(同样是中继定的数)——对方也加同样多 |
-| 用完之后 | 用自己的 key 或你已经在付费的套餐——在中国大陆,优先[阿里云百炼](own-key.md)(一把 key 覆盖聊天、手、图片和短视频);其他地方优先 [OpenRouter](https://openrouter.ai/keys) 或 OpenAI(百炼只接受中国大陆的注册);ChatGPT、Claude 或 Kimi 套餐在有该登录流程的客户端上登录;任何 OpenAI 兼容端点都可以;登录和你的设备不受影响 |
-| 成员 | 开发者和他们列出的人没有上限,可以设服务商有的任何模型(聊天用聊天模型,图片用图像模型,短视频用视频模型):App 的模型选择器在菜单之后列出它们,叫「账号还能用的更多模型」(中继 0.10 用 Cloud key 读取服务商的列表),也仍然可以手动输入一个 id——「其他模型名」 |
+| 免费额度 | 写这篇时是**每个账号 ¥10**,聊天、图片和短视频共用;不重置。数字由中继决定(可以不更新 App 就上调;App 显示的是中继说的数,`/v1/config`),账号页始终显示当前的 |
+| 邀请 | 每有一个*新*人用你的邀请码注册,你的池子加 **¥5**(同样是中继定的数),对方也加同样多 |
+| 用完之后 | 用自己的 key 或你已经在付费的套餐:在中国大陆,优先[阿里云百炼](own-key.md)(一把 key 覆盖聊天、手、图片和短视频);其他地方优先 [OpenRouter](https://openrouter.ai/keys) 或 OpenAI(百炼只接受中国大陆的注册);ChatGPT、Claude 或 Kimi 套餐在有该登录流程的客户端上登录;任何 OpenAI 兼容端点都可以;登录和你的设备不受影响 |
+| 成员 | 开发者和他们列出的人没有上限,可以设服务商有的任何模型(聊天用聊天模型,图片用图像模型,短视频用视频模型):App 的模型选择器在菜单之后列出它们,叫「账号还能用的更多模型」(中继 0.10 用 Cloud key 读取服务商的列表),也仍然可以手动输入一个 id(「其他模型名」) |
| 速率 | 每分钟 30 次请求 |
| token | 没有上限;用量会计量并显示 |
-消费按模型服务商的目录价计算(阿里云百炼,北京地域,2026 年 10 月 7 日查阅)。`deepseek-v4.1-flash` 是 0.1.34 起的聊天模型(能看图,回答前会思考):北京时间 8:00 到 22:00 每百万 token 输入 ¥2 / 输出 ¥8,其余夜间时段 ¥1 / ¥4,以回复到达的那一刻为准。`qwen3.8-27b` 是手的模型,也能用来聊天:¥3 / ¥12;`qwen3.8-flash` ¥0.8 / ¥2.7;`qwen-image-3.0` 每张图 ¥0.18,编辑时送进去的那张图另加 ¥0.02;`wan2.2-i2v-flash` 480P 视频每秒 ¥0.10(一段 5 秒的短视频是 ¥0.50),720P 每秒 ¥0.20,1080P 每秒 ¥0.48。提示词里被服务商从缓存里取出的那部分(系统提示和历史记录,一轮接一轮)按服务商的缓存价计:DeepSeek 是输入价的 10%,Qwen 模型是 20%,以回复里 `usage` 报告的数为准。思考模型的推理 token 算作输出。普通的一天聊下来花几分钱;¥10 大约是聊天模型的两百万 token,或者五十张图。一个新形象(四张候选、四个姿势、四段短视频)大约 ¥3.5,App 在画之前会显示估算和剩余额度。
+消费按模型服务商的目录价计算(阿里云百炼,北京地域,2026 年 10 月 7 日查阅)。`deepseek-v4.1-flash` 是 0.1.34 起的聊天模型(能看图,回答前会思考):北京时间 8:00 到 22:00 每百万 token 输入 ¥2 / 输出 ¥8,其余夜间时段 ¥1 / ¥4,以回复到达的那一刻为准。`qwen3.8-27b` 是手的模型,也能用来聊天:¥3 / ¥12;`qwen3.8-flash` ¥0.8 / ¥2.7;`qwen-image-3.0` 每张图 ¥0.18,编辑时送进去的那张图另加 ¥0.02;`wan2.2-i2v-flash` 480P 视频每秒 ¥0.10(一段 5 秒的短视频是 ¥0.50),720P 每秒 ¥0.20,1080P 每秒 ¥0.48。一段短视频在它的任务完成时扣一次费,按提交时定下的价;在那之前,这个价从额度池里预留,最多一小时。关掉的 App 不会再回来取结果,所以从中继 0.24 起由中继自己去问服务商:在那一小时到期前问一次,在任务那一行被丢弃前再问一次。做完的短视频在那时扣费,失败的不花钱,App 之后再来轮询也不再多扣。提示词里被服务商从缓存里取出的那部分(系统提示和历史记录,一轮接一轮)按服务商的缓存价计:DeepSeek 是输入价的 10%,Qwen 模型是 20%,以回复里 `usage` 报告的数为准。思考模型的推理 token 算作输出。普通的一天聊下来花几分钱;¥10 大约是聊天模型的两百万 token,或者五十张图。一个新形象(四张候选、四个姿势、四段短视频)大约 ¥3.5,App 在画之前会显示估算和剩余额度。
-*设置 → nanoMuse Cloud* 显示池子用了多少(以 ¥ 和 $ 计)、还剩多少、池子怎么增长。用到 80 % 时 App 提示一次;池子花完后中继以 `allowance_exhausted` 拒绝,App 显示接下来的路:自己的 key、你已经在付费的套餐,或者邀请(你们各加 ¥5,或者中继当天说的数)。先推荐哪家服务商取决于你在哪里(0.1.34:中继的 `region`,从号码的国家码或它自己磁盘上一份 ip2region 的离线副本读出——什么都不往外发):中国大陆账号被指向阿里云百炼——服务商表单打开时已经预填好,[指南](own-key.md)——其他所有人指向 OpenRouter 或 OpenAI,因为百炼只接受中国大陆的注册。
+*设置 → nanoMuse Cloud* 显示池子用了多少(以 ¥ 和 $ 计)、还剩多少、池子怎么增长。用到 80 % 时 App 提示一次;池子花完后中继以 `allowance_exhausted` 拒绝,App 显示接下来的路:自己的 key、你已经在付费的套餐,或者邀请(你们各加 ¥5,或者中继当天说的数)。先推荐哪家服务商取决于你在哪里(0.1.34:中继的 `region`,从号码的国家码或它自己磁盘上一份 ip2region 的离线副本读出;什么都不往外发):中国大陆账号被指向阿里云百炼(服务商表单打开时已经预填好,[指南](own-key.md)),其他所有人指向 OpenRouter 或 OpenAI,因为百炼只接受中国大陆的注册。
-中继 0.21 起,拒绝响应和 `/v1/me` 把整张卡片作为数据带上,而不只是两个链接(契约 C11):`spend.guidance`——以及 `allowance_exhausted` 错误旁边的 `guidance`——按顺序列出该地区的服务商和每家 key 覆盖的范围(`covers: chat | vision | image | video`,来自共享目录 [`nanomuse/llm/providers.json`](../../nanomuse/llm/providers.json),中继自带一份副本)、一个人可能已经在付费的套餐和哪些客户端能用它们登录(`plans`:ChatGPT 到处都行,Claude 和 Kimi 在手机上,OpenRouter 在手机上)、本地服务器(`local`)、文档链接,以及关于 ChatGPT 登录的那句实话(`caveats.chatgpt`、`caveats.chatgpt_zh`)。0.17 的 `ways` 行仍然发送,现在每行带着服务商的 `name`、`name_zh`、`key_url`、`covers` 和 `auth`,所以 0.1.38 的客户端照旧画出它一直画的那两个按钮。`/app` 的控制台用 `guidance` 画卡片,遇到旧中继则退回两个链接。其他中继可以定其他规则(`ALLOWANCE_CNY`、`INVITE_BONUS_CNY`、`SIGNUP_OPEN`、`ALLOWED_IDENTIFIERS`——三个数字都能在中继运行时调整,中继 0.15;见 [`cloud/README.md`](../../cloud/README.md))。
+中继 0.21 起,拒绝响应和 `/v1/me` 把整张卡片作为数据带上,而不只是两个链接(契约 C11):`spend.guidance`(以及 `allowance_exhausted` 错误旁边的 `guidance`)按顺序列出该地区的服务商和每家 key 覆盖的范围(`covers: chat | vision | image | video`,来自共享目录 [`nanomuse/llm/providers.json`](../../nanomuse/llm/providers.json),中继自带一份副本)、一个人可能已经在付费的套餐和哪些客户端能用它们登录(`plans`:ChatGPT 到处都行,Claude 和 Kimi 在手机上,OpenRouter 在手机上)、本地服务器(`local`)、文档链接,以及关于 ChatGPT 登录的那句实话(`caveats.chatgpt`、`caveats.chatgpt_zh`)。0.17 的 `ways` 行仍然发送,现在每行带着服务商的 `name`、`name_zh`、`key_url`、`covers` 和 `auth`,所以 0.1.38 的客户端照旧画出它一直画的那两个按钮。`/app` 的控制台用 `guidance` 画卡片,遇到旧中继则退回两个链接。厂商要求每个对话带一个固定 id 的服务商(目录里的 `session_header`,比如 OpenCode Go)单独列在 `session_providers` 里,并写明那个请求头:1.0.1 及更早的手机端只读 `providers`,也不发这个 id,所以不会推荐它;会发 id 的应用把这份列表接在 `providers` 后面。其他中继可以定其他规则(`ALLOWANCE_CNY`、`INVITE_BONUS_CNY`、`SIGNUP_OPEN`、`ALLOWED_IDENTIFIERS`;三个数字都能在中继运行时调整,中继 0.15;见 [`cloud/README.md`](../../cloud/README.md))。运营者能看到谁邀请了谁:管理页的「邀请」视图按邀请码带来的账号数给邀请人排序,并列出其中有多少人用过、有多少人的地址与邀请人相同;一个账号的页面写明它的邀请人,并列出它带来的人(`GET /v1/admin/invites`)。
-nanoMuse 只求一样回报:在 [GitHub](https://github.com/zeeshanhaque21/nanoMuse) 上点一个 star,这是帮项目被人找到的东西。什么时候开口由中继定,不由 App 定(0.1.35):`GET /v1/nudges` 说明什么时候问是合理的——它替你完成第三、第十和第三十个任务之后,你打开它的第七天和第三十天,达成一个目标时,画出一个新形象时,在账号页上一次,以及池子花完时——两次之间至少隔一周,每台设备最多四次。每次都是一张出现在当下位置的卡片;「以后再说」也算一次,去过那个页面之后就不再出现。运营者在管理页(*设置 › 请求 star*)改这项策略,不用更新 App;每个 App 都内置同样的默认值,供连不上中继时使用。策略定的是*什么时候*,运营者愿意的话也可以定*说什么*:卡片上的文字默认是每个 App 自己的、用它的语言写的;策略里带了句子就用策略的——`star.text`(英文)和 `star.text_zh`(简体中文),各最多 200 字,默认为空。中文界面的 App 先看 `text_zh`,没有再看 `text`,都没有就用自己的句子;其他语言的 App 看 `text`,没有就用自己的。只换卡片上的那句话,标题和按钮还是 App 自己的。按中继 0.22 及更早版本做的 App 不认这两个字段。
+nanoMuse 只求一样回报:在 [GitHub](https://github.com/zeeshanhaque21/nanoMuse) 上点一个 star,这是帮项目被人找到的东西。什么时候开口由中继定,不由 App 定(0.1.35):`GET /v1/nudges` 说明什么时候问是合理的:它替你完成第三、第十和第三十个任务之后,你打开它的第七天和第三十天,达成一个目标时,画出一个新形象时,在账号页上一次,以及池子花完时,两次之间至少隔一周,每台设备最多四次。每次都是一张出现在当下位置的卡片;「以后再说」也算一次,去过那个页面或点了「已经点过了」之后就不再出现(只记在本机,账号不同步这个标记)。运营者在管理页(*设置 › 请求 star*)改这项策略,不用更新 App;每个 App 都内置同样的默认值,供连不上中继时使用。策略定的是*什么时候*,运营者愿意的话也可以定*说什么*:卡片上的文字默认是每个 App 自己的、用它的语言写的;策略里带了句子就用策略的:`star.text`(英文)和 `star.text_zh`(简体中文),各最多 200 字,默认为空。中文界面的 App 先看 `text_zh`,没有再看 `text`,都没有就用自己的句子;其他语言的 App 看 `text`,没有就用自己的。只换卡片上的那句话,标题和按钮还是 App 自己的。按中继 0.22 及更早版本做的 App 不认这两个字段。
## 控制 {#controls}
-运营者可以不重启、不重新部署就暂停中继的某些部分(中继 0.22,管理控制台的「控制」;命令行是 `nanomuse-cloud admin controls …`——[`cloud/README.md`](../../cloud/README.md#controls-022))。五个开关,默认都开,都存在数据库里所以重启后不变,每次切换都在审计日志里记一行:谁、什么时候、为什么。某个开关关着时 App 看到的是:
+运营者可以不重启、不重新部署就暂停中继的某些部分(中继 0.22,管理控制台的「控制」;命令行是 `nanomuse-cloud admin controls …`;见 [`cloud/README.md`](../../cloud/README.md#controls-022))。五个开关,默认都开,都存在数据库里所以重启后不变,每次切换都在审计日志里记一行:谁、什么时候、为什么。某个开关关着时 App 看到的是:
| 开关 | 关着时 App 得到什么 |
|---|---|
-| **免费额度** | 受限账号(没有自己的 key,也不是成员)请求模型时得到 **429 `allowance_exhausted`**,带 `paused: true` 和 `reason: "allowance_paused"`——和池子花完时形状相同,所以每个 App 像今天一样显示它的「自己的 key」卡片,只是消息说额度暂时暂停,而不是花完了。成员、登录、设备和同步不受影响 |
+| **免费额度** | 受限账号(没有自己的 key,也不是成员)请求模型时得到 **429 `allowance_exhausted`**,带 `paused: true` 和 `reason: "allowance_paused"`,和池子花完时形状相同,所以每个 App 像今天一样显示它的「自己的 key」卡片,只是消息说额度暂时暂停,而不是花完了。成员、登录、设备和同步不受影响 |
| **注册** | 还没有账号的手机号或邮箱从 `POST /v1/auth/code` 和 `/v1/auth/verify` 得到 **403 `signup_closed`**,在发出任何验证码之前;`/v1/config` 里的 `signup_open` 变成 false。所有已有账号照常登录、照常使用 |
| **云服务** | 每个 API 调用都回答 **503 `service_paused`**,带 `paused: true`,例外是健康检查、`/v1/config`、管理控制台和 `/v1/admin/*`;每条 hub 套接字以 `4003 hub_paused` 关闭。什么都不删;已登录的 App 保留它的 key,开关恢复后重新登录 |
| **对话同步** | `/v1/sync/*` 的推送和拉取回答 **503 `sync_paused`**;`GET /v1/sync/state` 仍然应答并说 `paused: true`。已存的内容保留;每台设备各自继续工作 |
@@ -100,7 +100,7 @@ nanoMuse 只求一样回报:在 [GitHub](https://github.com/zeeshanhaque21/nan
## 自己部署 {#running-your-own}
-任何人都可以跑一个中继——给一家人、一个班、一家公司——然后把 App 指向它。服务器是 SQLite 之上的单个 Python 进程;一台装了 Docker 的 VPS 加一个域名就够了:
+任何人都可以跑一个中继(给一家人、一个班、一家公司),然后把 App 指向它。服务器是 SQLite 之上的单个 Python 进程;一台装了 Docker 的 VPS 加一个域名就够了:
```bash
cd cloud
@@ -114,7 +114,7 @@ docker compose up -d # Caddy fetches the TLS certificate
为了应用商店的审核,`REVIEW_ADDRESSES` 和 `REVIEW_CODE` 给审核员留一条进路:对这些邮箱之一请求验证码时什么都不发,但回答得像发了一样,而六位的 `REVIEW_CODE` 可以登录,受和所有人一样的验证码有效期、尝试次数和速率限制约束。这个账号是普通账号;管理页给它打上「review」标签,并把它排除在注册计数之外。两者都为空(默认)则一切照旧。细节在 [`cloud/README.md`](../../cloud/README.md#for-app-store-review)。
-中继也是账号各设备的会合点——`/v1/hub` 的 **hub** 和 `/app` 的网页控制台;见 [hub.md](hub.md)。`HUB_ENABLED` 可以关掉它,`HUB_FRAME_LIMIT` 限制单帧大小(文件和截图在帧里传,默认 16 MB)。
+中继也是账号各设备的会合点:`/v1/hub` 的 **hub** 和 `/app` 的网页控制台;见 [hub.md](hub.md)。`HUB_ENABLED` 可以关掉它,`HUB_FRAME_LIMIT` 限制单帧大小(文件和截图在帧里传,默认 16 MB)。
各端默认不连任何中继(这个 fork 不配置默认中继)。手机登录表单下面的「使用其他服务器」可以填你自己跑的中继地址([android.md](android.md)、[ios.md](ios.md));桌面端从插件的 `config.baseURL` 读([desktop.md](desktop.md));网页控制台就是在 `/app` 提供它的那台中继。
@@ -140,8 +140,8 @@ POST /v1/auth/sign-out-all Bearer {all?} → {signed_out}
POST /v1/auth/delete Bearer → 204
```
-其余都是 OpenAI API:`GET /v1/models`(带 `architecture` 模态,好认出图像模型,每个模型还有一个 `nanomuse` 块,含它的 `kind`、价格、`for`——聊天模型适用的通道,`chat` 和 / 或 `gui`——以及 `recommended_for`)、流式的 `POST /v1/chat/completions`、`POST /v1/images/generations` 和 `/v1/images/edits`。错误是 `{"error": {"message", "type": "nanomuse_cloud", "code"}}`,`code` 稳定不变,App 把它变成一句话。中继 0.22 加了运营者[开关](#controls)的码:`403 signup_closed`、`503 service_paused`、`503 sync_paused`、`503 hub_paused`(以及 hub 的关闭码 `4003 hub_paused`),还有免费额度是被暂停而不是花完时带 `paused: true` 的 `allowance_exhausted`;`/healthz`、`/v1/config` 和 `/v1/me` 在 `paused` 下列出关着的开关。
+其余都是 OpenAI API:`GET /v1/models`(带 `architecture` 模态,好认出图像模型,每个模型还有一个 `nanomuse` 块,含它的 `kind`、价格、`for`(聊天模型适用的通道:`chat` 和 / 或 `gui`)以及 `recommended_for`)、流式的 `POST /v1/chat/completions`、`POST /v1/images/generations` 和 `/v1/images/edits`。错误是 `{"error": {"message", "type": "nanomuse_cloud", "code"}}`,`code` 稳定不变,App 把它变成一句话。中继 0.22 加了运营者[开关](#controls)的码:`403 signup_closed`、`503 service_paused`、`503 sync_paused`、`503 hub_paused`(以及 hub 的关闭码 `4003 hub_paused`),还有免费额度是被暂停而不是花完时带 `paused: true` 的 `allowance_exhausted`;`/healthz`、`/v1/config` 和 `/v1/me` 在 `paused` 下列出关着的开关。
-中继不认识的 key 得到 `401 bad_key`;0.1.40 起,账号已被删除的 key 改为得到 `401 account_deleted`,在每一个需要 key 的调用上都如此(`/v1/me`、`/v1/models`、`/v1/chat/completions`、同步和 hub 的调用),删除后持续 90 天——中继把已删除账号的 key 哈希保留这么久,别的什么都不留。不认识这个码的客户端看到的是和以前一样的 `401`;认识的客户端(手机)在 `account_deleted` 时删除账号的本地数据,其他情况都收起来保留([sync.md](sync.md))。完整的错误码表在 [`cloud/README.md`](../../cloud/README.md)。
+中继不认识的 key 得到 `401 bad_key`;0.1.40 起,账号已被删除的 key 改为得到 `401 account_deleted`,在每一个需要 key 的调用上都如此(`/v1/me`、`/v1/models`、`/v1/chat/completions`、同步和 hub 的调用),删除后持续 90 天;中继把已删除账号的 key 哈希保留这么久,别的什么都不留。不认识这个码的客户端看到的是和以前一样的 `401`;认识的客户端(手机)在 `account_deleted` 时删除账号的本地数据,其他情况都收起来保留([sync.md](sync.md))。完整的错误码表在 [`cloud/README.md`](../../cloud/README.md)。
-设备:`GET /v1/devices` 列出账号的设备(在线或最后出现的时间),`DELETE /v1/devices/{id}` 忘记一台离线的设备,`WS /v1/hub` 就是 hub 本身——帧在 [hub.md](hub.md)。对话:`/v1/sync/state`、`/v1/sync/changes` 和 `/v1/sync/conversations/{cid}`——上面的[对话同步](#conversation-sync)一节。
+设备:`GET /v1/devices` 列出账号的设备(在线或最后出现的时间),`DELETE /v1/devices/{id}` 忘记一台离线的设备,`WS /v1/hub` 就是 hub 本身;帧在 [hub.md](hub.md)。对话:`/v1/sync/state`、`/v1/sync/changes` 和 `/v1/sync/conversations/{cid}`;见上面的[对话同步](#conversation-sync)一节。
diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md
index 80f31843..b1c7761a 100644
--- a/docs/zh/configuration.md
+++ b/docs/zh/configuration.md
@@ -82,7 +82,7 @@ video_base_url = "" # the asynchronous video API when it is not o
| vLLM / LM Studio | 你起的服务名 | `http://localhost:8000/v1` | 服务器不理会 `tools` 的话,设 `tool_mode = "prompt"` |
| 公司网关 | 按要求 | 按要求 | 用 `extra_headers` / `extra_body`;按网关说的 API 形状来选 provider |
-网页控制台提供同一组预设(「连接 → 对话模型」,以及第一次打开时),每个都带一个链接,指向它的 key 从哪里领;确切的主机名在 `nanomuse/server/connections.py` 的 `PROVIDERS` 里。从 App 保存的 `base_url` 如果没有路径,会补上 `/v1`(`http://host:8000` → `http://host:8000/v1`);预设自带的主机原样保留。Ollama 和自定义端点可以没有 key。
+网页控制台提供同一组预设(「连接 → 对话模型」,以及第一次打开时),每个都带一个链接,指向它的 key 从哪里领;确切的主机名在 `nanomuse/server/connections.py` 的 `PROVIDERS` 里。从 App 保存的 `base_url` 如果没有路径,会补上 `/v1`(`http://host:8000` → `http://host:8000/v1`);预设自带的主机原样保留。Ollama 和自定义端点可以没有 key。从网页控制台添加的自定义接口是一个列表(`app-settings.json` 里的 `endpoints`,每项是 `{id, label, base_url, api_key, models}`,key 以 `ENDPOINT__API_KEY` 存在保险库里);设在其中一个接口上的槽位带着它的地址和那个保险库引用,所以这个文件不需要知道列表的存在([web.md](web.md#where-it-shows))。在这里设在目录外主机上的 `[llm]` 对话模型保持原样;只有从 App 里设的那个会搬进列表。
`provider` 也可以是服务商目录 [`nanomuse/llm/providers.json`](../../nanomuse/llm/providers.json) 里的一个 id(`bailian`、`deepseek`、`moonshot`、`zhipu`、`openrouter`、`openai`、`gemini`……),这时 `base_url` 以及(为空时的)`model` 从目录里填上,只有 `api_key` 是你自己的。目录还写明每家服务商的 key 覆盖什么(聊天、手、图片、短视频);服务器上的 `GET /api/providers` 报告每个槽位用的是哪家服务商、还剩哪些没被覆盖,App 对一项没人覆盖的功能只显示一句话([own-key.md](own-key.md))。
@@ -442,12 +442,12 @@ token = "" # empty → generated once, stored in Meta 的 Muse 桌面应用长什么样、做什么,是用过之后记下来的;还有跑在 DeepSeek Harness 上的
> nanoMuse 桌面版从它那里取了什么、舍了什么、哪些按自己的方式来。只记界面:没有账号,没有对话,
-> 没有当时那次会话里的任何屏幕内容。Muse 是 Meta 的商标;nanoMuse 是社区项目,与 Meta 无关——见
+> 没有当时那次会话里的任何屏幕内容。Muse 是 Meta 的商标;nanoMuse 是社区项目,与 Meta 无关,见
> [README](../../README.md#disclaimer)。
这里的桌面应用是 **DeepSeek Harness 加上 `dsh-nanomuse` 插件包(bundle)**
([harness.md](harness.md))。dsh 带来循环、工具和一个完整的网页应用,上面留着插件可以接管的
插槽(seat);插件包接管那些能让窗口看起来、用起来像 Muse 的插槽,藏起 Muse 没有的东西,再加上
-手机 App 有而 Muse 没有的——每台设备上都在的账号、Reach、一层开放的模型层。
+手机 App 有而 Muse 没有的:每台设备上都在的账号、Reach、一层开放的模型层。
## 窗口 {#the-window}
@@ -22,12 +22,15 @@
- 侧栏,按 Muse 的顺序:聊天(智能体干活时有一个点)、搜索、动态(有你还没看的帖子时有一个点)、
点子、目标、资源库、设备;一段空白;底部是汉堡菜单(日程和 harness 的其他面板都在它的菜单里)。
- 侧栏上没有脸——和 Muse 一样,脸是钉在顶部的页头和资料面板。macOS 上窗口没有标题栏:红绿灯按钮
+ 侧栏上没有脸;和 Muse 一样,脸是钉在顶部的页头和资料面板。macOS 上窗口没有标题栏:红绿灯按钮
落在侧栏空着的顶端,那一块是拖动把手,全屏时这段留白收掉。Windows 得到同样的无边框窗口,系统
自己的标题栏按钮画在右上角(`titleBarOverlay`,按主题重新上色);Linux 保留窗口管理器的标题栏,
- 因为那里没有可移植的覆盖层。
-- 聊天列:一个「搜索」框带一个「···」菜单(已归档的聊天),**主要聊天**——一个始终留在顶部的会话,
- 要么是第一个,要么是你用「设为主要聊天」选定的那个——以及带「+」的**旁聊**:其余所有会话,置顶的
+ 因为那里没有可移植的覆盖层。Windows 上,顶栏右端有按钮的页面(动态、目标、资源库及其文件查看器、
+ 文档编辑器、资料面板、首次启动、插件、日程、右侧停靠栏)会在那里空出系统标题栏按钮的宽度,
+ 这些按钮排在系统按钮的左边;任务详情是右上角的一条窄栏,改为把标签栏移到系统按钮下方。全屏时,
+ 以及顶栏本来就更靠下的分屏里,不再额外留空。
+- 聊天列:一个「搜索」框带一个「···」菜单(已归档的聊天),**主要聊天**(一个始终留在顶部的会话,
+ 要么是第一个,要么是你用「设为主要聊天」选定的那个)以及带「+」的**旁聊**:其余所有会话,置顶的
在前,每个都有一个「···」,里面是「设为主要聊天」「置顶」「重命名」(就地改)「归档」。空会话显示
「新聊天」;一个点标记正在运行的,一个记号标记等你回复的。它可以收起,只剩侧栏。这一列显示的是
已登录账号的内容:同步来的对话记得自己来自哪个账号,所以另一个账号的对话留在这台电脑上,但不出现
@@ -37,64 +40,70 @@
标签:「已连接」/「未登录」/「思考中…」/「Hands · 第 N 步 · 它正在做的事」/「Reach · 第 N 步 ·
在 Laptop B 上:…」/「等你确认」;一轮运行时旁边有「停止」按钮。智能体干活时和等你回答时标签会
上色。点脸打开资料面板。右上角是「邀请」(有账号时),然后是 harness 的右侧栏开关;harness 自己
- 的标题行、标签页(「对话」/「Trace」)和会话操作都藏起来——重命名、置顶和归档由聊天行承担。
-- 对话:你的消息在右边,气泡用强调色调出来;智能体的在左边,大圆角的灰色气泡;时间戳和每条消息
- 的操作只在悬停时出现;空聊天没有问候语——输入框在底部等着,和 Muse 一样,上面是工作区和预设那一行。
-- 输入框是一个胶囊:「+」(附件)、「消息」、发送圆钮(一轮运行时是停止方块)。harness 的模型选择器、
+ 的标题行、标签页(「对话」/「Trace」)和会话操作都藏起来:重命名、置顶和归档由聊天行承担。
+- 对话和手机一样是单色的:你的消息在右边,是墨色药丸(浅色下黑底白字,深色下白底黑字);智能体的
+ 在左边,是比画布深一级的大圆角灰色药丸;时间戳和每条消息的操作只在悬停时出现;你药丸里的链接保持
+ 药丸的文字颜色,带下划线。页眉不是一条栏,而是罩在对话上的一层渐隐:消息从脸的下面滚过去、融进
+ 画布,页眉下面也没有分隔线。空聊天没有问候语:输入框在底部等着,和 Muse 一样,上面是工作区和预设那一行。
+- 输入框是一个胶囊:「+」(附件)、「消息」、发送圆钮(一轮运行时是停止方块);发送圆钮和 harness
+ 的「回到最新」圆钮用和你的药丸一样的墨色,图标是相反的颜色。harness 的模型选择器、
权限模式和计划开关都从这里藏起来(模型放在「设置 → 模型」,dsh 自己的「模型」页在「高级」下面,默认权限模式放在「通用」,和 Muse
一样),打开「通用」下的「显示 DeepSeek Harness 原生控件」它们才回来。在「插件」里打开 harness 的
语音输入后麦克风才出现(「设置 → 听写」会说怎么开)。
-- 主题:harness 的浅色和深色调色板都改成 Muse 的色调(浅色下 `#f9f9f9` 表面、`#e3e4e6` 智能体
- 气泡;深色下 `#171717`、`#242424` 气泡和 `#2b2b2b` 输入框),强调色跟随账号上那张脸的颜色,
- 所以在手机上给脸上了什么色,桌面也是什么色。桌面外壳用同样的底色画窗口,深色下改变窗口大小
+- 主题:harness 的浅色和深色调色板都改成 Muse 的色调(浅色下 `#f9f9f9` 表面、纯白的聊天画布、
+ `#000000` 的用户药丸和 `#f2f2f7` 的智能体药丸;深色下 `#171717`、`#ffffff` 的用户药丸、`#242424`
+ 的智能体药丸和 `#2b2b2b` 输入框),强调色跟随账号上那张脸的颜色,所以在手机上给脸上了什么色,
+ 桌面上的灯和链接也是什么色。桌面外壳用同样的底色画窗口,深色下改变窗口大小
绝不会闪一下白。
## 第一次打开 {#the-first-run}
-**Muse。** 登录(手机号,一条短信验证码——「登录或创建账号」),一个加载页,然后是三页全窗口的
+**Muse。** 登录(手机号,一条短信验证码:「登录或创建账号」),一个加载页,然后是三页全窗口的
页面,右上角有 ‹ ›,底下是圆点翻页器:「允许 Muse 使用你的电脑?」(辅助功能用于点击和输入;录屏
-用于截图——每行一个「允许」按钮,允许后变成绿色对勾,还有小字说明,卡片下方的「跳过」在两项都
+用于截图;每行一个「允许」按钮,允许后变成绿色对勾,还有小字说明,卡片下方的「跳过」在两项都
允许后变成蓝色的「继续」)、「允许 Muse 访问你的文件和本地应用?」(一个「完全磁盘访问权限」
状态胶囊,一列本地应用,每个带一个只读/…下拉菜单,「继续」)、「打开语音输入」(麦克风,「允许」、
「跳过」)。然后是主窗口,聊天里是智能体的自我介绍、一张给它起名的卡片,和一条连接器推荐。
**dsh 上的 nanoMuse。** 手机的那几页(Android 上的 `FirstRunSetup`),全窗口,放在 harness 的
`settings.onboarding` 插槽里(我们接管了自带那一步的 id,所以新装的应用见到的是智能体,而不是被
-要一把 DeepSeek key)。宿主把状态存在 `$DSH_HOME/nanomuse/firstrun.json`(`src/firstrun.ts`)——
-按过「开始」之后算*完成*,权限页看过没有,由哪个来源回答——所以这几页在新装时绝不会跳过,也绝不会
+要一把 DeepSeek key)。宿主把状态存在 `$DSH_HOME/nanomuse/firstrun.json`(`src/firstrun.ts`):
+按过「开始」之后算*完成*,权限页看过没有,由哪个来源回答,所以这几页在新装时绝不会跳过,也绝不会
显示两次:
-1. **欢迎**——脸、「欢迎使用 nanoMuse」、三行功能(对话、手、Reach)、社区声明、一个蓝色的「登录」
- 胶囊和「用我自己的 API Key」。登录是手机那一套:「手机号或邮箱」,下面一行说明中国大陆手机号收短信、其他发到邮箱,输到第六位就自动校验的验证码
+1. **欢迎**:应用的标志(形象要到最后一页「认识」才出现,随对话一起来)、「欢迎使用 nanoMuse」、
+ 三行功能(对话、Hands、Reach)、社区声明、一个蓝色的「登录」胶囊和「改用自己的 API key」,后者
+ 直接进入选择模型页,不需要账号(登录可以以后再说;在那一页「先跳过」而没存 key,就回到欢迎页)。
+ 登录是手机那一套:「手机号或邮箱」,下面一行说明中国大陆手机号收短信、其他发到邮箱,输到第六位就自动校验的验证码
格子,「试试其他方式」改用密码(`/v1/auth/code`、`/v1/auth/verify`、`/v1/auth/login`)。
2. **设一个密码**(只对新账号;「以后再说」跳过)。
-3. **让哪个模型来回答**——Cloud 账号的模型,或者你自己的 key。
-4. **选择模型**(只在用自己的 key 时)——目录里的服务商(`assets/providers.json`,和手机、网页版
- 同一份列表),你所在地区的排在前面——中国大陆是阿里云百炼,一把 key 包下对话、手、图片和短视频;
- 其他地区先是 OpenRouter,然后是 OpenAI——每一行写明它覆盖什么(「对话 · 操作屏幕 · 图片 ·
+3. **让哪个模型来回答**:Cloud 账号的模型,或者你自己的 key。
+4. **选择模型**(只在用自己的 key 时):目录里的服务商(`assets/providers.json`,和手机、网页版
+ 同一份列表),你所在地区的排在前面(中国大陆是阿里云百炼,一把 key 包下对话、手、图片和短视频;
+ 其他地区先是 OpenRouter,然后是 OpenAI),每一行写明它覆盖什么(「对话 · 操作屏幕 · 图片 ·
视频」),「去拿 key」打开厂商的 key 页面,「添加 key」就地把 key 收下(存进凭证库,键名
`NANOMUSE_KEY_`;`cloud.json` 只记名字)。OpenAI 下面有 **用 ChatGPT 登录**:通过
- OpenAI 的页面登录一个 Plus、Pro 或 Team 套餐,只覆盖对话和手——没有图片,没有短视频——附一行
+ OpenAI 的页面登录一个 Plus、Pro 或 Team 套餐,只覆盖对话和手(没有图片,没有短视频),附一行
老实话:OpenAI 的条款只允许在它自己的 Codex 里使用 ChatGPT 套餐,其他应用的这条路曾被切断过。
「更多途径」展开目录的其余部分、这台电脑上的服务器(Ollama、LM Studio、vLLM)和任何手动填写的
OpenAI 兼容接口。保存一把 key 或完成一次登录之前是「先跳过」;之后是「继续」。
-5. **允许 nanoMuse 使用你的电脑?**(仅 macOS)——「辅助功能」和「录屏」两行,每行一个「允许」,
- 通过桌面外壳向系统请求,系统放行后变成绿色对勾(轮询检查,窗口获得焦点或页面重新可见时再查一次——
+5. **允许 nanoMuse 使用你的电脑?**(仅 macOS):「辅助功能」和「录屏」两行,每行一个「允许」,
+ 通过桌面外壳向系统请求,系统放行后变成绿色对勾(轮询检查,窗口获得焦点或页面重新可见时再查一次;
同一个钩子 `permissions.ts`,支撑每一处显示权限的地方);按过「允许」之后按钮变成「打开系统设置」。
只需要打开 **nanoMuse Desktop** 这一项:macOS 把随附运行时的截屏和输入事件都算到启动它的那个
应用头上,所以没有第二个条目要找。对「录屏」,外壳在打开设置面板之前先截一次,这样 nanoMuse 就
出现在面板的列表里;如果权限是在应用运行中才给的,会弹一个对话框说 macOS 只对重新启动后的应用
- 生效,带「立即重启」(经桥接调用 `relaunch`)。「试一下」几行——截一张测试图、动一下鼠标——和
+ 生效,带「立即重启」(经桥接调用 `relaunch`)。「试一下」几行(截一张测试图、动一下鼠标)和
运行时那一行会告诉你能不能用(这一行写明用的是哪个二进制;上一次为手启动失败时,附上宿主记下的
原因),截出来是黑屏时另有一条提示,带「立即重启」。Linux 和 Windows 上
这一页跳过。
-6. **认识 <名字>**——脸,「开始」。
+6. **认识 <名字>**:脸,「开始」。
「开始」把**主要聊天**(智能体自己的文件夹 `~/nanoMuse`,注册为工作区,输入框因此可用)绑定为
-**第一场对话**,也就是手机的 `FirstConversation`:应用先开口——三句写好的台词,不花 token,永远
-不进模型的历史——然后问该怎么称呼你。从这里起由模型接着说,并在回复末尾用一小段 `nanomuse-naming`
+**第一场对话**,也就是手机的 `FirstConversation`:应用先开口(三句写好的台词,不花 token,永远
+不进模型的历史),然后问该怎么称呼你。从这里起由模型接着说,并在回复末尾用一小段 `nanomuse-naming`
块汇报发生了什么(对你的称呼、它建议的名字、它拿到的名字);聊天把建议显示成起名卡片(*NamingCard*,
-点一下或者直接输一个名字),阶段只跟着这个块推进,别的都不算——回复里没有这个块,就说明人在聊别的,
+点一下或者直接输一个名字),阶段只跟着这个块推进,别的都不算:回复里没有这个块,就说明人在聊别的,
模型帮完忙再把话题拉回来。智能体有了名字,第一次打开就结束了:动态的第一天写出来,每日例程设好。
「帮助与支持 → 再看一遍首次引导」可以有意重开这几页(第一场对话不会重复)。
@@ -105,18 +114,18 @@
资料、审批记录(允许过什么、什么时候,「本次任务允许」/「始终允许」)、活动,还有更多。新的脸在
聊天里画:四个候选排成网格,挑一个,智能体用文字确认。
-**dsh 上的 nanoMuse。** 顶部的脸打开智能体的页面——手机的 `AgentProfileScreen`,310 px 宽,在聊天
-右侧(聊天让出位置;× 或 Escape 关闭):86 px 的头像带一支笔——**更换虚拟形象**把手机的那句话放进
+**dsh 上的 nanoMuse。** 顶部的脸打开智能体的页面,即手机的 `AgentProfileScreen`,310 px 宽,在聊天
+右侧(聊天让出位置;× 或 Escape 关闭):86 px 的头像带一支笔(**更换虚拟形象**把手机的那句话放进
输入框(「把虚拟形象改成…」,聊天流程见下文)、**编辑名称**、**形象工坊…**(小龙、一个表情放在一种
-颜色上,或者一张画出来的脸,写到账号里,每台设备都穿它)——名字、「online」/「未登录」/「离线」或
+颜色上,或者一张画出来的脸,写到账号里,每台设备都穿它))、名字、「online」/「未登录」/「离线」或
智能体此刻正在做的事、一个分享按钮(一张带脸和名字的卡片,可以存成图片或复制),以及一个四段的
分段控件:**活动**(会话和 hub 的通知,「今天」和「更早」)、**审核记录**(按应用的「始终允许」授权,
带**撤回**,「管理权限」→ 设置 → 电脑操作,然后是你在审批卡片上允许或拒绝过的,带时间;只存在这台
-电脑上)、**每日**(例程;「管理每日任务」打开 harness 的日程面板)、**SOUL 与记忆**——智能体记得的
-关于你的事,一行一条:一个输入框添一条,× 忘掉一条,「导入记忆」(Muse 的那张表单:把另一个助手
+电脑上)、**每日**(例程;「管理每日任务」打开 harness 的日程面板)、**SOUL 与记忆**(智能体记得的
+关于你的事,一行一条):一个输入框添一条,× 忘掉一条,「导入记忆」(Muse 的那张表单:把另一个助手
对你的了解粘进来,一行一条记忆),账号有描述时还有智能体的描述。你告诉它一件下次该记得的事,
智能体就用 `remember` 工具自己写进记忆,每场聊天的提示词都带着这份清单(`rooms.json`,只在这台
-电脑上)。「更换形象」里有小龙、一个表情放在一种颜色上,还有**画一个**——形象工作室:描述角色,
+电脑上)。「更换形象」里有小龙、一个表情放在一种颜色上,还有**画一个**(形象工作室):描述角色,
选一种风格(默认是 Muse 的玩偶风格),看看按今天的额度要花多少,「画四个」,四个候选以 2×2 网格
出现,标着「选项 1–4」;「就这个」从选中的那张画出其他姿势(干活中、等你回复、搞定、出了点问题),
新形象就穿上了,账号的每台设备都是。图片来自账号的图像模型,经中继(`/v1/images/generations`、
@@ -127,17 +136,17 @@
**在聊天里换新形象**是手机的 `AvatarFlow`,一字不差:"change your avatar to an orange cat with
a scarf"、「把你的形象换成一只橘猫」、"become a robot"(同一组正则表达式,`src/avatar-flow.ts`)
在发出去之前就被截住;输入框上方的一张卡片说明四张图按今天的额度要花多少(手机的 `FaceCostDialog`),
-「开始画」让四个候选带编号以 2×2 网格出现,你点一下或者说一句来挑——「第二个」、"the second one"、
-"regenerate"——然后「正在完成形象」画出其他姿势,新形象在每台设备上穿上,「分享形象」给出那张卡片。
+「开始画」让四个候选带编号以 2×2 网格出现,你点一下或者说一句来挑(「第二个」、"the second one"、
+"regenerate"),然后「正在完成形象」画出其他姿势,新形象在每台设备上穿上,「分享形象」给出那张卡片。
穿上之后,宿主留下手机那条记忆(`- <日期>:用户把我的虚拟形象换成了"…"`),并请智能体用一句话说
出来、写下 `nanomuse-avatar` 围栏,聊天把它显示成一张带新脸的小卡片。智能体自己的 `draw_new_look`
仍然会为它想提议的任何形象打开工作室。
**脸会动。** Muse 的头像按状态有动画;这里小龙的四个状态(待命、干活中、等你回复、搞定)是手机 App
-的短视频(`assets/dragon-.mp4`),凡是脸不小于 44 px 的地方——页头、资料面板、舞台的光标
-标记——都静音循环播放,静态图作为封面;小于这个尺寸时、系统要求减弱动态效果时,或者列表只想要
-一张安静的图时,只显示静态图。画出来的脸也有自己的短视频,和手机上一样:四段 4 秒的短视频——
-待命、干活中、等你回复、搞定——由 `wan2.2-i2v-flash` 从这张脸画出来,走中继或你自己的百炼 key
+的短视频(`assets/dragon-.mp4`),凡是脸不小于 44 px 的地方(页头、资料面板、舞台的光标
+标记)都静音循环播放,静态图作为封面;小于这个尺寸时、系统要求减弱动态效果时,或者列表只想要
+一张安静的图时,只显示静态图。画出来的脸也有自己的短视频,和手机上一样:四段 4 秒的短视频
+(待命、干活中、等你回复、搞定),由 `wan2.2-i2v-flash` 从这张脸画出来,走中继或你自己的百炼 key
(`src/video.ts`、`src/motion.ts`;手机的提示词,一字不差),存在这台电脑的 `avatar/motion/` 下,
由 faces 路由提供(`/face//.webp|mp4`),在页头、侧边栏和胶囊里播放。「设置 → 图像与视频」
里有「换形象后自动生成动作」(默认开)、「现在生成动作 / 重新生成」和用到的模型;工作室的费用估算
@@ -148,83 +157,87 @@ a scarf"、「把你的形象换成一只橘猫」、"become a robot"(同一
**Muse。** 被控制的屏幕作为一块调暗的实时舞台显示在窗口里:左上角一个 × 用来停止,右上角是接管
控件,左下角一行说明刚做了什么(「已输入 · <应用>」、「按下 ↵ · <应用>」),脸作为光标标记。聊天里
-嵌着权限卡片——「允许 <名字> 截屏?」/「写一个文件?」——带「允许(本次任务)」「始终允许」「拒绝」。
+嵌着权限卡片(「允许 <名字> 截屏?」/「写一个文件?」),带「允许(本次任务)」「始终允许」「拒绝」。
(nanoMuse 从 0.1.34 到 0.1.39 有过这样一个舞台,0.1.40 去掉了它,换成下面的轨迹:一块跟着手走的
画中画又是一个在动的东西,而且事后看不回去。)
**dsh 上的 nanoMuse。** 手是运行时经 MCP 提供的 `computer_screen` / `computer_act`,所以模型看到的
就是它要来的那张截图,干活时状态标签写着「Hands · 第 N 步 · 点击"保存"」。harness 的审批卡片改成
-Muse 权限卡片的样子——一面盾、标题、细节、蓝色的「允许一次」在前、灰色的「拒绝」——每个回答都
+Muse 权限卡片的样子(一面盾、标题、细节、蓝色的「允许一次」在前、灰色的「拒绝」),每个回答都
写进智能体页面的**审核记录**标签页;哨兵(Sentinel)给的理由([sentinel.md](sentinel.md))就在标题里。
-同一个问题——「允许一次」、「在 <应用> 里始终允许」(针对手的工具;应用取自窗口标题的第一段)、
-「拒绝」(宿主的 `ApprovalDesk` 排在 `approval/request` 瀑布的最前面,替卡片作答)——也出现在
+同一个问题,「允许一次」、「在 <应用> 里始终允许」(针对手的工具;应用取自窗口标题的第一段)、
+「拒绝」(宿主的 `ApprovalDesk` 排在 `approval/request` 瀑布的最前面,替卡片作答),也出现在
**胶囊**上,也就是 nanoMuse 窗口不在前台时外壳显示的那个始终置顶的小药丸,这样你在哪都能回答。
「在 <应用> 里始终允许」写下一条 `computer_app:` 授权:在那个应用里手的工具不再询问,直到你
在智能体页面或「设置 → 电脑操作 → 已始终允许」撤回这条授权。「设置 → **电脑操作**」显示两项 macOS
权限,带「允许」和「打开系统设置」(通过桌面外壳;手第一次要开始时,外壳会按顺序请求它们,每项都
-说一句为什么,和手机、Codex 的做法一样——先辅助功能,再给 nanoMuse Desktop 的录屏,从 0.1.36 起
+说一句为什么,和手机、Codex 的做法一样:先辅助功能,再给 nanoMuse Desktop 的录屏,从 0.1.36 起
它就是负责截屏和点击的那个二进制;随附的 `nanomuse` 运行时只在脱离外壳单独运行时才需要它们)、
「操作时保持屏幕常亮」(会话运行期间外壳持有一个阻止省电的锁)和一条说明:凡是会发送、付款或删除
的操作都会先问。
-**暂停——轮到谁。** 智能体需要你时(密码、人机验证、一个不该由它做的选择),它用 `action: "hand_over"`
+**暂停:轮到谁。** 智能体需要你时(密码、人机验证、一个不该由它做的选择),它用 `action: "hand_over"`
和一个 `reason` 调用 `computer_act`;宿主开一个*暂停*(契约 C1:`{type:"hold", tool:"computer",
-status:"on", by:"agent", reason}`),聊天里的轨迹卡片写着 **该你了——<原因>——好了**,胶囊在窗口外
-也这么说(琥珀色,光晕跟着变琥珀色),工具调用一直等(最多十分钟),直到你按**好了**。**我来接手**——
-在轨迹卡片和胶囊上都有——由你自己开一个暂停(`POST /nanomuse/cloud/holds`),智能体在下一次手的
+status:"on", by:"agent", reason}`),聊天里的轨迹卡片写着 **该你了 · <原因> · 好了**,胶囊在窗口外
+也这么说(琥珀色,光晕跟着变琥珀色),工具调用一直等(最多十分钟),直到你按**好了**。**我来接手**
+(在轨迹卡片和胶囊上都有)由你自己开一个暂停(`POST /nanomuse/cloud/holds`),智能体在下一次手的
调用之前停住,你来用鼠标;「好了」恢复。提问仍然保持*打开*。停止(那个方块)照样取消这一轮。
**操作器。** 手本身是外壳的(`src/operator.ts`,从 UI-TARS-desktop 的 `NutJSOperator` 移植,基于
`@computer-use/nut-js`):截图走 Electron 的 `desktopCapturer`,按显示器的实际尺寸(Mac 上改走辅助
-程序 *nanoMuse Computer Use* 和 ScreenCaptureKit——[desktop.md](desktop.md#macos-permissions)),
+程序 *nanoMuse Computer Use* 和 ScreenCaptureKit,见 [desktop.md](desktop.md#macos-permissions)),
指针直接移到目标点、停 100 ms 再点,拖动、滚动,非 ASCII 的内容经剪贴板输入,还有快捷键表(Mac
上 `ctrl` 就是 ⌘)。运行时的 `nanomuse mcp` 通过外壳每次启动时开的一个回环 HTTP 服务器够到它们
(环境变量 `NANOMUSE_OPERATOR_URL` / `NANOMUSE_OPERATOR_TOKEN`,预设把它们透传给 MCP 服务器),
-所以模型给的坐标——它看到的那张图的像素——只换算一次成操作器的屏幕像素,落在它指的地方,缩放
+所以模型给的坐标(它看到的那张图的像素)只换算一次成操作器的屏幕像素,落在它指的地方,缩放
显示器和普通显示器一样([gui.md](gui.md#hands-on-the-computer-the-picture-is-the-unit))。没有
外壳时(单独运行 `nanomuse`),运行时退回到 `pyautogui` / `xdotool`。
**呼吸,不流动。** 从 0.1.40 起,桌面应用里没有任何东西沿着边缘转圈、扫过表面或闪烁:每一盏表示
-「在干活」的灯——聊天名字旁的点、页头的状态点、另一台设备那一轮的点、运行中一轮下方的思考点、
-监听中的麦克风、光晕、胶囊的圆环和小条、轨迹的实时点——都按手机的节奏呼吸:2.4 s 亮起、2.4 s
+「在干活」的灯(聊天名字旁的点、页头的状态点、另一台设备那一轮的点、运行中一轮下方的思考点、
+监听中的麦克风、光晕、胶囊的圆环和小条、轨迹的实时点)都按手机的节奏呼吸:2.4 s 亮起、2.4 s
暗下,ease-in-out,在暗和全亮之间。一次性的动作保留(卡片滑入、标记锁定、点击的两圈涟漪),脸的
几种状态和普通的转圈指示器也保留。系统开着*减弱动态效果*时,每盏呼吸的灯都变成常亮,标记的弧线
也不转了。
**光晕。** 手运行时,外壳在整块显示器上盖一层透明、点击穿透、始终置顶的窗口(UI-TARS 的
-ScreenMarker,用手机的话说——就是为桌面重画的 `HandsStage.kt`):一道光沿四条边呼吸,宽度是短边
-的 7 %,边缘一条 1.5 px 的发丝线——手干活时是 Muse 的动作蓝,暂停时是琥珀色——在操作器动作的那个
+ScreenMarker,用手机的话说,就是为桌面重画的 `HandsStage.kt`):一道光沿四条边呼吸,宽度是短边
+的 7 %,边缘一条 1.5 px 的发丝线(手干活时是 Muse 的动作蓝,暂停时是琥珀色),在操作器动作的那个
精确位置上是**标记**:一圈柔光、一个 12 px 的动作蓝圆环、一道绕着它转的青色弧线、中心一个带白芯
的点,旁边一个深色药丸写着动作的名字(靠近右边缘时放到左侧)。它在 220 ms 内从 1.8 倍缩小锁定,
点击时随着点击落下泛两圈涟漪;拖动时画出从蓝到青的虚线路径,带箭头和终点的落地环,并把圆环沿着
路径送过去;输入、组合键、打开应用和等待这些屏幕上没有落点的动作,把名字写在上一个圆环的位置。
标记在动作后 1.6 s 淡出。它来自操作器本身(它自己对显示器的比例坐标),不是客户端对工具调用的
解读,所以它就在点击真正落下的地方;换别的后端动手时(运行时自己的手),用客户端的坐标顶上,不带
-文字。光晕拿不到焦点也接不到点击,手停下 400 ms 后消失;它、胶囊和手运行期间的 nanoMuse 窗口本身
-都设了 `setContentProtection(true)`,所以运行时截的图里没有它们——Linux 上内容保护不起作用,光晕
+文字。光晕在整段运行里一直亮着:从这一轮第一次 `computer_screen` 或 `computer_act` 调用起,到这
+一轮结束为止,模型在两步之间思考的那几秒也不熄(宿主在 `hands.run` 里说明是哪个会话占着屏幕;以前
+光晕和胶囊跟着进行中的调用走,每两步之间都会暗一下);这一轮结束、按下「停止」或五分钟没有任何调用
+之后 400 ms 消失。光晕拿不到焦点也接不到点击;它、胶囊和手占着屏幕期间的 nanoMuse 窗口本身
+都设了 `setContentProtection(true)`,所以运行时截的图里没有它们;Linux 上内容保护不起作用,光晕
和胶囊改为在截图的那一瞬间让开。手机的长按圆环在桌面上没有对应物(没有什么是长按的);手机的脸
只在它自己的胶囊里跟着手指走,所以桌面上跟着指针跑的脸(0.1.34–0.1.39)去掉了。
**胶囊**是手机的 `HandsCapsule`,按指针操作调整了尺寸:工作区顶部正中最宽 420 px 的一个药丸
-(82 % 的墨色,一道白色发丝线),智能体的脸放在一个 36 px 的三色环里——动作蓝、紫、青——和四根
-小条一起呼吸,**第 N 步**和手正在做的事(「已点击"保存"」、「已输入"hello"」、「正在看屏幕」),
-**我来接手**和一个红色的**停止**。手开始时它用 320 ms 滑入,只在 nanoMuse 窗口不在前台时显示
+(82 % 的墨色,一道白色发丝线),智能体的脸放在一个 36 px 的三色环里(动作蓝、紫、青),和四根
+小条一起呼吸,**第 N 步**和手正在做的事(「正在看屏幕」、「已点击"保存"」、「已输入"hello"」;模型
+思考下一步时,上一步的话留在那里),**我来接手**和一个红色的**停止**。手开始时它用 320 ms 滑入,
+和光晕一样整段运行都在,只在 nanoMuse 窗口不在前台时显示
(聊天里的轨迹卡片显示同样的内容),运行结束 220 ms 后消失。手要在它下面点击或拖动时,它先挪到
-工作区底部(下次再回到顶上),就像手机的胶囊躲开手指。暂停让它变成琥珀色,写着**该你了——<原因>——
+工作区底部(下次再回到顶上),就像手机的胶囊躲开手指。暂停让它变成琥珀色,写着**该你了 · <原因> ·
好了**;智能体在某一步之前问的问题作为一张卡片挂在它下面,带「允许一次」「在 <应用> 里始终允许」
「拒绝」。外壳从主窗口经 IPC 喂给它数据(`nanomuse:overlay`,`{hands: {active, held, step, title,
text, face, stop, take, x, y, kind}, cards}`),它的回答也从同一条路回来。
**轨迹**是在聊天里回看一次手的运行的方式,运行中和运行后都是:这一轮最后一个工具调用下方的一张
卡片(聊天自己的一行,所以跟着对话一起滚动),有那一步的截图,截图上用标记的几种颜色画出手在那里
-做了什么——点击的圆环和点(双击两个圆环)、拖动的虚线路径和箭头、滚动的一个 V 形、输入的文字、
-组合键或者动作没有落点时标签药丸里的应用名——一行文字说明(「第 3 步 · 已点击"保存" · Finder」)、
+做了什么(点击的圆环和点(双击两个圆环)、拖动的虚线路径和箭头、滚动的一个 V 形、输入的文字、
+组合键或者动作没有落点时标签药丸里的应用名)、一行文字说明(「第 3 步 · 已点击"保存" · Finder」)、
智能体在这一步之前说的话(它的简短推理,卡片上最多三行)、一条缩略图胶片、「上一步」和「下一步」
(卡片有焦点时是 ← 和 → 键,Home 和 End 跳到两端)、「放大查看」(在一张表单里看这一步和完整文字)
和「复制这一步」。运行进行中,卡片跟着最新一步,带一个呼吸的点,并提供**我来接手**和**停止**;
-暂停时显示「该你了——<原因>」和**好了**。图片按需加载,只有选中那一步的是全尺寸。宿主把运行留在
+暂停时显示「该你了 · <原因>」和**好了**。图片按需加载,只有选中那一步的是全尺寸。宿主把运行留在
内存里(`src/trajectory.ts`):一次*运行*从一轮里第一次手的调用开始,到这一轮结束或手歇了十分钟
-为止;一*步*是模型看到的一帧(`computer_screen` 返回一帧,`computer_act` 返回动作之后的屏幕——
+为止;一*步*是模型看到的一帧(`computer_screen` 返回一帧,`computer_act` 返回动作之后的屏幕;
一个 `tools/execute` 中间件从 MCP 结果里把它取出来,连同第一行里的窗口标题和尺寸),加上模型接着
在这帧上做的动作,以及它动手之前最后一条助手消息的文字。上限:**每次运行 40 步**(更早的从前面掉
出去,卡片写着「最早的 N 步已不再保留」),**总共 4 次运行**,图片合计不超过 **64 MB**(最旧的先走;
@@ -233,91 +246,96 @@ text, face, stop, take, x, y, kind}, cards}`),它的回答也从同一条路
startedAt, endedAt, firstCall, lastCall, dropped, steps: [{i, seq, at, width, height, title, action,
words, callId}]}]}`,不含字节数据),图片读 `GET /nanomuse/cloud/stage/frame?seq=N`(不可变,缓存
一小时);实时状态里的 `trajectory: {rev, sessions}` 告诉它什么时候该再读一次。智能体通过 Reach
-看一台*手机*时(`device_screen`),这次运行是一次*设备*运行,带手机的名字——那就是手机 App 自己的
+看一台*手机*时(`device_screen`),这次运行是一次*设备*运行,带手机的名字:那就是手机 App 自己的
Hands 干活时显示的东西([gui.md](gui.md)),从这边看过去。
## 设置 {#settings}
-**Muse。** 一个带侧边栏的对话框:通用(账号、用量、语言、外观——模式和强调色、开机启动、显示在
-菜单栏、悬浮按钮、快捷键、关于——版本和检查更新)· 连接器 · 电脑操作(两项 macOS 权限、保持屏幕
+**Muse。** 一个带侧边栏的对话框:通用(账号、用量、语言、外观(模式和强调色)、开机启动、显示在
+菜单栏、悬浮按钮、快捷键、关于(版本和检查更新))· 连接器 · 电脑操作(两项 macOS 权限、保持屏幕
常亮,电脑控制和浏览器自动化各自的每次询问 / 始终允许 / 始终拒绝,屏蔽的应用)· 文件系统访问 ·
听写 · 钱包 · 安全存储 · 权限 · 消息渠道 · 设备 · 数据控制(隐私、导入记忆、下载你的数据、重置)·
帮助 · 法律信息 · 退出登录。
**dsh 上的 nanoMuse。** 同样的形状,来自 harness 的 `sidebar.settings` 插槽,自带的「通用」插件在
-插件包这一层关掉:
+插件包这一层关掉。一页上的链接打开另一页时(「模型」页的「添加服务商」打开账号页的接入方式;「通用」
+的账号卡片打开账号页;「文件」页的「沙箱」打开智能体页),打开的那一页顶上留着回去的路,写着你来自
+哪一页;在侧边栏点选则重新开始。
-- **通用**——按 Muse 的顺序:**账号卡片**(打码后的标识,或者「登录」,点开账号页)、**使用情况**
- (套餐和它的进度条——「会员」/「不限量」,或者免费额度和还剩多少,加「升级」)、**语言**(harness
+- **通用**,按 Muse 的顺序:**账号卡片**(打码后的标识,或者「登录」,点开账号页)、**使用情况**
+ (套餐和它的进度条:「会员」/「不限量」,或者免费额度和还剩多少,加「升级」)、**语言**(harness
自己的那一行,放进我们的卡片)、**外观**(harness 的浅色 / 深色 / 跟随系统开关;强调色跟着脸走)、
- 有桌面外壳时的**应用行为**——「开机启动」(一个登录项;Linux 上是 freedesktop 的自启动条目)、
+ 有桌面外壳时的**应用行为**:「开机启动」(一个登录项;Linux 上是 freedesktop 的自启动条目)、
「显示在菜单栏」/「显示在系统托盘」(一个图标,带「打开 nanoMuse」「新聊天」「退出」)、「快速唤起
(⌥ Space)」(其他平台是 Ctrl+Alt+Space:窗口带着一个新聊天弹出来,输入框已聚焦;窗口在前台时
- 再按,它就退到一边)——存在应用主目录下的 `desktop.json` 里;**快捷键**(「Quick Chat」和它的组合键——
+ 再按,它就退到一边),都存在应用主目录下的 `desktop.json` 里;**快捷键**(「Quick Chat」和它的组合键:
「更改」记录你按的下一个组合,Esc 取消,「恢复默认」换回平台默认,别的应用占着这个组合时会有一行
- 说明——以及键盘参考);**简介**(nanoMuse,基于 DeepSeek Harness,许可证,以及始终都在的两行版本:
- 「nanoMuse 桌面版 · harness ——已安装」和「最新 0.1.x——已经是最新版本」/「0.1.x
- 已发布——更新」/「没法检查——点一下再试」;检查读 GitHub 的
- releases,每天一次);还有「开发者」:「显示 DeepSeek Harness 原生控件」(模型选择器、模式、代替
+ 说明;以及键盘参考);**简介**(nanoMuse,基于 DeepSeek Harness,许可证,以及始终都在的两行版本:
+ 「nanoMuse 桌面版 · harness :已安装」和「最新 0.1.x,已经是最新版本」/「0.1.x
+ 已发布 · 更新」/「没法检查 · 点一下再试」;检查先读这个仓库的 GitHub Releases,再读配置过的镜像索引,
+ 每天一次);还有「开发者」:「显示 DeepSeek Harness 原生控件」(模型选择器、模式、代替
聊天列的工作区浏览器)。harness 的其他几行(权限预设、字号、链接打开方式、回车发送、性能、会话
日志)在「高级 → Harness」下。
-- **模型**(紧跟在「通用」后面,0.1.41 起)——模型替你做的每件事占一行:**对话**(「和你说话的模型。」)、
+- **模型**(紧跟在「通用」后面,0.1.41 起),模型替你做的每件事占一行:**对话**(「和你说话的模型。」)、
**操作屏幕**(「看着屏幕替你操作,需要能看图的模型。」)、**生成图片**(「形象的肖像,以及你要的
图片。」)和**生成视频**(「形象的短视频。」),每行一个选择器。登录时每个选择器都把 nanoMuse Cloud
排在最前,推荐的模型标着「推荐」,之后是你添加的每个服务商各一组,每组只列能做这件事的模型;值
写成「<服务商> · <模型>」。换了对话模型会写「新对话起生效。」;换了手的模型,从手的下一步起生效
(插件用新模型重新启动 `nanomuse mcp`,不用冷重启)。手只会说 OpenAI 的接口格式,所以 Anthropic 或
- Google 原生接口的 key 在这一行下面用一句话点名,不进列表。什么都做不了的一行显示门禁那句话——
- 「生成图片需要有图像模型的服务商:阿里云百炼、智谱 GLM、SiliconFlow……」——和「添加服务商」;页面
+ Google 原生接口的 key 在这一行下面用一句话点名,不进列表。什么都做不了的一行显示门禁那句话
+ (「生成图片需要有图像模型的服务商:阿里云百炼、智谱 GLM、SiliconFlow……」)和「添加服务商」;页面
底部也有「添加服务商」,打开账号页里的可用途径。没选的时候,一行跟着新对话用的那个服务商走(它在目录
里对这件事的默认模型),前提是那是你自己的服务商;否则登录时用 nanoMuse Cloud;再否则用你第一个能做
这件事的服务商。
-- **账号**(在「高级」下)——nanoMuse 账号:登录或打码后的标识、额度、形象、「打开设备页」,一行
+- **账号**(在「高级」下),即 nanoMuse 账号:登录或打码后的标识、额度、形象、「打开设备页」,一行
写着对话和手现在用什么、点开「设置 → 模型」,还有自己的 key 和账号共用的那一块:
- - **可用的途径**——登录时你账号的那一行在最前(它此刻覆盖什么,取自中继的模型列表),然后是目录,
+ - **可用的途径**:登录时你账号的那一行在最前(它此刻覆盖什么,取自中继的模型列表),然后是目录,
按第一次打开时的分组:你所在地区的服务商、ChatGPT 登录、其余按覆盖多少排序、本机服务器、自定义
接口。每一行写明它覆盖什么,就地填入或更换 key,链接到厂商的 key 页面,还有「移除」;移除一行
- 会连带删掉它的凭证,以及任何指向它的对话或手的模型选择。ChatGPT 这一行跑的是随附运行时的登录:
+ 会连带删掉它的凭证,以及任何指向它的对话或手的模型选择。自定义接口是一个一行(#294):存好的接口
+ 名字在上、主机名在下,「换 key」「移除」旁边多一个「改名」;下面的「添加接口」一行再加一个,带它
+ 自己的 key(`NANOMUSE_KEY_CUSTOM`、`NANOMUSE_KEY_CUSTOM_2`……),不碰其他接口,每个接口在模型页的
+ 选择器里自成一组。在这之前存好的接口还是第一行。ChatGPT 这一行跑的是随附运行时的登录:
「登录」打开 OpenAI 的页面,这一行带着「取消」(和再来一次的「打开登录页面」)等着,然后变成
「已登录:ChatGPT Plus」并提供「退出登录」;这个登录只覆盖对话和手,不含图片和短视频,关于 OpenAI
条款的那一行就在它下面。没有运行时的话这一行会说明,并保持禁用。已经存着的登录在重启后会回来,
连同它的本地桥接。退出账号后,同样的一块放在登录表单下面,所以没有账号也能换 key。存好一把 key
- 之后会出一张卡片问**用它来做什么**——「选这把 key 负责哪些事,其余仍由 nanoMuse Cloud 负责。」——
+ 之后会出一张卡片问**用它来做什么**(「选这把 key 负责哪些事,其余仍由 nanoMuse Cloud 负责。」),
这把 key 能做的每件事(对话、屏幕、图片、视频)各一个开关,默认全开,各写着会用的模型;「就这样」
把模型页上那几行换成这个服务商,「暂不」什么都不改,脚注写着「以后可以在 设置 › 模型 里改。」
-- **图像与视频**——画脸和它的短视频用的模型:和「设置 → 模型」里「生成图片」「生成视频」是同样的两个
+- **图像与视频**,即画脸和它的短视频用的模型:和「设置 → 模型」里「生成图片」「生成视频」是同样的两个
选择器,登录时 nanoMuse Cloud 在最前,之后是每个自己服务商的模型(视频选择器还有「关」)。用自己的
- key 画图直接走那个服务商——百炼的原生图像接口、OpenRouter 的图像接口,OpenAI、智谱、SiliconFlow、
- 火山引擎和 xAI 走 OpenAI 的格式——工作室在费用估算的位置写「由 <服务商> · <模型> 来画,不计入你的
+ key 画图直接走那个服务商(百炼的原生图像接口、OpenRouter 的图像接口,OpenAI、智谱、SiliconFlow、
+ 火山引擎和 xAI 走 OpenAI 的格式),工作室在费用估算的位置写「由 <服务商> · <模型> 来画,不计入你的
nanoMuse Cloud 账号。」;Google 的 key 只能通过它的 OpenAI 兼容层画。配好的东西里没有一样能画图或做短视频时,
- 这一行会说在你所在的地区谁能,并给出去「设置 → nanoMuse Cloud」的路——和形象工作室代替费用估算显示的是
- 同一句话——只有 ChatGPT 登录时则说图片和短视频不在它的覆盖范围内。
+ 这一行会说在你所在的地区谁能,并给出去「设置 → nanoMuse Cloud」的路(和形象工作室代替费用估算显示的是
+ 同一句话);只有 ChatGPT 登录时则说图片和短视频不在它的覆盖范围内。
下面是「换形象后自动生成动作」、「现在生成动作 / 重新生成」带四段各自的状态,以及费用说明。用自己的
key 生成的短视频失败了而你已登录时,失败那一行会给出「这次改用 nanoMuse Cloud」:这一轮走账号,选择器
不动;工作室一轮画失败后也给同样的按钮。
-- **模型**、**智能体**——harness 的页面,原样,在「高级」下。
-- **连接器**——Muse 的目录形状:一个搜索框,分类标签(「全部」「内置」「工作」「沟通」「文件」
+- **模型**、**智能体**:harness 的页面,原样,在「高级」下。
+- **连接器**,Muse 的目录形状:一个搜索框,分类标签(「全部」「内置」「工作」「沟通」「文件」
「代码与部署」「数据」「设计与网站」「支付」「搜索与网页」「基础设施」……),**已连接**的行在前,然后是
目录,每一行是服务的品牌标在一块白色方块上(标志来自 [simple-icons](https://simpleicons.org/),
CC0,由 `scripts/brand-marks.mjs` 写进 `src/client/brand-marks.ts`;没有的用字母方块),末尾是
- 「连接」;每个连接器有一页详情,写它能做什么——按服务器自己的描述列出工具,**每个工具一个开关**——
+ 「连接」;每个连接器有一页详情,写它能做什么(按服务器自己的描述列出工具,**每个工具一个开关**)、
什么把着它的门(权限预设,手和邮箱则是哨兵)、厂商的文档,以及「断开连接」(问两次)。三种行:
- - **内置**是智能体真能够到的东西:「手——这台电脑」(运行时回应了就是开着的;没回应时附原因:没有运行时、
+ - **内置**是智能体真能够到的东西:「手:这台电脑」(运行时回应了就是开着的;没回应时附原因:没有运行时、
路径指向不存在的文件,或运行时没启动起来,带启动时的报错)、
「邮箱」「日历」「通讯录」(运行时的连接器,它们的工具经 `nanomuse mcp` 到达就是开着的)、
- 「Reach——你的其他设备」「网页」「文件」「终端」、几个房间、「日程」。在邮箱、日历或通讯录上点「连接」
- 会打开一张 Muse 风格的**知情同意表单**——智能体会得到什么、谁来决定、凭证存在哪里——接着是步骤
+ 「Reach:你的其他设备」「网页」「文件」「终端」、几个房间、「日程」。在邮箱、日历或通讯录上点「连接」
+ 会打开一张 Muse 风格的**知情同意表单**(智能体会得到什么、谁来决定、凭证存在哪里),接着是步骤
(`nanomuse vault set …`、`config.toml` 里的几行、重启),带复制按钮;这里故意没有登录按钮,凭证
留在你自己的保险库里。
- - **服务**——目录(`src/connectors-catalogue.ts`,七十五个远程 MCP 服务器:Notion、Linear、
+ - **服务**:目录(`src/connectors-catalogue.ts`,七十五个远程 MCP 服务器:Notion、Linear、
Atlassian、Asana、GitHub、GitLab、Sentry、Vercel、Cloudflare、Supabase、Stripe、PayPal、QuickBooks、
Figma、Miro、Canva、Dropbox、Box、Hugging Face、DeepWiki……),每个带地址和它放客户端进来的方式。
「连接」去问服务器本身(`initialize`):**开放**的服务器立刻连上;会说 **MCP authorization** 的
- 服务器在系统浏览器里打开登录页——受保护资源和授权服务器的元数据、动态客户端注册(RFC 7591)、
- PKCE、`resource`(RFC 8707)、`127.0.0.1:38417/oauth/callback` 的回环回调、过期前刷新——表单带着
+ 服务器在系统浏览器里打开登录页(受保护资源和授权服务器的元数据、动态客户端注册(RFC 7591)、
+ PKCE、`resource`(RFC 8707)、`127.0.0.1:38417/oauth/callback` 的回环回调、过期前刷新),表单带着
「再次打开登录页」「再试一次」和「重新开始」(重新注册一次,给忘了我们的服务器用)等着;要**密钥**
的服务器用一张表单来要,表单上写着厂商在哪里发它。授权服务器不接受注册客户端的服务(GitHub、
- Slack、Discord、HubSpot、Render、Bitrise、PagerDuty、Box——目录里标了 `clientIdRequired` 的,
+ Slack、Discord、HubSpot、Render、Bitrise、PagerDuty、Box,也就是目录里标了 `clientIdRequired` 的,
以及任何实际发现是这样的地址)有自己的一张表单:到厂商的开发者页面(有链接)建一个 OAuth 应用,
填上我们的回调地址(显示出来,带复制按钮),粘贴 client id 和(如果有的话)secret;这个 id 像一次
注册一样存下来,下一次在那里登录就不再问。搜索框下的行同时搜索公开的 **MCP 注册表**
@@ -327,86 +345,90 @@ Hands 干活时显示的东西([gui.md](gui.md)),从这边看过去。
指向一个**回环代理**(`/c//mcp`,由一个秘密请求头把关),代理负责加上 token 或密钥、刷新它、
把 `tools/list` 过滤成打开了开关的那些工具、对其余的拒绝 `tools/call`;工具以
`mcp____` 出现,预设行 `nanomuse-connectors-tools` 为每个连接挂一个客户端,并跟着
- 页面走(连接、开关、断开——不用重启)。刷新被拒的连接变成「需要重新登录」,带「重新登录」;密钥
+ 页面走(连接、开关、断开,不用重启)。刷新被拒的连接变成「需要重新登录」,带「重新登录」;密钥
就地更换。
- - **预设**——手动配在智能体预设里的每个 MCP 服务器(`@deepseek-ai/dsh-mcp-client` 行),这里只读,
+ - **预设**:手动配在智能体预设里的每个 MCP 服务器(`@deepseek-ai/dsh-mcp-client` 行),这里只读,
「添加连接器」打开预设。
Muse 有而这里没有的:按动作分的权限下拉菜单(我们的是权限预设加工具开关),以及那些 MCP 服务器
- 要求事先在厂商那里注册客户端的服务(Zapier、Heroku……)——它们在目录里是*密钥*或*地址*行,不是
+ 要求事先在厂商那里注册客户端的服务(Zapier、Heroku……);它们在目录里是*密钥*或*地址*行,不是
登录。
-- **电脑操作**——系统权限(macOS)、保持常亮、风险说明。
-- **文件访问**——智能体用到的文件夹(主目录、资源库、下载、nanoMuse 自己的文件;每个都能在文件
- 管理器里打开)、规则(你点名的任何东西都能读;写入按这场聊天的权限预设,出了它的文件夹就问;沙箱),
- 以及 macOS 上的「完全磁盘访问权限」。
-- **听写**——harness 自己的语音输入(本地 SenseVoice;在「插件」里打开,发送旁边出现一个麦克风,
+- **电脑操作**:系统权限(macOS)、保持常亮、风险说明。
+- **文件访问**:智能体用到的文件夹(主目录、资源库、下载、nanoMuse 自己的文件;每个都能在文件
+ 管理器里打开,这一页不授权任何目录)、规则(你点名的任何东西都能读;写入按这场聊天的权限预设,
+ 除非智能体页另有设置,否则是 *workspace-write*:聊天的目录随便改,主要聊天的目录是 `~/nanoMuse`,
+ 目录之外的改动会被拒绝,除非智能体向你申请、你允许这一次;*read-only* 每次改动前都问,*full access*
+ 什么都不问;沙箱),以及 macOS 上的「完全磁盘访问权限」,它只关系到能不能读「桌面」「文稿」「下载」。
+ 主目录、下载和应用自己的文件有意放在主要聊天的目录之外;从资源库打开的聊天、在你自己的文件夹上
+ 开的聊天,就写在那里。
+- **听写**:harness 自己的语音输入(本地 SenseVoice;在「插件」里打开,发送旁边出现一个麦克风,
音频留在这台电脑上)、macOS 上的麦克风权限,以及怎么把系统的听写用到输入框里。
-- **设备**——这台电脑(它的名字、让手机能在这里跑东西的远程控制开关)和账号下的其他设备、在线
+- **设备**:这台电脑(它的名字、让手机能在这里跑东西的远程控制开关)和账号下的其他设备、在线
的点、「移除」。
-- **权限**——一页说清智能体可以碰什么(电脑操作、文件、麦克风、连接器、其他设备连同远程控制的
+- **权限**:一页说清智能体可以碰什么(电脑操作、文件、麦克风、连接器、其他设备连同远程控制的
状态),每一行点开就是带开关的那一页;它怎么问(权限预设,从输入框上方的标签切换);最近的审批。
-- **钱包**、**安全存储**、**消息渠道**——Muse 的几页,写的是这里的实情:不保存任何付款方式,聊天里
+- **钱包**、**安全存储**、**消息渠道**(Muse 的几页),写的是这里的实情:不保存任何付款方式,聊天里
不会买任何东西(智能体在结账处停下,用量放在「通用」下);这台电脑上哪些文件存着什么(账号的
- 登录、智能体的资料、房间和文档、脸——每个带路径和「显示文件夹」)和什么永远不存(系统钥匙串、
+ 登录、智能体的资料、房间和文档、脸;每个带路径和「显示文件夹」)和什么永远不存(系统钥匙串、
你的密码);哪些渠道能找到智能体(这台桌面、浏览器、登录后的邀请链接、你的其他设备),以及没有
接入任何第三方聊天软件。
-- **数据控制**——「我们认真对待你的隐私」加隐私政策,「在我的设备之间同步对话」(账号的开关,带中继
+- **数据控制**:「我们认真对待你的隐私」加隐私政策,「在我的设备之间同步对话」(账号的开关,带中继
保存了多少个聊天、多少条消息,以及「删除已同步的对话」),它下面是「同时同步旁聊」(这台电脑自己的
- 开关,默认关——「关:旁聊只留在本机。开:本机的旁聊上传到账号,其他设备的旁聊也会同步过来」;见
+ 开关,默认关;「关:旁聊只留在本机。开:本机的旁聊上传到账号,其他设备的旁聊也会同步过来」;见
[every-device.md](every-device.md)),「帮助改进 nanoMuse 的 AI 模型」(中继的开关,带它保存了
多少轮和「删除」),和其他每个应用一样;然后是「在这台电脑上」:**导入记忆**、**下载你的智能体
- 数据**(一个 zip 放到「下载」里——不含登录 token 的账号快照、形象和脸、房间、记忆,以及 harness
+ 数据**(一个 zip 放到「下载」里:不含登录 token 的账号快照、形象和脸、房间、记忆,以及 harness
保存形式的聊天;在文件管理器里显示出来)和**重置**(记忆、房间和本地的形象清掉,账号退出;聊天
保留)。
-- **帮助与支持**——文档、网站、讨论区、报告问题、版本。
-- **法律信息**——许可证、Meta 商标声明、致谢(DeepSeek Harness、OpenMinis)和隐私政策(`docs/privacy.md`,各端链接的同一页)。
-- **高级**——其他插件注册的每一页(harness 的插件管理器、已归档的会话……),集中放在底部,在那里
+- **帮助与支持**:文档、网站、讨论区、报告问题、版本。
+- **法律信息**:许可证、Meta 商标声明、致谢(DeepSeek Harness、OpenMinis)和隐私政策(各端链接的同一页)。
+- **高级**:其他插件注册的每一页(harness 的插件管理器、已归档的会话……),集中放在底部,在那里
但不碍事。
- 登录时底部有**退出登录**。退出登录(或「重置」)把窗口带回欢迎页,和新装时一样。
-钱包、安全存储和消息渠道其实不存在:Cloud 没有钱包——会员有的是额度([cloud.md](cloud.md#allowance))——
+钱包、安全存储和消息渠道其实不存在:Cloud 没有钱包;会员有的是额度([cloud.md](cloud.md#allowance))。
nanoMuse 不替智能体保存任何密码,消息通过手机 App 的通知到你手里,而不是通过某个聊天软件。
-## 侧栏上的其他房间——动态、点子、目标、资源库 {#the-rail-s-other-rooms-—-feed-ideas-goals-library}
+## 侧栏上的其他房间:动态、点子、目标、资源库 {#the-rail-s-other-rooms-feed-ideas-goals-library}
**Muse。** 侧栏上还有四个房间:智能体发帖的*动态*(带图的摘要,赞 / 讨论,一段用来引导后续帖子的
文字);*点子*,按生活领域给出的主动建议,每条是一张卡片,写着为什么、包含什么、怎么运作、「现在
开始」;*目标*,带分类、运行中的自动化(类似 cron)和一条带日期的时间线;*资源库*,所有生成出来的
-东西——文档(一个 Markdown 编辑器)、网页构件、图片、视频、播客、系统文件。
+东西:文档(一个 Markdown 编辑器)、网页构件、图片、视频、播客、系统文件。
**dsh 上的 nanoMuse。** 四个房间都在侧栏上,按 Muse 的顺序(聊天、搜索、动态、点子、目标、资源库、
设备),每个都是 harness 布局里的一个 `main` 面板,所以聊天列留在原处,房间在它的位置上打开。一个
宿主服务(`nanomuse-rooms`,`src/rooms.ts`)把它们存在 `$DSH_HOME/nanomuse/rooms.json`,提供
`/nanomuse/rooms/*`,并把变化流式推给浏览器这一半:
-- **动态**——智能体在一个隐藏的聊天里写给你的帖子,依据是你的*动态偏好*、你的目标和资料:标题、
+- **动态**,即智能体在一个隐藏的聊天里写给你的帖子,依据是你的*动态偏好*、你的目标和资料:标题、
带行内链接的 Markdown 正文、它读的页面有图时的一张图、♡ 和**讨论**(在这条帖子上开一场旁聊)。
- 房间打开时先是手机的介绍卡片(「关于动态」——帖子由什么写成、「编辑偏好」、「知道了」);空着的时候
+ 房间打开时先是手机的介绍卡片(「关于动态」:帖子由什么写成、「编辑偏好」、「知道了」);空着的时候
文字会说每日例程什么时候跑(「每天 08:00,我会读一遍记得的关于你的东西……」),带**现在写一批**;
第一天在第一场对话结束时写出来,每日例程(08:00,一个可以在滑杆下面挪时间或关掉的日程)在房间
第一次打开时创建。侧栏上的一个点标记比你上次来访更新的帖子。智能体也可以在任何聊天里用
`feed_post` 发帖。
-- **点子**——手机的精选列表(`assets/ideas.{en,zh}.json`,同样的 24 条,分六节:出行、工作、生活、
+- **点子**:手机的精选列表(`assets/ideas.{en,zh}.json`,同样的 24 条,分六节:出行、工作、生活、
学习、家庭与关系、更多点子),每行一个表情、一句第一人称的介绍和一行细节;点开一行是一张表单,
- 说明试一试会创建什么——「开始一场对话」「创建例程 · 08:00」「创建目标」——带**发到聊天**(点子的
+ 说明试一试会创建什么(「开始一场对话」「创建例程 · 08:00」「创建目标」),带**发到聊天**(点子的
文字进入输入框,由你补完)、**创建例程**(在它自己的聊天里按点子的时间建一条每日日程;「例程已
创建,去设定时间和细节」)或**开始这个目标**(目标对话,用这条点子起头);试过的点子得到一个绿色
对勾,⋯ 可以隐藏一条。
-- **目标**——「● 追踪」列表,带分类(健康、人际关系、金融、职业、兴趣、效率提升)、智能体的一行
- 状态、它汇报过进度之后出现的进度条,以及一个「需要留意」标记。**创建目标 › <分类>**是手机的流程:
+- **目标**:「● 追踪」列表,带分类(健康、人际关系、金融、职业、兴趣、效率提升)、智能体的一行
+ 状态、它汇报过进度之后出现的进度条,以及一个「需要留意」标记。「创建目标 › <分类>」是手机的流程:
「我想创建一个健康方面的目标。」进入你所在的聊天(它忙着时就开一个新的),带着手机的塑形提示;
- 智能体最多问三个简短的问题,一次一个——做什么、为什么、什么时候之前,多久检查一次——然后写一个
+ 智能体最多问三个简短的问题,一次一个(做什么、为什么、什么时候之前,多久检查一次),然后写一个
`nanomuse-goal` 围栏,聊天把它显示成一张「目标已创建」卡片(标题、为什么、步骤、「每天 09:00
检查」/「每 N 小时检查一次」、「在「目标」中查看」),宿主把它变成目标:那场聊天成了它的家,并用 harness
- 的日程插件在那里排一次检查(「该看看这个目标了:…」、「第一次:…」)。每次检查——以及任何进度变了
- 的一轮——都以一个 `nanomuse-goal-update` 围栏结束(`goal_id`、`progress`、`status` 取 on_track /
+ 的日程插件在那里排一次检查(「该看看这个目标了:…」、「第一次:…」)。每次检查(以及任何进度变了
+ 的一轮)都以一个 `nanomuse-goal-update` 围栏结束(`goal_id`、`progress`、`status` 取 on_track /
attention / done、`note`):聊天里一张「目标进展」卡片,带进度条和备注,目标的状态、进度和时间线
跟着更新;智能体也可以调用 `goals_room_update`。菜单里的「改为直接写下目标…」照样用你自己的话
创建目标;「问问进展」现在就跑一次检查。
-- **资源库**——几个书架(全部、文档、网页;影音内容:图片、视频、播客;底部是系统文件),「选择」、
- 「+ 创建…」(一份简介 → 一场把文件写到 `~/nanoMuse/Library` 下的聊天——中文界面里叫 `构件`——用
+- **资源库**:几个书架(全部、文档、网页;影音内容:图片、视频、播客;底部是系统文件),「选择」、
+ 「+ 创建…」(一份简介 → 一场把文件写到 `~/nanoMuse/Library` 下的聊天,中文界面里叫 `构件`,用
workspace-write 预设)、「最近」和一个卡片网格;文字有查看器和 Markdown 编辑器(把 IDENTITY.md 或
SOUL.md 恢复成原始文本前会先问一句),图片和影音内容就地显示,其余用「打开」/「在文件夹中显示」。智能体用 `present` 交付的一切都落在这里,`library_add`
则只登记一个文件而不交付。
@@ -422,9 +444,9 @@ nanoMuse 不替智能体保存任何密码,消息通过手机 App 的通知到
**dsh 上的 nanoMuse。** 设置(带快捷键;有新版本时它和 ••• 按钮上都有一个点)、有新版本时的
**更新到 0.1.x**(打开发布页)、**关于 nanoMuse**(版本行「nanoMuse 桌面版 · harness 」、
许可证、GitHub / Releases / 文档 / Issues)、键盘快捷键、日程和 harness 的其他面板(插件,以及装了
-的别的什么),收起 / 展开聊天列,「报告问题」——在桌面外壳下,窗口的一张截图存到「下载」,GitHub
+的别的什么),收起 / 展开聊天列,「报告问题」:在桌面外壳下,窗口的一张截图存到「下载」,GitHub
的 issue 页面打开,构建的信息已经填好(一条提示告诉你拖哪个文件进去);在浏览器里则打开 issues
-页面。dsh 有而 Muse 没有的一切——插件管理器、面板列表、已归档的会话、开发者工具——都从这里或
+页面。dsh 有而 Muse 没有的一切(插件管理器、面板列表、已归档的会话、开发者工具)都从这里或
「设置 → 高级」进,别处没有。
## Muse 没有、nanoMuse 有的 {#not-in-muse-in-nanomuse}
@@ -439,13 +461,15 @@ nanoMuse 不替智能体保存任何密码,消息通过手机 App 的通知到
模型一变就重启它。自己的模型在某一轮失败时,卡片给出「这次改用 nanoMuse Cloud」(已登录时):这一条
消息再走一次账号,这一轮结束后对话回到自己的模型,模型页保持原样。不会自动回退。
- **每台设备上都有的连接器。** 你在这里连上的东西(服务、名称、自定义 MCP 的 URL、登录方式、哪台
- 设备)发布到账号的资料里(契约 C3;永远不含 token 或密钥——宿主拒绝任何名字像它们的字段),所以
+ 设备)发布到账号的资料里(契约 C3;永远不含 token 或密钥:宿主拒绝任何名字像它们的字段),所以
手机和其他电脑在「在你的其他设备上」下列出它,带**连接**,这台电脑也列出它们的。
- **按地区排列的模型获取方式。** 界面是中文、用手机号登录,或者中继账号在中国大陆时,账号页先列
百炼,再列 OpenRouter;其他地区反过来,每个都带「去拿 key」。
-- **更新。** 每天一次,以及在「简介」里点「检查更新」时,宿主读 GitHub 的 releases API,然后比较版本;
+- **更新。** 每天一次,以及在「简介」里点「检查更新」时,宿主读这个仓库的 GitHub Releases API
+ (这个 fork 自己的发布,配置过的镜像索引作为回退),然后比较版本;
有更新的就显示为一个点、一个「更新到 0.1.x」菜单项,以及「简介」里针对这个平台安装包的「下载」
- 按钮(Linux 上是 `.deb`;正在运行的是 AppImage 时则是 AppImage)。
+ 按钮(Linux 上是 `.deb`;正在运行的是 AppImage 时则是 AppImage)。检查结果保留一天,并按当前运行的
+ 版本重新读取,所以刚装上的更新不会被再推荐给它自己。
- **开源,没有闭源组件。** 插件包是 GPL-3.0-or-later;harness 是 MIT,带着它的声明一起走;「简介」
那一行写明了这一点。
diff --git a/docs/zh/desktop.md b/docs/zh/desktop.md
index 789c9426..ed5fb9d2 100644
--- a/docs/zh/desktop.md
+++ b/docs/zh/desktop.md
@@ -38,6 +38,10 @@ harness 构建以 *nanoMuse Harness* 的名字另放在旁边;从 0.1.30 起
邀请码、按类型和按模型的用量、密码、持有 key 的设备、时间线、注销——通过 Host 的透传路由
读出来(`/nanomuse/cloud/me`、`/sessions`、`/account-events`、`/password`、`/sign-out-all`、
`/delete-account`、`/config`);
+- **一段你自己的视频当形象**:智能体页面钢笔菜单和形象工作室里的「你自己的视频…」接受一段
+ MP4 或 MOV(H.264 视频,最长 15 秒、最大 30 MB),先在形象的圆形框里预览,再在所有状态下
+ 静音循环播放;视频只保存在这台电脑的 `$DSH_HOME/nanomuse/avatar/own-clip.mp4`,不会发给
+ nanoMuse Cloud,「默认形象」可移除(回退规则见 [avatar.md](avatar.md));
- **手(Hands)**,在这台电脑上:自带运行时的 `nanomuse mcp` 走 stdio,运行时的
`computer_screen` 和 `computer_act` 工具连同它们的审批一起接进来,所以「我屏幕上是什么?」
和「打开设置把音量调小」开箱就能用。鼠标、键盘和截图是应用自己的(`src/operator.ts`,
diff --git a/docs/zh/every-device.md b/docs/zh/every-device.md
index af9291dc..964c0518 100644
--- a/docs/zh/every-device.md
+++ b/docs/zh/every-device.md
@@ -1,16 +1,16 @@
# 每一台设备
-定义 nanoMuse 的第四件事([路线图](roadmap.md)):一个智能体,而你拥有的每一台设备都是它的一双手、一扇门。这一页是这个阶段的设计——每台设备上的 App 长什么样,每一台自己能做什么、能替其他设备做什么,一个任务怎么流转,各部分分别由哪些现成的东西拼起来。协议本身在 [hub.md](hub.md);这一页讲的是产品的形状,以及围绕它的代码。
+定义 nanoMuse 的第四件事([路线图](roadmap.md)):一个智能体,而你拥有的每一台设备都是它的一双手、一扇门。这一页是这个阶段的设计:每台设备上的 App 长什么样,每一台自己能做什么、能替其他设备做什么,一个任务怎么流转,各部分分别由哪些现成的东西拼起来。协议本身在 [hub.md](hub.md);这一页讲的是产品的形状,以及围绕它的代码。
## 每台设备一个 Muse,同一个形状 {#one-muse-per-device-one-shape}
-每台设备跑着自己的 Muse——手机上是基于 OpenMinis 的智能体,电脑上是带着手的 Python 运行时的 nanoMuse 桌面版——而每一台显示的都是同一个 App。Android 版是参照;其他版本跟的是它的形状,不是它的像素:
+每台设备跑着自己的 Muse(手机上是基于 OpenMinis 的智能体,电脑上是带着手的 Python 运行时的 nanoMuse 桌面版),而每一台显示的都是同一个 App。Android 版是参照;其他版本跟的是它的形状,不是它的像素:
| 每台设备上都有 | Android 现状 | 电脑(这个阶段) |
|---|---|---|
| 对话顶部一张脸、一个名字胶囊、一行状态 | `HomeShell`、`AgentAvatarDisc` | 网页版的聊天头部,默认是小龙 |
| 一个主要聊天,旁聊收在抽屉里 | 抽屉 | 窄窗口用抽屉,宽窗口用侧栏 |
-| 动态 · 点子 · 目标 · 资源库 | 底栏 | 底栏 / 侧栏 |
+| 动态 · 点子 · 目标 · 资源库 | 悬浮的毛玻璃胶囊栏,只有图标 | 手机宽度下是同一枚胶囊栏(网页版和 iOS 一样),宽窗口和桌面应用里是侧栏 |
| 聊天里的审批卡片,三个层级,「记住」胶囊 | `RiskApprovalCard`、`RiskTier` | 哨兵(Sentinel)的卡片,标签相同 |
| 设置 → 权限:记住了什么,按层级列出 | `GrantsSection` | 权限页,按层级列出授权 |
| 设置 → Hands:把屏幕当手,默认关 | `Hands` | 设置 → Hands(这台电脑的屏幕) |
@@ -22,11 +22,11 @@
每台设备同时扮演这两个角色。
-**它自己就是一个智能体。** 它在每台设备上爬的都是同一道阶梯([gui.md](gui.md)):先是技能、CLI 或 MCP 服务器;再是用你的登录态抓一个网页;然后是浏览器;最后——在你允许的前提下——才是这台设备自己的屏幕:看一张截图,决定一个动作,做掉,再看。在手机上,shell 是 App 的 Linux 沙箱,屏幕就是「手」。在电脑上,shell 是真正的 shell(运行时能加沙箱的地方就加沙箱),浏览器是 Playwright 或系统浏览器,屏幕是「这台电脑上的手」:`computer_screen`、`computer_act`、`computer_task`——手机操作器的那套循环,底下换成一台电脑。一次点击按光标下方的文字(`label`)来评估,和一次点按完全一样;同样的那些词(支付、转账、发送、删除……)会让它成为最高层级的审批,任何长期授权都盖不住。
+**它自己就是一个智能体。** 它在每台设备上爬的都是同一道阶梯([gui.md](gui.md)):先是技能、CLI 或 MCP 服务器;再是用你的登录态抓一个网页;然后是浏览器;最后(在你允许的前提下)才是这台设备自己的屏幕:看一张截图,决定一个动作,做掉,再看。在手机上,shell 是 App 的 Linux 沙箱,屏幕就是「手」。在电脑上,shell 是真正的 shell(运行时能加沙箱的地方就加沙箱),浏览器是 Playwright 或系统浏览器,屏幕是「这台电脑上的手」:`computer_screen`、`computer_act`、`computer_task`,手机操作器的那套循环,底下换成一台电脑。一次点击按光标下方的文字(`label`)来评估,和一次点按完全一样;同样的那些词(支付、转账、发送、删除……)会让它成为最高层级的审批,任何长期授权都盖不住。
-**替其他设备当手、当门。** 登录同一个账号后,设备们在 hub(设备互联)上相遇,每一台把自己能做的事暴露为动作(`shell`、`files`、`file.get`、`file.put`、`open`、`screen`、`notify`),也可以作为整个任务的目标(`task`)。任何一台设备的智能体都把其他设备当工具用:`devices`、`device_shell`、`device_files`、`device_get`、`device_put`、`device_open`、`device_screen`、`device_notify`,还有 `delegate`——用一句话把活交给另一台设备的 Muse。所以「在我手机上找到订单号,填进我 Mac 上的表格」是一场对话就能做完的事:手机的 Muse 用「手」找到它,Mac 的 Muse 把它填进文件,而提问的人在自己打字的那个聊天里看到两头。
+**替其他设备当手、当门。** 登录同一个账号后,设备们在 hub(设备互联)上相遇,每一台把自己能做的事暴露为动作(`shell`、`files`、`file.get`、`file.put`、`open`、`screen`、`notify`),也可以作为整个任务的目标(`task`)。任何一台设备的智能体都把其他设备当工具用:`devices`、`device_shell`、`device_files`、`device_get`、`device_put`、`device_open`、`device_screen`、`device_notify`,还有 `delegate`,用一句话把活交给另一台设备的 Muse。所以「在我手机上找到订单号,填进我 Mac 上的表格」是一场对话就能做完的事:手机的 Muse 用「手」找到它,Mac 的 Muse 把它填进文件,而提问的人在自己打字的那个聊天里看到两头。
-一台设备自己决定让其他设备做什么:「远程控制」关着时,它只回答 `info`,别的都不答,但它自己照样能驱动其他设备。开着时,*在*这台设备跟前的人仍然要先同意,另一台设备才能在这里运行、读或写东西——那块屏幕上弹一张卡片,「允许一次」或「对这台设备总是允许」(一条长期授权,和其他授权一样列在「权限」下,可以在那里撤销)。`task` 则由这台设备自己的哨兵把关;`notify` 从不询问。在 nanoMuse 桌面版上,这个开关默认关,意思是「每台设备都要在这里问一下」;打开后,账号下的每台设备都直接通过,不再问。
+一台设备自己决定让其他设备做什么:「远程控制」关着时,它只回答 `info`,别的都不答,但它自己照样能驱动其他设备。开着时,*在*这台设备跟前的人仍然要先同意,另一台设备才能在这里运行、读或写东西:那块屏幕上弹一张卡片,「允许一次」或「对这台设备总是允许」(一条长期授权,和其他授权一样列在「权限」下,可以在那里撤销)。`task` 则由这台设备自己的哨兵把关;`notify` 从不询问。在 nanoMuse 桌面版上,这个开关默认关,意思是「每台设备都要在这里问一下」;打开后,账号下的每台设备都直接通过,不再问。
## 一个任务怎么流转 {#how-a-task-travels}
@@ -49,31 +49,31 @@ you, on the phone ──"编译一下项目,把日志发我"──▶ phone's
## 到哪里都是同样的对话 {#the-same-conversations-everywhere}
-从 0.1.36 起,聊天本身跟着账号走。登录后,每台设备把自己回合的文字发到中继的同步存储([cloud.md](cloud.md#conversation-sync)),再拉回其他设备发上来的——启动时、hub 说 `sync` 时、以及每分钟一次——这样早上在手机上开始的一场聊天,到了书桌前就在电脑上,在那里接着聊,回合就在*那台*设备上跑,以同步来的记录作为历史;它回答的内容再沿同一条路回去。发给某台设备的聊天,或者在这台设备上替另一台跑的聊天,留在原地。在任何地方重命名或删除一场聊天,各处都跟着改名或删除。只同步文字:文件和图片留在产生它们的设备上,同步过来的消息只显示它们的名字和大小。
+从 0.1.36 起,聊天本身跟着账号走。登录后,每台设备把自己回合的文字发到中继的同步存储([cloud.md](cloud.md#conversation-sync)),再拉回其他设备发上来的(启动时、hub 说 `sync` 时、以及每分钟一次),这样早上在手机上开始的一场聊天,到了书桌前就在电脑上,在那里接着聊,回合就在*那台*设备上跑,以同步来的记录作为历史;它回答的内容再沿同一条路回去。发给某台设备的聊天,或者在这台设备上替另一台跑的聊天,留在原地。在任何地方重命名或删除一场聊天,各处都跟着改名或删除。只同步文字:文件和图片留在产生它们的设备上,同步过来的消息只显示它们的名字和大小。
-从 0.1.37 起是**一条线程**。主要聊天是跨所有设备的同一场对话:你在手机上对 Muse 说的话,会出现在电脑和网页版的主要聊天里,按时间顺序排在那边说过的话中间,是一个只读气泡,下面标着「来自 Pixel 8」——而 Muse 无论接下来在哪里回答,都已经读过它。另一台设备的旁聊从被拉取的那一刻起就是这台设备上的一场聊天,带着标题,在这里以同一场对话继续;聊天列表里不会标明一场聊天是在哪里写的,气泡会。人的消息一发出就到了其他设备上,回复在回合结束时到;登录会把这台设备的全部历史发上去,最早的在前。在任何地方给 Muse 改名——包括第一场对话里的起名——下次拉取时名字就跟过来。
+从 0.1.37 起是**一条线程**。主要聊天是跨所有设备的同一场对话:你在手机上对 Muse 说的话,会出现在电脑和网页版的主要聊天里,按时间顺序排在那边说过的话中间,是一个只读气泡,下面标着「来自 Pixel 8」;而 Muse 无论接下来在哪里回答,都已经读过它。另一台设备的旁聊从被拉取的那一刻起就是这台设备上的一场聊天,带着标题,在这里以同一场对话继续;聊天列表里不会标明一场聊天是在哪里写的,气泡会。人的消息一发出就到了其他设备上,回复在回合结束时到;登录会把这台设备的全部历史发上去,最早的在前。在任何地方给 Muse 改名(包括第一场对话里的起名),下次拉取时名字就跟过来。
-从 0.1.38 起是**主要聊天优先**。除非设备另外要求,否则只有主要对话在设备间流动:旁聊留在创建它的设备上,别的设备的旁聊也不会过来。「数据控制」里同步开关下面的「同时同步旁聊」按设备设置,默认关——*关:旁聊只留在这台设备。开:这台设备的旁聊进入账号,其他设备的旁聊到这里来。*打开它会把这台设备的旁聊发上去,并从头把其他设备的拉下来一次。新登录先拉最新的 300 条消息,这样很长的历史一下子就能读,而不是从最早的开始慢慢到。另一台设备正在回答时,聊天最后一条消息下面显示「kwai 正在处理…」——一条在场提示,中继只转发、从不存储;回复到了、设备说做完了、或者过了十分钟,它就消失。从另一台设备来的消息永远不会显示为被打断,也永远不提供「继续」——只有跑那个回合的设备知道它是怎么结束的。
+从 0.1.38 起是**主要聊天优先**。除非设备另外要求,否则只有主要对话在设备间流动:旁聊留在创建它的设备上,别的设备的旁聊也不会过来。「数据控制」里同步开关下面的「同时同步旁聊」按设备设置,默认关。*关:旁聊只留在这台设备。开:这台设备的旁聊进入账号,其他设备的旁聊到这里来*。打开它会把这台设备的旁聊发上去,并从头把其他设备的拉下来一次。新登录先拉最新的 300 条消息,这样很长的历史一下子就能读,而不是从最早的开始慢慢到。另一台设备正在回答时,聊天最后一条消息下面显示「kwai 正在处理…」,一条在场提示,中继只转发、从不存储;回复到了、设备说做完了、或者过了十分钟,它就消失。从另一台设备来的消息永远不会显示为被打断,也永远不提供「继续」:只有跑那个回合的设备知道它是怎么结束的。
-开关在「设置 → 数据控制 → 在我的设备之间同步对话」,默认开;关掉会让中继删除这个账号的存储,其他每台设备的开关也跟着关。「删除已同步的对话」清空存储,但保留开关。手机和网页版把每一场本地聊天映射到一个对话 id,把其他设备的改动直接套进自己的聊天列表。**nanoMuse 桌面版(dsh 插件)**把另一台设备的回合放在自己的存储里(`$DSH_HOME/nanomuse/sync-remote.json`,按会话——dsh 的会话日志只能追加,归它的智能体循环所有,在回合之外写进去的一行既不会显示也不安全),在浏览器里显示为另一台设备的气泡(`client/RemoteBubbles.ts`),按时间排在这里打的回合之间,并在模型下一步之前把到达的内容作为一条备注交给它(`agent.inject`;这条备注只是上下文,不是一行记录,也永远不会被推回去)。从中继拉下来的旁聊会立刻得到自己的 dsh 会话(列在「旁聊」里,尽管这里还没跑过回合);主要聊天就是账号的主要对话所在的那个会话,账号已经有主要对话时,第一场对话就在它里面开始。harness 没有删除功能,日志什么都不忘:在别处删掉的消息在这里隐藏,在别处删掉的聊天在这里归档。从 0.1.38 起,插件自己保存「同时同步旁聊」的状态(默认关;关着时,这里的旁聊既不列出也不读来推送,仍然到达的旁聊行也不会生成会话),向中继请求 `scope=main`,并在第一次拉取和旁聊开关打开时请求尾部;另一台设备的「kwai 正在处理…」那一行,根据 hub 的 `working` 帧画在它最后一个气泡下面,由它的回复、`working: false` 或十分钟后清掉——远端的行从不进会话日志,所以 harness 不会把它误当成一个没结束的回合。宿主每次变化只读一遍会话日志(标题、行和提示位置一直保留到该会话的下一个事件),并把一连串 `sync` 帧合并成每秒一次拉取,所以一个大账号在空闲时对聊天没有任何开销。代码:运行时的 [`nanomuse/sync/`](../../nanomuse/sync/)(`ConversationSync`,`/api/sync/*` 见 [app.md](app.md#api)),网页版的 `SyncControls` 和 `web/src/` 里的消息说明文字,插件的 `src/sync.ts`、`client/RemoteBubbles.ts` 和 `tests/sync.test.mjs`,中继的 [`cloud/nanomuse_cloud/sync.py`](../../cloud/nanomuse_cloud/sync.py)。
+开关在「设置 → 数据控制 → 在我的设备之间同步对话」,默认开;关掉会让中继删除这个账号的存储,其他每台设备的开关也跟着关。「删除已同步的对话」清空存储,但保留开关。手机和网页版把每一场本地聊天映射到一个对话 id,把其他设备的改动直接套进自己的聊天列表。**nanoMuse 桌面版**(dsh 插件)把另一台设备的回合放在自己的存储里(`$DSH_HOME/nanomuse/sync-remote.json`,按会话;dsh 的会话日志只能追加,归它的智能体循环所有,在回合之外写进去的一行既不会显示也不安全),在浏览器里显示为另一台设备的气泡(`client/RemoteBubbles.ts`),按时间排在这里打的回合之间,并在模型下一步之前把到达的内容作为一条备注交给它(`agent.inject`;这条备注只是上下文,不是一行记录,也永远不会被推回去)。从中继拉下来的旁聊会立刻得到自己的 dsh 会话(列在「旁聊」里,尽管这里还没跑过回合);主要聊天就是账号的主要对话所在的那个会话,账号已经有主要对话时,第一场对话就在它里面开始。harness 没有删除功能,日志什么都不忘:在别处删掉的消息在这里隐藏,在别处删掉的聊天在这里归档。从 0.1.38 起,插件自己保存「同时同步旁聊」的状态(默认关;关着时,这里的旁聊既不列出也不读来推送,仍然到达的旁聊行也不会生成会话),向中继请求 `scope=main`,并在第一次拉取和旁聊开关打开时请求尾部;另一台设备的「kwai 正在处理…」那一行,根据 hub 的 `working` 帧画在它最后一个气泡下面,由它的回复、`working: false` 或十分钟后清掉;远端的行从不进会话日志,所以 harness 不会把它误当成一个没结束的回合。宿主每次变化只读一遍会话日志(标题、行和提示位置一直保留到该会话的下一个事件),并把一连串 `sync` 帧合并成每秒一次拉取,所以一个大账号在空闲时对聊天没有任何开销。代码:运行时的 [`nanomuse/sync/`](../../nanomuse/sync/)(`ConversationSync`,`/api/sync/*` 见 [app.md](app.md#api)),网页版的 `SyncControls` 和 `web/src/` 里的消息说明文字,插件的 `src/sync.ts`、`client/RemoteBubbles.ts` 和 `tests/sync.test.mjs`,中继的 [`cloud/nanomuse_cloud/sync.py`](../../cloud/nanomuse_cloud/sync.py)。
-**在另一台设备上干活。** 默认永远是你正在打字的这台设备。想把某一条消息发去别处,就以 `@` 加上 hub 列出的设备名开头——`@Desk compile the project and send me the log`——前缀匹配,不分大小写;输入框会在你打字时提示这些名字,提及词从正文里摘掉,气泡上写「发给 Desk」,回合前面加一行备注,告诉这台设备的智能体用 `delegate`(手机上是 `nanomuse-pc task … --on`)把它交给那台设备,并汇报它做了什么;设备页上的「交给这台设备」做的是同一件事。iPhone 没有 delegate 工具,提及词直接发到 hub——给那台设备发 `task`,它的答复作为聊天里的一个回合显示——而且只匹配在线的设备。哪些设备可以作为目标:**电脑**——nanoMuse 桌面版、运行时——只要在线就行,因为它们的 hub 连接一直保持着;**手机**——Android 和 iPhone——只在 nanoMuse App 打开时可以,因为连接活在 App 里,两个平台都不在后台保持它。不在线的设备在网页版和桌面版上照样作为目标提供,回答 `device_offline`;网页控制台永远不是目标。一台设备替另一台跑的会话保存在那台设备上,不同步。
+**在另一台设备上干活。** 默认永远是你正在打字的这台设备。想把某一条消息发去别处,就以 `@` 加上 hub 列出的设备名开头(`@Desk compile the project and send me the log`),前缀匹配,不分大小写;输入框会在你打字时提示这些名字,提及词从正文里摘掉,气泡上写「发给 Desk」,回合前面加一行备注,告诉这台设备的智能体用 `delegate`(手机上是 `nanomuse-pc task … --on`)把它交给那台设备,并汇报它做了什么;设备页上的「交给这台设备」做的是同一件事。iPhone 没有 delegate 工具,提及词直接发到 hub(给那台设备发 `task`,它的答复作为聊天里的一个回合显示),而且只匹配在线的设备。哪些设备可以作为目标:**电脑**(nanoMuse 桌面版、运行时)只要在线就行,因为它们的 hub 连接一直保持着;**手机**(Android 和 iPhone)只在 nanoMuse App 打开时可以,因为连接活在 App 里,两个平台都不在后台保持它。不在线的设备在网页版和桌面版上照样作为目标提供,回答 `device_offline`;网页控制台永远不是目标。一台设备替另一台跑的会话保存在那台设备上,不同步。
## 一台设备显示谁的对话 {#whose-conversations-a-device-shows}
-一台设备今天可以登录一个账号,明天换另一个——家里共用的平板,装了个人账号的工作笔记本,转手的手机。从 0.1.39 起,设备上的每一场对话都记得它是**谁的**:第一次上传到中继或第一次从中继到达时登录着的那个账号。于是手机、电脑、桌面版和网页控制台上的规则是一样的:
+一台设备今天可以登录一个账号,明天换另一个:家里共用的平板,装了个人账号的工作笔记本,转手的手机。从 0.1.39 起,设备上的每一场对话都记得它是**谁的**:第一次上传到中继或第一次从中继到达时登录着的那个账号。于是手机、电脑、桌面版和网页控制台上的规则是一样的:
-- **以 B 登录,就看到 B 的对话**——以及不属于任何人的那些:在登出状态下、这台设备上还没有任何人登录过时创建的聊天。A 的聊天不在列表里。
+- **以 B 登录,就看到 B 的对话**,以及不属于任何人的那些:在登出状态下、这台设备上还没有任何人登录过时创建的聊天。A 的聊天不在列表里。
- **A 的聊天是隐藏,不是删除。** 它们连同文件和图片留在设备上,A 再登录的那一刻就回来。单纯退出登录什么都不隐藏;删除一场聊天仍然是删除。
-- **账号之间什么都不互通。** A 的对话永远不会上传到 B 的账号,B 的对话也永远不会落进 A 的——同步不会,在这台设备上替账号的另一台设备跑的回合也不会。
+- **账号之间什么都不互通。** A 的对话永远不会上传到 B 的账号,B 的对话也永远不会落进 A 的:同步不会,在这台设备上替账号的另一台设备跑的回合也不会。
- **换一个账号就从头开始。** 登录的账号变了,设备就忘记自己在另一个账号的同步里走到了哪(下次拉取是最新的 300 行,和第一次登录一样),清掉保存的其他设备的气泡和在场提示,重新读取新账号的设备列表。切回 A,就接上 A 的聊天和 A 在同步里的位置。
- **中继不参与。** 它像一直以来那样把每个账号的存储分开;归类在设备上做,所以旧版中继的行为也一样。
-Muse 的名字和外观照旧跟着账号走。从 0.1.40 起,手机走得更远(约定 C12):每一场聊天都有主人——从没同步过的聊天也算——退出登录会把账号的聊天、记忆、动态、目标、例程和形象从手机上拿走,除非打开了「把这个账号的聊天留在这台设备上」;删除账号会移除全部;被中继拒绝的 key 会把这些东西放到一边,等账号回来。每一项状态的表格在 [sync.md](sync.md)。在桌面版和网页控制台上,上面 0.1.39 的规则仍然有效,记忆文件按设备存放。代码:运行时的 `nanomuse/sync/engine.py` 和 `nanomuse/server/api.py` 里的会话列表,桌面版的 `harness/dsh-nanomuse/src/{sync,cloud}.ts`,Android 的 `io.github.nanomuse.sync.*`,iPhone 的 `NanoMuse/NanoMuseSync.swift`。
+Muse 的名字和外观照旧跟着账号走。从 0.1.40 起,手机走得更远(约定 C12):每一场聊天都有主人(从没同步过的聊天也算),退出登录会把账号的聊天、记忆、动态、目标、例程和形象从手机上拿走,除非打开了「把这个账号的聊天留在这台设备上」;删除账号会移除全部;被中继拒绝的 key 会把这些东西放到一边,等账号回来。每一项状态的表格在 [sync.md](sync.md)。在桌面版和网页控制台上,上面 0.1.39 的规则仍然有效,记忆文件按设备存放。代码:运行时的 `nanomuse/sync/engine.py` 和 `nanomuse/server/api.py` 里的会话列表,桌面版的 `harness/dsh-nanomuse/src/{sync,cloud}.ts`,Android 的 `io.github.nanomuse.sync.*`,iPhone 的 `NanoMuse/NanoMuseSync.swift`。
## 电脑:nanoMuse 桌面版 {#the-computer-nanomuse-desktop}
-桌面端就是 Python 运行时(`nanomuse serve`),在同一个本地服务上开了三扇门——终端(`nanomuse chat`)、浏览器(它托管的网页版,同一网络下的手机也能打开)和一个窗口。这是 OpenCode v2 和 Codex 采用的形状(一个本地服务,上面架一个 CLI 和一个桌面 App),也是运行时本来就有的样子;这个阶段给它加上 hub、Cloud 账号和手。
+桌面端就是 Python 运行时(`nanomuse serve`),在同一个本地服务上开了三扇门:终端(`nanomuse chat`)、浏览器(它托管的网页版,同一网络下的手机也能打开)和一个窗口。这是 OpenCode v2 和 Codex 采用的形状(一个本地服务,上面架一个 CLI 和一个桌面 App),也是运行时本来就有的样子;这个阶段给它加上 hub、Cloud 账号和手。
```
nanomuse serve ─ FastAPI + WebSocket, 127.0.0.1:8787 ─┬─ web/ (React) in a browser or on the phone
@@ -83,30 +83,30 @@ Muse 的名字和外观照旧跟着账号走。从 0.1.40 起,手机走得更
└ nanomuse/cloud ── the nanoMuse Cloud account (e-mail code → key → models + hub)
```
-- **运行时**(`nanomuse/`):`cloud.py` 用邮箱(或手机)验证码登录 nanoMuse Cloud,把 key 存进保险库,并且可以让中继当模型服务商——和手机上一样的「登录——免费」第一步。`hub/` 是桌面二进制里 hub 客户端的异步移植:保持一条连接,收到的动作由 `hub/actions.py` 回答,收到的 `task` 在一个看得见的旁聊里跑、审批转发出去,发出的调用藏在 `device_*` 和 `delegate` 工具后面。`computer/` 是这台机器上把屏幕当手的那部分。设置项:`[cloud]`、`[hub]`、`[hands]`;全都可以在 App 里切换。
+- **运行时**(`nanomuse/`):`cloud.py` 用邮箱(或手机)验证码登录 nanoMuse Cloud,把 key 存进保险库,并且可以让中继当模型服务商,和手机上一样的「登录(免费)」第一步。`hub/` 是桌面二进制里 hub 客户端的异步移植:保持一条连接,收到的动作由 `hub/actions.py` 回答,收到的 `task` 在一个看得见的旁聊里跑、审批转发出去,发出的调用藏在 `device_*` 和 `delegate` 工具后面。`computer/` 是这台机器上把屏幕当手的那部分。设置项:`[cloud]`、`[hub]`、`[hands]`;全都可以在 App 里切换。
- **网页版**(`web/`):设备页、发给某台设备的旁聊、审批卡片的层级和「记住」胶囊、带「停止」的「手」卡片、设置 → Hands、第一次打开时和「连接」下的 Cloud 登录、默认形象小龙。构建产物放进 `nanomuse/server/static/`。
-- **窗口**:从 0.1.30 起,窗口是跑在 DeepSeek Harness(`harness/`)上的 [nanoMuse 桌面版](desktop.md):harness 的 Host 和网页版装在我们自己的 Electron 外壳里,nanoMuse 的账号、形象、Hands 和 Reach 作为插件,再捆上这个运行时给手用(`nanomuse mcp`,走 stdio)。原来包着这个运行时和网页版的 Electron 外壳(`desktop/app`,0.1.19–0.1.29)——托盘、画出手即将点击位置那个圈的透明舞台窗口、全局「停止」和快速唤起快捷键、「开机自动启动」——已经退役;这些会在 harness 的接缝上回来([harness.md](harness.md),第 7 阶段)。任何人在浏览器里打开 `nanomuse serve`,网页版在宽窗口上仍然按 Muse 的样子排布。
+- **窗口**:从 0.1.30 起,窗口是跑在 DeepSeek Harness(`harness/`)上的 [nanoMuse 桌面版](desktop.md):harness 的 Host 和网页版装在我们自己的 Electron 外壳里,nanoMuse 的账号、形象、Hands 和 Reach 作为插件,再捆上这个运行时给手用(`nanomuse mcp`,走 stdio)。原来包着这个运行时和网页版的 Electron 外壳(`desktop/app`,0.1.19–0.1.29;托盘、画出手即将点击位置那个圈的透明舞台窗口、全局「停止」和快速唤起快捷键、「开机自动启动」)已经退役;这些会在 harness 的接缝上回来([harness.md](harness.md),第 7 阶段)。任何人在浏览器里打开 `nanomuse serve`,网页版在宽窗口上仍然按 Muse 的样子排布。
- **标准库二进制**(`desktop/nanomuse_desktop`)曾是没有 Python 的机器上免安装的后备方案,也是 `nanomuse/hub` 的源头;0.1.39 去掉了它,因为 nanoMuse 桌面版自带运行时。
### 这台电脑上的手 {#hands-on-this-computer}
`computer_task` 是手机操作器底下换了一台电脑:同样的循环(看,决定一个动作,经哨兵执行,再看),同样的汇报,同样的「停止」。不同的是方言和设备:
-- **方言。** 模型说的是 Qwen 的 `computer_use` 格式——`left_click`、`double_click`、`right_click`、`left_click_drag`、`mouse_move`、`scroll`、`type`、`key`、`wait`,再加 `open`(按名字打开一个应用)、`answer`、`ask_user` 和 `terminate`——坐标用手机那套 0–999 网格,所以一个视觉模型两边通用。回复格式(Thought / Action / 一个 ``)和解析器都是手机的。
+- **方言。** 模型说的是 Qwen 的 `computer_use` 格式(`left_click`、`double_click`、`right_click`、`left_click_drag`、`mouse_move`、`scroll`、`type`、`key`、`wait`,再加 `open`(按名字打开一个应用)、`answer`、`ask_user` 和 `terminate`),坐标用手机那套 0–999 网格,所以一个视觉模型两边通用。回复格式(Thought / Action / 一个 ``)和解析器都是手机的。
- **设备。** 截图走 `mss`(所有平台,按模型需要缩放);动作走 `pyautogui`(`pip install "nanomuse[hands]"`),Linux 上装了 `xdotool` 而没装 pyautogui 时用 `xdotool` 兜底;当前窗口的标题从各平台取(`xdotool` / `osascript` / `GetForegroundWindow`),这样卡片和日志说的是「在 Firefox 里」,而不是「在屏幕上」。
- **权限。** macOS 会要一次「录屏」和「辅助功能」;设置 → Hands 会说明,并打开对应的面板。在桌面版下,这两项都归它的助手「nanoMuse Computer Use」([desktop.md](desktop.md#macos-permissions));单独跑的运行时则算在启动它的那个程序头上(比如终端)。Linux 需要 X11(Wayland 还没有可移植的办法移动指针;页面上会说明)。Windows 什么都不需要。
-- **按应用。** 一场对话里在某个应用中的第一个动作会问:「让 Nova 使用 Safari 吗?」——一次、本次对话,或总是。答案是一条 `computer_app:` 下的哨兵授权,所以设置 → 权限会列出它,也能收回。`[sentinel] mode = "auto"` 像跳过其他询问一样跳过它。
-- **手永远不打**:密码、PIN、卡号、验证码。操作器把屏幕交还给你——一张「轮到你了」卡片,写明原因;你输完,按「完成」,操作器再看一眼。
+- **按应用。** 一场对话里在某个应用中的第一个动作会问「让 Nova 使用 Safari 吗?」:一次、本次对话,或总是。答案是一条 `computer_app:` 下的哨兵授权,所以设置 → 权限会列出它,也能收回。`[sentinel] mode = "auto"` 像跳过其他询问一样跳过它。
+- **手永远不打**:密码、PIN、卡号、验证码。操作器把屏幕交还给你:一张「轮到你了」卡片,写明原因;你输完,按「完成」,操作器再看一眼。
#### 窗口模式(macOS) {#window-mode-macos}
在 Mac 上,手可以只在**一个应用的窗口**里干活,而不是整个屏幕(`[hands] mode`,以及「手」卡片上的「范围」:「自动」/「单个窗口」/「整个屏幕」;`nanomuse/computer/mac_window.py`):
-- 模型看到的画面只有那个窗口——macOS 14 及以上由桌面版的助手「nanoMuse Computer Use」用 ScreenCaptureKit 截取(操作器的 `POST /window`,[gui.md](gui.md#hands-on-the-computer-the-picture-is-the-unit));没有助手时由运行时自己的 `CGWindowListCreateImage` 截取——按模型需要缩放;坐标是这张画面的像素,再映射回窗口在屏幕上的位置;
-- 点击、拖动、滚动和按键投递给该应用的进程(`CGEventPostToPid`),不经过系统光标——鼠标还是你的,期间可以在别的窗口干活;文字以 unicode 键盘事件输入,所以中文和 emoji 都能原样打进去;授予了「辅助功能」时,落点处的按钮通过辅助功能树按下(`AXPress`),而不是模拟一次点击;
+- 模型看到的画面只有那个窗口,macOS 14 及以上由桌面版的助手「nanoMuse Computer Use」用 ScreenCaptureKit 截取(操作器的 `POST /window`,[gui.md](gui.md#hands-on-the-computer-the-picture-is-the-unit)),没有助手时由运行时自己的 `CGWindowListCreateImage` 截取,并按模型需要缩放;坐标是这张画面的像素,再映射回窗口在屏幕上的位置;
+- 点击、拖动、滚动和按键投递给该应用的进程(`CGEventPostToPid`),不经过系统光标,鼠标还是你的,期间可以在别的窗口干活;文字以 unicode 键盘事件输入,所以中文和 emoji 都能原样打进去;授予了「辅助功能」时,落点处的按钮通过辅助功能树按下(`AXPress`),而不是模拟一次点击;
- 智能体用 `computer_act` → `computer_target`(`app`:菜单栏显示的名字,或 bundle id)指定窗口,或者在任何动作上带 `app`;`open_app` 把打开的应用设为目标。「自动」是一有应用被指名就进窗口模式,在那之前用整个屏幕。
-你看到的是:「手」卡片写着「正在 Safari 的窗口里操作;鼠标仍归你用」,桌面舞台把光标画在点击的落点(手的事件带着屏幕像素的 `x`/`y`),审批卡片写明应用的名字。找不到、截不到(助手的「录屏」那一行没开——操作器的 `403`,用它的话说)或驱动不了的窗口,会退回整个屏幕,并在观察里加一句说明;什么都不会停。这一层本身出问题时(助手不在、pyobjc、窗口服务器),「自动」会在这个目标余下的时间里把手停在整个屏幕上,说明一次,等下一个应用被指名时再试窗口;「单个窗口」则照你要的继续尝试——两种情况下「手」卡片的「范围」那一行都会显示原因。Linux 和 Windows 一直用共享的屏幕;`pip install "nanomuse[hands]"` 只在 macOS 上带上 pyobjc 框架。
+你看到的是:「手」卡片写着「正在 Safari 的窗口里操作;鼠标仍归你用」,桌面舞台把光标画在点击的落点(手的事件带着屏幕像素的 `x`/`y`),审批卡片写明应用的名字。找不到、截不到(助手的「录屏」那一行没开:操作器的 `403`,用它的话说)或驱动不了的窗口,会退回整个屏幕,并在观察里加一句说明;什么都不会停。这一层本身出问题时(助手不在、pyobjc、窗口服务器),「自动」会在这个目标余下的时间里把手停在整个屏幕上,说明一次,等下一个应用被指名时再试窗口;「单个窗口」则照你要的继续尝试。两种情况下「手」卡片的「范围」那一行都会显示原因。Linux 和 Windows 一直用共享的屏幕;`pip install "nanomuse[hands]"` 只在 macOS 上带上 pyobjc 框架。
## 手机 {#the-phone}
@@ -114,19 +114,19 @@ Muse 的名字和外观照旧跟着账号走。从 0.1.40 起,手机走得更
## 浏览器:一台模拟手机上的演示 {#the-browser-a-demo-on-a-simulated-phone}
-上游的展示站(nanomuse.cn/web,这个 fork 不托管它)通向:浏览器里的一台模拟手机(MobileGym,nanoMuse App 放在前台——`demo/mobilegym/apps/nanoMuse`),背后是展示站网关为这次访问启动的、访客自己的一份私有 nanoMuse——`ghcr.io/nano-muse/nanomuse` 镜像,跑在一个除了网关没有任何出口的网络里,和展示站的模型对话,访问结束就消失。访客先登录 nanoMuse Cloud(验证码发到手机或邮箱,或用账号密码;`demo/showcase/gateway/showcase_gateway/visitors.py`),这样项目知道是谁在试,而且到安装 App 那天,同一个账号就在那里等着。页面上直说:这是一个演示,离 Android App 还差得远,并告诉你 App 在哪里。首页 上游的首页(nanomuse.cn,这个 fork 不托管它) 把同一个页面放在一个框架里显示(`?embed=1`:没有自己的页头,手机等你点一下才开机,而不是自己亮起来,所以打开首页什么都不会启动)。
+上游的展示站(nanomuse.cn/web,这个 fork 不托管它)通向:浏览器里的一台模拟手机(MobileGym,nanoMuse App 放在前台:`demo/mobilegym/apps/nanoMuse`),背后是展示站网关为这次访问启动的、访客自己的一份私有 nanoMuse,`ghcr.io/nano-muse/nanomuse` 镜像,跑在一个除了网关没有任何出口的网络里,和展示站的模型对话,访问结束就消失。访客先登录 nanoMuse Cloud(验证码发到手机或邮箱,或用账号密码;`demo/showcase/gateway/showcase_gateway/visitors.py`),这样项目知道是谁在试,而且到安装 App 那天,同一个账号就在那里等着。页面上直说:这是一个演示,离 Android App 还差得远,并告诉你 App 在哪里。上游的首页(nanomuse.cn,这个 fork 不托管它)把同一个页面放在一个框架里显示(`?embed=1`,没有自己的页头)。手机会自己开机;背后的 Muse 要等页面有理由认为有人在看(在视野里停留几秒,或者有指针、按键、滚轮的动作)才启动,脚本化的浏览器不会触发,展示站要求的登录之前也不会启动。
-网页版早先的形状——每个 Cloud 账号一份常驻的 Muse,带命名卷,在 hub 上和其他设备一样占一个位置(`accounts.py`,`WEB_ENABLED=1`)——仍然留在网关里,给自己跑网关的人用;项目自己的服务器上从 0.1.26 起关掉了。细节和设置项在 [demo/showcase/README.md](../../demo/showcase/README.md)。
+网页版早先的形状(每个 Cloud 账号一份常驻的 Muse,带命名卷,在 hub 上和其他设备一样占一个位置;`accounts.py`,`WEB_ENABLED=1`)仍然留在网关里,给自己跑网关的人用;项目自己的服务器上从 0.1.26 起关掉了。细节和设置项在 [demo/showcase/README.md](../../demo/showcase/README.md)。
## iOS、网页控制台、眼镜 {#ios-the-web-console-glasses}
-iOS 会说 hub 的话(`info`、`open`、`notify`、`task`),形状从 0.1.34 起就有了([ios.md](ios.md))。云端控制台(`/app/`)是一扇没有自己的手的门,以后也是。眼镜是一句话进、一句话出,手在别处——hub 对它们来说已经够用了。
+iOS 会说 hub 的话(`info`、`open`、`notify`、`task`),形状从 0.1.34 起就有了([ios.md](ios.md))。云端控制台(`/app/`)是一扇没有自己的手的门,以后也是。眼镜是一句话进、一句话出,手在别处;hub 对它们来说已经够用了。
## 复用了什么,从哪里来 {#what-is-reused-and-from-where}
-- **UI-TARS-desktop**(Apache-2.0):操作器的模式——截图进,解析出动作,每种设备一个 `execute`——以及智能体干活时屏幕上的标记。不要它的 Electron 应用,也不要它的模型:它的动作空间是 UI-TARS 模型的,我们的是 Qwen 的 `mobile_use` / `computer_use`。
+- **UI-TARS-desktop**(Apache-2.0):操作器的模式(截图进,解析出动作,每种设备一个 `execute`),以及智能体干活时屏幕上的标记。不要它的 Electron 应用,也不要它的模型:它的动作空间是 UI-TARS 模型的,我们的是 Qwen 的 `mobile_use` / `computer_use`。
- **OpenCode v2**(MIT):桌面端做成一个薄 Electron 外壳,启动一个本地服务,对着它渲染一个网页应用(electron-vite、electron-builder、托盘,以后还有深链接)。不要它的智能体:它是围绕一个项目目录的编程助手;nanoMuse 是围绕一个人的个人智能体。
-- **Codex**(Apache-2.0):CLI 作为通向同一个智能体的正式入口,在终端里审批,命令跑在沙箱里——运行时的 `nanomuse chat` 和哨兵已经是这个形状。
+- **Codex**(Apache-2.0):CLI 作为通向同一个智能体的正式入口,在终端里审批,命令跑在沙箱里;运行时的 `nanomuse chat` 和哨兵已经是这个形状。
- **Qwen-Agent / MemGUI-Bench**(MIT):`mobile_use` 和 `computer_use` 方言和回复解析器,已经在 `nanomuse/phone/operator.py` 里。
- **pyautogui**、**mss**、**xdotool**:鼠标、键盘和截图。不自己写输入库。
diff --git a/docs/zh/harness.md b/docs/zh/harness.md
index 5cbe75c5..1aa535be 100644
--- a/docs/zh/harness.md
+++ b/docs/zh/harness.md
@@ -151,9 +151,9 @@ Laptop B 自己的聊天里让它的 Muse「在 Desk A 上」运行一条命令
一支笔(*更换形象* → 经宿主 `PUT /v1/me/profile`,处处生效;*编辑名称*)、连接状态、四个标签:
来自会话和 hub 通知的活动、卡片上作出的审批(一个文档级的观察器记录每一次决定)、日程、记忆。
- **对话。** 一张 `html[data-nm-muse]` 下的样式表,盖在 harness 稳定的挂钩上:`[data-composer-card]`
- 变成一个药丸形(*+*、*消息*、发送圆钮),`[data-chat-flow-kind="user"]` 变成强调色气泡,智能体的
- 步骤变成灰色气泡,`[data-approval-key]` 变成 Muse 的权限卡片(盾牌、标题、蓝色的*允许一次*、
- *拒绝*),主视觉的问候隐藏,输入框停靠在底部;harness 的模型、模式和计划控件从输入框里隐藏,打开
+ 变成一个药丸形(*+*、*消息*、发送圆钮),`[data-chat-flow-kind="user"]` 变成墨色药丸(浅色下
+ 黑底白字,深色下白底黑字),智能体的步骤变成灰色药丸,页眉变成罩在对话上的一层渐隐,
+ `[data-approval-key]` 变成 Muse 的权限卡片(盾牌、标题、蓝色的*允许一次*、*拒绝*),主视觉的问候隐藏,输入框停靠在底部;harness 的模型、模式和计划控件从输入框里隐藏,打开
「开发者」开关就回来。harness 自己的 DOM 不动,只加样式。
- **设置。** 同样接管 `sidebar.settings` 位(原装的 `ui-settings-general` 行关掉):分组导航(
通用、账号、模型、智能体、电脑操作、设备、数据控制、帮助与支持、法律信息),然后是其他插件注册的
@@ -270,7 +270,7 @@ DeepSeek Harness 是 MIT 许可;它是依赖,不是内嵌的拷贝,它的
准备一个私有 Host、一个主运行时和硬件令牌签名,本身就是一个项目),而是一个小小的 Electron App,
做那个 App 核心上做的事:用自己的二进制以 Node 模式把 harness 的 Host 作为子进程启动
(`ELECTRON_RUN_AS_NODE`、`--expose-internals`;harness 的 `require-builtin` 原生扩展带着它构建时
- 所针对的 Electron 的指纹,所以外壳钉在 `44.0.0`),dsh 是构建时 npm 装到 `resources/dsh` 下的,
+ 所针对的 Electron 的指纹,所以外壳钉死一个确切的 Electron 版本),dsh 是构建时 npm 装到 `resources/dsh` 下的,
bundle 也一起;用的是 `~/.nanomuse/desktop` 下它自己的 profile,里面写明 bundle,并链接到 App 内的
那份拷贝。负责手的运行时以 PyInstaller 构建随行,`NANOMUSE_PY` 把预设指向它。`desktop-app.yml`
在每个发布标签上构建 Windows 安装包、两种架构的 macOS dmg/zip 和 Linux AppImage/deb,把每个打包好
diff --git a/docs/zh/hub.md b/docs/zh/hub.md
index ae80f6a0..b767eb48 100644
--- a/docs/zh/hub.md
+++ b/docs/zh/hub.md
@@ -82,7 +82,7 @@ hub 上传递两类请求:
← working {cid, from, device_name, working, at} another device started (true) or finished (false) a turn in that synced conversation
```
-关闭码:`4000` 期待 hello(15 秒内没有 hello,或第一帧不是 hello,二进制帧也算)、`4001` key 无效(原因 `bad_key` 或 `account_deleted`;账号在连接期间被删除或停用时为 `account_gone`)、`4002` 设备无效、`4003` 被同一设备 id 的更新连接顶替,或者,带原因 `hub_paused`(中继 0.22)时,是运营者关掉了 hub 或整个服务:客户端应等一会儿再重连,而不是立刻重试([cloud.md › 控制](cloud.md#controls));`4008` 帧太多(无视了下面的限速),以及 `4009` 读得太慢(这条连接上已经积压了 512 帧或 32 MB 而对方一直不读;客户端像遇到任何网络断开一样退避重连)。发往某台设备的帧从不等那台设备:每条连接各有一个队列,按顺序随 socket 排空写出;一部卡住的手机不会拖住正在呼叫它的电脑。
+关闭码:`4000` 期待 hello(15 秒内没有 hello,或第一帧不是 hello,二进制帧也算)、`4001` key 无效(原因 `bad_key` 或 `account_deleted`;账号在连接期间被删除或停用时为 `account_gone`;从 0.24.2 之后的那个中继版本起,连接期间 key 刚被吊销时也会收到 `bad_key`:另一台设备点了「在所有设备上退出」、在账号页退出了这个登录,或这台设备自己退出登录,这样丢失的手机会立刻断线,其他设备看到它离线)、`4002` 设备无效、`4003` 被同一设备 id 的更新连接顶替,或者,带原因 `hub_paused`(中继 0.22)时,是运营者关掉了 hub 或整个服务:客户端应等一会儿再重连,而不是立刻重试([cloud.md › 控制](cloud.md#controls));`4008` 帧太多(无视了下面的限速),以及 `4009` 读得太慢(这条连接上已经积压了 512 帧或 32 MB 而对方一直不读;客户端像遇到任何网络断开一样退避重连)。发往某台设备的帧从不等那台设备:每条连接各有一个队列,按顺序随 socket 排空写出;一部卡住的手机不会拖住正在呼叫它的电脑。
`Authorization` 头里的 key 被拒时,处理方式与 `hello` 里的 key 被拒一致:握手完成,先发一个带 code 的 `error` 帧,再以 `4001` 关闭。客户端看到 `4001` 或 `4002` 就离开退避重连,把 hub 显示为「被拒绝」并请人重新登录(运行时仍每分钟试一次,这样在中继上恢复的 key 不用重启也能接回来);其他关闭都视为网络问题,按退避重试。
diff --git a/docs/zh/index.md b/docs/zh/index.md
index eb65c93a..3de69854 100644
--- a/docs/zh/index.md
+++ b/docs/zh/index.md
@@ -23,21 +23,26 @@ hero:
| | |
|---|---|
-| **Android** 8.0 以上,arm64 | [nanoMuse-0.1.41-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-0.1.41-arm64.apk) · [怎么装](/zh/android) |
-| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc)——测试版;Apple 的 beta 审核通过后,这个链接就能装 · [iOS](/zh/ios) |
-| **macOS** 12 以上 | [Apple 芯片](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-Desktop-0.1.41-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-Desktop-0.1.41-mac-x64.dmg) · [桌面版](/zh/desktop) |
-| **Windows** 10 以上 | [nanoMuse-Desktop-0.1.41-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-Desktop-0.1.41-win-x64.exe) |
-| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-Desktop-0.1.41-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-Desktop-0.1.41-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v0.1.41/nanoMuse-Desktop-0.1.41-linux-x64.tar.gz) |
+| **Android** 8.0 以上,arm64 | [nanoMuse-1.0.3-arm64.apk](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-1.0.3-arm64.apk) · [怎么装](/zh/android) |
+| **iPhone / iPad** | [TestFlight](https://testflight.apple.com/join/ZHexbDqc):测试版,已通过 Apple 的 beta 审核;从这个链接安装 · [iOS](/zh/ios) |
+| **macOS** 12 以上 | [Apple 芯片](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-arm64.dmg) · [Intel](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-mac-x64.dmg) · [桌面版](/zh/desktop) |
+| **Windows** 10 以上 | [nanoMuse-Desktop-1.0.3-win-x64.exe](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-win-x64.exe) |
+| **Linux** x64 | [AppImage](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.AppImage) · [.deb](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.deb) · [tar.gz](https://github.com/zeeshanhaque21/nanoMuse/releases/download/v1.0.3-fork.1/nanoMuse-Desktop-1.0.3-linux-x64.tar.gz) |
| **Docker** | `bash scripts/self-host.sh --local` 搭自己的中继;`docker compose up -d app` 跑网页版 · [自己部署](/zh/self-hosting) |
-所有下载都在 [最新版本](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)。
+所有下载都在 [最新版本](https://github.com/zeeshanhaque21/nanoMuse/releases/latest)
-[0.1.41 Choice 改了什么](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v0.1.41) · [每个客户端能做什么](/zh/parity) · [从哪儿开始参与](/zh/roadmap) · [中继保存什么](/zh/privacy) · [什么留在手机上,归谁](/zh/sync)
+[1.0.3 Helm 改了什么](https://github.com/zeeshanhaque21/nanoMuse/releases/tag/v1.0.3-fork.1) · [每个客户端能做什么](/zh/parity) · [从哪儿开始参与](/zh/roadmap) · [中继保存什么](/zh/privacy) · [什么留在手机上,归谁](/zh/sync)
## 宣传片
+
74 秒,看 nanoMuse 在手机、电脑和网页上做什么。
+::: info nanoMuse 在持续进化
+个人智能体不该只长在别人的云里,nanoMuse 想做一个开源、低成本的平替:装在自己的设备上,模型自己选,中继也能自己架。我们希望和全球的开发者、使用者一起,让每个人真正拥有自己的个人智能体。提个 [issue](https://github.com/zeeshanhaque21/nanoMuse/issues/new/choose) 或者发个 [PR](../../CONTRIBUTING.md) 都是在帮 nanoMuse 变得更好。
+:::
+
## 引用
报告《nanoMuse: An Open-Source Personal Agent for Every Device You Own》在 arXiv 上,编号 [2610.08699](https://arxiv.org/abs/2610.08699):什么是个人智能体,Muse 是怎么搭的,nanoMuse 又是怎样用开源的方式回应的。如果 nanoMuse 对你有帮助,欢迎引用我们的论文。
diff --git a/docs/zh/ios.md b/docs/zh/ios.md
index 744a8b21..1b261edf 100644
--- a/docs/zh/ios.md
+++ b/docs/zh/ios.md
@@ -6,9 +6,8 @@ Mac runner 上构建后交给 TestFlight。这一页说的是:源码树里有
**现状。** 源码树、品牌、nanoMuse Cloud 登录、hub 客户端和流水线都是在一台 Linux 机器上
写的。这个 App **能构建、能签名、已经上了 TestFlight**:第一个构建 0.1.31 (2) 于 2026-10-03 走完
-*iOS · TestFlight* 工作流;0.1.41 是构建 14,在内部测试员手里,并已提交 Apple 的 beta 审核,
-为的是公开链接(`https://testflight.apple.com/join/ZHexbDqc`,审核通过后这个链接就能装——见
-下文*现状*)。测试员的反馈推动了发布说明里的那些修复(输入框、引导、退出登录);「设备」区
+*iOS · TestFlight* 工作流;1.0.0 是构建 16,已通过 Apple 的 beta 审核,公开链接
+(`https://testflight.apple.com/join/ZHexbDqc`)装的就是它;1.0.1 是构建 18,1.0.2 是构建 19,1.0.3 是构建 22,分别于 2026-10-10、2026-10-11 和 2026-10-12 提交了审核(见下文*现状*);构建 20 和 21(1.0.2)只到了内部测试员手里。测试员的反馈推动了发布说明里的那些修复(输入框、引导、退出登录);「设备」区
和来自其他设备的通知仍是真机上跑得最少的部分。第一次归档教会了流水线一件事:
自动签名需要一台已注册的设备,这就是它现在改为手动签名的原因。
@@ -46,8 +45,12 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
- **身份。** Bundle id `io.github.nanomuse.app`(加 `.ShareExtension`、`.AgentWidget`、
`.FileProvider`)、app group `group.io.github.nanomuse.app`、iCloud 容器
`iCloud.io.github.nanomuse.app`,以及带着 bundle id 的后台任务、UTType 和 URL scheme 的
- id。和 Android 不同,iOS 上没有源码包名的限制,所以整个家族一起换。`minis://` 和
- `minis-mcp://` 这两个 scheme 保留:那是上游和它自己沙箱之间的约定。
+ id。和 Android 不同,iOS 上没有源码包名的限制,所以整个家族一起换。系统认得这个 App 的
+ URL scheme 是 `nanomuse`(分享扩展打开 `nanomuse://share`)和 `nanomuse-mcp`,这样同一部手机上
+ 也装了 OpenMinis 时,不会有两个 App 抢同一个 scheme。App 内部的链接仍是 `minis://…`:那是上游
+ 和它自己的沙箱、系统提示词和模型之间的约定;从系统进来的 `nanomuse://` 链接按它的 `minis://`
+ 形式读(`NanoMuse/NanoMuseOwnScheme.swift`),聊天之外我们自己的按钮打开这类链接时走深链路由,
+ 不经过系统。
- **名字。** 显示名 nanoMuse、「Share to nanoMuse」「nanoMuse Files」;Swift 字符串字面量、
`Localizable.xcstrings`(键名改了,九种翻译全部更新)和 Info.plist 各语言的用途说明里,
「Minis」→「nanoMuse」。OpenRouter 的 `HTTP-Referer` 仍指向 OpenMinis,和 Android 一样:
@@ -66,10 +69,10 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
然后中继([cloud.md](cloud.md))就成了 App 里一个普通的 OpenAI 兼容服务商,自带一个模型组,
没有默认时它就是默认。和 Android 客户端(`io.github.nanomuse.cloud`)同样的线上格式、同样
的规则:每个中继一个实例,不替换用户自己的任何东西,刷新时遇到 401 就移除这个服务商(并把
- 账号的数据放到一边——见下文契约 C12)。任何构建都能指向另一个中继(见下文 *使用其他
- 服务器*)。从 0.1.32 起这一页就是整个账号——密码作为另一种登录方式、朋友的邀请码、以元
+ 账号的数据放到一边,见下文契约 C12)。任何构建都能指向另一个中继(见下文 *使用其他
+ 服务器*)。从 0.1.32 起这一页就是整个账号:密码作为另一种登录方式、朋友的邀请码、以元
计的额度池和余额不多时接着用的办法(自己的 key、邀请、点一次 star)、按种类和按模型的
- 用量、持有 key 的设备、时间线、删除——和手机的 `CloudAccountScreen` 相同的几块。
+ 用量、持有 key 的设备、时间线、删除,和手机的 `CloudAccountScreen` 相同的几块。
- **智能体的步骤,状态行。** *设置 → 聊天 → 执行步骤* 让已完成的消息里不再出现工具胶囊,
除非你要看;正在输入的那一行写的是 *〈名字〉正在处理*,不是 *正在思考*。两处都是上游
`AssistantBlockView` / `ContentView` 里的单行改动,标了 `// nanoMuse:`。
@@ -77,18 +80,23 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
`ContentView`;*设置 → nanoMuse* 里有两个开关(*Muse 主页*、*在对话顶部显示形象和名字*),
OpenMinis 的布局在抽屉里点一下就回得去。聊天的导航标题变成**形象 · 名字 · 状态行**
(`NanoMuseHeader.swift`):有问题或审批等着你时状态是 *等你回复*,然后是正在运行的工具的
- `tool_title`——模型自己对这一步的描述,比如 *打开携程网站*——然后是 *正在写回复*,然后是
+ `tool_title`(模型自己对这一步的描述,比如 *打开携程网站*),然后是 *正在写回复*,然后是
*正在处理:〈简述〉*,空闲时是模型的名字;正在画新形象时,显示的是形象流程那边的那行字。
形象(`NanoMuseFaces.swift`:Application Support 里画出来的脸,或者 bundle 里的小龙,五种
- 状态,带着其他客户端那样的呼吸、浮动、歪头、弹出和抖动)点一下进入**智能体的页面**。
+ 状态,带着其他客户端那样的呼吸、浮动、歪头、弹出和抖动;开了「减弱动态效果」静态图就不动了,
+ 只剩状态切换时的淡入淡出)点一下进入**智能体的页面**。
+ 也可以换成一段你自己的短视频来当形象(`NanoMuseOwnClip.swift`:智能体页面钢笔菜单和形象
+ 工作室里的 *你自己的视频…*;MP4 或 MOV,H.264 或 HEVC 视频,最长 15 秒、最大 30 MB,用之前
+ 先预览,只保存在这台手机的 `Application Support/nanomuse/avatar/own-clip.mp4`,*默认形象*
+ 可移除;回退规则见 [avatar.md](avatar.md) 里的那一节)。
抽屉里放着会话、搜索、新建旁聊、*设为主要聊天*、*全部对话* 和 nanoMuse 设置;点圆形按钮
打开,在主要聊天里从左边缘向右滑也打开(`NanoMuseEdgeSwipe.swift`,挂在窗口上的屏幕边缘
手势,和 Android 聊天页上的抽屉一样;推出来的旁聊仍然用系统的返回手势)。
- **智能体的页面(0.1.34)**(`NanoMuseAgentPage.swift`,对应 Android `ui/profile`):带钢笔
角标的大脸(*更换形象* 会把「把形象改成 」放进主要聊天的输入框,还有 *编辑名称*、
- *形象工作室*)、名字、*在线*,以及四个面板——**动态**(你要求过什么、智能体做了什么,取自
+ *形象工作室*)、名字、*在线*,以及四个面板:**动态**(你要求过什么、智能体做了什么,取自
最近两天的会话)、**审批**(长期有效的「总是允许」答复,可撤销)、**每日**(例程和目标检查
- 以及各自的下次运行时间,*管理例程*)、**SOUL 与记忆**(SOUL.md 和 GLOBAL.md,可编辑)——
+ 以及各自的下次运行时间,*管理例程*)、**SOUL 与记忆**(SOUL.md 和 GLOBAL.md,可编辑),
还有分享页(`NanoMuseAvatarShareSheet`,对应 Android `ui/avatar/AvatarShareSheet.kt`)。
- **从聊天里换形象(0.1.34)**(`NanoMuseAvatarFlow.swift`,对应 Android `avatar/AvatarFlow.kt`):
「换成一只橘猫」/「change your avatar to a red panda」在模型看到之前就被识别出来(同一套
@@ -97,11 +105,11 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
新脸换上、个人资料推送出去、记忆里记一笔、分享卡片。走中继时先出一张费用卡片
(`/v1/estimate`),手机上有自己的百炼 key 时走它(`NanoMuseImageGen.swift`,DashScope
原生的图像端点);工作室(`NanoMuseAvatarStudio.swift`)用同一条路,并且可以选图像模型。
-- **调度器(0.1.34)**(`NanoMuseScheduler.swift`):例程——一个标签、一段提示词、一个时间,
- 每天 / 工作日 / 一次或每 N 小时——在一个专属的对话里作为无头回合运行(`NanoMuseHeadless`)。
+- **调度器(0.1.34)**(`NanoMuseScheduler.swift`):例程(一个标签、一段提示词、一个时间,
+ 每天 / 工作日 / 一次或每 N 小时)在一个专属的对话里作为无头回合运行(`NanoMuseHeadless`)。
说实话:iPhone 在 App 打开时运行它们(变为活跃时把到期的全跑一遍),退到后台时向 iOS
申请一个 `BGAppRefreshTask`(`io.github.nanomuse.app.scheduler`),iOS 批了就跑到期的那些,
- 并且在每个到期时刻发一条本地通知(*检查:〈目标〉——打开即运行*),点通知打开那个对话。
+ 并且在每个到期时刻发一条本地通知(*检查:〈目标〉,打开即运行*),点通知打开那个对话。
所有关于例程的文案都这么写明了。*设置 → nanoMuse → 定时任务* 列出全部,目标检查和动态的
那条也在内。
- **目标(0.1.34)**(`NanoMuseGoals.swift`,对应 Android `goals/`):*创建目标 › 类别* 把开场白
@@ -116,7 +124,7 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
- **第一次打开和设置(0.1.34)**(`NanoMuseFirstRun.swift`、`NanoMuseSettings.swift`、
`NanoMuseCoding.swift`、`NanoMuseSystemFiles.swift`):Android 引导的四页(欢迎 → 登录,
免费开始 → 新账号设一次密码 → 哪个模型来回答 → 认识〈名字〉),*我有自己的 API key* 打开
- 自己的 key 面板(整份目录——见下文 *自己的 key*),之后是第一次对话(开场白、「我该怎么
+ 自己的 key 面板(整份目录,见下文 *自己的 key*),之后是第一次对话(开场白、「我该怎么
称呼你?」、从模型的 ` ```nanomuse-naming ` 块里选名字、写入 GLOBAL.md 和 SOUL.md)。
*设置 → nanoMuse*:账号、**编程助手**(通过 hub 的 `coding.*` 动作访问账号下各台电脑上的
Cursor / Codex / Claude Code 会话,可以给任何一个发消息)、**定时任务**、**系统文件**
@@ -125,12 +133,12 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
- **模型(0.1.34)**(`NanoMuseModels.swift`):中继的菜单打开时停在 `deepseek-v4.1-flash`
(标为 *聊天* 推荐的那个;`qwen3.8-27b` 是手的模型,从不作为聊天默认);在聊天的选择器里
选了哪个,哪个就挪到 Cloud 组的最前面,新聊天跟着它走,和 Android 的 `followPick` 一样。
- DeepSeek 的 id 一律视为纯文本,除非名字里有 `v4.1` 及以后、`vision` 或 `ocr`——这是
+ DeepSeek 的 id 一律视为纯文本,除非名字里有 `v4.1` 及以后、`vision` 或 `ocr`;这是
`LLMModel.withInferredModality()` 顶上的一步 `// nanoMuse:`。
- **模型分成四个位置(0.1.41)**(`NanoMuseModelSlots.swift`、`NanoMuseModelsView.swift`;
对应 Android `ui/models/ModelsScreen.kt`):*设置 → nanoMuse* 的第一张卡片是 *模型*,打开
- 四行——*对话*、*操作屏幕*(iPhone 上禁用,显示 *不在 iPhone 上*,副标题 *电脑用它自己的
- 设置。*)、*生成图片*、*生成视频*——每行显示 `<服务商> · <模型>`,点进去是一个选择器:先是
+ 四行:*对话*、*操作屏幕*(iPhone 上禁用,显示 *不在 iPhone 上*,副标题 *电脑用它自己的
+ 设置。*)、*生成图片*、*生成视频*,每行显示 `<服务商> · <模型>`,点进去是一个选择器:先是
*nanoMuse Cloud* 一组(中继推荐的那个标 *推荐*),然后每个能干这件事的自有服务商一组;
空着的行说明缺什么并给出 *添加服务商*,页面最后一行也是它;图片和视频的选择器下方会把
目录里说能画图、手机却驱动不了的 key(`NanoMuseModelSlots.notDriven`:只有 DashScope 主机
@@ -139,8 +147,8 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
设为默认),主要聊天的绑定也跟着换(`NanoMuseModelSlots.mainChatFollows`:主要聊天永远
不是新对话,0.1.41 上选了自己的 key 之后旁聊走 key、主要聊天却还留在 nanoMuse Cloud),
页面提示 *对主要聊天和新对话生效;旁聊保留自己的模型。*;在聊天自己的选择器里选的也
- 同样生效,不再只限 Cloud。图片和视频按一个顺序决定——你的选择,否则对话服务商自己的默认(当它是能画图的
- 百炼 key 时),否则已登录的 nanoMuse Cloud,否则第一个能干的 key——所以 Cloud 不会再被
+ 同样生效,不再只限 Cloud。图片和视频按一个顺序决定(你的选择,否则对话服务商自己的默认(当它是能画图的
+ 百炼 key 时),否则已登录的 nanoMuse Cloud,否则第一个能干的 key),所以 Cloud 不会再被
一把你没选的百炼 key 挤掉;图片和视频模型来自目录的 `defaults`,不再写在代码里。图片和
视频的选择器最上面有一项 *自动*(*当前为 〈服务商 · 模型〉*),选它就忘掉存下的选择,让这
个位置重新按上面的顺序走;视频选择器的 *关闭* 留在它下面。每组最多先显示八行(这个位置
@@ -158,7 +166,7 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
- **跨设备的连接(0.1.34)**(`NanoMuseConnectors.swift` 里的 `NanoMuseSharedConnectors`):
个人资料里的 `connectors` 列表记录哪台设备连了什么(id、标签、去掉查询字符串的地址、登录
方式、是否启用、时间、设备),从不记 token;这台手机写入自己的条目,读回其他设备的,
- 连接器页面在 *你的其他设备上* 下面列出它们——*已在〈设备〉上连接——在这台手机上登录一次,
+ 连接器页面在 *你的其他设备上* 下面列出它们:*已在〈设备〉上连接,在这台手机上登录一次,
这里也能用*。账号里那张「接着用的办法」卡片跟着地区走:中国大陆百炼在前,其他地方
*使用 OpenRouter 登录* 在前。
- **连接器**(`NanoMuseConnectors.swift`):桌面版的目录来自内置的 `connectors.json`
@@ -168,27 +176,33 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
的服务,询问 client id 并给出可复制的回调地址。原来叫 *MCP 集成* 的设置行现在是**连接器**;
末尾的 *你自己的服务器* 是上游的 MCP 页面。
- **数据控制**(`NanoMuseDataControls.swift`):中继的开关、保留的回合数、隐私页、带确认的
- 删除。**Reach**(`NanoMuseReach.swift`):账号下每台设备一个面板——打开链接、发一条便条、
- 跑一行 shell、截一张图——都通过 hub。
+ 删除。**Reach**(`NanoMuseReach.swift`):账号下每台设备一个面板(打开链接、发一条便条、
+ 跑一行 shell、截一张图),都通过 hub。
- **服务商连不上时**(`NanoMuseProviderReach.swift`、`NanoMuseProviderReachCard.swift`):
- 一个失败的回合,如果错误出在传输层——*Could not connect to the server*、*A server with the
- specified hostname could not be found*、TLS 或超时的那一行——或者是 OpenAI 的 *region not
+ 一个失败的回合,如果错误出在传输层(*Could not connect to the server*、*A server with the
+ specified hostname could not be found*、TLS 或超时的那一行),或者是 OpenAI 的 *region not
supported*,就显示一张卡片而不是红色横幅:发生了什么、什么有帮助(VPN、*设置 → 网络* 里的
代理、换一个用自己 key 的服务商)、*重试*、*详情* 后面的原始错误行。`LLMError` 会把出错的
主机以 `[host: …]` 的形式附在网络错误后面,卡片就能点出它的名字;`mapHTTPError` 丢掉正文的
401/403/429 由 `NanoMuseReachSignal` 保留几秒,再由 `friendlyErrorMessage` 以一行规范的
- `nm_reach:` 写进消息——ChatGPT 登录过期显示 *重新登录*,套餐窗口用尽显示 *ChatGPT 套餐
+ `nm_reach:` 写进消息:ChatGPT 登录过期显示 *重新登录*,套餐窗口用尽显示 *ChatGPT 套餐
暂时没有余量了*,附 OpenAI 的原话和重置时间。种类、文案和规则都和 Android 的
`ProviderReach` 一致。
- **中继拒绝一个回合时**(`NanoMuseRelayRefusal.swift`、`NanoMuseRelayRefusalCard.swift`):
- nanoMuse Cloud 发来的每一种拒绝都是一句用手机语言写的大白话,加一个合适的按钮——从不是
+ nanoMuse Cloud 发来的每一种拒绝都是一句用手机语言写的大白话,加一个合适的按钮,从不是
状态码、中继的 JSON 或者上游的 *Rate limited*。`NanoMuseReachSignal` 把模型调用的每个
非 2xx 回复先交给 `NanoMuseRelaySignal`(还是 `mapHTTPError` 里那个已有的 `// nanoMuse:`
- 位置,上游文件里没有新增);带 `type: nanomuse_cloud` 或中继某个代码的回复——或者来自
- 中继主机的裸 413 / 401 / 5xx——变成消息里一行规范的 `nm_relay:`,`NanoMuseProviderReachCard`
+ 位置,上游文件里没有新增);带 `type: nanomuse_cloud` 或中继某个代码的回复(或者来自
+ 中继主机的裸 413 / 401 / 5xx)变成消息里一行规范的 `nm_relay:`,`NanoMuseProviderReachCard`
把它画成拒绝卡片。句子来自 `NanoMuseCloud.describe`,和登录页、账号页用的是同一套,九种
语言都有:`413 too_large` → *这条消息对模型来说太长了。缩短一些、去掉几个附件,或者新开
- 一个对话。* 配 *新对话*;`401 bad_key` / `account_deleted` → *登录*;`403 not_invited` /
+ 一个对话。* 配 *新对话*;`400 content_rejected`(服务商的内容审核没有放行这些内容;用自己的
+ key 时服务商自己的 `DataInspectionFailed` 也算)→ *模型服务商的内容审核没有放行这次请求。换个
+ 说法试试;如果反复出现,就新开一个对话,被拦下的可能是对话里更早的内容。* 配 *新对话* 和
+ *重试*,压缩的对半重试(`isSegmentRetryableError`)不碰它,因为请求再小,同样的内容回来还是
+ 被拒;中继放在流里的错误(200 里的 `data: {"error": …}`)现在以它的卡片结束这一回合,而不是
+ 一个空回复(`OpenAIProvider` 流循环里的一处 `// nanoMuse:`);`401 bad_key` / `account_deleted`
+ → *登录*;`403 not_invited` /
`account_disabled` / `signup_closed` → *打开设置*;`429 too_many_in_flight` / `locked` /
`rate_limited` 和 `provider_busy`(带 `retry_after` 给出的等待时间)→ *重试*;
`404 model_not_offered` → *打开设置*;`503 service_paused`、`sync_paused`、`hub_paused`
@@ -197,28 +211,28 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
的 `AllowanceSignal` + `AllowanceWaysCard`):被 `429 allowance_exhausted` 或 `daily_cap`
拒绝的回合会在聊天标题下面钉一张卡片,钉法和 star 卡片一样(外壳的顶部 inset,
`AIChatView.body` 上没有新的环节):开头是 *免费额度已用完。*、*今天的免费额度……*,或者
- 带 `paused: true`(中继 0.22)时的 *这个中继上暂时停了——不是用完了*,并说明剩下的原样
- 保留——然后是接着用的办法和 *重试*(把当前聊天的最后一个回合再发一次)。额度池用到 80 %
- (`/v1/me` 的 `spend.warn`)时,`NanoMuseChatCardsHost` 里会有一行字挂在输入区上方——
- *快用完了:还剩 ¥…(共 ¥…)……*——每种池子大小只出现一次(新的发放会再说一次),点 ×
+ 带 `paused: true`(中继 0.22)时的 *这个中继上暂时停了,不是用完了*,并说明剩下的原样
+ 保留;然后是接着用的办法和 *重试*(把当前聊天的最后一个回合再发一次)。额度池用到 80 %
+ (`/v1/me` 的 `spend.warn`)时,`NanoMuseChatCardsHost` 里会有一行字挂在输入区上方,
+ *快用完了:还剩 ¥…(共 ¥…)……*,每种池子大小只出现一次(新的发放会再说一次),点 ×
划掉;卡片出现时替换掉它。
- **自己的 key**(`NanoMuseCatalogue.swift`、`NanoMuseVendorSheet.swift`、`NanoMuseModels.swift`
里的 `NanoMuseOwnKeySheet`;[own-key.md](own-key.md)):服务商来自内置的
`NanoMuse/Resources/providers.json`(`node scripts/providers-json.mjs` 保持它最新,CI 里跑
- `--check`)——中继发过 `spend.guidance` 时则先用它(中继 0.21;`429 allowance_exhausted`
+ `--check`),中继发过 `spend.guidance` 时则先用它(中继 0.21;`429 allowance_exhausted`
旁边也会带),存在 `UserDefaults` 里跨次运行保留,所以中继上新加的服务商在 App 更新之前
就能显示出来。`NanoMuseWaysList` 是同一份列表,用在钉着的卡片、设置 → nanoMuse Cloud 和
- 自己的 key 面板上:*换成自己的 key*——本地区的首选在前(大陆是阿里云百炼,其他地方是
+ 自己的 key 面板上:*换成自己的 key*:本地区的首选在前(大陆是阿里云百炼,其他地方是
OpenRouter 和 OpenAI),一次三个,其余收在 *更多服务商* 后面,每一行写明它覆盖什么
(*聊天 · 屏幕 · 图片 · 短视频*),*获取 key* 打开服务商的 key 页面,*添加* 在那个服务商上
打开 `NanoMuseVendorSheet`(粘贴 key,用服务商的端点和 `/v1` 设置添加实例,拉取它的模型,
- 以它命名的组在没有默认时成为默认);*已付费的订阅*——ChatGPT 走上游的 `CodexOAuthManager`,
- Claude、OpenRouter,Kimi 的设备码走 `KimiDeviceLoginSheet`——ChatGPT 那行下面有一句说明:
+ 以它命名的组在没有默认时成为默认);*已付费的订阅*:ChatGPT 走上游的 `CodexOAuthManager`,
+ Claude、OpenRouter,Kimi 的设备码走 `KimiDeviceLoginSheet`,ChatGPT 那行下面有一句说明:
OpenAI 的条款只覆盖在 OpenAI 自家 Codex 里使用套餐,别的 App 的这种访问以前被切断过
(OpenCode,2026 年 1 月),要是停了,API key 照样能用(中继发过自己的措辞时用中继的);
- *在自己的电脑上*——Ollama、LM Studio、vLLM——填地址。不推荐任何一家。
+ *在自己的电脑上*(Ollama、LM Studio、vLLM)填地址。不推荐任何一家。
- **网络**(`NanoMuseProxy.swift`、`NanoMuseNetworkView.swift`,*设置 → nanoMuse → 网络*):
- 给自己的服务商用的 HTTP 代理——主机、端口、可选的用户名和密码,默认关闭,只存在这台手机的
+ 给自己的服务商用的 HTTP 代理:主机、端口、可选的用户名和密码,默认关闭,只存在这台手机的
`UserDefaults` 里。`URLSession` 没有按主机切换代理的开关,所以代理是以一段代理自动配置脚本
的形式进入会话的(`connectionProxyDictionary`):对目录里的主机、`chatgpt.com`、
`auth.openai.com` 和服务商实例的自定义主机返回代理(从不包括中继的主机,也从不包括局域网
@@ -227,50 +241,52 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
共享的 `URLCredentialStorage`,归在代理的 protection space 下。*测试* 按你填的代理去取
`https://chatgpt.com/`。
- **手机的界面框架(0.1.35)**(`NanoMuseChrome.swift`、`NanoMuseAppearance.swift`,对应 Android
- `ui/chat/MuseHeader.kt` 和 `ui/settings`):Muse 标题栏——形象圆盘、名字胶囊和它下面的实时
- 状态行、圆形的抽屉和 ••• 按钮——出现在聊天和动态、点子、目标、资源库上;侧抽屉照着 Android
+ `ui/chat/MuseHeader.kt` 和 `ui/settings`):Muse 标题栏(形象圆盘、名字胶囊和它下面的实时
+ 状态行、圆形的抽屉和 ••• 按钮)出现在聊天和动态、点子、目标、资源库上;侧抽屉照着 Android
的做;智能体的回复用灰色气泡(`NanoMuseChatCards.swift` 里的 `NanoMuseAssistantBubble`);
智能体页面的工具栏改成圆形按钮;*设置 → nanoMuse* 按 Android 的顺序重做成 Muse 卡片
- (`NanoMuseSettingsHomeView`:图像与视频模型、形象、电脑、外观——形象大小、名字下面的模型、
- 步骤、主题——通知、账号、编程、定时任务、共享文件夹、对话文件、系统文件、版本)。一个
+ (`NanoMuseSettingsHomeView`:图像与视频模型、形象、电脑、外观(形象大小、名字下面的模型、
+ 步骤、主题)、通知、账号、编程、定时任务、共享文件夹、对话文件、系统文件、版本)。一个
`nmOnChange` 辅助函数让 `onChange` 在 iOS 16(App 的部署目标)上也能用。
- **动作短视频(0.1.35)**(`NanoMuseVideoGen.swift`、`NanoMuseAvatarMotion.swift`、
`NanoMuseMediaModels.swift`;对应 Android `avatar/VideoGen.kt`、`avatar/AvatarMotion.kt`):
- 画好的脸会得到四段 4 秒的短视频——待命、干活中、等你回复、搞定——来自 `wan2.2-i2v-flash`
+ 画好的脸会得到四段 4 秒的短视频(待命、干活中、等你回复、搞定),来自 `wan2.2-i2v-flash`
(DashScope 的异步 API,走中继或你自己的百炼 key),提示词和手机版一字不差,按设备存在
`avatar/motion/` 下;脸用 `AVQueuePlayer` 加 `AVPlayerLooper` 播放它们(后台时暂停),
小龙的短视频来自 bundle。*图像与视频模型* 列出模型、*换形象后自动生成动态*(默认开)和
*生成 / 重做短视频*;工作室的费用估算把短视频也算进去。
- **版本、star 提示、动态的第一天(0.1.35)**(`NanoMuseUpdateCheck.swift`、
- `NanoMuseNudges.swift`):版本那一行显示已安装的构建和最新版本(查 GitHub,缓存一天——*最新 0.1.x——你用的就是它* /
- *0.1.x 已发布* / *没查到——点一下再试*)。star 提示遵循中继的策略(`/v1/nudges`、`/v1/me`
+ `NanoMuseNudges.swift`):版本那一行显示已安装的构建和最新版本(先查
+ 这个仓库的 GitHub Releases,再查配置过的镜像索引,缓存一天:*最新 0.1.x,你用的就是它* /
+ *0.1.x 已发布* / *没查到,点一下再试*)。star 提示遵循中继的策略(`/v1/nudges`、`/v1/me`
里的 `nudges`,内置同样的默认值):第一次对话里绝不出现,然后在第 3 / 10 / 30 个任务
(`NanoMuseStarWatch`:一个由人发起、无错误地离开 `activeSessions` 的会话)、第 7 / 30 天、
- 达成一个目标、换了新形象、登录、额度用完时——间隔七天,每台手机最多四次——以钉在标题下
+ 达成一个目标、换了新形象、登录、额度用完时(间隔七天,每台手机最多四次),以钉在标题下
的卡片出现(消息列表是一个 UICollectionView,所以没法在最后一条消息下面放东西);卡片
里那句话,运营者在中继控制台设了(`star.text`,中文界面优先取 `star.text_zh`)就用设的,
- 没设就用应用自己为那个时机写的——标题和按钮始终是应用的。动态
+ 没设就用应用自己为那个时机写的;标题和按钮始终是应用的。动态
打开时先是介绍卡片,空着的时候说明每日例程什么时候跑,第一次对话之后写下它的第一天;
引导里加了一页通知。
- **输入区(0.1.38、0.1.40)**(`NanoMuseShell.swift` → `NanoMuseHomeView.body`、
`NanoMuseChatModifiers.swift`、`NanoMuseComposerField.swift`、`NanoMuseComposerWatch.swift`、
`NanoMuseComposerCheck.swift`,以及 `Views/Chat/AIChatView.swift` 里的那些 `// nanoMuse:` 行)。
- 有四个版本报告过聊天里没有输入框——0.1.36 和 0.1.37 在维护者的 iPhone 上,第一次对话起名
- 之后;0.1.39 在 iPad 上——而 0.1.37 的看门狗、0.1.38 的原生输入框和 0.1.38 的保险措施
+ 有四个版本报告过聊天里没有输入框(0.1.36 和 0.1.37 在维护者的 iPhone 上,第一次对话起名
+ 之后;0.1.39 在 iPad 上),而 0.1.37 的看门狗、0.1.38 的原生输入框和 0.1.38 的保险措施
(overlay 什么都报不出来时启用第二个宿主)都是对着截图写的,而截图里的输入区其实一直在。
维护者最后看到的是:App 打开时输入框在,键盘一收起就再也不见;0.1.36 的截图里,输入区的
上边缘从底栏上方露出来一点。外壳的底栏是 `NavigationStack` 祖先上的一个 `safeAreaInset`,
键盘弹起时隐藏;聊天在启动时尊重这个 inset,底栏离开又回来之后就不再尊重,于是整个聊天
- 都跑到底栏下面去了,而输入区——贴底对齐、布局完成、按看门狗的每一项指标都正常——正好
- 躲在底栏后面。从 0.1.40 起,**底栏是房间下面的一行**(一个 `VStack`,和 Android 一样),
- 输入区这一列(卡片、工具条、输入栏)同样是**消息列表下面的一行**:`NanoMuseComposerHost`
+ 都跑到底栏下面去了,而输入区(贴底对齐、布局完成、按看门狗的每一项指标都正常)正好
+ 躲在底栏后面。从 0.1.40 起,**底栏靠普通布局排在聊天下面,绝不做聊天祖先上的 inset**
+ (0.1.40 是一个 `VStack` 的一行;下文的悬浮胶囊栏之后,是聊天层上一段底栏高度的底部
+ padding,布局上是同一回事),输入区这一列(卡片、工具条、输入栏)同样是**消息列表下面的一行**:`NanoMuseComposerHost`
是一个普通的 `VStack`,列表拿到的底部 inset 是 0,`/` 和 `@` 的弹出菜单是列表底边的
overlay,所以从布局上就站在这一列的上边缘。没有任何东西盖在 UIKit 列表上,也没有第二个
宿主可切换。看门狗作为最后一道网留着(聊天在屏幕上时,这一列脱离或者量出 0,就通过
`.id(rebuildTick)` 重建,每次出现最多两次,这样一个量错了的健康输入区不会反复把键盘收走),
并且把看到的都记下来:**设置 → 外观 → 输入框检查** 在这一列四周画一个红框(探针视图在
- 它的背景里)并写一份报告——窗口和它的安全区、这一列的 frame、探针的每个 UIKit 祖先及其
- frame、hidden 标志和 alpha、窗口里的文本框和集合视图、看门狗的事件——附一行 *复制*,方便
+ 它的背景里)并写一份报告(窗口和它的安全区、这一列的 frame、探针的每个 UIKit 祖先及其
+ frame、hidden 标志和 alpha、窗口里的文本框和集合视图、看门狗的事件),附一行 *复制*,方便
提 bug。这一页就是一台设备在没有 Mac 的情况下能给出的视图层级。胶囊里的输入框是 SwiftUI
自己的 `TextField(axis: .vertical)`,带 `@FocusState` 和 `lineLimit(1...6)`,不是上游的
`UIViewRepresentable` 文本视图。representable 能做而原生输入框不能做的:直接把图片粘进输入框
@@ -284,7 +300,7 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
启动画面:更新之后旧的可能还会出现几次,重装则立刻显示新的。
- **旁聊和在线状态(0.1.38,契约 C9)**(`NanoMuseSync.swift`、`NanoMusePresence.swift`、
`NanoMuseSyncSettings.swift`、`NanoMuseFromDeviceCaption.swift`):*数据控制 → 同时同步旁聊*
- 是按设备的,默认关——只有主要聊天上传,拉取时带 `scope=main`;仍然到达的旁聊行会被忽略。
+ 是按设备的,默认关:只有主要聊天上传,拉取时带 `scope=main`;仍然到达的旁聊行会被忽略。
打开它会从 `since=0&scope=all&tail=300` 拉一次(按 `mid` 幂等),旁聊随下一次推送上传;
关掉只是把流量收窄,不删任何东西。一张表的第一次拉取是 `since=0&tail=300`:最新的 300 条
文本,不是整个历史。在线状态:用户那一行推送之后、回合结束推送之后各 `POST /v1/sync/working
@@ -301,26 +317,26 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
`10.foo.example.com` 是公网名字;私有网段、Tailscale 分配的 `100.64/10`、回环、链路本地、
IPv6 ULA、`localhost`、不带点的名字,以及 `.local`、`.lan`、`.home`、`.internal`、`.home.arpa`、
`.localdomain`、`.ts.net` 后缀都算自己的网络。登录后,账号页显示 *服务器:〈主机〉* 和 *更换*,更换会先让这台手机退出
- 登录——key 属于签发它的那个中继。
+ 登录:key 属于签发它的那个中继。
- **一个账号的对话,不是上一个人的(0.1.39,契约 C10)**(`NanoMuseSync.swift`、
- `NanoMuseShell.swift`):一个对话属于最先推送或拉取它的那个账号——映射它的同步表是那个
+ `NanoMuseShell.swift`):一个对话属于最先推送或拉取它的那个账号:映射它的同步表是那个
账号的,每个在这台手机上同步过的账号都保留自己的表(`nanomuse-sync-accounts.json`,以中继
的不透明 `account.id` 为键,从不用手机号或邮箱)。登录后,聊天列表和「聊天」标签页显示这个
账号自己的对话,以及还没有任何账号同步过的对话;别的账号的留在手机上,隐藏,绝不会以当前
- 登录的账号推送出去——一个没有归属的对话在第一次推送时归入这个账号。换一个账号登录会把
+ 登录的账号推送出去;一个没有归属的对话在第一次推送时归入这个账号。换一个账号登录会把
这个账号的游标从头开始(`tail=300` 那次拉取)并忘掉在线状态;再以第一个账号登录,它的对话
和主要聊天都回来。退出登录后,手机上的一切都显示,什么也不动。智能体的名字和外观已经跟着
账号走;本地的记忆文件还没有。
- **一个账号的东西不留给下一个(0.1.40,契约 C12)**(`NanoMuseAccountData.swift`、
`NanoMuseSignOutSheet.swift`):每个聊天都有一条归属记录(`nanomuse-owners.json`),不管
同步没同步,列表、「聊天」标签页、资源库、*今天的对话*、Siri 快捷指令和推送都只显示当前
- 登录者的;退出登录后,只显示退出状态下创建的聊天。退出登录——在这里、在任何地方、*使用
- 其他服务器*——是一个带一个开关的面板,*把这个账号的聊天留在这台设备上*,默认关:关着,
+ 登录者的;退出登录后,只显示退出状态下创建的聊天。退出登录(在这里、在任何地方、*使用
+ 其他服务器*)是一个带一个开关的面板,*把这个账号的聊天留在这台设备上*,默认关:关着,
这个账号的聊天、记忆、动态、目标、例程和形象都离开手机(它的同步表也一起,这样之后的登录
绝不会在它的其他设备上留下墓碑);开着,它们被收到 `MinisConfig/nanomuse/accounts//`
下面,随账号回来。换另一个账号登录走的是同一条路。*删除账号* 把这一切全部移除,不再多问。
- 刷新时被中继拒绝的 key(`401 bad_key`——从另一台设备撤销、中继重置)走的是 *保留* 这条路:
- 数据收起来,key 删掉,登录表单显示 *这台手机上的登录已结束——重新登录即可继续;在那之前,
+ 刷新时被中继拒绝的 key(`401 bad_key`:从另一台设备撤销、中继重置)走的是 *保留* 这条路:
+ 数据收起来,key 删掉,登录表单显示 *这台手机上的登录已结束,重新登录即可继续;在那之前,
你的聊天会留在这台设备上*,直到下一次登录(`NanoMuseCloud.signInEnded`);只有
`401 account_deleted` 才删除(`NanoMuseAccountData.keepOnRefusedKey`,在
`NanoMuseAccountsTests` 里有测试)。中继的 key 只存在这台设备上
@@ -331,18 +347,18 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
`NanoMuseAvatarFlow.swift`、`NanoMuseDevicesSection.swift`、`NanoMuseShell.swift`):登录面板
打开时输入框上方是 App 的标志,输入框写着*中国大陆手机号或邮箱*,输入其他国家区号的号码时,
*短信验证码只发中国大陆号码……*一边输一边就出现在输入框下面,而不是点了之后才出现;脚注用
- Android 的那段小字(中继保存什么——账号 id、一个打码的标识、用量计数、智能体的名字和形象),
+ Android 的那段小字(中继保存什么:账号 id、一个打码的标识、用量计数、智能体的名字和形象),
链到隐私政策而不是 GitHub 页面。首次运行的「通知」页显示标志,和欢迎页、登录页一样。设了
视频模型时选了新形象之后,回复会说四段短片随后在后台生成(Android 的那句话)。账号的设备
列表里,离线设备显示最后一次在线的时间,点一台在线设备会打开主聊天并在输入框里填好
`@〈名字〉 `(Reach 面板的*问这台设备*)。`NanoMuseRoot` 在知道是否需要引导之前什么都不画,
所以全新安装绝不会在欢迎页之前闪过一帧主界面。第一次对话的第四句话不再说消息*只*发给
- 模型:登录后,主对话也会跟着人到他的其他设备上,*数据控制*可以关掉——和 Android 同样的
+ 模型:登录后,主对话也会跟着人到他的其他设备上,*数据控制*可以关掉,和 Android 同样的
话,每种语言都有。
- **审计第二轮(第 10 轮)**(`NanoMuseHub.swift`、`NanoMuseDevicesSection.swift`、
`NanoMuseScheduler.swift`、`NanoMuseSync.swift`、`NanoMuseAccountData.swift`、
`NanoMuseProxy.swift`、`NanoMuseImageGen.swift`、`NanoMuseVideoGen.swift`):hub 客户端会读
- 中继的关闭码([hub.md](hub.md))——4001、4002 之后不再重试,直到重新登录,设备一行显示
+ 中继的关闭码([hub.md](hub.md)):4001、4002 之后不再重试,直到重新登录,设备一行显示
*重新登录*;4003 等 30 秒;带 `hub_paused` 原因的关闭等两分钟,一行显示*中转已暂停*;其余
情况保持 1 → 30 秒的退避,显示*正在重新连接…*。其他设备发来的 `open` 只打开 `http` 和
`https` 链接,`tel:` 或别的 App 的 scheme 会以 `usage` 拒绝;一次调用的超时计时器随答复
@@ -350,8 +366,8 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
(`nanomuse-sync-accounts.json`、`nanomuse-owners.json`)和聊天本身一样用默认的数据保护
等级;文件在但读不出来时(手机锁着、App 在后台被唤醒),存储等下一次调用再读,而不是从
空表开始并把空表写回去覆盖每个账号的记录。图片和视频生成和同一服务商的聊天一样走
- 「网络」里的代理(`NanoMuseProxy.SessionSlot`),失败时是每种语言都有的整句——*服务商拒绝了
- 这把密钥(HTTP 401)*、*视频超过 12 分钟仍未完成*——服务商自己的话跟在*服务商说:*后面。
+ 「网络」里的代理(`NanoMuseProxy.SessionSlot`),失败时是每种语言都有的整句,*服务商拒绝了
+ 这把密钥(HTTP 401)*、*视频超过 12 分钟仍未完成*,服务商自己的话跟在「服务商说:」后面。
- **审计第三遍(第 11 轮)**(`NanoMuseHubTasks.swift`、`NanoMuseHub.swift`):其他设备发来的
`stop` 既能按任务帧的 id 找到这次运行(`stop {call}`,桌面端发的就是这个),也能按会话找;
只有发起任务的设备能停它;停下之后任务按 [hub.md](hub.md) 的约定答 `cancelled`。socket
@@ -382,8 +398,97 @@ scripts/gen-ios-icons.py the app icon and the four alternates, from asse
源码,我们的 key 里出现破折号、感叹号或翻译过的中继名就失败。服务商表单在保存 key 之前先拿它
去取服务商的模型列表:401 或 403 时手机保持原样,并在输入框下面说明原因;之前上游的回退会
填入一份目录里的模型列表,表单像 key 正确一样关闭。订阅的 429 带的 `Retry-After` 现在会传到
- 可达性卡片,ChatGPT 或 Claude 订阅也能看到*……后重置*。动态详情的几行是一个 `Grid`,标签列
+ 可达性卡片,ChatGPT 或 Claude 订阅也能看到「……后重置」。动态详情的几行是一个 `Grid`,标签列
按最长的标签取宽;形象大小改成菜单,不再是五段分段控件。
+- **第三轮审计:版本行(1.0.x)**(`NanoMuseUpdateCheck.swift`、
+ `MinisTests/NanoMuseLogicTests.swift`):配置过的镜像 `index.json` 的形状是
+ `{"releases": [{"tag": "v1.0.0", …}, …]}`,最新的在前,版本行现在就读这个,和 Android 的
+ `UpdateCheck.parseIndex` 一样;之前它找的 `ios.version`、`latest` 从来没有写进过索引,所以
+ 连不上 GitHub 的手机上这一行永远是*没查到*。从索引查到的版本点开去镜像的下载页,从
+ GitHub 查到的去它的发布页。`parseIndex` 和 `parseGitHubLatest` 是纯函数,有测试。
+- **第三轮审计:登录表单(1.0.x)**(`NanoMuseFirstRun.swift`):欢迎页的 *Sign in · free*
+ 以浮层打开账号页;验证码或密码通过后,浮层自己关上(对登录状态做 `nmOnChange`),引导已经
+ 翻到下一页,密码页或来源页。之前浮层停在登录后的账号页上,带着设备和同步那几行,人得把它
+ 滑掉才发现引导已经往前走了。
+- **第三轮审计:中文用词(1.0.x)**(`Localizable.xcstrings`、`NanoMuseShell.swift`、
+ `NanoMuseAccountView.swift`、`MinisTests/NanoMuseCopyTests.swift`):中文文案和 Android 说
+ 一样的词。19 个 key 里还写着英文 "agent" 或「代理」的地方(系统文件的说明、例程、*关于这个
+ 智能体*、形象失败那句)改成智能体(繁體 智慧體),「代理」在「设置 → 网络」里是代理服务器的
+ 意思;七句说「中转」的中继句子(*中继说*、*中继已暂停*、暂停注册、额度、同步、互联、私人
+ 中继)改成中继 / 中繼;*Hands* 保留英文,和 Android 的「设置 → Hands」一致;聊天标签有了
+ 自己的 key `tab.chat`(聊天,Android 的 `nm_tab_chat`),不再用上游的 *Chat*(闲聊);账号页
+ 的用量行里对话类写「对话」(`models.slot.chat`,Android 的 `nm_usage_kind_chat`)。
+ `NanoMuseCopyTests` 遇到中文里的 "agent"、英文原文说 agent 处的「代理」、「中转」和「双手」
+ 就失败。
+- **第三轮审计:来自其他设备的任务(1.0.x)**(`NanoMuseHubTasks.swift`、
+ `MinisTests/NanoMuseHubTasksTests.swift`):`task` 帧带着发起设备的界面语言(`language`,
+ 见 [hub.md](hub.md));iPhone 的 Muse 现在被要求用这个语言回答,之前只看文字本身,所以在
+ 英文桌面上对一个中文网页输入 `@iPhone` 的人会得到英文回答。`NanoMuseHubTasks.prompt`
+ 是纯函数,有测试。手机自己发出的 `@设备` 提及从 1.0.0 起就带这个标签。
+- **第三轮审计:Cloud 开关与对话(1.0.x)**(`NanoMuseModelSlots.swift`、`NanoMuseCloud.swift`、
+ `NanoMuseModelsView.swift`、`ProviderInstanceDetailView.swift` 里的 `// nanoMuse:` 改动点、
+ `MinisTests/NanoMuseModelSlotsTests.swift`):新对话还由账号回答时关掉*使用 nanoMuse Cloud
+ 模型*(设置 › 模型,或 Cloud 服务商自己页面上的*已启用*),对话槽和主要聊天会交给第一个能对话
+ 的自有服务商,用目录里它的默认模型(`NanoMuseSlotResolver.nextChat`,对应 Android 的
+ `ModelSlots.nextChat`;`NanoMuseModelSlots.chatLeaves` 经 `useForChat` 完成切换)。没有自有
+ 对话模型时开关被拒绝:`NanoMuseCloud.setModelsOn` 返回 false,开关保持打开,开关下面出现
+ *先添加一个自己的服务商;关掉 nanoMuse Cloud 后没有别的模型能回答。*,模型页在卡片内,服务商
+ 页在「状态」一节的脚注。之前开关会关掉,对话那一行仍写着账号,主要聊天停在上游的*未配置模型*
+ 错误上;现在经这个开关到不了*未设置*的状态。自有服务商在自己页面上关掉仍走上游的路:对话经
+ 默认分组重新解析,什么都不剩时说*未配置模型。请在设置中添加服务商。*
+- **底栏是一个悬浮的胶囊**(`NanoMuseShell.swift` → `NanoMuseBottomBar`,`NanoMuseFaces.swift`
+ 里的 `NanoMuseTones.tabIndicator` / `tabIndicatorGlyph`;对应 Android 的 `MuseBottomBar`,
+ @Ichiro786 的 #329):五个标签放在一个 `Capsule` 里,离底部安全区 12 pt,和上方的输入框
+ 一样宽(同样的 16 pt 边距;最宽 420 pt,更宽的屏幕上居中),只有图标,
+ SF 符号、无障碍标签和选中特征都和以前一样。胶囊是 `.ultraThinMaterial`,上面盖一层 55%
+ 不透明度的外壳 surface 色(对比度靠这层色,模糊靠材质,和 Android 在背景模糊上盖 55% surface
+ 一样),边上一道 1 pt 的 `NanoMuseTones.hairline`;原来那条通栏的条和它顶上的细线没有了。
+ 五个标签平分胶囊的宽度,每一格整格可点;当前这个用 `tabIndicatorGlyph` 色显示实心符号,底下一个
+ 60 × 46 pt 的 `tabIndicator` 色胶囊(聊天的墨色:浅色黑底白符号,深色白底黑符号,和 Android 的
+ `MuseBottomBar` 一样),点下一个标签时它带着弹簧滑过去
+ (`matchedGeometryEffect`;开了「减弱动态效果」就不带弹簧,直接过去);其他标签用次要颜色显示线框符号。键盘弹起或聊天的语音面板打开时,
+ 底栏仍然离开。布局:胶囊在一个贴底对齐的 `ZStack` 里盖在房间和聊天上面。聊天沿用上面
+ 0.1.40 的规矩(聊天层加一段底栏高度、70 pt 的底部 padding:46 加上下各 6,再加 12 pt 的间隙,所以输入区在胶囊上面,
+ `NavigationStack` 的祖先上没有任何 inset);房间是普通的 SwiftUI 滚动视图,同样的高度用
+ `safeAreaInset` 给出,于是它们的内容会滚到胶囊下面,玻璃才有东西可模糊。在没有 Mac 的 Linux
+ 上写的:iOS 检查能编译它,还没有设备显示过它。
+- **聊天是单色的**(`NanoMuseFaces.swift` 里的 `NanoMuseTones.ink` / `onInk` / `agentPill`,
+ `NanoMuseHeader.swift` 里的 `NanoMuseHeaderScrim`,`NanoMuseComposer.swift`,
+ `NanoMuseChatCards.swift`,以及 `Views/Chat/ChatMessageViews.swift` 和
+ `Views/Chat/AIChatView.swift` 里的 `// nanoMuse:` 行;对应 Android 的 #331,@Ichiro786)。开着
+ Muse 外壳时,你的消息是墨色的胶囊,文字用画布色:浅色主题下白色画布上是黑胶囊白字,深色主题下
+ 黑色画布上是白胶囊黑字,没有蓝也没有灰。智能体的回复放在一个比
+ 画布深一点或浅一点的胶囊里,浅色 `#F2F2F7`,深色 `#1C1C1E`,文字用主题的文字色;代码、表格和
+ 原始标记照旧不加胶囊。输入胶囊里的发送箭头是同一种墨色的 30 pt 圆盘,箭头用画布色(没有可发
+ 的内容时圆盘是一层淡淡的墨色,箭头 40%),智能体回答时的停止方块也在同一个圆盘上;输入胶囊
+ 本身还是灰的。列表下面*回到最新*的圆盘也是
+ 同一种墨色,箭头用画布色;它上面那颗往回走的按钮保留浅色圆盘。主聊天上方的标题栏不再是模糊
+ 层:一层画布色的渐变遮罩,顶边 80%,过半一点 35%,到底部透明,于是对话会滚到形象下面并融进
+ 画布,而不是停在一条线上;列表滚离最新消息时,再从顶边以 250 ms 淡入一层完整画布色的渐变
+ (外壳 `safeAreaInset` 的背景,像标题栏绑定心情那样绑定主聊天的 `isNearBottom`)。排队中的消息
+ 保留上游的虚线边框和灰字,*来自 {设备}* 的说明和附件都在胶囊外面,长按预览还是原来那张不透明
+ 的卡片。从抽屉推出的侧聊天保留系统栏和它的材质;房间保留它们的画布。在没有 Mac 的 Linux
+ 上写的:iOS 检查能编译它,还没有设备显示过它。
+
+## 用操作按钮说一句 {#voice-prompts-from-the-action-button}
+
+*语音提示*是 App 注册的一个快捷指令(`NanoMuse/VoicePromptIntent.swift`):打开 nanoMuse 并开始
+聆听,一句话可以说出来而不用打字。在「快捷指令」App 里绑定一次,放到操作按钮(设置 › 操作按钮 ›
+快捷指令)、轻点背面或小组件上;按一下,输入框就进入语音模式、麦克风已在听,说的话一边识别一边
+出现在输入框里。发送仍然是点一下发送键:识别错的一句话不能因为一个停顿就让智能体跑起来。快捷
+指令带一个可选的*会话*参数;留空时送到当前打开的聊天,在 Muse 布局里就是主要聊天(压在它上面
+的侧聊天会先退出)。第一次按会像麦克风按钮一样请求麦克风和语音识别权限;被拒绝时面板里会显示,
+什么都不会录。
+
+实现:意图自己驱动不了语音面板(冷启动时它在窗口存在之前就运行,面板归 SwiftUI 管),所以它在
+`NanoMuseVoicePrompt` 里留下一个请求,指定了会话时按通知点击的路子跳过去
+(`NotificationNavigationStore`、`.openSessionFromIntent`)。聊天的钩子(`NanoMuseChatHooks`)
+取走发给自己这个聊天的请求,沿麦克风按钮的路把输入框切到内嵌语音面板,面板出现后再按下它的
+麦克风,走 `VoiceInputViewModel.handleMainButtonTap` 和它的权限流程。只有一条录音路径、一套识别
+栈;转写文字照常进入 `inputText`。超过 30 秒没人接的请求会被丢弃。iOS 不让 App Intent 接收自由
+语音,所以这是一个语音入口,而不是一口气说完的「问 nanoMuse 某件事」;App 之所以要打开,是因为
+屏幕上什么都没有而麦克风在转,看起来就像出了故障。Siri 的唤起短语在三张 `AppShortcuts.strings`
+表里(英文、简体中文、繁体中文),意图自己的字符串在 `Localizable.xcstrings`(#296、#298)。
## 在 Mac 上构建 {#building-on-a-mac}
@@ -400,16 +505,16 @@ open src/ios/Minis.xcodeproj
```
选 **Minis** scheme,在 *Signing & Capabilities* 里设好你的团队(项目里的 `DEVELOPMENT_TEAM`
-是空的),面向真机构建:原生库只有真机版本,所以模拟器链接不了——见 BUILDING.md 的故障
+是空的),面向真机构建:原生库只有真机版本,所以模拟器链接不了;见 BUILDING.md 的故障
排查一节。
聊天屏幕的一条规矩,从 0.1.38 的构建 9 学来的:**不要在 `AIChatView.body` 的修饰符链末尾
再加任何东西。** 这个 body 是一个由大约六十个链式修饰符组成的表达式;它的 getter 把不断
变大的值的副本留在栈上,0.1.38 加的四个环节就足以在 iPad 上、引导结束后聊天一出现的瞬间
-撑爆主线程的 1 MB——每次启动都崩,在 `AIChatView.body.getter` →
+撑爆主线程的 1 MB:每次启动都崩,在 `AIChatView.body.getter` →
`__swift_instantiateConcreteTypeFromMangledNameV2`。我们自己的聊天级行为放在
`NanoMuse/NanoMuseChatModifiers.swift`(`NanoMuseChatHooks`、`NanoMuseComposerHost`)里:
-往那里加,或者加第三个修饰符——只占一环。输入区那一叠在那里故意用了 `AnyView`。
+往那里加,或者加第三个修饰符,只占一环。输入区那一叠在那里故意用了 `AnyView`。
`MinisTests/NanoMuseRound6Tests.swift` 测量 body 值的大小和类型深度,任一超过上限就失败。
## TestFlight {#testflight}
@@ -437,15 +542,16 @@ open src/ios/Minis.xcodeproj
- 内部 TestFlight 组 *nanoMuse Core*,自动分发,所以每个处理完的构建会自己到达测试员手里;
设备列表故意留空(这里没有什么需要设备);
- **第一个构建**:0.1.31 (2),2026-10-03 由工作流归档并上传,经 App Store Connect 处理
- (`VALID`,出口合规由 Info.plist 的键回答),正在内部组里做 beta 测试——第一个能从
+ (`VALID`,出口合规由 Info.plist 的键回答),正在内部组里做 beta 测试:第一个能从
TestFlight 装上的东西;
- 外部组需要的测试信息,英文和简体中文各一份:beta App 描述、反馈地址、营销和隐私政策链接,
以及每个构建的 *What to Test*(没有一处提到别的产品);外部组 *nanoMuse Beta* 和它的公开
链接 `https://testflight.apple.com/join/ZHexbDqc`;*Beta App Review Information* 里的 beta
审核联系人和中继上的一个审核账号;
-- **构建 14(0.1.41)** 在内部测试员手里,已加进 *nanoMuse Beta* 并提交 Apple 的 beta 审核
- (构建 13,0.1.40,是 2026-10-06 提交的);公开链接只在审核通过后才会给出构建,在那之前它显示的是一个没有 App 的
- TestFlight 页面。以后每个版本都要重复这一步(审核按版本进行)。任何通往 App Store 的事
+- **构建 16(1.0.0)** 于 2026-10-09 加进 *nanoMuse Beta* 并提交 Apple 的 beta 审核,已经通过,公开链接装的就是它
+ (在那之前链接给的是构建 14,0.1.41);构建 17 是打标签之后合并的修复加上 1.0.0,只到了内部测试员手里。
+ **构建 18(1.0.1)** 于 2026-10-10、**构建 19(1.0.2)** 于 2026-10-11、**构建 22(1.0.3)** 于 2026-10-12 加进 *nanoMuse Beta* 并提交审核,有一个通过之前链接装的仍是构建 16。公开链接只在审核
+ 通过后才会给出构建,以后每个新版本都要重复这一步(审核按版本进行;已通过版本的后续构建不用再审)。任何通往 App Store 的事
都没有:这个 App 不上架。
### 在 App Store Connect 里做一次的事 {#once-in-app-store-connect}
@@ -479,11 +585,11 @@ open src/ios/Minis.xcodeproj
Xcode 的自动签名(带 key 的 `-allowProvisioningUpdates`)是最初的方案,但对这样的团队行不通:
归档先用 *iOS App Development* 描述文件签名,导出时才重新签成商店用的,而开发描述文件必须
-至少列出一台设备——一个只通过 TestFlight 发布的团队一台也没注册,所以归档停在
+至少列出一台设备;一个只通过 TestFlight 发布的团队一台也没注册,所以归档停在
*Your team has no devices from which to generate a provisioning profile*。因此 lane 改用商店
自己的材料手动签名,这不需要设备:上面两个 secrets 里的 Apple Distribution 证书,以及四个
*App Store* 描述文件(App 和它的三个扩展),由 `get_provisioning_profile` 在每次运行开始时
-用 key 从账号下载——如果它们指向的证书不是钥匙串里的那一个,就在账号里修好。
+用 key 从账号下载,如果它们指向的证书不是钥匙串里的那一个,就在账号里修好。
证书是在没有 Mac 的情况下做的,过期或者 key 丢了可以用同样的办法再做一次(一个团队可以
持有两三个分发证书;到上限了就先在 *Certificates, Identifiers & Profiles → Certificates* 里
@@ -508,12 +614,18 @@ gh secret set IOS_DIST_P12_PASSWORD -R zeeshanhaque21/nanoMuse < dist.p12.pass
### 能编译吗? {#does-it-compile}
-*Actions → iOS · build check → Run workflow*(`.github/workflows/ios-check.yml`)在 Mac runner
-上关闭签名,面向真机构建这个 App——不要 Apple 账号,不要 secrets。它和 TestFlight 工作流
-共用原生依赖的缓存,所以先跑它:一个编译错误在这里只花几分钟,不用浪费一次上传。完整的
+*iOS · build check*(`.github/workflows/ios-check.yml`)在 Mac runner 上关闭签名,面向真机构建
+这个 App:不要 Apple 账号,不要 secrets。凡是改到 `android/src/ios/` 的 pull request 都会跑它;
+其他情况(改了 `android/deps/` 下的脚本、还没开 pull request 的分支)用 *Actions → iOS · build
+check → Run workflow* 手动启动。它和 TestFlight 工作流共用原生依赖的缓存,所以先让它变绿:
+一个编译错误在这里只花几分钟,不用浪费一次上传。完整的
`xcodebuild` 日志附在运行记录上。`NanoMuse/` 下的文件只要报出一个警告,这次运行就算失败
(上游的文件不算),所以上面「零警告」的规矩是被检查的,不只是写在这里。
+字符串目录另有一项检查,每个 pull request 都在 Linux 上跑:`tests/test_ios_strings.py` 逐对键值
+解析 `Localizable.xcstrings`,同一个键出现两次就失败(JSON 解析器会悄悄留下其中一条、丢掉另一条,
+Xcode 也一样,于是真正打进包里的翻译取决于哪一条赢了)。
+
### 跑起来 {#running-it}
*Actions → iOS · TestFlight → Run workflow*,或者推一个 `ios-` 标签。任务会构建原生
@@ -529,24 +641,24 @@ runner 是 `macos-26`;你的 GitHub 套餐里没有这个标签的话,退路
## 接下来 {#what-follows}
nanoMuse 的样子长在 Android App 里;从 0.1.34 起,iPhone 在 OpenMinis 之上有了同样的样子
-——智能体的页面、从聊天里换形象、目标、动态、例程、第一次打开——文案里写明了 iOS 对后台
+(智能体的页面、从聊天里换形象、目标、动态、例程、第一次打开),文案里写明了 iOS 对后台
工作的限制。Android 代码在哪里,到了这里变成了什么:
| Android(`io.github.nanomuse.*`) | iOS 上 |
|---|---|
-| `cloud`——中继客户端、登录、账号 | 已完成:`NanoMuse/NanoMuseCloud*.swift`、`NanoMuseAccount*.swift`(0.1.32:密码、邀请码、以元计的额度、用量、会话、时间线、删除) |
-| `ui.onboarding`——带 *登录,免费开始* 的引导 | 已完成(0.1.34):`NanoMuseFirstRun.swift`,含第一次对话;0.1.35 加了通知页;iOS 上没有「手」那一页 |
-| `community.StarPrompt`、`community.Nudges`——按中继策略的 star 提示 | 已完成(0.1.35):`NanoMuseNudges.swift`、`NanoMuseStar`——时机、冷却和上限来自 `/v1/nudges` |
-| `community.UpdateCheck`、版本行 | 已完成(0.1.35):`NanoMuseUpdateCheck.swift`——已安装和最新,GitHub 的发布 |
-| `avatar.VideoGen`、`avatar.AvatarMotion`——动作短视频 | 已完成(0.1.35):`NanoMuseVideoGen.swift`、`NanoMuseAvatarMotion.swift`、`NanoMuseMediaModels.swift` |
-| `nm.show_steps`——智能体的步骤,从 0.1.37 起默认显示 | 0.1.32:`NanoMuseSteps.swift`;已完成的消息只留对话本身,正在进行的那条显示它的步骤 |
-| `connectors`——目录、`SharedConnectors` | 已完成:`NanoMuseConnectors.swift`(0.1.33 目录,0.1.34 其他设备的条目) |
-| `ui.home`、`ui.chat`、`ui.settings`、`ui.profile`——外壳、标题栏、智能体页面 | 已完成:`NanoMuseShell.swift`、`NanoMuseHeader.swift`、`NanoMuseAgentPage.swift`、`NanoMuseSettings.swift`;0.1.35 每个房间都有 Muse 标题栏,设置做成 Muse 卡片(`NanoMuseChrome.swift`、`NanoMuseAppearance.swift`) |
-| `avatar`——画出来的脸、从聊天里换、工作室 | 已完成(0.1.34):`NanoMuseAvatarFlow.swift`、`NanoMuseAvatarStudio.swift`、`NanoMuseImageGen.swift` |
-| `goals`、`feed`、调度器 | 已完成(0.1.34),到 iOS 允许的程度:前台补跑、`BGAppRefreshTask`、本地通知。App 睡着时没有闹钟级的准点运行——文案里写明了 |
-| `coding`——通过 hub 访问电脑上的编程助手 | 已完成(0.1.34):`NanoMuseCoding.swift`;电脑上要跑着用同一个账号登录的 nanoMuse |
-| `reach`——手机操控电脑 | 已完成:`NanoMuseReach.swift`,通过 hub |
-| `hands`——手机自己的屏幕 | 没有对应物:iOS 不允许一个 App 操控另一个。App Intents / 快捷指令是那里的门 |
+| `cloud`:中继客户端、登录、账号 | 已完成:`NanoMuse/NanoMuseCloud*.swift`、`NanoMuseAccount*.swift`(0.1.32:密码、邀请码、以元计的额度、用量、会话、时间线、删除) |
+| `ui.onboarding`:带 *登录,免费开始* 的引导 | 已完成(0.1.34):`NanoMuseFirstRun.swift`,含第一次对话;0.1.35 加了通知页;iOS 上没有「手」那一页 |
+| `community.StarPrompt`、`community.Nudges`:按中继策略的 star 提示 | 已完成(0.1.35):`NanoMuseNudges.swift`、`NanoMuseStar`(时机、冷却和上限来自 `/v1/nudges`) |
+| `community.UpdateCheck`、版本行 | 已完成(0.1.35):`NanoMuseUpdateCheck.swift`(已安装和最新,先这个仓库的 GitHub Releases 再配置过的镜像) |
+| `avatar.VideoGen`、`avatar.AvatarMotion`:动作短视频 | 已完成(0.1.35):`NanoMuseVideoGen.swift`、`NanoMuseAvatarMotion.swift`、`NanoMuseMediaModels.swift` |
+| `nm.show_steps`:智能体的步骤,从 0.1.37 起默认显示 | 0.1.32:`NanoMuseSteps.swift`;已完成的消息只留对话本身,正在进行的那条显示它的步骤 |
+| `connectors`:目录、`SharedConnectors` | 已完成:`NanoMuseConnectors.swift`(0.1.33 目录,0.1.34 其他设备的条目) |
+| `ui.home`、`ui.chat`、`ui.settings`、`ui.profile`:外壳、标题栏、智能体页面 | 已完成:`NanoMuseShell.swift`、`NanoMuseHeader.swift`、`NanoMuseAgentPage.swift`、`NanoMuseSettings.swift`;0.1.35 每个房间都有 Muse 标题栏,设置做成 Muse 卡片(`NanoMuseChrome.swift`、`NanoMuseAppearance.swift`) |
+| `avatar`:画出来的脸、从聊天里换、工作室 | 已完成(0.1.34):`NanoMuseAvatarFlow.swift`、`NanoMuseAvatarStudio.swift`、`NanoMuseImageGen.swift` |
+| `goals`、`feed`、调度器 | 已完成(0.1.34),到 iOS 允许的程度:前台补跑、`BGAppRefreshTask`、本地通知。App 睡着时没有闹钟级的准点运行;文案里写明了 |
+| `coding`:通过 hub 访问电脑上的编程助手 | 已完成(0.1.34):`NanoMuseCoding.swift`;电脑上要跑着用同一个账号登录的 nanoMuse |
+| `reach`:手机操控电脑 | 已完成:`NanoMuseReach.swift`,通过 hub |
+| `hands`:手机自己的屏幕 | 没有对应物:iOS 不允许一个 App 操控另一个。App Intents / 快捷指令是那里的门 |
| 小组件 | 上游的 `AgentWidget` 原样 |
还要在真机上验证的,按顺序:用新账号从头到尾走一遍第一次打开;通过中继和通过百炼 key
diff --git a/docs/zh/own-key.md b/docs/zh/own-key.md
index 3b19451f..3f0b02d1 100644
--- a/docs/zh/own-key.md
+++ b/docs/zh/own-key.md
@@ -123,7 +123,7 @@ ChatGPT 登录走的是 OpenAI 自家 Codex 的授权流程(网页控制台:
## 本机模型 {#local-models}
-Ollama、LM Studio、vLLM 跑在自己电脑上的模型也能用:选对应的预设(网页控制台:「连接」;桌面版:设置 → nanoMuse Cloud;手机:设置 → 服务商),地址默认是本机端口(`http://127.0.0.1:11434/v1`、`:1234`、`:8000`),不需要 key。它们算「对话」;手的模型要能看图,本机跑一个多模态模型(例如 Ollama 的 `qwen3-vl`)再在「操作屏幕」里选它(网页控制台:在「手的模型」里手填 id)。画图和视频需要上面表里的一家。
+Ollama、LM Studio、vLLM 跑在自己电脑上的模型也能用:选对应的预设(网页控制台:「连接」;桌面版:设置 → nanoMuse Cloud;手机:设置 → 服务商),地址默认是本机端口(`http://127.0.0.1:11434/v1`、`:1234`、`:8000`),不需要 key。它们算「对话」;手的模型要能看图,本机跑一个多模态模型(例如 Ollama 的 `qwen3-vl`)再在「操作屏幕」里选它(网页控制台:「对话模型」下面的「动手模型」一行列出这个端点提供的模型,在那里选它)。画图和视频需要上面表里的一家。
## 其他 OpenAI 兼容服务 {#any-other-openai-compatible-endpoint}
@@ -136,3 +136,5 @@ Ollama、LM Studio、vLLM 跑在自己电脑上的模型也能用:选对应的
| 模型 | 保存后从列表里选;接口列不出模型时,表单会请你填模型 id(逗号分开),再按这些保存 |
自定义接口能做什么由你来说:nanoMuse 会把它当作对话;手的模型、图片模型、视频模型各自填了 id 才算有。
+
+**几个同时用。** 在桌面版和网页控制台里,自定义接口是一个列表,不是一个槽位(#294):每个加进来的接口都有自己的 key 和自己的模型,再加一个绝不会替换掉前一个。桌面版的 设置 → 账号 → 任何 OpenAI 兼容接口 下,每个接口一行(名字在上、主机名在下,带「换 key」「改名」「移除」),下面是「添加接口」一行;设置 → 模型 里的选择器把每个接口列成单独一组。网页控制台的 连接 → 对话模型 里,「本地或你自己的端点」下每个接口一块卡片,最后是「添加接口」;双手、图片和短片的选择器也列出这些接口([web.md](web.md#where-it-shows))。列表出现之前设好的自定义接口,下次启动时成为列表的第一项,key 一并带过去。手机上每家服务商还是一个槽位。
diff --git a/docs/zh/parity.md b/docs/zh/parity.md
index 242ad1a5..82925b24 100644
--- a/docs/zh/parity.md
+++ b/docs/zh/parity.md
@@ -1,6 +1,6 @@
# 各客户端的功能对照
-nanoMuse 是同一个智能体,在每台设备上都是它:手机(Android、iOS)、电脑(nanoMuse 桌面版——Electron 外壳里的 `dsh-nanomuse` harness 包)和浏览器(nanoMuse Web,由运行时提供)。从 0.1.32 起的规则是**取并集**:一个客户端能做的,每个客户端都要能做,除非平台本身不允许(电脑不会像手机那样搭一个迷你 Linux;iOS 不让一个 App 操控另一个)。这一页是账本——每个客户端有什么,哪些是有意为之的平台差异,哪些还等着拍板。
+nanoMuse 是同一个智能体,在每台设备上都是它:手机(Android、iOS)、电脑(nanoMuse 桌面版,即 Electron 外壳里的 `dsh-nanomuse` harness 包)和浏览器(nanoMuse Web,由运行时提供)。从 0.1.32 起的规则是**取并集**:一个客户端能做的,每个客户端都要能做,除非平台本身不允许(电脑不会像手机那样搭一个迷你 Linux;iOS 不让一个 App 操控另一个)。这一页是账本:每个客户端有什么,哪些是有意为之的平台差异,哪些还等着拍板。
图例:**✓** 已做 · **◐** 部分(缺什么写在备注里)· **—** 这个客户端上没有 · **n/a** 平台特有,本来就不打算做。
@@ -31,13 +31,13 @@ nanoMuse 是同一个智能体,在每台设备上都是它:手机(Android
| 有自己的 key,App 不登录也能用(对话、动手、图片、短片);第一屏给出「改用自己的 API key」,登录留给 Cloud 模型、同步和设备 | ✓ | ✓ | ✓ 登录从来不是墙 | ✓ 有模型就绪或按过那个按钮后登录页让开 |
| 不悄悄回退:自有模型失败的一轮给「这次改用 nanoMuse Cloud」(登录时),只这一轮走账号,不改任何一行 | ✓ 0.1.41 `CloudRetry` | ✓ 0.1.41 `NanoMuseCloudOnce` | ✓ 0.1.41 形象工作室的一轮失败和一组短视频失败之后也有 | — 显示失败;各行保持原样 *(40)* |
| 「继续用的办法」按地区排序(中国大陆百炼在前,其他地区 OpenRouter 在前) | ✓ 0.1.34 | ✓ 0.1.34 | ✓ 0.1.34 | ✓ 0.1.34 |
-| 自己的 key 的目录(`providers.json`:18 家服务商,各家覆盖什么——对话 · 屏幕 · 图片 · 短视频——拿 key 的页面、地区;一个文件,为每个客户端生成) | ✓ 0.1.39 | ✓ 0.1.40 `NanoMuseCatalogue` *(32)* | ✓ 0.1.39 | ✓ 0.1.39 |
+| 自己的 key 的目录(`providers.json`:20 家服务商,各家覆盖什么(对话 · 屏幕 · 图片 · 短视频)、拿 key 的页面、地区;一个文件,为每个客户端生成) | ✓ 0.1.39 | ✓ 0.1.40 `NanoMuseCatalogue` *(32)* | ✓ 0.1.39 | ✓ 0.1.39 |
| 从目录生成「继续用的办法」:先是本地区的首选,再「更多服务商」,可以直接登录的套餐,key 就地填 | ✓ 0.1.39 | ✓ 0.1.40 置顶卡片、设置 → nanoMuse Cloud 和自己的 key 面板,同一个列表(`NanoMuseWaysList`)*(32)* | ✓ 0.1.39 设置 → nanoMuse Cloud,自己的 key 的首次运行步骤 | ✓ 0.1.39 |
| 办法读自中继的 `spend.guidance`(本地区的服务商、套餐、注意事项、文档),而不是 App 里写死的列表;只有中继没给时才用内置目录 | ✓ 0.1.40 先用 `guidance`,没有时用目录 *(34)* | ✓ 0.1.40 相同(`NanoMuseWays`)*(32)* | ✓ 0.1.40 聊天卡片和设置 → nanoMuse Cloud,同一个组件 | ✓ 0.1.39 |
-| 被拒的一轮(`429 allowance_exhausted`)在聊天里是它下面的一张卡片:一句话、三个继续用的办法、「打开设置」「再试一次」——绝不把中继的回复当文字显示 | ✓ 0.1.39 `AllowanceWaysCard` | ✓ 0.1.40 像 Star 卡片一样钉在页眉下(`NanoMuseAllowanceCard`)*(33)* | ✓ 0.1.40 *(之前:显示 JSON,重试五次后当成限流)* | ✓ 0.1.39 带办法的提示 |
+| 被拒的一轮(`429 allowance_exhausted`)在聊天里是它下面的一张卡片:一句话、三个继续用的办法、「打开设置」「再试一次」;绝不把中继的回复当文字显示 | ✓ 0.1.39 `AllowanceWaysCard` | ✓ 0.1.40 像 Star 卡片一样钉在页眉下(`NanoMuseAllowanceCard`)*(33)* | ✓ 0.1.40 *(之前:显示 JSON,重试五次后当成限流)* | ✓ 0.1.39 带办法的提示 |
| 中继的其他拒绝是一句话加一个按钮:413 *太大* → 「新对话」,401 → 「登录」,403,429 忙(附等待时间),每日上限,404 模型,5xx / 没有回应 → 「再试一次」;绝不显示状态码或 JSON | ✓ 0.1.40 `RelayRefusal` + `RelayRefusalCard`,17 种语言 *(35)* | ✓ 0.1.40 `NanoMuseRelayRefusal` + `NanoMuseRelayRefusalCard`,9 种语言;被拒的聊天轮次经过 `describe` *(35)* | ✓ 0.1.40 | ✓ 0.1.40 `failures.py`(新增 413、`not_invited`、`too_many_in_flight`、`provider_busy`) |
| 运营者的开关(中继 0.22,[cloud.md](cloud.md#controls))显示为平白的句子:额度*已暂停,不是用完了*(同一张卡片)、`service_paused`、`sync_paused`、`hub_paused`,登录时的 `signup_closed` | ✓ 0.1.40 *(35)* | ✓ 0.1.40 *(35)* | ✓ 0.1.40 | ✓ 0.1.40 |
-| 用 ChatGPT 套餐登录——只有对话和手看屏幕这两项,附一行关于 OpenAI 条款的话 | ✓ 0.1.39 上游的登录方式(还有 Claude、Kimi、OpenRouter) | ✓ 0.1.40 从「继续用的办法」走上游的 Codex OAuth(还有 Claude、Kimi、OpenRouter)*(32)* | ✓ 0.1.39 通过内置的运行时 | ✓ 0.1.39 运行时的 `nanomuse chatgpt login` |
+| 用 ChatGPT 套餐登录:只有对话和手看屏幕这两项,附一行关于 OpenAI 条款的话 | ✓ 0.1.39 上游的登录方式(还有 Claude、Kimi、OpenRouter) | ✓ 0.1.40 从「继续用的办法」走上游的 Codex OAuth(还有 Claude、Kimi、OpenRouter)*(32)* | ✓ 0.1.39 通过内置的运行时 | ✓ 0.1.39 运行时的 `nanomuse chatgpt login` |
| 没有任何已配置服务商能提供的能力,显示为一句话,点明谁能提供(图片、短视频、屏幕),绝不是原始错误 | ✓ 0.1.39 | ✓ 0.1.41 模型页上空的一行写明缺什么,并给「添加服务商」 *(32)* | ✓ 0.1.39 | ✓ 0.1.39 |
| 对话属于同步它们的那个账号:另一个账号的留在设备上,隐藏,绝不用新 key 推上去;切换账号会重新开始拉取(约定 C10) | ✓ 0.1.39 | ✓ 0.1.39 | ✓ 0.1.39 | ✓ 0.1.39 运行时的列表 |
@@ -48,12 +48,12 @@ nanoMuse 是同一个智能体,在每台设备上都是它:手机(Android
| 状态行说它正在做什么,绝不写「思考中」 | ✓ | ✓ 0.1.32 *「正在处理」* | ✓ | ✓ |
| 状态行 = 这一步自己的话(`step`:*打开携程网站*),绝不是原始命令 | ✓ 0.1.33 | ✓ 0.1.33 | ✓ 0.1.33 | ✓ 0.1.33 |
| 「显示执行步骤」,0.1.37 起默认开(存过的关闭保持关闭) | ✓ 0.1.37(`nm.show_steps`) | ✓ 0.1.37(`nanomuse.show_steps`)*(2)* | ✓ 0.1.37 | ✓ 0.1.37 |
-| 按中继定的时机请求点 Star(`/v1/nudges`:登录 · 第 3 / 10 / 30 个任务 · 第 7 / 30 天 · 达成一个目标 · 换了新形象 · 额度用完;间隔 7 天,每台设备 4 次;第一次对话里绝不问) | ✓ 0.1.35 | ✓ 0.1.35 *(3)* | ✓ 0.1.35 | ✓ 0.1.35 |
+| 按中继定的时机请求点 Star(`/v1/nudges`:登录 · 第 3 / 10 / 30 个任务 · 第 7 / 30 天 · 达成一个目标 · 换了新形象 · 额度用完;间隔 7 天,每台设备 4 次;第一次对话里绝不问)。每张卡片在「去 GitHub 点亮 Star」旁边都有「已经点过了」,点哪个这台设备都不再问 | ✓ 0.1.35 | ✓ 0.1.35 *(3)* | ✓ 0.1.35 | ✓ 0.1.35 |
| 第一次对话:App 先开口,问该怎么称呼你,模型的 `nanomuse-naming` 围栏变成起名卡片 | ✓ | ✓ 0.1.34 | ✓ 0.1.35 *(25)* | ✓ *(39)* |
-| 第一次打开:先于一切的「登录——免费」 | ✓ | ✓ 0.1.32 *(4)* | ✓ | ✓ |
+| 第一次打开:先于一切的「登录(免费)」 | ✓ | ✓ 0.1.32 *(4)* | ✓ | ✓ |
| 审批卡片,三个层级,记住的授权 | ✓ | 上游的 | ✓ | ✓ |
| 手在干活时,在 App 外面回答审批 | ✓ 0.1.33 胶囊上的「允许 / 拒绝」 | n/a *(10)* | ✓ 0.1.34 舞台上的「允许一次 / 在这个 App 里始终允许 / 拒绝」,主窗口被挡住时在胶囊上 *(17)* | n/a |
-| 交接:登录 / 验证码 / 付款 / 人机验证交回给人,智能体等着再接着做(hold) | ✓ 0.1.33 `hand_over` | — *(16)* | ✓ 0.1.34 「该你了——好了」「我来接手」*(16)* | ✓ 0.1.34 可以操作的浏览器视图、hold 卡片 *(16)* |
+| 交接:登录 / 验证码 / 付款 / 人机验证交回给人,智能体等着再接着做(hold) | ✓ 0.1.33 `hand_over` | — *(16)* | ✓ 0.1.34 「该你了」(带「好了」)、「我来接手」*(16)* | ✓ 0.1.34 可以操作的浏览器视图、hold 卡片 *(16)* |
| 点形象打开智能体页(换形象 · 改名字 · 工作室;动态 · 审批 · 日记 · SOUL 与记忆;分享卡片) | ✓ | ✓ 0.1.34 | ✓ 0.1.34 | ✓ Muse 页 |
| 在聊天里换形象(同样的话、四个候选、用话挑、重画) | ✓ | ✓ 0.1.34 | ✓ 0.1.34 | ✓ 工作室拦截,参考图 0.1.34 |
@@ -69,14 +69,14 @@ nanoMuse 是同一个智能体,在每台设备上都是它:手机(Android
| 技能 | ✓ 上游 | ✓ 上游 | — *(8)* | ✓ |
| 编程助手(电脑上的 Cursor、Codex、Claude Code) | ✓ | ✓ 0.1.34 经 hub *(9)* | ✓ 「设置 → 编程助手」,先这台电脑;自己声明并应答 `coding.*` *(8)* | ✓ |
| 账号的设备:远程控制、改名、忘记 | ✓ | ◐ 列表,Reach 面板 0.1.33 | ✓ | ✓ |
-| hub,出站:模型够得到账号的其他设备(`devices`、`device_*`、`delegate`、`@`) | ✓ 「电脑」,Reach 的转派 | ◐ 手动的 Reach 面板,`@device` 发到 hub——第 9 轮起,在账号的设备列表里点一台在线设备也行,和 Android 一样;模型手里没有工具 *(29)* | ✓ `reach.ts` | ✓ 运行时的 `tools/devices.py` |
+| hub,出站:模型够得到账号的其他设备(`devices`、`device_*`、`delegate`、`@`) | ✓ 「电脑」,Reach 的转派 | ◐ 手动的 Reach 面板,`@device` 发到 hub;第 9 轮起,在账号的设备列表里点一台在线设备也行,和 Android 一样;模型手里没有工具 *(29)* | ✓ `reach.ts` | ✓ 运行时的 `tools/devices.py` |
| hub,入站:这台设备回应别的设备(shell · 文件 · 打开 · 屏幕 · 通知 · 任务) | ✓ `HubActions` | ◐ 信息 · 打开 · 通知 · 任务;shell、文件和屏幕回答*不支持* *(10)* | ✓ `actions.ts` | ✓ 运行时;浏览器标签页不算一台设备 |
-| 手——这台设备自己的屏幕当手 | ✓ | n/a *(10)* | ✓ Linux X11(Wayland *(21)*);macOS 上一次一个窗口、鼠标仍归人、按 App 授权、光晕和胶囊不进截图 *(19)*;Windows 待试 *(31)* | n/a |
+| 手:这台设备自己的屏幕当手 | ✓ | n/a *(10)* | ✓ Linux X11(Wayland *(21)*);macOS 上一次一个窗口、鼠标仍归人、按 App 授权、光晕和胶囊不进截图 *(19)*;Windows 待试 *(31)* | n/a |
| 反馈问题:点一下打开一个 GitHub issue | ✓ 设置 → 账号 | ✓ 关于 → 「报告问题」 | ✓ 角落菜单;在外壳里截图存到「下载」,issue 带着内容打开 | ✓ 设置 → 反馈 |
| 已安装和最新版本并排显示(GitHub 的发布,缓存一天) | ✓ 0.1.35 设置 → 版本 | ✓ 0.1.35 设置 → 版本 | ✓ 0.1.35 关于,设置里一行 | ✓ 0.1.35 设置 |
| 全黑的截图是一个带修法的错误(录屏、Wayland),绝不是一张图 | n/a | n/a | ✓ 0.1.33 | n/a |
| macOS 权限实时读回;询问后「打开系统设置」;录屏需要重启的提示 | n/a | n/a | ✓ 0.1.33;0.1.35:只需打开一项,「试一下」行,录屏打开时弹重启对话框;0.1.38 起那一项是辅助应用「nanoMuse Computer Use」 *(26)* | n/a |
-| 设备上的迷你 Linux 沙箱 | ✓ | ✓ 上游(iSH) | n/a——运行时有自己的沙箱 | n/a |
+| 设备上的迷你 Linux 沙箱 | ✓ | ✓ 上游(iSH) | n/a(运行时有自己的沙箱) | n/a |
## 形象、房间、外观 {#face-rooms-look}
@@ -106,56 +106,56 @@ nanoMuse 是同一个智能体,在每台设备上都是它:手机(Android
## 等着拍板的(「下下步」清单) {#open-for-a-decision-the-next-next-list}
-这些是 0.1.32 起有意留着的缺口——要么平台决定了它得是另一种设计,要么它大到该由维护者来定。编号和上面备注里的一致;已经定下来的项保留编号,并写明结果。
+这些是 0.1.32 起有意留着的缺口:要么平台决定了它得是另一种设计,要么它大到该由维护者来定。编号和上面备注里的一致;已经定下来的项保留编号,并写明结果。
-1. **iOS · 数据控制**——0.1.33 做完(`NanoMuseDataControls.swift`)。
+1. **iOS · 数据控制。** 0.1.33 做完(`NanoMuseDataControls.swift`)。
2. **iOS · 步骤。** 页眉那一行(和形象)0.1.33 起就有了;正在运行的那条消息的步骤在设置关着时仍然可见,已完成的消息则把它们藏起来。*既然状态行已经说明在做什么,这些也一起藏起来?*
-3. **iOS · 第一个任务后请求点 Star**——0.1.33 做完,做成钉在页眉下面的一张卡片(消息列表是一个 UICollectionView;最后一条消息下面放不了东西)。取消的一轮算作完成,流里没有取消信号。0.1.35 起这张卡片和其他客户端一样跟着中继的策略走(28):第一次对话里不问,第 3 个任务是第一个时机。
-4. **iOS · 第一次打开**——0.1.34 做完(`NanoMuseFirstRun.swift`,Android 的 `needed / stage` 逻辑)。凡是没有 Cloud 账号的人都会看到,所以一个只用过自己的 key 的人升级后会看到一次;「全部设置」可以跳过它。
+3. **iOS · 第一个任务后请求点 Star。** 0.1.33 做完,做成钉在页眉下面的一张卡片(消息列表是一个 UICollectionView;最后一条消息下面放不了东西)。取消的一轮算作完成,流里没有取消信号。0.1.35 起这张卡片和其他客户端一样跟着中继的策略走(28):第一次对话里不问,第 3 个任务是第一个时机。
+4. **iOS · 第一次打开。** 0.1.34 做完(`NanoMuseFirstRun.swift`,Android 的 `needed / stage` 逻辑)。凡是没有 Cloud 账号的人都会看到,所以一个只用过自己的 key 的人升级后会看到一次;「全部设置」可以跳过它。
5. **Android · OAuth 连接器需要真机测试。** 发现、动态客户端注册、PKCE,以及写进条目 `Authorization` 头的令牌,都能编译,而且和桌面的流程逐行一致,但 0.1.32 之前没有在真机上登录过任何一个连接器(构建机上没有模拟器);0.1.33 的 client-id 路径(GitHub、Slack……)和浏览器交接也一样。key 式和开放式的连接器就是普通的 MCP 条目,不需要新东西。令牌最终和 API key 一起放在 `servers.json` 里,因为沙箱里的客户端读它;刷新在 App 启动和打开页面时跑,不在后台跑。
-6. **iOS · 连接器**——0.1.33 做完(`NanoMuseConnectors.swift`,移植了 Android 的流程),和第 5 条一样的保留:编译通过,还没在真机上登录过。
+6. **iOS · 连接器。** 0.1.33 做完(`NanoMuseConnectors.swift`,移植了 Android 的流程),和第 5 条一样的保留:编译通过,还没在真机上登录过。
7. **网页 · 连接器。** 运行时得自己跑 OAuth 流程并保管令牌(桌面在 harness 宿主里做这件事)。网页版有手动添加的 MCP 服务器。*决定:在运行时里跑这个流程(`nanomuse mcp` 已经在桥接连接器),还是让网页版指向账号上的一台桌面?*
8. **桌面 · 技能页和编程助手页。** harness 有自己的技能,技能页会和它们重复:不做。编程助手页做完了:「设置 → 编程助手」先显示这台电脑的 CLI 和聊天,再是账号下的其他电脑,而且桌面版自己声明并应答 `coding.*` hub 动作(`src/coding.ts`),所以手机把只装了桌面版的电脑也看作一台编程电脑。
-9. **iOS · 编程助手**——0.1.34 做完(`NanoMuseCoding.swift`,hub 的 `coding.*`)。
-10. **iOS · 手。** iOS 不让一个 App 操控另一个;App Intents / 快捷指令是那扇门。不打算做成「手」。0.1.36 定下:设置里有那一行(「Hands——iPhone 上不可用」),点开是一页,说明为什么,以及账号上的一台电脑能做什么;App 开着时,iPhone 自己的 Muse 接受其他设备发来的 `task` 调用(`@iPhone …`)。
-11. **iOS · Muse 外壳**——0.1.33 在 iPhone 上做完;0.1.34 起动态和目标是真的(`NanoMuseScheduler.swift`:回到前台时补跑、`BGAppRefreshTask`、到点的本地通知),iPad 也跑这个外壳,外壳和页眉的开关在 nanoMuse 设置页里,工作室通过中继或你自己的百炼 key 画图。还剩的:例程只在 iOS 给刷新任务分到时间片时才在后台跑——文案就是这么写的(*到点时手机提醒你打开它*);hub 这条路(账号上的一台电脑替它跑)仍然是稳妥的那条。iPad 的布局就是放大的 iPhone 布局——0.1.35 定下:不单独做分栏布局。
+9. **iOS · 编程助手。** 0.1.34 做完(`NanoMuseCoding.swift`,hub 的 `coding.*`)。
+10. **iOS · 手。** iOS 不让一个 App 操控另一个;App Intents / 快捷指令是那扇门。不打算做成「手」。0.1.36 定下:设置里有那一行(「Hands:iPhone 上不可用」),点开是一页,说明为什么,以及账号上的一台电脑能做什么;App 开着时,iPhone 自己的 Muse 接受其他设备发来的 `task` 调用(`@iPhone …`)。
+11. **iOS · Muse 外壳。** 0.1.33 在 iPhone 上做完;0.1.34 起动态和目标是真的(`NanoMuseScheduler.swift`:回到前台时补跑、`BGAppRefreshTask`、到点的本地通知),iPad 也跑这个外壳,外壳和页眉的开关在 nanoMuse 设置页里,工作室通过中继或你自己的百炼 key 画图。还剩的:例程只在 iOS 给刷新任务分到时间片时才在后台跑;文案就是这么写的(*到点时手机提醒你打开它*);hub 这条路(账号上的一台电脑替它跑)仍然是稳妥的那条。iPad 的布局就是放大的 iPhone 布局。0.1.35 定下:不单独做分栏布局。
12. **Android 和 iOS · Emoji 形象。** 在网页或桌面上把形象设成 emoji,手机上显示的是小龙。小问题。
-13. **主题色色块**——0.1.33 定下:从桌面移除;到处一条规则,强调色跟着形象走。
+13. **主题色色块。** 0.1.33 定下:从桌面移除;到处一条规则,强调色跟着形象走。
14. **Android · 记忆作为一个房间。** 手机上它是一个设置页,别处是一个房间。设计上的取舍。
15. **桌面上的浏览器视图。** 聊天里的轨迹展示手的动作;像网页版那样的页面视图没有。设计上的取舍。
-16. **桌面和网页上的交接**——0.1.34 两边都定下:运行时里的 *hold*(`nanomuse/agent/holds.py`;`browser`、`computer_act`、`phone_act` 上的 `hand_over`;`POST /api/holds`、`/done`;`hold` 事件;智能体最多等十分钟,然后再看一眼)。桌面的舞台显示「该你了——好了」和「我来接手」;网页的浏览器视图可以操作(「接管」、点击、输入、滚动、URL、「完成」)。还剩的:iOS 没有手,所以那里没有可交接的(10)。
-17. **桌面 · 窗口外的审批**——0.1.34 定下:实时舞台带着「允许一次 / 在 里始终允许 / 拒绝」,主窗口不在最前时,一个总在最上层的小胶囊显示同一张卡片。还要在 Mac 上查:胶囊的 `showInactive` 不能从被操作的 App 那里抢走焦点——在 Mac 任务单上(`docs/tasks/mac-check-0.1.36.md`,D)。
-18. **连接在设备间共享**——0.1.34 定下:档案的 `connectors`(中继 0.17;按设备合并,最多 64 条,像 key 的字段名返回 400),四个客户端都读写它,其他设备的条目显示为「已在 上连接——在这里登录」。凭据绝不离开签发它的那台设备。
-19. **桌面 · 不和人抢的手**——macOS 上 0.1.34 定下,照 Codex 应用的做法:*窗口模式*(`nanomuse/computer/mac_window.py`;`[hands] mode = auto`)只截取一个应用的窗口,并把事件发给它的进程,所以鼠标仍归人;每个应用问一次(「让 使用 ?」)。别的地方是共享整个屏幕,带 UI-TARS 式的光晕,叠加层不进截图。还剩的:Linux 和 Windows 没有窗口模式(虚拟显示器或第二个会话会是移植的路——Linux 先做,最便宜);macOS 这条路是对着 Quartz API 写的,必须在 Mac 上试(录屏回退、不走 AX 的点击、Retina 映射、滚动方向)。0.1.35 定下:`auto` 仍是默认,并加了一道保险——当 Quartz 层本身失败时(不是「窗口没了」,那个每次看屏幕都会重试),手在这个目标余下的时间里退回整个屏幕,并说一次;显式的 `window` 模式则一直尝试。0.1.36 对指针本身定下:桌面的手就是 UI-TARS-desktop 的操作器搬进了 Electron 主进程(`harness/desktop/src/operator.ts`、`@computer-use/nut-js`,经回环 HTTP 连到运行时的 `desktop` 后端),模型给的坐标是它看到的那张图的像素,一次性映射到操作器的屏幕(`nanomuse/computer/coords.py`)——在缩放显示器上点到目标旁边的那些点击没有了(4K 显示器、缩放 2 时 ≤1 px)。还要在 Mac 和 Windows 上试:打包后的应用里的原生插件、坐标空间(Mac 上是 point)、滚动单位、`ctrl` 对应 ⌘、内容保护——Mac 任务单,F。
-20. **没有公开远程 MCP 服务器的服务**——聊天应用在 0.1.34 定下,照 nanobot 的做法:飞书、钉钉、企业微信和 Telegram 是智能体在其中回复的*聊天入口*(`nanomuse/channels/`,各家的长连接 SDK,不需要公网地址,配对码),大多数人想从它们那里得到的就是这个。作为*连接器*(智能体读取服务里的内容或在里面操作)仍然悬着:Zoom、LinkedIn、Zoho Invoice、WHOOP、腾讯文档、滴答清单、网易邮箱、QQ 邮箱、微信读书——每一个都得单独在各家的 REST API 上搭一座桥,每家一个开发者账号。*决定:有哪些值得搭桥?还是一个都不值得?*
+16. **桌面和网页上的交接。** 0.1.34 两边都定下:运行时里的 *hold*(`nanomuse/agent/holds.py`;`browser`、`computer_act`、`phone_act` 上的 `hand_over`;`POST /api/holds`、`/done`;`hold` 事件;智能体最多等十分钟,然后再看一眼)。桌面的舞台显示「该你了」(带「好了」)和「我来接手」;网页的浏览器视图可以操作(「接管」、点击、输入、滚动、URL、「完成」)。还剩的:iOS 没有手,所以那里没有可交接的(10)。
+17. **桌面 · 窗口外的审批。** 0.1.34 定下:实时舞台带着「允许一次 / 在 里始终允许 / 拒绝」,主窗口不在最前时,一个总在最上层的小胶囊显示同一张卡片。还要在 Mac 上查:胶囊的 `showInactive` 不能从被操作的 App 那里抢走焦点;在 Mac 任务单上(`docs/tasks/mac-check-0.1.36.md`,D)。
+18. **连接在设备间共享。** 0.1.34 定下:档案的 `connectors`(中继 0.17;按设备合并,最多 64 条,像 key 的字段名返回 400),四个客户端都读写它,其他设备的条目显示为「已在 上连接,在这里登录」。凭据绝不离开签发它的那台设备。
+19. **桌面 · 不和人抢的手。** macOS 上 0.1.34 定下,照 Codex 应用的做法:*窗口模式*(`nanomuse/computer/mac_window.py`;`[hands] mode = auto`)只截取一个应用的窗口,并把事件发给它的进程,所以鼠标仍归人;每个应用问一次(「让 使用 ?」)。别的地方是共享整个屏幕,带 UI-TARS 式的光晕,叠加层不进截图。还剩的:Linux 和 Windows 没有窗口模式(虚拟显示器或第二个会话会是移植的路;Linux 先做,最便宜);macOS 这条路是对着 Quartz API 写的,必须在 Mac 上试(录屏回退、不走 AX 的点击、Retina 映射、滚动方向)。0.1.35 定下:`auto` 仍是默认,并加了一道保险:当 Quartz 层本身失败时(不是「窗口没了」,那个每次看屏幕都会重试),手在这个目标余下的时间里退回整个屏幕,并说一次;显式的 `window` 模式则一直尝试。0.1.36 对指针本身定下:桌面的手就是 UI-TARS-desktop 的操作器搬进了 Electron 主进程(`harness/desktop/src/operator.ts`、`@computer-use/nut-js`,经回环 HTTP 连到运行时的 `desktop` 后端),模型给的坐标是它看到的那张图的像素,一次性映射到操作器的屏幕(`nanomuse/computer/coords.py`);在缩放显示器上点到目标旁边的那些点击没有了(4K 显示器、缩放 2 时 ≤1 px)。还要在 Mac 和 Windows 上试:打包后的应用里的原生插件、坐标空间(Mac 上是 point)、滚动单位、`ctrl` 对应 ⌘、内容保护(Mac 任务单,F)。
+20. **没有公开远程 MCP 服务器的服务。** 聊天应用在 0.1.34 定下,照 nanobot 的做法:飞书、钉钉、企业微信和 Telegram 是智能体在其中回复的*聊天入口*(`nanomuse/channels/`,各家的长连接 SDK,不需要公网地址,配对码),大多数人想从它们那里得到的就是这个。作为*连接器*(智能体读取服务里的内容或在里面操作)仍然悬着:Zoom、LinkedIn、Zoho Invoice、WHOOP、腾讯文档、滴答清单、网易邮箱、QQ 邮箱、微信读书:每一个都得单独在各家的 REST API 上搭一座桥,每家一个开发者账号。*决定:有哪些值得搭桥?还是一个都不值得?*
21. **Linux 上 Wayland 下的手。** 截图和指针今天需要 X11 或 XWayland;Wayland 会话给出一张黑帧(现在是带提示的错误)。0.1.36 起操作器自己会说(`/info` → `available: false`,*Wayland 会话……请用 Xorg 登录*),Hands 卡片显示原因;UI-TARS-desktop 也没有 Wayland 路径。portal 这条路(`xdg-desktop-portal` ScreenCast + `libei`)能让它原生工作,代价是每个会话弹一次权限对话框。*决定:在 Linux 桌面被推上台面之前值得做吗?*
-22. **手机上的聊天入口。** 聊天入口住在运行时里;网页版(因此桌面也)有设置页面。Android 和 iOS 需要一个 `/api/channels` 客户端和同样的卡片列表(开关、字段、配对码、已配对的聊天、飞书二维码)——API 和字符串都准备好了。*决定:下一版?* 另外还悬着的:没有一家服务商是从构建机上端到端连通的(SDK 调用是离线对着真实的包检查的)——宣布之前每家一个测试机器人;飞书的发送者名字需要 `contact:user.base:readonly`;来自聊天的审批永远是*一次*。
+22. **手机上的聊天入口。** 聊天入口住在运行时里;网页版(因此桌面也)有设置页面。Android 和 iOS 需要一个 `/api/channels` 客户端和同样的卡片列表(开关、字段、配对码、已配对的聊天、飞书二维码);API 和字符串都准备好了。*决定:下一版?* 另外还悬着的:没有一家服务商是从构建机上端到端连通的(SDK 调用是离线对着真实的包检查的),所以宣布之前每家一个测试机器人;飞书的发送者名字需要 `contact:user.base:readonly`;来自聊天的审批永远是*一次*。
23. **自己的 key 预设和对话默认模型。** 保存百炼或 OpenRouter 的 key 之后,由服务商的 `/models` 列表决定提供哪些;`deepseek-v4.1-flash` 不会自动排到最前(只有 Cloud 模型组有 `followPick`)。*建议:列表里有它时就把它提到前面。*
24. **中继对推理 token 的计费。** 算作补全 token;当服务商单独报告推理 token 且数量超过补全数时相加,否则视为已包含。一家单独报告*且*数量更小的服务商会被少算。*决定:保留这个启发式,还是按服务商分别处理?*
-25. **桌面 · 第一次打开和第一次对话**——0.1.35 照手机定下:宿主保存 `firstrun.json`,全新安装绝不跳过那几页;App 先开口(三句写好的话,不花 token),问该怎么称呼你,模型的 `nanomuse-naming` 围栏变成起名卡片(`take_name` / `ask_user_question` 去掉了)。有意留下的:那几句写好的话是客户端叠上去的,不是存下来的消息;称呼是一条记忆,而不是对 `GLOBAL.md` 的修改;第一次打开那几页上的齿轮只在本次会话里关掉它们。
-26. **桌面 · macOS 权限**——0.1.35 重新审过:TCC 把内置的运行时归到负责的进程名下,所以只需打开「nanoMuse Desktop」一项(现在文案就这么说;运行时不再作为第二个条目出现让人去找)。0.1.38 起这两项授权改归辅助应用「nanoMuse Computer Use」(见 [desktop.md](desktop.md#macos-permissions))。还要在 Mac 上试:刚授权后的「试一下」行、录屏打开时的重启对话框、胶囊的 `showInactive`(17)、窗口模式里不走 AX 的点击和 Retina 映射(19)、0.1.36 改动后点形象是否正常(形象下面完全没有拖动区域;形象在标题栏带之下)——都在 Mac 任务单 `docs/tasks/mac-check-0.1.36.md` 上,写法让一个在 Mac 上的智能体可以从头跑到尾,把修复发回来。
+25. **桌面 · 第一次打开和第一次对话。** 0.1.35 照手机定下:宿主保存 `firstrun.json`,全新安装绝不跳过那几页;App 先开口(三句写好的话,不花 token),问该怎么称呼你,模型的 `nanomuse-naming` 围栏变成起名卡片(`take_name` / `ask_user_question` 去掉了)。有意留下的:那几句写好的话是客户端叠上去的,不是存下来的消息;称呼是一条记忆,而不是对 `GLOBAL.md` 的修改;第一次打开那几页上的齿轮只在本次会话里关掉它们。
+26. **桌面 · macOS 权限。** 0.1.35 重新审过:TCC 把内置的运行时归到负责的进程名下,所以只需打开「nanoMuse Desktop」一项(现在文案就这么说;运行时不再作为第二个条目出现让人去找)。0.1.38 起这两项授权改归辅助应用「nanoMuse Computer Use」(见 [desktop.md](desktop.md#macos-permissions))。还要在 Mac 上试:刚授权后的「试一下」行、录屏打开时的重启对话框、胶囊的 `showInactive`(17)、窗口模式里不走 AX 的点击和 Retina 映射(19)、0.1.36 改动后点形象是否正常(形象下面完全没有拖动区域;形象在标题栏带之下),都在 Mac 任务单 `docs/tasks/mac-check-0.1.36.md` 上,写法让一个在 Mac 上的智能体可以从头跑到尾,把修复发回来。
27. **网页 · 短视频。** 短视频是每台设备各自的(在形象所在的地方画:手机、iPhone、桌面)。网页版显示静止的形象。*决定:也在运行时里画,还是让网页保持静止?*
28. **请求点 Star · 什么算数。** 策略是中继的(`/v1/admin/nudges`,控制台 → 请求点 Star),一天之内不用更新就能到达每个客户端。还剩两处小差异:额度用完时 Android 每台手机问一次(其他客户端相同);起名对话期间换了形象的话,第一天的动态不会写(第一次打开永远到不了 *done*)。小问题。
-29. **每台设备上同一条对话**——0.1.36 定下(`docs/cloud.md` 里的约定 C7):聊天的文字住在中继上(`/v1/sync/*`,中继 0.19),登录后默认开,每个客户端的数据控制下都有开关和删除;每个账号一条主要聊天;设备自己发起的(例程、目标、动态、替另一台设备干的活)留在它自己那里。0.1.37 起是一条线(约定 C8):每台设备上的主要聊天是所有设备上说过的话按时间顺序的并集,其他设备的轮次是只读气泡(*来自 Pixel 8*),模型也读;别处的旁聊到了这台设备上立刻就是一条聊天;这个人的消息一发出就上传,回复在这一轮结束时上传;登录时整段历史回填。桌面把其他设备的轮次追加到 dsh 会话日志里(`user/message`,来源 `nanomuse-sync`),在浏览器里给它们穿上样子。还剩的:dsh 插件没有删除,所以归档的会话不是墓碑,在别处删掉的消息是隐藏而不是从日志里移除;在一台设备上关掉同步,要到其他设备下一次推或拉时(一个 `409`)才传到,不是立刻;iOS 上晚到的拉取消息排在本地消息之后(OpenMinis 的存储是追加式的)。`@` 在另一台设备上跑一轮——电脑只要在线就行,手机要 App 开着;iOS 上这个提及直接发到 hub(那里没有 `delegate` 工具),iPhone 自己回应 `task` 调用。
-30. **iOS · 输入框、气泡、页眉**——0.1.36 照手机定下:单行药丸形输入框、这个人的消息是扁平的灰色气泡、悬浮的半透明页眉。还剩的:iOS 16–18 上,对话从页眉边缘下方开始,而不是滚到它下面去(UIKit 列表需要把内边距传下去;要在真机上试——盲改有盖住第一条消息的风险,不值得)。
+29. **每台设备上同一条对话。** 0.1.36 定下(`docs/cloud.md` 里的约定 C7):聊天的文字住在中继上(`/v1/sync/*`,中继 0.19),登录后默认开,每个客户端的数据控制下都有开关和删除;每个账号一条主要聊天;设备自己发起的(例程、目标、动态、替另一台设备干的活)留在它自己那里。0.1.37 起是一条线(约定 C8):每台设备上的主要聊天是所有设备上说过的话按时间顺序的并集,其他设备的轮次是只读气泡(*来自 Pixel 8*),模型也读;别处的旁聊到了这台设备上立刻就是一条聊天;这个人的消息一发出就上传,回复在这一轮结束时上传;登录时整段历史回填。桌面把其他设备的轮次追加到 dsh 会话日志里(`user/message`,来源 `nanomuse-sync`),在浏览器里给它们穿上样子。还剩的:dsh 插件没有删除,所以归档的会话不是墓碑,在别处删掉的消息是隐藏而不是从日志里移除;在一台设备上关掉同步,要到其他设备下一次推或拉时(一个 `409`)才传到,不是立刻;iOS 上晚到的拉取消息排在本地消息之后(OpenMinis 的存储是追加式的)。`@` 在另一台设备上跑一轮(电脑只要在线就行,手机要 App 开着);iOS 上这个提及直接发到 hub(那里没有 `delegate` 工具),iPhone 自己回应 `task` 调用。
+30. **iOS · 输入框、气泡、页眉。** 0.1.36 照手机定下:单行药丸形输入框、这个人的消息是扁平的灰色气泡、悬浮的半透明页眉。还剩的:iOS 16–18 上,对话从页眉边缘下方开始,而不是滚到它下面去(UIKit 列表需要把内边距传下去;要在真机上试,因为盲改有盖住第一条消息的风险,不值得)。
31. **桌面 · Mac 和 Windows 上的操作器。** 从 UI-TARS-desktop 移植,在 Linux X11 上验证过;原生插件、坐标空间(Mac 上是 point,Windows 上是物理像素)、滚动单位、`ctrl` 对应 ⌘、光晕的内容保护都在 Mac 任务单上(`docs/tasks/mac-check-0.1.36.md`,F)。Wayland 仍是第 21 条。
32. **iOS · 基于目录的办法卡片。** 0.1.39 给了 iPhone 按账号区分的对话(约定 C10,`NanoMuseSync`)和内置的 `providers.json`,但额度卡片仍然提供 0.1.34 的预设,也没有套餐登录;Android 的 `AllowanceWaysCard` 是样板(本地区的首选、「更多服务商」、套餐、选择器为空时的一句话)。*0.1.40 定下:* `NanoMuseCatalogue` 读内置的 `providers.json`(或中继的 guidance),`NanoMuseWaysList` 是置顶卡片、账号页和自己的 key 面板上共用的那一个列表,`NanoMuseVendorSheet` 收 key 或跑套餐登录(ChatGPT 走上游的 Codex OAuth,Claude、OpenRouter,Kimi 的设备码),以及本地服务器的地址。选择器(图片、短视频)为空时的那一句话随 0.1.41 的模型页到了:空的一行写明缺什么,并给「添加服务商」。
-33. **iOS · 额度卡片和聊天里的提醒。** 中继以 `allowance_exhausted` 拒绝的一轮,在 iOS 上以上游的错误文字结束;办法只在账号页上,`spend.warn` 只给那里的进度条上色,聊天里没有一行。*提议:*流以这个代码结束时,像 Star 卡片(3)那样在页眉下钉一张卡片,办法取自 `spend.guidance`(和 32 一起);80 % 时输入框上方一行,每个额度池一次,和其他三个客户端一样。*0.1.40 定下:* `NanoMuseAllowance` 两者都管;外壳钉上 `NanoMuseAllowanceCard`(带「再试一次」),`NanoMuseChatCardsHost` 显示提醒;`AIChatView.body` 上没有新的链接。
-34. **Android · 从中继的 guidance 取办法。** `AllowanceWaysCard` 读内置的 `providers.json`;`/v1/me` 带着 `spend.guidance`(本地区的服务商、套餐、注意事项),卡片却不理它。*提议:*中继给了 `guidance` 就优先用,没有再退回目录——网页和桌面已经这么做了;`AllowanceWaysCard.kt` 和 `NanoMuseCloud.kt` 里几十行。*0.1.40 定下:* `Guidance.kt` 解析它,`Ways.resolve` 优先用它,卡片和设置 → nanoMuse Cloud 都读它。
-35. **手机 · 413、其余的拒绝和运营者的开关。** 手机的 `describe` 认得额度、每日上限、限流、坏 key、停用的账号和上游出问题,但不认得 `too_large`(413)、`not_invited`、`too_many_in_flight` 或 `provider_busy`,也不认得中继 0.22 的 `service_paused`、`sync_paused`、`hub_paused`、`signup_closed` 和带 `paused: true` 的 `allowance_exhausted`(中继说*已暂停*,额度卡片却说*已用完*)——这些到人眼前时是上游的文字或中继的英文句子。网页和桌面在 0.1.40 定下(`nanomuse/server/failures.py`、`harness/dsh-nanomuse/src/refusals.ts`——参考句子,en 和 zh)。*提议:*两个 `describe` 都加上这九个代码和那些句子,413 那条指向「新对话」,暂停的额度在同一张卡片上换一个开头;iOS 上被拒的聊天轮次也经过 `describe`。手机上每种语言都要有字符串。*0.1.40 定下*,两台手机都做了:`RelayRefusal` / `NanoMuseRelayRefusal` 给回复分类,`describe` 认得每一个代码,聊天里的卡片带着按钮(「新对话」·「登录」·「打开设置」·「再试一次」)。
-36. **桌面和网页上的语音。** 听写靠 Web Speech API,而 Electron 外壳不带它(那是 Chrome 里 Google 的服务),所以桌面打开听写页,网页只在有它的浏览器里能用;两者都不朗读回复。*提议:*语音进出都走运行时(`/api/speech`),用已配置服务商的模型(百炼的 `paraformer` / `cosyvoice`,OpenAI 的 `whisper` / `tts`),用这个人自己的 key 或中继——两个客户端一份实现;手机继续用系统的引擎。
-37. **网页 · 数据导出。** 桌面的房间宿主把智能体数据的 zip(`/data/export`)写到「下载」;网页版能分享形象卡片,也有数据控制,但没有文件。*提议:*运行时里加 `GET /api/data/export` 写同一个 zip,数据控制下加一行「下载你的智能体数据」。
-38. **界面语言。** 手机跟随系统(17 和 9 种语言),网页有应用内切换(自动 · en · zh-CN),桌面通过 harness 的语言行有英文和中文。*提议:*桌面的 `locales.ts` 随着大家的要求加上手机的那些语言,de 和 ja 先;手机上的应用内覆盖由上游决定。
-39. **网页 · 第一次对话。** 已按手机的做法做完,状态由运行时掌握:阶段机、提示词附加段和保存都在 `nanomuse/server/firstrun.py`(状态存在资料旁边的 `firstrun.json`),围栏的解析在 `nanomuse/fences.py`,路由是 `/api/firstrun*`,socket 帧是 `firstrun`;聊天说出那三句并画起名卡片(`web/src/components/FirstConversation.tsx`),首次运行的清单去掉了「认识你的 nanoMuse」那一页。称呼写进资料,也作为「Call them: …」写进记忆(运行时没有 USER.md)。身份表单留在「设置」里供以后修改。对约定的一处补充:运行时记下按「开始」时的语言(`lang`),附加段里引用的开场就是这个人看到的那几句。[web.md](web.md#the-first-run-and-the-chats-opening) 有完整的流程。
-40. **模型页,四个槽位(0.1.41)。** Android、iOS 和桌面版已定为同一个页面,加上保存 key 之后的「用它来做什么」卡片和自有模型失败时的「这次改用 nanoMuse Cloud」;网页控制台在「连接」页上有同样的四个槽位(运行时的 `PUT /api/connections/gui`、`/image`、`/video`,每个都有「自动」),但没有「用它来做什么」卡片,也没有只这一轮走账号的重试:自有 key 表单设的是对话服务商,失败的一轮显示失败。iOS 上还剩:图片和视频的选择器除了 nanoMuse Cloud 只列 DashScope 主机上的自有服务商(阿里云百炼),因为 iPhone 的图片和视频代码只说 DashScope 的接口。*提议:*有人要时,网页加上卡片和按钮,iPhone 加上 OpenAI 的图片接口形状。
+33. **iOS · 额度卡片和聊天里的提醒。** 中继以 `allowance_exhausted` 拒绝的一轮,在 iOS 上以上游的错误文字结束;办法只在账号页上,`spend.warn` 只给那里的进度条上色,聊天里没有一行。*提议*:流以这个代码结束时,像 Star 卡片(3)那样在页眉下钉一张卡片,办法取自 `spend.guidance`(和 32 一起);80 % 时输入框上方一行,每个额度池一次,和其他三个客户端一样。*0.1.40 定下:* `NanoMuseAllowance` 两者都管;外壳钉上 `NanoMuseAllowanceCard`(带「再试一次」),`NanoMuseChatCardsHost` 显示提醒;`AIChatView.body` 上没有新的链接。
+34. **Android · 从中继的 guidance 取办法。** `AllowanceWaysCard` 读内置的 `providers.json`;`/v1/me` 带着 `spend.guidance`(本地区的服务商、套餐、注意事项),卡片却不理它。*提议*:中继给了 `guidance` 就优先用,没有再退回目录,网页和桌面已经这么做了;`AllowanceWaysCard.kt` 和 `NanoMuseCloud.kt` 里几十行。*0.1.40 定下:* `Guidance.kt` 解析它,`Ways.resolve` 优先用它,卡片和设置 → nanoMuse Cloud 都读它。
+35. **手机 · 413、其余的拒绝和运营者的开关。** 手机的 `describe` 认得额度、每日上限、限流、坏 key、停用的账号和上游出问题,但不认得 `too_large`(413)、`not_invited`、`too_many_in_flight` 或 `provider_busy`,也不认得中继 0.22 的 `service_paused`、`sync_paused`、`hub_paused`、`signup_closed` 和带 `paused: true` 的 `allowance_exhausted`(中继说*已暂停*,额度卡片却说*已用完*);这些到人眼前时是上游的文字或中继的英文句子。网页和桌面在 0.1.40 定下(`nanomuse/server/failures.py`、`harness/dsh-nanomuse/src/refusals.ts`:参考句子,en 和 zh)。*提议*:两个 `describe` 都加上这九个代码和那些句子,413 那条指向「新对话」,暂停的额度在同一张卡片上换一个开头;iOS 上被拒的聊天轮次也经过 `describe`。手机上每种语言都要有字符串。*0.1.40 定下*,两台手机都做了:`RelayRefusal` / `NanoMuseRelayRefusal` 给回复分类,`describe` 认得每一个代码,聊天里的卡片带着按钮(「新对话」·「登录」·「打开设置」·「再试一次」)。
+36. **桌面和网页上的语音。** 听写靠 Web Speech API,而 Electron 外壳不带它(那是 Chrome 里 Google 的服务),所以桌面打开听写页,网页只在有它的浏览器里能用;两者都不朗读回复。*提议*:语音进出都走运行时(`/api/speech`),用已配置服务商的模型(百炼的 `paraformer` / `cosyvoice`,OpenAI 的 `whisper` / `tts`),用这个人自己的 key 或中继,两个客户端一份实现;手机继续用系统的引擎。
+37. **网页 · 数据导出。** 桌面的房间宿主把智能体数据的 zip(`/data/export`)写到「下载」;网页版能分享形象卡片,也有数据控制,但没有文件。*提议*:运行时里加 `GET /api/data/export` 写同一个 zip,数据控制下加一行「下载你的智能体数据」。
+38. **界面语言。** 手机跟随系统(17 和 9 种语言),网页有应用内切换(自动 · en · zh-CN),桌面通过 harness 的语言行有英文和中文。*提议*:桌面的 `locales.ts` 随着大家的要求加上手机的那些语言,de 和 ja 先;手机上的应用内覆盖由上游决定。
+39. **网页 · 第一次对话。** 已按手机的做法做完,状态由运行时掌握:阶段机、提示词附加段和保存都在 `nanomuse/server/firstrun.py`(状态存在资料旁边的 `firstrun.json`),围栏的解析在 `nanomuse/fences.py`,路由是 `/api/firstrun*`,socket 帧是 `firstrun`;聊天说出那三句并画起名卡片(`web/src/components/FirstConversation.tsx`),首次运行的清单去掉了「认识你的 nanoMuse」那一页。称呼写进资料,也作为「Call them: …」写进记忆(运行时没有 USER.md)。身份表单留在「设置」里供以后修改。对约定的一处补充:运行时记下按「开始」时的语言(`lang`),附加段里引用的开场就是这个人看到的那几句。[web.md](web.md#the-first-run-and-the-chat-s-opening) 有完整的流程。
+40. **模型页,四个槽位(0.1.41)。** Android、iOS 和桌面版已定为同一个页面,加上保存 key 之后的「用它来做什么」卡片和自有模型失败时的「这次改用 nanoMuse Cloud」;网页控制台在「连接」页上有同样的四个槽位(运行时的 `PUT /api/connections/gui`、`/image`、`/video`,每个都有「自动」),但没有「用它来做什么」卡片,也没有只这一轮走账号的重试:自有 key 表单设的是对话服务商,失败的一轮显示失败。iOS 上还剩:图片和视频的选择器除了 nanoMuse Cloud 只列 DashScope 主机上的自有服务商(阿里云百炼),因为 iPhone 的图片和视频代码只说 DashScope 的接口。*提议*:有人要时,网页加上卡片和按钮,iPhone 加上 OpenAI 的图片接口形状。
## 让这页保持真实 {#keeping-this-true}
- 桌面的连接器目录是源头(`harness/dsh-nanomuse/src/connectors-catalogue.ts`);`node scripts/connectors-json.mjs` 写出 Android 的资源文件和 iPhone 的内置副本,`--check`(在 harness 工作流里)在任一份过期时失败。
- 自己的 key 的服务商目录是 `nanomuse/llm/providers.json`;`node scripts/providers-json.mjs` 写出桌面、中继和手机的副本,`--check` 在某一份过期时失败。新服务商、改了的端点或某项能力只落在那里,别处不写。
- 面向中继的代码在每个客户端上用同一个线路格式:`nanomuse/cloud.py`(运行时)、`harness/dsh-nanomuse/src/relay.ts`(桌面)、`io.github.nanomuse.cloud.NanoMuseCloud`(Android)、`NanoMuse/NanoMuseCloud.swift` + `NanoMuseAccount.swift`(iOS)。中继的新字段四处都要落。
-- 中继的拒绝代码(`docs/cloud.md`)在四个地方变成句子:`nanomuse/server/failures.py`(运行时和网页)、`harness/dsh-nanomuse/src/refusals.ts`(桌面——宿主改写失败,客户端画卡片)、Android 和 iOS 上的 `NanoMuseCloud.describe`。新代码四处都要落,句子在该客户端的每种语言里都要有;桌面的 `tests/refusals.test.mjs` 和 `tests/refusal-card.test.mjs` 是可以照抄的测试形状。
-- 请求点 Star 到处都遵循一个策略——中继的 `/v1/nudges`(`docs/cloud.md` 里的约定 C1),每个客户端内置同样的默认值:一个*任务*是这个人发起并得到回复的一轮,第一次对话、例程、动态和目标检查都不算;每个时机一次,间隔 `cooldown_days`,每台设备 `max_asks` 次,点了「去 GitHub 点亮 Star」之后永远不再问(`nm.star.*` / `nanomuse.star.*`)。客户端:`nanomuse/nudges.py`、`harness/dsh-nanomuse/src/nudges.ts`、`web/src/nudges.ts`、`io.github.nanomuse.community.Nudges`、`NanoMuse/NanoMuseNudges.swift`。
+- 中继的拒绝代码(`docs/cloud.md`)在四个地方变成句子:`nanomuse/server/failures.py`(运行时和网页)、`harness/dsh-nanomuse/src/refusals.ts`(桌面:宿主改写失败,客户端画卡片)、Android 和 iOS 上的 `NanoMuseCloud.describe`。新代码四处都要落,句子在该客户端的每种语言里都要有;桌面的 `tests/refusals.test.mjs` 和 `tests/refusal-card.test.mjs` 是可以照抄的测试形状。
+- 请求点 Star 到处都遵循一个策略,即中继的 `/v1/nudges`(`docs/cloud.md` 里的约定 C1),每个客户端内置同样的默认值:一个*任务*是这个人发起并得到回复的一轮,第一次对话、例程、动态和目标检查都不算;每个时机一次,间隔 `cooldown_days`,每台设备 `max_asks` 次,点了「去 GitHub 点亮 Star」之后永远不再问(`nm.star.*` / `nanomuse.star.*`)。每张卡片还有「已经点过了」,写的是同一个标记,之后这台设备不管冷却期和剩余次数都不再问;设置页和关于页里的「去 GitHub 点 Star」一行也算点过。这个标记只在本机:账号没有同步小设置的地方,所以另一台设备要单独告诉它一次。客户端:`nanomuse/nudges.py`、`harness/dsh-nanomuse/src/nudges.ts`、`web/src/nudges.ts`、`io.github.nanomuse.community.Nudges`、`NanoMuse/NanoMuseNudges.swift`。
- 版本检查在每个客户端上按同样的顺序读同一个来源:GitHub 的 `releases/latest`;缓存一天;那一行永远也显示已安装的版本。
-- 对话同步从四个客户端和一个中继说同一种线路格式(`docs/cloud.md` 里的约定 C7):`cloud/nanomuse_cloud/sync.py`、`nanomuse/sync/`(运行时和网页)、`harness/dsh-nanomuse/src/sync.ts`(桌面)、`io.github.nanomuse.sync`(Android)、`NanoMuse/NanoMuseSync.swift`(iOS)。每个客户端都先应用一页的对话再应用它的消息,只在拉取时移动游标;每个客户端只推送这个人的话和最终答复——绝不推送工具步骤、工具结果或系统提示——例程、目标、动态和替另一台设备干的活都留在家里。
+- 对话同步从四个客户端和一个中继说同一种线路格式(`docs/cloud.md` 里的约定 C7):`cloud/nanomuse_cloud/sync.py`、`nanomuse/sync/`(运行时和网页)、`harness/dsh-nanomuse/src/sync.ts`(桌面)、`io.github.nanomuse.sync`(Android)、`NanoMuse/NanoMuseSync.swift`(iOS)。每个客户端都先应用一页的对话再应用它的消息,只在拉取时移动游标;每个客户端只推送这个人的话和最终答复(绝不推送工具步骤、工具结果或系统提示),例程、目标、动态和替另一台设备干的活都留在家里。
- `ideas.en.json` / `ideas.zh.json` 是同一个文件存四份(Android 资源、iOS Resources、`harness/dsh-nanomuse/assets`、`web/src/ideas`);某一份漂移时 harness 和网页的测试会失败。
diff --git a/docs/zh/privacy.md b/docs/zh/privacy.md
index acce1120..2c50597e 100644
--- a/docs/zh/privacy.md
+++ b/docs/zh/privacy.md
@@ -1,21 +1,21 @@
# 隐私
-这一页讲 nanoMuse 的各个应用——手机 App、网页版和桌面版、dsh 插件——以及 nanoMuse Cloud 怎么处理你的数据。它很短,因为它们对数据做的事很少。在要紧的地方,由你决定的只有一个开关:*设置 → 数据控制*。
+这一页讲 nanoMuse 的各个应用(手机 App、网页版和桌面版、dsh 插件)以及 nanoMuse Cloud 怎么处理你的数据。它很短,因为它们对数据做的事很少。在要紧的地方,由你决定的只有一个开关:*设置 → 数据控制*。
## 一切都留在你的设备上 {#everything-stays-on-your-device}
-智能体在 App 里面运行——在手机上,是 App 私有存储里的一个 Linux 根文件系统;在电脑上,是你装的那个运行时。对话、记忆、智能体写的文件、定时任务、技能和设置都存在那里,以及你挂载的那些自己选的文件夹里。没有任何分析统计、没有向我们发送的崩溃报告、没有任何形式的遥测。nanoMuse 唯一的服务器是下面说的那个可选的中继,没有它各个 App 也能完整使用。
+智能体在 App 里面运行:在手机上,是 App 私有存储里的一个 Linux 根文件系统;在电脑上,是你装的那个运行时。对话、记忆、智能体写的文件、定时任务、技能和设置都存在那里,以及你挂载的那些自己选的文件夹里。没有任何分析统计、没有向我们发送的崩溃报告、没有任何形式的遥测。nanoMuse 唯一的服务器是下面说的那个可选的中继,没有它各个 App 也能完整使用。
## 哪些会离开设备 {#what-leaves-the-device}
- **你的模型服务商。** 消息、附带的图片和工具结果会发给你配置的模型端点(阿里云百炼、DeepSeek、OpenAI、OpenRouter、你自己的 vLLM 或 Ollama……),受那家服务商的条款约束,用的是你输入的 API key。key 只存在你的设备上。
-- **nanoMuse Cloud,如果你登录了。** 第一屏的「登录,免费开始」(或 *设置 → nanoMuse Cloud*)用手机号或邮箱加一个验证码给你注册,之后 App 把我们的中继当成一个模型服务商来用——免费,每个账号 ¥10 的模型用量(社区成员不限;每有一个新人用你的邀请码注册,你的额度加 ¥5,对方也加 ¥5),钱由开发者出;nanoMuse 是非营利的社区项目,从不收费。中继把你的消息转发给模型(阿里云百炼)。关于账号,它保存这些:你地址的加盐哈希;地址本身的加密副本,让运行中继的人能看出账号是谁的(单看数据库文件什么也看不出来);一个打码的提示,比如 `s***@example.com`;发给这台设备的 key 的哈希;每次请求的类型、模型和 token 数;你的邀请码以及用过它的账号 id;还有——为了让你的各台设备显示同一个——智能体的名字和形象(用的是哪一张;如果是形象工作室里画的,还有它的五张小图)。每次登录、请求、时间线事件和设备,它还会记下请求来自的网络地址和客户端软件(`User-Agent`:Android App 及其版本、Windows、macOS 或 Linux 上的运行时、某个浏览器),账号则保留第一个和最后一个地址,以及最后一次出现的时间——运行中继的人在运营者页面上按账号、按地址看到这些,用来分辨「一个人开两个账号」和「两个人共用一个地址」,以及看清一个问题的轮廓。对话本身保存什么,由两个开关决定,下面接着讲。它收不到你的位置:各个 App 从不发送位置。地址旁边,运营者页面会显示这个地址属于哪个国家、省、市——在画页面的那一刻,从中继自己磁盘上一份离线的 ip2region 公开数据库里读出来,不问任何第三方,地点也不保存。退出登录会吊销 key;删除账号会删掉上述全部,地址也包括在内。中继的源码在仓库的 `cloud/` 目录下,[docs/cloud.md](cloud.md) 讲怎么自己部署。用自己的 key 时,关于中继的这一切都不适用。
-- **项目网站(nanomuse.cn),如果你访问它。** 它的 web 服务器保留七天访问日志,然后删除;一个脚本把日志变成按天的计数——看了哪些页面、多少访客、每个文件的下载次数、哪些网站链接过来。访客每天只计一次,用的是地址加浏览器字符串的哈希,盐是当天随机生成的、两天后丢弃;原始日志存活的那一周之外不保存任何地址,不和任何人共享,没有 cookie,也没有第三方统计。中继自己的运营者页面显示每天的登录数和账号数,来自上面说过的那些记录——没有为此多收集任何东西。
-- **数据控制——「帮助改进 nanoMuse 的 AI 模型」。** 每个 App 的 *设置 → 数据控制* 里有一个开关。开着时,中继保留你和 nanoMuse Cloud 模型的聊天文本,用来训练社区自己的开放模型:你写的、模型答的、它选择的工具调用,连同模型、token 数,以及请求头里 App 的平台和语言。它不保留系统提示词(你的记忆、SOUL 和指令)、工具返回的内容、图片、音频或短视频——这些都换成一个标记——而且这些轮次只和你的账号 id 关联;用于训练的导出不带账号 id、地址或提示。**在上游的公共中继(`cloud.nanomuse.cn`)上,中继 0.9 之后创建的账号这个开关默认开着;之前就有的账号保持它们当初的选择。** 开关旁边就写着这一点,自己部署的中继用 `IMPROVE_DEFAULT` 来选。随时可以关掉——从那之后不再保留任何东西——在同一页上点一下就能删掉此前保留的全部,删除账号也会一并删掉。额度和这个开关没有任何关系。
-- **同步的对话——登录后默认开启(中继 0.19 起)。** 你对话的文本存在 nanoMuse Cloud 上,让你账号下的每台设备显示同样的聊天。文件和图片不上传。*设置 → 数据控制* 可以关掉它并删除已存的内容;在一台设备上删除一个聊天,所有设备上都会删除。除了你的设备,没有人能读它;运营者看到的是数量,不是文本。(确切说,存的是:每个对话的标题和由哪台设备发起,每条消息的角色、文本、时间和设备,以及附件的名字和大小——从不存文件,从不存图片,从不存工具返回的内容。发给另一台设备的聊天,或者这台设备替别的设备跑的聊天,不同步。中继每个账号最多保留 20 000 条消息,单条消息在 16 KB 处截断。细节在 [docs/cloud.md](cloud.md#conversation-sync)。)
+- **nanoMuse Cloud,如果你登录了。** 第一屏的「登录,免费开始」(或 *设置 → nanoMuse Cloud*)用手机号或邮箱加一个验证码给你注册,之后 App 把我们的中继当成一个模型服务商来用:免费,每个账号 ¥10 的模型用量(社区成员不限;每有一个新人用你的邀请码注册,你的额度加 ¥5,对方也加 ¥5),钱由开发者出;nanoMuse 是非营利的社区项目,从不收费。中继把你的消息转发给模型(阿里云百炼)。关于账号,它保存这些:你地址的加盐哈希;地址本身的加密副本,让运行中继的人能看出账号是谁的(单看数据库文件什么也看不出来);一个打码的提示,比如 `s***@example.com`;发给这台设备的 key 的哈希;每次请求的类型、模型和 token 数;你的邀请码以及用过它的账号 id;还有,为了让你的各台设备显示同一个,智能体的名字和形象(用的是哪一张;如果是形象工作室里画的,还有它的五张小图)。每次登录、请求、时间线事件和设备,它还会记下请求来自的网络地址和客户端软件(`User-Agent`:Android App 及其版本、Windows、macOS 或 Linux 上的运行时、某个浏览器),账号则保留第一个和最后一个地址,以及最后一次出现的时间;运行中继的人在运营者页面上按账号、按地址看到这些,用来分辨「一个人开两个账号」和「两个人共用一个地址」,以及看清一个问题的轮廓。对话本身保存什么,由两个开关决定,下面接着讲。它收不到你的位置:各个 App 从不发送位置。地址旁边,运营者页面会显示这个地址属于哪个国家、省、市:在画页面的那一刻,从中继自己磁盘上一份离线的 ip2region 公开数据库里读出来,不问任何第三方,地点也不保存。退出登录会吊销 key;删除账号会删掉上述全部,地址也包括在内。中继的源码在仓库的 `cloud/` 目录下,[docs/cloud.md](cloud.md) 讲怎么自己部署。用自己的 key 时,关于中继的这一切都不适用。
+- **项目网站(nanomuse.cn),如果你访问它。** 它的 web 服务器保留七天访问日志,然后删除;一个脚本把日志变成按天的计数:看了哪些页面、多少访客、每个文件的下载次数、哪些网站链接过来。访客每天只计一次,用的是地址加浏览器字符串的哈希,盐是当天随机生成的、两天后丢弃;原始日志存活的那一周之外不保存任何地址,不和任何人共享,没有 cookie,也没有第三方统计。中继自己的运营者页面显示每天的登录数和账号数,来自上面说过的那些记录;没有为此多收集任何东西。
+- **数据控制:「帮助改进 nanoMuse 的 AI 模型」。** 每个 App 的 *设置 → 数据控制* 里有一个开关。开着时,中继保留你和 nanoMuse Cloud 模型的聊天文本,用来训练社区自己的开放模型:你写的、模型答的、它选择的工具调用,连同模型、token 数,以及请求头里 App 的平台和语言。它不保留系统提示词(你的记忆、SOUL 和指令)、工具返回的内容、图片、音频或短视频(这些都换成一个标记),而且这些轮次只和你的账号 id 关联;用于训练的导出不带账号 id、地址或提示。**在上游的公共中继(`cloud.nanomuse.cn`)上,中继 0.9 之后创建的账号这个开关默认开着;之前就有的账号保持它们当初的选择。** 开关旁边就写着这一点,自己部署的中继用 `IMPROVE_DEFAULT` 来选。随时可以关掉(从那之后不再保留任何东西),在同一页上点一下就能删掉此前保留的全部,删除账号也会一并删掉。额度和这个开关没有任何关系。
+- **同步的对话:登录后默认开启(中继 0.19 起)。** 你对话的文本存在 nanoMuse Cloud 上,让你账号下的每台设备显示同样的聊天。文件和图片不上传。*设置 → 数据控制* 可以关掉它并删除已存的内容;在一台设备上删除一个聊天,所有设备上都会删除。除了你的设备,没有人能读它;运营者看到的是数量,不是文本。(确切说,存的是:每个对话的标题和由哪台设备发起,每条消息的角色、文本、时间和设备,以及附件的名字和大小;从不存文件,从不存图片,从不存工具返回的内容。发给另一台设备的聊天,或者这台设备替别的设备跑的聊天,不同步。中继每个账号最多保留 20 000 条消息,单条消息在 16 KB 处截断。细节在 [docs/cloud.md](cloud.md#conversation-sync)。)
0.1.38 起默认只同步**主要聊天**:旁聊留在创建它的设备上,其他设备的旁聊也不会过来,除非你在那台设备上打开「同时同步旁聊」。你的一台设备正在回答时,其他设备会收到提示(「kwai 正在处理…」);这条提示只经过中继的内存,从不保存,十分钟后就忘掉。
- **网络,当智能体使用它时。** 网页搜索、抓取页面、App 内浏览器、MCP 服务器和命令行工具会访问它们各自对应的网站和服务。智能体发出去的,就是你让它做的事。
-- **检查更新。** *设置 → 关于*(手机上是「检查更新」;桌面版在托盘;`pipx`/Docker 运行时每六小时一次,`server.update_check = false` 可以停掉)向 GitHub API 询问 `zeeshanhaque21/nanoMuse` 的最新版本。除了这个请求本身什么都不发,也不会自行下载任何东西。
+- **检查更新。** *设置 → 关于*(手机上是「检查更新」;桌面版在托盘;自己跑的运行时(`nanomuse serve`、Docker)每天一次,`server.update_check = false` 可以停掉)向 GitHub API 询问 `zeeshanhaque21/nanoMuse` 的最新版本。除了这个请求本身(它的 `User-Agent` 里写着 App 及其版本)什么都不发,也不会自行下载任何东西。
- **备份。** 如果你备份到远端(SMB、WebDAV、SFTP、S3、FTP),备份会存到那里,用你选的密码加密。
## 权限 {#permissions}
@@ -24,13 +24,13 @@
## 智能体不问就能做什么 {#what-the-agent-may-do-without-asking}
-做任何无法撤销的事之前——删你的文件、把消息或数据发出去、付款——智能体会停下来,弹一张卡片问你,在 shell 里、浏览器里和手机屏幕上都一样。你能从那张卡片上记住的内容分三档,*设置 → 权限 → 记住的授权* 列出你记住过的全部,按同样的方式分组,一行一条,点一下即可撤销:
+做任何无法撤销的事之前(删你的文件、把消息或数据发出去、付款),智能体会停下来,弹一张卡片问你,在 shell 里、浏览器里和手机屏幕上都一样。你能从那张卡片上记住的内容分三档,*设置 → 权限 → 记住的授权* 列出你记住过的全部,按同样的方式分组,一行一条,点一下即可撤销:
-- **先做,再告诉你**——安装软件。从不询问;智能体事后会说一声。
-- **先问;你可以记住**——删除和发送。「本次对话都允许」或「对『X』总是允许」(一个文件夹、一个收件人、一个网站或 App)。
-- **最高一档:付款的那一刻问,每次都问**——购买、下单、预订、交易、转账。没有「本次对话」这一项。「记住,下次在 X 里直接执行」是有的,只针对一个 App 或网站,并且在执行前要求验证你的锁屏(指纹、面容、PIN);它排在权限页的最前面,每一笔靠它执行的付款都会在聊天里明说。
+- **先做,再告诉你**:安装软件。从不询问;智能体事后会说一声。
+- **先问;你可以记住**:删除和发送。「本次对话都允许」或「对『X』总是允许」(一个文件夹、一个收件人、一个网站或 App)。
+- **最高一档:付款的那一刻问,每次都问**:购买、下单、预订、交易、转账。没有「本次对话」这一项。「记住,下次在 X 里直接执行」是有的,只针对一个 App 或网站,并且在执行前要求验证你的锁屏(指纹、面容、PIN);它排在权限页的最前面,每一笔靠它执行的付款都会在聊天里明说。
-密码、验证码和卡号从不由智能体输入;这些界面交给你。命令里任何吓人的东西(抹掉一块磁盘、强推覆盖历史、下载后直接管道进 shell)每次都会问,也不能记住。你明确要买、要交易时,购物和交易没问题;不管怎么措辞,违法或有害的事都不会做——智能体会拒绝并说明原因。
+密码、验证码和卡号从不由智能体输入;这些界面交给你。命令里任何吓人的东西(抹掉一块磁盘、强推覆盖历史、下载后直接管道进 shell)每次都会问,也不能记住。你明确要买、要交易时,购物和交易没问题;不管怎么措辞,违法或有害的事都不会做;智能体会拒绝并说明原因。
## 反馈 {#feedback}
diff --git a/docs/zh/roadmap.md b/docs/zh/roadmap.md
index de7dbcf1..2b9cccac 100644
--- a/docs/zh/roadmap.md
+++ b/docs/zh/roadmap.md
@@ -100,3 +100,4 @@ Meta 的 Muse 定下了这类智能体的样子;下面是我们还落后于它
| 1.0.0 | Keel | 第一个稳定版:Android 和 iPhone 的手机 App、Mac、Windows 和 Linux 的桌面 App、网页控制台和中继,一个账号贯穿所有设备;nanoMuse Cloud 可以作为模型来源关掉,有自己 key 的设备退出登录也能用;主聊天跟着你选的对话模型走;模型选择器会折叠、能搜索;桌面上自己的 key 也能选思考深度;「我已经点过 Star」;桌面版等待 Windows 上慢的启动;回复跟随 App 的语言;`auto` 在带污点的外发和警告前会问;hub 和中继加固;人读到的话里不再有破折号 |
| 1.0.1 | Ballast | 手机 App 有了自己的 URL scheme,不再和 OpenMinis 相撞;内容审核拒绝在每个客户端都是一句话,中继的健康页把它和上游错误分开计数;桌面端从别的设备收到的聊天能滚到开头,操作屏幕时呼吸灯整段亮着,Windows 上的形象会动了;网页控制台可以让某一轮走 nanoMuse Cloud;TestFlight 测试版通过了 Apple 的审核 |
| 1.0.2 | Trim | 把项目的每一部分过了一遍:Sentinel 的污点规则在自动模式下对 `shell` 和 `send_email` 也成立;Android App 能和 OpenMinis 装在一起启动;桌面端在运行时拒绝某一步时重新弹出权限卡,Linux 上读图不再弄死 harness,Windows 的窗口按钮不再盖住页面按钮;记忆整理判断不了的就拒绝;`doctor` 会点名没人读的配置键;网页控制台和命令行的报错更清楚;iPhone 的页面有了全部九种语言;文档里没有破折号了 |
+| 1.0.3 | Helm | 聊天在每台设备上长得一样:和输入框一样宽的悬浮毛玻璃底栏、主题底色上的黑白药丸、当顶栏的一层遮罩,从 @Ichiro786 的 Android 改版带到 iPhone、网页控制台和桌面端;Android、iPhone 和桌面端可以用自己的一段视频当脸;桌面端和网页控制台能同时配置多个 OpenAI 兼容端点;一个下午报来的十个 Android 问题都修了,其中包括又能发送的自有 Anthropic key;iPhone 的操作按钮能发起语音提示;OpenCode Go 在每个客户端上可用;中继的镜像以非特权用户运行 |
diff --git a/docs/zh/self-hosting.md b/docs/zh/self-hosting.md
index 66693a64..c64d2924 100644
--- a/docs/zh/self-hosting.md
+++ b/docs/zh/self-hosting.md
@@ -10,7 +10,7 @@ nanoMuse 是跑在你自己设备上的一个智能体,里面没有任何东
## 1. 不要服务器,用自己的 key {#_1-no-server-your-own-key}
-社区中继上的免费额度是会用完的。用完的时候,或者在那之前,往 App 里放一把自己的 key——中国大陆用阿里云百炼,其他地方用 OpenRouter 或任何 OpenAI 兼容端点——模型请求就从你的设备直接发给那家服务商;中继只看得到登录,以及你的设备之间的 hub 帧。[own-key.md](own-key.md) 有每个 App 的步骤。不用装任何东西,不用让任何东西一直跑着。
+社区中继上的免费额度是会用完的。用完的时候,或者在那之前,往 App 里放一把自己的 key(中国大陆用阿里云百炼,其他地方用 OpenRouter 或任何 OpenAI 兼容端点),模型请求就从你的设备直接发给那家服务商;中继只看得到登录,以及你的设备之间的 hub 帧。[own-key.md](own-key.md) 有每个 App 的步骤。不用装任何东西,不用让任何东西一直跑着。
## 2. 自己的中继 {#_2-your-own-relay}
@@ -18,9 +18,9 @@ nanoMuse Cloud 就是各端登录的那个中继:邮箱或手机号登录、
### 你需要什么 {#what-you-need}
-- **一台机器。** 1 vCPU、1 GB 内存的 VPS 够一家人用;中继空闲时只占几十兆内存,模型的活在服务商那边干。这样一台服务器在中国大陆大约每月 ¥30–60,境外大约 US$4–6,哪家都行——中继不挑。只在局域网内用的中继,一台家用服务器或一台闲置笔记本就行。
+- **一台机器。** 1 vCPU、1 GB 内存的 VPS 够一家人用;中继空闲时只占几十兆内存,模型的活在服务商那边干。这样一台服务器在中国大陆大约每月 ¥30–60,境外大约 US$4–6,哪家都行,中继不挑。只在局域网内用的中继,一台家用服务器或一台闲置笔记本就行。
- **一个域名**,指向这台机器(一条 A 记录),80 和 443 端口可达。和中继一起跑的 Caddy 会自己申请和续期 Let's Encrypt 证书。只在局域网内用的中继可以跳过这条。
-- **一把模型服务商的 key。** 中继花的是*你的*钱、用的是*你的*服务商:在你的中继上登录的每个人都记在这把 key 上,按服务商的价格计费,在你设定的额度之内(`ALLOWANCE_CNY`、`SIGNUP_OPEN`,成员写在 `ALLOWED_IDENTIFIERS`——[cloud/README.md](../../cloud/README.md#settings))。没有 key,登录和 hub 能用,聊天不能。
+- **一把模型服务商的 key。** 中继花的是*你的*钱、用的是*你的*服务商:在你的中继上登录的每个人都记在这把 key 上,按服务商的价格计费,在你设定的额度之内(`ALLOWANCE_CNY`、`SIGNUP_OPEN`,成员写在 `ALLOWED_IDENTIFIERS`;[cloud/README.md](../../cloud/README.md#settings))。没有 key,登录和 hub 能用,聊天不能。
- **Docker**,带 compose 插件,还有 `curl`。
### 一条命令 {#in-one-command}
@@ -30,22 +30,22 @@ git clone https://github.com/zeeshanhaque21/nanoMuse.git && cd nanoMuse
bash scripts/self-host.sh
```
-脚本问五件事——域名(或 `local`)、给 Let's Encrypt 的邮箱、登录验证码怎么发出去、服务商的 URL 和 key、管理员密码——然后写出带一个新随机 `CLOUD_SECRET` 的 `cloud/.env`,启动 `docker compose`,等 `/healthz` 就绪,打印控制台和管理页的 URL。再跑一次会保留机密和账号,只是重新问一遍,把当前值作为默认值。只在这台机器上用、不要域名也不要 TLS:
+脚本问五件事:域名(或 `local`)、给 Let's Encrypt 的邮箱、登录验证码怎么发出去、服务商的 URL 和 key、管理员密码,然后写出带一个新随机 `CLOUD_SECRET` 的 `cloud/.env`,启动 `docker compose`,等 `/healthz` 就绪,打印控制台和管理页的 URL。再跑一次会保留机密和账号,只是重新问一遍,把当前值作为默认值。只在这台机器上用、不要域名也不要 TLS:
```bash
bash scripts/self-host.sh --local # http://127.0.0.1:8787, codes in the relay's log
bash scripts/self-host.sh --local --bind 0.0.0.0 # reachable from the phones on the same network
```
-不用脚本、自己动手:`cd cloud && cp .env.example .env`,填上 `CLOUD_DOMAIN`、`PUBLIC_BASE`、`ACME_EMAIL`、`CLOUD_SECRET`、`CLOUD_ADMIN_TOKEN`、`UPSTREAM_KEY` 和发信设置,然后 `docker compose up -d`——或者不要 Caddy,`docker compose -f docker-compose.yml -f docker-compose.local.yml up -d`。
+不用脚本、自己动手:`cd cloud && cp .env.example .env`,填上 `CLOUD_DOMAIN`、`PUBLIC_BASE`、`ACME_EMAIL`、`CLOUD_SECRET`、`CLOUD_ADMIN_TOKEN`、`UPSTREAM_KEY` 和发信设置,然后 `docker compose up -d`;或者不要 Caddy,`docker compose -f docker-compose.yml -f docker-compose.local.yml up -d`。中继在容器里以 uid 10001 运行,绑定挂载的数据目录 `cloud/data` 在宿主机上必须属于这个 uid:第一次启动前执行 `mkdir -p data && sudo chown -R 10001:10001 data`,从以 root 运行的旧镜像升级时再执行一次(脚本会替你做;写不了数据目录的中继会在启动时说明原因并停下)。
### 登录验证码 {#sign-in-codes}
每次有人登录都会发出一个验证码。`CODE_SENDER` 决定怎么发:
-- `log`——只打印到中继的日志:`cd cloud && docker compose logs relay` 里能看到 `verification code for …`。一家人用够了:你把验证码念给对方。`--local` 用的就是这个。
-- `smtp`——通过你自己的一个邮箱发邮件(`SMTP_HOST`、`SMTP_PORT`、`SMTP_USER`、`SMTP_PASSWORD`、`SMTP_FROM`)。任何支持 SMTP 的邮箱都行;几个人用,一个免费邮箱就够。
-- `aliyun`——通过阿里云 Dysmsapi 给中国大陆手机号发短信;需要一个已签约的模板。`both` 对邮箱地址用 SMTP,对手机号用阿里云。短信只发得到中国大陆手机号;其他人都用邮箱登录。
+- `log`:只打印到中继的日志(`cd cloud && docker compose logs relay` 里能看到 `verification code for …`)。一家人用够了:你把验证码念给对方。`--local` 用的就是这个。
+- `smtp`:通过你自己的一个邮箱发邮件(`SMTP_HOST`、`SMTP_PORT`、`SMTP_USER`、`SMTP_PASSWORD`、`SMTP_FROM`)。任何支持 SMTP 的邮箱都行;几个人用,一个免费邮箱就够。
+- `aliyun`:通过阿里云给中国大陆手机号发短信:默认走号码认证服务(`ALIYUN_SMS_API=dypns`,用它现成的模板),也可以走短信服务并使用你自己签约的模板(`ALIYUN_SMS_API=dysms`);相关变量见 `cloud/.env.example`。`both` 对邮箱地址用 SMTP,对手机号用阿里云。短信只发得到中国大陆手机号;其他人都用邮箱登录。
### 管理页 {#the-admin-page}
@@ -55,9 +55,9 @@ bash scripts/self-host.sh --local --bind 0.0.0.0 # reachable from the phones o
每个 App 都在 `base_url` 指定的中继上登录;没有设置时它不登录,直到你填上一个中继。同一个账号的设备必须都指向同一个中继——hub 和对话都在那里。
-- **Android**(0.1.38)。登录表单下面的「使用其他服务器」填中继地址;「检查」会访问它的 `/healthz` 并显示版本,「使用这个服务器」让这个地址在重启 App 之后也保留。在你自己的网络之外必须用 `https://`;私有地址(`10.x`、`172.16–31.x`、`192.168.x`、`localhost`、`.local` 或 `.ts.net` 名字)接受明文 `http://`。「设置 → 账号」显示当前服务器和「更换」,更换会先把手机退出登录([android.md](android.md))。
+- **Android**(0.1.38)。登录表单下面的「使用其他服务器」填中继地址;「检查」会访问它的 `/healthz` 并显示版本,「使用这个服务器」让这个地址在重启 App 之后也保留。在你自己的网络之外必须用 `https://`;私有地址(`10.x`、`172.16-31.x`、`192.168.x`、`localhost`、`.local` 或 `.ts.net` 名字)接受明文 `http://`。「设置 → 账号」显示当前服务器和「更换」,更换会先把手机退出登录([android.md](android.md))。
- **iPhone / iPad。** 登录页上同样的链接,同样的规则。
-- **nanoMuse 桌面版。** 中继就是插件 cloud 一行的 `baseURL`。把下面这段加进桌面版配置档里的 `cordis.patch.yml`——`~/.nanomuse/desktop/profiles/nanomuse/`(Windows 上是 `%USERPROFILE%\.nanomuse\desktop\profiles\nanomuse\`;`NANOMUSE_DESKTOP_HOME` 可以把它挪走)——然后重启应用:
+- **nanoMuse 桌面版。** 中继就是插件 cloud 一行的 `baseURL`。把下面这段加进桌面版配置档 `~/.nanomuse/desktop/profiles/nanomuse/`(Windows 上是 `%USERPROFILE%\.nanomuse\desktop\profiles\nanomuse\`;`NANOMUSE_DESKTOP_HOME` 可以把它挪走)里的 `cordis.patch.yml`,然后重启应用:
```yaml
- id: nanomuse-cloud
@@ -70,7 +70,7 @@ bash scripts/self-host.sh --local --bind 0.0.0.0 # reachable from the phones o
在应用启动的环境里设 `NANOMUSE_CLOUD_URL=https://cloud.example.com`,不用改文件也是一样的效果([desktop.md](desktop.md))。
- **终端运行时和托管的网页版**(`nanomuse …`、根目录的 `docker-compose.yml`):环境变量或 `.env` 里的 `NANOMUSE_CLOUD_BASE_URL=https://cloud.example.com`,或者 `config/config.toml` 里的 `[cloud] base_url`([configuration.md](configuration.md))。
-- **网页控制台**就是中继自己的 `/app`——`https:///app/`——不需要指向。
+- **网页控制台**就是中继自己的 `/app`(`https:///app/`),不需要指向。
从你的中继发出的邀请链接指向它自己的控制台(`INVITE_URL`,由脚本设置),所以你邀请的朋友落在你的中继上。
@@ -86,22 +86,32 @@ bash scripts/self-host.sh # Enter through the questions; secrets and acco
### 备份 {#backing-up}
-中继保存的一切就是 `cloud/data/cloud.db`(SQLite)加上 `cloud/.env` 里的 `CLOUD_SECRET`。没有这个密钥,数据库就没用——标识符是用它哈希和加密的——没有数据库,密钥也没用;两样一起备份,而且除非你打算从头来过,否则绝不要更换密钥:
+中继保存的一切就是 `cloud/data/cloud.db`(SQLite)加上 `cloud/.env` 里的 `CLOUD_SECRET`。没有这个密钥,数据库就没用(标识符是用它哈希和加密的,还在等待使用的登录验证码也是,那只有六位数字),没有数据库,密钥也没用;两样一起备份,而且除非你打算从头来过,否则绝不要更换密钥:
```bash
-cd cloud && docker compose exec relay sqlite3 /srv/nanomuse-cloud/data/cloud.db ".backup /srv/nanomuse-cloud/data/backup.db" \
- || cp data/cloud.db data/backup.db # when the container has no sqlite3: stop the relay first for a clean copy
+cd cloud && docker compose exec -T relay python - > cloud-$(date -u +%Y%m%d).db <<'EOF'
+import os, shutil, sqlite3, sys
+src = sqlite3.connect("/srv/nanomuse-cloud/data/cloud.db")
+dst = sqlite3.connect("/tmp/backup.db")
+src.backup(dst)
+dst.close(); src.close()
+with open("/tmp/backup.db", "rb") as f:
+ shutil.copyfileobj(f, sys.stdout.buffer)
+os.remove("/tmp/backup.db")
+EOF
```
+副本由运行中的容器里 SQLite 自带的备份接口生成(镜像里没有 `sqlite3` 命令),再流式输出到宿主机:中继一边写它也是一致的,落下来的文件归你所有,不在属于 uid 10001 的数据目录里。直接 `cp data/cloud.db` 做不到这一点:WAL 模式下最新的写入还在 `cloud.db-wal` 里,只复制主文件会漏掉它们。
+
Caddy 的证书在 `caddy_data` 卷里,丢了会重新申请。
### 你的中继存什么 {#what-your-relay-stores}
-哈希并加密过的标识符、每次请求的计数和价格、hub 上的设备,还有——只在「在我的设备之间同步对话」开着的时候——对话的文字,好让其他设备能显示它们。没有文件,没有截图,没有密码。完整清单和每个开关的作用在 [privacy.md](privacy.md);作为运营者,那一页现在描述的就是你。
+哈希并加密过的标识符、每次请求的计数和价格、hub 上的设备,还有,只在「在我的设备之间同步对话」开着的时候,对话的文字,好让其他设备能显示它们。没有文件,没有截图,没有密码。完整清单和每个开关的作用在 [privacy.md](privacy.md);作为运营者,那一页现在描述的就是你。
## 3. 自己的运行时,给网页版用 {#_3-a-runtime-of-your-own-for-the-web-app}
-手机和桌面各自带着自己的智能体。网页版没有——它对话的是 Python 运行时,在上游的展示站上,每个访客有一个自己的容器。你可以把这个运行时跑在自己的机器上,在 Docker 里,然后从任何浏览器打开它上面的网页版:
+手机和桌面各自带着自己的智能体。网页版没有;它对话的是 Python 运行时,在上游的展示站上,每个访客有一个自己的容器。你可以把这个运行时跑在自己的机器上,在 Docker 里,然后从任何浏览器打开它上面的网页版:
```bash
cp .env.example .env && $EDITOR .env # a model key, NANOMUSE_SERVER_TOKEN; NANOMUSE_CLOUD_BASE_URL for your own relay
@@ -109,4 +119,4 @@ nanomuse config init # or copy config/config.example.toml t
docker compose up -d app # the web app on http://:8787, the URL with its token in the logs
```
-容器只看得到它的数据卷和 `./workspace`;capability 都已丢弃。让它一直跑着、arm64 的发布镜像、从你的网络外面访问它,都在[部署](deployment.md)。这个运行时像其他任何设备一样登录到一个中继——设了 `NANOMUSE_CLOUD_BASE_URL` 的话,就是第 2 节里你自己的那个——之后手机就可以通过 hub 把任务交给它,像交给一台常开的电脑([every-device.md](every-device.md))。
+容器只看得到它的数据卷和 `./workspace`;capability 都已丢弃。让它一直跑着、arm64 的发布镜像、从你的网络外面访问它,都在[部署](deployment.md)。这个运行时像其他任何设备一样登录到一个中继(设了 `NANOMUSE_CLOUD_BASE_URL` 的话,就是第 2 节里你自己的那个),之后手机就可以通过 hub 把任务交给它,像交给一台常开的电脑([every-device.md](every-device.md))。
diff --git a/docs/zh/sentinel.md b/docs/zh/sentinel.md
index 7d635194..2ad74ae5 100644
--- a/docs/zh/sentinel.md
+++ b/docs/zh/sentinel.md
@@ -15,8 +15,8 @@ agent ◄── redact(result) ◄── taint bookkeeping ◄─────
| 级别 | 含义 | 例子 |
|---|---|---|
-| `safe` | 可逆、本地 | `files`(工作区之内)、`web_search`、`remember`、`goals`、`contacts`、`skills`(列出、使用)、`phone_screen` |
-| `moderate` | 触及外部或改变状态 | `web_fetch`、`python_execute`、`read_emails`、`browser`、`forget`、`phone_act` |
+| `safe` | 可逆、本地 | `files`(工作区之内)、`web_search`、`remember`、`goals`、`reminders` 和 `triggers`(列出、取消、记账类动作)、`contacts`、`skills`(列出、使用)、`phone_screen` |
+| `moderate` | 触及外部或改变状态 | `web_fetch`、`python_execute`、`read_emails`、`browser`、`forget`、`phone_act`、`goals`、`reminders` 和 `triggers`(创建:这是模型以后会自己接着做的长期工作,所以 `strict` 模式在设立之前会问) |
| `sensitive` | 难以撤销或外部可见 | `shell`、`send_email`、`skills`(保存、删除;技能是模型以后会照着做的长期指令,所以写一个技能总是会问) |
工具可以在 `assess()` 里为某一次调用提高级别:`shell` 遇到 `rm -rf`、`sudo`、`curl | sh` 这类模式会附上一条警告;`python_execute` 对纯计算和工作区内的文件是 `moderate`(前提是沙箱在工作;没有沙箱时,在 macOS、Windows 或没装 bubblewrap 的 Linux 机器上,每个脚本都是 `sensitive`,因为静态检查是仅剩的一道墙),而当代码访问网络、启动其他程序、读环境变量、删文件或触及工作区之外的路径时是 `sensitive`,原因写在卡片上;`web_fetch` 直接拒绝私有地址和回环地址,每一跳重定向都查;`phone_act` 在一次点击的 `label`(操作器对手指下面是什么的描述)含有 确认支付、转账、提交订单、发送、删除 或 `[gui] sensitive_words` 里的另一个词时,或者一步同时输入并提交时,是带警告的 `sensitive`;没有 label 的点击自带一条警告([gui.md](gui.md))。工具还声明 `reads_private_data`(污染这段对话)和 `egress`,可选带 `egress_target`(这次调用把数据发往的主机;`shell` 和 `python_execute` 未知;手机上是 App 的 id),供污点追踪使用。
@@ -99,7 +99,7 @@ nanomuse vault list # names only
nanomuse vault delete EMAIL_PASSWORD
```
-配置值(模型的 key、邮件、日历和联系人的链接、搜索的 key)用 `{{vault:NAME}}` 引用机密;模型看到的是占位符,连接器用真实值构建。工具参数**不**携带机密:没有任何正式工具选择接受(`accepts_secrets`),所以 `shell` 或 `python_execute` 调用里的 `{{vault:NAME}}` 会被拒绝并说明原因,而不是拿字面文本去跑(你自己的工具可以选择接受;哨兵会在执行前一刻替换)。如果某个机密出现在任何工具的输出里,模型读到之前它就被换回 `{{vault:NAME}}`。密钥是 `/vault.key`(首次使用时创建,权限 600)或 `NANOMUSE_VAULT_KEY`。
+配置值(模型的 key、邮件、日历和联系人的链接、搜索的 key)用 `{{vault:NAME}}` 引用机密;模型看到的是占位符,连接器用真实值构建。工具参数**不**携带机密:没有任何正式工具选择接受(`accepts_secrets`),所以 `shell` 或 `python_execute` 调用里的 `{{vault:NAME}}` 会被拒绝并说明原因,而不是拿字面文本去跑(你自己的工具可以选择接受;哨兵会在执行前一刻替换)。如果某个机密出现在任何工具的输出里,模型读到之前它就被换成 `[REDACTED:NAME]`。密钥是 `/vault.key`(首次使用时创建,权限 600)或 `NANOMUSE_VAULT_KEY`。
## 审计日志 {#audit-log}
@@ -119,7 +119,7 @@ Meta Muse 把每个用户的智能体跑在各自的云端 VM 里,哨兵在 VM
**覆盖到的**
- *模型做了你没要求的事。* 每个工具调用都过策略;敏感和危险的调用停下来等决定;许可限定在一个工具和一个目标上,可以撤销。
-- *试图外泄数据的提示注入。* 私密数据一旦被读取,外发到白名单之外的任何主机都先询问,目的地写在卡片上。`web_fetch` 不能被指向回环、链路本地、私有或云元数据地址,经重定向也不行。
+- *试图外泄数据的提示注入。* 私密数据一旦被读取,外发到白名单之外的任何主机都先询问,目的地写在卡片上。`web_fetch` 不能被指向回环、链路本地、私有、运营商级 NAT(100.64.0.0/10,例如 Tailscale 网络)或云元数据地址,IPv4 和 IPv6 都一样,经重定向也不行。
- *模型看到你的机密。* key 和密码放在加密的保险库里,以占位符进入工具调用;审批之后才替换,结果里再抹掉。子进程不继承看起来像凭据的环境变量,在沙箱里也读不到保险库、数据目录或你家目录下的任何其他东西。模型永远看不到 App 的访问令牌。
- *不知道发生了什么。* 一切都在 `audit.jsonl` 和「活动」视图里。
diff --git a/docs/zh/showcase.md b/docs/zh/showcase.md
index 1d9ac74f..a48d05f2 100644
--- a/docs/zh/showcase.md
+++ b/docs/zh/showcase.md
@@ -21,7 +21,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
全程没碰屏幕。哨兵(Sentinel)只问一次,为了那条消息;其余的要么只读,要么在你自己的工作区里。
-**轨迹:**[火车那一半](../traces/case-1-train.md),2026-09-24 在网页版用 12306 服务器录的;模型发现 13:00 前的二等座全卖完了,如实说了,并给出最接近的替代车次,而不是装作有票。高德路线、日历草稿和飞书消息*待跑*:测试机上需要一把高德 key 和一个飞书登录。
+**轨迹**:[火车那一半](../traces/case-1-train.md),2026-09-24 在网页版用 12306 服务器录的;模型发现 13:00 前的二等座全卖完了,如实说了,并给出最接近的替代车次,而不是装作有票。高德路线、日历草稿和飞书消息*待跑*:测试机上需要一把高德 key 和一个飞书登录。
## 2. 早间简报 {#_2-the-morning-brief}
@@ -35,7 +35,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
| `shell: tmeet meeting list --compact` | 今天的会议,带会议号和链接 |
| 通知 | 一张卡片;点开进入聊天,看到完整简报 |
-**轨迹:***待跑*:高德 key、飞书和腾讯会议登录。例程和通知这条路本身已经由 App 的测试和模拟器验证过([archive/android-python-line.md](../archive/android-python-line.md#keeping-it-running) 里*让它一直跑*那部分的工作)。
+**轨迹**:*待跑*:高德 key、飞书和腾讯会议登录。例程和通知这条路本身已经由 App 的测试和模拟器验证过([archive/android-python-line.md](../archive/android-python-line.md#keeping-it-running) 里*让它一直跑*那部分的工作)。
## 3. 我的京东订单到哪了 {#_3-where-is-my-京东-order}
@@ -51,7 +51,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
第二级(App 里一个带着你登录态的页面)排在任何屏幕之前:京东没有个人 API,但它的网页版不用 App 也能用。配置好快递100,查物流就成了一次工具调用。
-**轨迹:***待跑*:一台装了本地版的手机上的京东登录。浏览器后端、接管面板和登录态的保留已经在模拟器上验证过,见 [browser.md](browser.md)。
+**轨迹**:*待跑*:一台装了本地版的手机上的京东登录。浏览器后端、接管面板和登录态的保留已经在模拟器上验证过,见 [browser.md](browser.md)。
## 4. 国庆成都行的预算表 {#_4-a-budget-table-for-成都-over-the-holiday}
@@ -65,7 +65,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
第三级:公开网站,不用登录,在后台浏览,你可以去忙别的。智能体不下单;「只看不订」也正是 `browser` 工具的哨兵规则对下单和付款按钮的要求。
-**轨迹:**2026-09-24 那次运行见本页末尾的说明。
+**轨迹**:*待跑*:2026-09-24 在电脑上试过,去哪儿把无头的桌面 Chromium 送回首页,只渲染出页脚(机器人检测);21 次工具调用后停下。要改走手机的 WebView,那才是为它准备的后端;[发布清单](../launch-checklist.md#9-chinese-services-and-the-showcase-p0)在跟踪它。
## 5. 约一个会,告诉群里 {#_5-book-a-meeting-and-tell-the-group}
@@ -78,11 +78,11 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
| `shell: lark-cli im +messages-send --chat-id oc_… --text "…"` | 群消息,带会议号和链接;正文在第二张卡片上 |
| `reminders` | 提前十分钟 |
-两个 CLI,两次审批,没打开任何 App。**轨迹:***待跑*:测试机上的腾讯会议和飞书登录(`tmeet auth login`、`lark-cli auth login`)。
+两个 CLI,两次审批,没打开任何 App。**轨迹**:*待跑*:测试机上的腾讯会议和飞书登录(`tmeet auth login`、`lark-cli auth login`)。
## 6. 剪贴板 → 日历 + 闹钟 {#_6-clipboard-→-calendar-alarm}
-> (复制了一条消息:「周五上午10点,海淀区中关村大街1号3层会议室,带上合同」)把剪贴板里的这个安排加到日历,提前一小时给我设个闹钟。
+> (复制了一条消息:「周五上午 10 点,海淀区中关村大街 1 号 3 层会议室,带上合同」)把剪贴板里的这个安排加到日历,提前一小时给我设个闹钟。
| 手 | 做什么 |
|---|---|
@@ -91,7 +91,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
| `device__alarm_set` | 周五 09:00,通过时钟 App |
| `amap__maps_geo`(可选) | 核对地址,把路上时间加进备注 |
-手机自己的能力作为 MCP 工具([device.md](device.md));除了可选的那次地理编码,什么都不离开手机。**轨迹:***待跑*:Android 手机上的本地版(设备服务器只在那里跑)。
+手机自己的能力作为 MCP 工具([device.md](device.md));除了可选的那次地理编码,什么都不离开手机。**轨迹**:*待跑*:Android 手机上的本地版(设备服务器只在那里跑)。
## 7. 一单美团,停在付款前 {#_7-a-美团-order-that-stops-before-payment}
@@ -105,7 +105,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
唯一一个在屏幕上做的案例,因为美团没有个人 API,它的网页版又需要 App 的登录。操作器看到和做过的一切都在轨迹里,每次点按都画在对应的截图上。两个模型分工:聊天模型负责计划和汇报(展示站机器 `.env` 里的 `MAIN_MODEL`;留空时是 `deepseek-v4.1-flash`),手的模型读每张截图、决定点哪里(`qwen3.8-27b`,`GUI_MODEL`);访客用自己的 OpenRouter key 时,手用的是 `qwen/qwen3.8-27b`。
-**轨迹:***待跑*:一台装了美团的 Android 手机(x86 模拟器跑不了它)。执行器、胶囊和「停止」已经在模拟器上验证过,见 [gui.md](gui.md)。
+**轨迹**:*待跑*:一台装了美团的 Android 手机(x86 模拟器跑不了它)。执行器、胶囊和「停止」已经在模拟器上验证过,见 [gui.md](gui.md)。
## 写下来了,没有演示 {#documented-not-shown}
@@ -118,7 +118,7 @@ nanoMuse 是一个智能体,有好几只手。它读写文件、搜网页、
## 绝不出现在公开材料里 {#never-in-public-material}
-你要求的话,智能体可以在手机屏幕上读微信,也会有人这么用。但它不会出现在展示站、网站或影片里,支付宝账单、医保和个税也不会:这些服务要么条款禁止自动化,要么数据不该出现在演示里。
+你要求的话,智能体可以在手机屏幕上读微信,也会有人这么用。但真正的微信不会出现在展示站、网站或影片里(托管演示里的手机是模拟器,它的微信和铁路12306 是带预置数据的仿制品),支付宝账单、医保和个税也不会:这些服务要么条款禁止自动化,要么数据不该出现在演示里。
## 读懂留下的痕迹 {#reading-the-trail}
diff --git a/docs/zh/trial.md b/docs/zh/trial.md
index c13cca45..7ade6c83 100644
--- a/docs/zh/trial.md
+++ b/docs/zh/trial.md
@@ -59,7 +59,7 @@ notification"、"send pixel a notification: build finished"。
## 6. 跟着你走的东西 {#_6-what-follows-you}
-登录后,主要聊天和旁聊跟着你到其他设备;每台设备的「数据控制」可以把它关掉([sync.md](sync.md))。
+登录后,主要聊天跟着你到其他设备,打开「同时同步旁聊」后旁聊也跟着走;两个开关都在每台设备的「数据控制」里([sync.md](sync.md))。
智能体的名字和形象属于账号。在一台设备上登录过的连接器,在其他设备上显示为「已在〈设备〉上连接」,
一步就能在那里也登录([hub.md](hub.md))。模型按设备各选各的:每台的 设置 → 模型 是它自己的。
diff --git a/docs/zh/web.md b/docs/zh/web.md
index e660abb4..d171a053 100644
--- a/docs/zh/web.md
+++ b/docs/zh/web.md
@@ -10,6 +10,8 @@
**连接 → 对话模型。** 服务商卡片是运行时的预设,所在地区的首选排第一(中国大陆是阿里云百炼,其他地方是 OpenRouter;[region.ts](../../web/src/region.ts))。卡片下面是所选服务商在目录里的那一行:它的 key 覆盖什么,以及需要知道的事(Kimi 的几个版本、OpenRouter 的 Image API、ChatGPT 的注意事项)。「动手模型」选择器列出能看图的模型;它的默认值是目录里这个服务商自己的动手模型(`defaults.hands`),服务商没有能看图的模型而你登录了账号时,则是账号的动手模型;既没有 `vision` 也没有账号的服务商得到的是那一句话,而不是一个看不见的动手模型。下面的「代理(可选)」接受 `http://host:port`、`https://` 或 `socks5://` 地址,只用于这个服务商的请求(`[llm] proxy`;nanoMuse Cloud 的请求从不经过它,SOCKS 地址需要运行时装了 `httpx[socks]`);存过的密码回显为圆点,清空字段就清掉它。
+**连接 → 对话模型 → 你自己的接口。** 说 OpenAI API 但不在目录里的服务(网关、中转、另一台机器上的 vLLM 或 LM Studio)是运行时「接口列表」里的一个接口,列表里放几个都行(#294)。「本地或你自己的端点」下面,每个已保存的接口是一块卡片,显示名字和主机名;最后一块是「添加接口」:名字(可选,不填就用主机名)、Base URL、API key(或「无需密钥」),以及它在 `/models` 列不出模型时手填的模型 id(列不出时表单会这么说)。key 以接口自己的名字进保险库(`ENDPOINT__API_KEY`);再添加一个接口,绝不会碰到前一个的 key。选中一个接口的卡片后,卡片下面有一张小卡说明它的 key 在哪、提供几个模型、哪些槽位正通过它回答,带「改名」「换密钥」「编辑模型」「重新获取模型」和「移除」;对话模型选择器列出这个接口的模型,「保存」按接口的 id 设置对话槽位(`PUT /api/connections/llm {"endpoint": "", "model": ...}`),不用再填地址或 key。给接口改名、换 key 或换地址,用到它的每个槽位一起变。「动手模型」选择器为列表里的其他每个接口多出一组;在那里选的模型让双手的截图走那个接口、用它的 key(`PUT /api/connections/gui {"endpoint": "", "model": ...}`),「生成图片」和「生成视频」的下拉框在「你的接口」下同样列出这些接口(`PUT /api/connections/image` / `/video` 带 `endpoint` 和 `model`)。对话模型正通过某个接口回答时不能移除它(先换一个对话模型);设在它上面的双手、图片或短片回到「自动」,它的 key 从保险库里删掉。在列表出现之前设在「其他 OpenAI 兼容接口」上的对话模型,运行时下次启动时会成为一号接口,key 复制到接口自己的保险库条目里,不问任何问题。列表是 `GET /api/connections` → `endpoints`,每个是 `{id, label, base_url, key_source, models, slots}`,从不带 key;路由在 [app.md](app.md#api)。没有列表的运行时还是像以前那样只有一块「其他 OpenAI 兼容接口」。
+
**连接 → 生成图片、生成视频。** 模型约定(0.1.41)的两行媒体槽位,各一张卡片,放在对话模型下面。值是运行时解析出的「服务商 · 模型」(`GET /api/connections` → `image` / `video`:槽位的设置和它的 `effective_*`):明确的选择优先,否则是对话服务商自己的图片模型(它有的话),否则登录时是账号的,再否则是那一句话和一个「添加服务商」按钮。选择器第一项是「自动」,然后是登录时的「nanoMuse Cloud」一组、有这项能力时的「对话模型的服务商」(用它的 key,不用再粘贴),以及「添加服务商」下面所在地区每一家有这项能力的目录服务商,加上一个带 Base URL 的「其他 OpenAI 兼容接口」;没有这项能力的服务商不会出现。模型可以从接口列出的里面选(nanoMuse Cloud 上是中继的菜单和账号可用的其他模型),也可以留给目录的默认值。「保存」调用 `PUT /api/connections/image` 或 `/video`,带 `provider`(目录 id,或 `openai` 加 `base_url`)、`model`、`base_url`、`api_key`(进保险库,名为 `IMAGE_API_KEY` / `VIDEO_API_KEY`;中继那一行写的是账号的 key,对话服务商那一行留给对话的 key);「自动」把四项都发空,也就是清掉这个槽位。三个依赖槽位的样子一致:「自动」是「动手模型」选择器和两行媒体槽位的服务商下拉框的第一项,保存它就让槽位回到解析顺序(动手是 `PUT /api/connections/gui {"model": ""}`),控件下面一行「当前为 服务商 · 模型」说明今天解析成什么,来自运行时的 `effective_*` 字段。没有这些路由的运行时不显示这两行。模块是 `web/src/models.ts`(行的值、选择器的选项);`web/src/models.test.ts` 检查它。
「动手模型」「图片模型」「短片模型」三个控件是同一个选择器(`web/src/components/ModelPicker.tsx`):一个写着「服务商 · 模型」的按钮,点开是一个面板,「自动」排第一。列表很长的服务商(OpenRouter、硅基流动)起初只显示八个模型,目录里这个槽位的默认模型和你当前选的排在最前,组尾是「还有 {n} 个」;所有行加起来超过八个时,「搜索模型」输入框边打字边按模型 id 或名称过滤每一组(都不匹配时显示「没有匹配的模型」)。按 Esc 或点面板外面关闭。折叠和搜索的逻辑在 `web/src/model-list.ts`,由 `web/src/model-list.test.ts` 检查。
diff --git a/harness/README.md b/harness/README.md
index 6e2635b8..8fdfff95 100644
--- a/harness/README.md
+++ b/harness/README.md
@@ -119,17 +119,17 @@ needed on the machine:
| --- | --- |
| Windows | `nanoMuse-Desktop--win-x64.exe` (NSIS; no certificate, so SmartScreen asks for *Run anyway*) |
| macOS | `nanoMuse-Desktop--mac-arm64.dmg`, `-mac-x64.dmg` (and `.zip`; ad-hoc signed unless the Apple secrets are set, then *Open Anyway* once in System Settings → Privacy & Security) |
-| Linux | `nanoMuse-Desktop--linux-x64.AppImage`, `.deb` |
+| Linux | `nanoMuse-Desktop--linux-x64.deb` (preferred), `.AppImage`, `.tar.gz` |
How it runs, in one paragraph: the shell starts the harness's Host as a child process —
its own Electron binary in Node mode (`ELECTRON_RUN_AS_NODE`, `--expose-internals`, the way
DeepSeek Harness's desktop does; the harness's `require-builtin` addon accepts exactly the
Electron the harness was built against, so [`package.json`](desktop/package.json) pins
-`44.0.0`) running the `dsh` under `resources/dsh`, where npm installed `@deepseek-ai/dsh`
+one exact Electron version, no range) running the `dsh` under `resources/dsh`, where npm installed `@deepseek-ai/dsh`
and `dsh-nanomuse` side by side at build time. The profile the Host boots lives under
`~/.nanomuse/desktop/profiles/nanomuse` (its own home; the CLI's `~/.dsh` is not touched; a
home left by nanoMuse Harness 0.1.28–0.1.29 under `~/.nanomuse/harness` is taken over once):
-a manifest naming the three bundles and a link `node_modules/dsh-nanomuse` to the copy
+a manifest naming the four bundles (dsh's base and web app, the schedule bundle, ours) and a link `node_modules/dsh-nanomuse` to the copy
under resources, refreshed every start, which is how the Loader finds a bundle that is not
among the harness's own packages. The shell reads the Host's `dsh web: http://127.0.0.1:…`
line and loads that URL; external links open in the browser; a Host that does not come up
@@ -192,7 +192,7 @@ dsh-nanomuse/
MuseSidebar.tsx the rail (Chats, Search, Feed, Ideas, Goals, Library, Devices, hamburger) and the column that holds the chats
FeedPanel.tsx, IdeasPanel.tsx, GoalsPanel.tsx, LibraryPanel.tsx
the four rooms, Muse's layouts; rooms.ts is their SSE store and fetch helper
- LiveStage.tsx the live stage: the latest frame, the caption, the cursor marker, expand and take over
+ Trajectory.tsx the trajectory of a hands run in the chat: each step's screenshot with the action drawn on it and the agent's words
Memory.tsx the Memory tab's list, Muse's import-memory sheet, the Data controls rows (download, reset)
AvatarStudio.tsx the avatar studio: describe, four candidates in a grid, pick, the poses, worn on the account
Pages.tsx Settings → Connectors, Permissions, File system access, Dictation
diff --git a/harness/desktop/electron-builder.yml b/harness/desktop/electron-builder.yml
index 661d9c6c..b5f55d1d 100644
--- a/harness/desktop/electron-builder.yml
+++ b/harness/desktop/electron-builder.yml
@@ -5,7 +5,7 @@
# Nothing is signed by a developer certificate unless the Apple secrets are in the
# environment: Windows asks for "Run anyway"; on macOS the workflow runs `--dir` and
# scripts/desktop-app/package-mac.sh, which ad-hoc signs (or signs and notarizes) the
-# # bundle and writes the zip and an APFS dmg (docs/desktop.md).
+# bundle and writes the zip and an APFS dmg (docs/desktop.md).
# The appId is the one the previous desktop app (desktop/app, retired in 0.1.30) carried, so
# this one installs over it and keeps its place in the system's permission lists.
appId: io.github.nanomuse.desktop
diff --git a/harness/desktop/package-lock.json b/harness/desktop/package-lock.json
index e26c49f5..830390b2 100644
--- a/harness/desktop/package-lock.json
+++ b/harness/desktop/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanomuse-desktop",
- "version": "1.0.2",
+ "version": "1.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanomuse-desktop",
- "version": "1.0.2",
+ "version": "1.0.3",
"license": "GPL-3.0-or-later",
"dependencies": {
"@computer-use/nut-js": "4.2.0"
diff --git a/harness/desktop/package.json b/harness/desktop/package.json
index bf374446..54a7fffc 100644
--- a/harness/desktop/package.json
+++ b/harness/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "nanomuse-desktop",
"private": true,
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "nanoMuse Desktop: the nanoMuse desktop built on DeepSeek Harness. The harness's web app in a window of our own, with nanoMuse's account, face, Hands and Reach as plugins and the runtime for the hands bundled.",
"homepage": "https://github.com/zeeshanhaque21/nanoMuse",
"license": "GPL-3.0-or-later",
diff --git a/harness/desktop/tests/installer.test.mjs b/harness/desktop/tests/installer.test.mjs
index 5aa7e6f7..f2378e4f 100644
--- a/harness/desktop/tests/installer.test.mjs
+++ b/harness/desktop/tests/installer.test.mjs
@@ -11,7 +11,8 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
const here = new URL("..", import.meta.url);
-const config = readFileSync(new URL("electron-builder.yml", here), "utf8");
+// read with LF line ends whatever the checkout wrote (a Windows runner with autocrlf writes CRLF)
+const config = readFileSync(new URL("electron-builder.yml", here), "utf8").replace(/\r\n/g, "\n");
/** The lines of one top-level block of the YAML, by its key. */
function block(key) {
diff --git a/harness/desktop/tests/mac-helper.test.mjs b/harness/desktop/tests/mac-helper.test.mjs
index 5fe7c5ad..f15e90d8 100644
--- a/harness/desktop/tests/mac-helper.test.mjs
+++ b/harness/desktop/tests/mac-helper.test.mjs
@@ -10,12 +10,16 @@ import { createServer } from "node:http";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import Module, { createRequire } from "node:module";
import { tmpdir } from "node:os";
-import { join } from "node:path";
+import { join, sep } from "node:path";
import { after, test } from "node:test";
import { MacHelper, MacHelperError, HELPER_NAME, HELPER_SCREEN_PERMISSION_TEXT, QUARANTINE_KEPT_TEXT, clearQuarantine, defaultHelperPath, describeAction, translocated } from "../out/mac-helper.js";
const roots = [];
+// every fake helper's server, so a failed assertion between launch and stop() cannot leave
+// one listening: an open server keeps the test process alive and the run hangs instead of failing
+const servers = [];
after(() => {
+ for (const server of servers) server.close();
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
});
@@ -148,6 +152,7 @@ function fakeHelper(mode = "ok") {
});
});
state.servers.push(server);
+ servers.push(server);
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
state.port = server.address().port;
// the real helper writes it atomically; a short delay stands for its start-up
@@ -186,9 +191,12 @@ test("start: token file 0600 before the launch, open's arguments, port discovery
assert.ok(fake.args.includes("--port-file"));
assert.equal(fake.parentPid, process.pid);
assert.equal(fake.token.length, 48);
- // the token file is the person's alone while the helper runs
- assert.equal(statSync(fake.args[1]).mode & 0o777, 0o600);
- assert.equal(statSync(join(dir, "data")).mode & 0o777, 0o700);
+ // the token file is the person's alone while the helper runs (Windows has no POSIX mode
+ // bits to read back; the helper itself only ever runs on a Mac)
+ if (process.platform !== "win32") {
+ assert.equal(statSync(fake.args[1]).mode & 0o777, 0o600);
+ assert.equal(statSync(join(dir, "data")).mode & 0o777, 0o700);
+ }
// the token the fake read is the one the client sends: /status was answered 200
const status = helper.cachedStatus();
assert.equal(status.screen, "granted");
@@ -496,9 +504,14 @@ test("clearQuarantine: xattr's answers → none, removed, kept (with what it sai
assert.equal(calls.length, before);
});
+// macOS paths from a function that joins with the platform's separator, so the assertions
+// read the same on a Windows checkout (the helper never runs there; its path logic is tested)
+const posix = (path) => path.split(sep).join("/");
+
test("an app under App Translocation does not start its helper, and says why", async () => {
const dir = scratch();
- const app = join(dir, "AppTranslocation", "4F1C", "d", "nanoMuse.app", "Contents", "Helpers", `${HELPER_NAME}.app`);
+ // joined with "/" on purpose: translocation is a macOS path, and Windows accepts the slash
+ const app = [dir, "AppTranslocation", "4F1C", "d", "nanoMuse.app", "Contents", "Helpers", `${HELPER_NAME}.app`].join("/");
mkdirSync(app, { recursive: true });
assert.equal(translocated(app), true);
assert.equal(translocated("/Applications/nanoMuse.app/Contents/Helpers/x.app"), false);
@@ -512,8 +525,8 @@ test("an app under App Translocation does not start its helper, and says why", a
test("defaultHelperPath: the env override, Contents/Helpers when packaged, the build dir in development", () => {
assert.equal(defaultHelperPath("/x/nanoMuse.app/Contents/MacOS/nanomuse-desktop", true, "/proj", { NANOMUSE_COMPUTER_USE_APP: "/elsewhere/Helper.app" }), "/elsewhere/Helper.app");
- assert.equal(defaultHelperPath("/x/nanoMuse.app/Contents/MacOS/nanomuse-desktop", true, "/proj", {}), "/x/nanoMuse.app/Contents/Helpers/nanoMuse Computer Use.app");
- assert.equal(defaultHelperPath("/usr/bin/electron", false, "/proj", {}), "/proj/mac/computer-use/build/nanoMuse Computer Use.app");
+ assert.equal(posix(defaultHelperPath("/x/nanoMuse.app/Contents/MacOS/nanomuse-desktop", true, "/proj", {})), "/x/nanoMuse.app/Contents/Helpers/nanoMuse Computer Use.app");
+ assert.equal(posix(defaultHelperPath("/usr/bin/electron", false, "/proj", {})), "/proj/mac/computer-use/build/nanoMuse Computer Use.app");
});
test("describeAction: the glow's words, as the operator phrases them", () => {
diff --git a/harness/desktop/tests/overlays.test.mjs b/harness/desktop/tests/overlays.test.mjs
index d97d1aa1..d84185e8 100644
--- a/harness/desktop/tests/overlays.test.mjs
+++ b/harness/desktop/tests/overlays.test.mjs
@@ -7,10 +7,12 @@ import { readFileSync } from "node:fs";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
-const page = (name) => readFileSync(fileURLToPath(new URL(`../resources/${name}.html`, import.meta.url)), "utf8");
+// read with LF line ends whatever the checkout wrote (a Windows runner with autocrlf writes CRLF)
+const text = (path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), "utf8").replace(/\r\n/g, "\n");
+const page = (name) => text(`../resources/${name}.html`);
const glow = page("glow");
const capsule = page("capsule");
-const main = readFileSync(fileURLToPath(new URL("../src/main.ts", import.meta.url)), "utf8");
+const main = text("../src/main.ts");
test("the glow breathes along the four edges; nothing flows round the rim", () => {
assert.match(glow, /\.on \.glow \{[^\n]*animation: breathe 4\.8s ease-in-out infinite/);
diff --git a/harness/dsh-nanomuse/build.mjs b/harness/dsh-nanomuse/build.mjs
index 2712c51d..b8e8aa2f 100644
--- a/harness/dsh-nanomuse/build.mjs
+++ b/harness/dsh-nanomuse/build.mjs
@@ -35,7 +35,7 @@ const PLATFORM_MODULES = [
await rm(new URL('./lib/', import.meta.url), { recursive: true, force: true })
await build({
- entryPoints: ['src/index.ts', 'src/admit.ts', 'src/cloud.ts', 'src/relay.ts', 'src/reach.ts', 'src/hub.ts', 'src/profile.ts', 'src/actions.ts', 'src/task.ts', 'src/sync.ts', 'src/rooms.ts', 'src/rooms-tools.ts', 'src/connectors.ts', 'src/connectors-tools.ts', 'src/hands-tools.ts', 'src/images.ts', 'src/desk.ts', 'src/avatar-flow.ts', 'src/fences.ts', 'src/video.ts', 'src/motion.ts', 'src/hands-check.ts', 'src/firstrun.ts', 'src/nudges.ts', 'src/providers.ts', 'src/trajectory.ts', 'src/refusals.ts', 'src/coding.ts', 'src/coding-readers.ts', 'src/coding-runner.ts'],
+ entryPoints: ['src/index.ts', 'src/admit.ts', 'src/cloud.ts', 'src/relay.ts', 'src/reach.ts', 'src/hub.ts', 'src/profile.ts', 'src/actions.ts', 'src/task.ts', 'src/sync.ts', 'src/rooms.ts', 'src/rooms-tools.ts', 'src/connectors.ts', 'src/connectors-tools.ts', 'src/hands-tools.ts', 'src/images.ts', 'src/desk.ts', 'src/avatar-flow.ts', 'src/fences.ts', 'src/video.ts', 'src/motion.ts', 'src/own-clip.ts', 'src/hands-check.ts', 'src/firstrun.ts', 'src/nudges.ts', 'src/providers.ts', 'src/trajectory.ts', 'src/refusals.ts', 'src/coding.ts', 'src/coding-readers.ts', 'src/coding-runner.ts'],
outdir: 'lib',
format: 'esm',
platform: 'node',
diff --git a/harness/dsh-nanomuse/cordis.patch.yml b/harness/dsh-nanomuse/cordis.patch.yml
index 38eafbf0..9553b714 100644
--- a/harness/dsh-nanomuse/cordis.patch.yml
+++ b/harness/dsh-nanomuse/cordis.patch.yml
@@ -33,8 +33,8 @@
- id: ui-brand-official
disabled: true
-# The left column is ours: an icon rail (face, Chats, Search, Schedules,
-# Devices, a corner menu) beside the harness's own chats browser. The stock
+# The left column is ours: an icon rail (Chats, Search, Feed, Ideas, Goals,
+# Library, Devices, the hamburger) beside the harness's own chats browser. The stock
# sidebar steps aside; our occupant declares the same seven child seats, so the
# brand mark, panel glyphs, workspace browser, settings shell and footer
# actions all mount exactly as before.
diff --git a/harness/dsh-nanomuse/package.json b/harness/dsh-nanomuse/package.json
index 75503a87..15dbf854 100644
--- a/harness/dsh-nanomuse/package.json
+++ b/harness/dsh-nanomuse/package.json
@@ -1,6 +1,6 @@
{
"name": "dsh-nanomuse",
- "version": "1.0.2",
+ "version": "1.0.3",
"description": "nanoMuse as a DeepSeek Harness bundle: the account, the face, the persona, Hands and Reach of a Muse-style personal agent, as plugins.",
"license": "GPL-3.0-or-later",
"repository": {
diff --git a/harness/dsh-nanomuse/src/client/Avatar.tsx b/harness/dsh-nanomuse/src/client/Avatar.tsx
index 14a6c325..5dc2df2f 100644
--- a/harness/dsh-nanomuse/src/client/Avatar.tsx
+++ b/harness/dsh-nanomuse/src/client/Avatar.tsx
@@ -18,9 +18,13 @@
* failed to load or was not drawn, the error mood. Without a clip the face and
* the emoji move as a whole instead: breathe when idle, sway while working, hop
* while waiting. A clip pauses while the window is hidden.
+ *
+ * A clip of the person's own (`own-clip.ts`, #324) plays instead, for every mood, when one
+ * is set on this computer; the stills keep their place, and with no drawn face the clip's
+ * first frame is the still.
*/
import { createElement as h, useEffect, useRef, useState, type CSSProperties, type ReactElement } from 'react'
-import { useLive, type Live, type LiveMotion, type LiveProfile } from './live.ts'
+import { useLive, type Live, type LiveMotion, type LiveOwnClip, type LiveProfile } from './live.ts'
import type { Translate } from './api.ts'
export type Mood = 'idle' | 'working' | 'waiting' | 'happy' | 'error'
@@ -31,12 +35,21 @@ const CLIP_MIN_PX = 44
const noClip = new Set()
const reducedMotion = typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
-/** Document-relative, so it resolves under whatever mount served the page. */
-export function stillUrl(profile: LiveProfile | undefined, mood: Mood = 'idle'): string {
+/**
+ * Document-relative, so it resolves under whatever mount served the page. With `ownClip`
+ * and no drawn face, the own clip's first frame stands in for the dragon.
+ */
+export function stillUrl(profile: LiveProfile | undefined, mood: Mood = 'idle', ownClip?: LiveOwnClip | null): string {
if (profile?.avatar === 'face' && profile.faceId) return `nanomuse/assets/face/${profile.faceId}/${mood}.webp`
+ if (ownClip?.poster) return `nanomuse/avatar/own-clip.png?v=${ownClip.v}`
return `nanomuse/assets/dragon-${mood}.webp`
}
+/** The person's own clip, when one is set: the same for every mood, keyed by its mtime. */
+export function ownClipUrl(ownClip: LiveOwnClip | null | undefined): string | null {
+ return ownClip ? `nanomuse/avatar/own-clip.mp4?v=${ownClip.v}` : null
+}
+
/**
* The clip for a mood: none for the error pose (a still says it better) or the emoji. A
* drawn face has one only when this computer drew it (`motion`), and the URL carries the
@@ -92,8 +105,9 @@ export function Avatar({ size, profile, mood = 'idle', className, title, still =
const live = useLive()
const visible = useVisible()
const video = useRef(null)
- const candidate = still || reducedMotion || size < CLIP_MIN_PX ? null : clipUrl(profile, mood, live.motion)
+ const candidate = still || reducedMotion || size < CLIP_MIN_PX ? null : ownClipUrl(live.ownClip) ?? clipUrl(profile, mood, live.motion)
const clip = candidate && !noClip.has(candidate) ? candidate : null
+ const poster = stillUrl(profile, mood, live.ownClip)
// the whole face moves when nothing inside it does
const motion = still || clip || reducedMotion ? '' : mood === 'working' ? ' nm-avatar-working' : mood === 'waiting' ? ' nm-avatar-waiting' : mood === 'idle' ? ' nm-avatar-idle' : ''
@@ -106,26 +120,27 @@ export function Avatar({ size, profile, mood = 'idle', className, title, still =
const box: CSSProperties = { width: size, height: size }
const emoji = profile?.avatar === 'emoji'
- const inner = emoji
- ? h('span', { className: 'nm-avatar-emoji', style: { background: profile.color || '#0064d4', fontSize: Math.round(size * 0.58), lineHeight: `${size}px` } }, profile.emoji || '✨')
- : clip
- ? h('video', {
- key: clip,
- ref: video,
- src: clip,
- poster: stillUrl(profile, mood),
- autoPlay: visible,
- loop: true,
- muted: true,
- playsInline: true,
- disablePictureInPicture: true,
- 'aria-hidden': true,
- onError: () => {
- noClip.add(clip)
- bump((n) => n + 1)
- },
- })
- : h('img', { src: stillUrl(profile, mood), alt: '', width: size, height: size, draggable: false })
+ // the own clip plays over any look; without it an emoji is drawn, not loaded
+ const inner = clip
+ ? h('video', {
+ key: clip,
+ ref: video,
+ src: clip,
+ poster,
+ autoPlay: visible,
+ loop: true,
+ muted: true,
+ playsInline: true,
+ disablePictureInPicture: true,
+ 'aria-hidden': true,
+ onError: () => {
+ noClip.add(clip)
+ bump((n) => n + 1)
+ },
+ })
+ : emoji
+ ? h('span', { className: 'nm-avatar-emoji', style: { background: profile.color || '#0064d4', fontSize: Math.round(size * 0.58), lineHeight: `${size}px` } }, profile.emoji || '✨')
+ : h('img', { src: poster, alt: '', width: size, height: size, draggable: false })
return h(
'span',
diff --git a/harness/dsh-nanomuse/src/client/AvatarStudio.tsx b/harness/dsh-nanomuse/src/client/AvatarStudio.tsx
index 049190e5..8376f2d9 100644
--- a/harness/dsh-nanomuse/src/client/AvatarStudio.tsx
+++ b/harness/dsh-nanomuse/src/client/AvatarStudio.tsx
@@ -7,13 +7,15 @@
* library. The prompts are the runtime's (`nanomuse/avatar/studio.py`), kept in
* step by hand, so a face drawn on any device comes out alike. Opened from the
* profile panel's look editor, or by the agent (`draw_new_look`) when the person
- * asks for a new look in a chat.
+ * asks for a new look in a chat. The describe step also offers a clip of the person's
+ * own as the face (OwnClipChooser.tsx, #324): choose, replace, or back to the default.
*/
import { createElement as h, Fragment, useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react'
import { StarNudgeOnce } from './AccountPage.tsx'
import { call, errorCode, type Translate, failureText } from './api.ts'
import { useLive } from './live.ts'
import type { Words } from './locales.ts'
+import { useOwnClipChooser } from './OwnClipChooser.tsx'
import { UnavailableLine, useProviders } from './OwnKey.tsx'
import { useRooms } from './rooms.ts'
import { Sheet } from './ui.tsx'
@@ -136,6 +138,7 @@ export function AvatarStudioSheet({ t, initial, style: initialStyle, onClose }:
const round = useRef(0)
// what is configured and what it covers (C11): decides whether a failed estimate is "nobody draws pictures here"
const providers = useProviders(t)
+ const clipChooser = useOwnClipChooser(t)
useEffect(() => () => { alive.current = false }, [])
useEffect(() => {
call('studio/estimate').then((e) => { if (alive.current) setEstimate(e) }).catch((err: unknown) => { if (alive.current) setEstimateError(errorCode(err) === 'signed_out' ? t('stSignedOut') : failureText(t, err)) })
@@ -230,7 +233,15 @@ export function AvatarStudioSheet({ t, initial, style: initialStyle, onClose }:
: cannot ? t('stCannotAfford', { cost: estimate.cny.toFixed(2), left: estimate.leftCny.toFixed(2) })
: estimate.clips ? t('stCostClips', { cost: estimate.cny.toFixed(2), left: estimate.leftCny.toFixed(2) })
: t('stCost', { cost: estimate.cny.toFixed(2), left: estimate.leftCny.toFixed(2) })),
- error ? h('div', { className: 'nm-room-error' }, error) : null)
+ error ? h('div', { className: 'nm-room-error' }, error) : null,
+ // a clip of the person's own instead (#324): this computer's alone
+ h('div', { className: 'nm-oc-section' },
+ h('div', { className: 'nm-st-label' }, t('ocTitle')),
+ h('p', { className: 'nm-fine nm-oc-body' }, live.ownClip ? t('ocOn') : t('ocBody')),
+ h('div', { className: 'nm-oc-row' },
+ h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', disabled: clipChooser.busy, onClick: clipChooser.choose }, live.ownClip ? t('ocReplace') : t('ocChoose')),
+ live.ownClip ? h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', onClick: () => void call('own-clip/clear').catch(() => undefined) }, t('ocDefault')) : null),
+ h('p', { className: 'nm-fine nm-oc-body' }, t('ocLocal'))))
footer = h('div', { className: 'nm-sheet-actions' },
h('span', { style: { flex: 1 } }),
h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', onClick: onClose }, t('cancel')),
@@ -270,7 +281,9 @@ export function AvatarStudioSheet({ t, initial, style: initialStyle, onClose }:
h(StarNudgeOnce, { t, moment: 'new_look' }))
footer = h('div', { className: 'nm-sheet-actions' }, h('span', { style: { flex: 1 } }), h('button', { type: 'button', className: 'nm-pill nm-pill-sm', onClick: onClose }, t('stFinish')))
}
- return h(Sheet, { title: t('stTitle'), closeLabel: t('close'), onClose: stage === 'posing' ? () => undefined : onClose, footer, wide: stage !== 'describe' }, body)
+ return h(Fragment, null,
+ h(Sheet, { title: t('stTitle'), closeLabel: t('close'), onClose: stage === 'posing' ? () => undefined : onClose, footer, wide: stage !== 'describe' }, body),
+ clipChooser.element)
}
/** The overlay entry: opens on the bus (the look editor) and on the agent's request (the rooms' `studio` field). */
diff --git a/harness/dsh-nanomuse/src/client/OwnClipChooser.tsx b/harness/dsh-nanomuse/src/client/OwnClipChooser.tsx
new file mode 100644
index 00000000..1b0ac6d5
--- /dev/null
+++ b/harness/dsh-nanomuse/src/client/OwnClipChooser.tsx
@@ -0,0 +1,161 @@
+/**
+ * Picking a clip of one's own as the face (#324): the file dialog, the host's look at the
+ * file (`own-clip.ts`: MP4 or MOV with H.264, at most 15 seconds and 30 MB), the refusal in
+ * one sentence, and the preview sheet with the clip playing in the face's own round frame,
+ * "Use" and "Cancel". The agent's page and the avatar studio call `choose()` from a button
+ * or a menu and render `element` once. "Use" also hands the host the clip's first frame,
+ * drawn from the preview, as the still for when there is no drawn face.
+ */
+import { createElement as h, Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
+import { call, failureText, type Translate } from './api.ts'
+import { Sheet } from './ui.tsx'
+
+const API = 'nanomuse/cloud'
+const MAX_SECONDS = 15
+const MAX_BYTES = 30 * 1024 * 1024
+/** The first frame kept as the still: square, this many pixels on a side. */
+const POSTER_PX = 256
+
+interface Pending {
+ v: number
+ size: number
+ durationMs: number
+}
+
+/** `12.3 MB`, in the UI language. */
+export function sizeText(bytes: number, lang: string): string {
+ const mb = bytes / (1024 * 1024)
+ const n = mb >= 10 ? Math.round(mb) : Math.round(mb * 10) / 10
+ return `${n.toLocaleString(lang || undefined)} MB`
+}
+
+/** The one sentence a refused clip gets, with the limits in it. */
+export function refusalText(t: Translate, code: string, seconds: number, bytes: number): string {
+ if (code === 'clip_too_long') return t('ocRefusedLong', { seconds })
+ if (code === 'clip_too_large') return t('ocRefusedLarge', { size: sizeText(bytes, t('langTag')) })
+ return t('ocRefusedFormat')
+}
+
+/** The clip's first frame as a square PNG, base64, from the playing preview; empty when the frame cannot be read yet. */
+function posterOf(video: HTMLVideoElement | null): string {
+ if (!video || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return ''
+ try {
+ const side = Math.min(video.videoWidth, video.videoHeight)
+ const canvas = document.createElement('canvas')
+ canvas.width = POSTER_PX
+ canvas.height = POSTER_PX
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return ''
+ ctx.drawImage(video, (video.videoWidth - side) / 2, (video.videoHeight - side) / 2, side, side, 0, 0, POSTER_PX, POSTER_PX)
+ const url = canvas.toDataURL('image/png')
+ return url.slice(url.indexOf(',') + 1)
+ } catch {
+ return ''
+ }
+}
+
+export interface OwnClipChooser {
+ /** Open the file dialog. */
+ choose(): void
+ /** True from the pick until the host has looked at the file. */
+ busy: boolean
+ /** The preview sheet and the refusal, to render once on the screen. */
+ element: ReactNode
+}
+
+export function useOwnClipChooser(t: Translate): OwnClipChooser {
+ const [busy, setBusy] = useState(false)
+ const [pending, setPending] = useState(null)
+ const [message, setMessage] = useState(null)
+ const alive = useRef(true)
+ useEffect(() => () => { alive.current = false }, [])
+
+ const stage = async (file: File) => {
+ // the size is known before anything is sent; the rest the host reads from the file
+ if (file.size > MAX_BYTES) {
+ setMessage(refusalText(t, 'clip_too_large', 0, file.size))
+ return
+ }
+ setBusy(true)
+ try {
+ const type = file.type || (/\.mov$/i.test(file.name) ? 'video/quicktime' : 'video/mp4')
+ const res = await fetch(`${API}/own-clip/stage`, { method: 'POST', headers: { 'content-type': type, 'x-nanomuse': '1' }, body: file })
+ const json = (await res.json().catch(() => ({}))) as { pending?: Pending; error?: { code?: string; message?: string }; seconds?: number; bytes?: number }
+ if (!alive.current) return
+ if (res.ok && json.pending) {
+ setPending(json.pending)
+ } else if (json.error?.code?.startsWith('clip_')) {
+ setMessage(refusalText(t, json.error.code, json.seconds ?? 0, json.bytes ?? file.size))
+ } else {
+ setMessage(failureText(t, Object.assign(new Error(json.error?.message ?? `${res.status}`), { code: json.error?.code ?? `${res.status}` })))
+ }
+ } catch (err: unknown) {
+ if (alive.current) setMessage(failureText(t, err))
+ } finally {
+ if (alive.current) setBusy(false)
+ }
+ }
+
+ const choose = () => {
+ if (busy) return
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.accept = 'video/mp4,video/quicktime,.mp4,.mov'
+ input.onchange = () => {
+ const file = input.files?.[0]
+ if (file) void stage(file)
+ }
+ input.click()
+ }
+
+ const element = h(Fragment, null,
+ pending ? h(OwnClipPreview, { t, pending, onDone: () => setPending(null) }) : null,
+ message
+ ? h(Sheet, { title: t('ocTitle'), closeLabel: t('close'), onClose: () => setMessage(null), footer: h('div', { className: 'nm-sheet-actions' }, h('span', { style: { flex: 1 } }), h('button', { type: 'button', className: 'nm-pill nm-pill-sm', onClick: () => setMessage(null) }, t('ocOk'))) },
+ h('p', { className: 'nm-sheet-lead', role: 'alert' }, message))
+ : null)
+
+ return { choose, busy, element }
+}
+
+/** The preview: the clip playing as the face will play it, its length and size, "Use" and "Cancel", and that it stays here. */
+function OwnClipPreview({ t, pending, onDone }: { t: Translate; pending: Pending; onDone(): void }): ReactNode {
+ const video = useRef(null)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState()
+ const decided = useRef(false)
+ const cancel = () => {
+ if (decided.current) return
+ decided.current = true
+ void call('own-clip/discard').catch(() => undefined)
+ onDone()
+ }
+ const use = async () => {
+ if (decided.current) return
+ setBusy(true)
+ setError(undefined)
+ try {
+ await call('own-clip/use', { poster: posterOf(video.current) })
+ decided.current = true
+ onDone()
+ } catch (err: unknown) {
+ setError(failureText(t, err))
+ setBusy(false)
+ }
+ }
+ // closed any other way (Escape, the ×): the same as "Cancel"
+ useEffect(() => () => { if (!decided.current) void call('own-clip/discard').catch(() => undefined) }, [])
+ const seconds = Math.round(pending.durationMs / 1000)
+ const footer = h('div', { className: 'nm-sheet-actions' },
+ h('span', { style: { flex: 1 } }),
+ h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', disabled: busy, onClick: cancel }, t('cancel')),
+ h('button', { type: 'button', className: 'nm-pill nm-pill-sm', disabled: busy, onClick: () => void use() }, t('ocUse')))
+ return h(Sheet, { title: t('ocPreviewTitle'), closeLabel: t('close'), onClose: cancel, footer },
+ h('div', { className: 'nm-sheet-form' },
+ h('p', { className: 'nm-sheet-lead' }, t('ocPreviewLead')),
+ h('span', { className: 'nm-avatar nm-oc-disc', role: 'img', 'aria-label': t('ocPreviewLabel') },
+ h('video', { ref: video, src: `nanomuse/avatar/own-clip.pending.mp4?v=${pending.v}`, autoPlay: true, loop: true, muted: true, playsInline: true, disablePictureInPicture: true, 'aria-hidden': true })),
+ h('p', { className: 'nm-fine nm-oc-details' }, t('ocDetails', { seconds: Math.min(seconds, MAX_SECONDS), size: sizeText(pending.size, t('langTag')) })),
+ error ? h('div', { className: 'nm-room-error' }, error) : null,
+ h('p', { className: 'nm-fine' }, t('ocLocal'))))
+}
diff --git a/harness/dsh-nanomuse/src/client/OwnKey.tsx b/harness/dsh-nanomuse/src/client/OwnKey.tsx
index 73f25025..5400649f 100644
--- a/harness/dsh-nanomuse/src/client/OwnKey.tsx
+++ b/harness/dsh-nanomuse/src/client/OwnKey.tsx
@@ -33,6 +33,8 @@ export const ACCOUNT_SECTION = 'nanomuse-cloud'
/** An own-key row as the host keeps it in `cloud.json` (`src/providers.ts` `OwnProvider`): never a key. */
export interface OwnProvider {
+ /** The row's own id: the catalogue id for a vendor; `custom`, `custom-2`, ... for hand-made endpoints, several at once (#294). */
+ id: string
provider: string
label: string
protocol: string
@@ -122,7 +124,7 @@ export function UnavailableLine({ t, view, capability, link = false, className =
// ---- one row -----------------------------------------------------------------------------------
-function KeyForm({ t, entry, region, configured, onSaved, onCancel }: { t: Translate; entry: ProviderEntry; region: Region; configured: OwnProvider | undefined; onSaved(said: string, got: { label: string; offer: SlotOffer }): void; onCancel(): void }): ReactNode {
+function KeyForm({ t, entry, region, configured, onSaved, onCancel }: { t: Translate; entry: ProviderEntry; region: Region; configured: OwnProvider | undefined; onSaved(said: string, got: { id: string; label: string; offer: SlotOffer }): void; onCancel(): void }): ReactNode {
const base = baseUrlFor(entry, region)
const [apiKey, setApiKey] = useState('')
const [baseURL, setBaseURL] = useState(configured?.baseURL ?? base)
@@ -140,11 +142,13 @@ function KeyForm({ t, entry, region, configured, onSaved, onCancel }: { t: Trans
setBusy(true)
setError(undefined)
const body: Record = { id: entry.id, apiKey, lang: t('langTag') }
+ // a hand-made endpoint changed stays the row it is; without `row` the host adds another (#294)
+ if (configured && entry.user_capabilities) body.row = configured.id
if (editable) body.baseURL = baseURL
if (entry.user_capabilities) { body.capabilities = caps; body.label = label }
if (entry.user_capabilities && models.trim()) body.models = models
call('providers/save', body)
- .then((saved) => onSaved(saved.models.length ? t('ownKeySaved', { n: saved.models.length }) : t('ownKeySavedNone'), { label: saved.label, offer: saved.offer ?? {} }))
+ .then((saved) => onSaved(saved.models.length ? t('ownKeySaved', { n: saved.models.length }) : t('ownKeySavedNone'), { id: saved.id || entry.id, label: saved.label, offer: saved.offer ?? {} }))
.catch((err: unknown) => {
if (errorCode(err) === 'no_models') { setAskModels(true); setError(t('ownKeyNoModels')); return }
setError(failureText(t, err))
@@ -268,42 +272,77 @@ export function UseItCard({ t, id, label, offer, signedIn, onDone }: { t: Transl
h('div', { className: 'nm-wrap', style: { ...muted, fontSize: 12 } }, t('useItFoot')))
}
-export function ProviderRow({ t, entry, view, onChanged }: { t: Translate; entry: ProviderEntry; view: ProvidersView; onChanged(said?: string): void }): ReactNode {
- const configured = view.configured.find((p) => p.provider === entry.id)
+/**
+ * One catalogue entry's row: the vendor's name, what it covers, the key form. `own` names the
+ * saved row it stands for; by default the one whose `provider` is the entry. A hand-made
+ * endpoint is one row per saved endpoint (#294): each with its own name over its host, a
+ * *Rename* beside *Change key* and *Remove*; `own: null` is the row that adds another.
+ */
+export function ProviderRow({ t, entry, view, own, onChanged }: { t: Translate; entry: ProviderEntry; view: ProvidersView; own?: OwnProvider | null; onChanged(said?: string): void }): ReactNode {
+ const configured = own === null ? undefined : (own ?? view.configured.find((p) => p.provider === entry.id))
+ const rowId = configured?.id ?? entry.id
const [open, setOpen] = useState(false)
+ const [renaming, setRenaming] = useState(false)
+ const [label, setLabel] = useState(configured?.label ?? '')
const [said, setSaid] = useState()
- const [offer, setOffer] = useState<{ label: string; offer: SlotOffer } | undefined>()
+ const [offer, setOffer] = useState<{ id: string; label: string; offer: SlotOffer } | undefined>()
const [busy, setBusy] = useState(false)
const [error, setError] = useState()
const keyUrl = keyUrlFor(entry, view.region)
const needsKey = !entry.auth.includes('none')
+ // a hand-made endpoint: the row reads as its name (or host); the adding row as the entry
+ const several = Boolean(entry.user_capabilities)
+ const title = several && configured ? configured.label || hostOf(configured.baseURL) : nameOf(t, entry)
const remove = () => {
setBusy(true)
setError(undefined)
- call<{ cloudModelsOn?: boolean } | undefined>('providers/remove', { id: entry.id })
+ call<{ cloudModelsOn?: boolean } | undefined>('providers/remove', { id: rowId })
// the row was the last chat source while the account's models were off: they are on again, said once here
.then((r) => { setSaid(r?.cloudModelsOn ? t('ownKeyRemovedCloudOn') : undefined); setOffer(undefined); onChanged() })
.catch((err: unknown) => setError(failureText(t, err))).finally(() => setBusy(false))
}
- return h('div', { className: 'nm-way' },
+ const rename = (event: FormEvent) => {
+ event.preventDefault()
+ setBusy(true)
+ setError(undefined)
+ call('providers/rename', { id: rowId, label, lang: t('langTag') })
+ .then((r) => { setRenaming(false); setLabel(r.label); onChanged() })
+ .catch((err: unknown) => setError(failureText(t, err))).finally(() => setBusy(false))
+ }
+ return h('div', { className: 'nm-way', 'data-testid': several && configured ? `nm-way-${rowId}` : undefined },
h('span', { className: 'nm-way-icon' }, configured ? h(IconCheck, { size: 15 }) : h(IconKey, { size: 15 })),
h('div', { className: 'nm-way-main' },
- h('div', { className: 'nm-way-title' }, nameOf(t, entry), h('span', { style: { ...muted, marginLeft: 8 } }, coverage(t, configured?.capabilities ?? entry.capabilities))),
+ h('div', { className: 'nm-way-title' }, title, h('span', { style: { ...muted, marginLeft: 8 } }, coverage(t, configured?.capabilities ?? entry.capabilities))),
+ several && configured ? h('div', { className: 'nm-way-sub nm-wrap', style: muted }, hostOf(configured.baseURL)) : null,
open ? h('div', { className: 'nm-way-sub nm-wrap', style: muted }, noteOf(t, entry), entry.verified ? ` · ${t('ownKeyVerified', { month: entry.verified })}` : '') : null,
configured ? h('div', { className: 'nm-way-sub' }, `${t('ownKeyConfigured')} · ${configured.models.length ? t('ownKeySaved', { n: configured.models.length }) : t('ownKeySavedNone')}`) : null,
said ? h('div', { className: 'nm-way-sub' }, said) : null,
offer
- ? h(UseItCard, { t, id: entry.id, label: offer.label || nameOf(t, entry), offer: offer.offer, signedIn: view.cloud.signedIn, onDone: (text) => { setOffer(undefined); if (text) setSaid(text); onChanged(text) } })
+ ? h(UseItCard, { t, id: offer.id, label: offer.label || nameOf(t, entry), offer: offer.offer, signedIn: view.cloud.signedIn, onDone: (text) => { setOffer(undefined); if (text) setSaid(text); onChanged(text) } })
: null,
error ? h('div', { style: errorStyle }, error) : null,
- open
+ renaming
+ ? h('form', { style: { ...row, marginTop: 8 }, onSubmit: rename },
+ h('input', { className: 'nm-field', value: label, placeholder: hostOf(configured?.baseURL ?? ''), 'aria-label': t('ownKeyLabelField'), autoFocus: true, onChange: (e: FormEvent) => setLabel(e.currentTarget.value) }),
+ h('button', { type: 'submit', className: 'nm-pill nm-pill-sm', disabled: busy }, busy ? t('sending') : t('save')),
+ h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', disabled: busy, onClick: () => { setRenaming(false); setLabel(configured?.label ?? '') } }, t('cancel')))
+ : open
? h(KeyForm, { t, entry, region: view.region, configured, onSaved: (text, got) => { setOpen(false); setSaid(text); setOffer(Object.keys(got.offer).length ? got : undefined); onChanged(text) }, onCancel: () => setOpen(false) })
: h('div', { style: row },
- h('button', { type: 'button', className: `nm-pill nm-pill-sm${configured ? ' nm-pill-ghost' : ''}`, disabled: busy, onClick: () => setOpen(true) }, configured ? t('ownKeyChange') : needsKey ? t('ownKeyAdd') : t('ownKeyConnect')),
+ h('button', { type: 'button', className: `nm-pill nm-pill-sm${configured ? ' nm-pill-ghost' : ''}`, disabled: busy, onClick: () => setOpen(true) }, configured ? t('ownKeyChange') : several ? t('ownKeyAddEndpoint') : needsKey ? t('ownKeyAdd') : t('ownKeyConnect')),
keyUrl && needsKey ? h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', onClick: () => openLink(keyUrl) }, t('ownKeyGetKey')) : null,
+ several && configured ? h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', disabled: busy, onClick: () => setRenaming(true) }, t('ownKeyRename')) : null,
configured ? h('button', { type: 'button', className: 'nm-pill nm-pill-ghost nm-pill-sm', disabled: busy, onClick: remove }, t('ownKeyRemove')) : null)))
}
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host
+ } catch {
+ return url
+ }
+}
+
// ---- the groups ----------------------------------------------------------------------------------
export type Group = 'account' | 'first' | 'signIn' | 'rest' | 'local' | 'custom'
@@ -356,8 +395,11 @@ export function WaysOnRows({ t, view, groups = ALL_GROUPS, signInRow = true, hea
for (const p of g.local) out.push(rowOf(p))
}
if (groups.includes('custom') && g.custom) {
+ // one row per saved endpoint, then the row that adds another (#294)
+ const custom = g.custom
out.push(heading(t('ownKeyCustomGroup')))
- out.push(rowOf(g.custom))
+ for (const own of view.configured.filter((p) => p.provider === custom.id)) out.push(h(ProviderRow, { key: `custom:${own.id}`, t, entry: custom, view, own, onChanged }))
+ out.push(h(ProviderRow, { key: 'custom:add', t, entry: custom, view, own: null, onChanged }))
}
return h('div', { className: 'nm-ways' }, out)
}
@@ -372,7 +414,8 @@ export const MODELS_SECTION = 'nanomuse-models'
* with what new chats answer through and what the hands see with, in ` · `.
*/
export function ModelsLink({ t, view }: { t: Translate; view: ProvidersView }): ReactNode {
- const labelOf = (provider: string) => (provider === 'nanomuse' ? 'nanoMuse Cloud' : view.configured.find((p) => p.provider === provider)?.label || provider)
+ // the slot names the row's id (`custom-2` for a second hand-made endpoint), which is the catalogue id for a vendor
+ const labelOf = (provider: string) => (provider === 'nanomuse' ? 'nanoMuse Cloud' : (view.configured.find((p) => p.id === provider) ?? view.configured.find((p) => p.provider === provider))?.label || provider)
const line = (slot: { provider: string; model: string }) => (slot.model ? `${labelOf(slot.provider)} · ${slot.model}` : t('mdPick'))
return h('div', { className: 'nm-card', style: { marginTop: 4 } },
h('button', { type: 'button', className: 'nm-row nm-row-button', 'data-testid': 'nm-models-link', onClick: () => { settingsBus.openSection?.(MODELS_SECTION) } },
diff --git a/harness/dsh-nanomuse/src/client/ProfileDrawer.tsx b/harness/dsh-nanomuse/src/client/ProfileDrawer.tsx
index cbee76f1..265b5999 100644
--- a/harness/dsh-nanomuse/src/client/ProfileDrawer.tsx
+++ b/harness/dsh-nanomuse/src/client/ProfileDrawer.tsx
@@ -2,7 +2,7 @@
* The agent's page, the panel the face opens (android …/ui/profile/AgentProfileScreen.kt
* on a desktop): the big face with a pen badge whose menu is the phone's — Change
* avatar (words land in the chat, the flow in AvatarChat.tsx takes them), Edit name,
- * Avatar studio… — a share button, the name, "online" under it, and four panes:
+ * Your own clip… and Default face (OwnClipChooser.tsx, #324), Avatar studio… — a share button, the name, "online" under it, and four panes:
* Activity (what each chat did, day by day; an entry opens the task's steps),
* Approvals (the standing "always allow" grants with Manage permissions, then the
* answers given on cards), Daily (the goals' routines, Manage routines) and Soul &
@@ -18,6 +18,7 @@ import { IconAlarm, IconBell, IconCheck, IconChevronDown, IconChevronRight, Icon
import { AvatarShareSheet } from './AvatarShare.tsx'
import { studioBus } from './AvatarStudio.tsx'
import { prefillComposer } from './composer.ts'
+import { useOwnClipChooser } from './OwnClipChooser.tsx'
import { useLive, type LiveProfile } from './live.ts'
import type { ChatListState, ChatStatus } from './MuseChats.tsx'
import { usePrefs } from './prefs.ts'
@@ -59,6 +60,8 @@ function Drawer({ t, stop, openSchedules, useSessions, useSessionStatus }: Profi
const [share, setShare] = useState(false)
const [detail, setDetail] = useState(null)
const panel = useRef(null)
+ // a clip of the person's own as the face (#324): the dialog, the preview and the refusal live here
+ const clipChooser = useOwnClipChooser(t)
// Like Muse's panel it stays beside the chat until closed (×, Escape); the
// chat column makes room for it meanwhile.
@@ -121,6 +124,10 @@ function Drawer({ t, stop, openSchedules, useSessions, useSessionStatus }: Profi
? h('div', { className: 'nm-menu nm-pf-menu', role: 'menu' },
h('button', { type: 'button', role: 'menuitem', className: 'nm-menu-item', onClick: changeAvatar }, h('span', { className: 'nm-menu-item-label' }, t('pfChangeAvatar'))),
h('button', { type: 'button', role: 'menuitem', className: 'nm-menu-item', onClick: () => { setMenu(false); setEditing('name') } }, h('span', { className: 'nm-menu-item-label' }, t('pfEditName'))),
+ h('button', { type: 'button', role: 'menuitem', className: 'nm-menu-item', disabled: clipChooser.busy, onClick: () => { setMenu(false); clipChooser.choose() } }, h('span', { className: 'nm-menu-item-label' }, t('pfOwnClip'))),
+ live.ownClip
+ ? h('button', { type: 'button', role: 'menuitem', className: 'nm-menu-item', onClick: () => { setMenu(false); void call('own-clip/clear').catch(() => undefined) } }, h('span', { className: 'nm-menu-item-label' }, t('ocDefault')))
+ : null,
h('button', { type: 'button', role: 'menuitem', className: 'nm-menu-item', onClick: () => { setMenu(false); profileBus.close(); studioBus.open?.(live.profile.description || '', live.profile.style || 'muse') } }, h('span', { className: 'nm-menu-item-label' }, t('pfAvatarStudio'))),
h('button', { type: 'button', role: 'menuitem', className: 'nm-menu-item', onClick: () => { setMenu(false); setEditing('look') } }, h('span', { className: 'nm-menu-item-label' }, t('pfChangeLook'))))
: null),
@@ -132,7 +139,8 @@ function Drawer({ t, stop, openSchedules, useSessions, useSessionStatus }: Profi
: null,
h('div', { className: 'nm-pf-body' }, body),
share ? h(AvatarShareSheet, { t, name, onClose: () => setShare(false) }) : null,
- detail ? h(TaskDetail, { t, sessionId: detail, useSessions, onClose: () => setDetail(null) }) : null)
+ detail ? h(TaskDetail, { t, sessionId: detail, useSessions, onClose: () => setDetail(null) }) : null,
+ clipChooser.element)
}
/** The words under the face for a running task: the step under way, else the job itself
diff --git a/harness/dsh-nanomuse/src/client/live.ts b/harness/dsh-nanomuse/src/client/live.ts
index f21f379e..9c0e1862 100644
--- a/harness/dsh-nanomuse/src/client/live.ts
+++ b/harness/dsh-nanomuse/src/client/live.ts
@@ -143,6 +143,8 @@ export interface Live {
update: UpdateInfo | null
/** The drawn face's clips and how their drawing goes (desk-b). */
motion: LiveMotion
+ /** The person's own clip as the face (#324): size, the mtime (`v`) its URL carries, whether a first frame was kept; null for none. */
+ ownClip: LiveOwnClip | null
/** When the hands last saw an all-black screen (macOS: Screen Recording missing or granted after launch); 0 when never. */
blackScreenAt: number
/** Conversation sync (C8): `rev` moves with every change; the session that is the account's main conversation. Absent on an older host. */
@@ -189,6 +191,12 @@ export interface LiveMotion {
export const NO_MOTION: LiveMotion = { progress: null, clips: {}, faceId: '' }
+export interface LiveOwnClip {
+ size: number
+ v: number
+ poster: boolean
+}
+
export const DEFAULT_PROFILE: LiveProfile = { rev: 0, name: 'nanoMuse', avatar: 'dragon', emoji: '', color: '', description: '', style: '', faceId: '', connectors: [] }
const INITIAL: Live = {
@@ -204,6 +212,7 @@ const INITIAL: Live = {
handsModel: '',
update: null,
motion: NO_MOTION,
+ ownClip: null,
blackScreenAt: 0,
streaming: false,
}
@@ -228,7 +237,7 @@ function open(): void {
try {
const data = JSON.parse(event.data) as Partial>
// An older host (0.1.33) sends no approvals/holds; keep the defaults rather than undefined.
- publish({ ...snapshot, ...data, profile: { ...DEFAULT_PROFILE, ...data.profile }, approvals: data.approvals ?? [], holds: data.holds ?? [], grants: data.grants ?? [], handsModel: data.handsModel ?? '', update: data.update ?? null, motion: data.motion ?? NO_MOTION, blackScreenAt: data.blackScreenAt ?? 0, streaming: true })
+ publish({ ...snapshot, ...data, profile: { ...DEFAULT_PROFILE, ...data.profile }, approvals: data.approvals ?? [], holds: data.holds ?? [], grants: data.grants ?? [], handsModel: data.handsModel ?? '', update: data.update ?? null, motion: data.motion ?? NO_MOTION, ownClip: data.ownClip ?? null, blackScreenAt: data.blackScreenAt ?? 0, streaming: true })
} catch {
// a malformed frame is skipped; the next snapshot replaces everything anyway
}
diff --git a/harness/dsh-nanomuse/src/client/locales.ts b/harness/dsh-nanomuse/src/client/locales.ts
index 22af533c..58d5e7d7 100644
--- a/harness/dsh-nanomuse/src/client/locales.ts
+++ b/harness/dsh-nanomuse/src/client/locales.ts
@@ -283,6 +283,24 @@ export const en = {
pfChangeLook: 'Change the look',
pfEditName: 'Edit the name',
pfAvatarStudio: 'Avatar studio…',
+ // a clip of the person's own as the face (#324)
+ pfOwnClip: 'Your own clip…',
+ ocTitle: 'Your own clip',
+ ocBody: 'A short video of yours plays as the face at rest, and the other moods fall back to it. MP4 or MOV with H.264 video, up to 15 seconds and 30 MB; it plays muted in a loop.',
+ ocOn: 'Your clip is on and plays for every mood; the drawn stills still show where a still is used. Default face removes it.',
+ ocLocal: 'The clip stays on this computer. It is not uploaded to nanoMuse Cloud or synced to your other devices.',
+ ocChoose: 'Choose a clip',
+ ocReplace: 'Replace the clip',
+ ocDefault: 'Default face',
+ ocPreviewTitle: 'Preview',
+ ocPreviewLead: 'This is how it plays as the face: muted, in a loop, cropped to the circle.',
+ ocPreviewLabel: 'Preview of your clip',
+ ocDetails: '{seconds} s · {size}',
+ ocUse: 'Use',
+ ocOk: 'OK',
+ ocRefusedFormat: 'That file cannot be used here. Pick an MP4 or MOV with H.264 video, up to 15 seconds and 30 MB.',
+ ocRefusedLong: 'That clip runs {seconds} seconds. The limit is 15 seconds, and 30 MB.',
+ ocRefusedLarge: 'That clip is {size}. The limit is 30 MB, and 15 seconds.',
pfOffline: 'Offline',
pfActivity: 'Activity',
pfApprovals: 'Approvals',
@@ -1333,6 +1351,8 @@ export const en = {
ownKeyRestGroup: 'Other providers',
ownKeyLocalGroup: 'On this computer',
ownKeyCustomGroup: 'Any OpenAI-compatible endpoint',
+ ownKeyAddEndpoint: 'Add an endpoint',
+ ownKeyRename: 'Rename',
ownKeyCovChat: 'chat',
ownKeyCovVision: 'screen operation',
ownKeyCovImage: 'pictures',
@@ -1695,6 +1715,24 @@ export const zh: typeof en = {
pfChangeLook: '更换内置形象',
pfEditName: '编辑名称',
pfAvatarStudio: '形象工坊…',
+ // a clip of the person's own as the face (#324)
+ pfOwnClip: '你自己的视频…',
+ ocTitle: '你自己的视频',
+ ocBody: '一段你自己的短视频会作为待机时的形象播放,其他状态也沿用它。MP4 或 MOV,H.264 视频,最长 15 秒、最大 30 MB;静音循环播放。',
+ ocOn: '你的视频已启用,所有状态都播放它;需要静态图的地方仍用画好的图片。「默认形象」可移除它。',
+ ocLocal: '视频只保存在这台电脑上,不会上传到 nanoMuse Cloud,也不会同步到你的其他设备。',
+ ocChoose: '选择视频',
+ ocReplace: '更换视频',
+ ocDefault: '默认形象',
+ ocPreviewTitle: '预览',
+ ocPreviewLead: '作为形象时就是这样播放的:静音、循环、裁成圆形。',
+ ocPreviewLabel: '你的视频预览',
+ ocDetails: '{seconds} 秒 · {size}',
+ ocUse: '使用',
+ ocOk: '好',
+ ocRefusedFormat: '这个文件无法在这里使用。请选择 MP4 或 MOV(H.264 视频),最长 15 秒、最大 30 MB。',
+ ocRefusedLong: '这段视频长 {seconds} 秒。上限是 15 秒,以及 30 MB。',
+ ocRefusedLarge: '这段视频有 {size}。上限是 30 MB,以及 15 秒。',
pfOffline: '离线',
pfActivity: '活动',
pfApprovals: '审核记录',
@@ -2033,7 +2071,7 @@ export const zh: typeof en = {
cnGateSentinelMail: '发信每次都会先问;一次性验证码和重置链接会在模型看到之前抹掉。',
cnOwnPage: '打开它的设置页',
cnDetailFine: '连接器只会在你看得见的聊天里被使用;个人资料里的活动记录列出每一次调用。',
- cnConnectTitle: '连接{name}',
+ cnConnectTitle: '连接 {name}',
cnConsentGets: '智能体会得到什么',
cnConsentGetsSub: '只有这个连接器列出的那几个工具。除了工具本身发给该服务的内容,你的东西不会离开这台电脑。',
cnConsentYou: '它能做什么由你定',
@@ -2743,6 +2781,8 @@ export const zh: typeof en = {
ownKeyRestGroup: '其他服务商',
ownKeyLocalGroup: '本机',
ownKeyCustomGroup: '任何 OpenAI 兼容接口',
+ ownKeyAddEndpoint: '添加接口',
+ ownKeyRename: '改名',
ownKeyCovChat: '对话',
ownKeyCovVision: '操作屏幕',
ownKeyCovImage: '图片',
diff --git a/harness/dsh-nanomuse/src/client/styles.ts b/harness/dsh-nanomuse/src/client/styles.ts
index f043f468..c9c8b2c5 100644
--- a/harness/dsh-nanomuse/src/client/styles.ts
+++ b/harness/dsh-nanomuse/src/client/styles.ts
@@ -45,21 +45,30 @@ html[data-nanomuse] {
--nm-blue: #2f6fd0;
--nm-blue-hover: #2a63ba;
--nm-rail: 66px;
+ --nm-header-h: 108px;
--nm-radius: 14px;
--nm-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
}
-/* Light: paper, white fields, grey bubbles, the user's tinted with the accent. */
+/* Light: paper, white fields; the conversation monochrome as on the phone (ChatColors.kt): the
+ chat's canvas plain white, your pill the ink (black, white text), the agent's a grey a step
+ off the canvas, the send button and the scroll-to-latest disc the same ink with the glyph in
+ the opposite colour. The paper stays under the rail, the columns and the pages. */
html[data-nanomuse] body {
--nm-base: #f9f9f9;
+ --nm-canvas: #ffffff;
--nm-field: #ffffff;
--nm-field-border: #e3e4e6;
--nm-card: #ffffff;
--nm-selected: #f0f0f1;
--nm-hover: #f1f1f2;
--nm-divider: #ececec;
- --nm-agent-bubble: #e9e9eb;
- --nm-user-bubble: color-mix(in srgb, var(--nm-accent) 22%, #ffffff);
- --nm-send: #111111;
+ --nm-agent-bubble: #f2f2f7;
+ --nm-user-bubble: #000000;
+ --nm-user-bubble-fg: #ffffff;
+ --nm-user-bubble-selection: rgb(255 255 255 / 0.3);
+ --nm-ink: #000000;
+ --nm-ink-fg: #ffffff;
+ --nm-send: var(--nm-ink);
--nm-send-idle: #c9c9cc;
--dsw-alias-bg-base: #f9f9f9;
--dsw-alias-bg-layer-1: #f2f2f2;
@@ -71,9 +80,12 @@ html[data-nanomuse] body {
--dsw-specific-input-major: #ffffff;
--dsw-alias-brand-primary: #1d1d1f;
}
-/* Dark: one near-black, fields and cards a step lighter, bubbles a quiet grey. */
+/* Dark: one near-black, fields and cards a step lighter; the ink inverted: your pill white with
+ black text, the agent's the quiet grey a step off the near-black (the step the phone's #1c1c1e
+ is off pure black), the send button and the scroll-to-latest disc white with a black glyph. */
html[data-nanomuse] body[data-ds-dark-theme] {
--nm-base: #171717;
+ --nm-canvas: #171717;
--nm-field: #2b2b2b;
--nm-field-border: #2b2b2b;
--nm-card: #2d2d2d;
@@ -81,8 +93,12 @@ html[data-nanomuse] body[data-ds-dark-theme] {
--nm-hover: #222222;
--nm-divider: #242424;
--nm-agent-bubble: #242424;
- --nm-user-bubble: color-mix(in srgb, var(--nm-accent) 42%, #171717);
- --nm-send: #e8e8e8;
+ --nm-user-bubble: #ffffff;
+ --nm-user-bubble-fg: #000000;
+ --nm-user-bubble-selection: rgb(0 0 0 / 0.3);
+ --nm-ink: #ffffff;
+ --nm-ink-fg: #000000;
+ --nm-send: var(--nm-ink);
--nm-send-idle: #3a3a3a;
--dsw-alias-bg-base: #171717;
--dsw-alias-bg-layer-1: #1d1d1d;
@@ -141,12 +157,12 @@ html:is([data-nm-platform='darwin'], [data-nm-platform='win32']) header[data-win
html[data-nm-platform='darwin']:not([data-nm-fullscreen]) .nm-header-drag { height: 36px; }
html[data-nm-platform='darwin']:not([data-nm-fullscreen]) .nm-header { top: 40px; }
html[data-nm-platform='darwin']:not([data-nm-fullscreen]) .nm-header-back { top: 42px; }
-html[data-nm-platform='darwin']:not([data-nm-fullscreen]) header[data-window-drag]:has(.nm-header) { min-height: 136px; }
+html[data-nm-platform='darwin']:not([data-nm-fullscreen]) { --nm-header-h: 136px; }
html[data-nm-platform='win32']:not([data-nm-fullscreen]) .nm-rail-top { height: 24px; }
html[data-nm-platform='win32']:not([data-nm-fullscreen]) .nm-header-drag { height: 22px; right: 150px; }
html[data-nm-platform='win32']:not([data-nm-fullscreen]) .nm-header { top: 26px; }
html[data-nm-platform='win32']:not([data-nm-fullscreen]) .nm-header-back { top: 28px; }
-html[data-nm-platform='win32']:not([data-nm-fullscreen]) header[data-window-drag]:has(.nm-header) { min-height: 122px; }
+html[data-nm-platform='win32']:not([data-nm-fullscreen]) { --nm-header-h: 122px; }
html[data-nm-platform='win32']:not([data-nm-fullscreen]) [data-slot="conversation.session.header"] > :first-child { padding-right: 150px; min-height: 40px; }
html[data-nm-platform='win32']:not([data-nm-fullscreen]) .nm-ob-pager { top: 48px; }
/* Windows, out of full screen: the caption buttons cover the top right corner, so each top bar
@@ -244,7 +260,20 @@ html[data-nm-platform='darwin']:not([data-nm-fullscreen]) .nm-col-top { height:
[data-conversation-content][data-content-phase="hero"] > [data-conversation-scroll] > :is(.nm-remote, .nm-traj):not(:is(.nm-remote, .nm-traj) ~ :is(.nm-remote, .nm-traj)) { margin-top: auto; padding-top: 16px; }
/* ---- the pinned agent header over the conversation -------------------- */
-header[data-window-drag]:has(.nm-header) { position: relative; min-height: 108px; }
+header[data-window-drag]:has(.nm-header) { position: relative; min-height: var(--nm-header-h); }
+/* In the Muse look the header is a scrim rather than a bar: it lies over the conversation, which scrolls
+ under it and dissolves into the canvas (the canvas at 80 percent at the top edge, 35 percent past the
+ middle, clear at the bottom, as the phone draws it), and no line runs under it. The thread keeps the
+ header's height clear at its top, so the first message starts under the face rather than behind it.
+ The wheel over the empty parts of the scrim reaches the thread; the controls take their own clicks,
+ and the drag strip stays a drag strip. The chat's canvas is white in the light theme, the paper
+ staying under the rail, the columns and the pages; the harness's base token follows inside the
+ conversation so the composer's own fade matches. (The header sits in its slot's display:contents
+ anchor, so the thread is the anchor's sibling, not the header's.) */
+html[data-nm-muse] header[data-window-drag]:has(.nm-header) { position: absolute; top: 0; left: 0; right: 0; z-index: 6; border-bottom: 0; pointer-events: none; background: linear-gradient(to bottom, color-mix(in srgb, var(--nm-canvas) 80%, transparent) 0%, color-mix(in srgb, var(--nm-canvas) 35%, transparent) 55%, transparent 100%); }
+html[data-nm-muse] header[data-window-drag]:has(.nm-header) :is(button, a, input, [role="tab"], [role="dialog"], [role="menu"], .nm-header, .nm-header-drag) { pointer-events: auto; }
+html[data-nm-muse] [data-slot="conversation.header"]:has(.nm-header) ~ [data-conversation-content] { background: var(--nm-canvas); --dsw-alias-bg-base: var(--nm-canvas); }
+html[data-nm-muse] [data-slot="conversation.header"]:has(.nm-header) ~ [data-conversation-content] > [data-conversation-scroll] { padding-top: var(--nm-header-h); }
/* Centred with auto margins, not translateX(-50%): the window's drag-region map is built from
untransformed boxes, and a transformed, pointer-events:none box over a drag region left the
face unclickable on macOS. The header block itself is the no-drag island (see desk-a below). */
@@ -298,8 +327,12 @@ html[data-nm-muse] [data-composer-card] [class*="_standardControls"] { gap: 2px
html[data-nm-muse] [data-composer-card] [data-slot="conversation.input.model"] { display: none !important; }
html[data-nm-muse] [data-composer-card] [class*="_activity"] { display: none !important; }
html[data-nm-muse] [data-composer-card] [class*="_add"] { width: 34px; height: 34px; border-radius: 50%; color: var(--dsw-alias-label-secondary); }
-html[data-nm-muse] [data-composer-card] [class*="_primary"] { width: 34px; height: 34px; transform: none !important; background: var(--nm-send) !important; color: var(--nm-base) !important; }
-html[data-nm-muse] [data-composer-card] [class*="_primary"]:disabled { background: var(--nm-send-idle) !important; opacity: 1 !important; color: var(--nm-base) !important; }
+/* the send disc is the ink (black in the light, white in the dark) with the arrow in the opposite colour */
+html[data-nm-muse] [data-composer-card] [class*="_primary"] { width: 34px; height: 34px; transform: none !important; background: var(--nm-send) !important; color: var(--nm-ink-fg) !important; }
+html[data-nm-muse] [data-composer-card] [class*="_primary"]:disabled { background: var(--nm-send-idle) !important; opacity: 1 !important; color: var(--nm-canvas) !important; }
+/* the harness's scroll-to-latest disc, floating over the thread, reads the same ink */
+html[data-nm-muse] [data-conversation-scroll] [class*="_toBottomSlot"] > button[class*="_toBottom"] { background: var(--nm-send); color: var(--nm-ink-fg); box-shadow: 0 2px 10px rgba(0,0,0,0.18); }
+html[data-nm-muse] [data-conversation-scroll] [class*="_toBottomSlot"] > button[class*="_toBottom"]:hover { background: var(--nm-send); opacity: 0.88; }
html[data-nm-muse] [data-composer-card] ~ [class*="_dock"] { display: none !important; }
.nm-mic { width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
.nm-mic:hover { background: var(--nm-hover); color: var(--dsw-alias-label-primary); }
@@ -333,8 +366,14 @@ html[data-nm-muse] [data-chat-flow-kind="user"] [data-clock="start"] [class*="_t
html[data-nm-muse] [data-chat-flow-kind="user"] [data-clock="start"] button { opacity: 0; transition: opacity 160ms; }
html[data-nm-muse] [data-chat-flow-kind="user"]:hover [data-clock="start"] button, html[data-nm-muse] [data-chat-flow-kind="user"] [data-clock="start"] button:focus-visible { opacity: 1; }
html[data-nm-muse] [data-chat-flow-kind="turn-tail"] [class*="_timeEnd"] { font-size: 11px; color: var(--dsw-alias-label-dimmed); }
-/* The user's words: a tinted bubble on the right. The agent's: a quiet grey one. */
-html[data-nm-muse] [data-chat-flow-kind="user"] [class*="_bubble"] { background: var(--nm-user-bubble) !important; color: var(--dsw-alias-label-primary); border-radius: 18px !important; padding: 9px 14px !important; }
+/* The user's words: the ink pill on the right (black with white text in the light, white with black
+ in the dark). The agent's: a quiet grey one a step off the canvas. A link inside your pill keeps the
+ pill's text colour, underlined, and a selection on it is a translucent wash of that colour. */
+html[data-nm-muse] [data-chat-flow-kind="user"] [class*="_bubble"] { background: var(--nm-user-bubble) !important; color: var(--nm-user-bubble-fg); border-radius: 18px !important; padding: 9px 14px !important; }
+html[data-nm-muse] [data-chat-flow-kind="user"] [class*="_bubble"] a { color: inherit; text-decoration: underline; }
+html[data-nm-muse] [data-chat-flow-kind="user"] [class*="_bubble"] ::selection { background: var(--nm-user-bubble-selection); color: var(--nm-user-bubble-fg); }
+html[data-nm-muse] .nm-remote-user .nm-remote-bubble { background: var(--nm-user-bubble); color: var(--nm-user-bubble-fg); }
+html[data-nm-muse] .nm-remote-user .nm-remote-bubble ::selection { background: var(--nm-user-bubble-selection); color: var(--nm-user-bubble-fg); }
html[data-nm-muse] [data-chat-flow-kind="assistant-step"] [data-slot="conversation.chat.node"] > [class*="_root"] > [class*="_body"] { width: fit-content; max-width: 100%; box-sizing: border-box; background: var(--nm-agent-bubble); border-radius: 20px; padding: 10px 16px; }
html[data-nm-muse] [data-chat-flow-kind="assistant-step"] [data-slot="conversation.chat.node"] > [class*="_root"] > [class*="_body"]:empty { display: none; }
html[data-nm-muse] [data-chat-flow-kind="assistant-step"] [data-slot="conversation.chat.node"] > [class*="_root"] > [class*="_body"] > [class*="_markdown"] > :first-child { margin-top: 0; }
@@ -1028,6 +1067,13 @@ html[data-nm-platform='darwin']:not([data-nm-fullscreen]) .nm-lib-col-top { heig
.nm-st-mood { position: relative; width: 92px; height: 92px; border-radius: 14px; overflow: hidden; background: var(--nm-card); box-shadow: inset 0 0 0 1px var(--nm-field-border); display: flex; align-items: center; justify-content: center; }
.nm-st-mood img { width: 100%; height: 100%; object-fit: cover; display: block; }
.nm-st-mood .nm-st-tag { left: 6px; bottom: 6px; font-size: 10.5px; padding: 1px 6px; }
+/* a clip of the person's own as the face (#324): the studio's section and the preview's disc */
+.nm-oc-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--nm-divider); }
+.nm-oc-section .nm-st-label { margin-top: 0; }
+.nm-sheet-body .nm-oc-body { margin: 0 0 8px; line-height: 1.5; }
+.nm-oc-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 10px; }
+.nm-oc-disc { width: 148px; height: 148px; margin: 12px auto 8px; }
+.nm-sheet-body .nm-oc-details { margin: 0 0 12px; text-align: center; }
/* ---- memory and the data controls ---- */
.nm-mem-add { display: flex; gap: 8px; align-items: center; }
@@ -1250,7 +1296,10 @@ html[data-nm-platform='darwin']:not([data-nm-fullscreen]) .nm-fr-top { height: 6
.nm-cd-from-you { align-items: flex-end; }
.nm-cd-from-agent { align-items: flex-start; }
.nm-cd-bubble { max-width: 88%; padding: 9px 14px; border-radius: 18px; font-size: 14px; line-height: 1.55; color: var(--dsw-alias-label-primary); word-break: break-word; }
-.nm-cd-from-you .nm-cd-bubble { background: var(--nm-user-bubble); }
+.nm-cd-from-you .nm-cd-bubble { background: var(--nm-user-bubble); color: var(--nm-user-bubble-fg); }
+.nm-cd-from-you .nm-cd-bubble a { color: inherit; }
+.nm-cd-from-you .nm-cd-bubble ::selection { background: var(--nm-user-bubble-selection); color: var(--nm-user-bubble-fg); }
+.nm-cd-from-you .nm-cd-bubble pre { background: rgb(127 127 127 / 0.25); }
.nm-cd-from-agent .nm-cd-bubble { background: var(--nm-agent-bubble); }
.nm-cd-bubble p { margin: 0 0 6px; }
.nm-cd-bubble p:last-child { margin-bottom: 0; }
diff --git a/harness/dsh-nanomuse/src/cloud.ts b/harness/dsh-nanomuse/src/cloud.ts
index 92e60870..62b71f50 100644
--- a/harness/dsh-nanomuse/src/cloud.ts
+++ b/harness/dsh-nanomuse/src/cloud.ts
@@ -27,7 +27,7 @@
*/
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import type { IncomingMessage, ServerResponse } from 'node:http'
-import { ApprovalDesk, appOf, checkForUpdate, DEVICE_GRANT_PREFIX, HoldDesk, modelFor, pickChatModel, pickHandsModel, REMOTE_CONTROL_GRANT_ID, sharedConnectors, standingGrants, takesImages, type Grant, type Hold, type HoldTool, type PendingApproval, type StandingGrant, type UpdateInfo } from './desk.ts'
+import { ApprovalDesk, appOf, checkForUpdate, compareVersions, DEVICE_GRANT_PREFIX, HoldDesk, modelFor, pickChatModel, pickHandsModel, REMOTE_CONTROL_GRANT_ID, sharedConnectors, standingGrants, takesImages, type Grant, type Hold, type HoldTool, type PendingApproval, type StandingGrant, type UpdateInfo } from './desk.ts'
import { arch, homedir, hostname, release, type, userInfo } from 'node:os'
import { dirname, join } from 'node:path'
import { createHmac, randomBytes, randomUUID } from 'node:crypto'
@@ -53,6 +53,7 @@ import { TaskRunner, textOf } from './task.ts'
import { SCREEN_RUN_IDLE_MS, ScreenRuns, Trajectory, type ScreenRun, type StepAction, type TrajectoryView } from './trajectory.ts'
import { RemoteStore, SyncEngine, SyncRelay, type RemoteLine, type SessionInfo, type SessionLine, type SyncState } from './sync.ts'
import { AvatarMotion, ANIMATED, type MotionMood, type MotionView } from './motion.ts'
+import { OwnClip, type OwnClipView } from './own-clip.ts'
import { DEFAULT_VIDEO_MODEL, hostOf, KNOWN_DASHSCOPE_MODELS, looksLikeVideoModel, probe as probeVideoModel, speaksDashScope, type VideoEndpoint } from './video.ts'
import { editImage as ownEditImage, generateImage as ownGenerateImage, imageShapeOf, type ImageEndpoint } from './images.ts'
import { checkMove, checkScreenshot, displayInfo, isBlack, runtimeInfo, screenHead, type MoveCheck, type RuntimeInfo, type ScreenshotCheck } from './hands-check.ts'
@@ -273,6 +274,8 @@ export interface LiveState {
update: UpdateInfo | null
/** The face's clips: which exist (with a cache key) and how the drawing goes (desk-b). */
motion: MotionView
+ /** The person's own clip as the face (#324): its size, cache key and whether a first frame was kept; null for none. Absent on an older host. */
+ ownClip?: OwnClipView | null
/** When the hands last saw an all-black screen (macOS: Screen Recording missing, or granted after the app started); 0 when they have not. */
blackScreenAt: number
/** Conversation sync (C8): `rev` moves with every change pulled or pushed; the session that is the account's main conversation. */
@@ -441,6 +444,21 @@ export interface MediaView {
/** One check a day, at most, by itself; the About row may ask any time. */
const UPDATE_EVERY_MS = 24 * 60 * 60_000
+/**
+ * A cached check, read against the bundle that runs now. The state file outlives an
+ * update: right after 1.0.2 was installed over 1.0.1, the check 1.0.1 made within the day
+ * still said `{ current: '1.0.1', latest: '1.0.2', newer: true }`, and the sidebar offered
+ * "Update to 1.0.2" to the 1.0.2 that was running until the next daily check (#341). So
+ * `current` becomes this bundle's version and `newer` is compared again against the cached
+ * `latest`; the time of the check is kept, so no extra request is made. A cache made by
+ * this very version is returned as it is.
+ */
+export function updateForThisVersion(cached: UpdateInfo, current: string = BUNDLE_VERSION): UpdateInfo {
+ if (cached.current === current) return cached
+ const latest = typeof cached.latest === 'string' ? cached.latest : ''
+ return { ...cached, current, latest, newer: Boolean(latest) && compareVersions(latest, current) > 0 }
+}
+
/** Tool names whose calls the capsule follows. */
export function isHandsTool(name: string): boolean {
return name.startsWith('mcp__nanomuse__') || name === 'devices' || name.startsWith('device_') || name === 'delegate'
@@ -457,6 +475,15 @@ function trustedOf(raw: Record): Record): Record {
const out: Record = {}
@@ -465,6 +492,8 @@ function ownProvidersOf(raw: Record): Record
if (typeof p.baseURL !== 'string' || !Array.isArray(p.capabilities)) continue
out[id] = {
+ // the key is the id; a row written before the endpoint list (#294) carries none of its own
+ id,
provider: typeof p.provider === 'string' && p.provider ? p.provider : id,
label: typeof p.label === 'string' && p.label ? p.label : id,
protocol: p.protocol === 'anthropic' || p.protocol === 'gemini' || p.protocol === 'openai-responses' ? p.protocol : 'openai',
@@ -602,6 +631,8 @@ export default class NanomuseCloud extends Service {
private lastSharedConnectors = ''
/** The face's clips (desk-b). */
readonly motion: AvatarMotion
+ /** The person's own clip as the face (#324), this computer's alone. */
+ readonly ownClip: OwnClip
private blackScreenAt = 0
private handsMount: HandsMount = { at: 0, ok: true, reason: '' }
/** The own-key catalogue (C11), from `assets/providers.json`. */
@@ -634,6 +665,10 @@ export default class NanomuseCloud extends Service {
onChange: () => this.broadcast(),
log: (level, text) => this.ctx.logger[level](text),
})
+ this.ownClip = new OwnClip({
+ dir: join(this.dir(), 'avatar'),
+ onChange: () => this.broadcast(),
+ })
this.hub = new HubClient({
url: this.relay.hubURL,
key: () => this.token(),
@@ -669,6 +704,7 @@ export default class NanomuseCloud extends Service {
// The face's clips follow the face (C3): a face drawn here or pulled from the account drops the
// old clips and, with the Media setting on and a video model at hand, is animated again.
await this.motion.init().catch((error: unknown) => this.ctx.logger.warn('nanomuse: avatar clips not read: %s', message(error)))
+ await this.ownClip.init().catch((error: unknown) => this.ctx.logger.warn('nanomuse: own clip not read: %s', message(error)))
this.profile.onChange(() => void this.animateNewFace().catch((error: unknown) => this.ctx.logger.warn('nanomuse: avatar motion: %s', message(error))))
this.hub.onState(() => this.broadcast())
this.hub.onDevices(() => this.broadcast())
@@ -955,7 +991,9 @@ export default class NanomuseCloud extends Service {
/** The newest release against what runs here; cached a day unless `force`. */
update(force: boolean): Promise {
const cached = this.state.update
- if (!force && cached && Date.now() - cached.checkedAt < UPDATE_EVERY_MS) return Promise.resolve(cached)
+ // a cache made by another version of this bundle (the one that ran before an update was
+ // installed) compared the wrong `current`; it is not an answer for this one (#341)
+ if (!force && cached && cached.current === BUNDLE_VERSION && Date.now() - cached.checkedAt < UPDATE_EVERY_MS) return Promise.resolve(cached)
if (this.updateCheck) return this.updateCheck
// the bare number: 0.1.40 compared the labelled `VERSION` ("dsh-nanomuse 0.1.40"), which
// parsed as 0, so every release read as newer and the installed one was offered again
@@ -1425,22 +1463,34 @@ export default class NanomuseCloud extends Service {
* chat models the endpoint listed (or the catalogue's defaults) — goes into the harness's model
* adapter the way the Models page writes one. The row's capabilities are the catalogue's;
* `custom` takes what the person says.
+ *
+ * A vendor has one row, named after its catalogue id, so saving its key again replaces it.
+ * A hand-made endpoint (`custom`) is one row per endpoint (#294): without `row`, a new one
+ * joins under the lowest free id (`custom`, `custom-2`, ...); with `row`, that row is the one
+ * changed, and an empty key keeps the key it has.
*/
- async saveProvider(input: { id: string; apiKey?: string; baseURL?: string; label?: string; capabilities?: string[]; models?: string[]; lang?: string }): Promise {
+ async saveProvider(input: { id: string; row?: string; apiKey?: string; baseURL?: string; label?: string; capabilities?: string[]; models?: string[]; lang?: string }): Promise {
const id = input.id.trim().toLowerCase()
if (id === CHATGPT_PROVIDER || id === PROVIDER_ID) throw new RelayError(400, 'bad_provider', 'That row is written by a sign-in, not a key')
const entry = this.catalogue.find((p) => p.id === id)
if (!entry) throw new RelayError(404, 'unknown_provider', 'Not in the catalogue')
+ const wantedRow = (input.row ?? '').trim().toLowerCase()
+ const existing = wantedRow ? this.state.providers?.[wantedRow] : this.state.providers?.[id]
+ if (wantedRow && (!existing || existing.provider !== id)) throw new RelayError(404, 'not_found', 'No such row')
+ const rowId = wantedRow || (entry.id === 'custom' ? this.freeCustomRowId() : id)
const region = this.region(input.lang ?? '')
- const baseURL = (input.baseURL ?? '').trim().replace(/\/+$/, '') || baseUrlFor(entry, region)
+ const baseURL = (input.baseURL ?? '').trim().replace(/\/+$/, '') || (wantedRow ? existing?.baseURL : undefined) || baseUrlFor(entry, region)
if (!/^https?:\/\//.test(baseURL)) throw new RelayError(400, 'bad_url', 'The address must start with http:// or https://')
- const apiKey = (input.apiKey ?? '').trim()
+ let apiKey = (input.apiKey ?? '').trim()
+ // a row changed without a new key keeps the one it has, read back to ask the endpoint for its models
+ const keptKeyRef = !apiKey && wantedRow && existing?.keyRef ? existing.keyRef : ''
+ if (keptKeyRef) apiKey = await this.credential(keptKeyRef)
if (!apiKey && !entry.auth.includes('none')) throw new RelayError(400, 'no_key', 'This provider needs a key')
// not `bad_key`: the client reads that code as a retired sign-in
if (apiKey && /\s/.test(apiKey)) throw new RelayError(400, 'key_shape', 'A key has no spaces in it')
let capabilities: Capability[] = entry.capabilities
if (entry.user_capabilities) {
- const said = (input.capabilities ?? []).filter((c): c is Capability => (CAPABILITIES as readonly string[]).includes(c))
+ const said = (input.capabilities ?? (wantedRow ? existing?.capabilities : undefined) ?? []).filter((c): c is Capability => (CAPABILITIES as readonly string[]).includes(c))
capabilities = CAPABILITIES.filter((c) => c === 'chat' || said.includes(c))
}
// A vendor that wants a session id per conversation (OpenCode Go) is reached through the
@@ -1451,26 +1501,54 @@ export default class NanomuseCloud extends Service {
const listed = catalogRoute ? served.map((m) => ({ id: m })) : await listModelRows(entry.protocol, baseURL, apiKey)
// The ids the person typed (a gateway that does not answer `GET /models`, or one model
// wanted out of hundreds): first in the list, the endpoint's own after them; a typed id the
- // list also has keeps what the list said about it (OpenRouter's thinking levels).
- const typed = typedModelIds(input.models).filter((m) => !catalogRoute || served.includes(m))
+ // list also has keeps what the list said about it (OpenRouter's thinking levels). A row
+ // changed without new ids keeps the ones it had.
+ const typedInput = input.models ?? (wantedRow ? existing?.models.map((m) => m.id) : undefined)
+ const typed = typedModelIds(typedInput).filter((m) => !catalogRoute || served.includes(m))
let models = modelsOf({ ...entry, capabilities }, [...typed.map((id) => listed.find((m) => m.id === id) ?? { id }), ...listed.filter((m) => !typed.includes(m.id))])
if (entry.user_capabilities) models = models.map((m) => ({ ...m, vision: capabilities.includes('vision') }))
// A row with no chat model cannot be served (the model adapter refuses a route that resolves
// no models) and nothing could pick from it: a plain sentence, before anything is written.
if (!models.some((m) => m.kind === 'chat')) throw new RelayError(400, 'no_models', 'The endpoint did not list its models (GET /models). Type the ids of the models it serves, separated by commas.')
- const keyRef = apiKey ? keyRefFor(id) : ''
- if (keyRef) await this.ctx.credentials.set(credentialRef(keyRef), apiKey)
- const label = (input.label ?? '').trim() || (input.lang?.toLowerCase().startsWith('zh') ? entry.name_zh : entry.name)
- const row: OwnProvider = { provider: id, label, protocol: entry.protocol, baseURL, keyRef, capabilities, models, at: Date.now(), ...(catalogRoute ? { catalogRoute } : {}) }
- await this.ctx.settings.update(LLM_ROW, { providers: { [id]: ownProviderRow(row, entry.reasoning) } })
- this.state = { ...this.state, providers: { ...this.state.providers, [id]: row } }
+ const keyRef = keptKeyRef || (apiKey ? keyRefFor(rowId) : '')
+ if (keyRef && !keptKeyRef) await this.ctx.credentials.set(credentialRef(keyRef), apiKey)
+ // a hand-made endpoint reads as its host unless named, so several are told apart
+ const fallbackLabel = entry.id === 'custom' ? hostNameOf(baseURL) : input.lang?.toLowerCase().startsWith('zh') ? entry.name_zh : entry.name
+ const label = (input.label ?? '').trim() || (wantedRow ? existing?.label : undefined) || fallbackLabel
+ const row: OwnProvider = { id: rowId, provider: id, label, protocol: entry.protocol, baseURL, keyRef, capabilities, models, at: Date.now(), ...(catalogRoute ? { catalogRoute } : {}) }
+ await this.ctx.settings.update(LLM_ROW, { providers: { [rowId]: ownProviderRow(row, entry.reasoning) } })
+ this.state = { ...this.state, providers: { ...this.state.providers, [rowId]: row } }
await this.writeState()
- await this.adoptOwnDefault(id, row)
+ await this.adoptOwnDefault(rowId, row)
await this.writeHands()
this.broadcast()
return row
}
+ /** The lowest free id for a hand-made endpoint's row: `custom`, then `custom-2`, `custom-3`, ... (#294). */
+ private freeCustomRowId(): string {
+ const taken = this.state.providers ?? {}
+ if (!taken.custom) return 'custom'
+ for (let n = 2; ; n++) if (!taken[`custom-${n}`]) return `custom-${n}`
+ }
+
+ /**
+ * `POST /providers/rename`: a new name for an own row, in the pickers and the model adapter
+ * alike; empty goes back to the row's host (a hand-made endpoint) or the catalogue's name.
+ */
+ async renameProvider(id: string, label: string, lang = ''): Promise {
+ const row = this.state.providers?.[id]
+ if (!row || id === CHATGPT_PROVIDER) throw new RelayError(404, 'not_found', 'No such row')
+ const entry = this.catalogue.find((p) => p.id === row.provider)
+ const name = label.trim().slice(0, 80) || (row.provider === 'custom' ? hostNameOf(row.baseURL) : lang.toLowerCase().startsWith('zh') ? entry?.name_zh : entry?.name) || id
+ const renamed: OwnProvider = { ...row, label: name }
+ await this.ctx.settings.update(LLM_ROW, { providers: { [id]: ownProviderRow(renamed, entry?.reasoning) } })
+ this.state = { ...this.state, providers: { ...this.state.providers, [id]: renamed } }
+ await this.writeState()
+ this.broadcast()
+ return renamed
+ }
+
/**
* The saved own-key rows written to the model adapter again, where the row it has differs from
* what the catalogue now says (a vendor's thinking levels learned after the key was saved).
@@ -1667,7 +1745,7 @@ export default class NanomuseCloud extends Service {
const ids = listed.length ? listed : ['gpt-5.6-sol', 'gpt-5.4', 'gpt-5.4-mini']
const label = this.chatgpt.login.label || this.state.chatgpt?.label || 'ChatGPT'
const own: OwnModel[] = ids.map((id) => ({ id, name: id, vision: capabilities.includes('vision'), kind: 'chat' as const }))
- const row: OwnProvider = { provider: CHATGPT_PROVIDER, label, protocol: 'openai', baseURL: url, keyRef: CHATGPT_KEY_REF, capabilities, models: own, at: Date.now() }
+ const row: OwnProvider = { id: CHATGPT_PROVIDER, provider: CHATGPT_PROVIDER, label, protocol: 'openai', baseURL: url, keyRef: CHATGPT_KEY_REF, capabilities, models: own, at: Date.now() }
await this.ctx.settings.update(LLM_ROW, { providers: { [CHATGPT_PROVIDER]: ownProviderRow(row) } })
const fresh = !this.state.chatgpt
this.state = { ...this.state, providers: { ...this.state.providers, [CHATGPT_PROVIDER]: row }, chatgpt: { label, at: this.state.chatgpt?.at ?? Date.now() } }
@@ -1925,6 +2003,7 @@ export default class NanomuseCloud extends Service {
handsModel: this.handsModel(),
update: this.state.update ?? null,
motion: this.motion.view(),
+ ownClip: this.ownClip.view(),
blackScreenAt: this.blackScreenAt,
sync: this.sync ? { rev: this.sync.view().rev, mainSession: this.sync.state.mainSession } : { rev: 0, mainSession: '' },
ownKeys: {
@@ -2983,7 +3062,7 @@ export default class NanomuseCloud extends Service {
...(typeof raw.handsModel === 'string' && raw.handsModel ? { handsModel: raw.handsModel } : {}),
// a grant 0.1.37 kept for the "app" "Done. Screen now:" (the mis-read head line) names nothing — dropped
...(Array.isArray(raw.grants) ? { grants: (raw.grants as Grant[]).filter((g) => g && typeof g.id === 'string' && typeof g.target === 'string' && !MISREAD_GRANT.test(g.target)).map((g) => ({ id: g.id, target: g.target, at: Number(g.at) || 0 })) } : {}),
- ...(raw.update && typeof raw.update === 'object' && typeof (raw.update as UpdateInfo).checkedAt === 'number' ? { update: raw.update as UpdateInfo } : {}),
+ ...(raw.update && typeof raw.update === 'object' && typeof (raw.update as UpdateInfo).checkedAt === 'number' ? { update: updateForThisVersion(raw.update as UpdateInfo) } : {}),
...(raw.remoteControl === true ? { remoteControl: true } : {}),
...(raw.trusted && typeof raw.trusted === 'object' ? { trusted: trustedOf(raw.trusted) } : {}),
...(raw.taskSessions && typeof raw.taskSessions === 'object' ? { taskSessions: raw.taskSessions } : {}),
@@ -3393,6 +3472,7 @@ export default class NanomuseCloud extends Service {
if (req.method === 'POST' && route === '/providers/save') {
const body = await json(req)
const input: Parameters[0] = { id: String(body.id ?? '') }
+ if (typeof body.row === 'string' && body.row) input.row = body.row
if (typeof body.apiKey === 'string') input.apiKey = body.apiKey
if (typeof body.baseURL === 'string') input.baseURL = body.baseURL
if (typeof body.label === 'string') input.label = body.label
@@ -3401,7 +3481,11 @@ export default class NanomuseCloud extends Service {
if (Array.isArray(body.models) || typeof body.models === 'string') input.models = typedModelIds(body.models)
const row = await this.serialize(() => this.saveProvider(input))
// the "Use it for" card: the slots this row could take and the model each would get
- return send(res, 200, { ...row, offer: this.slotOffer(row.provider) })
+ return send(res, 200, { ...row, offer: this.slotOffer(row.id) })
+ }
+ if (req.method === 'POST' && route === '/providers/rename') {
+ const body = await json(req)
+ return send(res, 200, await this.serialize(() => this.renameProvider(String(body.id ?? ''), String(body.label ?? ''), typeof body.lang === 'string' ? body.lang : '')))
}
if (req.method === 'POST' && route === '/providers/adopt') {
const body = await json(req)
@@ -3453,6 +3537,28 @@ export default class NanomuseCloud extends Service {
this.motion.clearProgress()
return send(res, 204)
}
+ // A clip of the person's own as the face (#324): the picked file comes as the body, is looked at and
+ // kept as a pending copy for the preview; "Use" makes it the clip, "Cancel" drops it, "Default face" removes it.
+ if (req.method === 'POST' && route === '/own-clip/stage') {
+ const staged = await this.ownClip.stage(req)
+ if (staged.ok) return send(res, 200, staged)
+ const seconds = Math.floor((staged.probe.durationMs ?? 0) / 1000)
+ return send(res, staged.why === 'too_large' ? 413 : 415, { error: { code: `clip_${staged.why}`, message: `The clip was refused: ${staged.why}` }, seconds, bytes: staged.probe.bytes })
+ }
+ if (req.method === 'POST' && route === '/own-clip/use') {
+ const body = await json(req, 2 * 1024 * 1024)
+ const poster = typeof body.poster === 'string' && body.poster ? Buffer.from(body.poster, 'base64') : undefined
+ if (!(await this.ownClip.use(poster))) return send(res, 409, { error: { code: 'no_pending', message: 'No clip waiting to be used' } })
+ return send(res, 200, { ownClip: this.ownClip.view() })
+ }
+ if (req.method === 'POST' && route === '/own-clip/discard') {
+ await this.ownClip.discard()
+ return send(res, 204)
+ }
+ if (req.method === 'POST' && route === '/own-clip/clear') {
+ await this.ownClip.clear()
+ return send(res, 204)
+ }
// Settings → Computer use: the runtime and the "try it" checks (desk-b)
if (req.method === 'GET' && route === '/hands/runtime') return send(res, 200, await this.runtime())
if (req.method === 'POST' && route === '/hands/check') {
diff --git a/harness/dsh-nanomuse/src/index.ts b/harness/dsh-nanomuse/src/index.ts
index 7e8433e3..6301d6f8 100644
--- a/harness/dsh-nanomuse/src/index.ts
+++ b/harness/dsh-nanomuse/src/index.ts
@@ -46,8 +46,9 @@ export function apply(ctx: Context): void {
if (getDefaultAutoSelectFamilyAttemptTimeout() < CONNECT_ATTEMPT_MS) setDefaultAutoSelectFamilyAttemptTimeout(CONNECT_ATTEMPT_MS)
const handler = (req: IncomingMessage, res: ServerResponse) => serveAsset(req, res, (id, mood, ext) => ctx.get('nanomuseCloud')?.profile.stillPath(id, mood, ext))
mountGuarded(ctx, ASSETS_PREFIX, handler, 'nanomuse: assets')
- // The clips of a drawn face, made on this computer (`motion.ts`): `/nanomuse/avatar/motion/.mp4?v=`.
- mountGuarded(ctx, MOTION_PREFIX, (req, res) => serveClip(req, res, (mood) => ctx.get('nanomuseCloud')?.motion.clipPath(mood)), 'nanomuse: avatar clips')
+ // The clips of a drawn face, made on this computer (`motion.ts`): `/nanomuse/avatar/motion/.mp4?v=`;
+ // and the person's own clip (`own-clip.ts`): `/nanomuse/avatar/own-clip.mp4?v=`, its first frame, the preview copy.
+ mountGuarded(ctx, MOTION_PREFIX, (req, res) => serveClip(req, res, (mood) => ctx.get('nanomuseCloud')?.motion.clipPath(mood), (name) => ctx.get('nanomuseCloud')?.ownClip.path(name)), 'nanomuse: avatar clips')
}
/** One file from `assets/` by its base name, or a still of the account's face; anything else is 404. */
diff --git a/harness/dsh-nanomuse/src/motion.ts b/harness/dsh-nanomuse/src/motion.ts
index cad285c4..b3d34d80 100644
--- a/harness/dsh-nanomuse/src/motion.ts
+++ b/harness/dsh-nanomuse/src/motion.ts
@@ -277,17 +277,23 @@ export class AvatarMotion {
}
const CLIP = /^\/motion\/([a-z]+)\.mp4$/
+/** The person's own clip (#324), its first frame, and the copy waiting in the preview (`own-clip.ts`). */
+const OWN = /^\/(own-clip\.mp4|own-clip\.png|own-clip\.pending\.mp4)$/
-/** `GET /nanomuse/avatar/motion/.mp4`: the clip of the worn face, or 404. */
-export async function serveClip(req: IncomingMessage, res: ServerResponse, clipPath: (mood: string) => string | undefined): Promise {
+/**
+ * `GET /nanomuse/avatar/motion/.mp4`: the clip of the worn face, or 404. With `ownPath`,
+ * `GET /nanomuse/avatar/own-clip.mp4` (and `.png`, `.pending.mp4`) the same way for the person's own clip.
+ */
+export async function serveClip(req: IncomingMessage, res: ServerResponse, clipPath: (mood: string) => string | undefined, ownPath?: (name: string) => string | undefined): Promise {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
const rel = url.pathname.slice(MOTION_PREFIX.length)
const m = CLIP.exec(rel)
- if ((req.method !== 'GET' && req.method !== 'HEAD') || !m) {
+ const own = ownPath ? OWN.exec(rel) : null
+ if ((req.method !== 'GET' && req.method !== 'HEAD') || (!m && !own)) {
res.writeHead(404, { 'cache-control': 'no-store' }).end()
return
}
- const path = clipPath(m[1] ?? '')
+ const path = own ? ownPath?.(own[1] ?? '') : clipPath(m?.[1] ?? '')
if (!path) {
res.writeHead(404, { 'cache-control': 'no-store' }).end()
return
@@ -300,7 +306,8 @@ export async function serveClip(req: IncomingMessage, res: ServerResponse, clipP
return
}
// the URL carries the mtime, so a clip may be cached for good
- res.writeHead(200, { 'content-type': 'video/mp4', 'content-length': size, 'cache-control': url.searchParams.has('v') ? 'public, max-age=31536000, immutable' : 'no-cache' })
+ const type = own && own[1]?.endsWith('.png') ? 'image/png' : 'video/mp4'
+ res.writeHead(200, { 'content-type': type, 'content-length': size, 'cache-control': url.searchParams.has('v') ? 'public, max-age=31536000, immutable' : 'no-cache' })
if (req.method === 'HEAD') {
res.end()
return
diff --git a/harness/dsh-nanomuse/src/own-clip.ts b/harness/dsh-nanomuse/src/own-clip.ts
new file mode 100644
index 00000000..25e3a334
--- /dev/null
+++ b/harness/dsh-nanomuse/src/own-clip.ts
@@ -0,0 +1,302 @@
+/**
+ * A clip of the person's own as the face (#324). One short video from this computer plays
+ * muted in a loop wherever the face moves: for the mood at rest, and for every other mood
+ * too, since there is only the one clip. The stills keep their place where a still is
+ * shown: a drawn face's mood pictures sit under the clip until its first frame is on
+ * screen; with no drawn face the clip's own first frame (a PNG the browser takes at the
+ * preview) is the still.
+ *
+ * The clip lives next to the face's clips as `$DSH_HOME/nanomuse/avatar/own-clip.mp4`,
+ * its first frame as `own-clip.png`, and nowhere else: it is this computer's alone, never
+ * part of the profile the relay holds and never synced. It survives a new drawn face (the
+ * face's clips under `avatar/motion/` are the ones that go) and only "Default face"
+ * removes it. The browser plays it from `/nanomuse/avatar/own-clip.mp4?v=`.
+ *
+ * The rule is the phones' (`avatar/OwnClip.kt`, `NanoMuseOwnClip.swift`): MP4 or MOV with
+ * H.264 video, at most 15 seconds and 30 MB; the format first (an unreadable file is a
+ * format problem whatever its size), then the length, then the size. Node has no decoder,
+ * so the file is read as ISO base media boxes: `ftyp`, then `moov/mvhd` for the length and
+ * the video track's `stsd` sample entry for the codec.
+ */
+import { createWriteStream } from 'node:fs'
+import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
+import type { IncomingMessage } from 'node:http'
+import { join } from 'node:path'
+import { pipeline } from 'node:stream/promises'
+
+/** The longest clip taken, in whole seconds; a clip that rounds down to this is fine. */
+export const OWN_CLIP_MAX_SECONDS = 15
+/** The largest file taken. */
+export const OWN_CLIP_MAX_BYTES = 30 * 1024 * 1024
+/** The sample entry codes of H.264 video in an MP4 or MOV. */
+const H264 = new Set(['avc1', 'avc3'])
+/** Boxes whose children are boxes. */
+const CONTAINERS = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'mvex'])
+/** What a file may start with and still be an MP4 or a MOV (an old QuickTime file has no `ftyp`). */
+const FIRST_BOXES = new Set(['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'])
+
+export type OwnClipRefusal = 'format' | 'too_long' | 'too_large'
+
+/** What a look at a file found; an absent field is one that could not be read. */
+export interface OwnClipProbe {
+ /** True when the file reads as ISO base media (MP4 or MOV). */
+ container: boolean
+ codec?: string
+ durationMs?: number
+ bytes: number
+}
+
+/** What the browser sees (`live.ownClip`): the clip's size, the cache key, and whether a first frame was kept. */
+export interface OwnClipView {
+ size: number
+ /** Modification time in ms, the browser's cache key. */
+ v: number
+ poster: boolean
+}
+
+export type OwnClipStaged =
+ | { ok: true; pending: { v: number; size: number; durationMs: number } }
+ | { ok: false; why: OwnClipRefusal; probe: OwnClipProbe }
+
+/** The rule: nothing means the clip is taken. */
+export function checkOwnClip(p: OwnClipProbe): OwnClipRefusal | undefined {
+ if (!p.container || !p.codec || p.durationMs === undefined) return 'format'
+ if (!H264.has(p.codec)) return 'format'
+ if (p.durationMs >= (OWN_CLIP_MAX_SECONDS + 1) * 1000) return 'too_long'
+ if (p.bytes > OWN_CLIP_MAX_BYTES) return 'too_large'
+ return undefined
+}
+
+interface Box {
+ type: string
+ /** Where the box's own content starts. */
+ start: number
+ end: number
+}
+
+function* boxes(buf: Buffer, from: number, to: number): Generator {
+ let at = from
+ while (at + 8 <= to) {
+ let size = buf.readUInt32BE(at)
+ const type = buf.toString('latin1', at + 4, at + 8)
+ let head = 8
+ if (size === 1) {
+ if (at + 16 > to) return
+ const large = buf.readBigUInt64BE(at + 8)
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return
+ size = Number(large)
+ head = 16
+ } else if (size === 0) {
+ size = to - at
+ }
+ if (size < head || at + size > to) return
+ yield { type, start: at + head, end: at + size }
+ at += size
+ }
+}
+
+function find(buf: Buffer, from: number, to: number, type: string): Box | undefined {
+ for (const box of boxes(buf, from, to)) if (box.type === type) return box
+ return undefined
+}
+
+/** `mvhd` (or `mehd`): the length in ms, or undefined when it is not there or zero. */
+function lengthOf(buf: Buffer, moov: Box): number | undefined {
+ const mvhd = find(buf, moov.start, moov.end, 'mvhd')
+ if (mvhd && mvhd.end - mvhd.start >= 20) {
+ const version = buf[mvhd.start]
+ let timescale: number
+ let duration: number
+ if (version === 1) {
+ if (mvhd.end - mvhd.start < 32) return undefined
+ timescale = buf.readUInt32BE(mvhd.start + 20)
+ const d = buf.readBigUInt64BE(mvhd.start + 24)
+ // all ones is "unknown" in a version 1 header, as 0xffffffff is in a version 0 one
+ duration = d === 0xffffffffffffffffn ? 0 : Number(d)
+ } else {
+ timescale = buf.readUInt32BE(mvhd.start + 12)
+ duration = buf.readUInt32BE(mvhd.start + 16)
+ }
+ if (timescale > 0 && duration > 0 && duration !== 0xffffffff) return Math.round((duration * 1000) / timescale)
+ // a fragmented file keeps the length in mvex/mehd
+ const mvex = find(buf, moov.start, moov.end, 'mvex')
+ const mehd = mvex ? find(buf, mvex.start, mvex.end, 'mehd') : undefined
+ if (mehd && timescale > 0) {
+ const v = buf[mehd.start]
+ const d = v === 1 ? (mehd.end - mehd.start >= 12 ? Number(buf.readBigUInt64BE(mehd.start + 4)) : 0) : mehd.end - mehd.start >= 8 ? buf.readUInt32BE(mehd.start + 4) : 0
+ if (d > 0) return Math.round((d * 1000) / timescale)
+ }
+ }
+ return undefined
+}
+
+/** The first video track's sample entry code (`avc1`, `hvc1`, `vp09`, …). */
+function codecOf(buf: Buffer, moov: Box): string | undefined {
+ for (const trak of boxes(buf, moov.start, moov.end)) {
+ if (trak.type !== 'trak') continue
+ const mdia = find(buf, trak.start, trak.end, 'mdia')
+ if (!mdia) continue
+ const hdlr = find(buf, mdia.start, mdia.end, 'hdlr')
+ if (!hdlr || hdlr.end - hdlr.start < 12 || buf.toString('latin1', hdlr.start + 8, hdlr.start + 12) !== 'vide') continue
+ const minf = find(buf, mdia.start, mdia.end, 'minf')
+ const stbl = minf ? find(buf, minf.start, minf.end, 'stbl') : undefined
+ const stsd = stbl ? find(buf, stbl.start, stbl.end, 'stsd') : undefined
+ if (!stsd || stsd.end - stsd.start < 16) return undefined
+ // version and flags, entry count, then the first entry: its size and its code
+ return buf.toString('latin1', stsd.start + 12, stsd.start + 16).toLowerCase()
+ }
+ return undefined
+}
+
+/** A look at the bytes of a file: whether it is an MP4 or MOV, its video codec and its length. */
+export function probeOwnClip(buf: Buffer): OwnClipProbe {
+ const probe: OwnClipProbe = { container: false, bytes: buf.length }
+ if (buf.length < 8 || !FIRST_BOXES.has(buf.toString('latin1', 4, 8))) return probe
+ probe.container = true
+ const moov = find(buf, 0, buf.length, 'moov')
+ if (!moov) return probe
+ const durationMs = lengthOf(buf, moov)
+ if (durationMs !== undefined) probe.durationMs = durationMs
+ const codec = codecOf(buf, moov)
+ if (codec) probe.codec = codec
+ return probe
+}
+
+export interface OwnClipDeps {
+ /** `$DSH_HOME/nanomuse/avatar`. */
+ dir: string
+ /** Called whenever the clip changes. */
+ onChange(): void
+}
+
+export class OwnClip {
+ private current: OwnClipView | null = null
+
+ constructor(private readonly deps: OwnClipDeps) {}
+
+ get clipFile(): string {
+ return join(this.deps.dir, 'own-clip.mp4')
+ }
+
+ get posterFile(): string {
+ return join(this.deps.dir, 'own-clip.png')
+ }
+
+ get pendingFile(): string {
+ return join(this.deps.dir, 'own-clip.pending.mp4')
+ }
+
+ /** A copy that was never used or cancelled (the app quit in between) is of no use now. */
+ async init(): Promise {
+ await rm(this.pendingFile, { force: true }).catch(() => undefined)
+ await this.rescan()
+ }
+
+ view(): OwnClipView | null {
+ return this.current
+ }
+
+ /** The file behind `/nanomuse/avatar/`: the clip, its first frame, or the pending copy; else nothing. */
+ path(name: string): string | undefined {
+ if (name === 'own-clip.mp4') return this.current ? this.clipFile : undefined
+ if (name === 'own-clip.png') return this.current?.poster ? this.posterFile : undefined
+ if (name === 'own-clip.pending.mp4') return this.pendingFile
+ return undefined
+ }
+
+ async rescan(): Promise {
+ let next: OwnClipView | null = null
+ try {
+ const s = await stat(this.clipFile)
+ if (s.size > 0) {
+ let poster = false
+ try {
+ poster = (await stat(this.posterFile)).size > 0
+ } catch {
+ // no first frame kept
+ }
+ next = { size: s.size, v: Math.floor(s.mtimeMs), poster }
+ }
+ } catch {
+ // no clip
+ }
+ const changed = JSON.stringify(next) !== JSON.stringify(this.current)
+ this.current = next
+ if (changed) this.deps.onChange()
+ }
+
+ /**
+ * A picked file arrives as the request body: it is written to the pending copy while
+ * it stays under the size cap, then looked at; a clip the rule refuses is removed at
+ * once and the refusal returned, one it takes waits for "Use" or "Cancel".
+ */
+ async stage(req: IncomingMessage): Promise {
+ const declared = Number(req.headers['content-length'] ?? 0) || 0
+ if (declared > OWN_CLIP_MAX_BYTES) {
+ req.resume()
+ return { ok: false, why: 'too_large', probe: { container: true, bytes: declared } }
+ }
+ await mkdir(this.deps.dir, { recursive: true })
+ const file = this.pendingFile
+ await rm(file, { force: true }).catch(() => undefined)
+ let size = 0
+ // Past the cap the rest is read and dropped (so the refusal still reaches the browser),
+ // up to twice the cap; a body longer than that is cut off.
+ const cap = async function* (source: AsyncIterable) {
+ for await (const chunk of source) {
+ size += chunk.length
+ if (size > OWN_CLIP_MAX_BYTES) {
+ if (size > 2 * OWN_CLIP_MAX_BYTES) return
+ continue
+ }
+ yield chunk
+ }
+ }
+ try {
+ await pipeline(req, cap, createWriteStream(file))
+ } catch (error: unknown) {
+ await rm(file, { force: true }).catch(() => undefined)
+ throw error
+ }
+ if (size > OWN_CLIP_MAX_BYTES) {
+ await rm(file, { force: true }).catch(() => undefined)
+ return { ok: false, why: 'too_large', probe: { container: true, bytes: Math.max(size, declared) } }
+ }
+ const probe = probeOwnClip(await readFile(file))
+ const why = checkOwnClip(probe)
+ if (why) {
+ await rm(file, { force: true }).catch(() => undefined)
+ return { ok: false, why, probe }
+ }
+ const s = await stat(file)
+ return { ok: true, pending: { v: Math.floor(s.mtimeMs), size: s.size, durationMs: probe.durationMs ?? 0 } }
+ }
+
+ /** "Use": the pending copy becomes the clip; `poster`, when the browser took one, its first frame. */
+ async use(poster?: Buffer): Promise {
+ try {
+ await stat(this.pendingFile)
+ } catch {
+ return false
+ }
+ await rm(this.clipFile, { force: true }).catch(() => undefined)
+ await rename(this.pendingFile, this.clipFile)
+ if (poster && poster.length) await writeFile(this.posterFile, poster)
+ else await rm(this.posterFile, { force: true }).catch(() => undefined)
+ await this.rescan()
+ return true
+ }
+
+ /** "Cancel" in the preview: the copy goes. */
+ async discard(): Promise {
+ await rm(this.pendingFile, { force: true }).catch(() => undefined)
+ }
+
+ /** "Default face": back to the drawn face, or the dragon. */
+ async clear(): Promise {
+ await rm(this.clipFile, { force: true }).catch(() => undefined)
+ await rm(this.posterFile, { force: true }).catch(() => undefined)
+ await rm(this.pendingFile, { force: true }).catch(() => undefined)
+ await this.rescan()
+ }
+}
diff --git a/harness/dsh-nanomuse/src/providers.ts b/harness/dsh-nanomuse/src/providers.ts
index 0af61de6..0e411c5c 100644
--- a/harness/dsh-nanomuse/src/providers.ts
+++ b/harness/dsh-nanomuse/src/providers.ts
@@ -174,6 +174,12 @@ export async function loadCatalogue(path?: string | URL): Promise [m.id, m.kind, m.vision]), [['glm-4.7', 'chat', true], ['kimi-k2.5', 'chat', true]])
assert.deepEqual(out.settings[LLM_ROW].providers.custom.models.map((m) => m.id), ['glm-4.7', 'kimi-k2.5'])
assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM'), 'sk-secret')
- // typed ids come first, the endpoint's after them
+ // typed ids come first, the endpoint's after them (`row`: the same endpoint moved, not a second one)
const listing = await modelsServer(['listed-a', 'glm-4.7'])
try {
- const both = await out.api('POST', '/providers/save', { id: 'custom', apiKey: 'sk-secret', baseURL: listing.url, label: 'Gateway', capabilities: ['chat'], models: ['glm-4.7'] })
+ const both = await out.api('POST', '/providers/save', { id: 'custom', row: 'custom', apiKey: 'sk-secret', baseURL: listing.url, label: 'Gateway', capabilities: ['chat'], models: ['glm-4.7'] })
+ assert.equal(both.body.id, 'custom')
assert.deepEqual(both.body.models.map((m) => m.id), ['glm-4.7', 'listed-a'])
} finally {
await listing.close()
@@ -422,6 +423,110 @@ test('an endpoint that lists no models: `no_models` with the key not stored, or
}
})
+test('several hand-made endpoints at once (#294): each save without `row` adds a row with its own key; `row` changes one and keeps its key; rename and remove touch that row only', async () => {
+ const out = await cloud()
+ const first = await modelsServer(['gw-chat', 'gw-vl'])
+ const second = await modelsServer(['local-chat'])
+ try {
+ // two endpoints: `custom`, then `custom-2`, each with its own credential; unnamed, each reads as its host
+ const one = await out.api('POST', '/providers/save', { id: 'custom', apiKey: 'sk-one', baseURL: first.url, capabilities: ['chat', 'vision'] })
+ assert.equal(one.status, 200)
+ assert.equal(one.body.id, 'custom')
+ assert.equal(one.body.keyRef, 'NANOMUSE_KEY_CUSTOM')
+ assert.equal(one.body.label, new URL(first.url).host)
+ const two = await out.api('POST', '/providers/save', { id: 'custom', apiKey: 'sk-two', baseURL: second.url, label: 'Local server', capabilities: ['chat'] })
+ assert.equal(two.status, 200)
+ assert.equal(two.body.id, 'custom-2')
+ assert.equal(two.body.provider, 'custom')
+ assert.equal(two.body.keyRef, 'NANOMUSE_KEY_CUSTOM_2')
+ assert.equal(two.body.label, 'Local server')
+ assert.deepEqual(two.body.offer, { chat: 'local-chat' }, 'the "Use it for" card offers the new row, not the first')
+ assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM'), 'sk-one')
+ assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM_2'), 'sk-two')
+ const state = await out.state()
+ assert.deepEqual(Object.keys(state.providers).sort(), ['custom', 'custom-2'])
+ assert.equal(state.providers['custom-2'].id, 'custom-2')
+ assert.equal(out.settings[LLM_ROW].providers['custom-2'].apiKeyEnv, 'NANOMUSE_KEY_CUSTOM_2')
+ assert.equal(out.settings[LLM_ROW].providers['custom-2'].displayName, 'Local server')
+ // both stand in GET /providers and in the pickers, told apart by name
+ const view = (await out.api('GET', '/providers?lang=en')).body
+ assert.deepEqual(view.configured.map((p) => [p.id, p.provider, p.label]).sort(), [['custom', 'custom', new URL(first.url).host], ['custom-2', 'custom', 'Local server']])
+ assert.deepEqual(out.svc.chatOptions().map((o) => [o.provider, o.providerLabel, o.id]), [['custom', new URL(first.url).host, 'gw-chat'], ['custom', new URL(first.url).host, 'gw-vl'], ['custom-2', 'Local server', 'local-chat']])
+ assert.deepEqual(out.svc.handsOptions().map((o) => [o.provider, o.id]), [['custom', 'gw-chat'], ['custom', 'gw-vl']])
+ // new chats answer through the first endpoint; the second changed nothing there
+ assert.deepEqual(out.selection.current, { provider: 'custom', model: 'gw-chat' })
+ // a change of key on the first row: the second's key stays; an empty key keeps the first's own
+ const rekeyed = await out.api('POST', '/providers/save', { id: 'custom', row: 'custom', apiKey: 'sk-one-b' })
+ assert.equal(rekeyed.status, 200)
+ assert.equal(rekeyed.body.id, 'custom')
+ assert.equal(rekeyed.body.baseURL, first.url, 'the address stands when none is typed')
+ assert.deepEqual(rekeyed.body.capabilities, ['chat', 'vision'], 'the capabilities stand when none are ticked again')
+ assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM'), 'sk-one-b')
+ assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM_2'), 'sk-two')
+ const kept = await out.api('POST', '/providers/save', { id: 'custom', row: 'custom', apiKey: '', label: 'Gateway' })
+ assert.equal(kept.status, 200)
+ assert.equal(kept.body.label, 'Gateway')
+ assert.equal(kept.body.keyRef, 'NANOMUSE_KEY_CUSTOM')
+ assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM'), 'sk-one-b')
+ assert.equal(Object.keys((await out.state()).providers).length, 2, 'a change never doubles the row')
+ // `row` must name a row of that entry
+ assert.equal((await out.api('POST', '/providers/save', { id: 'custom', row: 'custom-9', apiKey: 'sk-x', baseURL: first.url })).status, 404)
+ // rename: the pickers and the model adapter read the new name; empty goes back to the host
+ const renamed = await out.api('POST', '/providers/rename', { id: 'custom-2', label: 'vLLM box' })
+ assert.equal(renamed.status, 200)
+ assert.equal(renamed.body.label, 'vLLM box')
+ assert.equal(out.settings[LLM_ROW].providers['custom-2'].displayName, 'vLLM box')
+ assert.equal((await out.state()).providers['custom-2'].label, 'vLLM box')
+ assert.equal((await out.api('POST', '/providers/rename', { id: 'custom-2', label: ' ' })).body.label, new URL(second.url).host)
+ assert.equal((await out.api('POST', '/providers/rename', { id: 'nobody', label: 'x' })).status, 404)
+ // remove the first: the second and its key stay; the next endpoint takes the lowest free id again
+ assert.equal((await out.api('POST', '/providers/remove', { id: 'custom' })).status, 200)
+ assert.deepEqual(Object.keys((await out.state()).providers), ['custom-2'])
+ assert.equal(out.credentials.has('NANOMUSE_KEY_CUSTOM'), false)
+ assert.equal(out.credentials.get('NANOMUSE_KEY_CUSTOM_2'), 'sk-two')
+ assert.equal(out.settings[LLM_ROW].providers.custom, undefined)
+ assert.equal(out.settings[LLM_ROW].providers['custom-2'].apiKeyEnv, 'NANOMUSE_KEY_CUSTOM_2')
+ const third = await out.api('POST', '/providers/save', { id: 'custom', apiKey: 'sk-three', baseURL: first.url, capabilities: ['chat'] })
+ assert.equal(third.body.id, 'custom')
+ assert.deepEqual(Object.keys((await out.state()).providers).sort(), ['custom', 'custom-2'])
+ // a vendor's row is still one per vendor: saving its key again replaces it
+ const vendor = await modelsServer(['deepseek-v4.1-flash'])
+ try {
+ await out.api('POST', '/providers/save', { id: 'deepseek', apiKey: 'sk-d1', baseURL: vendor.url })
+ await out.api('POST', '/providers/save', { id: 'deepseek', apiKey: 'sk-d2', baseURL: vendor.url })
+ assert.deepEqual(Object.keys((await out.state()).providers).sort(), ['custom', 'custom-2', 'deepseek'])
+ assert.equal(out.credentials.get('NANOMUSE_KEY_DEEPSEEK'), 'sk-d2')
+ } finally {
+ await vendor.close()
+ }
+ } finally {
+ await first.close()
+ await second.close()
+ await out.done()
+ }
+})
+
+test('a row written before the endpoint list (#294) carries no id of its own: it is read back under its key, and the slot choices still find it', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'nm-cloud-'))
+ const statePath = join(home, 'cloud.json')
+ const fake = fakeContext()
+ const row = { provider: 'custom', label: 'Gateway', protocol: 'openai', baseURL: 'http://127.0.0.1:9/v1', keyRef: 'NANOMUSE_KEY_CUSTOM', capabilities: ['chat', 'vision'], models: [{ id: 'gw-vl', name: 'gw-vl', vision: true, kind: 'chat' }], at: 1 }
+ await writeFile(statePath, JSON.stringify({ providers: { custom: row }, handsProvider: 'custom', handsModel: 'gw-vl', update: { checkedAt: Date.now() } }))
+ await fake.ctx.credentials.set('NANOMUSE_KEY_CUSTOM', 'sk-old')
+ const svc = new NanomuseCloud(fake.ctx, { baseURL: 'https://relay.invalid', deviceName: 'test-desktop', statePath })
+ svc.hub.start = () => undefined
+ svc.refresh = async () => svc.status()
+ try {
+ await svc[Service.init]()
+ const view = await svc.providersView('en')
+ assert.deepEqual(view.configured.map((p) => [p.id, p.provider, p.label]), [['custom', 'custom', 'Gateway']])
+ assert.deepEqual(svc.handsChoice(), { provider: 'custom', model: 'gw-vl' })
+ assert.deepEqual(svc.handsOptions().map((o) => [o.provider, o.id]), [['custom', 'gw-vl']])
+ } finally {
+ await rm(home, { recursive: true, force: true })
+ }
+})
+
test('OpenCode Go is written as the adapter’s catalog route (no api, no base URL), with the models the adapter lists, and stays that route after a restart', async () => {
const out = await cloud()
// the adapter's catalog knows two Go models; a typed id it does not list is left out
diff --git a/harness/dsh-nanomuse/tests/own-clip.test.mjs b/harness/dsh-nanomuse/tests/own-clip.test.mjs
new file mode 100644
index 00000000..dc71630c
--- /dev/null
+++ b/harness/dsh-nanomuse/tests/own-clip.test.mjs
@@ -0,0 +1,383 @@
+// A clip of the person's own as the face (#324): the rule (MP4 or MOV with H.264, at most
+// 15 seconds and 30 MB, format before length before size), the look at the bytes, the
+// pending copy that the preview plays and "Use" or "Cancel" settle, and the route that
+// serves the clip, its first frame and the pending copy.
+import assert from 'node:assert/strict'
+import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { createServer, request } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { test } from 'node:test'
+import { MOTION_PREFIX, serveClip } from '../lib/motion.js'
+import { OWN_CLIP_MAX_BYTES, OWN_CLIP_MAX_SECONDS, OwnClip, checkOwnClip, probeOwnClip } from '../lib/own-clip.js'
+
+// ---- a small MP4 writer: boxes as the probe reads them ------------------------------------
+
+function box(type, ...parts) {
+ const body = Buffer.concat(parts.map((p) => (typeof p === 'string' ? Buffer.from(p, 'latin1') : p)))
+ const head = Buffer.alloc(8)
+ head.writeUInt32BE(body.length + 8, 0)
+ head.write(type, 4, 'latin1')
+ return Buffer.concat([head, body])
+}
+
+/** The same box with the 64-bit size field (`size` 1, then the length after the type). */
+function largeBox(type, ...parts) {
+ const body = Buffer.concat(parts.map((p) => (typeof p === 'string' ? Buffer.from(p, 'latin1') : p)))
+ const head = Buffer.alloc(16)
+ head.writeUInt32BE(1, 0)
+ head.write(type, 4, 'latin1')
+ head.writeBigUInt64BE(BigInt(body.length + 16), 8)
+ return Buffer.concat([head, body])
+}
+
+/** The same box with size 0: it runs to the end of the file, so it must be the last one. */
+function lastBox(type, ...parts) {
+ const out = box(type, ...parts)
+ out.writeUInt32BE(0, 0)
+ return out
+}
+
+function u32(n) {
+ const b = Buffer.alloc(4)
+ b.writeUInt32BE(n, 0)
+ return b
+}
+
+function u64(n) {
+ const b = Buffer.alloc(8)
+ b.writeBigUInt64BE(BigInt(n), 0)
+ return b
+}
+
+/** `mvhd` version 0: timescale and duration, the rest zero. */
+function mvhd(timescale, duration, version = 0) {
+ if (version === 1) return box('mvhd', Buffer.from([1, 0, 0, 0]), u64(0), u64(0), u32(timescale), u64(duration), Buffer.alloc(80))
+ return box('mvhd', u32(0), u32(0), u32(0), u32(timescale), u32(duration), Buffer.alloc(80))
+}
+
+/** `mvex` with a `mehd`, the length of a fragmented file; without `mehd` when `duration` is undefined. */
+function mvex(duration, version = 0) {
+ if (duration === undefined) return box('mvex', box('trex', Buffer.alloc(24)))
+ const mehd = version === 1 ? box('mehd', Buffer.from([1, 0, 0, 0]), u64(duration)) : box('mehd', u32(0), u32(duration))
+ return box('mvex', mehd, box('trex', Buffer.alloc(24)))
+}
+
+function trak(handler, codec) {
+ const stsd = box('stsd', u32(0), u32(1), box(codec, Buffer.alloc(78)))
+ const hdlr = box('hdlr', u32(0), u32(0), handler, Buffer.alloc(13))
+ return box('trak', box('mdia', hdlr, box('minf', box('stbl', stsd))))
+}
+
+/** An MP4 of `ms` milliseconds whose first video track is `codec`, with `extra` bytes of media to pad it. */
+function mp4({ ms = 4000, codec = 'avc1', brand = 'isom', extra = 64, audioFirst = false, version = 0, faststart = true } = {}) {
+ const ftyp = box('ftyp', brand, u32(0), 'isom', 'mp42')
+ const tracks = audioFirst ? [trak('soun', 'mp4a'), trak('vide', codec)] : [trak('vide', codec)]
+ const moov = box('moov', mvhd(1000, ms, version), ...tracks)
+ const mdat = box('mdat', Buffer.alloc(extra, 7))
+ return faststart ? Buffer.concat([ftyp, moov, mdat]) : Buffer.concat([ftyp, mdat, moov])
+}
+
+async function withDir(fn) {
+ const dir = await mkdtemp(join(tmpdir(), 'nm-own-clip-'))
+ try {
+ await fn(dir)
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+}
+
+function store(dir) {
+ const changes = []
+ const own = new OwnClip({ dir: join(dir, 'avatar'), onChange: () => changes.push(own.view()) })
+ return { own, changes }
+}
+
+/** Sends `body` to `handler` as the request of a POST and returns what the handler returned. */
+async function post(handler, body, headers = {}) {
+ let outcome
+ const server = createServer(async (req, res) => {
+ outcome = await handler(req)
+ res.writeHead(204).end()
+ })
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok))
+ const { port } = server.address()
+ await new Promise((ok, no) => {
+ const req = request({ host: '127.0.0.1', port, method: 'POST', path: '/x', headers }, (res) => {
+ res.resume()
+ res.on('end', ok)
+ })
+ req.on('error', no)
+ if (typeof body === 'function') body(req)
+ else req.end(body)
+ })
+ await new Promise((ok) => server.close(ok))
+ return outcome
+}
+
+// ---- the rule ---------------------------------------------------------------------------
+
+test('the limits are the phones\u2019: 15 seconds and 30 MB', () => {
+ assert.equal(OWN_CLIP_MAX_SECONDS, 15)
+ assert.equal(OWN_CLIP_MAX_BYTES, 30 * 1024 * 1024)
+})
+
+test('a clip that rounds down to 15 s is taken, one of 16 s is too long', () => {
+ const ok = { container: true, codec: 'avc1', durationMs: 15_999, bytes: 1000 }
+ assert.equal(checkOwnClip(ok), undefined)
+ assert.equal(checkOwnClip({ ...ok, durationMs: 16_000 }), 'too_long')
+})
+
+test('exactly 30 MB is taken, one byte more is too large', () => {
+ const ok = { container: true, codec: 'avc1', durationMs: 1000, bytes: OWN_CLIP_MAX_BYTES }
+ assert.equal(checkOwnClip(ok), undefined)
+ assert.equal(checkOwnClip({ ...ok, bytes: OWN_CLIP_MAX_BYTES + 1 }), 'too_large')
+})
+
+test('anything but H.264 in an MP4 or MOV is a format problem, and it comes before length and size', () => {
+ const base = { container: true, codec: 'avc1', durationMs: 1000, bytes: 1000 }
+ assert.equal(checkOwnClip({ ...base, codec: 'hvc1' }), 'format')
+ assert.equal(checkOwnClip({ ...base, codec: 'vp09' }), 'format')
+ assert.equal(checkOwnClip({ ...base, container: false }), 'format')
+ assert.equal(checkOwnClip({ container: true, bytes: 1000 }), 'format')
+ // a WebM that is also too long and too large is refused for its format, not its size
+ assert.equal(checkOwnClip({ container: false, codec: 'vp09', durationMs: 60_000, bytes: OWN_CLIP_MAX_BYTES * 2 }), 'format')
+ // too long and too large: the length is named first
+ assert.equal(checkOwnClip({ ...base, durationMs: 60_000, bytes: OWN_CLIP_MAX_BYTES * 2 }), 'too_long')
+ assert.equal(checkOwnClip({ ...base, codec: 'avc3' }), undefined)
+})
+
+// ---- the look at the bytes --------------------------------------------------------------
+
+test('probe reads the length and the codec of an MP4, faststart or not, and a MOV', () => {
+ assert.deepEqual(probeOwnClip(mp4({ ms: 4321 })), { container: true, bytes: mp4({ ms: 4321 }).length, durationMs: 4321, codec: 'avc1' })
+ const late = probeOwnClip(mp4({ ms: 4321, faststart: false }))
+ assert.equal(late.durationMs, 4321)
+ assert.equal(late.codec, 'avc1')
+ const mov = probeOwnClip(mp4({ ms: 2500, brand: 'qt ' }))
+ assert.equal(mov.container, true)
+ assert.equal(mov.durationMs, 2500)
+ const v1 = probeOwnClip(mp4({ ms: 12_000, version: 1 }))
+ assert.equal(v1.durationMs, 12_000)
+})
+
+test('probe finds the video track behind an audio track, and reports other codecs as they are', () => {
+ assert.equal(probeOwnClip(mp4({ audioFirst: true })).codec, 'avc1')
+ assert.equal(probeOwnClip(mp4({ codec: 'hvc1' })).codec, 'hvc1')
+ assert.equal(probeOwnClip(mp4({ codec: 'vp09' })).codec, 'vp09')
+})
+
+test('probe reads a moov with a 64-bit size, a file that opens with wide and free atoms, and an mdat that runs to the end', () => {
+ const ftyp = box('ftyp', 'isom', u32(0), 'isom', 'mp42')
+ const large = Buffer.concat([ftyp, largeBox('moov', mvhd(1000, 7000), trak('vide', 'avc1')), box('mdat', Buffer.alloc(64, 7))])
+ assert.deepEqual(probeOwnClip(large), { container: true, bytes: large.length, durationMs: 7000, codec: 'avc1' })
+ // an old QuickTime file: no ftyp, a wide and a free atom before the movie
+ const quicktime = Buffer.concat([box('wide'), box('free', Buffer.alloc(16)), box('mdat', Buffer.alloc(64, 7)), box('moov', mvhd(600, 1800), trak('vide', 'avc1'))])
+ assert.deepEqual(probeOwnClip(quicktime), { container: true, bytes: quicktime.length, durationMs: 3000, codec: 'avc1' })
+ // the media as the last box with size 0, which means "to the end of the file"
+ const open = Buffer.concat([ftyp, box('moov', mvhd(1000, 2000), trak('vide', 'avc3')), lastBox('mdat', Buffer.alloc(64, 7))])
+ assert.deepEqual(probeOwnClip(open), { container: true, bytes: open.length, durationMs: 2000, codec: 'avc3' })
+ // a 64-bit size the file cannot hold ends the look without a throw
+ const lying = Buffer.concat([ftyp, largeBox('moov', mvhd(1000, 2000), trak('vide', 'avc1'))])
+ lying.writeBigUInt64BE(0xfffffffffffffn, ftyp.length + 8)
+ assert.equal(probeOwnClip(lying).container, true)
+ assert.equal(probeOwnClip(lying).codec, undefined)
+})
+
+test('probe takes the length of a fragmented file from mvex/mehd, and has none without it', () => {
+ const ftyp = box('ftyp', 'iso5', u32(0), 'iso5', 'dash')
+ const fragmented = (header, ex) => Buffer.concat([ftyp, box('moov', header, trak('vide', 'avc1'), ex), box('moof', Buffer.alloc(16)), box('mdat', Buffer.alloc(64, 7))])
+ // the movie header says 0 (or all ones, "unknown"), the extends header has the length
+ assert.equal(probeOwnClip(fragmented(mvhd(1000, 0), mvex(9500))).durationMs, 9500)
+ assert.equal(probeOwnClip(fragmented(mvhd(1000, 0xffffffff), mvex(9500, 1))).durationMs, 9500)
+ const unknown64 = box('mvhd', Buffer.from([1, 0, 0, 0]), u64(0), u64(0), u32(1000), Buffer.alloc(8, 0xff), Buffer.alloc(80))
+ assert.equal(probeOwnClip(fragmented(unknown64, mvex(12_000))).durationMs, 12_000)
+ // without mehd the length is not in the file, and the rule refuses it as a format problem
+ const open = probeOwnClip(fragmented(mvhd(1000, 0), mvex(undefined)))
+ assert.equal(open.codec, 'avc1')
+ assert.equal(open.durationMs, undefined)
+ assert.equal(checkOwnClip(open), 'format')
+ // a mehd that says 16 s is too long, as a mvhd would be
+ assert.equal(checkOwnClip(probeOwnClip(fragmented(mvhd(1000, 0), mvex(16_000)))), 'too_long')
+})
+
+test('probe calls a WebM, a text file and an empty file no container at all', () => {
+ const webm = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(64, 1)])
+ assert.equal(probeOwnClip(webm).container, false)
+ assert.equal(probeOwnClip(Buffer.from('hello there, not a video at all')).container, false)
+ assert.equal(probeOwnClip(Buffer.alloc(0)).container, false)
+ // a box that claims more bytes than there are does not break the look
+ const cut = mp4().subarray(0, 40)
+ assert.equal(probeOwnClip(cut).container, true)
+ assert.equal(probeOwnClip(cut).codec, undefined)
+ assert.equal(checkOwnClip(probeOwnClip(cut)), 'format')
+})
+
+// ---- the pending copy, "Use", "Cancel", "Default face" ------------------------------------
+
+test('stage keeps a clip the rule takes as the pending copy; use makes it the clip and keeps the first frame', async () => {
+ await withDir(async (dir) => {
+ const { own, changes } = store(dir)
+ await own.init()
+ assert.equal(own.view(), null)
+ const bytes = mp4({ ms: 15_400 })
+ const staged = await post((req) => own.stage(req), bytes, { 'content-length': String(bytes.length) })
+ assert.equal(staged.ok, true)
+ assert.equal(staged.pending.size, bytes.length)
+ assert.equal(staged.pending.durationMs, 15_400)
+ assert.ok(staged.pending.v > 0)
+ assert.equal(own.view(), null, 'not the clip yet')
+ assert.equal(own.path('own-clip.pending.mp4'), join(dir, 'avatar', 'own-clip.pending.mp4'))
+ assert.equal(own.path('own-clip.mp4'), undefined)
+ const poster = Buffer.from([0x89, 0x50, 0x4e, 0x47])
+ assert.equal(await own.use(poster), true)
+ const view = own.view()
+ assert.equal(view.size, bytes.length)
+ assert.equal(view.poster, true)
+ assert.ok(view.v > 0)
+ assert.deepEqual(await readFile(join(dir, 'avatar', 'own-clip.mp4')), bytes)
+ assert.deepEqual(await readFile(join(dir, 'avatar', 'own-clip.png')), poster)
+ await assert.rejects(stat(join(dir, 'avatar', 'own-clip.pending.mp4')))
+ assert.equal(own.path('own-clip.mp4'), join(dir, 'avatar', 'own-clip.mp4'))
+ assert.equal(own.path('own-clip.png'), join(dir, 'avatar', 'own-clip.png'))
+ assert.equal(own.path('motion/idle.mp4'), undefined)
+ assert.ok(changes.length >= 1)
+ // use with nothing pending changes nothing
+ assert.equal(await own.use(), false)
+ assert.equal(own.view().size, bytes.length)
+ })
+})
+
+test('discard drops the pending copy and leaves the clip; clear removes everything', async () => {
+ await withDir(async (dir) => {
+ const { own } = store(dir)
+ await own.init()
+ await post((req) => own.stage(req), mp4({ ms: 3000 }))
+ await own.use()
+ assert.equal(own.view().poster, false, 'no first frame handed over')
+ const before = own.view()
+ await post((req) => own.stage(req), mp4({ ms: 5000, extra: 1024 }))
+ await own.discard()
+ await assert.rejects(stat(join(dir, 'avatar', 'own-clip.pending.mp4')))
+ assert.deepEqual(own.view(), before)
+ await own.clear()
+ assert.equal(own.view(), null)
+ await assert.rejects(stat(join(dir, 'avatar', 'own-clip.mp4')))
+ await assert.rejects(stat(join(dir, 'avatar', 'own-clip.png')))
+ })
+})
+
+test('a stale pending copy is dropped on init, and an empty clip file counts as none', async () => {
+ await withDir(async (dir) => {
+ const avatar = join(dir, 'avatar')
+ const first = store(dir)
+ await first.own.init()
+ await post((req) => first.own.stage(req), mp4())
+ assert.ok(await stat(join(avatar, 'own-clip.pending.mp4')))
+ const second = store(dir)
+ await second.own.init()
+ await assert.rejects(stat(join(avatar, 'own-clip.pending.mp4')))
+ await writeFile(join(avatar, 'own-clip.mp4'), Buffer.alloc(0))
+ await second.own.rescan()
+ assert.equal(second.own.view(), null)
+ })
+})
+
+test('stage refuses a WebM, a 16 s clip and an HEVC clip, and keeps nothing of them', async () => {
+ await withDir(async (dir) => {
+ const { own } = store(dir)
+ await own.init()
+ const webm = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(64, 1)])
+ const a = await post((req) => own.stage(req), webm)
+ assert.equal(a.ok, false)
+ assert.equal(a.why, 'format')
+ const b = await post((req) => own.stage(req), mp4({ ms: 16_000 }))
+ assert.equal(b.ok, false)
+ assert.equal(b.why, 'too_long')
+ assert.equal(b.probe.durationMs, 16_000)
+ const c = await post((req) => own.stage(req), mp4({ codec: 'hvc1' }))
+ assert.equal(c.ok, false)
+ assert.equal(c.why, 'format')
+ await assert.rejects(stat(join(dir, 'avatar', 'own-clip.pending.mp4')))
+ assert.equal(own.view(), null)
+ })
+})
+
+test('stage refuses a body over 30 MB by its declared length before reading it, and a chunked one as it arrives', async () => {
+ await withDir(async (dir) => {
+ const { own } = store(dir)
+ await own.init()
+ const small = await post((req) => own.stage(req), Buffer.alloc(16), { 'content-length': '16' })
+ assert.equal(small.ok, false, 'a small declared body goes through to the probe, which finds no video')
+ assert.equal(small.why, 'format')
+ const big = await post((req) => own.stage(req), Buffer.alloc(OWN_CLIP_MAX_BYTES + 1), { 'content-length': String(OWN_CLIP_MAX_BYTES + 1) })
+ assert.equal(big.ok, false)
+ assert.equal(big.why, 'too_large')
+ assert.equal(big.probe.bytes, OWN_CLIP_MAX_BYTES + 1)
+ // chunked, no length: the cap is hit while the body streams in
+ const chunk = Buffer.alloc(1024 * 1024, 3)
+ const streamed = await post((req) => own.stage(req), (req) => {
+ for (let i = 0; i < 31; i++) req.write(chunk)
+ req.end()
+ })
+ assert.equal(streamed.ok, false)
+ assert.equal(streamed.why, 'too_large')
+ assert.ok(streamed.probe.bytes > OWN_CLIP_MAX_BYTES)
+ await assert.rejects(stat(join(dir, 'avatar', 'own-clip.pending.mp4')))
+ })
+})
+
+// ---- the route --------------------------------------------------------------------------
+
+test('serveClip hands out the own clip, its first frame and the pending copy, and 404 for the rest', async () => {
+ await withDir(async (dir) => {
+ const { own } = store(dir)
+ await own.init()
+ const bytes = mp4({ ms: 2000 })
+ await post((req) => own.stage(req), bytes)
+ const server = createServer((req, res) => serveClip(req, res, () => undefined, (name) => own.path(name)))
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok))
+ const { port } = server.address()
+ const get = async (path) => {
+ const res = await fetch(`http://127.0.0.1:${port}${MOTION_PREFIX}${path}`)
+ return { status: res.status, type: res.headers.get('content-type'), cache: res.headers.get('cache-control'), body: Buffer.from(await res.arrayBuffer()) }
+ }
+ try {
+ const pending = await get('/own-clip.pending.mp4?v=1')
+ assert.equal(pending.status, 200)
+ assert.equal(pending.type, 'video/mp4')
+ assert.deepEqual(pending.body, bytes)
+ assert.equal((await get('/own-clip.mp4')).status, 404, 'not the clip until Use')
+ await own.use(Buffer.from([1, 2, 3]))
+ const clip = await get(`/own-clip.mp4?v=${own.view().v}`)
+ assert.equal(clip.status, 200)
+ assert.deepEqual(clip.body, bytes)
+ assert.match(clip.cache, /immutable/)
+ const poster = await get(`/own-clip.png?v=${own.view().v}`)
+ assert.equal(poster.status, 200)
+ assert.equal(poster.type, 'image/png')
+ assert.deepEqual(poster.body, Buffer.from([1, 2, 3]))
+ assert.equal((await get('/own-clip.pending.mp4')).status, 404)
+ assert.equal((await get('/own-clip.txt')).status, 404)
+ assert.equal((await get('/../own-clip.mp4')).status, 404)
+ assert.equal((await get('/motion/idle.mp4')).status, 404)
+ await own.clear()
+ assert.equal((await get('/own-clip.mp4')).status, 404)
+ assert.equal((await get('/own-clip.png')).status, 404)
+ } finally {
+ await new Promise((ok) => server.close(ok))
+ }
+ })
+})
+
+test('without an own-clip path the route is as it was: only the moods', async () => {
+ const server = createServer((req, res) => serveClip(req, res, () => undefined))
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok))
+ const { port } = server.address()
+ try {
+ const res = await fetch(`http://127.0.0.1:${port}${MOTION_PREFIX}/own-clip.mp4`)
+ assert.equal(res.status, 404)
+ } finally {
+ await new Promise((ok) => server.close(ok))
+ }
+})
diff --git a/harness/dsh-nanomuse/tests/styles.test.mjs b/harness/dsh-nanomuse/tests/styles.test.mjs
index d89e03f5..dffdeafb 100644
--- a/harness/dsh-nanomuse/tests/styles.test.mjs
+++ b/harness/dsh-nanomuse/tests/styles.test.mjs
@@ -6,7 +6,9 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
-const css = readFileSync(fileURLToPath(new URL('../src/client/styles.ts', import.meta.url)), 'utf8')
+// A Windows checkout may carry CRLF line endings (core.autocrlf); the tests look for
+// `\n {\n`, so the file is read with LF endings whatever the checkout did.
+const css = readFileSync(fileURLToPath(new URL('../src/client/styles.ts', import.meta.url)), 'utf8').replace(/\r\n/g, '\n')
/** The declarations of the first rule whose selector list is exactly `selector`. */
function rule(selector) {
@@ -121,3 +123,69 @@ test('under prefers-reduced-motion every breathing light is steady', () => {
for (const sel of ['.nm-chat-dot.nm-live', '.nm-status-dot.nm-live', '.nm-remote-working::before', '.nm-mic.nm-live', 'html[data-nm-muse] [data-chat-running]::after']) assert.ok(steady.includes(sel), sel)
assert.match(steady, /animation: none; opacity: 1;/)
})
+
+test('in the Muse look the chat is monochrome: ink pills, grey agent pills, the send disc the same ink', () => {
+ // the token blocks span several lines
+ const block = (selector) => {
+ const at = css.indexOf(`\n${selector} {\n`)
+ assert.ok(at >= 0, `no block for ${selector}`)
+ return css.slice(at, css.indexOf('\n}', at))
+ }
+ const light = block('html[data-nanomuse] body')
+ const dark = block('html[data-nanomuse] body[data-ds-dark-theme]')
+ const tokens = (block) => Object.fromEntries([...block.matchAll(/--(nm-[a-z-]+):\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]))
+ const l = tokens(light)
+ const d = tokens(dark)
+ assert.equal(l['nm-user-bubble'], '#000000')
+ assert.equal(l['nm-user-bubble-fg'], '#ffffff')
+ assert.equal(l['nm-agent-bubble'], '#f2f2f7')
+ assert.equal(l['nm-canvas'], '#ffffff')
+ assert.equal(l['nm-ink'], '#000000')
+ assert.equal(l['nm-ink-fg'], '#ffffff')
+ assert.equal(d['nm-user-bubble'], '#ffffff')
+ assert.equal(d['nm-user-bubble-fg'], '#000000')
+ assert.equal(d['nm-ink'], '#ffffff')
+ assert.equal(d['nm-ink-fg'], '#000000')
+ // the agent's dark pill keeps the same step off the near-black as the phone's off pure black
+ assert.equal(d['nm-agent-bubble'], '#242424')
+ assert.equal(d['nm-canvas'], d['nm-base'])
+ for (const t of [l, d]) assert.equal(t['nm-send'], 'var(--nm-ink)')
+ // the user's pill carries its own text colour, and so do the remote and coding copies of it
+ for (const sel of [
+ 'html[data-nm-muse] [data-chat-flow-kind="user"] [class*="_bubble"]',
+ 'html[data-nm-muse] .nm-remote-user .nm-remote-bubble',
+ '.nm-cd-from-you .nm-cd-bubble',
+ ]) {
+ const r = rule(sel)
+ assert.match(r, /background:\s*var\(--nm-user-bubble\)/, sel)
+ assert.match(r, /color:\s*var\(--nm-user-bubble-fg\)/, sel)
+ }
+ // the send disc and the scroll-to-latest disc: the ink, the glyph in the opposite colour
+ assert.match(rule('html[data-nm-muse] [data-composer-card] [class*="_primary"]'), /color:\s*var\(--nm-ink-fg\)/)
+ const toBottom = rule('html[data-nm-muse] [data-conversation-scroll] [class*="_toBottomSlot"] > button[class*="_toBottom"]')
+ assert.match(toBottom, /background:\s*var\(--nm-send\)/)
+ assert.match(toBottom, /color:\s*var\(--nm-ink-fg\)/)
+})
+
+test('in the Muse look the header is a scrim over the thread, not a bar with a line under it', () => {
+ const header = 'html[data-nm-muse] header[data-window-drag]:has(.nm-header)'
+ const scrim = rule(header)
+ assert.match(scrim, /position:\s*absolute/)
+ assert.match(scrim, /border-bottom:\s*0/)
+ assert.match(scrim, /pointer-events:\s*none/)
+ assert.match(scrim, /linear-gradient\(to bottom, color-mix\(in srgb, var\(--nm-canvas\) 80%, transparent\) 0%, color-mix\(in srgb, var\(--nm-canvas\) 35%, transparent\) 55%, transparent 100%\)/)
+ // the controls in it still take clicks, and the drag strip still drags
+ const controls = css.split('\n').find((l) => l.startsWith(`${header} :is(`) && l.includes('pointer-events: auto'))
+ assert.ok(controls, 'the header controls take their clicks back')
+ for (const sel of ['button', 'a', '.nm-header', '.nm-header-drag', '[role="tab"]']) assert.ok(controls.includes(sel), sel)
+ // the thread keeps the header's height clear, one variable per platform
+ const thread = 'html[data-nm-muse] [data-slot="conversation.header"]:has(.nm-header) ~ [data-conversation-content]'
+ assert.match(rule(`${thread} > [data-conversation-scroll]`), /padding-top:\s*var\(--nm-header-h\)/)
+ assert.match(rule('header[data-window-drag]:has(.nm-header)'), /min-height:\s*var\(--nm-header-h\)/)
+ assert.match(rule("html[data-nm-platform='darwin']:not([data-nm-fullscreen])"), /--nm-header-h:\s*136px/)
+ assert.match(rule("html[data-nm-platform='win32']:not([data-nm-fullscreen])"), /--nm-header-h:\s*122px/)
+ // the chat's canvas: white in the light, and the harness's own base token follows it there
+ const content = rule(thread)
+ assert.match(content, /background:\s*var\(--nm-canvas\)/)
+ assert.match(content, /--dsw-alias-bg-base:\s*var\(--nm-canvas\)/)
+})
diff --git a/harness/dsh-nanomuse/tests/update-cache.test.mjs b/harness/dsh-nanomuse/tests/update-cache.test.mjs
new file mode 100644
index 00000000..0a4862f0
--- /dev/null
+++ b/harness/dsh-nanomuse/tests/update-cache.test.mjs
@@ -0,0 +1,122 @@
+// The update check's cache against the bundle that runs now: the state file outlives an
+// update, so the check the previous version made within the day said "1.0.2 is out" to the
+// 1.0.2 that had just been installed (#341). The cache is read against this version, and
+// `update(false)` does not answer from a cache another version made.
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Service } from '@deepseek-ai/cordis'
+import NanomuseCloud, { BUNDLE_VERSION, LLM_ROW, updateForThisVersion } from '../lib/cloud.js'
+
+process.env.NANOMUSE_PY = join(tmpdir(), 'nanomuse-runtime-that-does-not-exist')
+
+function fakeContext() {
+ const settings = { [LLM_ROW]: { providers: {} } }
+ const credentials = new Map()
+ return {
+ reflect: { provide() {} },
+ logger: { info() {}, warn() {}, debug() {}, error() {} },
+ credentials: {
+ async set(ref, value) { credentials.set(String(ref), value) },
+ async resolve(ref) { return credentials.has(String(ref)) ? { value: credentials.get(String(ref)) } : undefined },
+ async unset(ref) { credentials.delete(String(ref)) },
+ },
+ settings: {
+ async update(ns, patch) { Object.assign(settings[ns] ?? (settings[ns] = {}), patch) },
+ async mutate() {},
+ describe() { return Object.entries(settings).map(([ns, value]) => ({ ns, value })) },
+ },
+ get() { return undefined },
+ inject() {},
+ effect(fn) { fn() },
+ on() { return () => undefined },
+ }
+}
+
+/** A service over a state file that already holds `update`, as the previous version left it. */
+async function cloudWith(update) {
+ const home = await mkdtemp(join(tmpdir(), 'nm-update-cache-'))
+ const statePath = join(home, 'cloud.json')
+ await writeFile(statePath, JSON.stringify({ deviceId: 'pc-dsh-test', update }))
+ const svc = new NanomuseCloud(fakeContext(), { baseURL: 'https://relay.invalid', deviceName: 'test-desktop', statePath })
+ svc.hub.start = () => undefined
+ svc.refresh = async () => svc.status()
+ await svc[Service.init]()
+ return { svc, done: () => rm(home, { recursive: true, force: true }) }
+}
+
+/** The version `package.json` carries: the bundle under test must report it, whatever the release. */
+const PKG_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
+/** A version below any release the tests run under. */
+const PREVIOUS = '0.1.0'
+/** One patch release beyond the version under test. */
+const BEYOND = PKG_VERSION.replace(/(\d+)$/, (n) => String(Number(n) + 1))
+const checkedByPrevious = (latest) => ({ current: PREVIOUS, latest, newer: true, page: 'https://github.com/zeeshanhaque21/nanoMuse/releases', assets: [], notes: '', source: 'github', checkedAt: Date.now() })
+
+test('the bundle version the tests run under is the package version, a release, not the fallback', () => {
+ assert.equal(BUNDLE_VERSION, PKG_VERSION)
+ assert.match(BUNDLE_VERSION, /^\d+\.\d+\.\d+$/)
+ assert.notEqual(BUNDLE_VERSION, PREVIOUS)
+})
+
+test('a cached check made by the previous version is read against this one', () => {
+ // installed the release the cache announced: nothing is newer any more
+ const same = updateForThisVersion(checkedByPrevious(BUNDLE_VERSION))
+ assert.equal(same.current, BUNDLE_VERSION)
+ assert.equal(same.latest, BUNDLE_VERSION)
+ assert.equal(same.newer, false)
+ // a release beyond the one installed is still newer
+ const beyond = updateForThisVersion(checkedByPrevious(BEYOND))
+ assert.equal(beyond.current, BUNDLE_VERSION)
+ assert.equal(beyond.newer, true)
+ // the time of the check is kept: no extra request on this start
+ const cached = checkedByPrevious(BUNDLE_VERSION)
+ assert.equal(updateForThisVersion(cached).checkedAt, cached.checkedAt)
+ // a cache this version made is returned as it is
+ const own = { ...checkedByPrevious(BEYOND), current: BUNDLE_VERSION }
+ assert.equal(updateForThisVersion(own), own)
+ // a cache without a `latest` (an older state file) says nothing is newer rather than throwing
+ const bare = updateForThisVersion({ checkedAt: 1 })
+ assert.equal(bare.newer, false)
+ assert.equal(bare.latest, '')
+})
+
+test('right after installing this version over the previous one, the sidebar is not told to update to it', async () => {
+ const out = await cloudWith(checkedByPrevious(BUNDLE_VERSION))
+ try {
+ const live = out.svc.live()
+ assert.equal(live.update.current, BUNDLE_VERSION)
+ assert.equal(live.update.latest, BUNDLE_VERSION)
+ assert.equal(live.update.newer, false)
+ } finally {
+ await out.done()
+ }
+})
+
+test('update(false) does not answer from a cache another version made; it checks again', async () => {
+ const out = await cloudWith(checkedByPrevious(BUNDLE_VERSION))
+ const realFetch = globalThis.fetch
+ const asked = []
+ // the mirror answers that this very version is the latest; nothing leaves the test
+ globalThis.fetch = async (url) => { asked.push(String(url)); return { ok: true, status: 200, json: async () => ({ repo: 'nano-muse/nanoMuse', releases: [{ tag: `v${BUNDLE_VERSION}`, assets: [] }] }) } }
+ try {
+ // the stale cache, put back in memory as if nothing had normalised it
+ const stale = checkedByPrevious(BUNDLE_VERSION)
+ out.svc.state.update = stale
+ const info = await out.svc.update(false)
+ assert.notEqual(info, stale, 'not the stale object')
+ assert.equal(asked.length, 1, 'one request, to the mirror')
+ assert.equal(info.current, BUNDLE_VERSION)
+ assert.equal(info.newer, false)
+ // a cache this version made within the day is answered without a request
+ asked.length = 0
+ assert.equal(await out.svc.update(false), info)
+ assert.equal(asked.length, 0)
+ } finally {
+ globalThis.fetch = realFetch
+ await out.done()
+ }
+})
diff --git a/maintenance-evidence/run-2026-10-11/pr-body.md b/maintenance-evidence/run-2026-10-11/pr-body.md
new file mode 100644
index 00000000..1f204324
--- /dev/null
+++ b/maintenance-evidence/run-2026-10-11/pr-body.md
@@ -0,0 +1,239 @@
+# Sync upstream nanoMuse v1.0.3 (Helm) into the fork (sanitized)
+
+Merges the published upstream release `v1.0.3` into the fork without losing any fork-only
+feature, and keeps every `.cn` cloud-backend reference out of the shipped tree.
+
+## Provenance
+
+| | |
+|---|---|
+| Upstream repository (read-only) | `nano-muse/nanoMuse` |
+| Upstream tip merged | `189621240d7efd6a99debc45a4cc1a55edb6b141` (`upstream/main`) |
+| Published upstream releases discovered | `v1.0.3` (Helm) — the only release newer than the fork's previous integration point |
+| Upstream release commit | `34a950c82977f20006d19d87d2241fe1f7071e7c` (`refs/tags/v1.0.3`) — **an ancestor of `upstream/main`**, 67 commits behind it, so no separate release-commit integration was needed |
+| Fork baseline before this run | `f3237f9b697e0fd6c988c1fafcd2bfce830d54fc` (`fork/main`) |
+| Previous merge base | `5379e97a5e6d8c68c6f1fc4da3b4649692b94f82` |
+| Commits integrated | 180 (`git rev-list --count fork/main..upstream/main`) |
+| Content head (all code) | `cc4b552a040c53d78d05e57ef88c75c63fb0827d` |
+| Tested head | the branch tip — every commit after the content head changes only `maintenance-evidence/run-2026-10-11/`, so tip and content head have identical code |
+| Diff (`fork/main..cc4b552a04`, content only) | 334 files changed, 12,087 insertions(+), 2,301 deletions(−) |
+
+Five content commits sit on `fork/main`, each with a DCO `Signed-off-by` line. Everything
+after `cc4b552a04` is evidence-only, touching nothing but `maintenance-evidence/run-2026-10-11/`:
+
+```
+c7bd659949 chore(sync): merge sanitized upstream through v1.0.3 (Helm)
+df23dbb031 build(web): regenerate committed static from the merged source (Node 22)
+f38a067f85 test(sync): keep the fork's expectations on two tests upstream 1.0.3 rewrote
+204633f988 style(sync): apply ruff format to the three files an earlier sanitization left unformatted
+cc4b552a04 fix(docs): restore the sentence period the mirror-clause removal ate, and guard it
+```
+
+The full runtime suite was re-run at the branch tip as well as at the content head, so the
+numbers below describe the code that would merge rather than an earlier revision. Because
+each further evidence edit moves the tip without touching code, the exact tip SHA at merge
+time is recorded in `maintenance-evidence/run-2026-10-11/completion.md` instead of here.
+
+Releases are discovered independently of whether `upstream/main` moved. Fork releases
+already present (`v1.0.0-fork.1`, `v1.0.1-fork.1`, `v1.0.2-fork.1`, `v0.1.41-fork.1`, …)
+were deduplicated by upstream tag; `v1.0.3-fork.1` did not exist.
+
+## Sanitization — the scan of record
+
+Post-merge, the canonical scan (this exact command is the evidence of record in
+`maintenance/RUNBOOK.md`):
+
+```
+git grep -l 'nanomuse\.cn' -- . \
+ ':(exclude)CHANGELOG.md' ':(exclude)docs/releases/**' ':(exclude)cloud/deploy/**' \
+ ':(exclude)demo/**' ':(exclude)docs/privacy.md' ':(exclude)docs/roadmap.md' \
+ ':(exclude)docs/readme/**' ':(exclude)site/README.md' \
+ ':(exclude)docs/release-notes-template.md' ':(exclude)nanomuse/server/static/**'
+```
+
+Remainder — **19 files**, every one classified, none a new active reference:
+
+```
+cloud/README.md 1 the deploy artifact name cloud.nanomuse.cn.caddy
+docs/zh/cloud.md 3 attribution prose for upstream's public relay
+docs/zh/every-device.md and upstream's showcase site
+docs/zh/privacy.md legal text (in the sanitization test's ALLOWED set)
+maintenance/RUNBOOK.md 1 the runbook's own literal scan commands
+tests/test_fork_relay_defaults.py 3 absence assertions
+tests/test_fork_upstream_037_sanitization.py
+tests/test_nudges_runtime.py
+maintenance-evidence/run-2026-10-05/* 5 immutable prior-run evidence
+maintenance-evidence/run-2026-10-10/* 5 immutable prior-run evidence
+maintenance-evidence/run-2026-10-11/pr-body.md 1 this run's PR body — it quotes the scan command
+```
+
+(The runbook's "as of the 0.1.41 sync" list is 13 files; the extra six are prior runs'
+evidence files plus this run's own, which necessarily quote `nanomuse\.cn`. This run's
+`completion.md` will be a 20th once the run finishes.)
+
+Active-surface scan of the areas the command does not exclude is **empty**:
+
+```
+git grep -nI 'nanomuse\.cn\|demo\.nanomuse\.dev\|nano-muse\.github\.io' -- \
+ nanomuse web/src cloud/nanomuse_cloud harness desktop android/src \
+ ':(exclude)nanomuse/server/static/**' # no hits
+grep -rl 'nanomuse\.cn' nanomuse/server/static/ # empty
+```
+
+### Contract held
+
+- `NANOMUSE_CLOUD_BASE_URL` / `cloud.base_url` is still the only relay seam.
+- `cloud/nanomuse_cloud/config.py`: `invite_url`, `own_key_docs`, `privacy_url`, `repo_url`
+ all default to `""`; `openrouter_url` keeps its documented third-party default.
+- Empty relay still raises `relay_unconfigured` before any network I/O
+ (`nanomuse/cloud.py`), and the Android path still calls `requireBaseUrl(context)` before
+ building a request (`NanoMuseCloud.kt:1129`).
+- Upstream's 1.0.3 rewording turned the Chinese docs' relay mentions into bare hostnames
+ (`在 \`cloud.nanomuse.cn\` 上`). The fork's "upstream's public relay" attribution was
+ re-applied so the text never reads as this fork's own relay, matching `fork/main`.
+
+### Historical and attribution material
+
+Untouched relative to `upstream/main` — no active reference was removed from them:
+
+- `CHANGELOG.md` — byte-identical to `upstream/main`
+- `docs/releases/**` — relative to `fork/main` upstream added only its own
+ `docs/releases/v1.0.3.md`; the directory carries no fork edit
+- `docs/privacy.md`, `site/README.md` — byte-identical to `upstream/main`
+
+Edited relative to `upstream/main` **only** to rename the repository, drop the hosted-demo
+and download-mirror references, and bump the version. Their history and their upstream
+attribution are kept, not rewritten:
+
+- `docs/readme/README_*.md` (all ten), `docs/release-notes-template.md`, `docs/roadmap.md`
+- `cloud/deploy/nanomuse-hk/deploy.sh`
+- `demo/showcase/**` (`gateway/showcase_gateway/{config,logs,webpage}.py`, `mirror/traffic.py`)
+
+Unrelated third-party `.cn` citations are untouched: `open.bigmodel.cn`, `api.moonshot.cn`,
+`api.siliconflow.cn`, `open.feishu.cn`, `iat-api.xfyun.cn`, `pypi.tuna.tsinghua.edu.cn`,
+`goproxy.cn`, and the rest.
+
+### Generated assets
+
+`nanomuse/server/static/**` is never merged by hand. Commit `c7bd659949` takes upstream's
+tree unchanged; commit `df23dbb031` replaces it with the output of the merged source built
+on Node 22 (`vite.config.ts` writes `outDir: "../nanomuse/server/static"`,
+`emptyOutDir: true`). CI's `web app build` job re-runs `npm run build` and fails if the
+committed bundle is stale — it is green.
+
+## Fork-only features preserved
+
+| Feature | Where | State |
+|---|---|---|
+| Google Calendar over OAuth | `nanomuse/calendar/feeds.py` | fork's `_refresh_feeds` / `_refresh_google` split kept **and** upstream's `_error_text` URL redaction taken (private feed links are secrets) |
+| `relay_unconfigured` before network I/O | `NanoMuseCloud.kt` | `requireBaseUrl(context)` kept inside upstream's refactored `request()` |
+| Configurable relay hosting / signed-out relay editing | `nanomuse/server/*`, `web/src/**` | kept |
+| Bundled desktop Playwright/Chromium | `scripts/desktop-app/*`, `harness/desktop` | kept |
+| Update check: GitHub only, mirror opt-in | `nanomuse/server/update.py` | `INDEX_URL = os.environ.get("NANOMUSE_UPDATE_INDEX_URL", "")`, no default, no fallback |
+| `source: 'github' \| 'mirror' \| 'none'` | `harness/dsh-nanomuse/src/desk.ts` | kept |
+| Fork badge (no upstream repo ref in the READMEs) | `README.md`, `docs/readme/**` | kept — see the test note below |
+
+No file present at `fork/main` is missing from this branch except regenerated
+`nanomuse/server/static/assets/*` bundles; zero non-static files were dropped.
+
+## Three upstream tests needed a fork-aware expectation
+
+These arrive with 1.0.3 and fail against the fork's configuration rather than against the
+merged code. They are fixed, not papered over:
+
+- `tests/test_readme_contributors.py` asserted the badge is
+ `hits.sh/github.com/nano-muse/nanoMuse.svg?`. That URL embeds an upstream repository
+ reference, which `tests/test_fork_docs_sanitization.py` forbids; the ten READMEs carry
+ the fork's komarev badge. The fork's assertion (already present at `fork/main`) is
+ restored.
+- `tests/test_endpoints_api.py` is new to the fork and posts
+ `settings.cloud.base_url + "/v1"` as a custom endpoint, expecting the *"it is the
+ account, not an endpoint"* refusal. With the fork's empty relay default that address is
+ the relative `/v1`, refused earlier as malformed. The test now asserts the fork's
+ behaviour for an unconfigured relay, then sets a relay on the live `Settings` and keeps
+ upstream's coverage of the account guard.
+- `tests/test_fork_docs_sanitization.py` grows a regression test for the sentence-gluing
+ scar described under *Review* below.
+
+## Checks actually run
+
+### Local
+
+| Check | Result |
+|---|---|
+| `pytest -q -m "not live"` (runtime, py3.12 CI venv) | **955 passed, 5 skipped, 1 deselected, 0 failed** |
+| Same command at the `fork/main` baseline (`/tmp/nm_base`, `f3237f9b69`) | **854 passed, 5 skipped, 1 deselected, 0 failed** |
+| Fork contract tests (3 files) | 144 passed |
+| `cloud/` `ruff check . && ruff format --check . && pytest -q` | clean; 178 passed |
+| `demo` `ruff check && ruff format --check`; gateway `pytest -q` | clean; 44 passed |
+| `ruff check nanomuse tests scripts` / `ruff format --check nanomuse tests scripts` | clean / 229 formatted |
+| `mypy` (py3.12 venv, `[tool.mypy] files = ["nanomuse"]`) | Success: no issues found in 140 source files |
+| `python -m compileall -q demo cloud nanomuse scripts` | clean |
+| `web` `npm run check` (lint + tsc + vitest) | exit 0, 105 vitest tests passed |
+| `harness/dsh-nanomuse` build + typecheck + test | 313 tests pass, 0 fail |
+| `harness/desktop` typecheck + test | 59 tests pass, 0 fail |
+| `node scripts/connectors-json.mjs --check` / `providers-json.mjs --check` | clean |
+
+The merge adds **101** passing runtime tests and no new failure (the count rose from 854 at
+`fork/main` to 955 here; the last one is the regression test for the scar below).
+
+### CI
+
+The first pushed head (`f38a067f85`) had **two red jobs**, both `ruff format --check`
+failures the fork's own earlier sanitization had left behind and which the local commands
+in `AGENTS.md` do not cover (`demo/` and `cloud/` are checked by their own jobs):
+
+- `gateway tests` — `demo/showcase/mirror/traffic.py`
+- `relay · ruff and pytest` — `cloud/nanomuse_cloud/config.py:493`,
+ `cloud/tests/test_cloud.py:694` (an indentation scar and two expressions laid out for
+ upstream's longer URLs)
+
+`ruff format --check demo cloud` reported **11** unformatted files at `fork/main` and **3**
+here — the merge already took upstream's formatted versions of the other eight. Commit
+`204633f988` applies the repository's own formatter to the remaining three; both jobs are
+now green at the current head, as are `Signed-off-by on every commit`, `web app build`,
+`build · test · pack` and `build · test (Windows)`.
+
+### End-to-end smoke proof (before any expensive build)
+
+`nanomuse serve` v1.0.3 booted on `127.0.0.1:8791` from the merged tree:
+
+- `/api/state` with the Bearer token → **HTTP 200**, `version: "1.0.3"`
+- `/api/state` without a token → **HTTP 401** `invalid or missing token`
+- all 7 content-hashed assets referenced by `index.html` → **HTTP 200**, including the
+ entry chunk `assets/index-tYqPtwPX.js` produced by the regenerated bundle
+- a real browser (`chrome-headless-shell`, Chrome for Testing 153.0.8010.12) navigated
+ `http://127.0.0.1:8791/#token=…`: the SPA mounted into `` and rendered the
+ onboarding copy (*"nanoMuse · Skip setup · Meet your nanoMuse …"*). The only stderr line
+ is the known non-GUI-session `CVDisplayLinkCreateWithCGDisplay failed` noise.
+
+## Review
+
+A separate fresh-context read-only subagent reviewed the diff, independently re-ran the
+backend scan and both pytest suites, re-checked the baseline comparison, audited credential
+handling and release provenance, and fact-checked this body. **Verdict: safe to merge, no
+blockers.**
+
+Its findings, all corrected before the merge:
+
+1. **A real scar** — the mirror-clause removal swallowed a sentence's `.` and the joining
+ `;` in `README.md` and five translated readmes, gluing two sentences together. Fixed in
+ `cc4b552a04`, with a regression test. (Review listed four readmes; `README_ru.md` had
+ the same scar and is fixed too.)
+2. **This body's counts were wrong** — the remainder is 19 files, not 18, and this run's
+ evidence directory held one file, not three. Corrected above.
+3. **The stated tested head was stale and had two red CI jobs.** Corrected above.
+4. **"Preserved, not rewritten" was overstated** — several listed paths *are* edited
+ relative to `upstream/main`. The section above now separates what is genuinely
+ untouched from what was edited, and says why.
+5. **The diff stat and tested head were stale.** Both corrected.
+
+## Not verified here
+
+- Windows and Linux desktop installers, the iOS build, and the Android release APK are CI
+ and release-time artifacts, not verified in this PR.
+- The committed static bundle was structurally checked (every `index.html` asset reference
+ exists on disk, no `.cn` strings, no upstream repo refs) and CI rebuilds it, but it was
+ not byte-compared against a fresh build in this session.
+- The adversarial reviewer did not reproduce the end-to-end smoke proof itself; it verified
+ the surrounding evidence instead.
diff --git a/nanomuse/__init__.py b/nanomuse/__init__.py
index c592a54b..0558bfa2 100644
--- a/nanomuse/__init__.py
+++ b/nanomuse/__init__.py
@@ -9,5 +9,5 @@
from __future__ import annotations
-__version__ = "1.0.2"
+__version__ = "1.0.3"
__all__ = ["__version__"]
diff --git a/nanomuse/calendar/feeds.py b/nanomuse/calendar/feeds.py
index 012b6718..ea196edf 100644
--- a/nanomuse/calendar/feeds.py
+++ b/nanomuse/calendar/feeds.py
@@ -184,19 +184,33 @@ async def _refresh_feeds(self) -> None:
texts: dict[str, str] = {}
for feed in self.settings.feeds:
state = self.states.setdefault(feed.name, FeedState(feed.name))
+ url = ""
try:
- text = await self._read(self._url(feed.url))
+ url = self._url(feed.url)
+ text = await self._read(url)
if "BEGIN:VCALENDAR" not in text[:2000]:
raise ValueError("not an iCalendar file (no BEGIN:VCALENDAR)")
state.events = parse_ics(text, self.tz, calendar=feed.name)
state.error = ""
texts[feed.name] = text
except (httpx.HTTPError, OSError, ValueError) as exc:
- state.error = f"{type(exc).__name__}: {exc}"[:200]
+ state.error = self._error_text(exc, url or feed.url)
logger.warning("calendar feed '{}' failed: {}", feed.name, state.error)
state.fetched_at = time.time()
self._save_cache(texts)
+ @staticmethod
+ def _error_text(exc: Exception, url: str) -> str:
+ """What the status and the log say about a failed feed. A private calendar link is
+ a secret (whoever has it reads the calendar), and httpx puts the full URL into its
+ status errors, so the status code stands alone and the address never appears."""
+ if isinstance(exc, httpx.HTTPStatusError):
+ return f"HTTP {exc.response.status_code}"
+ text = f"{type(exc).__name__}: {exc}"
+ if url and url in text:
+ text = text.replace(url, "")
+ return text[:200]
+
async def _refresh_google(self) -> None:
"""Read every selected Google calendar over the API into ``google_states``."""
g = self.settings.google
diff --git a/nanomuse/cli.py b/nanomuse/cli.py
index f1cf0cf5..7c82a160 100644
--- a/nanomuse/cli.py
+++ b/nanomuse/cli.py
@@ -1421,7 +1421,7 @@ def config_init(
'api_key = "${DEEPSEEK_API_KEY}"\n\n[sentinel]\nmode = "ask"\n',
"utf-8",
)
- console.print(f"wrote [cyan]{path}[/cyan] – edit llm.api_key / model, then run `nanomuse chat`")
+ console.print(f"wrote [cyan]{path}[/cyan]; edit llm.api_key / model, then run `nanomuse chat`")
@config_app.command("show")
@@ -1433,19 +1433,28 @@ def config_show(config: ConfigOpt = None) -> None:
)
-_SECRET_KEY_RE = re.compile(r"(api_key|password|secret|token|app_key|passwd)$", re.IGNORECASE)
+_SECRET_KEY_RE = re.compile(
+ r"(api_key|password|passwd|secret|token|app_key|authorization|cookie|(^|_)key)$",
+ re.IGNORECASE,
+)
def mask_secrets(data: Any) -> Any:
"""The same structure with every value under a key that names a credential
- (``api_key``, ``password``, ``secret``, ``token`` and the like) shortened to its first
- and last characters, in every section: the image and video slots, the mail account,
- the search connector and the chat channels hold keys too, not only ``[llm]``."""
+ (``api_key``, ``password``, ``secret``, ``token``, any ``*_KEY`` an MCP server's ``env``
+ carries, an ``Authorization`` header and the like) shortened to its first and last
+ characters, in every section: the image and video slots, the mail account, the search
+ connector and the chat channels hold keys too, not only ``[llm]``. A proxy URL keeps
+ its host and loses its password."""
+ from nanomuse.server.connections import mask_proxy
+
if isinstance(data, dict):
out: dict[str, Any] = {}
for k, v in data.items():
if isinstance(v, str) and v and _SECRET_KEY_RE.search(str(k)):
out[k] = v if v.startswith("{{vault:") else _masked(v)
+ elif isinstance(v, str) and str(k) == "proxy":
+ out[k] = mask_proxy(v)
else:
out[k] = mask_secrets(v)
return out
diff --git a/nanomuse/coding/agents.py b/nanomuse/coding/agents.py
index d437ca7e..eb8281f8 100644
--- a/nanomuse/coding/agents.py
+++ b/nanomuse/coding/agents.py
@@ -420,7 +420,7 @@ def _cursor_cli_meta() -> dict[str, dict[str, Any]]:
meta = c / "meta.json"
if meta.is_file():
try:
- out[c.name] = json.loads(meta.read_text())
+ out[c.name] = json.loads(meta.read_text("utf-8"))
except (OSError, ValueError):
continue
return out
diff --git a/nanomuse/config.py b/nanomuse/config.py
index 4d0d49f6..a1bfa934 100644
--- a/nanomuse/config.py
+++ b/nanomuse/config.py
@@ -636,8 +636,9 @@ class ServerSettings(BaseModel):
cors_origins: list[str] = Field(default_factory=list)
# Largest file the app may attach to a message (photos from a phone run 3–12 MB).
max_upload_mb: int = 25
- # Ask GitHub Releases (at most every six hours) whether a newer nanoMuse is out, and say so
- # under Settings → About. Nothing is downloaded or reported. NANOMUSE_NO_UPDATE_CHECK=1 also turns it off.
+ # Ask the download mirror's index, then GitHub Releases (once a day, or on "Check now")
+ # whether a newer nanoMuse is out, and say so under Settings → About. Nothing is downloaded
+ # or reported. NANOMUSE_NO_UPDATE_CHECK=1 also turns it off.
update_check: bool = True
# How long the services (tools, MCP servers, the cloud account) may take before the
# socket opens anyway; past it the app answers and /api/health names the step still running.
@@ -885,19 +886,28 @@ def load_app_settings(data_dir: Path) -> dict[str, Any]:
return {}
try:
data = json.loads(path.read_text("utf-8"))
- except (OSError, json.JSONDecodeError):
+ except (OSError, json.JSONDecodeError) as exc:
+ # a file that cannot be read is not silently a first start: say so in the log, the
+ # defaults apply until the next save rewrites it
+ logger.warning("{} could not be read ({}); the app's settings are ignored", path, exc)
return {}
return data if isinstance(data, dict) else {}
def save_app_settings(data_dir: Path, data: dict[str, Any]) -> None:
+ """Write the file whole or not at all: a temporary file next to it, mode 0600 before any
+ content is in it, then renamed over the old one, so a crash mid-write cannot leave a
+ truncated file that the next start reads as a first start."""
data_dir.mkdir(parents=True, exist_ok=True)
path = data_dir / APP_SETTINGS_FILE
- path.write_text(json.dumps({"version": 1, **data}, ensure_ascii=False, indent=1), "utf-8")
+ tmp = path.with_suffix(".tmp")
+ tmp.touch(mode=0o600, exist_ok=True)
+ tmp.write_text(json.dumps({"version": 1, **data}, ensure_ascii=False, indent=1), "utf-8")
try:
- path.chmod(0o600)
+ tmp.chmod(0o600)
except OSError: # pragma: no cover
pass
+ tmp.replace(path)
def apply_app_settings(settings: Settings, data: dict[str, Any]) -> None:
diff --git a/nanomuse/server/api.py b/nanomuse/server/api.py
index 0e31e0b4..dc94a9d3 100644
--- a/nanomuse/server/api.py
+++ b/nanomuse/server/api.py
@@ -27,6 +27,8 @@
PUT /api/connections/gui the hands' model (a protocol or a catalogue id, as for llm) POST /api/connections/gui/test
GET|PUT /api/connections/image|video where pictures and clips come from: provider (a catalogue id), model, base_url, api_key
POST /api/llm/models the models an endpoint offers (live /models, else the catalogue)
+ POST /api/connections/endpoints {label,base_url,api_key,models} PUT|DELETE /api/connections/endpoints/{id}
+ POST /api/connections/endpoints/{id}/models custom OpenAI-compatible endpoints, several at once (#294)
GET /api/providers (?lang=en|zh®ion=cn|global) the provider catalogue, which slots use which, what that covers
POST /api/chatgpt/login GET /api/chatgpt/status GET /api/chatgpt/usage
POST /api/chatgpt/callback {url} POST /api/chatgpt/logout
@@ -215,24 +217,43 @@ class LLMBody(BaseModel):
api_key: str | None = None
# `[llm] proxy`: http(s):// or socks5(h):// for this slot's requests only; "" clears it
proxy: str | None = None
+ # one of the endpoint list (#294): the slot takes its address and its key; provider,
+ # base_url and api_key in the body are then ignored
+ endpoint: str | None = None
class MediaBody(BaseModel):
"""One of the ``image`` / ``video`` slots. ``provider`` is a catalogue id, or ``openai``
/ ``openai_responses`` with a ``base_url``; ``model`` empty means the catalogue's default;
``api_key`` goes into the vault, "" removes it, None keeps it. All four "" clears the
- slot: pictures (clips) come from the chat provider again, or the account."""
+ slot: pictures (clips) come from the chat provider again, or the account. ``endpoint``
+ names one of the endpoint list instead of a provider (#294)."""
provider: str | None = None
model: str | None = None
base_url: str | None = None
api_key: str | None = None
+ endpoint: str | None = None
class LLMModelsBody(BaseModel):
preset: str = ""
base_url: str = ""
api_key: str = ""
+ # one of the endpoint list (#294): its address and its own key
+ endpoint: str = ""
+
+
+class EndpointBody(BaseModel):
+ """A custom OpenAI-compatible endpoint of the list (#294). ``base_url`` is required to
+ add one; ``label`` empty means the host name; ``api_key`` goes into the vault, "" means
+ no key (a local server), None keeps it; ``models`` are the ids the person typed, for an
+ endpoint that does not list its own (a string with commas, or a list)."""
+
+ label: str | None = Field(default=None, max_length=200)
+ base_url: str | None = Field(default=None, max_length=2000)
+ api_key: str | None = Field(default=None, max_length=4000)
+ models: str | list[str] | None = None
class ChatGptCallbackBody(BaseModel):
@@ -971,6 +992,45 @@ async def llm_models(body: LLMModelsBody) -> dict[str, Any]:
"""The models an endpoint offers (its /models, else the preset's catalogue); saves nothing."""
return await conn.llm_models(body.model_dump())
+ # ------------------------------------------------------------------ endpoints (#294)
+ @app.post("/api/connections/endpoints", dependencies=dep)
+ async def add_endpoint(body: EndpointBody) -> dict[str, Any]:
+ """A custom OpenAI-compatible endpoint added to the list: its models are asked for
+ (``GET /models``) unless typed; 400 when there is neither."""
+ try:
+ return await conn.endpoints.add(body.model_dump(exclude_none=True))
+ except ValueError as exc:
+ raise HTTPException(400, str(exc)) from exc
+
+ @app.put("/api/connections/endpoints/{endpoint_id}", dependencies=dep)
+ async def update_endpoint(endpoint_id: str, body: EndpointBody) -> dict[str, Any]:
+ """Rename, change the key or the address, retype the models; the slots that use the
+ endpoint follow a new address or key."""
+ try:
+ return await conn.endpoints.update(endpoint_id, body.model_dump(exclude_none=True))
+ except ValueError as exc:
+ status = 404 if str(exc).startswith("no endpoint") else 400
+ raise HTTPException(status, str(exc)) from exc
+
+ @app.delete("/api/connections/endpoints/{endpoint_id}", dependencies=dep)
+ async def remove_endpoint(endpoint_id: str) -> dict[str, Any]:
+ """The endpoint, its key and the hands, picture and clip slots that used it (back to
+ automatic); 409 while the chat model answers through it."""
+ try:
+ if not conn.endpoints.remove(endpoint_id):
+ raise HTTPException(404, "no such endpoint")
+ except ValueError as exc:
+ raise HTTPException(409, str(exc)) from exc
+ return {"ok": True}
+
+ @app.post("/api/connections/endpoints/{endpoint_id}/models", dependencies=dep)
+ async def refresh_endpoint_models(endpoint_id: str) -> dict[str, Any]:
+ """Ask the endpoint for its models again; the saved list stays when it does not answer."""
+ try:
+ return await conn.endpoints.refresh_models(endpoint_id)
+ except ValueError as exc:
+ raise HTTPException(404, str(exc)) from exc
+
# ------------------------------------------------------------------ providers (contract C11)
@app.get("/api/providers", dependencies=dep)
async def providers(
@@ -1133,13 +1193,6 @@ async def avatar_view() -> dict[str, Any]:
session under way if any."""
return svc.avatar.view()
- @app.post("/api/avatar/estimate", dependencies=dep)
- async def avatar_estimate() -> dict[str, Any]:
- try:
- return await svc.avatar.estimate()
- except StudioError as exc:
- raise _studio_http(exc) from exc
-
@app.post("/api/avatar/begin", dependencies=dep)
async def avatar_begin(body: AvatarBeginBody) -> dict[str, Any]:
"""A new session from a description: the card with the cost appears in the chat."""
diff --git a/nanomuse/server/connections.py b/nanomuse/server/connections.py
index 681e9aa9..a1efe562 100644
--- a/nanomuse/server/connections.py
+++ b/nanomuse/server/connections.py
@@ -1,10 +1,10 @@
"""Connections: the model, email, the browser and MCP servers, set up from the app.
-Muse's Connections screen is where you plug things in and take them out again. Here that
-means: non-secret settings go to ``/app-settings.json`` (layered over
+The Connections screen is where a person plugs things in and takes them out again. Here
+that means: non-secret settings go to ``/app-settings.json`` (layered over
``config.toml`` on every start, for the app and the CLI alike); secrets go straight into
the encrypted vault and are referred to as ``{{vault:NAME}}``. Nothing here is ever shown
-to the model — it only gets the tools that result.
+to the model; it only gets the tools that result.
"""
from __future__ import annotations
@@ -37,6 +37,7 @@
from nanomuse.logger import logger
from nanomuse.schema import Message
from nanomuse.search import WebSearchProvider
+from nanomuse.server.endpoints import Endpoints
from nanomuse.server.failures import describe_failure
from nanomuse.tools import (
Calendar,
@@ -262,6 +263,49 @@ def normalize_base_url(url: str) -> str:
return url
+async def fetch_models(base_url: str, key: str = "") -> tuple[list[str], str]:
+ """The model ids an OpenAI-compatible endpoint lists (``GET /models``, then ``/v1/models``
+ when the address names no version), sorted; with an empty list, one word on why
+ (``404``, ``HTTP 401``, ``ConnectError``, ``empty list``). Never raises."""
+ headers = {"Authorization": f"Bearer {key}"} if key else {}
+ candidates = [f"{base_url}/models"]
+ if not re.search(r"/v\d+$", base_url):
+ candidates.append(f"{base_url}/v1/models")
+ error = ""
+ try:
+ import httpx
+
+ async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as client:
+ for url in candidates:
+ try:
+ r = await client.get(url, headers=headers)
+ except httpx.HTTPError as exc:
+ error = f"{type(exc).__name__}"
+ continue
+ if r.status_code == 404:
+ error = "404"
+ continue
+ if r.status_code >= 400:
+ error = f"HTTP {r.status_code}"
+ break
+ data = r.json()
+ rows = data.get("data") if isinstance(data, dict) else data
+ ids = sorted(
+ {
+ str(row.get("id") if isinstance(row, dict) else row)
+ for row in (rows or [])
+ if (row.get("id") if isinstance(row, dict) else row)
+ }
+ )
+ if ids:
+ return ids, ""
+ error = "empty list"
+ break
+ except Exception as exc: # noqa: BLE001
+ error = f"{type(exc).__name__}"
+ return [], error
+
+
PROXY_SCHEMES = ("http", "https", "socks5", "socks5h")
@@ -345,6 +389,13 @@ def __init__(self, svc: MuseService):
self._toml_sources = [
c for c in svc.settings.connectors.contacts.sources if c.name not in app_sources
]
+ # custom OpenAI-compatible endpoints, several at once (#294); the chat slot of before
+ # on an unlisted host becomes endpoint number one on the first start with the list
+ self.endpoints = Endpoints(self)
+ try:
+ self.endpoints.migrate()
+ except (OSError, ValueError, VaultError) as exc:
+ logger.warning("the endpoint list could not be set up: {}", exc)
# ------------------------------------------------------------------ helpers
@property
@@ -434,8 +485,12 @@ def view(self) -> dict[str, Any]:
and getattr(self.svc, "hub", None) is not None
and s.llm.base_url.rstrip("/") == model_url(self.svc.hub.cloud.base_url)
),
+ # the endpoint of the list the slot points at (#294); "" for any other host
+ "endpoint": self.endpoints.id_for(s.llm.endpoint),
},
"providers": self._providers(),
+ # the custom OpenAI-compatible endpoints, several at once (#294): never a key
+ "endpoints": self.endpoints.view(),
"embeddings": self._embeddings_view(),
"search": self._search_view(),
"email": {
@@ -560,7 +615,16 @@ def _contacts_view(self) -> dict[str, Any]:
# ------------------------------------------------------------------ model
def set_llm(self, body: dict[str, Any]) -> dict[str, Any]:
+ """The chat model. ``endpoint`` names one of the list (#294): the slot takes its
+ address and its key by reference, and ``provider``, ``base_url`` and ``api_key`` in
+ the body are ignored; the other fields apply as always."""
llm = dict(self.data.get("llm") or {})
+ body = dict(body)
+ if body.get("endpoint"):
+ fields = self.endpoints.slot_fields(str(body["endpoint"]))
+ body.update({"provider": fields["provider"], "base_url": fields["base_url"]})
+ body["api_key"] = None
+ llm["api_key"] = fields["api_key"]
for key in ("provider", "model", "base_url", "tool_mode", "image_model", "video_model"):
if body.get(key) is not None:
llm[key] = str(body[key]).strip()
@@ -842,52 +906,38 @@ async def llm_models(self, body: dict[str, Any]) -> dict[str, Any]:
preset = PROVIDERS.get(preset_id) or {}
base_url = normalize_base_url(str(body.get("base_url") or preset.get("base_url") or ""))
catalogue: list[str] = list(preset.get("models") or [])
+ key = str(body.get("api_key") or "").strip()
+ endpoint = self.endpoints.get(str(body.get("endpoint") or ""))
+ if endpoint is not None:
+ # one of the endpoint list (#294): its address and its own key, and the models it
+ # was saved with when it does not answer now
+ base_url = str(endpoint["base_url"])
+ catalogue = [str(m) for m in endpoint.get("models") or []]
+ if not key:
+ key = self.vault.resolve(str(endpoint.get("api_key") or ""), strict=False)
if not base_url:
return {"models": catalogue, "source": "catalogue"}
- key = str(body.get("api_key") or "").strip()
if not key and normalize_base_url(self.settings.llm.base_url or "") == base_url:
key = self.vault.resolve(self.settings.llm.api_key or "", strict=False)
- headers = {"Authorization": f"Bearer {key}"} if key else {}
- candidates = [f"{base_url}/models"]
- if not re.search(r"/v\d+$", base_url):
- candidates.append(f"{base_url}/v1/models")
- error = ""
- try:
- import httpx
-
- async with httpx.AsyncClient(timeout=8.0, follow_redirects=True) as client:
- for url in candidates:
- try:
- r = await client.get(url, headers=headers)
- except httpx.HTTPError as exc:
- error = f"{type(exc).__name__}"
- continue
- if r.status_code == 404:
- error = "404"
- continue
- if r.status_code >= 400:
- error = f"HTTP {r.status_code}"
- break
- data = r.json()
- rows = data.get("data") if isinstance(data, dict) else data
- ids = sorted(
- {
- str(row.get("id") if isinstance(row, dict) else row)
- for row in (rows or [])
- if (row.get("id") if isinstance(row, dict) else row)
- }
- )
- if ids:
- return {
- "models": ids,
- "image_models": [i for i in ids if looks_like_image_model(i)],
- "video_models": [i for i in ids if looks_like_video_model(i)],
- "source": "live",
- }
- error = "empty list"
- break
- except Exception as exc: # noqa: BLE001
- error = f"{type(exc).__name__}"
+ if self.vault.has_placeholders(key):
+ key = ""
+ ids, error = await fetch_models(base_url, key)
+ if ids:
+ return {
+ "models": ids,
+ "image_models": [i for i in ids if looks_like_image_model(i)],
+ "video_models": [i for i in ids if looks_like_video_model(i)],
+ "source": "live",
+ }
+ if endpoint is not None:
+ # the saved list serves the picture and clip pickers too
+ return {
+ "models": catalogue,
+ "image_models": [i for i in catalogue if looks_like_image_model(i)],
+ "video_models": [i for i in catalogue if looks_like_video_model(i)],
+ "source": "catalogue",
+ "error": error,
+ }
return {"models": catalogue, "source": "catalogue", "error": error}
async def test_llm(self) -> dict[str, Any]:
@@ -1392,6 +1442,8 @@ def _gui_view(self) -> dict[str, Any]:
"cloud": self.svc.app.llm_is_cloud(),
"base_url": gui.base_url or "",
"key_source": self._key_source(gui.api_key),
+ # the endpoint of the list the slot points at (#294); "" for any other host
+ "endpoint": self.endpoints.id_for(gui.endpoint) if gui.model else "",
"max_steps": gui.max_steps,
"phone": self.svc.phone.status(),
}
@@ -1405,6 +1457,13 @@ def set_gui(self, body: dict[str, Any]) -> dict[str, Any]:
model; a catalogue id brings its endpoint along and must have models that see.
"""
gui = dict(self.data.get("gui") or {})
+ body = dict(body)
+ if body.get("endpoint"):
+ # one of the endpoint list (#294): its address, its key by reference
+ fields = self.endpoints.slot_fields(str(body["endpoint"]))
+ body.update({"provider": fields["provider"], "base_url": fields["base_url"]})
+ body["api_key"] = None
+ gui["api_key"] = fields["api_key"]
if body.get("enabled") is not None:
gui["enabled"] = bool(body["enabled"])
for key in ("provider", "model", "base_url"):
@@ -1483,6 +1542,8 @@ def media_view(self, slot: str) -> dict[str, Any]:
"model": media.model,
"base_url": media.base_url or "",
"key_source": self._key_source(media.api_key),
+ # the endpoint of the list the slot points at (#294); "" for any other host
+ "endpoint": self.endpoints.id_for(media.endpoint) if media.configured else "",
"configured": media.configured,
"from_app": bool(self.data.get(slot)),
"effective_provider": resolved["provider"] if resolved else "",
@@ -1500,6 +1561,14 @@ def set_media(self, slot: str, body: dict[str, Any]) -> dict[str, Any]:
if slot not in _MEDIA_KEYS:
raise ValueError("slot must be 'image' or 'video'")
media = dict(self.data.get(slot) or {})
+ body = dict(body)
+ if body.get("endpoint"):
+ # one of the endpoint list (#294): its address, its key by reference
+ fields = self.endpoints.slot_fields(str(body["endpoint"]))
+ body.update({"provider": fields["provider"], "base_url": fields["base_url"]})
+ body["api_key"] = fields["api_key"] or None
+ if not fields["api_key"]:
+ media["api_key"] = ""
for key in ("provider", "model", "base_url"):
if body.get(key) is not None:
media[key] = str(body[key]).strip()
@@ -1702,4 +1771,5 @@ def set_onboarded(self, done: bool = True) -> None:
"PROVIDERS",
"VIDEO_KEY",
"Connections",
+ "fetch_models",
]
diff --git a/nanomuse/server/endpoints.py b/nanomuse/server/endpoints.py
new file mode 100644
index 00000000..5ccf8637
--- /dev/null
+++ b/nanomuse/server/endpoints.py
@@ -0,0 +1,374 @@
+"""Custom OpenAI-compatible endpoints, several at once (#294).
+
+The four model slots (``[llm]``, ``[gui]``, ``[image]``, ``[video]``) each hold one provider,
+one address and one key. Before this module the web console offered a single *Other
+OpenAI-compatible endpoint* row, so a second gateway could only replace the first. Now the
+app keeps a list of endpoints in ``/app-settings.json`` under ``endpoints``: each
+one has a stable id, a name the person typed (the host name by default), its base URL, its
+key as a vault reference, and the models it serves (the endpoint's own ``/models`` when it
+answers, else the ids the person typed). A slot that should use one is set from it
+(``PUT /api/connections/llm {"endpoint": id, "model": ...}`` and the same for ``gui``,
+``image`` and ``video``), so the console never handles a vault name.
+
+On the first start with this module the chat slot is migrated: when it points at a host the
+catalogue does not list, that host becomes endpoint number one, its key copied to the
+endpoint's own vault entry, and the slot keeps working as it did, without a question asked.
+
+Nothing here is shown to the model, and no key ever leaves the vault: the list carries
+``{{vault:ENDPOINT_