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.

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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 Library: pages with live previews

-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 First run: meet your nanoMuse

-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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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)。三つの道

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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。

Visitors

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). 세 가지

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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.

Visitors

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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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。

Visitors

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

wsvn53 +PovedaAqui hobostay aliabbas6622 -PovedaAqui +Ichiro786 +cypggs jiangkaiqi2005

@@ -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。

Visitors

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 第一次打开:认识你的 nanoMuse

-数据目录是全新的时候,应用打开的是设置流程,不是聊天。先用三句话说它是什么——替你做事;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__API_KEY}}`` references and the view says only where a key is. +""" + +from __future__ import annotations + +import re +import secrets +import time +from typing import TYPE_CHECKING, Any +from urllib.parse import urlsplit + +from nanomuse.cloud import model_url +from nanomuse.logger import logger + +if TYPE_CHECKING: + from nanomuse.server.connections import Connections + +#: the slots an endpoint can serve, in the order of the Models contract +SLOTS: tuple[str, ...] = ("chat", "hands", "image", "video") +#: how many model ids a person may type for one endpoint, and how long each may be +MAX_TYPED_MODELS = 40 +MAX_MODEL_ID = 120 +#: the app settings key the list lives under +DATA_KEY = "endpoints" + + +def key_name(endpoint_id: str) -> str: + """The vault entry an endpoint's key is kept under: ``ENDPOINT__API_KEY``.""" + stem = re.sub(r"[^A-Z0-9]+", "_", endpoint_id.upper()).strip("_") or "ENDPOINT" + return f"ENDPOINT_{stem}_API_KEY" + + +def typed_model_ids(value: Any) -> list[str]: + """The model ids a person typed (a comma- or newline-separated field, or a list): + trimmed, without duplicates, at most 40 of up to 120 characters each.""" + if isinstance(value, list): + raw = [str(v) for v in value] + elif isinstance(value, str): + raw = re.split(r"[,\n]", value) + else: + raw = [] + out: list[str] = [] + for piece in raw: + model_id = piece.strip() + if not model_id or len(model_id) > MAX_MODEL_ID or model_id in out: + continue + out.append(model_id) + if len(out) == MAX_TYPED_MODELS: + break + return out + + +def host_label(base_url: str) -> str: + """The name an endpoint gets when the person types none: its host, with the port when + the address names one (two local servers are then told apart).""" + parts = urlsplit(base_url) + host = parts.hostname or base_url + return f"{host}:{parts.port}" if parts.port else host + + +class Endpoints: + """The list behind *Add endpoint*: owned by :class:`Connections`, which holds the + settings file, the vault and the slot setters.""" + + def __init__(self, conn: Connections): + self.conn = conn + + # ------------------------------------------------------------------ the list + def rows(self) -> list[dict[str, Any]]: + rows = self.conn.data.get(DATA_KEY) + return ( + [r for r in rows if isinstance(r, dict) and r.get("id")] + if isinstance(rows, list) + else [] + ) + + def get(self, endpoint_id: str) -> dict[str, Any] | None: + for row in self.rows(): + if row.get("id") == endpoint_id: + return row + return None + + def _write(self, rows: list[dict[str, Any]]) -> None: + self.conn.data[DATA_KEY] = rows + self.conn._save() + + def _new_id(self) -> str: + taken = {r["id"] for r in self.rows()} + while True: + candidate = "ep-" + secrets.token_hex(4) + if candidate not in taken: + return candidate + + @staticmethod + def _same(a: str | None, b: str | None) -> bool: + return bool(a) and bool(b) and str(a).rstrip("/") == str(b).rstrip("/") + + def id_for(self, base_url: str | None) -> str: + """The endpoint whose address a slot points at; "" for any other host.""" + for row in self.rows(): + if self._same(row.get("base_url"), base_url): + return str(row["id"]) + return "" + + def slots_of(self, endpoint_id: str) -> list[str]: + """The slots that use the endpoint today, by address.""" + row = self.get(endpoint_id) + if row is None: + return [] + s = self.conn.settings + used: list[str] = [] + if self._same(s.llm.endpoint, row["base_url"]): + used.append("chat") + if s.gui.model and self._same(s.gui.endpoint, row["base_url"]): + used.append("hands") + for slot in ("image", "video"): + media = getattr(s, slot) + if media.configured and self._same(media.endpoint, row["base_url"]): + used.append(slot) + return used + + def view(self) -> list[dict[str, Any]]: + """What the app shows: never a key, only where it is.""" + return [ + { + "id": row["id"], + "label": row.get("label") or host_label(str(row.get("base_url") or "")), + "base_url": row.get("base_url") or "", + "key_source": self.conn._key_source(str(row.get("api_key") or "")), + "models": [str(m) for m in row.get("models") or []], + "slots": self.slots_of(str(row["id"])), + } + for row in self.rows() + ] + + def slot_fields(self, endpoint_id: str) -> dict[str, str]: + """What a slot takes from an endpoint: the plain OpenAI protocol, its address, its + key by reference (or no key).""" + row = self.get(endpoint_id) + if row is None: + raise ValueError(f"no endpoint {endpoint_id!r}; add it first") + return { + "provider": "openai", + "base_url": str(row.get("base_url") or ""), + "api_key": str(row.get("api_key") or ""), + } + + # ------------------------------------------------------------------ migration + def migrate(self) -> None: + """Once, on the first start with the list: the chat slot set from the app on a host + the catalogue does not list becomes endpoint number one. Its key is copied into the + endpoint's own vault entry and the slot is pointed at that entry, so changing the + key on the endpoint later changes it for the slot too. Nothing is asked.""" + if DATA_KEY in self.conn.data: + return + rows: list[dict[str, Any]] = [] + llm = dict(self.conn.data.get("llm") or {}) + base_url = str(llm.get("base_url") or "").rstrip("/") + if base_url and self._unlisted(base_url) and (llm.get("provider") or "openai") == "openai": + endpoint_id = self._new_id() + row: dict[str, Any] = { + "id": endpoint_id, + "label": host_label(base_url), + "base_url": base_url, + "api_key": "", + "models": [str(llm["model"])] if llm.get("model") else [], + "at": int(time.time()), + } + ref = str(llm.get("api_key") or "") + if ref: + value = self.conn.vault.resolve(ref, strict=False) + if value and not self.conn.vault.has_placeholders(value): + name = key_name(endpoint_id) + self.conn.vault.set(name, str(value)) + row["api_key"] = "{{vault:" + name + "}}" + llm["api_key"] = row["api_key"] + self.conn.data["llm"] = llm + self.conn.settings.llm.api_key = row["api_key"] + else: + # the slot names a vault entry nothing answers: the endpoint keeps the + # same reference, so both say "missing" and setting that entry mends both + row["api_key"] = ref + rows.append(row) + logger.info("the chat model's endpoint {} is now endpoint number one", base_url) + self._write(rows) + + def _unlisted(self, base_url: str) -> bool: + """A host that is neither a catalogue vendor, nor a preset of the app, nor the + account's relay: the kind the custom row used to hold.""" + from nanomuse.llm import catalogue + from nanomuse.server.connections import PROVIDERS + + if self._same(base_url, model_url(self.conn.settings.cloud.base_url)): + return False + if any(self._same(base_url, str(p.get("base_url") or "")) for p in PROVIDERS.values()): + return False + return catalogue.load().by_base_url(base_url) is None + + # ------------------------------------------------------------------ add / change / remove + def _check_url(self, base_url: str) -> str: + from nanomuse.server.connections import normalize_base_url + + url = normalize_base_url(base_url) + if not re.match(r"^https?://[^/\s]+", url): + raise ValueError("the address must start with http:// or https://") + if self._same(url, model_url(self.conn.settings.cloud.base_url)): + raise ValueError("that is nanoMuse Cloud; it is the account, not an endpoint") + return url + + async def _models_for(self, base_url: str, key: str, typed: list[str]) -> list[str]: + """The ids the person typed first, then what the endpoint lists; a ValueError when + there is neither (a slot cannot pick from an empty list).""" + from nanomuse.server.connections import fetch_models + + listed, _error = await fetch_models(base_url, key) + models = typed + [m for m in listed if m not in typed] + if not models: + raise ValueError( + "the endpoint did not list its models (GET /models); type the ids of the " + "models it serves, separated by commas" + ) + return models + + async def add(self, body: dict[str, Any]) -> dict[str, Any]: + """A new endpoint: ``base_url`` (required), ``label`` (the host when empty), + ``api_key`` (into the vault; empty for a server that wants none), ``models`` (typed + ids; the endpoint's own list is added after them).""" + base_url = self._check_url(str(body.get("base_url") or "")) + key = str(body.get("api_key") or "").strip() + if key and re.search(r"\s", key): + raise ValueError("a key has no spaces in it") + typed = typed_model_ids(body.get("models")) + models = await self._models_for(base_url, key, typed) + endpoint_id = self._new_id() + row: dict[str, Any] = { + "id": endpoint_id, + "label": str(body.get("label") or "").strip()[:80] or host_label(base_url), + "base_url": base_url, + "api_key": "", + "models": models, + "at": int(time.time()), + } + if key: + name = key_name(endpoint_id) + self.conn.vault.set(name, key) + row["api_key"] = "{{vault:" + name + "}}" + self._write([*self.rows(), row]) + self.conn._publish() + return self._one(endpoint_id) + + async def update(self, endpoint_id: str, body: dict[str, Any]) -> dict[str, Any]: + """Rename, change the key or the address, or retype the models. A key or address + that changed asks the endpoint for its models again (typed ids stay first); the + slots that use the endpoint follow it.""" + row = self.get(endpoint_id) + if row is None: + raise ValueError(f"no endpoint {endpoint_id!r}") + changed = dict(row) + if body.get("label") is not None: + changed["label"] = str(body["label"]).strip()[:80] or host_label(changed["base_url"]) + moved = False + if body.get("base_url") is not None: + url = self._check_url(str(body["base_url"])) + moved = not self._same(url, changed["base_url"]) + changed["base_url"] = url + api_key = body.get("api_key") + rekeyed = False + if api_key: + key = str(api_key).strip() + if re.search(r"\s", key): + raise ValueError("a key has no spaces in it") + name = key_name(endpoint_id) + self.conn.vault.set(name, key) + changed["api_key"] = "{{vault:" + name + "}}" + rekeyed = True + elif api_key == "": + self.conn.vault.delete(key_name(endpoint_id)) + changed["api_key"] = "" + rekeyed = True + typed = typed_model_ids(body.get("models")) if body.get("models") is not None else [] + if typed or moved or rekeyed: + key = self.conn.vault.resolve(changed["api_key"] or "", strict=False) + if self.conn.vault.has_placeholders(key): + key = "" + kept = typed or [str(m) for m in changed.get("models") or []] + try: + changed["models"] = await self._models_for(changed["base_url"], key, kept) + except ValueError: + if not kept: + raise + changed["models"] = kept + used = self.slots_of(endpoint_id) + self._write([changed if r.get("id") == endpoint_id else r for r in self.rows()]) + if moved or rekeyed: + for slot in used: + self._follow(slot, endpoint_id) + self.conn._publish() + return self._one(endpoint_id) + + def _follow(self, slot: str, endpoint_id: str) -> None: + """A slot that used the endpoint takes its new address or key.""" + if slot == "chat": + self.conn.set_llm({"endpoint": endpoint_id}) + elif slot == "hands": + self.conn.set_gui({"endpoint": endpoint_id}) + else: + self.conn.set_media(slot, {"endpoint": endpoint_id}) + + def remove(self, endpoint_id: str) -> bool: + """The endpoint, its key and the slots that pointed at it (back to automatic). The + chat slot cannot be left empty, so an endpoint it uses is refused until another + chat model is chosen.""" + row = self.get(endpoint_id) + if row is None: + return False + used = self.slots_of(endpoint_id) + if "chat" in used: + raise ValueError( + "the chat model answers through this endpoint; pick another chat model first" + ) + for slot in used: + if slot == "hands": + self.conn.set_gui({"provider": "", "model": "", "base_url": "", "api_key": ""}) + else: + self.conn.set_media( + slot, {"provider": "", "model": "", "base_url": "", "api_key": ""} + ) + self.conn.vault.delete(key_name(endpoint_id)) + self._write([r for r in self.rows() if r.get("id") != endpoint_id]) + self.conn._publish() + return True + + async def refresh_models(self, endpoint_id: str) -> dict[str, Any]: + """Ask the endpoint for its models again; the list stays when it does not answer.""" + row = self.get(endpoint_id) + if row is None: + raise ValueError(f"no endpoint {endpoint_id!r}") + from nanomuse.server.connections import fetch_models + + key = self.conn.vault.resolve(str(row.get("api_key") or ""), strict=False) + if self.conn.vault.has_placeholders(key): + key = "" + listed, error = await fetch_models(str(row["base_url"]), key) + if listed: + changed = {**row, "models": listed} + self._write([changed if r.get("id") == endpoint_id else r for r in self.rows()]) + self.conn._publish() + return {**self._one(endpoint_id), "source": "live" if listed else "kept", "error": error} + + def _one(self, endpoint_id: str) -> dict[str, Any]: + for entry in self.view(): + if entry["id"] == endpoint_id: + return entry + raise ValueError(f"no endpoint {endpoint_id!r}") + + +__all__ = ["DATA_KEY", "SLOTS", "Endpoints", "host_label", "key_name", "typed_model_ids"] diff --git a/nanomuse/server/static/assets/AccountScreen-COuDQPDa.js b/nanomuse/server/static/assets/AccountScreen-Bws27b-C.js similarity index 97% rename from nanomuse/server/static/assets/AccountScreen-COuDQPDa.js rename to nanomuse/server/static/assets/AccountScreen-Bws27b-C.js index 953f81b5..c24cba30 100644 --- a/nanomuse/server/static/assets/AccountScreen-COuDQPDa.js +++ b/nanomuse/server/static/assets/AccountScreen-Bws27b-C.js @@ -1 +1 @@ -import{E as e,J as t,K as n,Q as r,R as i,Y as a,b as o,f as s,k as c,q as l,x as u}from"./MuseHeader-DHtaUVEa.js";import{k as d,s as f,t as p}from"./StarNudge-viN7_tiM.js";import{n as m}from"./external-link-3iD-Bcbv.js";import{a as h,i as g}from"./Form-heEKMZyj.js";import{t as _}from"./clapperboard-vj4UL6pS.js";import{t as v}from"./database-zap-DrvRjpVx.js";import{t as y}from"./image-pZaHCatu.js";import{B as b,G as x,Q as S,W as C,at as w,ht as T,k as E,lt as D,q as O,st as k,t as A}from"./index-CrH9-E2E.js";import{t as j}from"./BackBar-B5YUZVK1.js";var M=t(`gift`,[[`path`,{d:`M12 7v14`,key:`1akyts`}],[`path`,{d:`M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8`,key:`1sqzm4`}],[`path`,{d:`M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5`,key:`kc0143`}],[`rect`,{x:`3`,y:`7`,width:`18`,height:`4`,rx:`1`,key:`1hberx`}]]),N=r(a(),1),P=o();function F(){let{state:e,toast:t,setTab:r}=s(),i=n(),a=e.hub?.account??null,[o,c]=(0,N.useState)(null),[l,d]=(0,N.useState)(null),[m,h]=(0,N.useState)(null),[g,_]=(0,N.useState)(!1),v=!!a?.signed_in,y=f(),b=async()=>{if(v){_(!0);try{let[e,t,n]=await Promise.all([u.cloudMe(),u.cloudSessions(),u.cloudEvents(40)]);c(e),d(t.sessions),h(n.events)}catch(e){t(e.message)}finally{_(!1)}}};return(0,N.useEffect)(()=>{b()},[v,a?.has_password]),(0,P.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,P.jsx)(j,{title:i(`Account`),description:i(v?`nanoMuse Cloud · one account for all your devices`:`Sign in to nanoMuse Cloud`)}),(0,P.jsx)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-4`,children:v?(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(L,{account:a,me:o}),o&&(0,P.jsx)(p,{moment:`signed_in`}),o&&(0,P.jsx)(R,{me:o,onChanged:()=>void b()}),o?.invite?.code&&(0,P.jsx)(B,{invite:o.invite}),o&&(0,P.jsx)(H,{me:o}),o&&(0,P.jsx)(z,{me:o}),(0,P.jsx)(W,{account:a,onChanged:()=>void b()}),(0,P.jsx)(G,{sessions:l,loading:g,onChanged:()=>void b()}),m&&m.length>0&&(0,P.jsx)(q,{events:m}),(0,P.jsx)(J,{account:a,onDone:()=>r(`chat`)})]}):(0,P.jsxs)(I,{children:[(0,P.jsx)(`p`,{className:`text-[13.5px] text-muted leading-relaxed`,children:i(`Free. One account for all your devices, with a model and ¥{allowance} of use to start; a code the first time, a password afterwards if you like.`,{allowance:(y.allowance_cny??10).toFixed(0)})}),(0,P.jsx)(A,{onSignedIn:()=>t(i(`Signed in to nanoMuse Cloud.`))})]})})]})}function I({title:t,children:n,tone:r}){return(0,P.jsxs)(`section`,{className:e(`rounded-3xl border p-4 space-y-3 shadow-sm`,r===`danger`?`border-rose-500/25 bg-rose-500/[0.04]`:`border-border/70 bg-surface`),children:[t&&(0,P.jsx)(`h2`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted`,children:t}),n]})}function L({account:e,me:t}){let r=n(),a=e?.hint??``,o=t?.account.created_at?new Date(t.account.created_at*1e3):null;return(0,P.jsxs)(`section`,{className:`relative overflow-hidden rounded-3xl border border-border/70 bg-surface p-5 shadow-sm`,children:[(0,P.jsx)(`div`,{className:`pointer-events-none absolute -right-16 -top-20 h-48 w-48 rounded-full bg-accent/15 blur-3xl`}),(0,P.jsxs)(`div`,{className:`flex items-center gap-4`,children:[(0,P.jsx)(`div`,{className:`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-sky-400 text-[22px] font-bold text-white shadow-md`,children:(a.replace(/[^a-z0-9]/gi,``).slice(0,1)||`n`).toUpperCase()}),(0,P.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,P.jsx)(`div`,{className:`truncate text-[18px] font-semibold tracking-tight`,children:a}),(0,P.jsxs)(`div`,{className:`mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12.5px] text-muted`,children:[(0,P.jsx)(`span`,{children:e?.channel===`phone`||e?.channel===`sms`?r(`Mobile number`):r(`E-mail`)}),o&&(0,P.jsxs)(`span`,{children:[`· `,r(`since {date}`,{date:o.toLocaleDateString(i())})]}),t?.account.member&&(0,P.jsxs)(`span`,{className:`inline-flex items-center gap-1 rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300`,children:[(0,P.jsx)(x,{size:11}),` `,r(`Unlimited`)]})]})]})]}),e?.account_id&&(0,P.jsxs)(`div`,{className:`mt-4 flex items-center justify-between gap-3 rounded-2xl bg-surface-2/70 px-3 py-2 text-[12px]`,children:[(0,P.jsx)(`span`,{className:`text-muted`,children:r(`Account id`)}),(0,P.jsx)(`code`,{className:`truncate font-mono text-[11.5px] text-fg/80`,children:e.account_id})]})]})}function R({me:t,onChanged:r}){let i=n(),a=t.spend,o=a.grant??a.daily_cap??0,s=a.grant===void 0?a.today:a.total,c=a.left??(a.unlimited?null:Math.max(0,o-s)),l=!a.unlimited&&o>0,u=l?Math.min(100,Math.round(s/o*100)):0,f=a.usd_cny??0,p=e=>f>0?` ≈ $${(e/f).toFixed(2)}`:``,m=l&&(c??0)<=0,h=l&&!m&&(a.warn??u>=80),g={left:c,grant:o,invite_url:t.invite?.url,invite_bonus_cny:a.invite_bonus_cny??t.invite?.bonus_cny,invitee_bonus_cny:a.invitee_bonus_cny??t.invite?.invitee_bonus_cny,own_key_docs:a.own_key_docs,guidance:a.guidance};return(0,P.jsxs)(I,{title:i(`Free allowance`),children:[(0,P.jsxs)(`div`,{className:`flex items-end justify-between gap-3`,children:[(0,P.jsxs)(`div`,{children:[(0,P.jsx)(`div`,{className:`text-[28px] font-bold tracking-tight leading-none`,children:l?(0,P.jsxs)(P.Fragment,{children:[`¥`,(c??0).toFixed(2),(0,P.jsxs)(`span`,{className:`text-[14px] font-medium text-muted`,children:[` `,i(`left of ¥{grant}`,{grant:o.toFixed(0)})]})]}):(0,P.jsxs)(P.Fragment,{children:[`¥`,s.toFixed(2)]})}),(0,P.jsx)(`div`,{className:`mt-1 text-[12.5px] text-muted`,children:l?i(`¥{spent} used{usd}; the allowance does not reset.`,{spent:s.toFixed(2),usd:p(s)}):i(`No limit on this account.`)})]}),(0,P.jsxs)(`div`,{className:`text-right text-[12.5px] text-muted`,children:[(0,P.jsx)(`div`,{children:i(`All time`)}),(0,P.jsxs)(`div`,{className:`text-[15px] font-semibold text-fg`,children:[`¥`,a.total.toFixed(2)]}),f>0&&(0,P.jsxs)(`div`,{className:`text-[11.5px]`,children:[`≈ $`,(a.total/f).toFixed(2)]})]})]}),l&&(0,P.jsx)(`div`,{className:`h-2 overflow-hidden rounded-full bg-surface-2`,children:(0,P.jsx)(`div`,{className:e(`h-full rounded-full transition-all`,u>=100?`bg-rose-500`:u>=80?`bg-amber-500`:`bg-accent`),style:{width:`${u}%`}})}),l&&(0,P.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[i(`¥{allowance} to start, +¥{invite} for each friend you invite, and +¥{invite} for them; after that, your own key keeps the model going.`,{allowance:(a.allowance_cny??10).toFixed(0),invite:(g.invite_bonus_cny??5).toFixed(0)}),` `,!m&&!h&&d(i,{signed_in:!0,channel:t.account.channel,region:t.account.region})]}),(m||h)&&(0,P.jsx)(E,{info:g,exhausted:m,onChanged:r})]})}function z({me:e}){let t=n(),{setTab:r}=s(),i=e.contribute??{on:!1,samples:0};return(0,P.jsx)(I,{title:t(`Data controls`),children:(0,P.jsxs)(`button`,{type:`button`,onClick:()=>{window.location.hash=`data`,r(`you`)},className:`flex w-full items-center gap-3 text-left`,children:[(0,P.jsx)(`span`,{className:`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-fg`,children:(0,P.jsx)(v,{size:18})}),(0,P.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,P.jsx)(`span`,{className:`block text-[14px] font-medium`,children:t(`Help improve nanoMuse's AI models`)}),(0,P.jsxs)(`span`,{className:`block text-[12.5px] text-muted`,children:[i.on?t(`On`):t(`Off`),i.samples>0&&` · ${t(`{n} turns kept so far`,{n:String(i.samples)})}`]})]}),(0,P.jsx)(`span`,{className:`text-[13px] font-medium text-accent`,children:t(`Open`)})]})})}function B({invite:t}){let r=n(),{toast:i}=s(),a=t.url||``,o=t.earned_cny??t.invites*t.bonus_cny,c=async e=>{try{await navigator.clipboard.writeText(e),i(r(`Copied`))}catch{i(e)}},l=async()=>{let e=a?r(`Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}: {link}`,{code:t.code,link:a}):r(`Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}`,{code:t.code}),n=navigator;if(n.share)try{await n.share({text:e});return}catch{}await c(e)};return(0,P.jsxs)(I,{title:r(`Invite a friend`),children:[(0,P.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:r(`Each new person who signs up with your code adds ¥{bonus} to your allowance, and ¥{bonus} to theirs. It never expires.`,{bonus:t.bonus_cny.toFixed(0)})}),(0,P.jsxs)(`div`,{className:`flex items-center justify-between gap-3 rounded-2xl bg-surface-2/70 px-3 py-2`,children:[(0,P.jsxs)(`div`,{children:[(0,P.jsx)(`div`,{className:`text-[11px] text-muted`,children:r(`Your code`)}),(0,P.jsx)(`code`,{className:`font-mono text-[18px] font-semibold tracking-[0.2em]`,children:t.code})]}),(0,P.jsxs)(`button`,{type:`button`,onClick:()=>void c(t.code),className:e(h,`inline-flex items-center gap-1.5`),children:[(0,P.jsx)(T,{size:14}),` `,r(`Copy`)]})]}),(0,P.jsxs)(`div`,{className:`flex gap-2`,children:[(0,P.jsxs)(`button`,{type:`button`,onClick:()=>void l(),className:e(g,`inline-flex flex-1 items-center justify-center gap-1.5 py-2.5`),children:[(0,P.jsx)(O,{size:14}),` `,r(a?`Share the link`:`Share the code`)]}),a?(0,P.jsxs)(`button`,{type:`button`,onClick:()=>void c(a),className:e(h,`inline-flex items-center gap-1.5`),children:[(0,P.jsx)(M,{size:14}),` `,r(`Copy the link`)]}):null]}),(0,P.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[r(`{n} friends joined`,{n:String(t.invites)}),o>0&&(0,P.jsxs)(P.Fragment,{children:[` · `,r(`¥{amount} added by invitations`,{amount:o.toFixed(0)})]})]})]})}var V={chat:{label:`Chat`,icon:(0,P.jsx)(w,{size:15}),tone:`bg-accent`},image:{label:`Pictures`,icon:(0,P.jsx)(y,{size:15}),tone:`bg-violet-500`},video:{label:`Clips`,icon:(0,P.jsx)(_,{size:15}),tone:`bg-pink-500`},realtime:{label:`Calls`,icon:(0,P.jsx)(S,{size:15}),tone:`bg-emerald-500`}};function H({me:t}){let r=n(),[i,a]=(0,N.useState)(`today`),o=i===`today`?t.usage.today.by_kind:t.usage.total.by_kind,s=t.usage.kinds.length?t.usage.kinds:[`chat`,`image`,`video`,`realtime`],c=new Map(o.map(e=>[e.kind,e])),l=o.reduce((e,t)=>e+t.cost_cny,0),u=t.usage.total.by_model.slice(0,8);return(0,P.jsxs)(I,{title:r(`Usage`),children:[(0,P.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,P.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:r(`Every kind of use is counted, not just the chat model.`)}),(0,P.jsx)(`div`,{className:`flex rounded-full bg-surface-2/80 p-0.5 text-[12px] font-medium`,children:[`today`,`total`].map(t=>(0,P.jsx)(`button`,{type:`button`,onClick:()=>a(t),className:e(`rounded-full px-2.5 py-1 transition`,i===t?`bg-surface shadow-sm`:`text-muted`),children:r(t===`today`?`Today`:`All time`)},t))})]}),l>0&&(0,P.jsx)(`div`,{className:`flex h-2.5 overflow-hidden rounded-full bg-surface-2`,children:s.map(t=>{let n=c.get(t);return!n||n.cost_cny<=0?null:(0,P.jsx)(`div`,{className:e(`h-full`,V[t]?.tone??`bg-fg/40`),style:{width:`${n.cost_cny/l*100}%`},title:r(V[t]?.label??t)},t)})}),(0,P.jsx)(`ul`,{className:`grid grid-cols-2 gap-2`,children:s.map(t=>{let n=c.get(t),i=V[t];return(0,P.jsxs)(`li`,{className:`rounded-2xl bg-surface-2/60 p-3`,children:[(0,P.jsxs)(`div`,{className:`flex items-center gap-1.5 text-[12.5px] text-muted`,children:[(0,P.jsx)(`span`,{className:e(`flex h-5 w-5 items-center justify-center rounded-md text-white`,i?.tone??`bg-fg/40`),children:i?.icon}),r(i?.label??t)]}),(0,P.jsxs)(`div`,{className:`mt-1.5 text-[17px] font-semibold tracking-tight`,children:[`¥`,(n?.cost_cny??0).toFixed(2)]}),(0,P.jsx)(`div`,{className:`text-[11.5px] text-muted`,children:n?t===`realtime`?r(`{n} answers · {tokens} tokens`,{n:n.requests,tokens:U(n.prompt_tokens+n.completion_tokens)}):t===`chat`?r(`{n} requests · {tokens} tokens`,{n:n.requests,tokens:U(n.prompt_tokens+n.completion_tokens)}):r(`{n} requests`,{n:n.requests}):r(`Nothing yet`)})]},t)})}),u.length>0&&(0,P.jsxs)(`div`,{children:[(0,P.jsx)(`div`,{className:`mb-1.5 text-[12px] font-medium text-muted`,children:r(`By model, all time`)}),(0,P.jsx)(`ul`,{className:`divide-y divide-border/60 rounded-2xl border border-border/60`,children:u.map(t=>(0,P.jsxs)(`li`,{className:`flex items-center gap-2 px-3 py-2 text-[12.5px]`,children:[(0,P.jsx)(`span`,{className:e(`h-2 w-2 shrink-0 rounded-full`,V[t.kind]?.tone??`bg-fg/40`)}),(0,P.jsx)(`span`,{className:`min-w-0 flex-1 truncate font-mono text-[12px]`,children:t.model}),(0,P.jsx)(`span`,{className:`text-muted`,children:U(t.prompt_tokens+t.completion_tokens)}),(0,P.jsxs)(`span`,{className:`w-16 text-right font-medium`,children:[`¥`,t.cost_cny.toFixed(2)]})]},`${t.model}-${t.kind}`))})]})]})}function U(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(e>=1e5?0:1)}k`:String(e)}function W({account:t,onChanged:r}){let i=n(),{toast:a}=s(),o=!!t?.has_password,[c,d]=(0,N.useState)(!1),[f,p]=(0,N.useState)(``),[h,g]=(0,N.useState)(``),[_,v]=(0,N.useState)(``),[y,b]=(0,N.useState)(!1),[x,S]=(0,N.useState)(null),C=async(e=!1)=>{if(!(!e&&(h.length<8||h!==_))){b(!0),S(null);try{await u.cloudPassword(e?``:h,o?f:void 0),a(i(e?`Password removed.`:o?`Password changed.`:`Password set.`)),d(!1),p(``),g(``),v(``),r()}catch(e){S(i(e.message))}finally{b(!1)}}};return(0,P.jsxs)(I,{title:i(`Password`),children:[(0,P.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,P.jsx)(`span`,{className:e(`flex h-9 w-9 items-center justify-center rounded-xl`,o?`bg-emerald-500/12 text-emerald-600 dark:text-emerald-300`:`bg-surface-2 text-muted`),children:(0,P.jsx)(D,{size:17})}),(0,P.jsxs)(`div`,{className:`min-w-0 flex-1 text-[13.5px]`,children:[(0,P.jsx)(`div`,{className:`font-medium`,children:i(o?`A password is set`:`No password yet`)}),(0,P.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:i(o?`Sign in with it on a new device, or with a code as before.`:`Optional. With one, a new device can sign in without waiting for a code.`)})]}),!c&&(0,P.jsx)(`button`,{type:`button`,onClick:()=>d(!0),className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`shrink-0 px-3 py-2 text-[13px]`),children:i(o?`Change`:`Set`)})]}),c&&(0,P.jsxs)(`form`,{className:`space-y-2.5`,onSubmit:e=>{e.preventDefault(),C()},children:[o&&(0,P.jsx)(`input`,{type:`password`,value:f,onChange:e=>p(e.target.value),placeholder:i(`Current password`),autoComplete:`current-password`,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,P.jsx)(`input`,{type:`password`,value:h,onChange:e=>g(e.target.value),placeholder:i(`New password (8 characters or more)`),autoComplete:`new-password`,autoFocus:!0,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,P.jsx)(`input`,{type:`password`,value:_,onChange:e=>v(e.target.value),placeholder:i(`Once more`),autoComplete:`new-password`,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),_&&h!==_&&(0,P.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:i(`The two do not match.`)}),x&&(0,P.jsx)(`p`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:x}),(0,P.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,P.jsxs)(`button`,{type:`submit`,disabled:y||h.length<8||h!==_||o&&!f,className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1`),children:[y?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(m,{size:15}),` `,i(o?`Change password`:`Set password`)]}),o&&(0,P.jsx)(`button`,{type:`button`,disabled:y||!f,onClick:()=>void C(!0),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:i(`Remove`)}),(0,P.jsx)(`button`,{type:`button`,onClick:()=>d(!1),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:i(`Cancel`)})]})]})]})}function G({sessions:e,loading:t,onChanged:r}){let a=n(),{toast:o}=s(),[d,f]=(0,N.useState)(null),p=async e=>{if(window.confirm(a(`Sign out {device}? It will need to sign in again.`,{device:e.device||e.prefix}))){f(e.prefix);try{await u.cloudRevokeSession(e.prefix),r()}catch(e){o(e.message)}finally{f(null)}}};return(0,P.jsx)(I,{title:a(`Signed in on`),children:e?(0,P.jsx)(`ul`,{className:`divide-y divide-border/60`,children:e.map(e=>(0,P.jsxs)(`li`,{className:`flex items-center gap-3 py-2.5`,children:[(0,P.jsx)(`span`,{className:`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-fg/70`,children:(0,P.jsx)(C,{size:17})}),(0,P.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,P.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px] font-medium`,children:[(0,P.jsx)(`span`,{className:`truncate`,children:e.device||a(`Unnamed device`)}),e.current&&(0,P.jsx)(`span`,{className:`rounded-full bg-accent/12 px-2 py-0.5 text-[10.5px] font-semibold text-accent`,children:a(`This one`)})]}),(0,P.jsxs)(`div`,{className:`truncate text-[12px] text-muted`,children:[e.via===`password`?a(`password`):a(`code`),` · `,a(`since {date}`,{date:new Date(e.created_at*1e3).toLocaleDateString(i())}),e.last_used_at?` · ${a(`used {when}`,{when:c(e.last_used_at)})}`:``]})]}),!e.current&&(0,P.jsx)(`button`,{type:`button`,disabled:d!==null,onClick:()=>void p(e),className:`rounded-full px-3 py-1.5 text-[12.5px] font-medium text-rose-600 hover:bg-rose-500/10 dark:text-rose-300`,children:d===e.prefix?(0,P.jsx)(l,{size:14,className:`animate-spin`}):a(`Sign out`)})]},e.prefix))}):(0,P.jsx)(`div`,{className:`flex justify-center py-3 text-muted`,children:t?(0,P.jsx)(l,{size:18,className:`animate-spin`}):null})})}var K={"account.created":`Account created`,"sign_in.code":`Signed in with a code`,"sign_in.password":`Signed in with the password`,"sign_in.failed":`A sign-in attempt failed`,sign_out:`Signed out`,"sign_out.all":`Signed out everywhere`,"password.set":`Password set`,"password.changed":`Password changed`,"password.cleared":`Password removed`,"budget.refused":`A request was refused: the allowance is used up`,"call.ended":`Call ended`,"upstream.error":`The model provider returned an error`,"credit.granted":`Allowance added`,"pool.set":`Allowance adjusted`,"invite.accepted":`Signed up with a friend's code`,"invite.used":`A friend signed up with your code`,"invite.unknown":`An invite code was not recognised`,"contribute.default":`Data controls: on for new accounts`,"contribute.on":`Data controls: help improve turned on`,"contribute.off":`Data controls: help improve turned off`,"contribute.deleted":`Data controls: kept conversations deleted`,"profile.put":`Agent's name and look saved`,"profile.clear":`Agent's name and look cleared`};function q({events:e}){let t=n(),[r,i]=(0,N.useState)(!1),a=r?e:e.slice(0,6);return(0,P.jsxs)(I,{title:t(`Recent activity`),children:[(0,P.jsx)(`ul`,{className:`space-y-2`,children:a.map((e,n)=>(0,P.jsxs)(`li`,{className:`flex gap-3 text-[12.5px]`,children:[(0,P.jsx)(`span`,{className:`w-[74px] shrink-0 text-muted`,children:c(e.ts)}),(0,P.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,P.jsx)(`span`,{className:`font-medium`,children:t(K[e.kind]??e.kind)}),e.detail&&(0,P.jsx)(`span`,{className:`ml-1.5 break-all text-muted`,children:e.detail})]})]},`${e.ts}-${n}`))}),e.length>6&&(0,P.jsx)(`button`,{type:`button`,onClick:()=>i(e=>!e),className:`text-[12.5px] font-medium text-accent`,children:r?t(`Show less`):t(`Show all {n}`,{n:e.length})})]})}function J({account:t,onDone:r}){let i=n(),{toast:a}=s(),[o,c]=(0,N.useState)(null),d=async e=>{let t=i(e===`here`?`Sign out of nanoMuse Cloud on this device? The hub and the Cloud model stop working here until you sign in again.`:e===`all`?`Sign out on every device, including this one?`:`Delete the account? The relay forgets your identifier, your devices and your usage. This cannot be undone.`);if(window.confirm(t)&&(e!==`delete`||window.confirm(i(`Really delete it?`)))){c(e);try{e===`here`?await u.cloudSignOut():e===`all`?await u.cloudSignOutAll(!0):await u.cloudDelete(),a(i(e===`delete`?`Account deleted.`:`Signed out.`)),r()}catch(e){a(e.message)}finally{c(null)}}};return(0,P.jsxs)(I,{tone:`danger`,children:[(0,P.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,P.jsxs)(`button`,{type:`button`,disabled:o!==null,onClick:()=>void d(`here`),className:e(h,`flex-1`),children:[o===`here`?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(k,{size:15}),` `,i(`Sign out`)]}),(0,P.jsxs)(`button`,{type:`button`,disabled:o!==null,onClick:()=>void d(`all`),className:e(h,`flex-1`),children:[o===`all`?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(C,{size:15}),` `,i(`Sign out everywhere`)]})]}),(0,P.jsxs)(`button`,{type:`button`,disabled:o!==null,onClick:()=>void d(`delete`),className:`flex w-full items-center justify-center gap-1.5 rounded-2xl py-2 text-[13px] font-medium text-rose-600 hover:bg-rose-500/10 dark:text-rose-300`,children:[o===`delete`?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(b,{size:15}),` `,i(`Delete the account`)]}),t?.is_model&&(0,P.jsx)(`p`,{className:`text-center text-[11.5px] text-muted`,children:i(`The Cloud model is in use; after signing out, pick another under Connections.`)})]})}export{F as AccountScreen}; \ No newline at end of file +import{E as e,J as t,K as n,Q as r,R as i,Y as a,b as o,f as s,k as c,q as l,x as u}from"./MuseHeader-RC--uR8o.js";import{k as d,s as f,t as p}from"./StarNudge-Df6LQyo9.js";import{n as m}from"./external-link-TvxP_dKX.js";import{a as h,i as g}from"./Form-H3s9oTHq.js";import{t as _}from"./clapperboard-CuoVHGrc.js";import{t as v}from"./database-zap-Bce5E3JJ.js";import{t as y}from"./image-nIygWCIp.js";import{B as b,G as x,Q as S,W as C,at as w,ht as T,k as E,lt as D,q as O,st as k,t as A}from"./index-tYqPtwPX.js";import{t as j}from"./BackBar-JEuC8Q_e.js";var M=t(`gift`,[[`path`,{d:`M12 7v14`,key:`1akyts`}],[`path`,{d:`M20 11v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8`,key:`1sqzm4`}],[`path`,{d:`M7.5 7a1 1 0 0 1 0-5A4.8 8 0 0 1 12 7a4.8 8 0 0 1 4.5-5 1 1 0 0 1 0 5`,key:`kc0143`}],[`rect`,{x:`3`,y:`7`,width:`18`,height:`4`,rx:`1`,key:`1hberx`}]]),N=r(a(),1),P=o();function F(){let{state:e,toast:t,setTab:r}=s(),i=n(),a=e.hub?.account??null,[o,c]=(0,N.useState)(null),[l,d]=(0,N.useState)(null),[m,h]=(0,N.useState)(null),[g,_]=(0,N.useState)(!1),v=!!a?.signed_in,y=f(),b=async()=>{if(v){_(!0);try{let[e,t,n]=await Promise.all([u.cloudMe(),u.cloudSessions(),u.cloudEvents(40)]);c(e),d(t.sessions),h(n.events)}catch(e){t(e.message)}finally{_(!1)}}};return(0,N.useEffect)(()=>{b()},[v,a?.has_password]),(0,P.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,P.jsx)(j,{title:i(`Account`),description:i(v?`nanoMuse Cloud · one account for all your devices`:`Sign in to nanoMuse Cloud`)}),(0,P.jsx)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-4`,children:v?(0,P.jsxs)(P.Fragment,{children:[(0,P.jsx)(L,{account:a,me:o}),o&&(0,P.jsx)(p,{moment:`signed_in`}),o&&(0,P.jsx)(R,{me:o,onChanged:()=>void b()}),o?.invite?.code&&(0,P.jsx)(B,{invite:o.invite}),o&&(0,P.jsx)(H,{me:o}),o&&(0,P.jsx)(z,{me:o}),(0,P.jsx)(W,{account:a,onChanged:()=>void b()}),(0,P.jsx)(G,{sessions:l,loading:g,onChanged:()=>void b()}),m&&m.length>0&&(0,P.jsx)(q,{events:m}),(0,P.jsx)(J,{account:a,onDone:()=>r(`chat`)})]}):(0,P.jsxs)(I,{children:[(0,P.jsx)(`p`,{className:`text-[13.5px] text-muted leading-relaxed`,children:i(`Free. One account for all your devices, with a model and ¥{allowance} of use to start; a code the first time, a password afterwards if you like.`,{allowance:(y.allowance_cny??10).toFixed(0)})}),(0,P.jsx)(A,{onSignedIn:()=>t(i(`Signed in to nanoMuse Cloud.`))})]})})]})}function I({title:t,children:n,tone:r}){return(0,P.jsxs)(`section`,{className:e(`rounded-3xl border p-4 space-y-3 shadow-sm`,r===`danger`?`border-rose-500/25 bg-rose-500/[0.04]`:`border-border/70 bg-surface`),children:[t&&(0,P.jsx)(`h2`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted`,children:t}),n]})}function L({account:e,me:t}){let r=n(),a=e?.hint??``,o=t?.account.created_at?new Date(t.account.created_at*1e3):null;return(0,P.jsxs)(`section`,{className:`relative overflow-hidden rounded-3xl border border-border/70 bg-surface p-5 shadow-sm`,children:[(0,P.jsx)(`div`,{className:`pointer-events-none absolute -right-16 -top-20 h-48 w-48 rounded-full bg-accent/15 blur-3xl`}),(0,P.jsxs)(`div`,{className:`flex items-center gap-4`,children:[(0,P.jsx)(`div`,{className:`flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-sky-400 text-[22px] font-bold text-white shadow-md`,children:(a.replace(/[^a-z0-9]/gi,``).slice(0,1)||`n`).toUpperCase()}),(0,P.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,P.jsx)(`div`,{className:`truncate text-[18px] font-semibold tracking-tight`,children:a}),(0,P.jsxs)(`div`,{className:`mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[12.5px] text-muted`,children:[(0,P.jsx)(`span`,{children:e?.channel===`phone`||e?.channel===`sms`?r(`Mobile number`):r(`E-mail`)}),o&&(0,P.jsxs)(`span`,{children:[`· `,r(`since {date}`,{date:o.toLocaleDateString(i())})]}),t?.account.member&&(0,P.jsxs)(`span`,{className:`inline-flex items-center gap-1 rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-300`,children:[(0,P.jsx)(x,{size:11}),` `,r(`Unlimited`)]})]})]})]}),e?.account_id&&(0,P.jsxs)(`div`,{className:`mt-4 flex items-center justify-between gap-3 rounded-2xl bg-surface-2/70 px-3 py-2 text-[12px]`,children:[(0,P.jsx)(`span`,{className:`text-muted`,children:r(`Account id`)}),(0,P.jsx)(`code`,{className:`truncate font-mono text-[11.5px] text-fg/80`,children:e.account_id})]})]})}function R({me:t,onChanged:r}){let i=n(),a=t.spend,o=a.grant??a.daily_cap??0,s=a.grant===void 0?a.today:a.total,c=a.left??(a.unlimited?null:Math.max(0,o-s)),l=!a.unlimited&&o>0,u=l?Math.min(100,Math.round(s/o*100)):0,f=a.usd_cny??0,p=e=>f>0?` ≈ $${(e/f).toFixed(2)}`:``,m=l&&(c??0)<=0,h=l&&!m&&(a.warn??u>=80),g={left:c,grant:o,invite_url:t.invite?.url,invite_bonus_cny:a.invite_bonus_cny??t.invite?.bonus_cny,invitee_bonus_cny:a.invitee_bonus_cny??t.invite?.invitee_bonus_cny,own_key_docs:a.own_key_docs,guidance:a.guidance};return(0,P.jsxs)(I,{title:i(`Free allowance`),children:[(0,P.jsxs)(`div`,{className:`flex items-end justify-between gap-3`,children:[(0,P.jsxs)(`div`,{children:[(0,P.jsx)(`div`,{className:`text-[28px] font-bold tracking-tight leading-none`,children:l?(0,P.jsxs)(P.Fragment,{children:[`¥`,(c??0).toFixed(2),(0,P.jsxs)(`span`,{className:`text-[14px] font-medium text-muted`,children:[` `,i(`left of ¥{grant}`,{grant:o.toFixed(0)})]})]}):(0,P.jsxs)(P.Fragment,{children:[`¥`,s.toFixed(2)]})}),(0,P.jsx)(`div`,{className:`mt-1 text-[12.5px] text-muted`,children:l?i(`¥{spent} used{usd}; the allowance does not reset.`,{spent:s.toFixed(2),usd:p(s)}):i(`No limit on this account.`)})]}),(0,P.jsxs)(`div`,{className:`text-right text-[12.5px] text-muted`,children:[(0,P.jsx)(`div`,{children:i(`All time`)}),(0,P.jsxs)(`div`,{className:`text-[15px] font-semibold text-fg`,children:[`¥`,a.total.toFixed(2)]}),f>0&&(0,P.jsxs)(`div`,{className:`text-[11.5px]`,children:[`≈ $`,(a.total/f).toFixed(2)]})]})]}),l&&(0,P.jsx)(`div`,{className:`h-2 overflow-hidden rounded-full bg-surface-2`,children:(0,P.jsx)(`div`,{className:e(`h-full rounded-full transition-all`,u>=100?`bg-rose-500`:u>=80?`bg-amber-500`:`bg-accent`),style:{width:`${u}%`}})}),l&&(0,P.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[i(`¥{allowance} to start, +¥{invite} for each friend you invite, and +¥{invite} for them; after that, your own key keeps the model going.`,{allowance:(a.allowance_cny??10).toFixed(0),invite:(g.invite_bonus_cny??5).toFixed(0)}),` `,!m&&!h&&d(i,{signed_in:!0,channel:t.account.channel,region:t.account.region})]}),(m||h)&&(0,P.jsx)(E,{info:g,exhausted:m,onChanged:r})]})}function z({me:e}){let t=n(),{setTab:r}=s(),i=e.contribute??{on:!1,samples:0};return(0,P.jsx)(I,{title:t(`Data controls`),children:(0,P.jsxs)(`button`,{type:`button`,onClick:()=>{window.location.hash=`data`,r(`you`)},className:`flex w-full items-center gap-3 text-left`,children:[(0,P.jsx)(`span`,{className:`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-fg`,children:(0,P.jsx)(v,{size:18})}),(0,P.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,P.jsx)(`span`,{className:`block text-[14px] font-medium`,children:t(`Help improve nanoMuse's AI models`)}),(0,P.jsxs)(`span`,{className:`block text-[12.5px] text-muted`,children:[i.on?t(`On`):t(`Off`),i.samples>0&&` · ${t(`{n} turns kept so far`,{n:String(i.samples)})}`]})]}),(0,P.jsx)(`span`,{className:`text-[13px] font-medium text-accent`,children:t(`Open`)})]})})}function B({invite:t}){let r=n(),{toast:i}=s(),a=t.url||``,o=t.earned_cny??t.invites*t.bonus_cny,c=async e=>{try{await navigator.clipboard.writeText(e),i(r(`Copied`))}catch{i(e)}},l=async()=>{let e=a?r(`Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}: {link}`,{code:t.code,link:a}):r(`Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}`,{code:t.code}),n=navigator;if(n.share)try{await n.share({text:e});return}catch{}await c(e)};return(0,P.jsxs)(I,{title:r(`Invite a friend`),children:[(0,P.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:r(`Each new person who signs up with your code adds ¥{bonus} to your allowance, and ¥{bonus} to theirs. It never expires.`,{bonus:t.bonus_cny.toFixed(0)})}),(0,P.jsxs)(`div`,{className:`flex items-center justify-between gap-3 rounded-2xl bg-surface-2/70 px-3 py-2`,children:[(0,P.jsxs)(`div`,{children:[(0,P.jsx)(`div`,{className:`text-[11px] text-muted`,children:r(`Your code`)}),(0,P.jsx)(`code`,{className:`font-mono text-[18px] font-semibold tracking-[0.2em]`,children:t.code})]}),(0,P.jsxs)(`button`,{type:`button`,onClick:()=>void c(t.code),className:e(h,`inline-flex items-center gap-1.5`),children:[(0,P.jsx)(T,{size:14}),` `,r(`Copy`)]})]}),(0,P.jsxs)(`div`,{className:`flex gap-2`,children:[(0,P.jsxs)(`button`,{type:`button`,onClick:()=>void l(),className:e(g,`inline-flex flex-1 items-center justify-center gap-1.5 py-2.5`),children:[(0,P.jsx)(O,{size:14}),` `,r(a?`Share the link`:`Share the code`)]}),a?(0,P.jsxs)(`button`,{type:`button`,onClick:()=>void c(a),className:e(h,`inline-flex items-center gap-1.5`),children:[(0,P.jsx)(M,{size:14}),` `,r(`Copy the link`)]}):null]}),(0,P.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[r(`{n} friends joined`,{n:String(t.invites)}),o>0&&(0,P.jsxs)(P.Fragment,{children:[` · `,r(`¥{amount} added by invitations`,{amount:o.toFixed(0)})]})]})]})}var V={chat:{label:`Chat`,icon:(0,P.jsx)(w,{size:15}),tone:`bg-accent`},image:{label:`Pictures`,icon:(0,P.jsx)(y,{size:15}),tone:`bg-violet-500`},video:{label:`Clips`,icon:(0,P.jsx)(_,{size:15}),tone:`bg-pink-500`},realtime:{label:`Calls`,icon:(0,P.jsx)(S,{size:15}),tone:`bg-emerald-500`}};function H({me:t}){let r=n(),[i,a]=(0,N.useState)(`today`),o=i===`today`?t.usage.today.by_kind:t.usage.total.by_kind,s=t.usage.kinds.length?t.usage.kinds:[`chat`,`image`,`video`,`realtime`],c=new Map(o.map(e=>[e.kind,e])),l=o.reduce((e,t)=>e+t.cost_cny,0),u=t.usage.total.by_model.slice(0,8);return(0,P.jsxs)(I,{title:r(`Usage`),children:[(0,P.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,P.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:r(`Every kind of use is counted, not just the chat model.`)}),(0,P.jsx)(`div`,{className:`flex rounded-full bg-surface-2/80 p-0.5 text-[12px] font-medium`,children:[`today`,`total`].map(t=>(0,P.jsx)(`button`,{type:`button`,onClick:()=>a(t),className:e(`rounded-full px-2.5 py-1 transition`,i===t?`bg-surface shadow-sm`:`text-muted`),children:r(t===`today`?`Today`:`All time`)},t))})]}),l>0&&(0,P.jsx)(`div`,{className:`flex h-2.5 overflow-hidden rounded-full bg-surface-2`,children:s.map(t=>{let n=c.get(t);return!n||n.cost_cny<=0?null:(0,P.jsx)(`div`,{className:e(`h-full`,V[t]?.tone??`bg-fg/40`),style:{width:`${n.cost_cny/l*100}%`},title:r(V[t]?.label??t)},t)})}),(0,P.jsx)(`ul`,{className:`grid grid-cols-2 gap-2`,children:s.map(t=>{let n=c.get(t),i=V[t];return(0,P.jsxs)(`li`,{className:`rounded-2xl bg-surface-2/60 p-3`,children:[(0,P.jsxs)(`div`,{className:`flex items-center gap-1.5 text-[12.5px] text-muted`,children:[(0,P.jsx)(`span`,{className:e(`flex h-5 w-5 items-center justify-center rounded-md text-white`,i?.tone??`bg-fg/40`),children:i?.icon}),r(i?.label??t)]}),(0,P.jsxs)(`div`,{className:`mt-1.5 text-[17px] font-semibold tracking-tight`,children:[`¥`,(n?.cost_cny??0).toFixed(2)]}),(0,P.jsx)(`div`,{className:`text-[11.5px] text-muted`,children:n?t===`realtime`?r(`{n} answers · {tokens} tokens`,{n:n.requests,tokens:U(n.prompt_tokens+n.completion_tokens)}):t===`chat`?r(`{n} requests · {tokens} tokens`,{n:n.requests,tokens:U(n.prompt_tokens+n.completion_tokens)}):r(`{n} requests`,{n:n.requests}):r(`Nothing yet`)})]},t)})}),u.length>0&&(0,P.jsxs)(`div`,{children:[(0,P.jsx)(`div`,{className:`mb-1.5 text-[12px] font-medium text-muted`,children:r(`By model, all time`)}),(0,P.jsx)(`ul`,{className:`divide-y divide-border/60 rounded-2xl border border-border/60`,children:u.map(t=>(0,P.jsxs)(`li`,{className:`flex items-center gap-2 px-3 py-2 text-[12.5px]`,children:[(0,P.jsx)(`span`,{className:e(`h-2 w-2 shrink-0 rounded-full`,V[t.kind]?.tone??`bg-fg/40`)}),(0,P.jsx)(`span`,{className:`min-w-0 flex-1 truncate font-mono text-[12px]`,children:t.model}),(0,P.jsx)(`span`,{className:`text-muted`,children:U(t.prompt_tokens+t.completion_tokens)}),(0,P.jsxs)(`span`,{className:`w-16 text-right font-medium`,children:[`¥`,t.cost_cny.toFixed(2)]})]},`${t.model}-${t.kind}`))})]})]})}function U(e){return e>=1e6?`${(e/1e6).toFixed(1)}M`:e>=1e3?`${(e/1e3).toFixed(e>=1e5?0:1)}k`:String(e)}function W({account:t,onChanged:r}){let i=n(),{toast:a}=s(),o=!!t?.has_password,[c,d]=(0,N.useState)(!1),[f,p]=(0,N.useState)(``),[h,g]=(0,N.useState)(``),[_,v]=(0,N.useState)(``),[y,b]=(0,N.useState)(!1),[x,S]=(0,N.useState)(null),C=async(e=!1)=>{if(!(!e&&(h.length<8||h!==_))){b(!0),S(null);try{await u.cloudPassword(e?``:h,o?f:void 0),a(i(e?`Password removed.`:o?`Password changed.`:`Password set.`)),d(!1),p(``),g(``),v(``),r()}catch(e){S(i(e.message))}finally{b(!1)}}};return(0,P.jsxs)(I,{title:i(`Password`),children:[(0,P.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,P.jsx)(`span`,{className:e(`flex h-9 w-9 items-center justify-center rounded-xl`,o?`bg-emerald-500/12 text-emerald-600 dark:text-emerald-300`:`bg-surface-2 text-muted`),children:(0,P.jsx)(D,{size:17})}),(0,P.jsxs)(`div`,{className:`min-w-0 flex-1 text-[13.5px]`,children:[(0,P.jsx)(`div`,{className:`font-medium`,children:i(o?`A password is set`:`No password yet`)}),(0,P.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:i(o?`Sign in with it on a new device, or with a code as before.`:`Optional. With one, a new device can sign in without waiting for a code.`)})]}),!c&&(0,P.jsx)(`button`,{type:`button`,onClick:()=>d(!0),className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`shrink-0 px-3 py-2 text-[13px]`),children:i(o?`Change`:`Set`)})]}),c&&(0,P.jsxs)(`form`,{className:`space-y-2.5`,onSubmit:e=>{e.preventDefault(),C()},children:[o&&(0,P.jsx)(`input`,{type:`password`,value:f,onChange:e=>p(e.target.value),placeholder:i(`Current password`),autoComplete:`current-password`,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,P.jsx)(`input`,{type:`password`,value:h,onChange:e=>g(e.target.value),placeholder:i(`New password (8 characters or more)`),autoComplete:`new-password`,autoFocus:!0,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,P.jsx)(`input`,{type:`password`,value:_,onChange:e=>v(e.target.value),placeholder:i(`Once more`),autoComplete:`new-password`,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),_&&h!==_&&(0,P.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:i(`The two do not match.`)}),x&&(0,P.jsx)(`p`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:x}),(0,P.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,P.jsxs)(`button`,{type:`submit`,disabled:y||h.length<8||h!==_||o&&!f,className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1`),children:[y?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(m,{size:15}),` `,i(o?`Change password`:`Set password`)]}),o&&(0,P.jsx)(`button`,{type:`button`,disabled:y||!f,onClick:()=>void C(!0),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:i(`Remove`)}),(0,P.jsx)(`button`,{type:`button`,onClick:()=>d(!1),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:i(`Cancel`)})]})]})]})}function G({sessions:e,loading:t,onChanged:r}){let a=n(),{toast:o}=s(),[d,f]=(0,N.useState)(null),p=async e=>{if(window.confirm(a(`Sign out {device}? It will need to sign in again.`,{device:e.device||e.prefix}))){f(e.prefix);try{await u.cloudRevokeSession(e.prefix),r()}catch(e){o(e.message)}finally{f(null)}}};return(0,P.jsx)(I,{title:a(`Signed in on`),children:e?(0,P.jsx)(`ul`,{className:`divide-y divide-border/60`,children:e.map(e=>(0,P.jsxs)(`li`,{className:`flex items-center gap-3 py-2.5`,children:[(0,P.jsx)(`span`,{className:`flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-fg/70`,children:(0,P.jsx)(C,{size:17})}),(0,P.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,P.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px] font-medium`,children:[(0,P.jsx)(`span`,{className:`truncate`,children:e.device||a(`Unnamed device`)}),e.current&&(0,P.jsx)(`span`,{className:`rounded-full bg-accent/12 px-2 py-0.5 text-[10.5px] font-semibold text-accent`,children:a(`This one`)})]}),(0,P.jsxs)(`div`,{className:`truncate text-[12px] text-muted`,children:[e.via===`password`?a(`password`):a(`code`),` · `,a(`since {date}`,{date:new Date(e.created_at*1e3).toLocaleDateString(i())}),e.last_used_at?` · ${a(`used {when}`,{when:c(e.last_used_at)})}`:``]})]}),!e.current&&(0,P.jsx)(`button`,{type:`button`,disabled:d!==null,onClick:()=>void p(e),className:`rounded-full px-3 py-1.5 text-[12.5px] font-medium text-rose-600 hover:bg-rose-500/10 dark:text-rose-300`,children:d===e.prefix?(0,P.jsx)(l,{size:14,className:`animate-spin`}):a(`Sign out`)})]},e.prefix))}):(0,P.jsx)(`div`,{className:`flex justify-center py-3 text-muted`,children:t?(0,P.jsx)(l,{size:18,className:`animate-spin`}):null})})}var K={"account.created":`Account created`,"sign_in.code":`Signed in with a code`,"sign_in.password":`Signed in with the password`,"sign_in.failed":`A sign-in attempt failed`,sign_out:`Signed out`,"sign_out.all":`Signed out everywhere`,"password.set":`Password set`,"password.changed":`Password changed`,"password.cleared":`Password removed`,"budget.refused":`A request was refused: the allowance is used up`,"call.ended":`Call ended`,"upstream.error":`The model provider returned an error`,"credit.granted":`Allowance added`,"pool.set":`Allowance adjusted`,"invite.accepted":`Signed up with a friend's code`,"invite.used":`A friend signed up with your code`,"invite.unknown":`An invite code was not recognised`,"contribute.default":`Data controls: on for new accounts`,"contribute.on":`Data controls: help improve turned on`,"contribute.off":`Data controls: help improve turned off`,"contribute.deleted":`Data controls: kept conversations deleted`,"profile.put":`Agent's name and look saved`,"profile.clear":`Agent's name and look cleared`};function q({events:e}){let t=n(),[r,i]=(0,N.useState)(!1),a=r?e:e.slice(0,6);return(0,P.jsxs)(I,{title:t(`Recent activity`),children:[(0,P.jsx)(`ul`,{className:`space-y-2`,children:a.map((e,n)=>(0,P.jsxs)(`li`,{className:`flex gap-3 text-[12.5px]`,children:[(0,P.jsx)(`span`,{className:`w-[74px] shrink-0 text-muted`,children:c(e.ts)}),(0,P.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,P.jsx)(`span`,{className:`font-medium`,children:t(K[e.kind]??e.kind)}),e.detail&&(0,P.jsx)(`span`,{className:`ml-1.5 break-all text-muted`,children:e.detail})]})]},`${e.ts}-${n}`))}),e.length>6&&(0,P.jsx)(`button`,{type:`button`,onClick:()=>i(e=>!e),className:`text-[12.5px] font-medium text-accent`,children:r?t(`Show less`):t(`Show all {n}`,{n:e.length})})]})}function J({account:t,onDone:r}){let i=n(),{toast:a}=s(),[o,c]=(0,N.useState)(null),d=async e=>{let t=i(e===`here`?`Sign out of nanoMuse Cloud on this device? The hub and the Cloud model stop working here until you sign in again.`:e===`all`?`Sign out on every device, including this one?`:`Delete the account? The relay forgets your identifier, your devices and your usage. This cannot be undone.`);if(window.confirm(t)&&(e!==`delete`||window.confirm(i(`Really delete it?`)))){c(e);try{e===`here`?await u.cloudSignOut():e===`all`?await u.cloudSignOutAll(!0):await u.cloudDelete(),a(i(e===`delete`?`Account deleted.`:`Signed out.`)),r()}catch(e){a(e.message)}finally{c(null)}}};return(0,P.jsxs)(I,{tone:`danger`,children:[(0,P.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,P.jsxs)(`button`,{type:`button`,disabled:o!==null,onClick:()=>void d(`here`),className:e(h,`flex-1`),children:[o===`here`?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(k,{size:15}),` `,i(`Sign out`)]}),(0,P.jsxs)(`button`,{type:`button`,disabled:o!==null,onClick:()=>void d(`all`),className:e(h,`flex-1`),children:[o===`all`?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(C,{size:15}),` `,i(`Sign out everywhere`)]})]}),(0,P.jsxs)(`button`,{type:`button`,disabled:o!==null,onClick:()=>void d(`delete`),className:`flex w-full items-center justify-center gap-1.5 rounded-2xl py-2 text-[13px] font-medium text-rose-600 hover:bg-rose-500/10 dark:text-rose-300`,children:[o===`delete`?(0,P.jsx)(l,{size:15,className:`animate-spin`}):(0,P.jsx)(b,{size:15}),` `,i(`Delete the account`)]}),t?.is_model&&(0,P.jsx)(`p`,{className:`text-center text-[11.5px] text-muted`,children:i(`The Cloud model is in use; after signing out, pick another under Connections.`)})]})}export{F as AccountScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/AvatarPicker-zOz-S1nk.js b/nanomuse/server/static/assets/AvatarPicker-BCihUURa.js similarity index 95% rename from nanomuse/server/static/assets/AvatarPicker-zOz-S1nk.js rename to nanomuse/server/static/assets/AvatarPicker-BCihUURa.js index 71adee3d..e8d25717 100644 --- a/nanomuse/server/static/assets/AvatarPicker-zOz-S1nk.js +++ b/nanomuse/server/static/assets/AvatarPicker-BCihUURa.js @@ -1 +1 @@ -import{E as e,K as t,a as n,b as r,c as i,s as a,u as o}from"./MuseHeader-DHtaUVEa.js";import{n as s}from"./external-link-3iD-Bcbv.js";import{U as c}from"./index-CrH9-E2E.js";var l=r(),u=[`✨`,`🌙`,`🪐`,`🌿`,`🔥`,`🌊`,`🦉`,`🦊`,`🐙`,`🎯`,`🧭`,`💎`,`🍀`,`🎈`,`🤖`,`🧠`],d=[`#0064d4`,`#7c3aed`,`#0891b2`,`#059669`,`#d97706`,`#dc2626`,`#db2777`,`#4b5563`],f=[{id:`muse`,label:`3D toy (Muse)`},{id:`flat`,label:`Flat`},{id:`clay`,label:`3D clay`},{id:`watercolor`,label:`Watercolour`},{id:`pixel`,label:`Pixel`},{id:`line`,label:`Line`},{id:`sticker`,label:`Sticker`}];function p({value:r,onChange:f,onStudio:p}){let m=t(),h=r.avatar===``,g=!h&&!i(r.avatar)?r.avatar:null,_=`ring-[2.5px] ring-accent ring-offset-2 ring-offset-bg`,v=`relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded-full transition`;return(0,l.jsxs)(`div`,{className:`space-y-3`,children:[(0,l.jsxs)(`div`,{className:`flex flex-wrap gap-3`,children:[(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`The dragon`),"aria-pressed":r.avatar===n,onClick:()=>f({...r,avatar:n}),className:e(v,`bg-[#f1efeb]`,r.avatar===`dragon`?_:`opacity-90 hover:opacity-100`),children:(0,l.jsx)(`img`,{src:a(r.avatar===`dragon`?`happy`:`idle`),alt:``,draggable:!1,className:`h-full w-full object-cover`})}),g&&(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`The face drawn for you`),"aria-pressed":!0,className:e(v,`bg-surface-2`,_),children:(0,l.jsx)(`img`,{src:o(g,`happy`),alt:``,draggable:!1,className:`h-full w-full object-cover`})}),p&&(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`Avatar studio`),onClick:p,className:e(v,`flex items-center justify-center border border-dashed border-border text-accent hover:bg-surface-2`),children:(0,l.jsx)(c,{size:22})}),(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`An emoji instead`),"aria-pressed":h,onClick:()=>f({...r,avatar:``}),className:e(`flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-full text-[26px] transition`,h?_:`opacity-90 hover:opacity-100`),style:{background:`linear-gradient(135deg, ${r.color}, color-mix(in srgb, ${r.color} 60%, #ffffff))`},children:r.emoji})]}),h&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`div`,{className:`grid grid-cols-8 gap-1.5`,children:u.map(t=>(0,l.jsx)(`button`,{type:`button`,onClick:()=>f({...r,emoji:t}),className:e(`flex aspect-square items-center justify-center rounded-2xl bg-surface-2 text-[22px]`,r.emoji===t&&`ring-2 ring-accent`),children:t},t))}),(0,l.jsx)(`div`,{className:`flex gap-2`,children:d.map(e=>(0,l.jsx)(`button`,{type:`button`,"aria-label":e,onClick:()=>f({...r,color:e}),className:`flex h-8 w-8 items-center justify-center rounded-full text-white`,style:{background:e},children:r.color===e&&(0,l.jsx)(s,{size:16})},e))})]})]})}export{f as n,p as r,d as t}; \ No newline at end of file +import{E as e,K as t,a as n,b as r,c as i,s as a,u as o}from"./MuseHeader-RC--uR8o.js";import{n as s}from"./external-link-TvxP_dKX.js";import{U as c}from"./index-tYqPtwPX.js";var l=r(),u=[`✨`,`🌙`,`🪐`,`🌿`,`🔥`,`🌊`,`🦉`,`🦊`,`🐙`,`🎯`,`🧭`,`💎`,`🍀`,`🎈`,`🤖`,`🧠`],d=[`#0064d4`,`#7c3aed`,`#0891b2`,`#059669`,`#d97706`,`#dc2626`,`#db2777`,`#4b5563`],f=[{id:`muse`,label:`3D toy (Muse)`},{id:`flat`,label:`Flat`},{id:`clay`,label:`3D clay`},{id:`watercolor`,label:`Watercolour`},{id:`pixel`,label:`Pixel`},{id:`line`,label:`Line`},{id:`sticker`,label:`Sticker`}];function p({value:r,onChange:f,onStudio:p}){let m=t(),h=r.avatar===``,g=!h&&!i(r.avatar)?r.avatar:null,_=`ring-[2.5px] ring-accent ring-offset-2 ring-offset-bg`,v=`relative h-[72px] w-[72px] shrink-0 overflow-hidden rounded-full transition`;return(0,l.jsxs)(`div`,{className:`space-y-3`,children:[(0,l.jsxs)(`div`,{className:`flex flex-wrap gap-3`,children:[(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`The dragon`),"aria-pressed":r.avatar===n,onClick:()=>f({...r,avatar:n}),className:e(v,`bg-[#f1efeb]`,r.avatar===`dragon`?_:`opacity-90 hover:opacity-100`),children:(0,l.jsx)(`img`,{src:a(r.avatar===`dragon`?`happy`:`idle`),alt:``,draggable:!1,className:`h-full w-full object-cover`})}),g&&(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`The face drawn for you`),"aria-pressed":!0,className:e(v,`bg-surface-2`,_),children:(0,l.jsx)(`img`,{src:o(g,`happy`),alt:``,draggable:!1,className:`h-full w-full object-cover`})}),p&&(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`Avatar studio`),onClick:p,className:e(v,`flex items-center justify-center border border-dashed border-border text-accent hover:bg-surface-2`),children:(0,l.jsx)(c,{size:22})}),(0,l.jsx)(`button`,{type:`button`,"aria-label":m(`An emoji instead`),"aria-pressed":h,onClick:()=>f({...r,avatar:``}),className:e(`flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-full text-[26px] transition`,h?_:`opacity-90 hover:opacity-100`),style:{background:`linear-gradient(135deg, ${r.color}, color-mix(in srgb, ${r.color} 60%, #ffffff))`},children:r.emoji})]}),h&&(0,l.jsxs)(l.Fragment,{children:[(0,l.jsx)(`div`,{className:`grid grid-cols-8 gap-1.5`,children:u.map(t=>(0,l.jsx)(`button`,{type:`button`,onClick:()=>f({...r,emoji:t}),className:e(`flex aspect-square items-center justify-center rounded-2xl bg-surface-2 text-[22px]`,r.emoji===t&&`ring-2 ring-accent`),children:t},t))}),(0,l.jsx)(`div`,{className:`flex gap-2`,children:d.map(e=>(0,l.jsx)(`button`,{type:`button`,"aria-label":e,onClick:()=>f({...r,color:e}),className:`flex h-8 w-8 items-center justify-center rounded-full text-white`,style:{background:e},children:r.color===e&&(0,l.jsx)(s,{size:16})},e))})]})]})}export{f as n,p as r,d as t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/AvatarStudioScreen-B7jrVEOY.js b/nanomuse/server/static/assets/AvatarStudioScreen-B7jrVEOY.js deleted file mode 100644 index 57769f49..00000000 --- a/nanomuse/server/static/assets/AvatarStudioScreen-B7jrVEOY.js +++ /dev/null @@ -1 +0,0 @@ -import{E as e,K as t,L as n,Q as r,S as i,Y as a,a as o,b as s,c,f as l,l as u,o as d,q as f,r as p,s as m,u as h,x as g}from"./MuseHeader-DHtaUVEa.js";import{C as _,S as v,t as y}from"./StarNudge-viN7_tiM.js";import{n as b}from"./external-link-3iD-Bcbv.js";import{i as x,o as S}from"./Form-heEKMZyj.js";import{t as ee}from"./refresh-cw-DQelymc2.js";import{U as C,mt as te}from"./index-CrH9-E2E.js";import{t as ne}from"./BackBar-B5YUZVK1.js";import{n as w}from"./AvatarPicker-zOz-S1nk.js";var T=r(a(),1),E=s(),D=[`idle`,`working`,`waiting`,`happy`,`error`],O={idle:`Idle`,working:`Working`,waiting:`Waiting for you`,happy:`Done`,error:`Something went wrong`},re=4,k=`a round, custard-yellow baby dragon with small orange horns and folded little wings`;function A(){let{state:r,setTab:a,toast:s,draft:u,refreshSettings:d}=l(),m=t(),h=r.profile,O=h?.name??`nanoMuse`,[A,oe]=(0,T.useState)(null),[M,se]=(0,T.useState)(``),[N,ce]=(0,T.useState)(`muse`),[P,F]=(0,T.useState)(null),[I,L]=(0,T.useState)(null),[R,z]=(0,T.useState)(!1),[B,V]=(0,T.useState)(``),H=r.studio??A?.current??null,U=H?.stage??``,W=U===`drawing`,G=H?.candidates??[],K=(U===`drawing`||U===`choose`)&&G.length>0,le=()=>g.avatarView().then(oe,e=>s(e.message));(0,T.useEffect)(()=>{le()},[h?.avatar,r.settings?.llm.model,r.connectionsVersion]),(0,T.useEffect)(()=>{U!==`choose`&&F(null)},[U]);let q=async(e,t)=>{L(e),V(``);try{await t()}catch(e){V(e.message)}finally{L(null)}},ue=()=>q(`draw`,async()=>{let e=M.trim()||k,t=await g.avatarBegin(e,``,N);if(t.available===!1)throw Error(_(m,`image`,v(r.hub?.account),n()));t.cost?.cloud===!1&&t.session&&await g.avatarStart(t.session)}),de=()=>q(`reset`,async()=>{await g.updateSettings({profile:{avatar:o}}),await d(),s(m(`Back to the built-in look.`))}),J=A?.face??null,fe=!!h?.avatar&&!c(h.avatar),Y=H&&(U===`posing`||U===`animating`||U===`done`)&&H.face===h?.avatar,pe=Y?Object.keys(H.moods??{}).length:0,me=Y?Object.keys(H.clips??{}).length:0,X=Y?(H.errors??[]).filter(e=>!/clip/.test(e)).length:0,Z=H?.cost??{},Q=Z.clips?m(`{n} pictures and {c} clips`,{n:Z.pictures??8,c:Z.clips}):m(`{n} pictures`,{n:Z.pictures??8}),he=(()=>{if(Z.error)return m(`The cost could not be checked: {error}`,{error:Z.error});if(typeof Z.cny!=`number`)return m(`Checking the cost…`);if(Z.unlimited)return m(`About ¥{cny} for {what}; your account has no limit.`,{cny:Z.cny.toFixed(2),what:Q});let e=typeof Z.left_cny==`number`?Z.left_cny.toFixed(2):`?`;return Z.affordable===!1?m(`About ¥{cny} for {what}, more than the ¥{left} left in your allowance.`,{cny:Z.cny.toFixed(2),what:Q,left:e}):m(`About ¥{cny} for {what}; ¥{left} left in your allowance.`,{cny:Z.cny.toFixed(2),what:Q,left:e})})(),$=e=>w.find(t=>t.id===e)?.label,ge=A?.cloud?`nanoMuse Cloud`:ie(A?.host??``,m);return(0,E.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,E.jsx)(ne,{title:m(`Avatar studio`),description:m(`Draw a look with your image model; it moves with what it is doing.`),actions:(0,E.jsxs)(`div`,{className:`relative`,children:[(0,E.jsx)(p,{small:!0,onClick:()=>z(e=>!e),label:m(`More`),expanded:R,children:(0,E.jsx)(te,{size:20})}),R&&(0,E.jsxs)(`div`,{className:`absolute right-0 z-20 mt-1 w-56 overflow-hidden rounded-2xl border border-border/70 bg-surface py-1 text-[14px] shadow-lg`,children:[(0,E.jsx)(j,{label:m(`Name & style`),onClick:()=>{z(!1),a(`you`)}}),(0,E.jsx)(j,{label:m(`Image model`),onClick:()=>{z(!1),a(`connections`)}}),fe&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(j,{label:m(`Redraw the poses`),onClick:()=>{z(!1),q(`moods`,()=>g.avatarMoods())}}),(0,E.jsx)(j,{label:m(`Back to the built-in look`),danger:!0,onClick:()=>{z(!1),de()}})]})]})]})}),(0,E.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-5`,onClick:()=>R&&z(!1),children:[(0,E.jsxs)(`div`,{className:`flex flex-col items-center pt-2 text-center`,children:[(0,E.jsx)(ae,{avatar:h?.avatar,emoji:h?.emoji,color:h?.color,name:O}),J&&(0,E.jsx)(`div`,{className:`mt-0.5 max-w-sm text-[12.5px] text-muted`,children:[J.description,$(J.style)&&m($(J.style))].filter(Boolean).join(` · `)}),Y&&(0,E.jsxs)(`div`,{className:`mt-2.5 flex items-center gap-2 text-[13px] text-muted`,children:[U===`posing`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(f,{size:14,className:`animate-spin text-accent`}),(0,E.jsx)(`span`,{children:m(`Drawing the poses… {done}/{total}`,{done:pe,total:D.length})})]}),U===`animating`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(f,{size:14,className:`animate-spin text-accent`}),(0,E.jsx)(`span`,{children:m(`The new look is on; making the clips… {done}/{total}`,{done:me,total:re})})]}),U===`done`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(`span`,{children:X===0?m(`All five poses are in.`):m(`{done} of {total} poses are in; the rest show the main picture.`,{done:D.length-X,total:D.length})}),X>0&&(0,E.jsx)(`button`,{type:`button`,onClick:()=>void q(`moods`,()=>g.avatarMoods()),className:`font-medium text-accent`,children:m(`Retry`)})]})]})]}),U===`done`&&X===0&&(0,E.jsx)(y,{moment:`new_look`}),(0,E.jsxs)(`section`,{className:`rounded-3xl border border-border/70 bg-surface p-4 shadow-sm space-y-3`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h2`,{className:`text-[17px] font-semibold`,children:m(`Describe a new look`)}),(0,E.jsx)(`p`,{className:`mt-0.5 text-[13px] leading-snug text-muted`,children:m(`One sentence is enough: what it is, what it wears, its air. Pick a style and your image model draws four to choose from.`)})]}),(0,E.jsx)(`textarea`,{value:M,onChange:e=>se(e.target.value),rows:2,maxLength:200,placeholder:m(k),className:`w-full resize-none rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,E.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:w.map(t=>(0,E.jsx)(`button`,{type:`button`,onClick:()=>ce(t.id),className:e(`rounded-full px-3 py-1.5 text-[13px] font-medium transition`,N===t.id?`bg-fg text-bg`:`bg-surface-2 text-fg hover:bg-border/60`),children:m(t.label)},t.id))}),(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null||W||A?.available===!1,onClick:()=>void ue(),className:e(x,`w-full rounded-full py-3`),children:[W||I===`draw`?(0,E.jsx)(f,{size:16,className:`animate-spin`}):(0,E.jsx)(C,{size:16}),m(W?`Drawing…`:K||U===`choose`?`Draw four more`:`Draw four`)]}),(0,E.jsxs)(`button`,{type:`button`,onClick:()=>a(`connections`),className:`flex w-full items-center justify-between gap-3 py-1 text-left text-[12.5px] text-muted`,children:[(0,E.jsx)(`span`,{className:`min-w-0 flex-1`,children:A===null?`…`:A.available?m(`Drawn by {provider} · {model}`,{provider:ge,model:A.image_model||`—`}):_(m,`image`,v(r.hub?.account),n())}),(0,E.jsx)(`span`,{className:`shrink-0 font-medium text-accent`,children:m(`Change model`)})]}),B&&(0,E.jsx)(`p`,{className:`text-[13px] text-rose-600 dark:text-rose-300`,children:B})]}),U===`estimate`&&H&&(0,E.jsxs)(`section`,{className:`rounded-3xl border border-accent/50 bg-surface p-4 shadow-sm space-y-3`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h2`,{className:`text-[15px] font-semibold`,children:m(`A new look for {name}`,{name:O})}),(0,E.jsx)(`p`,{className:`mt-0.5 text-[13px] text-muted`,children:H.description})]}),(0,E.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:he}),(0,E.jsxs)(`div`,{className:`flex gap-2`,children:[(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null||typeof Z.cny!=`number`&&!Z.error,onClick:()=>void q(`start`,()=>g.avatarStart(H.session)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`rounded-full`),children:[I===`start`?(0,E.jsx)(f,{size:14,className:`animate-spin`}):(0,E.jsx)(C,{size:14}),` `,m(`Draw`)]}),(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`cancel`,()=>g.avatarCancel(H.session)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`rounded-full`),children:[(0,E.jsx)(S,{size:14}),` `,m(`Not now`)]})]})]}),K&&H&&(0,E.jsxs)(`section`,{className:`space-y-3 px-1`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h2`,{className:`text-[17px] font-semibold`,children:m(`Pick one`)}),(0,E.jsx)(`p`,{className:`mt-0.5 text-[13px] leading-snug text-muted`,children:m(`Draw four more any time; the old ones stay until you do.`)})]}),(0,E.jsx)(`div`,{className:`grid grid-cols-2 gap-3 wide:grid-cols-4`,children:G.map((t,n)=>(0,E.jsxs)(`button`,{type:`button`,disabled:!t||U!==`choose`,onClick:()=>F(e=>e===n?null:n),"aria-label":m(`Candidate {n}`,{n:n+1}),"aria-pressed":P===n,className:e(`relative aspect-square overflow-hidden rounded-[20px] bg-[#f1efeb] transition`,P===n?`ring-[3px] ring-accent`:t&&U===`choose`?`hover:ring-2 hover:ring-accent/60`:``),children:[t?(0,E.jsx)(`img`,{src:i(t),alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,E.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center text-muted`,children:W?(0,E.jsx)(f,{size:22,className:`animate-spin`}):(0,E.jsx)(S,{size:18,className:`opacity-50`})}),P===n&&(0,E.jsx)(`span`,{className:`absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-accent text-accent-fg shadow`,children:(0,E.jsx)(b,{size:16})})]},n))}),H.errors&&H.errors.length>0&&U===`choose`&&(0,E.jsx)(`p`,{className:`text-[12.5px] text-rose-600 dark:text-rose-300`,children:m(H.errors[0])}),P!==null&&U===`choose`&&(0,E.jsxs)(`div`,{className:`space-y-1.5 pt-1`,children:[(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`choose`,()=>g.avatarChoose(H.session,P)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`w-full rounded-full py-3`),children:[I===`choose`?(0,E.jsx)(f,{size:16,className:`animate-spin`}):(0,E.jsx)(b,{size:16}),` `,m(`Use this one`)]}),(0,E.jsx)(`p`,{className:`text-center text-[12px] leading-snug text-muted`,children:m(`Next, its poses are drawn from it in the background: working, waiting for you, done, something wrong.`)})]}),U===`choose`&&(0,E.jsx)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`cancel`,()=>g.avatarCancel(H.session)),className:`w-full py-1 text-center text-[13px] text-muted`,children:m(`Keep the current look`)})]}),U===`failed`&&H&&(0,E.jsxs)(`section`,{className:`rounded-3xl border border-border/70 bg-surface p-4 shadow-sm space-y-2`,children:[(0,E.jsx)(`h2`,{className:`text-[15px] font-semibold`,children:m(`That did not work`)}),(0,E.jsx)(`p`,{className:`text-[13px] text-muted`,children:m(H.message||`Nothing came back.`)}),(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`retry`,()=>g.avatarStart(H.session)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`rounded-full`),children:[(0,E.jsx)(ee,{size:14}),` `,m(`Try again`)]})]}),(0,E.jsx)(`p`,{className:`px-1 text-[12px] leading-snug text-muted`,children:m(`The pictures stay in this computer's workspace under avatar/. Each new look asks your provider for eight pictures (four to choose from and four poses) and four short clips where there is a video model.`)}),(0,E.jsx)(`p`,{className:`px-1 text-[12px] leading-snug text-muted`,children:(0,E.jsx)(`button`,{type:`button`,onClick:()=>{u(m(`Change your avatar to `)),a(`chat`)},className:`font-medium text-accent`,children:m(`Or just say it in the chat: “change your avatar to a corgi”.`)})})]})]})}function ie(e,t){return e?/dashscope|aliyuncs/.test(e)?t(`Alibaba Cloud Bailian`):/openai\.com/.test(e)?`OpenAI`:/deepseek/.test(e)?`DeepSeek`:/moonshot/.test(e)?`Moonshot`:/bigmodel|zhipu/.test(e)?t(`Zhipu`):/siliconflow/.test(e)?`SiliconFlow`:/volces|ark/.test(e)?t(`Volcano Ark`):/openrouter/.test(e)?`OpenRouter`:/googleapis/.test(e)?`Gemini`:/x\.ai/.test(e)?`xAI`:/^(127\.0\.0\.1|localhost)/.test(e)?t(`this computer`):e:`—`}function j({label:t,onClick:n,danger:r=!1}){return(0,E.jsx)(`button`,{type:`button`,onClick:n,className:e(`block w-full px-4 py-2.5 text-left hover:bg-surface-2`,r?`text-rose-600 dark:text-rose-300`:``),children:t})}function ae({avatar:e,emoji:n,color:r,name:i}){let a=t(),[o,s]=(0,T.useState)(0),l=D[o%D.length],f=(0,T.useRef)(0);(0,T.useEffect)(()=>(f.current=window.setTimeout(()=>s(e=>(e+1)%D.length),l===`idle`?2600:2e3),()=>window.clearTimeout(f.current)),[o,l]);let p=c(e),g=!p&&e?e:null,_=p?m(l):g?h(g,l):``,v=p?d(l):g?u(g,l):null,[y,b]=(0,T.useState)(()=>new Set),x=v&&!y.has(v)?v:null;return(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(`div`,{className:`avatar-pop h-[148px] w-[148px] overflow-hidden rounded-full`,style:_?{background:`#f1efeb`}:{background:`linear-gradient(135deg, ${r??`#0064d4`}, color-mix(in srgb, ${r??`#0064d4`} 60%, #ffffff))`},children:x?(0,E.jsx)(`video`,{src:x,poster:_,autoPlay:!0,loop:!0,muted:!0,playsInline:!0,disablePictureInPicture:!0,onError:()=>b(e=>new Set(e).add(x)),className:`h-full w-full object-cover`},x):_?(0,E.jsx)(`img`,{src:_,alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,E.jsx)(`span`,{className:`flex h-full w-full items-center justify-center text-[64px] leading-none`,children:n??`✨`})},e),(0,E.jsx)(`div`,{className:`mt-3 text-[20px] font-semibold tracking-tight`,children:i}),(0,E.jsx)(`div`,{className:`mt-1 text-[13px] text-muted`,children:a(O[l])})]})}export{A as AvatarStudioScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/AvatarStudioScreen-Dgqo0TEO.js b/nanomuse/server/static/assets/AvatarStudioScreen-Dgqo0TEO.js new file mode 100644 index 00000000..3d799924 --- /dev/null +++ b/nanomuse/server/static/assets/AvatarStudioScreen-Dgqo0TEO.js @@ -0,0 +1 @@ +import{E as e,K as t,L as n,Q as r,S as i,Y as a,a as o,b as s,c,f as l,l as u,o as d,q as f,r as p,s as m,u as h,x as g}from"./MuseHeader-RC--uR8o.js";import{C as _,S as v,t as y}from"./StarNudge-Df6LQyo9.js";import{n as b}from"./external-link-TvxP_dKX.js";import{i as x,o as S}from"./Form-H3s9oTHq.js";import{t as ee}from"./refresh-cw-CxZ3ZdI3.js";import{U as C,mt as te}from"./index-tYqPtwPX.js";import{t as ne}from"./BackBar-JEuC8Q_e.js";import{n as w}from"./AvatarPicker-BCihUURa.js";var T=r(a(),1),E=s(),D=[`idle`,`working`,`waiting`,`happy`,`error`],O={idle:`Idle`,working:`Working`,waiting:`Waiting for you`,happy:`Done`,error:`Something went wrong`},re=4,k=`a round, custard-yellow baby dragon with small orange horns and folded little wings`;function A(){let{state:r,setTab:a,toast:s,draft:u,refreshSettings:d}=l(),m=t(),h=r.profile,O=h?.name??`nanoMuse`,[A,oe]=(0,T.useState)(null),[M,se]=(0,T.useState)(``),[N,ce]=(0,T.useState)(`muse`),[P,F]=(0,T.useState)(null),[I,L]=(0,T.useState)(null),[R,z]=(0,T.useState)(!1),[B,V]=(0,T.useState)(``),H=r.studio??A?.current??null,U=H?.stage??``,W=U===`drawing`,G=H?.candidates??[],K=(U===`drawing`||U===`choose`)&&G.length>0,le=()=>g.avatarView().then(oe,e=>s(e.message));(0,T.useEffect)(()=>{le()},[h?.avatar,r.settings?.llm.model,r.connectionsVersion]),(0,T.useEffect)(()=>{U!==`choose`&&F(null)},[U]);let q=async(e,t)=>{L(e),V(``);try{await t()}catch(e){V(e.message)}finally{L(null)}},ue=()=>q(`draw`,async()=>{let e=M.trim()||k,t=await g.avatarBegin(e,``,N);if(t.available===!1)throw Error(_(m,`image`,v(r.hub?.account),n()));t.cost?.cloud===!1&&t.session&&await g.avatarStart(t.session)}),de=()=>q(`reset`,async()=>{await g.updateSettings({profile:{avatar:o}}),await d(),s(m(`Back to the built-in look.`))}),J=A?.face??null,fe=!!h?.avatar&&!c(h.avatar),Y=H&&(U===`posing`||U===`animating`||U===`done`)&&H.face===h?.avatar,pe=Y?Object.keys(H.moods??{}).length:0,me=Y?Object.keys(H.clips??{}).length:0,X=Y?(H.errors??[]).filter(e=>!/clip/.test(e)).length:0,Z=H?.cost??{},Q=Z.clips?m(`{n} pictures and {c} clips`,{n:Z.pictures??8,c:Z.clips}):m(`{n} pictures`,{n:Z.pictures??8}),he=(()=>{if(Z.error)return m(`The cost could not be checked: {error}`,{error:Z.error});if(typeof Z.cny!=`number`)return m(`Checking the cost…`);if(Z.unlimited)return m(`About ¥{cny} for {what}; your account has no limit.`,{cny:Z.cny.toFixed(2),what:Q});let e=typeof Z.left_cny==`number`?Z.left_cny.toFixed(2):`?`;return Z.affordable===!1?m(`About ¥{cny} for {what}, more than the ¥{left} left in your allowance.`,{cny:Z.cny.toFixed(2),what:Q,left:e}):m(`About ¥{cny} for {what}; ¥{left} left in your allowance.`,{cny:Z.cny.toFixed(2),what:Q,left:e})})(),$=e=>w.find(t=>t.id===e)?.label,ge=A?.cloud?`nanoMuse Cloud`:ie(A?.host??``,m);return(0,E.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,E.jsx)(ne,{title:m(`Avatar studio`),description:m(`Draw a look with your image model; it moves with what it is doing.`),actions:(0,E.jsxs)(`div`,{className:`relative`,children:[(0,E.jsx)(p,{small:!0,onClick:()=>z(e=>!e),label:m(`More`),expanded:R,children:(0,E.jsx)(te,{size:20})}),R&&(0,E.jsxs)(`div`,{className:`absolute right-0 z-20 mt-1 w-56 overflow-hidden rounded-2xl border border-border/70 bg-surface py-1 text-[14px] shadow-lg`,children:[(0,E.jsx)(j,{label:m(`Name & style`),onClick:()=>{z(!1),a(`you`)}}),(0,E.jsx)(j,{label:m(`Image model`),onClick:()=>{z(!1),a(`connections`)}}),fe&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(j,{label:m(`Redraw the poses`),onClick:()=>{z(!1),q(`moods`,()=>g.avatarMoods())}}),(0,E.jsx)(j,{label:m(`Back to the built-in look`),danger:!0,onClick:()=>{z(!1),de()}})]})]})]})}),(0,E.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-5`,onClick:()=>R&&z(!1),children:[(0,E.jsxs)(`div`,{className:`flex flex-col items-center pt-2 text-center`,children:[(0,E.jsx)(ae,{avatar:h?.avatar,emoji:h?.emoji,color:h?.color,name:O}),J&&(0,E.jsx)(`div`,{className:`mt-0.5 max-w-sm text-[12.5px] text-muted`,children:[J.description,$(J.style)&&m($(J.style))].filter(Boolean).join(` · `)}),Y&&(0,E.jsxs)(`div`,{className:`mt-2.5 flex items-center gap-2 text-[13px] text-muted`,children:[U===`posing`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(f,{size:14,className:`animate-spin text-accent`}),(0,E.jsx)(`span`,{children:m(`Drawing the poses… {done}/{total}`,{done:pe,total:D.length})})]}),U===`animating`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(f,{size:14,className:`animate-spin text-accent`}),(0,E.jsx)(`span`,{children:m(`The new look is on; making the clips… {done}/{total}`,{done:me,total:re})})]}),U===`done`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(`span`,{children:X===0?m(`All five poses are in.`):m(`{done} of {total} poses are in; the rest show the main picture.`,{done:D.length-X,total:D.length})}),X>0&&(0,E.jsx)(`button`,{type:`button`,onClick:()=>void q(`moods`,()=>g.avatarMoods()),className:`font-medium text-accent`,children:m(`Retry`)})]})]})]}),U===`done`&&X===0&&(0,E.jsx)(y,{moment:`new_look`}),(0,E.jsxs)(`section`,{className:`rounded-3xl border border-border/70 bg-surface p-4 shadow-sm space-y-3`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h2`,{className:`text-[17px] font-semibold`,children:m(`Describe a new look`)}),(0,E.jsx)(`p`,{className:`mt-0.5 text-[13px] leading-snug text-muted`,children:m(`One sentence is enough: what it is, what it wears, its air. Pick a style and your image model draws four to choose from.`)})]}),(0,E.jsx)(`textarea`,{value:M,onChange:e=>se(e.target.value),rows:2,maxLength:200,placeholder:m(k),className:`w-full resize-none rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,E.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:w.map(t=>(0,E.jsx)(`button`,{type:`button`,onClick:()=>ce(t.id),className:e(`rounded-full px-3 py-1.5 text-[13px] font-medium transition`,N===t.id?`bg-fg text-bg`:`bg-surface-2 text-fg hover:bg-border/60`),children:m(t.label)},t.id))}),(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null||W||A?.available===!1,onClick:()=>void ue(),className:e(x,`w-full rounded-full py-3`),children:[W||I===`draw`?(0,E.jsx)(f,{size:16,className:`animate-spin`}):(0,E.jsx)(C,{size:16}),m(W?`Drawing…`:K||U===`choose`?`Draw four more`:`Draw four`)]}),(0,E.jsxs)(`button`,{type:`button`,onClick:()=>a(`connections`),className:`flex w-full items-center justify-between gap-3 py-1 text-left text-[12.5px] text-muted`,children:[(0,E.jsx)(`span`,{className:`min-w-0 flex-1`,children:A===null?`…`:A.available?m(`Drawn by {provider} · {model}`,{provider:ge,model:A.image_model||m(`default model`)}):_(m,`image`,v(r.hub?.account),n())}),(0,E.jsx)(`span`,{className:`shrink-0 font-medium text-accent`,children:m(`Change model`)})]}),B&&(0,E.jsx)(`p`,{className:`text-[13px] text-rose-600 dark:text-rose-300`,children:B})]}),U===`estimate`&&H&&(0,E.jsxs)(`section`,{className:`rounded-3xl border border-accent/50 bg-surface p-4 shadow-sm space-y-3`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h2`,{className:`text-[15px] font-semibold`,children:m(`A new look for {name}`,{name:O})}),(0,E.jsx)(`p`,{className:`mt-0.5 text-[13px] text-muted`,children:H.description})]}),(0,E.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:he}),(0,E.jsxs)(`div`,{className:`flex gap-2`,children:[(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null||typeof Z.cny!=`number`&&!Z.error,onClick:()=>void q(`start`,()=>g.avatarStart(H.session)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`rounded-full`),children:[I===`start`?(0,E.jsx)(f,{size:14,className:`animate-spin`}):(0,E.jsx)(C,{size:14}),` `,m(`Draw`)]}),(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`cancel`,()=>g.avatarCancel(H.session)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`rounded-full`),children:[(0,E.jsx)(S,{size:14}),` `,m(`Not now`)]})]})]}),K&&H&&(0,E.jsxs)(`section`,{className:`space-y-3 px-1`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h2`,{className:`text-[17px] font-semibold`,children:m(`Pick one`)}),(0,E.jsx)(`p`,{className:`mt-0.5 text-[13px] leading-snug text-muted`,children:m(`Draw four more any time; the old ones stay until you do.`)})]}),(0,E.jsx)(`div`,{className:`grid grid-cols-2 gap-3 wide:grid-cols-4`,children:G.map((t,n)=>(0,E.jsxs)(`button`,{type:`button`,disabled:!t||U!==`choose`,onClick:()=>F(e=>e===n?null:n),"aria-label":m(`Candidate {n}`,{n:n+1}),"aria-pressed":P===n,className:e(`relative aspect-square overflow-hidden rounded-[20px] bg-[#f1efeb] transition`,P===n?`ring-[3px] ring-accent`:t&&U===`choose`?`hover:ring-2 hover:ring-accent/60`:``),children:[t?(0,E.jsx)(`img`,{src:i(t),alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,E.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center text-muted`,children:W?(0,E.jsx)(f,{size:22,className:`animate-spin`}):(0,E.jsx)(S,{size:18,className:`opacity-50`})}),P===n&&(0,E.jsx)(`span`,{className:`absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-accent text-accent-fg shadow`,children:(0,E.jsx)(b,{size:16})})]},n))}),H.errors&&H.errors.length>0&&U===`choose`&&(0,E.jsx)(`p`,{className:`text-[12.5px] text-rose-600 dark:text-rose-300`,children:m(H.errors[0])}),P!==null&&U===`choose`&&(0,E.jsxs)(`div`,{className:`space-y-1.5 pt-1`,children:[(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`choose`,()=>g.avatarChoose(H.session,P)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`w-full rounded-full py-3`),children:[I===`choose`?(0,E.jsx)(f,{size:16,className:`animate-spin`}):(0,E.jsx)(b,{size:16}),` `,m(`Use this one`)]}),(0,E.jsx)(`p`,{className:`text-center text-[12px] leading-snug text-muted`,children:m(`Next, its poses are drawn from it in the background: working, waiting for you, done, something wrong.`)})]}),U===`choose`&&(0,E.jsx)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`cancel`,()=>g.avatarCancel(H.session)),className:`w-full py-1 text-center text-[13px] text-muted`,children:m(`Keep the current look`)})]}),U===`failed`&&H&&(0,E.jsxs)(`section`,{className:`rounded-3xl border border-border/70 bg-surface p-4 shadow-sm space-y-2`,children:[(0,E.jsx)(`h2`,{className:`text-[15px] font-semibold`,children:m(`That did not work`)}),(0,E.jsx)(`p`,{className:`text-[13px] text-muted`,children:m(H.message||`Nothing came back.`)}),(0,E.jsxs)(`button`,{type:`button`,disabled:I!==null,onClick:()=>void q(`retry`,()=>g.avatarStart(H.session)),className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`rounded-full`),children:[(0,E.jsx)(ee,{size:14}),` `,m(`Try again`)]})]}),(0,E.jsx)(`p`,{className:`px-1 text-[12px] leading-snug text-muted`,children:m(`The pictures stay in this computer's workspace under avatar/. Each new look asks your provider for eight pictures (four to choose from and four poses) and four short clips where there is a video model.`)}),(0,E.jsx)(`p`,{className:`px-1 text-[12px] leading-snug text-muted`,children:(0,E.jsx)(`button`,{type:`button`,onClick:()=>{u(m(`Change your avatar to `)),a(`chat`)},className:`font-medium text-accent`,children:m(`Or just say it in the chat: “change your avatar to a corgi”.`)})})]})]})}function ie(e,t){return e?/dashscope|aliyuncs/.test(e)?t(`Alibaba Cloud Bailian`):/openai\.com/.test(e)?`OpenAI`:/deepseek/.test(e)?`DeepSeek`:/moonshot/.test(e)?`Moonshot`:/bigmodel|zhipu/.test(e)?t(`Zhipu`):/siliconflow/.test(e)?`SiliconFlow`:/volces|ark/.test(e)?t(`Volcano Ark`):/openrouter/.test(e)?`OpenRouter`:/googleapis/.test(e)?`Gemini`:/x\.ai/.test(e)?`xAI`:/^(127\.0\.0\.1|localhost)/.test(e)?t(`this computer`):e:t(`your provider`)}function j({label:t,onClick:n,danger:r=!1}){return(0,E.jsx)(`button`,{type:`button`,onClick:n,className:e(`block w-full px-4 py-2.5 text-left hover:bg-surface-2`,r?`text-rose-600 dark:text-rose-300`:``),children:t})}function ae({avatar:e,emoji:n,color:r,name:i}){let a=t(),[o,s]=(0,T.useState)(0),l=D[o%D.length],f=(0,T.useRef)(0);(0,T.useEffect)(()=>(f.current=window.setTimeout(()=>s(e=>(e+1)%D.length),l===`idle`?2600:2e3),()=>window.clearTimeout(f.current)),[o,l]);let p=c(e),g=!p&&e?e:null,_=p?m(l):g?h(g,l):``,v=p?d(l):g?u(g,l):null,[y,b]=(0,T.useState)(()=>new Set),x=v&&!y.has(v)?v:null;return(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(`div`,{className:`avatar-pop h-[148px] w-[148px] overflow-hidden rounded-full`,style:_?{background:`#f1efeb`}:{background:`linear-gradient(135deg, ${r??`#0064d4`}, color-mix(in srgb, ${r??`#0064d4`} 60%, #ffffff))`},children:x?(0,E.jsx)(`video`,{src:x,poster:_,autoPlay:!0,loop:!0,muted:!0,playsInline:!0,disablePictureInPicture:!0,onError:()=>b(e=>new Set(e).add(x)),className:`h-full w-full object-cover`},x):_?(0,E.jsx)(`img`,{src:_,alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,E.jsx)(`span`,{className:`flex h-full w-full items-center justify-center text-[64px] leading-none`,children:n??`✨`})},e),(0,E.jsx)(`div`,{className:`mt-3 text-[20px] font-semibold tracking-tight`,children:i}),(0,E.jsx)(`div`,{className:`mt-1 text-[13px] text-muted`,children:a(O[l])})]})}export{A as AvatarStudioScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/BackBar-B5YUZVK1.js b/nanomuse/server/static/assets/BackBar-JEuC8Q_e.js similarity index 85% rename from nanomuse/server/static/assets/BackBar-B5YUZVK1.js rename to nanomuse/server/static/assets/BackBar-JEuC8Q_e.js index 6be4360d..5175646f 100644 --- a/nanomuse/server/static/assets/BackBar-B5YUZVK1.js +++ b/nanomuse/server/static/assets/BackBar-JEuC8Q_e.js @@ -1 +1 @@ -import{K as e,b as t,f as n,r}from"./MuseHeader-DHtaUVEa.js";import{Et as i}from"./index-CrH9-E2E.js";var a=t();function o({title:t,description:o,actions:s,onBack:c,backLabel:l}){let{setTab:u}=n(),d=e();return(0,a.jsxs)(`header`,{className:`safe-top shrink-0`,children:[(0,a.jsxs)(`div`,{className:`relative flex h-14 items-center justify-center px-3`,children:[(0,a.jsx)(`div`,{className:`absolute left-3 top-1/2 -translate-y-1/2`,children:(0,a.jsx)(r,{small:!0,onClick:c??(()=>u(`chat`)),label:l??d(`Back`),children:(0,a.jsx)(i,{size:20})})}),(0,a.jsx)(`h1`,{className:`max-w-[calc(100%-128px)] truncate text-[17px] font-semibold leading-[22px]`,children:t}),s&&(0,a.jsx)(`div`,{className:`absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2`,children:s})]}),o&&(0,a.jsx)(`p`,{className:`px-5 pb-2 text-[13px] leading-[18px] text-muted`,children:o})]})}export{o as t}; \ No newline at end of file +import{K as e,b as t,f as n,r}from"./MuseHeader-RC--uR8o.js";import{Et as i}from"./index-tYqPtwPX.js";var a=t();function o({title:t,description:o,actions:s,onBack:c,backLabel:l}){let{setTab:u}=n(),d=e();return(0,a.jsxs)(`header`,{className:`safe-top shrink-0`,children:[(0,a.jsxs)(`div`,{className:`relative flex h-14 items-center justify-center px-3`,children:[(0,a.jsx)(`div`,{className:`absolute left-3 top-1/2 -translate-y-1/2`,children:(0,a.jsx)(r,{small:!0,onClick:c??(()=>u(`chat`)),label:l??d(`Back`),children:(0,a.jsx)(i,{size:20})})}),(0,a.jsx)(`h1`,{className:`max-w-[calc(100%-128px)] truncate text-[17px] font-semibold leading-[22px]`,children:t}),s&&(0,a.jsx)(`div`,{className:`absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2`,children:s})]}),o&&(0,a.jsx)(`p`,{className:`px-5 pb-2 text-[13px] leading-[18px] text-muted`,children:o})]})}export{o as t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/ChannelsScreen-BbqVSzaT.js b/nanomuse/server/static/assets/ChannelsScreen-D_7Ynsew.js similarity index 97% rename from nanomuse/server/static/assets/ChannelsScreen-BbqVSzaT.js rename to nanomuse/server/static/assets/ChannelsScreen-D_7Ynsew.js index e7747fe8..4a83a893 100644 --- a/nanomuse/server/static/assets/ChannelsScreen-BbqVSzaT.js +++ b/nanomuse/server/static/assets/ChannelsScreen-D_7Ynsew.js @@ -1,2 +1,2 @@ -import{E as e,J as t,K as n,Q as r,Y as i,b as a,f as o,q as s,w as c}from"./MuseHeader-DHtaUVEa.js";import{n as l,t as u}from"./external-link-3iD-Bcbv.js";import{a as d,i as f,o as p,r as m}from"./Form-heEKMZyj.js";import{t as h}from"./refresh-cw-DQelymc2.js";import{B as g,J as _,S as v,a as y,c as b,i as x,it as S,o as C,r as w,s as T}from"./index-CrH9-E2E.js";import{t as E}from"./BackBar-B5YUZVK1.js";var D=t(`qr-code`,[[`rect`,{width:`5`,height:`5`,x:`3`,y:`3`,rx:`1`,key:`1tu5fj`}],[`rect`,{width:`5`,height:`5`,x:`16`,y:`3`,rx:`1`,key:`1v8r4q`}],[`rect`,{width:`5`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`1x03jg`}],[`path`,{d:`M21 16h-3a2 2 0 0 0-2 2v3`,key:`177gqh`}],[`path`,{d:`M21 21v.01`,key:`ents32`}],[`path`,{d:`M12 7v3a2 2 0 0 1-2 2H7`,key:`8crl2c`}],[`path`,{d:`M3 12h.01`,key:`nlz23k`}],[`path`,{d:`M12 3h.01`,key:`n36tog`}],[`path`,{d:`M12 16v.01`,key:`133mhm`}],[`path`,{d:`M16 12h1`,key:`1slzba`}],[`path`,{d:`M21 12v.01`,key:`1lwtk9`}],[`path`,{d:`M12 21v-1`,key:`1880an`}]]),O=r(i(),1),k=(e,t)=>({method:e,body:JSON.stringify(t)}),A=encodeURIComponent,j={view:()=>c(`/api/channels`),update:(e,t)=>c(`/api/channels/${A(e)}`,k(`PUT`,t)),reload:()=>c(`/api/channels/reload`,{method:`POST`}),test:(e,t=``)=>c(`/api/channels/${A(e)}/test`,k(`POST`,{chat_id:t})),approve:e=>c(`/api/channels/pairing/${A(e)}/approve`,{method:`POST`}),deny:e=>c(`/api/channels/pairing/${A(e)}/deny`,{method:`POST`}),setDeliver:(e,t,n)=>c(`/api/channels/${A(e)}/chats/${A(t)}`,k(`PUT`,{deliver:n})),removeChat:(e,t)=>c(`/api/channels/${A(e)}/chats/${A(t)}`,{method:`DELETE`}),loginBegin:(e,t)=>c(`/api/channels/${A(e)}/login`,k(`POST`,{domain:t})),loginPoll:(e,t)=>c(`/api/channels/${A(e)}/login/${A(t)}`),deliver:e=>c(`/api/channels/deliver`,k(`POST`,{text:e}))},M=a(),N={feishu:[`In the Feishu developer console create a custom app and add the Bot capability.`,`Permissions: im:message, im:message.p2p_msg:readonly, im:message.group_at_msg:readonly, im:resource.`,`Events & callbacks: subscription mode Long connection; add im.message.receive_v1; callback mode Long connection; add card.action.trigger.`,`Copy App ID and App Secret from Credentials & Basic Info, then publish a version.`],dingtalk:[`In the DingTalk developer console create an internal app and add the Robot capability.`,`Set the message receive mode to Stream.`,`Copy Client ID (AppKey) and Client Secret (AppSecret) from Credentials & Basic Info.`,`Add the robot send-message permission and publish the version.`],wecom:[`In the WeCom admin console open Intelligent robot and create a robot in API mode with a long connection.`,`Copy the Bot ID and Secret.`,`Add the robot to the chats where it should listen.`],telegram:[`In Telegram talk to @BotFather, send /newbot and copy the token.`,`Direct messages work at once. For a group, add the bot to it; with Group Privacy on it only hears @-mentions and replies.`,`If api.telegram.org is out of reach from this computer, set a proxy below.`]},P={telegram:(0,M.jsx)(_,{size:20})};function F(e,t){let n=t.status.state;return e(n===`off`?`Off`:n===`unconfigured`?`Needs settings`:n===`missing_sdk`?`Not installed`:n===`connecting`?`Connecting…`:n===`connected`?`Connected`:`Error`)}function I({state:t}){return(0,M.jsx)(`span`,{"aria-hidden":!0,className:e(`inline-block h-2 w-2 rounded-full`,t===`connected`?`bg-emerald-500`:t===`error`?`bg-red-500`:t===`connecting`?`bg-amber-400`:`bg-border`)})}function L(){let{toast:e}=o(),t=n(),[r,i]=(0,O.useState)(null),[a,c]=(0,O.useState)(null),l=async()=>{try{i(await j.view()),c(null)}catch(e){c(e.message||`Could not load.`)}};(0,O.useEffect)(()=>{l();let e=window.setInterval(()=>void l(),5e3),t=()=>void l();return window.addEventListener(`focus`,t),()=>{window.clearInterval(e),window.removeEventListener(`focus`,t)}},[]);let u=async(t,n)=>{try{i(await t()),n&&e(n)}catch(t){e(t.message)}};return(0,M.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,M.jsx)(E,{title:t(`Chat apps`),description:t(`Talk to your Muse from the chat apps you already use. Nothing needs a public address: each app keeps a long connection open.`)}),(0,M.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-8`,children:[r===null&&a&&(0,M.jsx)(v,{message:a,onRetry:()=>void l()}),r===null&&!a&&(0,M.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,M.jsx)(s,{className:`animate-spin`,size:20})}),r&&r.pending.length>0&&(0,M.jsxs)(M.Fragment,{children:[(0,M.jsx)(T,{children:t(`Waiting for a yes`)}),(0,M.jsx)(x,{children:r.pending.map((e,t)=>(0,M.jsxs)(`div`,{children:[t>0&&(0,M.jsx)(y,{inset:16}),(0,M.jsx)(R,{p:e,label:r.channels.find(t=>t.name===e.channel)?.label??e.channel,onApprove:()=>u(()=>j.approve(e.code)),onDeny:()=>u(()=>j.deny(e.code))})]},e.code))})]}),r?.channels.map(e=>(0,M.jsx)(z,{c:e,run:u,reload:l},e.name)),r&&(0,M.jsx)(w,{children:t(`Each chat is one conversation of your Muse; its replies stream back as they are written.`)})]})]})}function R({p:t,label:r,onApprove:i,onDeny:a}){let o=n(),s=t.sender_name||t.sender_id;return(0,M.jsxs)(`div`,{className:`flex items-center gap-3 px-4 py-3`,children:[(0,M.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,M.jsx)(`p`,{className:`truncate text-[15px]`,children:o(`{name} wrote to the bot. Approve to let them talk to your Muse.`,{name:`${s} · ${r}`})}),(0,M.jsx)(`p`,{className:`text-[13px] text-muted`,children:o(`Code {code}`,{code:t.code})})]}),(0,M.jsxs)(`button`,{type:`button`,className:e(f,`px-3 py-2`),onClick:i,"aria-label":o(`Approve`),children:[(0,M.jsx)(l,{size:16}),` `,o(`Approve`)]}),(0,M.jsx)(`button`,{type:`button`,className:e(d,`px-3 py-2`),onClick:a,"aria-label":o(`Deny`),children:(0,M.jsx)(p,{size:16})})]})}function z({c:t,run:r,reload:i}){let a=n(),{toast:s}=o(),[c,l]=(0,O.useState)(!1),[d,f]=(0,O.useState)({}),[p,m]=(0,O.useState)([]),[_,v]=(0,O.useState)(null),[E,k]=(0,O.useState)(!1),[A,L]=(0,O.useState)(!1),[R,z]=(0,O.useState)(!1),[V,H]=(0,O.useState)(null),[U,W]=(0,O.useState)(``),[G,K]=(0,O.useState)(!1),q=(0,O.useRef)(null),J=Object.keys(d).length>0||_!==null,Y=_??t.allow_from.join(` +import{E as e,J as t,K as n,Q as r,Y as i,b as a,f as o,q as s,w as c}from"./MuseHeader-RC--uR8o.js";import{n as l,t as u}from"./external-link-TvxP_dKX.js";import{a as d,i as f,o as p,r as m}from"./Form-H3s9oTHq.js";import{t as h}from"./refresh-cw-CxZ3ZdI3.js";import{B as g,J as _,S as v,a as y,c as b,i as x,it as S,o as C,r as w,s as T}from"./index-tYqPtwPX.js";import{t as E}from"./BackBar-JEuC8Q_e.js";var D=t(`qr-code`,[[`rect`,{width:`5`,height:`5`,x:`3`,y:`3`,rx:`1`,key:`1tu5fj`}],[`rect`,{width:`5`,height:`5`,x:`16`,y:`3`,rx:`1`,key:`1v8r4q`}],[`rect`,{width:`5`,height:`5`,x:`3`,y:`16`,rx:`1`,key:`1x03jg`}],[`path`,{d:`M21 16h-3a2 2 0 0 0-2 2v3`,key:`177gqh`}],[`path`,{d:`M21 21v.01`,key:`ents32`}],[`path`,{d:`M12 7v3a2 2 0 0 1-2 2H7`,key:`8crl2c`}],[`path`,{d:`M3 12h.01`,key:`nlz23k`}],[`path`,{d:`M12 3h.01`,key:`n36tog`}],[`path`,{d:`M12 16v.01`,key:`133mhm`}],[`path`,{d:`M16 12h1`,key:`1slzba`}],[`path`,{d:`M21 12v.01`,key:`1lwtk9`}],[`path`,{d:`M12 21v-1`,key:`1880an`}]]),O=r(i(),1),k=(e,t)=>({method:e,body:JSON.stringify(t)}),A=encodeURIComponent,j={view:()=>c(`/api/channels`),update:(e,t)=>c(`/api/channels/${A(e)}`,k(`PUT`,t)),reload:()=>c(`/api/channels/reload`,{method:`POST`}),test:(e,t=``)=>c(`/api/channels/${A(e)}/test`,k(`POST`,{chat_id:t})),approve:e=>c(`/api/channels/pairing/${A(e)}/approve`,{method:`POST`}),deny:e=>c(`/api/channels/pairing/${A(e)}/deny`,{method:`POST`}),setDeliver:(e,t,n)=>c(`/api/channels/${A(e)}/chats/${A(t)}`,k(`PUT`,{deliver:n})),removeChat:(e,t)=>c(`/api/channels/${A(e)}/chats/${A(t)}`,{method:`DELETE`}),loginBegin:(e,t)=>c(`/api/channels/${A(e)}/login`,k(`POST`,{domain:t})),loginPoll:(e,t)=>c(`/api/channels/${A(e)}/login/${A(t)}`),deliver:e=>c(`/api/channels/deliver`,k(`POST`,{text:e}))},M=a(),N={feishu:[`In the Feishu developer console create a custom app and add the Bot capability.`,`Permissions: im:message, im:message.p2p_msg:readonly, im:message.group_at_msg:readonly, im:resource.`,`Events & callbacks: subscription mode Long connection; add im.message.receive_v1; callback mode Long connection; add card.action.trigger.`,`Copy App ID and App Secret from Credentials & Basic Info, then publish a version.`],dingtalk:[`In the DingTalk developer console create an internal app and add the Robot capability.`,`Set the message receive mode to Stream.`,`Copy Client ID (AppKey) and Client Secret (AppSecret) from Credentials & Basic Info.`,`Add the robot send-message permission and publish the version.`],wecom:[`In the WeCom admin console open Intelligent robot and create a robot in API mode with a long connection.`,`Copy the Bot ID and Secret.`,`Add the robot to the chats where it should listen.`],telegram:[`In Telegram talk to @BotFather, send /newbot and copy the token.`,`Direct messages work at once. For a group, add the bot to it; with Group Privacy on it only hears @-mentions and replies.`,`If api.telegram.org is out of reach from this computer, set a proxy below.`]},P={telegram:(0,M.jsx)(_,{size:20})};function F(e,t){let n=t.status.state;return e(n===`off`?`Off`:n===`unconfigured`?`Needs settings`:n===`missing_sdk`?`Not installed`:n===`connecting`?`Connecting…`:n===`connected`?`Connected`:`Error`)}function I({state:t}){return(0,M.jsx)(`span`,{"aria-hidden":!0,className:e(`inline-block h-2 w-2 rounded-full`,t===`connected`?`bg-emerald-500`:t===`error`?`bg-red-500`:t===`connecting`?`bg-amber-400`:`bg-border`)})}function L(){let{toast:e}=o(),t=n(),[r,i]=(0,O.useState)(null),[a,c]=(0,O.useState)(null),l=async()=>{try{i(await j.view()),c(null)}catch(e){c(e.message||`Could not load.`)}};(0,O.useEffect)(()=>{l();let e=window.setInterval(()=>void l(),5e3),t=()=>void l();return window.addEventListener(`focus`,t),()=>{window.clearInterval(e),window.removeEventListener(`focus`,t)}},[]);let u=async(t,n)=>{try{i(await t()),n&&e(n)}catch(t){e(t.message)}};return(0,M.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,M.jsx)(E,{title:t(`Chat apps`),description:t(`Talk to your Muse from the chat apps you already use. Nothing needs a public address: each app keeps a long connection open.`)}),(0,M.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-8`,children:[r===null&&a&&(0,M.jsx)(v,{message:a,onRetry:()=>void l()}),r===null&&!a&&(0,M.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,M.jsx)(s,{className:`animate-spin`,size:20})}),r&&r.pending.length>0&&(0,M.jsxs)(M.Fragment,{children:[(0,M.jsx)(T,{children:t(`Waiting for a yes`)}),(0,M.jsx)(x,{children:r.pending.map((e,t)=>(0,M.jsxs)(`div`,{children:[t>0&&(0,M.jsx)(y,{inset:16}),(0,M.jsx)(R,{p:e,label:r.channels.find(t=>t.name===e.channel)?.label??e.channel,onApprove:()=>u(()=>j.approve(e.code)),onDeny:()=>u(()=>j.deny(e.code))})]},e.code))})]}),r?.channels.map(e=>(0,M.jsx)(z,{c:e,run:u,reload:l},e.name)),r&&(0,M.jsx)(w,{children:t(`Each chat is one conversation of your Muse; its replies stream back as they are written.`)})]})]})}function R({p:t,label:r,onApprove:i,onDeny:a}){let o=n(),s=t.sender_name||t.sender_id;return(0,M.jsxs)(`div`,{className:`flex items-center gap-3 px-4 py-3`,children:[(0,M.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,M.jsx)(`p`,{className:`truncate text-[15px]`,children:o(`{name} wrote to the bot. Approve to let them talk to your Muse.`,{name:`${s} · ${r}`})}),(0,M.jsx)(`p`,{className:`text-[13px] text-muted`,children:o(`Code {code}`,{code:t.code})})]}),(0,M.jsxs)(`button`,{type:`button`,className:e(f,`px-3 py-2`),onClick:i,"aria-label":o(`Approve`),children:[(0,M.jsx)(l,{size:16}),` `,o(`Approve`)]}),(0,M.jsx)(`button`,{type:`button`,className:e(d,`px-3 py-2`),onClick:a,"aria-label":o(`Deny`),children:(0,M.jsx)(p,{size:16})})]})}function z({c:t,run:r,reload:i}){let a=n(),{toast:s}=o(),[c,l]=(0,O.useState)(!1),[d,f]=(0,O.useState)({}),[p,m]=(0,O.useState)([]),[_,v]=(0,O.useState)(null),[E,k]=(0,O.useState)(!1),[A,L]=(0,O.useState)(!1),[R,z]=(0,O.useState)(!1),[V,H]=(0,O.useState)(null),[U,W]=(0,O.useState)(``),[G,K]=(0,O.useState)(!1),q=(0,O.useRef)(null),J=Object.keys(d).length>0||_!==null,Y=_??t.allow_from.join(` `),X=async()=>{k(!0);try{let e={},n={};for(let[e,r]of Object.entries(d))(t.fields.find(t=>t.key===e)?.kind!==`secret`||r!==``||p.includes(e))&&(n[e]=r);Object.keys(n).length&&(e.settings=n),_!==null&&(e.allow_from=_.split(/[\n,]/).map(e=>e.trim()).filter(Boolean)),await r(()=>j.update(t.name,e),a(`Saved.`)),f({}),m([]),v(null)}finally{k(!1)}},Z=async()=>{L(!0);try{let e=await j.test(t.name);s(e.detail)}catch(e){s(e.message)}finally{L(!1)}},Q=()=>{q.current!==null&&window.clearTimeout(q.current),q.current=null};(0,O.useEffect)(()=>Q,[]);let $=async()=>{W(``);try{let e=await j.loginBegin(t.name,G?`lark`:`feishu`);H(e);let n=Date.now(),r=async()=>{try{let r=await j.loginPoll(t.name,e.device_code);if(r.status===`succeeded`){H(null),W(a(`The bot was created and is connecting.`)),await i();return}if(r.status===`failed`||Date.now()-n>e.expires_in*1e3){H(null),W(a(`The login did not finish ({error}).`,{error:r.error??`expired`}));return}}catch(e){H(null),W(e.message);return}q.current=window.setTimeout(()=>void r(),Math.max(2,e.interval)*1e3)};q.current=window.setTimeout(()=>void r(),Math.max(2,e.interval)*1e3)}catch(e){s(e.message)}},ee=()=>{Q(),H(null)},te=t.fields.filter(e=>e.required&&(e.kind===`secret`?!e.has_value:!e.value)).map(e=>e.label);return(0,M.jsxs)(M.Fragment,{children:[(0,M.jsx)(T,{children:t.label}),(0,M.jsxs)(x,{children:[(0,M.jsx)(C,{icon:P[t.name]??(0,M.jsx)(S,{size:20}),label:t.label,value:F(a,t),trailing:(0,M.jsx)(I,{state:t.status.state}),onClick:()=>l(e=>!e)}),(0,M.jsx)(y,{}),(0,M.jsx)(b,{label:a(`Switched on`),checked:t.enabled,disabled:!t.sdk_available&&!t.enabled,onChange:e=>void r(()=>j.update(t.name,{enabled:e}))}),t.status.state===`error`&&t.status.detail&&(0,M.jsx)(`p`,{className:`px-4 pb-3 text-[13px] leading-[18px] text-red-600 dark:text-red-400`,children:t.status.detail}),t.status.state===`unconfigured`&&(0,M.jsxs)(`p`,{className:`px-4 pb-3 text-[13px] leading-[18px] text-muted`,children:[a(`Missing`),`: `,t.status.detail||te.join(`, `)]}),!t.sdk_available&&(0,M.jsxs)(`div`,{className:`px-4 pb-3 text-[13px] leading-[18px] text-muted`,children:[a(`Install on the computer running nanoMuse, then switch it on again:`),(0,M.jsx)(`code`,{className:`mt-1 block select-all rounded-lg bg-surface-2 px-2 py-1 text-[12.5px] text-fg`,children:t.install})]}),(c||t.enabled)&&(0,M.jsxs)(M.Fragment,{children:[(0,M.jsx)(y,{inset:16}),(0,M.jsxs)(`div`,{className:`space-y-3 px-4 py-3`,children:[t.fields.map(e=>(0,M.jsx)(B,{f:e,value:d[e.key],onChange:t=>{f(n=>({...n,[e.key]:t})),t!==``&&m(t=>t.filter(t=>t!==e.key))},onClear:()=>{f(t=>({...t,[e.key]:``})),m(t=>t.includes(e.key)?t:[...t,e.key])}},e.key)),(0,M.jsxs)(`label`,{className:`block`,children:[(0,M.jsx)(`span`,{className:`mb-1 block text-[13px] text-muted`,children:a(`In groups, reply`)}),(0,M.jsxs)(`select`,{className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,value:t.group_policy,onChange:e=>void r(()=>j.update(t.name,{group_policy:e.target.value})),children:[(0,M.jsx)(`option`,{value:`mention`,children:a(`only when @-mentioned`)}),(0,M.jsx)(`option`,{value:`open`,children:a(`to every message`)})]})]}),(0,M.jsxs)(`label`,{className:`block`,children:[(0,M.jsx)(`span`,{className:`mb-1 block text-[13px] text-muted`,children:a(`Always allowed (ids, one per line)`)}),(0,M.jsx)(`textarea`,{className:e(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`min-h-[56px] font-mono text-[13px]`),value:Y,onChange:e=>v(e.target.value),rows:2}),(0,M.jsx)(`span`,{className:`mt-1 block text-[12.5px] leading-[17px] text-muted`,children:a(`People listed here never need a pairing code. A lone * lets anyone in; do that only on a bot nobody else can reach.`)})]}),(0,M.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,M.jsx)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,disabled:!J||E,onClick:()=>void X(),children:a(E?`Saving`:`Save`)}),(0,M.jsxs)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,disabled:A||t.status.state!==`connected`,onClick:()=>void Z(),children:[(0,M.jsx)(h,{size:14,className:A?`animate-spin`:``}),` `,a(A?`Sending`:`Send a test message`)]}),t.name===`feishu`&&!V&&(0,M.jsxs)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,onClick:()=>void $(),children:[(0,M.jsx)(D,{size:14}),` `,a(`Create the bot by QR code`)]}),(0,M.jsx)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,onClick:()=>z(e=>!e),"aria-expanded":R,children:a(`Set up`)})]}),t.name===`feishu`&&!V&&(0,M.jsxs)(`label`,{className:`flex items-center gap-2 text-[13px] text-muted`,children:[(0,M.jsx)(`input`,{type:`checkbox`,checked:G,onChange:e=>K(e.target.checked)}),a(`Lark (international) instead of 飞书`)]}),V&&(0,M.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 p-3 text-center`,children:[V.qr_png?(0,M.jsx)(`img`,{src:`data:image/png;base64,${V.qr_png}`,alt:`QR`,className:`mx-auto h-44 w-44 rounded-lg bg-white p-1`}):null,(0,M.jsx)(`p`,{className:`mt-2 text-[13px] text-muted`,children:a(`Scan with Feishu, confirm the new bot, and the App ID and Secret are saved for you.`)}),(0,M.jsx)(`p`,{className:`text-[13px] text-muted`,children:a(`Waiting for the scan…`)}),(0,M.jsxs)(`div`,{className:`mt-2 flex justify-center gap-2`,children:[(0,M.jsxs)(`a`,{href:V.url,target:`_blank`,rel:`noreferrer`,className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`px-3 py-1.5`),children:[(0,M.jsx)(u,{size:14}),` `,a(`Open in Feishu`)]}),(0,M.jsx)(`button`,{type:`button`,className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`px-3 py-1.5`),onClick:ee,children:a(`Cancel`)})]})]}),U&&(0,M.jsx)(`p`,{className:`text-[13px] text-muted`,children:U}),R&&(0,M.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 p-3 text-[13.5px] leading-[19px]`,children:[(0,M.jsx)(`ol`,{className:`list-decimal space-y-1 pl-5`,children:(N[t.name]??[]).map(e=>(0,M.jsx)(`li`,{children:a(e)},e))}),(0,M.jsxs)(`p`,{className:`mt-2 text-muted`,children:[a(`Then write to the bot from the app. The first message gets a pairing code; approve it here or run`),` `,(0,M.jsx)(`code`,{className:`rounded bg-surface px-1`,children:`nanomuse channels approve `})]}),t.console_url&&(0,M.jsxs)(`a`,{href:t.console_url,target:`_blank`,rel:`noreferrer`,className:`mt-2 inline-flex items-center gap-1 text-accent underline-offset-2 hover:underline`,children:[(0,M.jsx)(u,{size:14}),` `,a(`Open the console`)]})]})]}),(0,M.jsx)(y,{inset:16}),(0,M.jsx)(`p`,{className:`px-4 pt-3 text-[13px] text-muted`,children:a(`Paired chats`)}),t.paired.length===0?(0,M.jsx)(`p`,{className:`px-4 pb-3 pt-1 text-[13px] leading-[18px] text-muted`,children:a(`Nobody yet. Write to the bot from the app; a pairing code comes back, approve it here.`)}):t.paired.map(e=>(0,M.jsxs)(`div`,{className:`flex items-center`,children:[(0,M.jsx)(`div`,{className:`min-w-0 flex-1`,children:(0,M.jsx)(b,{label:e.sender_name||e.sender_id,value:a(`Deliver here`),checked:e.deliver,onChange:n=>void r(()=>j.setDeliver(t.name,e.sender_id,n))})}),(0,M.jsx)(`button`,{type:`button`,className:`mr-3 rounded-full p-2 text-muted hover:bg-surface-2`,"aria-label":a(`Remove`),onClick:()=>{window.confirm(a(`Remove {name}? They will need a new pairing code.`,{name:e.sender_name||e.sender_id}))&&r(()=>j.removeChat(t.name,e.sender_id))},children:(0,M.jsx)(g,{size:16})})]},e.sender_id))]})]}),(c||t.enabled)&&t.paired.length>0&&(0,M.jsx)(w,{children:a(`Chats with “deliver here” on also get what the Muse does on its own: check-ins, reminders, the results of background work, and the questions it needs answered.`)})]})}function B({f:t,value:r,onChange:i,onClear:a}){let o=n();if(t.kind===`choice`)return(0,M.jsxs)(`label`,{className:`block`,children:[(0,M.jsx)(`span`,{className:`mb-1 block text-[13px] text-muted`,children:t.label}),(0,M.jsx)(`select`,{className:m,value:r??String(t.value??t.default),onChange:e=>i(e.target.value),children:t.choices.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))}),t.help&&(0,M.jsx)(`span`,{className:`mt-1 block text-[12.5px] text-muted`,children:t.help})]});let s=t.kind===`secret`;return(0,M.jsxs)(`label`,{className:`block`,children:[(0,M.jsxs)(`span`,{className:`mb-1 block text-[13px] text-muted`,children:[t.label,t.required?` *`:``]}),(0,M.jsxs)(`div`,{className:`flex gap-2`,children:[(0,M.jsx)(`input`,{type:s?`password`:`text`,autoComplete:`off`,className:m,value:r??(s?``:String(t.value??``)),placeholder:s&&t.has_value?o(`Set; leave blank to keep`):t.help,onChange:e=>i(e.target.value)}),s&&t.has_value&&(0,M.jsx)(`button`,{type:`button`,className:e(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`shrink-0 px-3`),onClick:a,title:o(`Clear the saved value`),"aria-label":o(`Clear the saved value`),children:(0,M.jsx)(p,{size:14})})]}),t.help&&!s&&(0,M.jsx)(`span`,{className:`mt-1 block text-[12.5px] text-muted`,children:t.help})]})}export{L as ChannelsScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/CloudCard-D6djdKgw.js b/nanomuse/server/static/assets/CloudCard-D5foJm1I.js similarity index 95% rename from nanomuse/server/static/assets/CloudCard-D6djdKgw.js rename to nanomuse/server/static/assets/CloudCard-D5foJm1I.js index ff977361..73f1855b 100644 --- a/nanomuse/server/static/assets/CloudCard-D6djdKgw.js +++ b/nanomuse/server/static/assets/CloudCard-D5foJm1I.js @@ -1 +1 @@ -import{K as e,Q as t,Y as n,b as r,f as i,q as a,x as o}from"./MuseHeader-DHtaUVEa.js";import{a as s,t as c}from"./Form-heEKMZyj.js";import{_t as l,c as u,st as d,t as f}from"./index-CrH9-E2E.js";var p=t(n(),1),m=r();function h({account:t,onChange:n,compact:r=!1,useAsModel:h=!1}){let{toast:_}=i(),v=e(),[y,b]=(0,p.useState)(r||!t?.signed_in),[x,S]=(0,p.useState)(null),[C,w]=(0,p.useState)(null),T=!!t?.signed_in,E=async()=>{S(`model`),w(null);try{await o.cloudUseAsModel(),_(v(`The Cloud model is in use.`)),n()}catch(e){w(v(e.message))}finally{S(null)}},D=t?.models!==!1,O=async e=>{S(`switch`),w(null);try{await o.cloudModels(e),n()}catch(e){w(v(e.message))}finally{S(null)}},k=async()=>{if(window.confirm(v(`Sign out of nanoMuse Cloud on this device? The hub and the Cloud model stop working here until you sign in again.`))){S(`out`);try{await o.cloudSignOut(),n()}catch(e){_(e.message)}finally{S(null)}}},A=T?{text:v(`Signed in`),tone:`ok`}:{text:v(`Not signed in`),tone:`off`},j=T?t?.is_model?v(`{hint} · model and hub`,{hint:t.hint}):v(`{hint} · hub`,{hint:t?.hint??``}):v(`Free. One account for all your devices, and a model to start with.`);return(0,m.jsx)(c,{icon:(0,m.jsx)(l,{size:20}),title:v(`nanoMuse Cloud`),summary:j,status:A,open:y,onToggle:r?void 0:()=>b(e=>!e),children:T?(0,m.jsxs)(`div`,{className:`space-y-3`,children:[(0,m.jsx)(`p`,{className:`text-[13px] text-muted leading-relaxed`,children:v(`Signed in as {hint}. Your devices meet here; the Cloud model comes with a free allowance.`,{hint:t?.hint??``})}),(0,m.jsx)(`div`,{className:`-mx-4 border-y border-border/60`,children:(0,m.jsx)(u,{label:v(`Use nanoMuse Cloud models`),checked:D,disabled:x!==null,onChange:e=>void O(e)})}),(0,m.jsx)(`p`,{className:`text-[12.5px] text-muted leading-relaxed`,children:v(D?`nanoMuse Cloud is one of the sources for the chat, the hands, pictures and clips; what runs on it comes off your allowance.`:`Off: nothing runs on nanoMuse Cloud unless you choose it yourself. You stay signed in for sync and your devices.`)}),(0,m.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[!t?.is_model&&(0,m.jsxs)(`button`,{type:`button`,disabled:x!==null,onClick:()=>void E(),className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,children:[x===`model`?(0,m.jsx)(a,{size:15,className:`animate-spin`}):null,` `,v(`Use the Cloud model`)]}),(0,m.jsxs)(`button`,{type:`button`,disabled:x!==null,onClick:()=>void k(),className:s,children:[(0,m.jsx)(d,{size:15}),` `,v(`Sign out`)]})]}),C&&(0,m.jsx)(g,{text:C})]}):(0,m.jsx)(f,{useAsModel:h,autoFocus:!1,onSignedIn:()=>{_(v(`Signed in to nanoMuse Cloud.`)),n()}})})}function g({text:e}){return(0,m.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:e})}export{h as t}; \ No newline at end of file +import{K as e,Q as t,Y as n,b as r,f as i,q as a,x as o}from"./MuseHeader-RC--uR8o.js";import{a as s,t as c}from"./Form-H3s9oTHq.js";import{_t as l,c as u,st as d,t as f}from"./index-tYqPtwPX.js";var p=t(n(),1),m=r();function h({account:t,onChange:n,compact:r=!1,useAsModel:h=!1}){let{toast:_}=i(),v=e(),[y,b]=(0,p.useState)(r||!t?.signed_in),[x,S]=(0,p.useState)(null),[C,w]=(0,p.useState)(null),T=!!t?.signed_in,E=async()=>{S(`model`),w(null);try{await o.cloudUseAsModel(),_(v(`The Cloud model is in use.`)),n()}catch(e){w(v(e.message))}finally{S(null)}},D=t?.models!==!1,O=async e=>{S(`switch`),w(null);try{await o.cloudModels(e),n()}catch(e){w(v(e.message))}finally{S(null)}},k=async()=>{if(window.confirm(v(`Sign out of nanoMuse Cloud on this device? The hub and the Cloud model stop working here until you sign in again.`))){S(`out`);try{await o.cloudSignOut(),n()}catch(e){_(e.message)}finally{S(null)}}},A=T?{text:v(`Signed in`),tone:`ok`}:{text:v(`Not signed in`),tone:`off`},j=T?t?.is_model?v(`{hint} · model and hub`,{hint:t.hint}):v(`{hint} · hub`,{hint:t?.hint??``}):v(`Free. One account for all your devices, and a model to start with.`);return(0,m.jsx)(c,{icon:(0,m.jsx)(l,{size:20}),title:v(`nanoMuse Cloud`),summary:j,status:A,open:y,onToggle:r?void 0:()=>b(e=>!e),children:T?(0,m.jsxs)(`div`,{className:`space-y-3`,children:[(0,m.jsx)(`p`,{className:`text-[13px] text-muted leading-relaxed`,children:v(`Signed in as {hint}. Your devices meet here; the Cloud model comes with a free allowance.`,{hint:t?.hint??``})}),(0,m.jsx)(`div`,{className:`-mx-4 border-y border-border/60`,children:(0,m.jsx)(u,{label:v(`Use nanoMuse Cloud models`),checked:D,disabled:x!==null,onChange:e=>void O(e)})}),(0,m.jsx)(`p`,{className:`text-[12.5px] text-muted leading-relaxed`,children:v(D?`nanoMuse Cloud is one of the sources for the chat, the hands, pictures and clips; what runs on it comes off your allowance.`:`Off: nothing runs on nanoMuse Cloud unless you choose it yourself. You stay signed in for sync and your devices.`)}),(0,m.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[!t?.is_model&&(0,m.jsxs)(`button`,{type:`button`,disabled:x!==null,onClick:()=>void E(),className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,children:[x===`model`?(0,m.jsx)(a,{size:15,className:`animate-spin`}):null,` `,v(`Use the Cloud model`)]}),(0,m.jsxs)(`button`,{type:`button`,disabled:x!==null,onClick:()=>void k(),className:s,children:[(0,m.jsx)(d,{size:15}),` `,v(`Sign out`)]})]}),C&&(0,m.jsx)(g,{text:C})]}):(0,m.jsx)(f,{useAsModel:h,autoFocus:!1,onSignedIn:()=>{_(v(`Signed in to nanoMuse Cloud.`)),n()}})})}function g({text:e}){return(0,m.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:e})}export{h as t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/CodingScreen-BpFj4V_H.js b/nanomuse/server/static/assets/CodingScreen-BLVF-Er8.js similarity index 89% rename from nanomuse/server/static/assets/CodingScreen-BpFj4V_H.js rename to nanomuse/server/static/assets/CodingScreen-BLVF-Er8.js index d68ee529..79618f51 100644 --- a/nanomuse/server/static/assets/CodingScreen-BpFj4V_H.js +++ b/nanomuse/server/static/assets/CodingScreen-BLVF-Er8.js @@ -1,3 +1,3 @@ -import{E as e,J as t,K as n,Q as r,Y as i,b as a,f as o,k as s,q as c,r as l,x as u}from"./MuseHeader-DHtaUVEa.js";import{a as d,i as f,r as p}from"./Form-heEKMZyj.js";import{t as m}from"./refresh-cw-DQelymc2.js";import{C as h,H as g,P as _,R as v,St as y,V as b,X as x,rt as S,wt as C,yt as w}from"./index-CrH9-E2E.js";import{t as T}from"./BackBar-B5YUZVK1.js";var E=t(`folder-open`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),D=r(i(),1),O=a(),k={cursor:{label:`Cursor`,tone:`from-slate-700 to-slate-900`,glyph:`C`},codex:{label:`Codex`,tone:`from-emerald-500 to-teal-700`,glyph:`X`},claude:{label:`Claude Code`,tone:`from-orange-400 to-amber-600`,glyph:`A`}};function A(){let{state:t,toast:r}=o(),i=n(),[a,d]=(0,D.useState)(``),[f,p]=(0,D.useState)(null),[h,_]=(0,D.useState)([]),[v,y]=(0,D.useState)(null),[b,C]=(0,D.useState)(``),[A,M]=(0,D.useState)(!1),[F,V]=(0,D.useState)(null),[H,U]=(0,D.useState)(null),[W,G]=(0,D.useState)(null),K=(0,D.useMemo)(()=>(t.hub?.devices??[]).filter(e=>!e.this&&e.online&&e.kind!==`web`&&(e.actions??[]).includes(`coding.sessions`)),[t.hub?.devices]),q=(0,D.useCallback)(async()=>{M(!0),V(null);try{let[e,t]=await Promise.all([u.coding(a),u.codingSessions({device:a,limit:40})]);p(e.agents),_(e.runs??[]),y(t.sessions)}catch(e){V(e.message),p([]),y([])}finally{M(!1)}},[a]);(0,D.useEffect)(()=>{q()},[q]);let J=Object.values(t.codingLive),Y=J.filter(e=>e.run.status!==`running`).length;(0,D.useEffect)(()=>{Y&&q()},[Y]);let X=(v??[]).filter(e=>(!b||e.agent===b)&&(e.messages>0||e.status===`running`)),Z=(f??[]).filter(e=>e.installed),Q=new Set(J.filter(e=>e.run.status===`running`&&(e.run.device??``)===(a?z(K,a):``)).map(e=>e.run.session_id||e.run.asked_session_id));return(0,O.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,O.jsx)(T,{title:i(`Coding agents`),description:i(`Cursor, Codex and Claude Code: see what they are doing, and tell them things from anywhere.`),actions:(0,O.jsx)(l,{small:!0,onClick:()=>void q(),label:i(`Refresh`),children:(0,O.jsx)(m,{size:18,className:A?`animate-spin`:``})})}),(0,O.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-4`,children:[(K.length>0||a)&&(0,O.jsxs)(`div`,{className:`no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4`,children:[(0,O.jsx)(R,{active:!a,onClick:()=>d(``),icon:(0,O.jsx)(g,{size:14}),label:t.hub?.device?.name?i(`This one · {name}`,{name:t.hub.device.name}):i(`This computer`)}),K.map(e=>(0,O.jsx)(R,{active:a===e.id,onClick:()=>d(e.id),icon:(0,O.jsx)(S,{size:14}),label:e.name},e.id))]}),f&&(0,O.jsx)(`div`,{className:`grid grid-cols-3 gap-2`,children:f.map(t=>{let n=k[t.id];return(0,O.jsxs)(`button`,{type:`button`,disabled:!t.installed,onClick:()=>C(e=>e===t.id?``:t.id),className:e(`rounded-3xl border p-3 text-left transition`,b===t.id?`border-accent/40 bg-accent/5`:`border-border/70 bg-surface`,!t.installed&&`opacity-45`),children:[(0,O.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,O.jsx)(I,{agent:t.id,size:30}),t.running>0&&(0,O.jsxs)(`span`,{className:`flex items-center gap-1 rounded-full bg-emerald-500/12 px-1.5 py-0.5 text-[10.5px] font-semibold text-emerald-700 dark:text-emerald-300`,children:[(0,O.jsx)(`span`,{className:`h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500`}),` `,t.running]})]}),(0,O.jsx)(`div`,{className:`mt-2 truncate text-[13.5px] font-semibold`,children:n?.label??t.name}),(0,O.jsx)(`div`,{className:`truncate text-[11.5px] text-muted`,children:t.installed?t.cli?t.version||i(`installed`):i(`chats only`):i(`not found`)})]},t.id)})}),F&&(0,O.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:F}),J.filter(e=>e.run.status===`running`).length>0&&(0,O.jsxs)(`section`,{className:`space-y-2`,children:[(0,O.jsx)(`h2`,{className:`px-1 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:i(`Working now`)}),J.filter(e=>e.run.status===`running`).map(e=>(0,O.jsx)(N,{live:e,onOpen:()=>U({agent:e.run.agent,id:e.run.session_id||e.run.asked_session_id||``})},e.run.id))]}),(0,O.jsxs)(`section`,{className:`space-y-2`,children:[(0,O.jsxs)(`div`,{className:`flex items-center justify-between px-1`,children:[(0,O.jsx)(`h2`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted`,children:b?i(`{agent} chats`,{agent:k[b]?.label??b}):i(`Recent chats`)}),Z.some(e=>e.cli)&&(0,O.jsxs)(`button`,{type:`button`,onClick:()=>G(b&&Z.find(e=>e.id===b&&e.cli)?b:Z.find(e=>e.cli)?.id??null),className:`flex items-center gap-1 text-[12.5px] font-medium text-accent`,children:[(0,O.jsx)(x,{size:14}),` `,i(`New chat`)]})]}),v===null?(0,O.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,O.jsx)(c,{size:20,className:`animate-spin`})}):X.length===0?(0,O.jsx)(`div`,{className:`rounded-3xl border border-dashed border-border/80 px-4 py-8 text-center text-[13.5px] text-muted`,children:f&&f.every(e=>!e.installed)?i(`No coding agent found on this computer. Install Cursor, Codex or Claude Code and their chats appear here.`):i(`No chats yet.`)}):(0,O.jsx)(`ul`,{className:`divide-y divide-border/60 overflow-hidden rounded-3xl border border-border/70 bg-surface`,children:X.map(e=>(0,O.jsx)(`li`,{children:(0,O.jsxs)(`button`,{type:`button`,onClick:()=>U({agent:e.agent,id:e.id}),className:`flex w-full items-center gap-3 px-3.5 py-3 text-left hover:bg-surface-2/60 active:bg-surface-2`,children:[(0,O.jsx)(I,{agent:e.agent,size:34}),(0,O.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,O.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,O.jsx)(`span`,{className:`truncate text-[14.5px] font-medium`,children:e.title||i(`Untitled chat`)}),(0,O.jsx)(L,{status:Q.has(e.id)?`running`:e.status})]}),(0,O.jsxs)(`span`,{className:`mt-0.5 flex items-center gap-1.5 truncate text-[12px] text-muted`,children:[e.workspace&&(0,O.jsxs)(`span`,{className:`flex min-w-0 items-center gap-1`,children:[(0,O.jsx)(E,{size:11}),` `,(0,O.jsx)(`span`,{className:`truncate`,children:B(e.workspace)})]}),(0,O.jsxs)(`span`,{children:[`· `,s(e.updated_at)]}),(0,O.jsxs)(`span`,{children:[`· `,i(`{n} messages`,{n:e.messages})]})]})]}),(0,O.jsx)(w,{size:16,className:`shrink-0 text-muted`})]})},`${e.agent}-${e.id}`))})]}),h.filter(e=>e.status!==`running`).length>0&&(0,O.jsxs)(`section`,{className:`space-y-2`,children:[(0,O.jsx)(`h2`,{className:`px-1 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:i(`Sent through nanoMuse`)}),(0,O.jsx)(`ul`,{className:`divide-y divide-border/60 overflow-hidden rounded-3xl border border-border/70 bg-surface`,children:h.filter(e=>e.status!==`running`).slice(0,8).map(e=>(0,O.jsxs)(`li`,{className:`flex items-center gap-3 px-3.5 py-2.5`,children:[(0,O.jsx)(I,{agent:e.agent,size:28}),(0,O.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,O.jsx)(`span`,{className:`block truncate text-[13.5px]`,children:e.text}),(0,O.jsxs)(`span`,{className:`block truncate text-[11.5px] text-muted`,children:[e.status===`done`?i(`done`):e.status===`failed`?i(`failed`):i(`stopped`),` · `,s(e.started_at),e.tools?` · ${i(`{n} steps`,{n:e.tools})}`:``,e.error?` · ${e.error}`:``]})]}),e.session_id&&(0,O.jsx)(`button`,{type:`button`,onClick:()=>U({agent:e.agent,id:e.session_id}),className:`text-[12.5px] font-medium text-accent`,children:i(`Open`)})]},e.id))})]})]}),H&&(0,O.jsx)(j,{agent:H.agent,id:H.id,device:a,deviceLabel:a?z(K,a):``,onClose:()=>U(null)}),W&&(0,O.jsx)(P,{agent:W,agents:Z.filter(e=>e.cli),device:a,onClose:()=>G(null),onStarted:e=>{G(null),r(i(`Sent to {agent}.`,{agent:k[e.agent]?.label??e.agent}))}})]})}function j({agent:t,id:r,device:i,deviceLabel:a,onClose:s}){let{state:l,toast:d}=o(),f=n(),[m,g]=(0,D.useState)(null),[v,y]=(0,D.useState)(null),[x,S]=(0,D.useState)(``),[w,T]=(0,D.useState)(!1),E=(0,D.useRef)(null),A=(0,D.useCallback)(async()=>{try{g(await u.codingSession(t,r,i)),y(null)}catch(e){y(e.message)}},[t,r,i]);(0,D.useEffect)(()=>{A()},[A]);let j=Object.values(l.codingLive).filter(e=>e.run.agent===t&&(e.run.session_id===r||e.run.asked_session_id===r)),N=j.find(e=>e.run.status===`running`),P=j.filter(e=>e.run.status!==`running`).length;(0,D.useEffect)(()=>{P&&A()},[P]),(0,D.useEffect)(()=>{let e=E.current;e&&(e.scrollTop=e.scrollHeight)},[m?.transcript?.length,N?.events.length,N?.current.length]);let L=async()=>{let e=x.trim();if(e&&!w){T(!0);try{await u.codingSend({agent:t,text:e,session_id:r,workspace:m?.workspace??``,device:i}),S(``)}catch(e){d(e.message)}finally{T(!1)}}},R=k[t],z=m?.transcript??[],V=!!m||!!N,H=!!m&&!m.resumable&&!N;return(0,O.jsx)(h,{open:!0,onClose:s,title:(0,O.jsxs)(`span`,{className:`flex items-center gap-2.5`,children:[(0,O.jsx)(I,{agent:t,size:30}),(0,O.jsxs)(`span`,{className:`min-w-0`,children:[(0,O.jsx)(`span`,{className:`block truncate`,children:m?.title||R?.label||t}),(0,O.jsxs)(`span`,{className:`block truncate text-[12px] font-normal text-muted`,children:[a?`${a} · `:``,m?.workspace?B(m.workspace):R?.label,m?.source&&m.source!==`ide`&&m.source!==`cli`?` · ${m.source}`:``]})]})]}),footer:(0,O.jsxs)(O.Fragment,{children:[H&&(0,O.jsx)(`p`,{className:`mb-1.5 px-1 text-[11.5px] leading-snug text-muted`,children:f(`Made in the IDE: your message starts a new chat in the same workspace, with the last exchange quoted.`)}),(0,O.jsxs)(`form`,{className:`flex items-end gap-2`,onSubmit:e=>{e.preventDefault(),L()},children:[(0,O.jsx)(`textarea`,{value:x,onChange:e=>S(e.target.value),onKeyDown:e=>{e.key===`Enter`&&!e.shiftKey&&(e.preventDefault(),L())},rows:1,disabled:!V||!!N,placeholder:N?f(`{agent} is working…`,{agent:R?.label??t}):m?H?f(`Continue in a new chat…`):f(`Tell {agent}…`,{agent:R?.label??t}):`…`,className:e(p,`max-h-32 min-h-[44px] resize-none py-2.5`)}),N?(0,O.jsx)(`button`,{type:`button`,onClick:()=>void u.codingStop(N.run.id,i).catch(e=>d(e.message)),"aria-label":f(`Stop`),className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-rose-500 text-white`,children:(0,O.jsx)(b,{size:16})}):(0,O.jsx)(`button`,{type:`submit`,disabled:!x.trim()||w||!V,"aria-label":f(`Send`),className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-accent text-white disabled:opacity-40`,children:w?(0,O.jsx)(c,{size:16,className:`animate-spin`}):(0,O.jsx)(C,{size:18})})]})]}),children:(0,O.jsxs)(`div`,{ref:E,className:`space-y-2.5 py-1`,children:[v&&(0,O.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:v}),!m&&!v&&(0,O.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,O.jsx)(c,{size:20,className:`animate-spin`})}),z.map((e,n)=>(0,O.jsx)(F,{who:e.role===`user`?`you`:`agent`,agent:t,children:e.role===`user`?(0,O.jsx)(`span`,{className:`whitespace-pre-wrap`,children:e.text}):(0,O.jsx)(_,{text:e.text})},n)),j.filter(e=>!(e.run.status===`done`&&(e.run.resumed||!e.run.asked_session_id)&&z.some(t=>t.role===`user`&&t.text.trim()===e.run.text.trim()))).map(e=>(0,O.jsx)(M,{live:e,agent:t,hideFinishedText:z.length>0&&e.run.status===`done`},e.run.id)),m&&z.length===0&&j.length===0&&(0,O.jsx)(`p`,{className:`py-6 text-center text-[13px] text-muted`,children:f(`Nothing readable in this chat yet.`)})]})})}function M({live:e,agent:t,hideFinishedText:r}){let i=n(),{run:a,events:o,current:s}=e,l=o.filter(e=>e.kind===`tool`),u=k[t];return(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(F,{who:`you`,agent:t,badge:i(`via nanoMuse`),children:(0,O.jsx)(`span`,{className:`whitespace-pre-wrap`,children:a.text})}),(l.length>0||a.status===`running`)&&(0,O.jsxs)(`div`,{className:`mx-1 rounded-2xl border border-border/60 bg-surface-2/40 px-3 py-2 text-[12.5px]`,children:[(0,O.jsxs)(`div`,{className:`flex items-center gap-1.5 font-medium text-fg/80`,children:[a.status===`running`?(0,O.jsx)(c,{size:13,className:`animate-spin text-accent`}):(0,O.jsx)(v,{size:13,className:`text-muted`}),a.status===`running`?i(`{agent} is working…`,{agent:u?.label??t}):i(`{n} steps`,{n:l.length}),a.device&&(0,O.jsxs)(`span`,{className:`ml-auto flex items-center gap-1 text-muted`,children:[(0,O.jsx)(S,{size:11}),` `,a.device]})]}),l.length>0&&(0,O.jsx)(`ul`,{className:`mt-1.5 max-h-28 space-y-0.5 overflow-y-auto font-mono text-[11.5px] text-muted`,children:l.slice(-12).map((e,t)=>(0,O.jsx)(`li`,{className:`truncate`,children:e.text},t))})]}),(s||a.output&&!r)&&(0,O.jsxs)(F,{who:`agent`,agent:t,children:[(0,O.jsx)(_,{text:a.status===`running`?(a.output?a.output+` +import{E as e,J as t,K as n,Q as r,Y as i,b as a,f as o,k as s,q as c,r as l,x as u}from"./MuseHeader-RC--uR8o.js";import{a as d,i as f,r as p}from"./Form-H3s9oTHq.js";import{t as m}from"./refresh-cw-CxZ3ZdI3.js";import{C as h,H as g,P as _,R as v,St as y,V as b,X as x,rt as S,wt as C,yt as w}from"./index-tYqPtwPX.js";import{t as T}from"./BackBar-JEuC8Q_e.js";var E=t(`folder-open`,[[`path`,{d:`m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2`,key:`usdka0`}]]),D=r(i(),1),O=a(),k={cursor:{label:`Cursor`,tone:`from-slate-700 to-slate-900`,glyph:`C`},codex:{label:`Codex`,tone:`from-emerald-500 to-teal-700`,glyph:`X`},claude:{label:`Claude Code`,tone:`from-orange-400 to-amber-600`,glyph:`A`}};function A(){let{state:t,toast:r}=o(),i=n(),[a,d]=(0,D.useState)(``),[f,p]=(0,D.useState)(null),[h,_]=(0,D.useState)([]),[v,y]=(0,D.useState)(null),[b,C]=(0,D.useState)(``),[A,M]=(0,D.useState)(!1),[F,V]=(0,D.useState)(null),[H,U]=(0,D.useState)(null),[W,G]=(0,D.useState)(null),K=(0,D.useMemo)(()=>(t.hub?.devices??[]).filter(e=>!e.this&&e.online&&e.kind!==`web`&&(e.actions??[]).includes(`coding.sessions`)),[t.hub?.devices]),q=(0,D.useCallback)(async()=>{M(!0),V(null);try{let[e,t]=await Promise.all([u.coding(a),u.codingSessions({device:a,limit:40})]);p(e.agents),_(e.runs??[]),y(t.sessions)}catch(e){V(e.message),p([]),y([])}finally{M(!1)}},[a]);(0,D.useEffect)(()=>{q()},[q]);let J=Object.values(t.codingLive),Y=J.filter(e=>e.run.status!==`running`).length;(0,D.useEffect)(()=>{Y&&q()},[Y]);let X=(v??[]).filter(e=>(!b||e.agent===b)&&(e.messages>0||e.status===`running`)),Z=(f??[]).filter(e=>e.installed),Q=new Set(J.filter(e=>e.run.status===`running`&&(e.run.device??``)===(a?z(K,a):``)).map(e=>e.run.session_id||e.run.asked_session_id));return(0,O.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,O.jsx)(T,{title:i(`Coding agents`),description:i(`Cursor, Codex and Claude Code: see what they are doing, and tell them things from anywhere.`),actions:(0,O.jsx)(l,{small:!0,onClick:()=>void q(),label:i(`Refresh`),children:(0,O.jsx)(m,{size:18,className:A?`animate-spin`:``})})}),(0,O.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-4`,children:[(K.length>0||a)&&(0,O.jsxs)(`div`,{className:`no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4`,children:[(0,O.jsx)(R,{active:!a,onClick:()=>d(``),icon:(0,O.jsx)(g,{size:14}),label:t.hub?.device?.name?i(`This one · {name}`,{name:t.hub.device.name}):i(`This computer`)}),K.map(e=>(0,O.jsx)(R,{active:a===e.id,onClick:()=>d(e.id),icon:(0,O.jsx)(S,{size:14}),label:e.name},e.id))]}),f&&(0,O.jsx)(`div`,{className:`grid grid-cols-3 gap-2`,children:f.map(t=>{let n=k[t.id];return(0,O.jsxs)(`button`,{type:`button`,disabled:!t.installed,onClick:()=>C(e=>e===t.id?``:t.id),className:e(`rounded-3xl border p-3 text-left transition`,b===t.id?`border-accent/40 bg-accent/5`:`border-border/70 bg-surface`,!t.installed&&`opacity-45`),children:[(0,O.jsxs)(`div`,{className:`flex items-center justify-between`,children:[(0,O.jsx)(I,{agent:t.id,size:30}),t.running>0&&(0,O.jsxs)(`span`,{className:`flex items-center gap-1 rounded-full bg-emerald-500/12 px-1.5 py-0.5 text-[10.5px] font-semibold text-emerald-700 dark:text-emerald-300`,children:[(0,O.jsx)(`span`,{className:`h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500`}),` `,t.running]})]}),(0,O.jsx)(`div`,{className:`mt-2 truncate text-[13.5px] font-semibold`,children:n?.label??t.name}),(0,O.jsx)(`div`,{className:`truncate text-[11.5px] text-muted`,children:t.installed?t.cli?t.version||i(`installed`):i(`chats only`):i(`not found`)})]},t.id)})}),F&&(0,O.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:F}),J.filter(e=>e.run.status===`running`).length>0&&(0,O.jsxs)(`section`,{className:`space-y-2`,children:[(0,O.jsx)(`h2`,{className:`px-1 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:i(`Working now`)}),J.filter(e=>e.run.status===`running`).map(e=>(0,O.jsx)(N,{live:e,onOpen:()=>U({agent:e.run.agent,id:e.run.session_id||e.run.asked_session_id||``})},e.run.id))]}),(0,O.jsxs)(`section`,{className:`space-y-2`,children:[(0,O.jsxs)(`div`,{className:`flex items-center justify-between px-1`,children:[(0,O.jsx)(`h2`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted`,children:b?i(`{agent} chats`,{agent:k[b]?.label??b}):i(`Recent chats`)}),Z.some(e=>e.cli)&&(0,O.jsxs)(`button`,{type:`button`,onClick:()=>G(b&&Z.find(e=>e.id===b&&e.cli)?b:Z.find(e=>e.cli)?.id??null),className:`flex items-center gap-1 text-[12.5px] font-medium text-accent`,children:[(0,O.jsx)(x,{size:14}),` `,i(`New chat`)]})]}),v===null?(0,O.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,O.jsx)(c,{size:20,className:`animate-spin`})}):X.length===0?(0,O.jsx)(`div`,{className:`rounded-3xl border border-dashed border-border/80 px-4 py-8 text-center text-[13.5px] text-muted`,children:f&&f.every(e=>!e.installed)?i(`No coding agent found on this computer. Install Cursor, Codex or Claude Code and their chats appear here.`):i(`No chats yet.`)}):(0,O.jsx)(`ul`,{className:`divide-y divide-border/60 overflow-hidden rounded-3xl border border-border/70 bg-surface`,children:X.map(e=>(0,O.jsx)(`li`,{children:(0,O.jsxs)(`button`,{type:`button`,onClick:()=>U({agent:e.agent,id:e.id}),className:`flex w-full items-center gap-3 px-3.5 py-3 text-left hover:bg-surface-2/60 active:bg-surface-2`,children:[(0,O.jsx)(I,{agent:e.agent,size:34}),(0,O.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,O.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,O.jsx)(`span`,{className:`truncate text-[14.5px] font-medium`,children:e.title||i(`Untitled chat`)}),(0,O.jsx)(L,{status:Q.has(e.id)?`running`:e.status})]}),(0,O.jsxs)(`span`,{className:`mt-0.5 flex items-center gap-1.5 truncate text-[12px] text-muted`,children:[e.workspace&&(0,O.jsxs)(`span`,{className:`flex min-w-0 items-center gap-1`,children:[(0,O.jsx)(E,{size:11}),` `,(0,O.jsx)(`span`,{className:`truncate`,children:B(e.workspace)})]}),(0,O.jsxs)(`span`,{children:[`· `,s(e.updated_at)]}),(0,O.jsxs)(`span`,{children:[`· `,i(`{n} messages`,{n:e.messages})]})]})]}),(0,O.jsx)(w,{size:16,className:`shrink-0 text-muted`})]})},`${e.agent}-${e.id}`))})]}),h.filter(e=>e.status!==`running`).length>0&&(0,O.jsxs)(`section`,{className:`space-y-2`,children:[(0,O.jsx)(`h2`,{className:`px-1 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:i(`Sent through nanoMuse`)}),(0,O.jsx)(`ul`,{className:`divide-y divide-border/60 overflow-hidden rounded-3xl border border-border/70 bg-surface`,children:h.filter(e=>e.status!==`running`).slice(0,8).map(e=>(0,O.jsxs)(`li`,{className:`flex items-center gap-3 px-3.5 py-2.5`,children:[(0,O.jsx)(I,{agent:e.agent,size:28}),(0,O.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,O.jsx)(`span`,{className:`block truncate text-[13.5px]`,children:e.text}),(0,O.jsxs)(`span`,{className:`block truncate text-[11.5px] text-muted`,children:[e.status===`done`?i(`done`):e.status===`failed`?i(`failed`):i(`stopped`),` · `,s(e.started_at),e.tools?` · ${i(`{n} steps`,{n:e.tools})}`:``,e.error?` · ${e.error}`:``]})]}),e.session_id&&(0,O.jsx)(`button`,{type:`button`,onClick:()=>U({agent:e.agent,id:e.session_id}),className:`text-[12.5px] font-medium text-accent`,children:i(`Open`)})]},e.id))})]})]}),H&&(0,O.jsx)(j,{agent:H.agent,id:H.id,device:a,deviceLabel:a?z(K,a):``,onClose:()=>U(null)}),W&&(0,O.jsx)(P,{agent:W,agents:Z.filter(e=>e.cli),device:a,onClose:()=>G(null),onStarted:e=>{G(null),r(i(`Sent to {agent}.`,{agent:k[e.agent]?.label??e.agent}))}})]})}function j({agent:t,id:r,device:i,deviceLabel:a,onClose:s}){let{state:l,toast:d}=o(),f=n(),[m,g]=(0,D.useState)(null),[v,y]=(0,D.useState)(null),[x,S]=(0,D.useState)(``),[w,T]=(0,D.useState)(!1),E=(0,D.useRef)(null),A=(0,D.useCallback)(async()=>{try{g(await u.codingSession(t,r,i)),y(null)}catch(e){y(e.message)}},[t,r,i]);(0,D.useEffect)(()=>{A()},[A]);let j=Object.values(l.codingLive).filter(e=>e.run.agent===t&&(e.run.session_id===r||e.run.asked_session_id===r)),N=j.find(e=>e.run.status===`running`),P=j.filter(e=>e.run.status!==`running`).length;(0,D.useEffect)(()=>{P&&A()},[P]),(0,D.useEffect)(()=>{let e=E.current;e&&(e.scrollTop=e.scrollHeight)},[m?.transcript?.length,N?.events.length,N?.current.length]);let L=async()=>{let e=x.trim();if(e&&!w){T(!0);try{await u.codingSend({agent:t,text:e,session_id:r,workspace:m?.workspace??``,device:i}),S(``)}catch(e){d(e.message)}finally{T(!1)}}},R=k[t],z=m?.transcript??[],V=!!m||!!N,H=!!m&&!m.resumable&&!N;return(0,O.jsx)(h,{open:!0,onClose:s,title:(0,O.jsxs)(`span`,{className:`flex items-center gap-2.5`,children:[(0,O.jsx)(I,{agent:t,size:30}),(0,O.jsxs)(`span`,{className:`min-w-0`,children:[(0,O.jsx)(`span`,{className:`block truncate`,children:m?.title||R?.label||t}),(0,O.jsxs)(`span`,{className:`block truncate text-[12px] font-normal text-muted`,children:[a?`${a} · `:``,m?.workspace?B(m.workspace):R?.label,m?.source&&m.source!==`ide`&&m.source!==`cli`?` · ${m.source}`:``]})]})]}),footer:(0,O.jsxs)(O.Fragment,{children:[H&&(0,O.jsx)(`p`,{className:`mb-1.5 px-1 text-[11.5px] leading-snug text-muted`,children:f(`Made in the IDE: your message starts a new chat in the same workspace, with the last exchange quoted.`)}),(0,O.jsxs)(`form`,{className:`flex items-end gap-2`,onSubmit:e=>{e.preventDefault(),L()},children:[(0,O.jsx)(`textarea`,{value:x,onChange:e=>S(e.target.value),onKeyDown:e=>{e.key===`Enter`&&!e.shiftKey&&(e.preventDefault(),L())},rows:1,disabled:!V||!!N,placeholder:N?f(`{agent} is working…`,{agent:R?.label??t}):m?H?f(`Continue in a new chat…`):f(`Tell {agent}…`,{agent:R?.label??t}):`…`,className:e(p,`max-h-32 min-h-[44px] resize-none py-2.5`)}),N?(0,O.jsx)(`button`,{type:`button`,onClick:()=>void u.codingStop(N.run.id,i).catch(e=>d(e.message)),"aria-label":f(`Stop`),className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-rose-500 text-white`,children:(0,O.jsx)(b,{size:16})}):(0,O.jsx)(`button`,{type:`submit`,disabled:!x.trim()||w||!V,"aria-label":f(`Send`),className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-accent text-white disabled:opacity-40`,children:w?(0,O.jsx)(c,{size:16,className:`animate-spin`}):(0,O.jsx)(C,{size:18})})]})]}),children:(0,O.jsxs)(`div`,{ref:E,className:`space-y-2.5 py-1`,children:[v&&(0,O.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:v}),!m&&!v&&(0,O.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,O.jsx)(c,{size:20,className:`animate-spin`})}),z.map((e,n)=>(0,O.jsx)(F,{who:e.role===`user`?`you`:`agent`,agent:t,children:e.role===`user`?(0,O.jsx)(`span`,{className:`whitespace-pre-wrap`,children:e.text}):(0,O.jsx)(_,{text:e.text})},n)),j.filter(e=>!(e.run.status===`done`&&(e.run.resumed||!e.run.asked_session_id)&&z.some(t=>t.role===`user`&&t.text.trim()===e.run.text.trim()))).map(e=>(0,O.jsx)(M,{live:e,agent:t,hideFinishedText:z.length>0&&e.run.status===`done`},e.run.id)),m&&z.length===0&&j.length===0&&(0,O.jsx)(`p`,{className:`py-6 text-center text-[13px] text-muted`,children:f(`Nothing readable in this chat yet.`)})]})})}function M({live:e,agent:t,hideFinishedText:r}){let i=n(),{run:a,events:o,current:s}=e,l=o.filter(e=>e.kind===`tool`),u=k[t];return(0,O.jsxs)(O.Fragment,{children:[(0,O.jsx)(F,{who:`you`,agent:t,badge:i(`via nanoMuse`),children:(0,O.jsx)(`span`,{className:`whitespace-pre-wrap`,children:a.text})}),(l.length>0||a.status===`running`)&&(0,O.jsxs)(`div`,{className:`mx-1 rounded-2xl border border-border/60 bg-surface-2/40 px-3 py-2 text-[12.5px]`,children:[(0,O.jsxs)(`div`,{className:`flex items-center gap-1.5 font-medium text-fg/80`,children:[a.status===`running`?(0,O.jsx)(c,{size:13,className:`animate-spin text-accent`}):(0,O.jsx)(v,{size:13,className:`text-muted`}),a.status===`running`?i(`{agent} is working…`,{agent:u?.label??t}):i(`{n} steps`,{n:l.length}),a.device&&(0,O.jsxs)(`span`,{className:`ml-auto flex items-center gap-1 text-muted`,children:[(0,O.jsx)(S,{size:11}),` `,a.device]})]}),l.length>0&&(0,O.jsx)(`ul`,{className:`mt-1.5 max-h-28 space-y-0.5 overflow-y-auto font-mono text-[11.5px] text-muted`,children:l.slice(-12).map((e,t)=>(0,O.jsx)(`li`,{className:`truncate`,children:e.text},t))})]}),(s||a.output&&!r)&&(0,O.jsxs)(F,{who:`agent`,agent:t,children:[(0,O.jsx)(_,{text:a.status===`running`?(a.output?a.output+` -`:``)+s:a.output}),a.status===`running`&&(0,O.jsx)(`span`,{className:`ml-1 inline-block h-3.5 w-1 animate-pulse bg-accent align-middle`})]}),a.status===`failed`&&(0,O.jsx)(`div`,{className:`mx-1 rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:a.error||i(`failed`)}),a.status===`stopped`&&(0,O.jsx)(`div`,{className:`mx-1 text-center text-[12px] text-muted`,children:i(`stopped`)}),!a.resumed&&a.asked_session_id&&a.status!==`running`&&(0,O.jsx)(`div`,{className:`mx-1 text-center text-[12px] text-muted`,children:i(`Continued as a new chat: the agent could not reopen the old one.`)})]})}function N({live:e,onOpen:t}){let r=n(),{run:i,events:a,current:o}=e,s=[...a].reverse().find(e=>e.kind===`tool`);return(0,O.jsxs)(`button`,{type:`button`,onClick:t,className:`flex w-full items-center gap-3 rounded-3xl border border-accent/25 bg-accent/5 px-3.5 py-3 text-left`,children:[(0,O.jsx)(I,{agent:i.agent,size:34}),(0,O.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,O.jsx)(`span`,{className:`block truncate text-[14px] font-medium`,children:i.text}),(0,O.jsxs)(`span`,{className:`mt-0.5 flex items-center gap-1.5 truncate text-[12px] text-muted`,children:[(0,O.jsx)(c,{size:11,className:`animate-spin text-accent`}),(0,O.jsx)(`span`,{className:`truncate`,children:o.slice(-80)||s?.text||r(`starting…`)})]})]}),i.device&&(0,O.jsx)(S,{size:14,className:`shrink-0 text-muted`})]})}function P({agent:t,agents:r,device:i,onClose:a,onStarted:s}){let{toast:l}=o(),m=n(),[g,_]=(0,D.useState)(t),[v,y]=(0,D.useState)(``),[b,x]=(0,D.useState)(``),[S,w]=(0,D.useState)(!1),[T,E]=(0,D.useState)([]);(0,D.useEffect)(()=>{u.codingSessions({agent:g,device:i,limit:30}).then(e=>E(Array.from(new Set(e.sessions.map(e=>e.workspace).filter(Boolean))).slice(0,6))).catch(()=>E([]))},[g,i]);let A=async()=>{if(b.trim()&&!S){w(!0);try{s(await u.codingSend({agent:g,text:b.trim(),workspace:v.trim(),device:i}))}catch(e){l(e.message)}finally{w(!1)}}};return(0,O.jsx)(h,{open:!0,onClose:a,title:m(`New chat`),footer:(0,O.jsxs)(`button`,{type:`button`,disabled:!b.trim()||S,onClick:()=>void A(),className:e(f,`w-full py-3`),children:[S?(0,O.jsx)(c,{size:15,className:`animate-spin`}):(0,O.jsx)(C,{size:15}),` `,m(`Send to {agent}`,{agent:k[g]?.label??g})]}),children:(0,O.jsxs)(`div`,{className:`space-y-4 py-1`,children:[(0,O.jsx)(`div`,{className:`grid grid-cols-3 gap-1 rounded-2xl bg-surface-2/70 p-1 text-[13px] font-medium`,children:r.map(t=>(0,O.jsxs)(`button`,{type:`button`,onClick:()=>_(t.id),className:e(`flex items-center justify-center gap-1.5 rounded-xl px-2 py-2 transition`,g===t.id?`bg-surface text-fg shadow-sm`:`text-muted`),children:[(0,O.jsx)(I,{agent:t.id,size:18}),` `,k[t.id]?.label??t.name]},t.id))}),(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`label`,{className:`text-[12px] text-muted`,children:m(`Project folder`)}),(0,O.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),placeholder:`/home/you/project`,className:e(p,`mt-1 font-mono text-[13px]`)}),T.length>0&&(0,O.jsx)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:T.map(t=>(0,O.jsx)(`button`,{type:`button`,onClick:()=>y(t),className:e(`rounded-full border px-2.5 py-1 text-[12px]`,v===t?`border-accent/40 bg-accent/10 text-accent`:`border-border text-muted`),children:B(t)},t))})]}),(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`label`,{className:`text-[12px] text-muted`,children:m(`What to do`)}),(0,O.jsx)(`textarea`,{value:b,onChange:e=>x(e.target.value),rows:4,autoFocus:!0,placeholder:m(`Add tests for the parser and run them`),className:e(p,`mt-1 resize-none`)})]}),(0,O.jsx)(`p`,{className:`text-[12px] text-muted`,children:m(`The agent runs on that computer with its own permissions; nanoMuse only carries the message and brings the answer back.`)}),(0,O.jsx)(`button`,{type:`button`,onClick:a,className:e(d,`w-full`),children:m(`Cancel`)})]})})}function F({who:t,agent:n,badge:r,children:i}){return(0,O.jsxs)(`div`,{className:e(`flex`,t===`you`?`justify-end`:`justify-start`),children:[t===`agent`&&(0,O.jsx)(I,{agent:n,size:24,className:`mr-2 mt-1 shrink-0`}),(0,O.jsxs)(`div`,{className:e(`max-w-[86%] rounded-3xl px-3.5 py-2 text-[14px] leading-relaxed`,t===`you`?`rounded-br-lg bg-bubble-user text-bubble-user-fg`:`rounded-bl-lg bg-surface-2`),children:[r&&(0,O.jsx)(`div`,{className:`mb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-fg/45`,children:r}),(0,O.jsx)(`div`,{className:`md break-words`,children:i})]})]})}function I({agent:t,size:n=28,className:r}){let i=k[t];return(0,O.jsx)(`span`,{className:e(`flex shrink-0 items-center justify-center rounded-xl bg-gradient-to-br font-bold text-white shadow-sm`,i?.tone??`from-slate-400 to-slate-600`,r),style:{width:n,height:n,fontSize:Math.round(n*.46)},"aria-hidden":!0,children:i?.glyph??(0,O.jsx)(y,{size:Math.round(n*.55)})})}function L({status:e}){let t=n();return e===`running`?(0,O.jsxs)(`span`,{className:`flex shrink-0 items-center gap-1 rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10.5px] font-semibold text-emerald-700 dark:text-emerald-300`,children:[(0,O.jsx)(`span`,{className:`h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500`}),` `,t(`working`)]}):e===`active`?(0,O.jsx)(`span`,{className:`h-2 w-2 shrink-0 rounded-full bg-accent`,title:t(`active`)}):null}function R({active:t,onClick:n,icon:r,label:i}){return(0,O.jsxs)(`button`,{type:`button`,onClick:n,className:e(`flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12.5px] font-medium transition`,t?`border-fg bg-fg text-bg`:`border-border bg-surface text-fg/75`),children:[r,` `,i]})}function z(e,t){return e.find(e=>e.id===t)?.name??t}function B(e){let t=e.replace(/[\\/]+$/,``).split(/[\\/]/);return t[t.length-1]||e}export{A as CodingScreen}; \ No newline at end of file +`:``)+s:a.output}),a.status===`running`&&(0,O.jsx)(`span`,{className:`ml-1 inline-block h-3.5 w-1 animate-pulse bg-accent align-middle`})]}),a.status===`failed`&&(0,O.jsx)(`div`,{className:`mx-1 rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:a.error||i(`failed`)}),a.status===`stopped`&&(0,O.jsx)(`div`,{className:`mx-1 text-center text-[12px] text-muted`,children:i(`stopped`)}),!a.resumed&&a.asked_session_id&&a.status!==`running`&&(0,O.jsx)(`div`,{className:`mx-1 text-center text-[12px] text-muted`,children:i(`Continued as a new chat: the agent could not reopen the old one.`)})]})}function N({live:e,onOpen:t}){let r=n(),{run:i,events:a,current:o}=e,s=[...a].reverse().find(e=>e.kind===`tool`);return(0,O.jsxs)(`button`,{type:`button`,onClick:t,className:`flex w-full items-center gap-3 rounded-3xl border border-accent/25 bg-accent/5 px-3.5 py-3 text-left`,children:[(0,O.jsx)(I,{agent:i.agent,size:34}),(0,O.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,O.jsx)(`span`,{className:`block truncate text-[14px] font-medium`,children:i.text}),(0,O.jsxs)(`span`,{className:`mt-0.5 flex items-center gap-1.5 truncate text-[12px] text-muted`,children:[(0,O.jsx)(c,{size:11,className:`animate-spin text-accent`}),(0,O.jsx)(`span`,{className:`truncate`,children:o.slice(-80)||s?.text||r(`starting…`)})]})]}),i.device&&(0,O.jsx)(S,{size:14,className:`shrink-0 text-muted`})]})}function P({agent:t,agents:r,device:i,onClose:a,onStarted:s}){let{toast:l}=o(),m=n(),[g,_]=(0,D.useState)(t),[v,y]=(0,D.useState)(``),[b,x]=(0,D.useState)(``),[S,w]=(0,D.useState)(!1),[T,E]=(0,D.useState)([]);(0,D.useEffect)(()=>{u.codingSessions({agent:g,device:i,limit:30}).then(e=>E(Array.from(new Set(e.sessions.map(e=>e.workspace).filter(Boolean))).slice(0,6))).catch(()=>E([]))},[g,i]);let A=async()=>{if(b.trim()&&!S){w(!0);try{s(await u.codingSend({agent:g,text:b.trim(),workspace:v.trim(),device:i}))}catch(e){l(e.message)}finally{w(!1)}}};return(0,O.jsx)(h,{open:!0,onClose:a,title:m(`New chat`),footer:(0,O.jsxs)(`button`,{type:`button`,disabled:!b.trim()||S,onClick:()=>void A(),className:e(f,`w-full py-3`),children:[S?(0,O.jsx)(c,{size:15,className:`animate-spin`}):(0,O.jsx)(C,{size:15}),` `,m(`Send to {agent}`,{agent:k[g]?.label??g})]}),children:(0,O.jsxs)(`div`,{className:`space-y-4 py-1`,children:[(0,O.jsx)(`div`,{className:`grid grid-cols-3 gap-1 rounded-2xl bg-surface-2/70 p-1 text-[13px] font-medium`,children:r.map(t=>(0,O.jsxs)(`button`,{type:`button`,onClick:()=>_(t.id),className:e(`flex items-center justify-center gap-1.5 rounded-xl px-2 py-2 transition`,g===t.id?`bg-surface text-fg shadow-sm`:`text-muted`),children:[(0,O.jsx)(I,{agent:t.id,size:18}),` `,k[t.id]?.label??t.name]},t.id))}),(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`label`,{className:`text-[12px] text-muted`,children:m(`Project folder`)}),(0,O.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),placeholder:`/home/you/project`,className:e(p,`mt-1 font-mono text-[13px]`)}),T.length>0&&(0,O.jsx)(`div`,{className:`mt-2 flex flex-wrap gap-1.5`,children:T.map(t=>(0,O.jsx)(`button`,{type:`button`,onClick:()=>y(t),className:e(`rounded-full border px-2.5 py-1 text-[12px]`,v===t?`border-accent/40 bg-accent/10 text-accent`:`border-border text-muted`),children:B(t)},t))})]}),(0,O.jsxs)(`div`,{children:[(0,O.jsx)(`label`,{className:`text-[12px] text-muted`,children:m(`What to do`)}),(0,O.jsx)(`textarea`,{value:b,onChange:e=>x(e.target.value),rows:4,autoFocus:!0,placeholder:m(`Add tests for the parser and run them`),className:e(p,`mt-1 resize-none`)})]}),(0,O.jsx)(`p`,{className:`text-[12px] text-muted`,children:m(`The agent runs on that computer with its own permissions; nanoMuse only carries the message and brings the answer back.`)}),(0,O.jsx)(`button`,{type:`button`,onClick:a,className:e(d,`w-full`),children:m(`Cancel`)})]})})}function F({who:t,agent:n,badge:r,children:i}){return(0,O.jsxs)(`div`,{className:e(`flex`,t===`you`?`justify-end`:`justify-start`),children:[t===`agent`&&(0,O.jsx)(I,{agent:n,size:24,className:`mr-2 mt-1 shrink-0`}),(0,O.jsxs)(`div`,{className:e(`max-w-[86%] rounded-3xl px-3.5 py-2 text-[14px] leading-relaxed`,t===`you`?`bubble-user rounded-br-lg bg-bubble-user text-bubble-user-fg`:`rounded-bl-lg bg-bubble-agent`),children:[r&&(0,O.jsx)(`div`,{className:`mb-0.5 text-[10.5px] font-semibold uppercase tracking-wide opacity-60`,children:r}),(0,O.jsx)(`div`,{className:`md break-words`,children:i})]})]})}function I({agent:t,size:n=28,className:r}){let i=k[t];return(0,O.jsx)(`span`,{className:e(`flex shrink-0 items-center justify-center rounded-xl bg-gradient-to-br font-bold text-white shadow-sm`,i?.tone??`from-slate-400 to-slate-600`,r),style:{width:n,height:n,fontSize:Math.round(n*.46)},"aria-hidden":!0,children:i?.glyph??(0,O.jsx)(y,{size:Math.round(n*.55)})})}function L({status:e}){let t=n();return e===`running`?(0,O.jsxs)(`span`,{className:`flex shrink-0 items-center gap-1 rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10.5px] font-semibold text-emerald-700 dark:text-emerald-300`,children:[(0,O.jsx)(`span`,{className:`h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500`}),` `,t(`working`)]}):e===`active`?(0,O.jsx)(`span`,{className:`h-2 w-2 shrink-0 rounded-full bg-accent`,title:t(`active`)}):null}function R({active:t,onClick:n,icon:r,label:i}){return(0,O.jsxs)(`button`,{type:`button`,onClick:n,className:e(`flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12.5px] font-medium transition`,t?`border-fg bg-fg text-bg`:`border-border bg-surface text-fg/75`),children:[r,` `,i]})}function z(e,t){return e.find(e=>e.id===t)?.name??t}function B(e){let t=e.replace(/[\\/]+$/,``).split(/[\\/]/);return t[t.length-1]||e}export{A as CodingScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/ConnectionsScreen-CuIVN07d.js b/nanomuse/server/static/assets/ConnectionsScreen-CuIVN07d.js new file mode 100644 index 00000000..d0dbee3c --- /dev/null +++ b/nanomuse/server/static/assets/ConnectionsScreen-CuIVN07d.js @@ -0,0 +1 @@ +import{A as e,E as t,J as n,K as r,L as i,Q as a,Y as o,b as s,f as c,q as l,x as u}from"./MuseHeader-RC--uR8o.js";import{C as d,D as f,O as p,S as m,_ as h,b as g,f as _,h as v,m as y,p as b,x,y as S}from"./StarNudge-Df6LQyo9.js";import{n as C,t as w}from"./external-link-TvxP_dKX.js";import{a as T,c as E,i as D,n as O,r as k,s as A,t as j}from"./Form-H3s9oTHq.js";import{t as M}from"./clapperboard-CuoVHGrc.js";import{t as N}from"./image-nIygWCIp.js";import{t as ee}from"./plug-DV_c5_Ny.js";import{t as P}from"./refresh-cw-CxZ3ZdI3.js";import{A as F,B as I,M as te,N as L,St as ne,T as R,W as z,X as re,Y as B,bt as ie,ct as ae,ft as V,lt as H,ot as oe,w as se}from"./index-tYqPtwPX.js";import{t as ce}from"./BackBar-JEuC8Q_e.js";import{t as le}from"./CloudCard-D5foJm1I.js";var ue=n(`brain-circuit`,[[`path`,{d:`M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z`,key:`l5xja`}],[`path`,{d:`M9 13a4.5 4.5 0 0 0 3-4`,key:`10igwf`}],[`path`,{d:`M6.003 5.125A3 3 0 0 0 6.401 6.5`,key:`105sqy`}],[`path`,{d:`M3.477 10.896a4 4 0 0 1 .585-.396`,key:`ql3yin`}],[`path`,{d:`M6 18a4 4 0 0 1-1.967-.516`,key:`2e4loj`}],[`path`,{d:`M12 13h4`,key:`1ku699`}],[`path`,{d:`M12 18h6a2 2 0 0 1 2 2v1`,key:`105ag5`}],[`path`,{d:`M12 8h8`,key:`1lhi5i`}],[`path`,{d:`M16 8V5a2 2 0 0 1 2-2`,key:`u6izg6`}],[`circle`,{cx:`16`,cy:`13`,r:`.5`,key:`ry7gng`}],[`circle`,{cx:`18`,cy:`3`,r:`.5`,key:`1aiba7`}],[`circle`,{cx:`20`,cy:`21`,r:`.5`,key:`yhc1fs`}],[`circle`,{cx:`20`,cy:`8`,r:`.5`,key:`1e43v0`}]]),U=n(`contact`,[[`path`,{d:`M16 2v2`,key:`scm5qe`}],[`path`,{d:`M7 22v-2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2`,key:`1waht3`}],[`path`,{d:`M8 2v2`,key:`pbkmx`}],[`circle`,{cx:`12`,cy:`11`,r:`3`,key:`itu57m`}],[`rect`,{x:`3`,y:`4`,width:`18`,height:`18`,rx:`2`,key:`12vinp`}]]),de=n(`eye-off`,[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]),fe=n(`eye`,[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),W=n(`unplug`,[[`path`,{d:`m19 5 3-3`,key:`yk6iyv`}],[`path`,{d:`m2 22 3-3`,key:`19mgm9`}],[`path`,{d:`M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z`,key:`goz73y`}],[`path`,{d:`M7.5 13.5 10 11`,key:`7xgeeb`}],[`path`,{d:`M10.5 16.5 13 14`,key:`10btkg`}],[`path`,{d:`m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z`,key:`1snsnr`}]]),pe=n(`upload`,[[`path`,{d:`M12 3v12`,key:`1x0j5s`}],[`path`,{d:`m17 8-5-5-5 5`,key:`7q97r8`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}]]),G=a(o(),1);function me(e,t){let n=t.trim().toLowerCase();return!n||e.id.toLowerCase().includes(n)||e.name.toLowerCase().includes(n)}function he(e,t){let n=e.default&&e.rows.some(t=>t.id===e.default)?e.default:e.rows.find(e=>e.recommended)?.id,r=t&&t.group===e.key?t.id:void 0,i=[];for(let t of[n,r]){if(!t||i.some(e=>e.id===t))continue;let n=e.rows.find(e=>e.id===t);n&&i.push(n)}return[...i,...e.rows.filter(e=>!i.includes(e))]}function ge(e){return e.reduce((e,t)=>e+t.rows.length,0)}function K(e){return ge(e)>8}function _e(e,{current:t,query:n=``,expanded:r}={}){let i=n.trim().length>0,a=[];for(let o of e){let e=he(o,t);if(i){let t=e.filter(e=>me(e,n));t.length&&a.push({key:o.key,label:o.label,rows:t,more:0});continue}e.length>8&&!r?.has(o.key)?a.push({key:o.key,label:o.label,rows:e.slice(0,8),more:e.length-8}):a.push({key:o.key,label:o.label,rows:e,more:0})}return a}var q=s(),ve=`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-[14px] hover:bg-surface-2 focus:bg-surface-2 focus:outline-none`;function ye({label:e,text:n,heads:i=[],headValue:a,tail:o,groups:s,current:c,disabled:l,onHead:u,onPick:d,onTail:f,rowText:p}){let m=r(),[h,g]=(0,G.useState)(!1),[_,v]=(0,G.useState)(``),[y,b]=(0,G.useState)(()=>new Set),[x,S]=(0,G.useState)(!1),w=(0,G.useRef)(null),T=(0,G.useRef)(null),D=K(s),O=(0,G.useCallback)((e=!1)=>{g(!1),v(``),b(new Set),S(e)},[]);(0,G.useEffect)(()=>{!h&&x&&(S(!1),w.current?.focus())},[h,x]),(0,G.useEffect)(()=>{if(!h)return;let e=e=>{let t=e.target;T.current?.contains(t)||w.current?.contains(t)||O()},t=e=>{e.key===`Escape`&&(e.stopPropagation(),O(!0))};return document.addEventListener(`pointerdown`,e,!0),document.addEventListener(`keydown`,t,!0),()=>{document.removeEventListener(`pointerdown`,e,!0),document.removeEventListener(`keydown`,t,!0)}},[h,O]),(0,G.useEffect)(()=>{h&&T.current&&(T.current.querySelector(`input`)??T.current.querySelector(`[aria-selected="true"]`)??T.current.querySelector(`[data-row]`))?.focus()},[h]);let A=e=>{if(e.key!==`ArrowDown`&&e.key!==`ArrowUp`)return;let t=[...e.currentTarget.closest(`[role="listbox"]`)?.querySelectorAll(`[data-row]`)??[]];if(!t.length)return;e.preventDefault();let n=t.indexOf(e.target);t[e.key===`ArrowDown`?n<0?0:Math.min(t.length-1,n+1):n<0?t.length-1:Math.max(0,n-1)]?.focus()},j=_e(s,{current:c,query:_,expanded:y}),M=_.trim().length>0,N=(e,n,r,i,a)=>(0,q.jsxs)(`button`,{type:`button`,role:`option`,"aria-selected":n,"data-row":``,title:a,onClick:i,onKeyDown:A,className:t(ve,n&&`text-accent font-medium`),children:[(0,q.jsx)(`span`,{className:`min-w-0 flex-1 truncate`,children:r}),n&&(0,q.jsx)(C,{size:15,className:`shrink-0`})]},e);return(0,q.jsxs)(`div`,{className:`relative`,children:[(0,q.jsxs)(`button`,{ref:w,type:`button`,disabled:l,"aria-label":e,"aria-haspopup":`listbox`,"aria-expanded":h,title:n,onClick:()=>h?O():g(!0),className:t(k,`flex items-center gap-2 text-left text-fg disabled:opacity-50`),children:[(0,q.jsx)(`span`,{className:`min-w-0 flex-1 truncate`,children:n}),(0,q.jsx)(E,{size:16,className:`shrink-0 text-muted`})]}),h&&(0,q.jsxs)(`div`,{ref:T,role:`listbox`,"aria-label":e,className:`absolute left-0 right-0 top-full z-30 mt-1 flex max-h-[60vh] flex-col overflow-hidden rounded-2xl border border-border bg-surface p-1.5 shadow-xl`,children:[i.length>0&&(0,q.jsx)(`div`,{className:`mb-1 flex flex-col border-b border-border pb-1`,children:i.map(e=>N(`head:${e.value}`,a===e.value,e.label,()=>{u?.(e.value),O(!0)}))}),D&&(0,q.jsxs)(`label`,{className:`mb-1.5 flex items-center gap-2 rounded-xl bg-surface-2 px-3 py-2 text-muted focus-within:ring-2 focus-within:ring-accent/40`,children:[(0,q.jsx)(B,{size:15,className:`shrink-0`}),(0,q.jsx)(`input`,{type:`search`,value:_,onChange:e=>v(e.target.value),onKeyDown:A,placeholder:m(`Search models`),"aria-label":m(`Search models`),autoComplete:`off`,spellCheck:!1,className:`min-w-0 flex-1 bg-transparent text-[14px] text-fg outline-none placeholder:text-muted`})]}),(0,q.jsxs)(`div`,{className:`min-h-0 flex-1 overflow-y-auto`,children:[M&&j.length===0&&(0,q.jsx)(`div`,{className:`px-3 py-2.5 text-[13.5px] text-muted`,children:m(`No model matches`)}),j.map(e=>(0,q.jsxs)(`div`,{role:`group`,"aria-label":e.label,className:`[&+&]:mt-1.5`,children:[(0,q.jsx)(`div`,{className:`truncate px-3 pb-0.5 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted`,children:e.label}),e.rows.map(t=>N(`${e.key}:${t.id}`,c?.group===e.key&&c.id===t.id,p?p(t):t.name,()=>{d(e.key,t),O(!0)},t.id)),e.more>0&&(0,q.jsx)(`button`,{type:`button`,"data-row":``,onKeyDown:A,onClick:()=>b(t=>new Set([...t,e.key])),className:t(ve,`text-[13px] text-accent`),children:m(`Show {n} more`,{n:e.more})})]},e.key))]}),o&&!M&&(0,q.jsx)(`div`,{className:`mt-1 border-t border-border pt-1`,children:N(`tail`,!1,o,()=>{f?.(),O(!0)})})]})]})}var be=`nanomuse_cloud`,xe=`{{vault:NANOMUSE_CLOUD_KEY}}`,Se=`custom`,Ce=`endpoint:`,we=[`openai`,`openai_responses`];function J(e){try{return new URL(e).hostname.toLowerCase()}catch{return``}}function Te(e){return Ce+e}function Ee(e){return e.startsWith(`endpoint:`)&&e.length>9?e.slice(9):null}function De(e){if(e.label.trim())return e.label.trim();try{return new URL(e.base_url).host}catch{return e.base_url}}function Oe(e,t){return e?t.find(t=>t.id===e)??null:null}function ke(e,t=``){return e.filter(e=>e.id!==t&&e.models.length).map(e=>({key:Te(e.id),label:De(e),rows:e.models.filter(e=>f(e)!==!1).map(e=>({id:e,name:e}))})).filter(e=>e.rows.length)}function Ae(e,t,n=_){if(e&&!we.includes(e))return n.find(t=>t.id===e)??null;let r=J(t);if(!r)return null;let i=e=>[e.base_url,e.base_url_global].map(e=>J(e??``)).filter(Boolean);return n.find(e=>i(e).includes(r))||(n.find(e=>i(e).some(e=>{let t=e.indexOf(`.`);return t>0&&r.endsWith(e.slice(t))}))??null)}function je(e,t,n=``,r=_){if(e===`nanomuse_cloud`)return`nanoMuse Cloud`;if(e===`chatgpt`)return`ChatGPT`;let i=r.find(t=>t.id===e);return i&&e!==`custom`?g(i,t):J(n)||e}function Y(e,t,n=_,r=[]){if(!e.effective_provider)return``;let i=Oe(e.endpoint,r),a=i?De(i):je(e.effective_provider,t,e.base_url,n);return e.effective_model?`${a} · ${e.effective_model}`:a}function Me(e,t,n,r=_,i=[]){let a=e.effective_model??``;if(!a)return``;let o=(e,t)=>je(Ae(e,t,r)?.id??(we.includes(e)?`custom`:e),n,t,r),s,c=Oe(e.endpoint,i),l=Oe(t.endpoint,i);return s=e.effective_source===`cloud`?`nanoMuse Cloud`:e.effective_source===`gui`&&c?De(c):e.effective_source===`gui`&&(e.provider_id||e.base_url||!we.includes(e.provider||`openai`))?e.provider_id?je(e.provider_id,n,e.base_url,r):o(e.provider,e.base_url):t.cloud?`nanoMuse Cloud`:l?De(l):o(t.provider,t.base_url),`${s} · ${a}`}function Ne(e,t){let n=t.list??_,r=[];t.signedIn&&r.push({value:be,label:`nanoMuse Cloud`,group:`cloud`,needsKey:!1,entry:null});let i=t.chat.cloud?null:Ae(t.chat.provider,t.chat.base_url,n);i&&i.capabilities.includes(e)&&r.push({value:i.id,label:g(i,t.locale),group:`chat`,needsKey:!1,entry:i});for(let e of t.endpoints??[])r.push({value:Te(e.id),label:De(e),group:`endpoints`,needsKey:!1,entry:null});for(let a of x(t.region,e,n))a.id!==i?.id&&r.push({value:a.id,label:g(a,t.locale),group:`add`,needsKey:!0,entry:a});return r.push({value:Se,label:``,group:`add`,needsKey:!1,entry:null}),r}function Pe(e,t){return e.configured?t&&e.base_url&&J(e.base_url)===J(t)?be:e.endpoint?Te(e.endpoint):e.provider_id||`custom`:``}var Fe=`nanomuse_cloud`,Ie=`add-endpoint`;function Le(){let{state:e}=c(),[t,n]=(0,G.useState)(null),[i,a]=(0,G.useState)(null),o=e.profile?.name??`nanoMuse`,s=r(),d=(0,G.useCallback)(async()=>{try{n(await u.connections()),a(null)}catch(e){a(s(e.message))}},[s]);return(0,G.useEffect)(()=>{d()},[d,e.connectionsVersion]),(0,q.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,q.jsx)(ce,{title:s(`Connections`),description:s(`What {name} can reach; keys stay in the vault on this machine.`,{name:o})}),(0,q.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-4`,children:[i&&(0,q.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 text-rose-700 dark:text-rose-300 p-3 text-[13.5px]`,children:i}),!t&&!i&&(0,q.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,q.jsx)(l,{className:`animate-spin`,size:20})}),t&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)(Re,{data:t,onChange:d}),t.image&&(0,q.jsx)(X,{slot:`image`,data:t,onChange:d}),t.video&&(0,q.jsx)(X,{slot:`video`,data:t,onChange:d}),(0,q.jsx)(le,{account:e.hub?.account??null,onChange:d}),t.embeddings.memory_enabled&&(0,q.jsx)(Z,{data:t,onChange:d}),(0,q.jsx)(He,{data:t,onChange:d}),(0,q.jsx)(We,{data:t,onChange:d}),(0,q.jsx)(Ke,{data:t,onChange:d}),(0,q.jsx)(qe,{data:t,onChange:d}),(0,q.jsx)(Je,{data:t,onChange:d}),(0,q.jsx)(Ye,{data:t,onChange:d}),(0,q.jsx)(Xe,{data:t,onChange:d}),(0,q.jsx)(Ze,{data:t,onChange:d})]})]})]})}function Re({data:e,onChange:n,compact:a}){let{toast:o,state:s}=c(),v=r(),[x,S]=(0,G.useState)(!!a),T=e.providers,D=e.endpoints??[],O=e.endpoints!==void 0,k=D.find(t=>t.id===e.llm.endpoint)??null,M=!!s.hub?.account?.any_model,N=(k?Te(k.id):null)??Object.entries(T).find(([t,n])=>t!==`custom`&&n.base_url&&e.llm.base_url.startsWith(n.base_url)&&n.provider===e.llm.provider)?.[0]??Object.entries(T).find(([t,n])=>t!==`custom`&&n.base_url&&e.llm.base_url.startsWith(n.base_url))?.[0]??(e.llm.base_url?`custom`:`deepseek`),P=e=>{if(e===Ie)return{label:v(`Add an endpoint`),subtitle:T.custom?.subtitle,group:`local`,provider:`openai`,base_url:``,key_optional:!0};let t=Ee(e);if(!t)return T[e];let n=D.find(e=>e.id===t);return n?{label:De(n),group:`local`,provider:`openai`,base_url:n.base_url,models:n.models,no_key:!0}:void 0},[F,I]=(0,G.useState)(N),R=(0,G.useRef)(``),[z,B]=(0,G.useState)(e.llm.model),[ie,V]=(0,G.useState)(e.llm.base_url),[H,oe]=(0,G.useState)(``),[se,ce]=(0,G.useState)(!1),[le,ue]=(0,G.useState)(!1),[U,W]=(0,G.useState)(e.llm.key_source===`none`&&!!T[N]?.key_optional),[pe,me]=(0,G.useState)(e.llm.tool_mode||`auto`),[he,ge]=(0,G.useState)(e.llm.proxy??``),[K,_e]=(0,G.useState)(!1),[ve,be]=(0,G.useState)(!1),[xe,Se]=(0,G.useState)(null),[Ce,we]=(0,G.useState)(!1),[J,Oe]=(0,G.useState)(e.gui?.model??``),[Ae,je]=(0,G.useState)(e.gui?.endpoint??``),[Y,Ne]=(0,G.useState)({list:P(N)?.models??[],image:[],video:[],menu:[],catalog:[],imageCatalog:[],videoCatalog:[],vision:[],gui:T[N]?.gui_models??[],source:`catalogue`}),[Pe,Le]=(0,G.useState)(!1),[Re,ze]=(0,G.useState)(!1);(0,G.useEffect)(()=>{B(e.llm.model),V(e.llm.base_url),me(e.llm.tool_mode||`auto`),ge(e.llm.proxy??``),_e(!1),Oe(e.gui?.model??``),je(e.gui?.endpoint??``);let t=D.find(e=>e.id===R.current);if(t){R.current=``,I(Te(t.id)),V(t.base_url),t.models.includes(e.llm.model)||B(t.models[0]??``),Le(!1);return}I(N)},[e.llm,e.gui?.model,e.gui?.endpoint,e.endpoints]);let X=P(F),Z=D.find(e=>Te(e.id)===F)??null,He=F===Ie,Ue=F===`custom`||!X?.base_url?ie:X.base_url;(0,G.useEffect)(()=>{if(!x||He)return;let e=!0,t=setTimeout(()=>{Ne(e=>({...e,source:`loading`})),u.llmModels(Z?{endpoint:Z.id}:{preset:F,base_url:Ue,api_key:H||void 0}).then(t=>{e&&(Ne({list:t.models,image:t.image_models??[],video:t.video_models??[],menu:t.menu??[],catalog:t.catalog??[],imageCatalog:t.image_catalog??[],videoCatalog:t.video_catalog??[],vision:t.vision??[],gui:t.gui??T[F]?.gui_models??[],source:t.source}),t.source===`live`&&t.models.length&&!Pe&&B(e=>t.models.includes(e)?e:t.models[0]))}).catch(()=>e&&Ne({list:X?.models??[],image:[],video:[],menu:[],catalog:[],imageCatalog:[],videoCatalog:[],vision:[],gui:X?.gui_models??[],source:`catalogue`}))},H?600:150);return()=>{e=!1,clearTimeout(t)}},[x,F,Ue,H]);let We=t=>{I(t);let n=P(t);n&&(n.base_url?V(n.base_url):X?.base_url&&V(``),(!z||Object.values(T).some(e=>e.models?.includes(z))||D.some(e=>e.models.includes(z)))&&n.models?.length&&!n.models.includes(z)&&B(n.models[0]),Ne({list:n.models??[],image:[],video:[],menu:[],catalog:[],imageCatalog:[],videoCatalog:[],vision:[],gui:n.gui_models??[],source:`catalogue`}),(!e.gui?.model||Object.values(T).some(t=>t.gui_model===e.gui?.model))&&Oe(n.cloud?``:n.gui_model??``),ze(!1),Le(!1),W(!1))};(0,G.useEffect)(()=>{let e=te();e&&T[e]&&(S(!0),We(e))},[]);let Ge=!!X?.no_key||U&&!!X?.key_optional,Ke=e.llm.cloud?{text:v(`Your account's model`),tone:`ok`}:e.llm.key_source===`none`&&k?{text:v(`No key`),tone:`ok`}:e.llm.key_source===`none`&&!T[N]?.no_key&&!T[N]?.key_optional?{text:v(`No key yet`),tone:`warn`}:e.llm.key_source===`missing`?{text:v(`Key missing from vault`),tone:`warn`}:{text:e.llm.key_source===`vault`?v(`Key in vault`):e.llm.key_source===`config`?v(`Key from config`):v(`No key needed`),tone:`ok`},qe=async()=>{be(!0),Se(null);try{X?.cloud?await u.cloudUseAsModel(z.trim()):Z?await u.setLLM({endpoint:Z.id,model:z.trim(),tool_mode:pe,proxy:K&&e.llm.proxy!==void 0?he.trim():null}):await u.setLLM({provider:X?.provider??`openai`,model:z.trim(),base_url:Ue.trim(),tool_mode:pe,api_key:H||(Ge?``:null),proxy:K&&e.llm.proxy!==void 0?he.trim():null});let t={model:e.gui?.model??``,endpoint:e.gui?.endpoint??``};(J.trim()!==t.model||Ae!==t.endpoint)&&(Ae?await u.setGui({endpoint:Ae,model:J.trim()}):t.endpoint?await u.setGui({model:J.trim(),provider:``,base_url:``,api_key:``}):await u.setGui({model:J.trim()})),oe(``),h(),o(v(`Model saved`)),n(),a||S(!1)}catch(e){o(e.message)}finally{be(!1)}},Je=async()=>{we(!0);try{Se(await u.testLLM())}catch(e){Se({ok:!1,error:e.message})}finally{we(!1)}},Ye=[{id:`cloud`,title:v(`Your account`),ids:[]},{id:`openai`,title:v(`OpenAI-compatible · Chat Completions`),ids:[]},{id:`responses`,title:v(`Responses API`),ids:[]},{id:`local`,title:v(`Local or your own endpoint`),ids:[]}];for(let[e,t]of Object.entries(T))(Ye.find(e=>e.id===(t.group??`openai`))??Ye[0]).ids.push(e);for(let e of Ye)e.ids=p(e.ids,T,s.hub?.account);let Xe=Ye.find(e=>e.id===`local`);if(Xe){let e=Xe.ids.filter(e=>e!==`custom`||!O||N===`custom`);Xe.ids=[...D.map(e=>Te(e.id)),...e,...O?[Ie]:[]]}let Ze=!Z&&!He&&(F===`custom`||!X?.base_url),Q=X?.cloud||Z||He?null:_.find(e=>e.id===b(F))??null,et=m(s.hub?.account),tt=i(),nt=e=>!!Q&&!Q.user_capabilities&&!Q.capabilities.includes(e),at=T[Fe],ot=X?.cloud?Y.gui[0]??X.gui_model??``:Q&&!Q.user_capabilities?Q.capabilities.includes(`vision`)?Q.defaults.hands||z.trim():at?.gui_model??z.trim():z.trim(),st=!X?.cloud&&nt(`vision`)&&!!at,ct=e.gui?Me(e.gui,e.llm,tt,_,D):``,lt=Array.from(new Set([...X?.cloud?Y.gui:[],...X?.gui_model?[X.gui_model]:[],...Y.list.filter(e=>Y.vision.includes(e)||f(e)===!0)])).filter(Boolean),ut=X?.cloud?`nanoMuse Cloud`:Z?De(Z):Q?g(Q,tt):it(Ue)||F,dt=X?.cloud?Y.gui[0]??X.gui_model??``:Q?.defaults.hands??X?.gui_model??``,ft=[{key:`hands`,label:ut,rows:lt.map(e=>({id:e,name:e})),...dt?{default:dt}:{}},...ke(D,Z?.id??``)],pt=Ae&&Ae!==Z?.id?Te(Ae):`hands`,mt=D.find(e=>e.id===Ae)??null,ht=J?`${pt===`hands`?ut:mt?De(mt):``} · ${J}`:v(`Automatic`),gt=Ze&&/^https?:\/\/[^/]+\/?$/.test(ie.trim());return(0,q.jsxs)(j,{icon:(0,q.jsx)(ne,{size:19}),title:v(`Chat model`),summary:e.llm.cloud?`nanoMuse Cloud · ${e.llm.model||v(`No model yet`)}`:k?`${e.llm.model||v(`No model yet`)} · ${De(k)}`:`${e.llm.model||v(`No model yet`)}${e.llm.base_url?` · ${it(e.llm.base_url)}`:``}`,status:Ke,open:x,onToggle:a?void 0:()=>S(!x),children:[(0,q.jsx)($,{label:v(`Provider`),children:(0,q.jsxs)(`div`,{className:`space-y-2.5`,children:[Ye.filter(e=>e.ids.length).map(e=>(0,q.jsxs)(`div`,{children:[(0,q.jsx)(`div`,{className:`mb-1 text-[11px] uppercase tracking-wide text-muted`,children:e.title}),(0,q.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.ids.map(e=>{let n=P(e);if(!n)return null;let r=D.find(t=>Te(t.id)===e),i=r?it(r.base_url)===n.label?void 0:it(r.base_url):n.subtitle?v(n.subtitle):void 0;return(0,q.jsxs)(`button`,{type:`button`,onClick:()=>We(e),className:t(`rounded-2xl px-3 py-1.5 text-left border leading-tight`,F===e?`border-accent bg-accent/10 text-accent`:`border-border text-muted`),children:[(0,q.jsxs)(`span`,{className:t(`block text-[13px]`,F===e&&`font-medium`,e===Ie&&`inline-flex items-center gap-1`),children:[e===Ie&&(0,q.jsx)(re,{size:13}),n.label]}),i&&(0,q.jsx)(`span`,{className:`block text-[10.5px] opacity-80`,children:i})]},e)})})]},e.id)),Q&&(0,q.jsxs)(`p`,{className:`text-[12px] text-muted`,children:[(0,q.jsx)(`span`,{className:`font-medium text-fg`,children:g(Q,tt)}),` · `,y(v,Q.capabilities),`. `,tt===`zh-CN`?Q.note_zh:Q.note]}),(0,q.jsxs)(`div`,{children:[(0,q.jsxs)(`button`,{type:`button`,onClick:()=>ue(!le),className:`inline-flex items-center gap-1.5 text-[12.5px] text-accent`,children:[(0,q.jsx)(ae,{size:13}),` `,v(`Or sign in with a ChatGPT plan`),` `,le?(0,q.jsx)(A,{size:13}):(0,q.jsx)(E,{size:13})]}),le&&(0,q.jsx)(`div`,{className:`mt-2`,children:(0,q.jsx)(L,{compact:!0,onChanged:()=>{n()}})})]})]})}),He&&(0,q.jsx)(Be,{onAdded:e=>{R.current=e.id,h(),n()}}),Z&&(0,q.jsx)($,{label:v(`Endpoint`),children:(0,q.jsx)(Ve,{endpoint:Z,onChange:()=>{h(),n()},onModels:e=>{Ne(t=>({...t,list:e,source:`live`})),e.length&&!Pe&&!e.includes(z)&&B(e[0])}})}),Ze&&(0,q.jsx)($,{label:v(`Base URL`),hint:v(gt?`/v1 is added when the URL has no path.`:`The endpoint that serves /chat/completions, for example http://127.0.0.1:8000/v1.`),children:(0,q.jsx)(`input`,{value:ie,onChange:e=>V(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`https://host/v1`,inputMode:`url`,autoCapitalize:`off`})}),!He&&(0,q.jsxs)($,{label:v(`Chat model`),hint:X?.cloud&&M&&Y.catalog.length?v(`{n} models your account may use: the menu first, then everything else the provider lists under the Cloud key. Pick one; the ones beyond the menu are priced as the dearest menu model of their kind.`,{n:Y.list.length}):X?.cloud&&M?v(`Your account may name any model the provider has, not only these: type its id (a chat model here, a picture or clip model below) and it goes through as typed.`):Y.source===`live`?v(`{n} models from the endpoint`,{n:Y.list.length}):Y.source===`loading`?v(`Asking the endpoint…`):Y.list.length&&Z?v(`{n} models saved with this endpoint`,{n:Y.list.length}):Y.list.length?v(`Could not list the endpoint's models; these are the usual ones.`):void 0,children:[X?.cloud&&Y.catalog.length>0&&!Re&&(!z||Y.list.includes(z))?(0,q.jsxs)(`select`,{value:z,onChange:e=>{if(e.target.value===Qe){ze(!0);return}B(e.target.value),Le(!0)},className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`text-fg`),children:[!z&&(0,q.jsx)(`option`,{value:``,children:v(`model name`)}),(0,q.jsx)(`optgroup`,{label:v(`Menu`),children:Y.menu.map(e=>(0,q.jsx)(`option`,{value:e,children:Y.vision.includes(e)?`${e} · ${v(`reads pictures`)}`:e},e))}),(0,q.jsx)(`optgroup`,{label:v(`More models on your account`),children:Y.catalog.map(e=>(0,q.jsx)(`option`,{value:e,children:Y.vision.includes(e)?`${e} · ${v(`reads pictures`)}`:e},e))}),(0,q.jsx)(`option`,{value:Qe,children:v(`Type a model id…`)})]}):(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)(`input`,{list:`om-models`,value:z,onChange:e=>{B(e.target.value),Le(!0)},className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:v(`model name`),autoCapitalize:`off`,autoCorrect:`off`,autoFocus:Re}),(0,q.jsx)(`datalist`,{id:`om-models`,children:Y.list.map(e=>(0,q.jsx)(`option`,{value:e},e))}),X?.cloud&&Y.catalog.length>0&&(0,q.jsx)(`button`,{type:`button`,className:`mt-1.5 text-[12px] text-accent`,onClick:()=>{ze(!1),Y.list.includes(z)||B(Y.menu[0]??Y.list[0]??``)},children:v(`Pick from the list`)})]}),Y.list.length>0&&(0,q.jsx)(`div`,{className:`mt-1.5 flex flex-wrap gap-1.5`,children:(Y.menu.length?Y.menu:Y.list).slice(0,8).map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>B(e),className:t(`rounded-full px-2.5 py-1 text-[12px] border`,z===e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e},e))})]}),!He&&(0,q.jsxs)($,{label:v(`Hands model`),hint:X?.cloud?v(`The model that looks at screens when the hands run: the phone, this computer, a page the browser cannot read. Your account's hands model is {model}; the chat model above is a different pick.`,{model:ot||v(`none`)}):st?v(`The model that looks at screens when the hands run. This provider has no model that sees, so the hands use your account's, {model}, unless you pick one here.`,{model:ot||v(`none`)}):D.length>+!!Z?v(`The model that looks at screens when the hands run: a small, fast one that takes pictures. Pick one of this provider's, or one from another endpoint of your list, which then lends its address and key.`):v(`The model that looks at screens when the hands run: a small, fast one that takes pictures, on the same endpoint and key. Default: this provider's own hands model.`),children:[(0,q.jsx)(ye,{label:v(`Hands model`),text:ht,heads:[{value:``,label:v(`Automatic`)}],headValue:J?void 0:``,groups:ft,current:J?{group:pt,id:J}:void 0,onHead:()=>{Oe(``),je(``)},onPick:(e,t)=>{Oe(t.id),je(Ee(e)??``)}}),ct&&(0,q.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:v(`Currently {value}`,{value:ct})}),!X?.cloud&&z.trim()&&!J&&f(z.trim())===!1&&(0,q.jsx)(`div`,{className:`mt-1.5 text-[12px] text-amber-700 dark:text-amber-300`,children:v(`{model} does not take pictures, so the hands would be blind with it. Pick a model that sees for them.`,{model:z.trim()})}),nt(`vision`)&&!st&&(0,q.jsxs)(`div`,{className:`mt-1.5 text-[12px] text-amber-700 dark:text-amber-300`,children:[d(v,`vision`,et,tt),` `,(0,q.jsx)($e,{})]})]}),!X?.no_key&&!He&&(0,q.jsxs)($,{label:v(`API key`),hint:U&&X?.key_optional?v(`No key will be sent.`):e.llm.key_source===`vault`&&F===N?v(`A key is in the vault. Leave blank to keep it.`):v(`Stored encrypted in the vault as LLM_API_KEY.`),children:[(0,q.jsxs)(`div`,{className:`relative`,children:[(0,q.jsx)(`input`,{type:se?`text`:`password`,value:H,onChange:e=>oe(e.target.value),disabled:U&&!!X?.key_optional,"aria-label":v(`API key`),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pr-10 disabled:opacity-50`),placeholder:e.llm.key_source===`vault`&&F===N?`••••••••`:X?.key_hint||`sk-…`,autoComplete:`off`,autoCapitalize:`off`,spellCheck:!1}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>ce(!se),"aria-label":v(se?`Hide key`:`Show key`),className:`absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-muted`,children:se?(0,q.jsx)(de,{size:16}):(0,q.jsx)(fe,{size:16})})]}),(0,q.jsxs)(`div`,{className:`mt-1.5 flex items-center justify-between gap-2 text-[12px]`,children:[X?.key_url?(0,q.jsxs)(`a`,{href:X.key_url,target:`_blank`,rel:`noreferrer`,className:`inline-flex items-center gap-1 text-accent`,children:[v(`Get a key from {vendor}`,{vendor:X.label}),` `,(0,q.jsx)(w,{size:12})]}):(0,q.jsx)(`span`,{}),X?.key_optional&&(0,q.jsxs)(`label`,{className:`inline-flex items-center gap-1.5 text-muted`,children:[(0,q.jsx)(`input`,{type:`checkbox`,checked:U,onChange:e=>W(e.target.checked),className:`accent-accent`}),` `,v(`No key`)]})]})]}),!He&&(0,q.jsx)($,{label:v(`Tool calling`),hint:v(`Auto uses the API's function calling and falls back to describing tools in the prompt when the endpoint rejects them. Prompt: for endpoints that silently ignore tools.`),children:(0,q.jsx)(`div`,{className:`flex gap-1.5`,children:[`auto`,`native`,`prompt`].map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>me(e),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,pe===e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e},e))})}),!X?.cloud&&!He&&e.llm.proxy!==void 0&&(0,q.jsx)($,{label:v(`Proxy (optional)`),hint:v(`Only this provider's requests go through it. nanoMuse Cloud never does.`),children:(0,q.jsx)(`input`,{className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,value:he,onChange:e=>{ge(e.target.value),_e(!0)},onFocus:()=>he.includes(`••••`)&&!K&&ge(``),onBlur:()=>!K&&ge(e.llm.proxy??``),placeholder:`http://host:port / socks5://host:port`,autoComplete:`off`,spellCheck:!1})}),!He&&(0,q.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:ve||!z.trim()||Ze&&!ie.trim(),onClick:()=>void qe(),className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,children:[ve?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16}),` `,v(`Save`)]}),(0,q.jsxs)(`button`,{type:`button`,disabled:Ce,onClick:()=>void Je(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[Ce?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,v(`Test`)]})]}),xe&&(0,q.jsx)(rt,{result:xe,okText:v(`Replied "{reply}" in {ms} ms`,{reply:xe.reply??``,ms:xe.ms??0})})]})}var ze={chat:`the chat model`,hands:`the hands`,image:`pictures`,video:`clips`};function Be({onAdded:e}){let n=r(),{toast:i}=c(),[a,o]=(0,G.useState)(``),[s,d]=(0,G.useState)(``),[f,p]=(0,G.useState)(``),[m,h]=(0,G.useState)(!1),[g,_]=(0,G.useState)(!1),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null),[S,C]=(0,G.useState)(!1),w=/^https?:\/\/[^/]+\/?$/.test(s.trim()),T=async()=>{C(!0),x(null);try{let t=await u.addEndpoint({label:a.trim(),base_url:s.trim(),api_key:g?``:f,models:v});i(n(`Endpoint added`)),e(t)}catch(e){x(n(e.message))}finally{C(!1)}};return(0,q.jsxs)(`div`,{className:`space-y-3 rounded-2xl border border-border p-3`,children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:n(`An endpoint joins your list with its own key and stays there; add as many as you use. Pick one as the chat model, or use its models for the hands, pictures and clips.`)}),(0,q.jsx)($,{label:n(`Name (optional)`),hint:n(`How the endpoint reads in the lists; its host when left empty.`),children:(0,q.jsx)(`input`,{value:a,onChange:e=>o(e.target.value),className:k,placeholder:n(`for example: my gateway`),autoCapitalize:`off`})}),(0,q.jsx)($,{label:n(`Base URL`),hint:n(w?`/v1 is added when the URL has no path.`:`The endpoint that serves /chat/completions, for example http://127.0.0.1:8000/v1.`),children:(0,q.jsx)(`input`,{value:s,onChange:e=>d(e.target.value),className:k,placeholder:`https://host/v1`,inputMode:`url`,autoCapitalize:`off`})}),(0,q.jsxs)($,{label:n(`API key`),hint:n(g?`No key will be sent.`:`Stored encrypted in the vault under this endpoint's own name.`),children:[(0,q.jsxs)(`div`,{className:`relative`,children:[(0,q.jsx)(`input`,{type:m?`text`:`password`,value:f,onChange:e=>p(e.target.value),disabled:g,"aria-label":n(`API key`),className:t(k,`pr-10 disabled:opacity-50`),placeholder:`sk-…`,autoComplete:`off`,autoCapitalize:`off`,spellCheck:!1}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>h(!m),"aria-label":n(m?`Hide key`:`Show key`),className:`absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-muted`,children:m?(0,q.jsx)(de,{size:16}):(0,q.jsx)(fe,{size:16})})]}),(0,q.jsxs)(`label`,{className:`mt-1.5 inline-flex items-center gap-1.5 text-[12px] text-muted`,children:[(0,q.jsx)(`input`,{type:`checkbox`,checked:g,onChange:e=>_(e.target.checked),className:`accent-accent`}),` `,n(`No key`)]})]}),(0,q.jsx)($,{label:n(`Models (optional)`),hint:n(`Leave empty when the endpoint lists its models at /models. Otherwise type their ids, separated by commas.`),children:(0,q.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:k,placeholder:`model-a, model-b`,autoCapitalize:`off`,autoCorrect:`off`,spellCheck:!1})}),b&&(0,q.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:b}),(0,q.jsxs)(`button`,{type:`button`,disabled:S||!s.trim(),onClick:()=>void T(),className:D,children:[S?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(re,{size:16}),` `,n(`Add endpoint`)]})]})}function Ve({endpoint:e,onChange:n,onModels:a}){let o=r(),{toast:s}=c(),[d,f]=(0,G.useState)(``),[p,m]=(0,G.useState)(e.label),[h,g]=(0,G.useState)(``),[_,v]=(0,G.useState)(!1),[y,b]=(0,G.useState)(!1),[x,S]=(0,G.useState)(e.models.join(`, `)),[C,w]=(0,G.useState)(!1),[T,E]=(0,G.useState)(null),D=e.models.join(`, `);(0,G.useEffect)(()=>{m(e.label),S(D),f(``),g(``),b(!1),E(null)},[e.id,e.label,D]);let O=async(e,t)=>{w(!0),E(null);try{await e(),t&&s(t),f(``),n()}catch(e){E(o(e.message))}finally{w(!1)}},k=async()=>{w(!0),E(null);try{let t=await u.refreshEndpointModels(e.id);t.source===`live`?(a(t.models),s(o(`{n} models from the endpoint`,{n:t.models.length})),n()):E(o(`The endpoint did not list its models; the saved list stands.`)+(t.error?` ${o(t.error)}`:``))}catch(e){E(o(e.message))}finally{w(!1)}},A=e.key_source===`vault`?o(`Key in vault`):e.key_source===`config`?o(`Key from config`):e.key_source===`missing`?o(`Key missing from vault`):o(`No key`),j=e.slots.map(e=>o(ze[e])).join(i()===`zh-CN`?`、`:`, `),M=e.slots.includes(`chat`),N=`text-[12.5px] text-accent disabled:opacity-50`;return(0,q.jsxs)(`div`,{className:`space-y-2 rounded-2xl border border-border p-3 text-[13px]`,children:[(0,q.jsxs)(`div`,{children:[(0,q.jsx)(`span`,{className:`font-medium text-fg`,children:De(e)}),(0,q.jsxs)(`span`,{className:`text-muted`,children:[` · `,it(e.base_url)]})]}),(0,q.jsxs)(`div`,{className:`text-[12px] text-muted`,children:[A,` · `,o(`{n} models`,{n:e.models.length}),j?` · ${o(`Used by {slots}`,{slots:j})}`:``]}),d===``&&(0,q.jsxs)(`div`,{className:`flex flex-wrap gap-x-3 gap-y-1`,children:[(0,q.jsx)(`button`,{type:`button`,className:N,disabled:C,onClick:()=>f(`rename`),children:o(`Rename`)}),(0,q.jsx)(`button`,{type:`button`,className:N,disabled:C,onClick:()=>f(`key`),children:o(`Change key`)}),(0,q.jsx)(`button`,{type:`button`,className:N,disabled:C,onClick:()=>f(`models`),children:o(`Edit models`)}),(0,q.jsxs)(`button`,{type:`button`,className:N,disabled:C,onClick:()=>void k(),children:[C?(0,q.jsx)(l,{size:12,className:`inline animate-spin`}):(0,q.jsx)(P,{size:12,className:`inline`}),` `,o(`Ask for its models`)]}),(0,q.jsx)(`button`,{type:`button`,className:t(N,`text-rose-600 dark:text-rose-300`),disabled:C,onClick:()=>f(`remove`),children:o(`Remove`)})]}),d===`rename`&&(0,q.jsxs)(`div`,{className:`space-y-2`,children:[(0,q.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:it(e.base_url),autoCapitalize:`off`,"aria-label":o(`Name`)}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsx)(`button`,{type:`button`,disabled:C,className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,onClick:()=>void O(()=>u.updateEndpoint(e.id,{label:p.trim()}),o(`Endpoint renamed`)),children:o(`Save`)}),(0,q.jsx)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,onClick:()=>f(``),children:o(`Cancel`)})]})]}),d===`key`&&(0,q.jsxs)(`div`,{className:`space-y-2`,children:[(0,q.jsxs)(`div`,{className:`relative`,children:[(0,q.jsx)(`input`,{type:_?`text`:`password`,value:h,onChange:e=>g(e.target.value),disabled:y,className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pr-10 disabled:opacity-50`),placeholder:e.key_source===`vault`?`••••••••`:`sk-…`,"aria-label":o(`API key`),autoComplete:`off`,autoCapitalize:`off`,spellCheck:!1}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>v(!_),"aria-label":o(_?`Hide key`:`Show key`),className:`absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-muted`,children:_?(0,q.jsx)(de,{size:16}):(0,q.jsx)(fe,{size:16})})]}),(0,q.jsxs)(`label`,{className:`inline-flex items-center gap-1.5 text-[12px] text-muted`,children:[(0,q.jsx)(`input`,{type:`checkbox`,checked:y,onChange:e=>b(e.target.checked),className:`accent-accent`}),` `,o(`No key`)]}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsx)(`button`,{type:`button`,disabled:C||!y&&!h.trim(),className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,onClick:()=>void O(()=>u.updateEndpoint(e.id,{api_key:y?``:h.trim()}),o(`Key saved`)),children:o(`Save`)}),(0,q.jsx)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,onClick:()=>f(``),children:o(`Cancel`)})]})]}),d===`models`&&(0,q.jsxs)(`div`,{className:`space-y-2`,children:[(0,q.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`model-a, model-b`,"aria-label":o(`Models`),autoCapitalize:`off`,autoCorrect:`off`,spellCheck:!1}),(0,q.jsx)(`div`,{className:`text-[12px] text-muted`,children:o(`The ids of the models this endpoint serves, separated by commas; what it lists at /models is added after them.`)}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsx)(`button`,{type:`button`,disabled:C,className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,onClick:()=>void O(()=>u.updateEndpoint(e.id,{models:x}),o(`Models saved`)),children:o(`Save`)}),(0,q.jsx)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,onClick:()=>f(``),children:o(`Cancel`)})]})]}),d===`remove`&&(0,q.jsxs)(`div`,{className:`space-y-2`,children:[(0,q.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:o(M?`The chat model answers through this endpoint; pick another chat model first.`:`Its key goes with it, and the hands, pictures or clips set on it go back to automatic.`)}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:C||M,className:t(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`bg-rose-600 hover:bg-rose-600/90`),onClick:()=>void O(()=>u.removeEndpoint(e.id),o(`Endpoint removed`)),children:[(0,q.jsx)(I,{size:16}),` `,o(`Remove`)]}),(0,q.jsx)(`button`,{type:`button`,className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,onClick:()=>f(``),children:o(`Cancel`)})]})]}),T&&(0,q.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:T})]})}function X({slot:e,data:n,onChange:a}){let{toast:o,state:s}=c(),f=r(),p=n[e],b=e,x=s.hub?.account??null,T=!!x?.signed_in,E=m(x),O=i(),A=n.providers[Fe]?.base_url??``,ee=n.endpoints??[],P=Ne(b,{signedIn:T,chat:{provider:n.llm.provider,base_url:n.llm.base_url,cloud:!!n.llm.cloud},region:E,locale:O,endpoints:ee}),F=Pe(p,A),[I,te]=(0,G.useState)(!1),[L,ne]=(0,G.useState)(F),[R,z]=(0,G.useState)(p.model),[B,ie]=(0,G.useState)(p.base_url),[ae,V]=(0,G.useState)(``),[H,oe]=(0,G.useState)(!1),[se,ce]=(0,G.useState)(!1),[le,ue]=(0,G.useState)({list:[],more:[],source:`catalogue`});(0,G.useEffect)(()=>{ne(F),z(p.model),ie(p.base_url),V(``)},[p]);let U=P.find(e=>e.value===L)??null,W=U?.entry??null,pe=W?v(W,E):null,me=ee.find(e=>Te(e.id)===L)??null,he=L===`nanomuse_cloud`?A:me?me.base_url:L===`custom`?B.trim():pe?.base_url??``,ge=F===L&&F!==``&&(p.key_source===`vault`||p.key_source===`config`),K=!!U?.needsKey&&!ae&&!ge,_e=L===`nanomuse_cloud`?le.list[0]??``:W?.defaults[e]??``;(0,G.useEffect)(()=>{if(!I||!L||L!==`nanomuse_cloud`&&!he){ue({list:[],more:[],source:`catalogue`});return}let t=!0,n=setTimeout(()=>{ue(e=>({...e,source:`loading`})),u.llmModels(L===`nanomuse_cloud`?{preset:Fe}:me?{endpoint:me.id}:{preset:W?S(W.id):``,base_url:he,api_key:ae||void 0}).then(n=>{if(!t)return;let r=(e===`image`?n.image_models:n.video_models)??[],i=(e===`image`?n.image_catalog:n.video_catalog)??[];ue({list:r.filter(e=>!i.includes(e)),more:i,source:n.source})}).catch(()=>t&&ue({list:[],more:[],source:`catalogue`}))},ae?600:150);return()=>{t=!1,clearTimeout(n)}},[I,L,he,ae]);let ve=f(e===`image`?`Making pictures`:`Making clips`),ye=f(e===`image`?`Portraits of your Muse.`:`Short clips of your Muse.`),be=Y(p,O,_,ee),Se=d(f,b,E,O),Ce=p.effective_provider?p.effective_source===`cloud`?{text:f(`Your account`),tone:`ok`}:p.effective_source===`chat`?{text:f(`The chat model's provider`),tone:`ok`}:{text:f(`Your key`),tone:`ok`}:{text:f(`Not set up`),tone:`warn`},we=async()=>{ce(!0);try{L?L===`nanomuse_cloud`?await u.setMedia(e,{provider:`openai`,base_url:A,model:R.trim(),api_key:xe}):me?await u.setMedia(e,{endpoint:me.id,model:R.trim()}):L===`custom`?await u.setMedia(e,{provider:`openai`,base_url:B.trim(),model:R.trim(),api_key:ae||null}):await u.setMedia(e,{provider:L,base_url:``,model:R.trim(),api_key:ae||(U?.group===`chat`?``:null)}):await u.setMedia(e,{provider:``,model:``,base_url:``,api_key:``}),V(``),h(),o(f(`Saved`)),a(),te(!1)}catch(e){o(e.message)}finally{ce(!1)}},J=e=>f(e===`cloud`?`Your account`:e===`chat`?`The chat model's provider`:e===`endpoints`?`Your endpoints`:`Add a provider`),Ee=[`cloud`,`chat`,`endpoints`,`add`].map(e=>({g:e,items:P.filter(t=>t.group===e)})).filter(e=>e.items.length),De=e===`image`?`IMAGE_API_KEY`:`VIDEO_API_KEY`;return(0,q.jsxs)(j,{icon:e===`image`?(0,q.jsx)(N,{size:19}):(0,q.jsx)(M,{size:19}),title:ve,summary:be||Se,status:Ce,open:I,onToggle:()=>te(!I),children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:ye}),(0,q.jsxs)($,{label:f(`Provider`),hint:!be&&!L?Se:void 0,children:[(0,q.jsxs)(`select`,{value:L,onChange:e=>{ne(e.target.value),z(``),V(``)},className:t(k,`text-fg`),children:[(0,q.jsx)(`option`,{value:``,children:f(`Automatic`)}),Ee.map(({g:e,items:t})=>(0,q.jsx)(`optgroup`,{label:J(e),children:t.map(e=>(0,q.jsx)(`option`,{value:e.value,children:e.value===`custom`?f(`Other OpenAI-compatible endpoint`):e.label},e.value))},e))]}),be&&(0,q.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:f(`Currently {value}`,{value:be})}),!be&&!L&&(0,q.jsxs)(`button`,{type:`button`,className:`mt-1.5 inline-flex items-center gap-1 text-[12.5px] text-accent`,onClick:()=>ne(P.find(e=>e.group===`add`)?.value??``),children:[(0,q.jsx)(re,{size:13}),` `,f(`Add a provider`)]}),W&&(0,q.jsxs)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:[(0,q.jsx)(`span`,{className:`font-medium text-fg`,children:g(W,O)}),` · `,y(f,W.capabilities),`. `,O===`zh-CN`?W.note_zh:W.note]})]}),L===`custom`&&(0,q.jsx)($,{label:f(`Base URL`),hint:f(`The endpoint that serves /images/generations, for example https://host/v1.`),children:(0,q.jsx)(`input`,{value:B,onChange:e=>ie(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`https://host/v1`,inputMode:`url`,autoCapitalize:`off`})}),L&&(0,q.jsx)(Q,{label:f(e===`image`?`Picture model`:`Clip model`),provider:L===`nanomuse_cloud`?`nanoMuse Cloud`:L===`custom`?it(B)||f(`Other OpenAI-compatible endpoint`):U?.label??L,value:R,options:le.list,more:le.more,onChange:z,other:L!==`nanomuse_cloud`||!!x?.any_model,placeholder:_e||void 0}),L&&L!==`nanomuse_cloud`&&U?.group!==`chat`&&!me&&(0,q.jsxs)($,{label:f(`API key`),hint:ge?f(`A key is in the vault. Leave blank to keep it.`):f(`Stored encrypted in the vault as {name}.`,{name:De}),children:[(0,q.jsxs)(`div`,{className:`relative`,children:[(0,q.jsx)(`input`,{type:H?`text`:`password`,value:ae,onChange:e=>V(e.target.value),"aria-label":f(`API key`),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pr-10`),placeholder:ge?`••••••••`:W?.key_hint||`sk-…`,autoComplete:`off`,autoCapitalize:`off`,spellCheck:!1}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>oe(!H),"aria-label":f(H?`Hide key`:`Show key`),className:`absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-muted`,children:H?(0,q.jsx)(de,{size:16}):(0,q.jsx)(fe,{size:16})})]}),pe?.key_url&&(0,q.jsxs)(`a`,{href:pe.key_url,target:`_blank`,rel:`noreferrer`,className:`mt-1.5 inline-flex items-center gap-1 text-[12px] text-accent`,children:[f(`Get a key from {vendor}`,{vendor:W?g(W,O):``}),` `,(0,q.jsx)(w,{size:12})]})]}),U?.group===`chat`&&(0,q.jsx)(`p`,{className:`text-[12px] text-muted`,children:f(`Uses the chat model's key.`)}),me&&(0,q.jsx)(`p`,{className:`text-[12px] text-muted`,children:f(`Uses the key saved with this endpoint.`)}),(0,q.jsx)(`div`,{className:`flex gap-2 pt-1`,children:(0,q.jsxs)(`button`,{type:`button`,disabled:se||K||L===`custom`&&!B.trim(),onClick:()=>void we(),className:D,children:[se?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16}),` `,f(`Save`)]})})]})}function Z({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.embeddings,[s,d]=(0,G.useState)(!1),[f,p]=(0,G.useState)(o.mode),[m,h]=(0,G.useState)(!!o.base_url),[g,_]=(0,G.useState)(o.base_url),[v,y]=(0,G.useState)(o.model),[b,x]=(0,G.useState)(``),[S,w]=(0,G.useState)(!1),[E,O]=(0,G.useState)(null),[k,A]=(0,G.useState)(!1);(0,G.useEffect)(()=>{p(o.mode),h(!!o.base_url),_(o.base_url),y(o.model)},[e.embeddings]);let M=o.mode===`off`?{text:a(`Off`),tone:`off`}:o.available===!0?{text:a(`On`),tone:`ok`}:o.available===!1?{text:o.mode===`on`?a(`Not reachable`):a(`By keyword`),tone:`warn`}:{text:a(`Not tried yet`),tone:`off`},N=o.mode===`off`?a(`Keyword recall only`):o.available===!0?a(`{model} · {n} of {total} memories indexed`,{model:o.model||o.default_model,n:o.indexed,total:o.total}):o.available===!1?o.reason:a(`{model} at {host}`,{model:o.model||o.default_model,host:it(o.effective_base_url)}),P=async()=>{w(!0),O(null);try{await u.setEmbeddings({mode:f,base_url:m?g.trim():``,model:v.trim(),api_key:m?b||void 0:``}),x(``),i(a(`Saved`)),n()}catch(e){i(e.message)}finally{w(!1)}},F=async()=>{A(!0);try{O(await u.testEmbeddings()),n()}catch(e){O({ok:!1,error:e.message})}finally{A(!1)}},I=e=>t(`rounded-full px-3 py-1.5 text-[13px] border`,e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`);return(0,q.jsxs)(j,{icon:(0,q.jsx)(ue,{size:19}),title:a(`Recall by meaning`),summary:N,status:M,open:s,onToggle:()=>d(!s),children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:a(`Memories are embedded once and a message finds the ones that mean the same thing, in any language: “写邮件给房东” finds “the landlord is Bob Li”. Keyword recall stays; the two are fused.`)}),(0,q.jsx)($,{label:a(`Mode`),hint:a(`Auto uses the model's endpoint when it has embeddings and falls back to keywords when it does not. On insists and warns. Off: keywords only.`),children:(0,q.jsx)(`div`,{className:`flex gap-1.5`,children:[`auto`,`on`,`off`].map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>p(e),className:I(f===e),children:a(e===`auto`?`Auto`:e===`on`?`On`:`Off`)},e))})}),f!==`off`&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)($,{label:a(`Endpoint`),hint:a(m?`Any OpenAI-compatible /embeddings: Ollama with an embedding model pulled, OpenAI, a gateway.`:`The model's endpoint and key. DeepSeek has no embeddings; pick another endpoint.`),children:(0,q.jsxs)(`div`,{className:`flex gap-1.5`,children:[(0,q.jsx)(`button`,{type:`button`,onClick:()=>h(!1),className:I(!m),children:a(`Same as the model`)}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>{h(!0),g||_(`http://127.0.0.1:11434/v1`)},className:I(m),children:a(`Another endpoint`)})]})}),m&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)($,{label:a(`Base URL`),children:(0,q.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`http://127.0.0.1:11434/v1`,inputMode:`url`})}),(0,q.jsx)($,{label:a(`API key`),hint:o.key_source===`vault`?a(`A key is in the vault. Leave blank to keep it.`):a(`Stored encrypted in the vault as EMBEDDINGS_API_KEY. Ollama needs none.`),children:(0,q.jsx)(`input`,{type:`password`,value:b,onChange:e=>x(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:o.key_source===`vault`?`••••••••`:`sk-…`,autoComplete:`off`})})]}),(0,q.jsx)($,{label:a(`Embedding model`),hint:a(`Blank uses the endpoint's default: {model}. On Ollama: ollama pull qwen3-embedding:0.6b (reads Chinese and English).`,{model:o.default_model}),children:(0,q.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:o.default_model,autoComplete:`off`})})]}),(0,q.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:S||m&&!/^https?:\/\//.test(g.trim()),onClick:()=>void P(),className:D,children:[S?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16}),` `,a(`Save`)]}),(0,q.jsxs)(`button`,{type:`button`,disabled:k||o.mode===`off`,onClick:()=>void F(),className:T,children:[k?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,a(`Test`)]})]}),E&&(0,q.jsx)(rt,{result:E,okText:a(`{model} · {dims} dims · {n} memories indexed · {ms} ms`,{model:E.model??``,dims:E.dims??0,n:E.indexed??0,ms:E.ms??0})})]})}function He({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.search,[s,d]=(0,G.useState)(!1),[f,p]=(0,G.useState)(o.provider),[m,h]=(0,G.useState)(o.base_url),[g,_]=(0,G.useState)(``),[v,y]=(0,G.useState)(!1),[b,x]=(0,G.useState)(null),[S,w]=(0,G.useState)(!1);(0,G.useEffect)(()=>{p(o.provider),h(o.base_url)},[e.search]);let E=o.providers.find(e=>e.id===o.provider),O=o.providers.find(e=>e.id===f),k=!!O?.needs_key,A=o.configured?{text:a(`On`),tone:`ok`}:{text:a(`Not set up`),tone:`warn`},M=o.configured?o.provider===`searxng`?a(`{provider} at {host}`,{provider:E?.label??o.provider,host:it(o.base_url)}):E?.label??o.provider:a(`{provider} needs {what}; searches use DuckDuckGo until then`,{provider:E?.label??o.provider,what:E?.needs_key?a(`a key`):a(`an instance URL`)}),N=async()=>{y(!0),x(null);try{await u.setSearch({provider:f,base_url:f===`searxng`?m.trim():``,api_key:k?g||void 0:``}),_(``),i(a(`Saved`)),n()}catch(e){i(e.message)}finally{y(!1)}},P=async()=>{w(!0);try{x(await u.testSearch())}catch(e){x({ok:!1,error:e.message})}finally{w(!1)}},F=e=>t(`rounded-full px-3 py-1.5 text-[13px] border`,e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),I=f!==`searxng`||/^https?:\/\//.test(m.trim());return(0,q.jsxs)(j,{icon:(0,q.jsx)(B,{size:19}),title:a(`Web search`),summary:M,status:A,open:s,onToggle:()=>d(!s),children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:a(`DuckDuckGo needs nothing, but it is scraped and rate-limits now and then. For searches that always work, use a provider with an API. Whichever you pick, a failed search falls back to DuckDuckGo with a note.`)}),(0,q.jsx)($,{label:a(`Provider`),children:(0,q.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:o.providers.map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>p(e.id),className:F(f===e.id),children:e.label},e.id))})}),k&&(0,q.jsxs)($,{label:a(`API key`),hint:o.key_source===`vault`&&o.provider===f?a(`A key is in the vault. Leave blank to keep it.`):a(`Stored encrypted in the vault as SEARCH_API_KEY.`),children:[(0,q.jsx)(`input`,{type:`password`,value:g,onChange:e=>_(e.target.value),"aria-label":a(`API key`),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:o.key_source===`vault`&&o.provider===f?`••••••••`:`…`,autoComplete:`off`}),O?.keys_url&&(0,q.jsx)(`a`,{href:O.keys_url,target:`_blank`,rel:`noreferrer`,className:`mt-1 inline-block text-[12px] text-accent`,children:a(`Get a key from {label}`,{label:O.label})})]}),f===`searxng`&&(0,q.jsx)($,{label:a(`Instance URL`),hint:a(`A SearXNG you run, with the JSON format enabled in its settings.`),children:(0,q.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`http://127.0.0.1:8080`,inputMode:`url`})}),(0,q.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:v||!I,onClick:()=>void N(),className:D,children:[v?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16}),` `,a(`Save`)]}),(0,q.jsxs)(`button`,{type:`button`,disabled:S,onClick:()=>void P(),className:T,children:[S?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,a(`Test`)]})]}),b&&(0,q.jsx)(rt,{result:b,okText:a(`{provider} · {n} results · {ms} ms`,{provider:b.provider??``,n:b.results??0,ms:b.ms??0})})]})}var Ue=[{id:`gmail`,label:`Gmail`,imap:`imap.gmail.com`,smtp:`smtp.gmail.com`,port:587,starttls:!0},{id:`outlook`,label:`Outlook`,imap:`outlook.office365.com`,smtp:`smtp.office365.com`,port:587,starttls:!0},{id:`icloud`,label:`iCloud`,imap:`imap.mail.me.com`,smtp:`smtp.mail.me.com`,port:587,starttls:!0},{id:`qq`,label:`QQ`,imap:`imap.qq.com`,smtp:`smtp.qq.com`,port:587,starttls:!0},{id:`163`,label:`163`,imap:`imap.163.com`,smtp:`smtp.163.com`,port:465,starttls:!1},{id:`fastmail`,label:`Fastmail`,imap:`imap.fastmail.com`,smtp:`smtp.fastmail.com`,port:587,starttls:!0}];function We({data:e,onChange:n,compact:i}){let{toast:a}=c(),o=r(),s=e.email,[d,f]=(0,G.useState)(!!i),[p,m]=(0,G.useState)(s.address),[h,g]=(0,G.useState)(``),[_,v]=(0,G.useState)(s.imap_host),[y,b]=(0,G.useState)(s.imap_port),[x,S]=(0,G.useState)(s.smtp_host),[C,w]=(0,G.useState)(s.smtp_port),[T,E]=(0,G.useState)(s.smtp_starttls),[O,A]=(0,G.useState)(null),[M,N]=(0,G.useState)(null);(0,G.useEffect)(()=>{m(s.address),v(s.imap_host),b(s.imap_port),S(s.smtp_host),w(s.smtp_port),E(s.smtp_starttls)},[s]);let P=e=>{v(e.imap),b(993),S(e.smtp),w(e.port),E(e.starttls)},F=async()=>{A(`save`),N(null);try{await u.setEmail({enabled:!0,address:p.trim(),password:h||void 0,imap_host:_.trim(),imap_port:y,smtp_host:x.trim(),smtp_port:C,smtp_starttls:T}),g(``);let e=await u.testEmail();N(e),a(e.ok?o(`Email connected`):o(`Saved, but the test failed`)),n()}catch(e){a(e.message)}finally{A(null)}},I=async()=>{A(`off`);try{await u.disconnectEmail(),N(null),a(o(`Email disconnected`)),n()}catch(e){a(e.message)}finally{A(null)}},te=s.enabled&&s.configured?{text:o(`Connected`),tone:`ok`}:s.enabled?{text:o(`Incomplete`),tone:`warn`}:{text:o(`Not connected`),tone:`off`};return(0,q.jsxs)(j,{icon:(0,q.jsx)(oe,{size:19}),title:o(`Email`),summary:s.configured&&s.enabled?`${s.address} · ${o(`reads and sends`)}`:o(`Read your inbox, draft and send mail`),status:te,open:d,onToggle:i?void 0:()=>f(!d),children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:o(`Reading is a moderate action; sending always asks you first. Use an app password where your provider offers one.`)}),(0,q.jsx)($,{label:o(`Provider`),children:(0,q.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:Ue.map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>P(e),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,_===e.imap?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e.label},e.id))})}),(0,q.jsx)($,{label:o(`Address`),children:(0,q.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),className:k,placeholder:`you@example.com`,inputMode:`email`,autoComplete:`off`})}),(0,q.jsx)($,{label:o(`Password`),hint:s.password_set?o(`A password is in the vault. Leave blank to keep it.`):o(`Stored encrypted in the vault as EMAIL_PASSWORD.`),children:(0,q.jsx)(`input`,{type:`password`,value:h,onChange:e=>g(e.target.value),className:k,placeholder:s.password_set?`••••••••`:o(`app password`),autoComplete:`off`})}),(0,q.jsxs)(`div`,{className:`grid grid-cols-[1fr_84px] gap-2`,children:[(0,q.jsx)($,{label:o(`IMAP server`),children:(0,q.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),className:k,placeholder:`imap.example.com`})}),(0,q.jsx)($,{label:o(`Port`),children:(0,q.jsx)(`input`,{type:`number`,value:y,onChange:e=>b(Number(e.target.value)),className:k})}),(0,q.jsx)($,{label:o(`SMTP server`),children:(0,q.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),className:k,placeholder:`smtp.example.com`})}),(0,q.jsx)($,{label:o(`Port`),children:(0,q.jsx)(`input`,{type:`number`,value:C,onChange:e=>w(Number(e.target.value)),className:k})})]}),(0,q.jsxs)(`label`,{className:`flex items-center gap-2 text-[13.5px]`,children:[(0,q.jsx)(`input`,{type:`checkbox`,checked:T,onChange:e=>E(e.target.checked),className:`accent-[var(--om-accent)]`}),` `,o(`STARTTLS for SMTP (off for port 465)`)]}),(0,q.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:O!==null||!p.trim()||!_.trim()||!x.trim()||!h&&!s.password_set,onClick:()=>void F(),className:D,children:[O===`save`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,s.configured?o(`Save & test`):o(`Connect`)]}),s.enabled&&(0,q.jsxs)(`button`,{type:`button`,disabled:O!==null,onClick:()=>void I(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[O===`off`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(W,{size:16}),` `,o(`Disconnect`)]})]}),M&&(0,q.jsx)(rt,{result:M,okText:o(`Signed in · {n} messages in the inbox`,{n:M.inbox??`?`})})]})}var Ge=[{id:`google`,label:`Google`,hint:`Settings → your calendar → Integrate calendar → Secret address in iCal format`},{id:`outlook`,label:`Outlook`,hint:`Settings → Calendar → Shared calendars → Publish a calendar → ICS link`},{id:`icloud`,label:`iCloud`,hint:`Share Calendar → Public Calendar → copy the webcal:// link`},{id:`fastmail`,label:`Fastmail`,hint:`Settings → Calendars → Export → Calendar URL`},{id:`file`,label:`.ics file`,hint:`A path on the machine where nanoMuse runs, for example ~/calendar.ics`}];function Ke({data:n,onChange:i,compact:a}){let{toast:o}=c(),s=r(),d=n.calendar,[f,p]=(0,G.useState)(!!a),[m,h]=(0,G.useState)(!1),[g,_]=(0,G.useState)(``),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null),[S,E]=(0,G.useState)(d.day_start),[A,M]=(0,G.useState)(d.day_end),[N,F]=(0,G.useState)(null),[te,L]=(0,G.useState)(null),[ne,R]=(0,G.useState)(null),[z,B]=(0,G.useState)(d.google.client_id),[ae,V]=(0,G.useState)(``),[H,oe]=(0,G.useState)(d.google.write);(0,G.useEffect)(()=>{E(d.day_start),M(d.day_end)},[d.day_start,d.day_end]),(0,G.useEffect)(()=>{B(d.google.client_id),oe(d.google.write)},[d.google.client_id,d.google.write]),(0,G.useEffect)(()=>{if(!d.google.connected){R(null);return}let e=!0;return(async()=>{try{let t=await u.googleCalendar();e&&R(t.calendars??[])}catch{e&&R([])}})(),()=>{e=!1}},[d.google.connected]);let se=async()=>{F(`add`),L(null);try{let e=await u.addCalendarFeed(g.trim(),v.trim());if(e.error)o(s(`Added, but it could not be read: {error}`,{error:e.error}));else{let t=e.feeds.find(e=>e.name===g.trim());o(`${s(`Calendar connected`)} · ${_e(t?.events??0)}`),_(``),y(``),h(!1)}i()}catch(e){o(e.message)}finally{F(null)}},ce=async e=>{F(e);try{await u.removeCalendarFeed(e),i()}catch(e){o(e.message)}finally{F(null)}},le=async()=>{F(`hours`);try{await u.setCalendar({day_start:S,day_end:A}),o(s(`Saved`)),i()}catch(e){o(e.message)}finally{F(null)}},ue=async()=>{F(`test`);try{L(await u.testCalendar()),i()}catch(e){o(e.message)}finally{F(null)}},U=async()=>{F(`gsave`);try{await u.setGoogleCalendar({client_id:z.trim(),client_secret:ae.trim()||void 0,write:H,enabled:!0}),V(``),o(s(`Saved`)),i()}catch(e){o(e.message)}finally{F(null)}},de=async()=>{F(`gconnect`);try{let{url:e}=await u.connectGoogleCalendar(H);window.open(e,`_blank`,`noopener`),o(s(`Finish signing in with Google in the window that opened.`)),i()}catch(e){o(e.message)}finally{F(null)}},fe=async()=>{F(`gdisconnect`);try{await u.disconnectGoogleCalendar(),R(null),o(s(`Disconnected`)),i()}catch(e){o(e.message)}finally{F(null)}},pe=async e=>{F(`gpick`);try{await u.setGoogleCalendars({default_calendar:e,calendar_ids:[e]}),i()}catch(e){o(e.message)}finally{F(null)}},me=async e=>{oe(e),F(`gwrite`);try{await u.setGoogleCalendar({write:e}),i()}catch(e){o(e.message)}finally{F(null)}},he=d.feeds.reduce((e,t)=>e+t.events,0),ge=d.feeds.filter(e=>e.error).length,K=d.google,_e=e=>e===1?s(`1 event`):s(`{n} events`,{n:e}),ve=K.connected?{text:K.can_write?s(`Connected`):s(`Read-only`),tone:`ok`}:K.configured?{text:s(`Sign in needed`),tone:`warn`}:d.configured?ge?{text:s(`{n} not reading`,{n:ge}),tone:`warn`}:{text:s(`Connected`),tone:`ok`}:{text:s(`Not connected`),tone:`off`},ye=K.connected?`${K.account||s(`Google Calendar`)} · ${K.can_write?s(`read + write`):s(`read-only`)}`:d.configured?d.feeds.length===1?`${d.feeds[0].name} · ${_e(he)}`:`${s(`{n} calendars`,{n:d.feeds.length})} · ${_e(he)}`:s(`Your agenda, free time, and events it can draft`);return(0,q.jsxs)(j,{icon:(0,q.jsx)(ie,{size:19}),title:s(`Calendar`),summary:K.connected||d.configured?ye:s(`Your agenda, free time, and events it can draft`),status:ve,open:f,onToggle:a?void 0:()=>p(!f),children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:s(`Reads your calendar from its private .ics link; the link stays in the vault. It never changes your calendar; an event it proposes comes as a file you add with a tap.`)}),(0,q.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,q.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,q.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,K.connected?`bg-emerald-500`:K.configured?`bg-amber-500`:`bg-surface-2 border border-border`)}),(0,q.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,q.jsx)(`div`,{className:`text-[13.5px] font-medium`,children:s(`Google Calendar`)}),(0,q.jsx)(`div`,{className:`text-[11.5px] text-muted truncate`,children:K.connected?`${K.account||s(`Signed in`)} · ${K.can_write?s(`read + write`):s(`read-only`)}`:K.configured?s(`Client set — sign in with Google`):s(`Paste your Google OAuth client id and secret`)})]}),K.connected?(0,q.jsx)(`button`,{type:`button`,"aria-label":s(`Disconnect`),disabled:N===`gdisconnect`,onClick:()=>void fe(),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:N===`gdisconnect`?(0,q.jsx)(l,{size:15,className:`animate-spin`}):(0,q.jsx)(W,{size:15})}):null]}),!K.connected&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)($,{label:s(`Client ID`),hint:s(`From the OAuth client you downloaded in Google Cloud Console.`),children:(0,q.jsx)(`input`,{value:z,onChange:e=>B(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`…apps.googleusercontent.com`,autoComplete:`off`})}),(0,q.jsx)($,{label:s(`Client secret`),hint:K.has_secret?s(`A secret is saved. Type a new one only to replace it.`):s(`Stored encrypted in the vault as GOOGLE_CALENDAR_SECRET.`),children:(0,q.jsx)(`input`,{type:`password`,value:ae,onChange:e=>V(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:K.has_secret?`••••••••`:`GOCSPX-…`,autoComplete:`off`})}),(0,q.jsx)($,{label:s(`Callback URL`),hint:s(`Register this exact URL on the OAuth client as an authorized redirect URI.`),children:(0,q.jsx)(`input`,{value:K.redirect_uri,readOnly:!0,className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`text-muted`)})}),(0,q.jsx)(O,{label:s(`Let the agent create and change events`),hint:s(`Off: read-only, the agent can look but not touch.`),checked:H,onChange:e=>oe(e)}),(0,q.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:N===`gsave`,onClick:()=>void U(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[N===`gsave`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16}),` `,s(`Save client`)]}),(0,q.jsxs)(`button`,{type:`button`,disabled:N===`gconnect`||!K.configured,onClick:()=>void de(),className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,children:[N===`gconnect`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(w,{size:16}),` `,s(`Sign in with Google`)]})]})]}),K.connected&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)(O,{label:s(`Let the agent create and change events`),hint:s(`Off: read-only, the agent can look but not touch.`),checked:H,onChange:e=>void me(e)}),ne&&ne.length>0&&(0,q.jsx)($,{label:s(`Calendar to read and write`),hint:s(`Events the agent reads and creates land here.`),children:(0,q.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:ne.map(e=>{let n=(K.default_calendar||K.calendar_ids[0]||``)===e.id;return(0,q.jsx)(`button`,{type:`button`,disabled:N===`gpick`,onClick:()=>void pe(e.id),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,n?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e.primary?s(`{name} (primary)`,{name:e.name}):e.name},e.id)})})}),K.error&&(0,q.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:K.error})]})]}),d.feeds.length>0&&(0,q.jsx)(`ul`,{className:`space-y-1.5`,children:d.feeds.map(n=>(0,q.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,q.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,n.error?`bg-rose-500`:n.fetched_at?`bg-emerald-500`:`bg-amber-500`)}),(0,q.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,q.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:n.name}),(0,q.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[n.error?n.error:n.fetched_at?`${_e(n.events)} · ${s(`read {when}`,{when:e(n.fetched_at)})}`:s(`not read yet`),!n.from_app&&` · ${s(`from config.toml`)}`]})]}),n.from_app&&(0,q.jsx)(`button`,{type:`button`,"aria-label":s(`Remove`),disabled:N===n.name,onClick:()=>void ce(n.name),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:N===n.name?(0,q.jsx)(l,{size:15,className:`animate-spin`}):(0,q.jsx)(I,{size:15})})]},n.name))}),m?(0,q.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,q.jsxs)($,{label:s(`Where is the link?`),children:[(0,q.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:Ge.map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>x(b===e.id?null:e.id),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,b===e.id?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:s(e.label)},e.id))}),b&&(0,q.jsx)(`div`,{className:`mt-1.5 text-[11.5px] text-muted leading-snug`,children:s(Ge.find(e=>e.id===b).hint)})]}),(0,q.jsx)($,{label:s(`Name`),children:(0,q.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),className:k,placeholder:s(`Work`)})}),(0,q.jsx)($,{label:s(`Private .ics link or file path`),hint:s(`Stored encrypted in the vault as CALENDAR_{name}.`,{name:g.trim().toUpperCase().replace(/[^A-Z0-9]+/g,`_`).replace(/^_+|_+$/g,``)||`NAME`})+` `+s(`nanoMuse fetches whatever address you put here, from this machine; only paste links you trust.`),children:(0,q.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:k,placeholder:`https://calendar.google.com/calendar/ical/…/basic.ics`,inputMode:`url`,autoComplete:`off`})}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:N===`add`||!g.trim()||!v.trim(),onClick:()=>void se(),className:D,children:[N===`add`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,s(`Connect`)]}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>h(!1),className:T,children:s(`Cancel`)})]})]}):(0,q.jsxs)(`button`,{type:`button`,onClick:()=>h(!0),className:T,children:[(0,q.jsx)(re,{size:16}),` `,s(`Add a calendar`)]}),d.configured&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)($,{label:s(`Working hours`),hint:s(`Free time is looked for inside these hours.`),children:(0,q.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,q.jsx)(`input`,{type:`time`,value:S,onChange:e=>E(e.target.value),"aria-label":s(`Working hours start`),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`w-auto`)}),(0,q.jsx)(`span`,{className:`text-muted`,children:s(`to`)}),(0,q.jsx)(`input`,{type:`time`,value:A,onChange:e=>M(e.target.value),"aria-label":s(`Working hours end`),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`w-auto`)}),(S!==d.day_start||A!==d.day_end)&&(0,q.jsx)(`button`,{type:`button`,disabled:N===`hours`,onClick:()=>void le(),className:t(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`px-3`),"aria-label":s(`Save`),children:N===`hours`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16})})]})}),(0,q.jsxs)(`button`,{type:`button`,disabled:N===`test`,onClick:()=>void ue(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[N===`test`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(P,{size:16}),` `,s(`Read again`)]}),te&&(0,q.jsx)(rt,{result:te,okText:d.feeds.length===1?_e(te.events??0):`${_e(te.events??0)} · ${s(`{n} calendars`,{n:d.feeds.length})}`})]})]})}function qe({data:n,onChange:i,compact:a}){let{toast:o}=c(),s=r(),d=n.contacts,[f,p]=(0,G.useState)(!!a),[m,h]=(0,G.useState)(!1),[g,_]=(0,G.useState)(``),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null),[S,C]=(0,G.useState)(null),[w,E]=(0,G.useState)(``),[O,A]=(0,G.useState)(null),M=(0,G.useRef)(null),N=e=>e===1?s(`1 person`):s(`{n} people`,{n:e}),F=(e,t)=>{if(e.error)o(s(`Added, but it could not be read: {error}`,{error:e.error}));else{let n=e.sources.find(e=>e.name===t);o(`${s(`Address book connected`)} · ${N(n?.contacts??0)}`),_(``),y(``),h(!1)}i()},te=async()=>{x(`add`),C(null);try{F(await u.addContactsSource(g.trim(),v.trim()),g.trim())}catch(e){o(e.message)}finally{x(null)}},L=async e=>{x(`upload`),C(null);try{let t=(g.trim()||e.name.replace(/\.vcf$/i,``)||`Imported`).slice(0,40);F(await u.importContacts(t,await e.text()),t)}catch(e){o(e.message)}finally{x(null),M.current&&(M.current.value=``)}},ne=async e=>{x(e);try{await u.removeContactsSource(e),i()}catch(e){o(e.message)}finally{x(null)}},R=async()=>{x(`test`);try{C(await u.testContacts()),i()}catch(e){o(e.message)}finally{x(null)}};(0,G.useEffect)(()=>{if(!w.trim()){A(null);return}let e=setTimeout(()=>{u.contacts(w.trim(),5).then(e=>A(e.people)).catch(()=>A([]))},250);return()=>clearTimeout(e)},[w]);let z=d.sources.filter(e=>e.error).length,ie=d.enabled?d.configured?z?{text:s(`{n} not reading`,{n:z}),tone:`warn`}:{text:s(`Connected`),tone:`ok`}:{text:s(`Not connected`),tone:`off`}:{text:s(`Off`),tone:`off`};return(0,q.jsxs)(j,{icon:(0,q.jsx)(U,{size:19}),title:s(`Contacts`),summary:d.configured?d.sources.length===0?`${N(d.count)} · ${s(`told to it in chat`)}`:`${N(d.count)} · ${d.sources.length===1?d.sources[0].name:s(`{n} address books`,{n:d.sources.length})}`:s(`Who is who, so it never guesses an address`),status:ie,open:f,onToggle:a?void 0:()=>p(!f),children:[(0,q.jsx)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:s(`Import a .vcf export from Google Contacts, iCloud, Outlook or your phone, or paste a link to one. People you mention in chat go into its own book. It looks people up before writing to them and warns when an address is unknown.`)}),(d.sources.length>0||d.own>0)&&(0,q.jsxs)(`ul`,{className:`space-y-1.5`,children:[d.own>0&&(0,q.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,q.jsx)(`span`,{className:`h-2 w-2 rounded-full bg-emerald-500`}),(0,q.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,q.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:s(`My contacts`)}),(0,q.jsx)(`div`,{className:`text-[11.5px] text-muted truncate`,children:`${N(d.own)} · ${s(`added in chat`)}`})]})]}),d.sources.map(n=>(0,q.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,q.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,n.error?`bg-rose-500`:n.fetched_at?`bg-emerald-500`:`bg-amber-500`)}),(0,q.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,q.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:n.name}),(0,q.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[n.error?n.error:n.fetched_at?`${N(n.contacts)} · ${s(`read {when}`,{when:e(n.fetched_at)})}`:s(`not read yet`),!n.from_app&&` · ${s(`from config.toml`)}`]})]}),n.from_app&&(0,q.jsx)(`button`,{type:`button`,"aria-label":s(`Remove`),disabled:b===n.name,onClick:()=>void ne(n.name),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:b===n.name?(0,q.jsx)(l,{size:15,className:`animate-spin`}):(0,q.jsx)(I,{size:15})})]},n.name))]}),m?(0,q.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,q.jsx)($,{label:s(`Name`),children:(0,q.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),className:k,placeholder:s(`iPhone`)})}),(0,q.jsxs)($,{label:s(`A .vcf file from your phone or computer`),hint:s(`Google Contacts: Export → vCard. iPhone: Contacts → select all → Share → Save to Files. Outlook: People → Manage → Export.`),children:[(0,q.jsx)(`input`,{ref:M,type:`file`,accept:`.vcf,text/vcard,text/x-vcard`,className:`hidden`,onChange:e=>e.target.files?.[0]&&void L(e.target.files[0])}),(0,q.jsxs)(`button`,{type:`button`,disabled:b===`upload`,onClick:()=>M.current?.click(),className:T,children:[b===`upload`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(pe,{size:16}),` `,s(`Choose a .vcf file`)]})]}),(0,q.jsx)($,{label:s(`Or a link / path to one`),hint:s(`A link is stored encrypted in the vault as CONTACTS_{name}.`,{name:g.trim().toUpperCase().replace(/[^A-Z0-9]+/g,`_`).replace(/^_+|_+$/g,``)||`NAME`})+` `+s(`nanoMuse fetches whatever address you put here, from this machine; only paste links you trust.`),children:(0,q.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:k,placeholder:`https://… /contacts.vcf`,inputMode:`url`,autoComplete:`off`})}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:b===`add`||!g.trim()||!v.trim(),onClick:()=>void te(),className:D,children:[b===`add`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,s(`Connect`)]}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>h(!1),className:T,children:s(`Cancel`)})]})]}):(0,q.jsxs)(`button`,{type:`button`,onClick:()=>h(!0),className:T,children:[(0,q.jsx)(re,{size:16}),` `,s(`Add an address book`)]}),d.configured&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsxs)(`div`,{className:`relative`,children:[(0,q.jsx)(B,{size:15,className:`absolute left-3 top-1/2 -translate-y-1/2 text-muted`}),(0,q.jsx)(`input`,{value:w,onChange:e=>E(e.target.value),"aria-label":s(`Find someone, as the agent would`),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pl-9`),placeholder:s(`Find someone, as the agent would`),autoComplete:`off`})]}),O!==null&&(0,q.jsxs)(`ul`,{className:`space-y-1`,children:[O.length===0&&(0,q.jsx)(`li`,{className:`text-[12.5px] text-muted px-1`,children:s(`No one matches.`)}),O.map(e=>(0,q.jsxs)(`li`,{className:`rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,q.jsxs)(`div`,{className:`text-[13.5px] font-medium`,children:[e.name,e.org&&(0,q.jsxs)(`span`,{className:`text-muted font-normal`,children:[` · `,e.org]})]}),(0,q.jsx)(`div`,{className:`text-[12px] text-muted break-words`,children:[...e.emails,...e.phones].join(` · `)})]},e.id))]}),d.sources.length>0&&(0,q.jsxs)(`button`,{type:`button`,disabled:b===`test`,onClick:()=>void R(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[b===`test`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(P,{size:16}),` `,s(`Read again`)]}),S&&(0,q.jsx)(rt,{result:S,okText:N(S.contacts??0)})]})]})}function Je({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.browser,[s,l]=(0,G.useState)(!1),d=async()=>{l(!0);try{await u.setBrowser(!o.enabled),n()}catch(e){i(e.message)}finally{l(!1)}};return(0,q.jsx)(j,{icon:(0,q.jsx)(V,{size:19}),title:a(`Browser`),summary:o.available?a(`Open pages, click, fill forms, screenshot`):a(`Playwright is not installed on the server`),status:o.enabled&&o.available?{text:a(`On`),tone:`ok`}:o.enabled?{text:a(`Unavailable`),tone:`warn`}:{text:a(`Off`),tone:`off`},open:!1,trailing:(0,q.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":o.enabled,disabled:s,onClick:()=>void d(),className:t(`relative h-7 w-12 shrink-0 rounded-full transition`,o.enabled?`bg-accent`:`bg-surface-2 border border-border`),children:(0,q.jsx)(`span`,{className:t(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,o.enabled?`left-[22px]`:`left-0.5`)})}),children:!o.available&&(0,q.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[a(`Install it where the server runs:`),` `,(0,q.jsx)(`code`,{className:`rounded bg-surface-2 px-1`,children:`pip install "nanomuse[browser]" && playwright install chromium`})]})})}function Ye({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.gui,[s,d]=(0,G.useState)(!1),[f,p]=(0,G.useState)(!1),[m,h]=(0,G.useState)(o.model),[g,_]=(0,G.useState)(o.base_url),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null);(0,G.useEffect)(()=>{h(o.model),_(o.base_url)},[o.model,o.base_url]);let S=async()=>{p(!0);try{await u.setGui({enabled:!o.enabled}),n()}catch(e){i(e.message)}finally{p(!1)}},w=async()=>{p(!0),x(null);try{await u.setGui({model:m.trim(),base_url:g.trim(),...v?{api_key:v.trim()}:{}}),y(``),n(),x(await u.testGui())}catch(e){i(e.message)}finally{p(!1)}},T=o.phone,[E,O]=(0,G.useState)(se),A=(0,G.useRef)(E);(0,G.useEffect)(()=>{if(!s)return;let e=()=>{let e=se();e!==A.current&&(A.current=e,O(e),setTimeout(n,400))};return e(),document.addEventListener(`visibilitychange`,e),window.addEventListener(`focus`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`focus`,e)}},[s,n]);let M=T.connected?a(`{name} is connected`,{name:T.device?.name??a(`A phone`)}):o.enabled?a(`No phone connected. Open the app on the phone`):a(`Tap, type and swipe in the apps on your phone`);return(0,q.jsxs)(j,{icon:(0,q.jsx)(z,{size:19}),title:a(`Phone`),summary:M,status:o.enabled&&T.connected?{text:a(`On`),tone:`ok`}:o.enabled?{text:a(`Waiting`),tone:`warn`}:{text:a(`Off`),tone:`off`},open:s,onToggle:()=>d(e=>!e),children:[(0,q.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,q.jsxs)(`div`,{className:`text-[13px] leading-snug`,children:[(0,q.jsx)(`div`,{className:`font-medium`,children:a(`Operate the phone`)}),(0,q.jsx)(`div`,{className:`text-[12px] text-muted`,children:a(`When on, the agent can read the screen and act in the apps on the connected phone: 12306, WeChat, Alipay… It asks before paying, sending or deleting.`)})]}),(0,q.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":o.enabled,disabled:f,onClick:()=>void S(),className:t(`relative h-7 w-12 shrink-0 rounded-full transition`,o.enabled?`bg-accent`:`bg-surface-2 border border-border`),children:(0,q.jsx)(`span`,{className:t(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,o.enabled?`left-[22px]`:`left-0.5`)})})]}),E!==null&&(0,q.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 px-3 py-2.5 text-[12.5px] leading-snug space-y-1.5`,children:[(0,q.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,q.jsx)(`span`,{className:`font-medium`,children:a(`This phone`)}),(0,q.jsx)(`span`,{className:t(`rounded-full px-2 py-0.5 text-[11px] font-medium`,E===`on`&&`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`,E===`off`&&`bg-amber-500/15 text-amber-700 dark:text-amber-300`,E===`unsupported`&&`bg-surface text-muted`),children:a(E===`on`?`Ready`:E===`off`?`Not yet`:`Not on this Android`)})]}),E===`unsupported`&&(0,q.jsx)(`div`,{className:`text-muted`,children:a(`Operating the screen needs Android 11 or newer; this phone can still be used for everything else.`)}),E===`off`&&(0,q.jsxs)(q.Fragment,{children:[(0,q.jsx)(`div`,{className:`text-muted`,children:a(`Turn on the nanoMuse accessibility service; that is how it sees the screen and taps for you, only while a task runs, with a Stop button on screen.`)}),(0,q.jsxs)(`div`,{className:`flex flex-wrap gap-2 pt-0.5`,children:[(0,q.jsx)(`button`,{type:`button`,onClick:()=>R()?.openAccessibilitySettings?.(),className:`rounded-full bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white`,children:a(`Open Accessibility settings`)}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>R()?.openAppSettings?.(),className:`rounded-full border border-border px-3 py-1.5 text-[12.5px]`,children:a(`App settings`)})]}),(0,q.jsx)(`div`,{className:`text-muted`,children:a(`Greyed out with “Restricted setting”? Android 13+ does that for apps installed from a download: in App settings tap ⋮ → Allow restricted settings, then come back.`)})]}),E===`on`&&(0,q.jsx)(`div`,{className:`text-muted`,children:a(`Android may switch the service off after an update or a battery clean-up; if the phone stops answering, come back here.`)})]}),T.connected&&T.device&&(0,q.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 px-3 py-2 text-[12.5px] text-muted`,children:[a(`{name} ({platform}), {n} apps`,{name:T.device.name,platform:T.device.platform,n:String(T.device.apps)}),T.last_screen&&(0,q.jsxs)(q.Fragment,{children:[` `,`·`,` `,a(`last seen in {app}`,{app:T.last_screen.app_name||T.last_screen.app})]})]}),(0,q.jsx)(`div`,{className:`text-[12px] text-muted leading-snug`,children:a(`The operator's model looks at screens step by step: a small, fast model that takes pictures. The Hands model row under Chat model picks one on the same endpoint and key; the fields here are for a different endpoint. Empty = {model}.`,{model:o.default_model||a(`the chat model`)})}),(0,q.jsx)($,{label:a(`Model`),children:(0,q.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),placeholder:o.default_model||a(`same as the chat model`),className:k})}),(0,q.jsx)($,{label:a(`Base URL`),children:(0,q.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),placeholder:a(`same as the chat model`),className:k})}),(0,q.jsx)($,{label:a(`API key`),hint:o.key_source===`vault`?a(`A key is in the vault; leave empty to keep it.`):o.key_source===`config`?a(`Set in config.toml.`):a(`Leave empty to use the main model's key.`),children:(0,q.jsx)(`input`,{type:`password`,value:v,onChange:e=>y(e.target.value),placeholder:`sk-…`,className:k,autoComplete:`off`})}),(0,q.jsxs)(`button`,{type:`button`,disabled:f,onClick:()=>void w(),className:D,children:[f?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(C,{size:16}),a(`Save and test`)]}),b&&(0,q.jsx)(rt,{result:b,okText:a(`The operator's model answers.`)})]})}function Xe({data:e,onChange:n}){let{toast:i}=c(),a=r(),[o,s]=(0,G.useState)(!1),[d,f]=(0,G.useState)(!1),[p,m]=(0,G.useState)(``),[h,g]=(0,G.useState)(``),[_,v]=(0,G.useState)(``),[y,b]=(0,G.useState)(`moderate`),[x,S]=(0,G.useState)(null),C=async()=>{S(`add`);try{let e=h.trim().split(/\s+/).filter(Boolean);await u.addMCP({name:p.trim(),command:e[0]??null,args:e.slice(1),url:_.trim()||null,risk:y}),m(``),g(``),v(``),f(!1),i(a(`Server connected`)),n()}catch(e){i(e.message)}finally{S(null)}},w=async e=>{S(e);try{await u.removeMCP(e),n()}catch(e){i(e.message)}finally{S(null)}},E=e.mcp.filter(e=>e.connected).length,O=(e.shared??[]).filter(e=>e.enabled&&!e.here),A=e=>{m(e.id),v(e.url),g(``),f(!0),s(!0)};return(0,q.jsxs)(j,{icon:(0,q.jsx)(ee,{size:19}),title:a(`MCP servers`),summary:e.mcp.length?a(`{n} of {total} connected · {tools} tools`,{n:E,total:e.mcp.length,tools:e.mcp.reduce((e,t)=>e+t.tools,0)}):a(`Add tools from any Model Context Protocol server`),status:e.mcp.length?{text:a(`{n} on`,{n:E}),tone:E?`ok`:`warn`}:{text:a(`None`),tone:`off`},open:o,onToggle:()=>s(!o),children:[e.mcp.length>0&&(0,q.jsx)(`ul`,{className:`space-y-1.5`,children:e.mcp.map(e=>(0,q.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,q.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,e.connected?`bg-emerald-500`:`bg-rose-500`)}),(0,q.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,q.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.builtin?a(`This phone`):e.name}),(0,q.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[e.builtin?a(`clipboard, notifications, calendar, contacts, location, alarms, photos`):e.url??[e.command,...e.args].join(` `),` ·`,` `,a(`{n} tools`,{n:e.tools}),` · `,a(e.risk),!e.from_app&&!e.builtin&&` · ${a(`from config.toml`)}`]})]}),e.from_app&&(0,q.jsx)(`button`,{type:`button`,"aria-label":a(`Remove`),disabled:x===e.name,onClick:()=>void w(e.name),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:x===e.name?(0,q.jsx)(l,{size:15,className:`animate-spin`}):(0,q.jsx)(I,{size:15})})]},e.name))}),O.length>0&&(0,q.jsx)(`ul`,{className:`space-y-1.5`,children:O.map(e=>(0,q.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl border border-dashed border-border/70 px-3 py-2`,children:[(0,q.jsx)(`span`,{className:`h-2 w-2 rounded-full bg-border`}),(0,q.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,q.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.label||e.id}),(0,q.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[a(`Connected on {device}; sign in here to use it on this device`,{device:e.device||e.device_id}),e.url?` · ${e.url}`:``]})]}),e.url&&(0,q.jsx)(`button`,{type:`button`,onClick:()=>A(e),className:t(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`py-1.5 text-[12.5px]`),children:a(`Sign in here`)})]},`${e.device_id}:${e.id}`))}),d?(0,q.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,q.jsx)($,{label:a(`Name`),children:(0,q.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),className:k,placeholder:`filesystem`})}),(0,q.jsx)($,{label:a(`Command`),hint:a(`Run locally over stdio, for example: npx -y @modelcontextprotocol/server-filesystem ./workspace`),children:(0,q.jsx)(`input`,{value:h,onChange:e=>g(e.target.value),className:k,placeholder:`npx -y @modelcontextprotocol/server-…`})}),(0,q.jsx)($,{label:a(`or URL`),hint:a(`A remote server over Streamable HTTP.`),children:(0,q.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),className:k,placeholder:`https://host/mcp`,inputMode:`url`})}),(0,q.jsx)($,{label:a(`Risk of its tools`),hint:a(`Decides when the Sentinel asks you before a call.`),children:(0,q.jsx)(`div`,{className:`flex gap-1.5`,children:[`safe`,`moderate`,`sensitive`].map(e=>(0,q.jsx)(`button`,{type:`button`,onClick:()=>b(e),"aria-pressed":y===e,className:t(`rounded-full px-3 py-1.5 text-[13px] border`,y===e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:a(e)},e))})}),(0,q.jsxs)(`div`,{className:`flex gap-2`,children:[(0,q.jsxs)(`button`,{type:`button`,disabled:x===`add`||!p.trim()||!(h.trim()||_.trim()),onClick:()=>void C(),className:D,children:[x===`add`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(ee,{size:16}),` `,a(`Connect`)]}),(0,q.jsx)(`button`,{type:`button`,onClick:()=>f(!1),className:T,children:a(`Cancel`)})]})]}):(0,q.jsxs)(`button`,{type:`button`,onClick:()=>f(!0),className:T,children:[(0,q.jsx)(re,{size:16}),` `,a(`Add a server`)]})]})}function Ze({data:e,onChange:n}){let{toast:i}=c(),a=r(),[o,s]=(0,G.useState)(!1),[d,f]=(0,G.useState)(``),[p,m]=(0,G.useState)(``),[h,g]=(0,G.useState)(null),_=async()=>{g(`add`);try{await u.vaultSet(d.trim(),p),f(``),m(``),i(a(`Stored in the vault`)),n()}catch(e){i(e.message)}finally{g(null)}},v=async e=>{g(e);try{await u.vaultDelete(e),n()}catch(e){i(e.message)}finally{g(null)}};return(0,q.jsxs)(j,{icon:(0,q.jsx)(H,{size:19}),title:a(`Vault`),summary:e.vault.length?a(`{n} secrets · encrypted on your machine`,{n:e.vault.length}):a(`Encrypted secrets the model never sees`),status:{text:`${e.vault.length}`,tone:`off`},open:o,onToggle:()=>s(!o),children:[(0,q.jsxs)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:[a(`Refer to a secret as`),` `,(0,q.jsx)(`code`,{className:`rounded bg-surface-2 px-1`,children:`{{vault:NAME}}`}),` `,a(`in a tool call or config: the value is filled in after the Sentinel approves and is redacted from everything the model reads.`)]}),e.vault.length>0&&(0,q.jsx)(`ul`,{className:`flex flex-wrap gap-1.5`,children:e.vault.map(e=>(0,q.jsxs)(`li`,{className:`flex items-center gap-1 rounded-full bg-surface-2 pl-3 pr-1 py-1 text-[12.5px] font-mono`,children:[e,(0,q.jsx)(`button`,{type:`button`,"aria-label":a(`Delete {name}`,{name:e}),disabled:h===e,onClick:()=>void v(e),className:`p-1 rounded-full text-muted hover:text-rose-500`,children:h===e?(0,q.jsx)(l,{size:13,className:`animate-spin`}):(0,q.jsx)(I,{size:13})})]},e))}),(0,q.jsxs)(`div`,{className:`grid grid-cols-[1fr_1fr_auto] gap-2 items-end`,children:[(0,q.jsx)($,{label:a(`Name`),children:(0,q.jsx)(`input`,{value:d,onChange:e=>f(e.target.value.toUpperCase().replace(/[^A-Z0-9_.-]/g,`_`)),className:t(k,`font-mono`),placeholder:`GITHUB_TOKEN`})}),(0,q.jsx)($,{label:a(`Value`),children:(0,q.jsx)(`input`,{type:`password`,value:p,onChange:e=>m(e.target.value),className:k,placeholder:a(`secret`),autoComplete:`off`})}),(0,q.jsx)(`button`,{type:`button`,disabled:h===`add`||!d||!p,onClick:()=>void _(),className:t(D,`px-3`),"aria-label":a(`Store`),children:h===`add`?(0,q.jsx)(l,{size:16,className:`animate-spin`}):(0,q.jsx)(re,{size:16})})]})]})}var Qe=`\0other`;function Q({label:e,provider:n,value:i,options:a,more:o=[],onChange:s,other:c=!0,placeholder:l}){let u=r(),d=a.includes(i)||o.includes(i),[f,p]=(0,G.useState)(!1),m=l?u(`Automatic · {model}`,{model:l}):u(`Automatic`),h=e=>e.map(e=>({id:e,name:e})),g=o.length?[{key:`menu`,label:u(`Menu`),rows:h(a),...l?{default:l}:{}},{key:`more`,label:u(`More models on your account`),rows:h(o)}]:[{key:`list`,label:n,rows:h(a),...l?{default:l}:{}}],_=i&&d?{group:a.includes(i)?g[0].key:`more`,id:i}:void 0;return(0,q.jsxs)(`div`,{className:`block text-[12.5px] text-muted`,children:[(0,q.jsx)(`span`,{className:`block mb-1`,children:e}),(a.length||o.length)&&(d||!i)&&!f?(0,q.jsx)(ye,{label:e,text:i?`${n} · ${i}`:m,heads:[{value:``,label:m}],headValue:i?void 0:``,groups:g,current:_,onHead:()=>s(``),onPick:(e,t)=>s(t.id),tail:c?u(`Other model…`):void 0,onTail:()=>{p(!0),s(``)}}):(0,q.jsx)(`input`,{value:i,onChange:e=>s(e.target.value),placeholder:m,"aria-label":e,className:t(k,`text-fg`),spellCheck:!1})]})}function $e(){let e=r();return F?(0,q.jsxs)(`a`,{href:F,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-0.5 text-accent`,children:[e(`How`),` `,(0,q.jsx)(w,{size:11})]}):null}var et=new Set([`input`,`select`,`textarea`]);function tt(e){return(0,G.isValidElement)(e)&&typeof e.type==`string`&&et.has(e.type)}function nt(e,t,n){let r=e=>{let r=e.props.id??t;return{node:(0,G.cloneElement)(e,{id:r,"aria-describedby":e.props[`aria-describedby`]??n}),id:r}};if(tt(e))return r(e);if(!(0,G.isValidElement)(e))return null;let i=G.Children.toArray(e.props.children),a=i.filter(tt);if(a.length!==1)return null;let o=r(a[0]),s=i.map(e=>e===a[0]?o.node:e);return{node:(0,G.cloneElement)(e,void 0,...s),id:o.id}}function $({label:e,hint:t,children:n}){let r=(0,G.useId)(),i=t?`${r}-hint`:void 0,a=`${r}-label`,o=nt(n,r,i);return(0,q.jsxs)(`div`,{children:[(0,q.jsx)(`label`,{id:a,htmlFor:o?.id,className:`text-[12px] text-muted`,children:e}),(0,q.jsx)(`div`,{className:`mt-1`,role:o?void 0:`group`,"aria-labelledby":o?void 0:a,"aria-describedby":o?void 0:i,children:o?o.node:n}),t&&(0,q.jsx)(`div`,{id:i,className:`mt-1 text-[11.5px] text-muted leading-snug`,children:t})]})}function rt({result:e,okText:n}){let i=r(),[a,o]=(0,G.useState)(!1),s=!e.ok&&e.detail&&e.detail!==e.error?e.detail:null;return(0,q.jsxs)(`div`,{className:t(`rounded-2xl px-3 py-2 text-[12.5px]`,e.ok?`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`:`bg-rose-500/12 text-rose-700 dark:text-rose-300`),children:[e.ok?n:i(e.error??`The model provider did not answer.`),s&&(0,q.jsxs)(q.Fragment,{children:[` `,(0,q.jsx)(`button`,{type:`button`,onClick:()=>o(e=>!e),className:`underline decoration-dotted underline-offset-2 opacity-70 hover:opacity-100`,children:i(a?`Hide details`:`Details`)}),a&&(0,q.jsx)(`div`,{className:`mt-1.5 break-all font-mono text-[11px] opacity-80`,children:s})]})]})}function it(e){try{return new URL(e).host}catch{return e}}export{Ke as CalendarCard,Le as ConnectionsScreen,qe as ContactsCard,We as EmailCard,Re as ModelCard}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/ConnectionsScreen-DjHkABEK.js b/nanomuse/server/static/assets/ConnectionsScreen-DjHkABEK.js deleted file mode 100644 index 7bf90c46..00000000 --- a/nanomuse/server/static/assets/ConnectionsScreen-DjHkABEK.js +++ /dev/null @@ -1 +0,0 @@ -import{A as e,E as t,J as n,K as r,L as i,Q as a,Y as o,b as s,f as c,q as l,x as u}from"./MuseHeader-DHtaUVEa.js";import{C as d,D as f,O as p,S as m,_ as h,b as g,f as _,h as v,m as y,p as b,x,y as S}from"./StarNudge-viN7_tiM.js";import{n as C,t as w}from"./external-link-3iD-Bcbv.js";import{a as T,c as E,i as D,n as O,r as k,s as A,t as j}from"./Form-heEKMZyj.js";import{t as M}from"./clapperboard-vj4UL6pS.js";import{t as N}from"./image-pZaHCatu.js";import{t as P}from"./plug-DQfRcVG2.js";import{t as F}from"./refresh-cw-DQelymc2.js";import{A as I,B as L,M as R,N as ee,St as te,T as z,W as B,X as ne,Y as V,bt as re,ct as ie,ft as ae,lt as oe,ot as se,w as ce}from"./index-CrH9-E2E.js";import{t as H}from"./BackBar-B5YUZVK1.js";import{t as U}from"./CloudCard-D6djdKgw.js";var W=n(`brain-circuit`,[[`path`,{d:`M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z`,key:`l5xja`}],[`path`,{d:`M9 13a4.5 4.5 0 0 0 3-4`,key:`10igwf`}],[`path`,{d:`M6.003 5.125A3 3 0 0 0 6.401 6.5`,key:`105sqy`}],[`path`,{d:`M3.477 10.896a4 4 0 0 1 .585-.396`,key:`ql3yin`}],[`path`,{d:`M6 18a4 4 0 0 1-1.967-.516`,key:`2e4loj`}],[`path`,{d:`M12 13h4`,key:`1ku699`}],[`path`,{d:`M12 18h6a2 2 0 0 1 2 2v1`,key:`105ag5`}],[`path`,{d:`M12 8h8`,key:`1lhi5i`}],[`path`,{d:`M16 8V5a2 2 0 0 1 2-2`,key:`u6izg6`}],[`circle`,{cx:`16`,cy:`13`,r:`.5`,key:`ry7gng`}],[`circle`,{cx:`18`,cy:`3`,r:`.5`,key:`1aiba7`}],[`circle`,{cx:`20`,cy:`21`,r:`.5`,key:`yhc1fs`}],[`circle`,{cx:`20`,cy:`8`,r:`.5`,key:`1e43v0`}]]),le=n(`contact`,[[`path`,{d:`M16 2v2`,key:`scm5qe`}],[`path`,{d:`M7 22v-2a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2`,key:`1waht3`}],[`path`,{d:`M8 2v2`,key:`pbkmx`}],[`circle`,{cx:`12`,cy:`11`,r:`3`,key:`itu57m`}],[`rect`,{x:`3`,y:`4`,width:`18`,height:`18`,rx:`2`,key:`12vinp`}]]),ue=n(`eye-off`,[[`path`,{d:`M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49`,key:`ct8e1f`}],[`path`,{d:`M14.084 14.158a3 3 0 0 1-4.242-4.242`,key:`151rxh`}],[`path`,{d:`M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143`,key:`13bj9a`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]),de=n(`eye`,[[`path`,{d:`M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0`,key:`1nclc0`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),fe=n(`unplug`,[[`path`,{d:`m19 5 3-3`,key:`yk6iyv`}],[`path`,{d:`m2 22 3-3`,key:`19mgm9`}],[`path`,{d:`M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z`,key:`goz73y`}],[`path`,{d:`M7.5 13.5 10 11`,key:`7xgeeb`}],[`path`,{d:`M10.5 16.5 13 14`,key:`10btkg`}],[`path`,{d:`m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z`,key:`1snsnr`}]]),pe=n(`upload`,[[`path`,{d:`M12 3v12`,key:`1x0j5s`}],[`path`,{d:`m17 8-5-5-5 5`,key:`7q97r8`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}]]),G=a(o(),1);function me(e,t){let n=t.trim().toLowerCase();return!n||e.id.toLowerCase().includes(n)||e.name.toLowerCase().includes(n)}function he(e,t){let n=e.default&&e.rows.some(t=>t.id===e.default)?e.default:e.rows.find(e=>e.recommended)?.id,r=t&&t.group===e.key?t.id:void 0,i=[];for(let t of[n,r]){if(!t||i.some(e=>e.id===t))continue;let n=e.rows.find(e=>e.id===t);n&&i.push(n)}return[...i,...e.rows.filter(e=>!i.includes(e))]}function ge(e){return e.reduce((e,t)=>e+t.rows.length,0)}function K(e){return ge(e)>8}function q(e,{current:t,query:n=``,expanded:r}={}){let i=n.trim().length>0,a=[];for(let o of e){let e=he(o,t);if(i){let t=e.filter(e=>me(e,n));t.length&&a.push({key:o.key,label:o.label,rows:t,more:0});continue}e.length>8&&!r?.has(o.key)?a.push({key:o.key,label:o.label,rows:e.slice(0,8),more:e.length-8}):a.push({key:o.key,label:o.label,rows:e,more:0})}return a}var J=s(),_e=`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-[14px] hover:bg-surface-2 focus:bg-surface-2 focus:outline-none`;function ve({label:e,text:n,heads:i=[],headValue:a,tail:o,groups:s,current:c,disabled:l,onHead:u,onPick:d,onTail:f,rowText:p}){let m=r(),[h,g]=(0,G.useState)(!1),[_,v]=(0,G.useState)(``),[y,b]=(0,G.useState)(()=>new Set),[x,S]=(0,G.useState)(!1),w=(0,G.useRef)(null),T=(0,G.useRef)(null),D=K(s),O=(0,G.useCallback)((e=!1)=>{g(!1),v(``),b(new Set),S(e)},[]);(0,G.useEffect)(()=>{!h&&x&&(S(!1),w.current?.focus())},[h,x]),(0,G.useEffect)(()=>{if(!h)return;let e=e=>{let t=e.target;T.current?.contains(t)||w.current?.contains(t)||O()},t=e=>{e.key===`Escape`&&(e.stopPropagation(),O(!0))};return document.addEventListener(`pointerdown`,e,!0),document.addEventListener(`keydown`,t,!0),()=>{document.removeEventListener(`pointerdown`,e,!0),document.removeEventListener(`keydown`,t,!0)}},[h,O]),(0,G.useEffect)(()=>{h&&T.current&&(T.current.querySelector(`input`)??T.current.querySelector(`[aria-selected="true"]`)??T.current.querySelector(`[data-row]`))?.focus()},[h]);let A=e=>{if(e.key!==`ArrowDown`&&e.key!==`ArrowUp`)return;let t=[...e.currentTarget.closest(`[role="listbox"]`)?.querySelectorAll(`[data-row]`)??[]];if(!t.length)return;e.preventDefault();let n=t.indexOf(e.target);t[e.key===`ArrowDown`?n<0?0:Math.min(t.length-1,n+1):n<0?t.length-1:Math.max(0,n-1)]?.focus()},j=q(s,{current:c,query:_,expanded:y}),M=_.trim().length>0,N=(e,n,r,i,a)=>(0,J.jsxs)(`button`,{type:`button`,role:`option`,"aria-selected":n,"data-row":``,title:a,onClick:i,onKeyDown:A,className:t(_e,n&&`text-accent font-medium`),children:[(0,J.jsx)(`span`,{className:`min-w-0 flex-1 truncate`,children:r}),n&&(0,J.jsx)(C,{size:15,className:`shrink-0`})]},e);return(0,J.jsxs)(`div`,{className:`relative`,children:[(0,J.jsxs)(`button`,{ref:w,type:`button`,disabled:l,"aria-label":e,"aria-haspopup":`listbox`,"aria-expanded":h,title:n,onClick:()=>h?O():g(!0),className:t(k,`flex items-center gap-2 text-left text-fg disabled:opacity-50`),children:[(0,J.jsx)(`span`,{className:`min-w-0 flex-1 truncate`,children:n}),(0,J.jsx)(E,{size:16,className:`shrink-0 text-muted`})]}),h&&(0,J.jsxs)(`div`,{ref:T,role:`listbox`,"aria-label":e,className:`absolute left-0 right-0 top-full z-30 mt-1 flex max-h-[60vh] flex-col overflow-hidden rounded-2xl border border-border bg-surface p-1.5 shadow-xl`,children:[i.length>0&&(0,J.jsx)(`div`,{className:`mb-1 flex flex-col border-b border-border pb-1`,children:i.map(e=>N(`head:${e.value}`,a===e.value,e.label,()=>{u?.(e.value),O(!0)}))}),D&&(0,J.jsxs)(`label`,{className:`mb-1.5 flex items-center gap-2 rounded-xl bg-surface-2 px-3 py-2 text-muted focus-within:ring-2 focus-within:ring-accent/40`,children:[(0,J.jsx)(V,{size:15,className:`shrink-0`}),(0,J.jsx)(`input`,{type:`search`,value:_,onChange:e=>v(e.target.value),onKeyDown:A,placeholder:m(`Search models`),"aria-label":m(`Search models`),autoComplete:`off`,spellCheck:!1,className:`min-w-0 flex-1 bg-transparent text-[14px] text-fg outline-none placeholder:text-muted`})]}),(0,J.jsxs)(`div`,{className:`min-h-0 flex-1 overflow-y-auto`,children:[M&&j.length===0&&(0,J.jsx)(`div`,{className:`px-3 py-2.5 text-[13.5px] text-muted`,children:m(`No model matches`)}),j.map(e=>(0,J.jsxs)(`div`,{role:`group`,"aria-label":e.label,className:`[&+&]:mt-1.5`,children:[(0,J.jsx)(`div`,{className:`truncate px-3 pb-0.5 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted`,children:e.label}),e.rows.map(t=>N(`${e.key}:${t.id}`,c?.group===e.key&&c.id===t.id,p?p(t):t.name,()=>{d(e.key,t),O(!0)},t.id)),e.more>0&&(0,J.jsx)(`button`,{type:`button`,"data-row":``,onKeyDown:A,onClick:()=>b(t=>new Set([...t,e.key])),className:t(_e,`text-[13px] text-accent`),children:m(`Show {n} more`,{n:e.more})})]},e.key))]}),o&&!M&&(0,J.jsx)(`div`,{className:`mt-1 border-t border-border pt-1`,children:N(`tail`,!1,o,()=>{f?.(),O(!0)})})]})]})}var ye=`nanomuse_cloud`,be=`{{vault:NANOMUSE_CLOUD_KEY}}`,Y=`custom`,xe=[`openai`,`openai_responses`];function X(e){try{return new URL(e).hostname.toLowerCase()}catch{return``}}function Se(e,t,n=_){if(e&&!xe.includes(e))return n.find(t=>t.id===e)??null;let r=X(t);if(!r)return null;let i=e=>[e.base_url,e.base_url_global].map(e=>X(e??``)).filter(Boolean);return n.find(e=>i(e).includes(r))||(n.find(e=>i(e).some(e=>{let t=e.indexOf(`.`);return t>0&&r.endsWith(e.slice(t))}))??null)}function Ce(e,t,n=``,r=_){if(e===`nanomuse_cloud`)return`nanoMuse Cloud`;if(e===`chatgpt`)return`ChatGPT`;let i=r.find(t=>t.id===e);return i&&e!==`custom`?g(i,t):X(n)||e}function we(e,t,n=_){if(!e.effective_provider)return``;let r=Ce(e.effective_provider,t,e.base_url,n);return e.effective_model?`${r} · ${e.effective_model}`:r}function Te(e,t,n,r=_){let i=e.effective_model??``;if(!i)return``;let a=(e,t)=>Ce(Se(e,t,r)?.id??(xe.includes(e)?`custom`:e),n,t,r),o;return o=e.effective_source===`cloud`?`nanoMuse Cloud`:e.effective_source===`gui`&&(e.provider_id||e.base_url||!xe.includes(e.provider||`openai`))?e.provider_id?Ce(e.provider_id,n,e.base_url,r):a(e.provider,e.base_url):t.cloud?`nanoMuse Cloud`:a(t.provider,t.base_url),`${o} · ${i}`}function Ee(e,t){let n=t.list??_,r=[];t.signedIn&&r.push({value:ye,label:`nanoMuse Cloud`,group:`cloud`,needsKey:!1,entry:null});let i=t.chat.cloud?null:Se(t.chat.provider,t.chat.base_url,n);i&&i.capabilities.includes(e)&&r.push({value:i.id,label:g(i,t.locale),group:`chat`,needsKey:!1,entry:i});for(let a of x(t.region,e,n))a.id!==i?.id&&r.push({value:a.id,label:g(a,t.locale),group:`add`,needsKey:!0,entry:a});return r.push({value:Y,label:``,group:`add`,needsKey:!1,entry:null}),r}function De(e,t){return e.configured?t&&e.base_url&&X(e.base_url)===X(t)?ye:e.provider_id||`custom`:``}var Oe=`nanomuse_cloud`;function Z(){let{state:e}=c(),[t,n]=(0,G.useState)(null),[i,a]=(0,G.useState)(null),o=e.profile?.name??`nanoMuse`,s=r(),d=(0,G.useCallback)(async()=>{try{n(await u.connections()),a(null)}catch(e){a(s(e.message))}},[s]);return(0,G.useEffect)(()=>{d()},[d,e.connectionsVersion]),(0,J.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,J.jsx)(H,{title:s(`Connections`),description:s(`What {name} can reach; keys stay in the vault on this machine.`,{name:o})}),(0,J.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-4`,children:[i&&(0,J.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 text-rose-700 dark:text-rose-300 p-3 text-[13.5px]`,children:i}),!t&&!i&&(0,J.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,J.jsx)(l,{className:`animate-spin`,size:20})}),t&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(ke,{data:t,onChange:d}),t.image&&(0,J.jsx)(Ae,{slot:`image`,data:t,onChange:d}),t.video&&(0,J.jsx)(Ae,{slot:`video`,data:t,onChange:d}),(0,J.jsx)(U,{account:e.hub?.account??null,onChange:d}),t.embeddings.memory_enabled&&(0,J.jsx)(je,{data:t,onChange:d}),(0,J.jsx)(Me,{data:t,onChange:d}),(0,J.jsx)(Pe,{data:t,onChange:d}),(0,J.jsx)(Ie,{data:t,onChange:d}),(0,J.jsx)(Q,{data:t,onChange:d}),(0,J.jsx)(Le,{data:t,onChange:d}),(0,J.jsx)(Re,{data:t,onChange:d}),(0,J.jsx)(ze,{data:t,onChange:d}),(0,J.jsx)(Be,{data:t,onChange:d})]})]})]})}function ke({data:e,onChange:n,compact:a}){let{toast:o,state:s}=c(),v=r(),[x,S]=(0,G.useState)(!!a),O=e.providers,M=!!s.hub?.account?.any_model,N=Object.entries(O).find(([t,n])=>t!==`custom`&&n.base_url&&e.llm.base_url.startsWith(n.base_url)&&n.provider===e.llm.provider)?.[0]??Object.entries(O).find(([t,n])=>t!==`custom`&&n.base_url&&e.llm.base_url.startsWith(n.base_url))?.[0]??(e.llm.base_url?`custom`:`deepseek`),[F,I]=(0,G.useState)(N),[L,z]=(0,G.useState)(e.llm.model),[B,ne]=(0,G.useState)(e.llm.base_url),[V,re]=(0,G.useState)(``),[ae,oe]=(0,G.useState)(!1),[se,ce]=(0,G.useState)(!1),[H,U]=(0,G.useState)(e.llm.key_source===`none`&&!!O[N]?.key_optional),[W,le]=(0,G.useState)(e.llm.tool_mode||`auto`),[fe,pe]=(0,G.useState)(e.llm.proxy??``),[me,he]=(0,G.useState)(!1),[ge,K]=(0,G.useState)(!1),[q,_e]=(0,G.useState)(null),[ye,be]=(0,G.useState)(!1),[Y,xe]=(0,G.useState)(e.gui?.model??``),[X,Se]=(0,G.useState)({list:O[N]?.models??[],image:[],video:[],menu:[],catalog:[],imageCatalog:[],videoCatalog:[],vision:[],gui:O[N]?.gui_models??[],source:`catalogue`}),[Ce,we]=(0,G.useState)(!1),[Ee,De]=(0,G.useState)(!1);(0,G.useEffect)(()=>{z(e.llm.model),ne(e.llm.base_url),le(e.llm.tool_mode||`auto`),pe(e.llm.proxy??``),he(!1),xe(e.gui?.model??``),I(N)},[e.llm,e.gui?.model]);let Z=O[F],ke=F===`custom`||!Z?.base_url?B:Z.base_url;(0,G.useEffect)(()=>{if(!x)return;let e=!0,t=setTimeout(()=>{Se(e=>({...e,source:`loading`})),u.llmModels({preset:F,base_url:ke,api_key:V||void 0}).then(t=>{e&&(Se({list:t.models,image:t.image_models??[],video:t.video_models??[],menu:t.menu??[],catalog:t.catalog??[],imageCatalog:t.image_catalog??[],videoCatalog:t.video_catalog??[],vision:t.vision??[],gui:t.gui??O[F]?.gui_models??[],source:t.source}),t.source===`live`&&t.models.length&&!Ce&&z(e=>t.models.includes(e)?e:t.models[0]))}).catch(()=>e&&Se({list:Z?.models??[],image:[],video:[],menu:[],catalog:[],imageCatalog:[],videoCatalog:[],vision:[],gui:Z?.gui_models??[],source:`catalogue`}))},V?600:150);return()=>{e=!1,clearTimeout(t)}},[x,F,ke,V]);let Ae=t=>{I(t);let n=O[t];n&&(n.base_url?ne(n.base_url):O[F]?.base_url&&ne(``),(!L||Object.values(O).some(e=>e.models?.includes(L)))&&n.models?.length&&!n.models.includes(L)&&z(n.models[0]),Se({list:n.models??[],image:[],video:[],menu:[],catalog:[],imageCatalog:[],videoCatalog:[],vision:[],gui:n.gui_models??[],source:`catalogue`}),(!e.gui?.model||Object.values(O).some(t=>t.gui_model===e.gui?.model))&&xe(n.cloud?``:n.gui_model??``),De(!1),we(!1),U(!1))};(0,G.useEffect)(()=>{let e=R();e&&O[e]&&(S(!0),Ae(e))},[]);let je=!!Z?.no_key||H&&!!Z?.key_optional,Me=e.llm.cloud?{text:v(`Your account's model`),tone:`ok`}:e.llm.key_source===`none`&&!O[N]?.no_key&&!O[N]?.key_optional?{text:v(`No key yet`),tone:`warn`}:e.llm.key_source===`missing`?{text:v(`Key missing from vault`),tone:`warn`}:{text:e.llm.key_source===`vault`?v(`Key in vault`):e.llm.key_source===`config`?v(`Key from config`):v(`No key needed`),tone:`ok`},Ne=async()=>{K(!0),_e(null);try{Z?.cloud?await u.cloudUseAsModel(L.trim()):await u.setLLM({provider:Z?.provider??`openai`,model:L.trim(),base_url:ke.trim(),tool_mode:W,api_key:V||(je?``:null),proxy:me&&e.llm.proxy!==void 0?fe.trim():null}),Y.trim()!==(e.gui?.model??``)&&await u.setGui({model:Y.trim()}),re(``),h(),o(v(`Model saved`)),n(),a||S(!1)}catch(e){o(e.message)}finally{K(!1)}},Pe=async()=>{be(!0);try{_e(await u.testLLM())}catch(e){_e({ok:!1,error:e.message})}finally{be(!1)}},Fe=[{id:`cloud`,title:v(`Your account`),ids:[]},{id:`openai`,title:v(`OpenAI-compatible · Chat Completions`),ids:[]},{id:`responses`,title:v(`Responses API`),ids:[]},{id:`local`,title:v(`Local or your own endpoint`),ids:[]}];for(let[e,t]of Object.entries(O))(Fe.find(e=>e.id===(t.group??`openai`))??Fe[0]).ids.push(e);for(let e of Fe)e.ids=p(e.ids,O,s.hub?.account);let Ie=F===`custom`||!Z?.base_url,Q=Z?.cloud?null:_.find(e=>e.id===b(F))??null,Le=m(s.hub?.account),Re=i(),ze=e=>!!Q&&!Q.user_capabilities&&!Q.capabilities.includes(e),Be=O[Oe],He=Z?.cloud?X.gui[0]??Z.gui_model??``:Q&&!Q.user_capabilities?Q.capabilities.includes(`vision`)?Q.defaults.hands||L.trim():Be?.gui_model??L.trim():L.trim(),Ke=!Z?.cloud&&ze(`vision`)&&!!Be,qe=e.gui?Te(e.gui,e.llm,Re):``,Je=Array.from(new Set([...Z?.cloud?X.gui:[],...Z?.gui_model?[Z.gui_model]:[],...X.list.filter(e=>X.vision.includes(e)||f(e)===!0)])).filter(Boolean),Ye=Z?.cloud?`nanoMuse Cloud`:Q?g(Q,Re):Ge(ke)||F,Xe=Z?.cloud?X.gui[0]??Z.gui_model??``:Q?.defaults.hands??Z?.gui_model??``,Ze=[{key:`hands`,label:Ye,rows:Je.map(e=>({id:e,name:e})),...Xe?{default:Xe}:{}}],Qe=Ie&&/^https?:\/\/[^/]+\/?$/.test(B.trim());return(0,J.jsxs)(j,{icon:(0,J.jsx)(te,{size:19}),title:v(`Chat model`),summary:e.llm.cloud?`nanoMuse Cloud · ${e.llm.model||`—`}`:`${e.llm.model||`—`}${e.llm.base_url?` · ${Ge(e.llm.base_url)}`:``}`,status:Me,open:x,onToggle:a?void 0:()=>S(!x),children:[(0,J.jsx)($,{label:v(`Provider`),children:(0,J.jsxs)(`div`,{className:`space-y-2.5`,children:[Fe.filter(e=>e.ids.length).map(e=>(0,J.jsxs)(`div`,{children:[(0,J.jsx)(`div`,{className:`mb-1 text-[11px] uppercase tracking-wide text-muted`,children:e.title}),(0,J.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.ids.map(e=>{let n=O[e];return(0,J.jsxs)(`button`,{type:`button`,onClick:()=>Ae(e),className:t(`rounded-2xl px-3 py-1.5 text-left border leading-tight`,F===e?`border-accent bg-accent/10 text-accent`:`border-border text-muted`),children:[(0,J.jsx)(`span`,{className:t(`block text-[13px]`,F===e&&`font-medium`),children:n.label}),n.subtitle&&(0,J.jsx)(`span`,{className:`block text-[10.5px] opacity-80`,children:v(n.subtitle)})]},e)})})]},e.id)),Q&&(0,J.jsxs)(`p`,{className:`text-[12px] text-muted`,children:[(0,J.jsx)(`span`,{className:`font-medium text-fg`,children:g(Q,Re)}),` · `,y(v,Q.capabilities),`. `,Re===`zh-CN`?Q.note_zh:Q.note]}),(0,J.jsxs)(`div`,{children:[(0,J.jsxs)(`button`,{type:`button`,onClick:()=>ce(!se),className:`inline-flex items-center gap-1.5 text-[12.5px] text-accent`,children:[(0,J.jsx)(ie,{size:13}),` `,v(`Or sign in with a ChatGPT plan`),` `,se?(0,J.jsx)(A,{size:13}):(0,J.jsx)(E,{size:13})]}),se&&(0,J.jsx)(`div`,{className:`mt-2`,children:(0,J.jsx)(ee,{compact:!0,onChanged:()=>{n()}})})]})]})}),Ie&&(0,J.jsx)($,{label:v(`Base URL`),hint:v(Qe?`/v1 is added when the URL has no path.`:`The endpoint that serves /chat/completions, for example http://127.0.0.1:8000/v1.`),children:(0,J.jsx)(`input`,{value:B,onChange:e=>ne(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`https://host/v1`,inputMode:`url`,autoCapitalize:`off`})}),(0,J.jsxs)($,{label:v(`Chat model`),hint:Z?.cloud&&M&&X.catalog.length?v(`{n} models your account may use: the menu first, then everything else the provider lists under the Cloud key. Pick one; the ones beyond the menu are priced as the dearest menu model of their kind.`,{n:X.list.length}):Z?.cloud&&M?v(`Your account may name any model the provider has, not only these: type its id (a chat model here, a picture or clip model below) and it goes through as typed.`):X.source===`live`?v(`{n} models from the endpoint`,{n:X.list.length}):X.source===`loading`?v(`Asking the endpoint…`):X.list.length?v(`Could not list the endpoint's models; these are the usual ones.`):void 0,children:[Z?.cloud&&X.catalog.length>0&&!Ee&&(!L||X.list.includes(L))?(0,J.jsxs)(`select`,{value:L,onChange:e=>{if(e.target.value===Ve){De(!0);return}z(e.target.value),we(!0)},className:t(k,`text-fg`),children:[!L&&(0,J.jsx)(`option`,{value:``,children:v(`model name`)}),(0,J.jsx)(`optgroup`,{label:v(`Menu`),children:X.menu.map(e=>(0,J.jsx)(`option`,{value:e,children:X.vision.includes(e)?`${e} · ${v(`reads pictures`)}`:e},e))}),(0,J.jsx)(`optgroup`,{label:v(`More models on your account`),children:X.catalog.map(e=>(0,J.jsx)(`option`,{value:e,children:X.vision.includes(e)?`${e} · ${v(`reads pictures`)}`:e},e))}),(0,J.jsx)(`option`,{value:Ve,children:v(`Type a model id…`)})]}):(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(`input`,{list:`om-models`,value:L,onChange:e=>{z(e.target.value),we(!0)},className:k,placeholder:v(`model name`),autoCapitalize:`off`,autoCorrect:`off`,autoFocus:Ee}),(0,J.jsx)(`datalist`,{id:`om-models`,children:X.list.map(e=>(0,J.jsx)(`option`,{value:e},e))}),Z?.cloud&&X.catalog.length>0&&(0,J.jsx)(`button`,{type:`button`,className:`mt-1.5 text-[12px] text-accent`,onClick:()=>{De(!1),X.list.includes(L)||z(X.menu[0]??X.list[0]??``)},children:v(`Pick from the list`)})]}),X.list.length>0&&(0,J.jsx)(`div`,{className:`mt-1.5 flex flex-wrap gap-1.5`,children:(X.menu.length?X.menu:X.list).slice(0,8).map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>z(e),className:t(`rounded-full px-2.5 py-1 text-[12px] border`,L===e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e},e))})]}),(0,J.jsxs)($,{label:v(`Hands model`),hint:Z?.cloud?v(`The model that looks at screens when the hands run: the phone, this computer, a page the browser cannot read. Your account's hands model is {model}; the chat model above is a different pick.`,{model:He||`—`}):Ke?v(`The model that looks at screens when the hands run. This provider has no model that sees, so the hands use your account's, {model}, unless you pick one here.`,{model:He||`—`}):v(`The model that looks at screens when the hands run: a small, fast one that takes pictures, on the same endpoint and key. Default: this provider's own hands model.`),children:[(0,J.jsx)(ve,{label:v(`Hands model`),text:Y?`${Ye} · ${Y}`:v(`Automatic`),heads:[{value:``,label:v(`Automatic`)}],headValue:Y?void 0:``,groups:Ze,current:Y?{group:`hands`,id:Y}:void 0,onHead:()=>xe(``),onPick:(e,t)=>xe(t.id)}),qe&&(0,J.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:v(`Currently {value}`,{value:qe})}),!Z?.cloud&&L.trim()&&!Y&&f(L.trim())===!1&&(0,J.jsx)(`div`,{className:`mt-1.5 text-[12px] text-amber-700 dark:text-amber-300`,children:v(`{model} does not take pictures, so the hands would be blind with it. Pick a model that sees for them.`,{model:L.trim()})}),ze(`vision`)&&!Ke&&(0,J.jsxs)(`div`,{className:`mt-1.5 text-[12px] text-amber-700 dark:text-amber-300`,children:[d(v,`vision`,Le,Re),` `,(0,J.jsx)(Ue,{})]})]}),!Z?.no_key&&(0,J.jsxs)($,{label:v(`API key`),hint:H&&Z?.key_optional?v(`No key will be sent.`):e.llm.key_source===`vault`&&F===N?v(`A key is in the vault. Leave blank to keep it.`):v(`Stored encrypted in the vault as LLM_API_KEY.`),children:[(0,J.jsxs)(`div`,{className:`relative`,children:[(0,J.jsx)(`input`,{type:ae?`text`:`password`,value:V,onChange:e=>re(e.target.value),disabled:H&&!!Z?.key_optional,className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pr-10 disabled:opacity-50`),placeholder:e.llm.key_source===`vault`&&F===N?`••••••••`:Z?.key_hint||`sk-…`,autoComplete:`off`,autoCapitalize:`off`,spellCheck:!1}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>oe(!ae),"aria-label":v(ae?`Hide key`:`Show key`),className:`absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-muted`,children:ae?(0,J.jsx)(ue,{size:16}):(0,J.jsx)(de,{size:16})})]}),(0,J.jsxs)(`div`,{className:`mt-1.5 flex items-center justify-between gap-2 text-[12px]`,children:[Z?.key_url?(0,J.jsxs)(`a`,{href:Z.key_url,target:`_blank`,rel:`noreferrer`,className:`inline-flex items-center gap-1 text-accent`,children:[v(`Get a key from {vendor}`,{vendor:Z.label}),` `,(0,J.jsx)(w,{size:12})]}):(0,J.jsx)(`span`,{}),Z?.key_optional&&(0,J.jsxs)(`label`,{className:`inline-flex items-center gap-1.5 text-muted`,children:[(0,J.jsx)(`input`,{type:`checkbox`,checked:H,onChange:e=>U(e.target.checked),className:`accent-accent`}),` `,v(`No key`)]})]})]}),(0,J.jsx)($,{label:v(`Tool calling`),hint:v(`Auto uses the API's function calling and falls back to describing tools in the prompt when the endpoint rejects them. Prompt: for endpoints that silently ignore tools.`),children:(0,J.jsx)(`div`,{className:`flex gap-1.5`,children:[`auto`,`native`,`prompt`].map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>le(e),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,W===e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e},e))})}),!Z?.cloud&&e.llm.proxy!==void 0&&(0,J.jsx)($,{label:v(`Proxy (optional)`),hint:v(`Only this provider's requests go through it. nanoMuse Cloud never does.`),children:(0,J.jsx)(`input`,{className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,value:fe,onChange:e=>{pe(e.target.value),he(!0)},onFocus:()=>fe.includes(`••••`)&&!me&&pe(``),onBlur:()=>!me&&pe(e.llm.proxy??``),placeholder:`http://host:port / socks5://host:port`,autoComplete:`off`,spellCheck:!1})}),(0,J.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:ge||!L.trim()||Ie&&!B.trim(),onClick:()=>void Ne(),className:D,children:[ge?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16}),` `,v(`Save`)]}),(0,J.jsxs)(`button`,{type:`button`,disabled:ye,onClick:()=>void Pe(),className:T,children:[ye?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,v(`Test`)]})]}),q&&(0,J.jsx)(We,{result:q,okText:v(`Replied "{reply}" in {ms} ms`,{reply:q.reply??``,ms:q.ms??0})})]})}function Ae({slot:e,data:n,onChange:a}){let{toast:o,state:s}=c(),f=r(),p=n[e],_=e,b=s.hub?.account??null,x=!!b?.signed_in,T=m(b),E=i(),O=n.providers[Oe]?.base_url??``,A=Ee(_,{signedIn:x,chat:{provider:n.llm.provider,base_url:n.llm.base_url,cloud:!!n.llm.cloud},region:T,locale:E}),P=De(p,O),[F,I]=(0,G.useState)(!1),[L,R]=(0,G.useState)(P),[ee,te]=(0,G.useState)(p.model),[z,B]=(0,G.useState)(p.base_url),[V,re]=(0,G.useState)(``),[ie,ae]=(0,G.useState)(!1),[oe,se]=(0,G.useState)(!1),[ce,H]=(0,G.useState)({list:[],more:[],source:`catalogue`});(0,G.useEffect)(()=>{R(P),te(p.model),B(p.base_url),re(``)},[p]);let U=A.find(e=>e.value===L)??null,W=U?.entry??null,le=W?v(W,T):null,fe=L===`nanomuse_cloud`?O:L===`custom`?z.trim():le?.base_url??``,pe=P===L&&P!==``&&(p.key_source===`vault`||p.key_source===`config`),me=!!U?.needsKey&&!V&&!pe,he=L===`nanomuse_cloud`?ce.list[0]??``:W?.defaults[e]??``;(0,G.useEffect)(()=>{if(!F||!L||L!==`nanomuse_cloud`&&!fe){H({list:[],more:[],source:`catalogue`});return}let t=!0,n=setTimeout(()=>{H(e=>({...e,source:`loading`})),u.llmModels(L===`nanomuse_cloud`?{preset:Oe}:{preset:W?S(W.id):``,base_url:fe,api_key:V||void 0}).then(n=>{if(!t)return;let r=(e===`image`?n.image_models:n.video_models)??[],i=(e===`image`?n.image_catalog:n.video_catalog)??[];H({list:r.filter(e=>!i.includes(e)),more:i,source:n.source})}).catch(()=>t&&H({list:[],more:[],source:`catalogue`}))},V?600:150);return()=>{t=!1,clearTimeout(n)}},[F,L,fe,V]);let ge=f(e===`image`?`Making pictures`:`Making clips`),K=f(e===`image`?`Portraits of your Muse.`:`Short clips of your Muse.`),q=we(p,E),_e=d(f,_,T,E),ve=p.effective_provider?p.effective_source===`cloud`?{text:f(`Your account`),tone:`ok`}:p.effective_source===`chat`?{text:f(`The chat model's provider`),tone:`ok`}:{text:f(`Your key`),tone:`ok`}:{text:f(`Not set up`),tone:`warn`},ye=async()=>{se(!0);try{L?L===`nanomuse_cloud`?await u.setMedia(e,{provider:`openai`,base_url:O,model:ee.trim(),api_key:be}):L===`custom`?await u.setMedia(e,{provider:`openai`,base_url:z.trim(),model:ee.trim(),api_key:V||null}):await u.setMedia(e,{provider:L,base_url:``,model:ee.trim(),api_key:V||(U?.group===`chat`?``:null)}):await u.setMedia(e,{provider:``,model:``,base_url:``,api_key:``}),re(``),h(),o(f(`Saved`)),a(),I(!1)}catch(e){o(e.message)}finally{se(!1)}},Y=e=>f(e===`cloud`?`Your account`:e===`chat`?`The chat model's provider`:`Add a provider`),xe=[`cloud`,`chat`,`add`].map(e=>({g:e,items:A.filter(t=>t.group===e)})).filter(e=>e.items.length),X=e===`image`?`IMAGE_API_KEY`:`VIDEO_API_KEY`;return(0,J.jsxs)(j,{icon:e===`image`?(0,J.jsx)(N,{size:19}):(0,J.jsx)(M,{size:19}),title:ge,summary:q||_e,status:ve,open:F,onToggle:()=>I(!F),children:[(0,J.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:K}),(0,J.jsxs)($,{label:f(`Provider`),hint:!q&&!L?_e:void 0,children:[(0,J.jsxs)(`select`,{value:L,onChange:e=>{R(e.target.value),te(``),re(``)},className:t(k,`text-fg`),children:[(0,J.jsx)(`option`,{value:``,children:f(`Automatic`)}),xe.map(({g:e,items:t})=>(0,J.jsx)(`optgroup`,{label:Y(e),children:t.map(e=>(0,J.jsx)(`option`,{value:e.value,children:e.value===`custom`?f(`Other OpenAI-compatible endpoint`):e.label},e.value))},e))]}),q&&(0,J.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:f(`Currently {value}`,{value:q})}),!q&&!L&&(0,J.jsxs)(`button`,{type:`button`,className:`mt-1.5 inline-flex items-center gap-1 text-[12.5px] text-accent`,onClick:()=>R(A.find(e=>e.group===`add`)?.value??``),children:[(0,J.jsx)(ne,{size:13}),` `,f(`Add a provider`)]}),W&&(0,J.jsxs)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:[(0,J.jsx)(`span`,{className:`font-medium text-fg`,children:g(W,E)}),` · `,y(f,W.capabilities),`. `,E===`zh-CN`?W.note_zh:W.note]})]}),L===`custom`&&(0,J.jsx)($,{label:f(`Base URL`),hint:f(`The endpoint that serves /images/generations, for example https://host/v1.`),children:(0,J.jsx)(`input`,{value:z,onChange:e=>B(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`https://host/v1`,inputMode:`url`,autoCapitalize:`off`})}),L&&(0,J.jsx)(He,{label:f(e===`image`?`Picture model`:`Clip model`),provider:L===`nanomuse_cloud`?`nanoMuse Cloud`:L===`custom`?Ge(z)||f(`Other OpenAI-compatible endpoint`):U?.label??L,value:ee,options:ce.list,more:ce.more,onChange:te,other:L!==`nanomuse_cloud`||!!b?.any_model,placeholder:he||void 0}),L&&L!==`nanomuse_cloud`&&U?.group!==`chat`&&(0,J.jsxs)($,{label:f(`API key`),hint:pe?f(`A key is in the vault. Leave blank to keep it.`):f(`Stored encrypted in the vault as {name}.`,{name:X}),children:[(0,J.jsxs)(`div`,{className:`relative`,children:[(0,J.jsx)(`input`,{type:ie?`text`:`password`,value:V,onChange:e=>re(e.target.value),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pr-10`),placeholder:pe?`••••••••`:W?.key_hint||`sk-…`,autoComplete:`off`,autoCapitalize:`off`,spellCheck:!1}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>ae(!ie),"aria-label":f(ie?`Hide key`:`Show key`),className:`absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-muted`,children:ie?(0,J.jsx)(ue,{size:16}):(0,J.jsx)(de,{size:16})})]}),le?.key_url&&(0,J.jsxs)(`a`,{href:le.key_url,target:`_blank`,rel:`noreferrer`,className:`mt-1.5 inline-flex items-center gap-1 text-[12px] text-accent`,children:[f(`Get a key from {vendor}`,{vendor:W?g(W,E):``}),` `,(0,J.jsx)(w,{size:12})]})]}),U?.group===`chat`&&(0,J.jsx)(`p`,{className:`text-[12px] text-muted`,children:f(`Uses the chat model's key.`)}),(0,J.jsx)(`div`,{className:`flex gap-2 pt-1`,children:(0,J.jsxs)(`button`,{type:`button`,disabled:oe||me||L===`custom`&&!z.trim(),onClick:()=>void ye(),className:D,children:[oe?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16}),` `,f(`Save`)]})})]})}function je({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.embeddings,[s,d]=(0,G.useState)(!1),[f,p]=(0,G.useState)(o.mode),[m,h]=(0,G.useState)(!!o.base_url),[g,_]=(0,G.useState)(o.base_url),[v,y]=(0,G.useState)(o.model),[b,x]=(0,G.useState)(``),[S,w]=(0,G.useState)(!1),[E,O]=(0,G.useState)(null),[k,A]=(0,G.useState)(!1);(0,G.useEffect)(()=>{p(o.mode),h(!!o.base_url),_(o.base_url),y(o.model)},[e.embeddings]);let M=o.mode===`off`?{text:a(`Off`),tone:`off`}:o.available===!0?{text:a(`On`),tone:`ok`}:o.available===!1?{text:o.mode===`on`?a(`Not reachable`):a(`By keyword`),tone:`warn`}:{text:a(`Not tried yet`),tone:`off`},N=o.mode===`off`?a(`Keyword recall only`):o.available===!0?a(`{model} · {n} of {total} memories indexed`,{model:o.model||o.default_model,n:o.indexed,total:o.total}):o.available===!1?o.reason:a(`{model} at {host}`,{model:o.model||o.default_model,host:Ge(o.effective_base_url)}),F=async()=>{w(!0),O(null);try{await u.setEmbeddings({mode:f,base_url:m?g.trim():``,model:v.trim(),api_key:m?b||void 0:``}),x(``),i(a(`Saved`)),n()}catch(e){i(e.message)}finally{w(!1)}},I=async()=>{A(!0);try{O(await u.testEmbeddings()),n()}catch(e){O({ok:!1,error:e.message})}finally{A(!1)}},L=e=>t(`rounded-full px-3 py-1.5 text-[13px] border`,e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`);return(0,J.jsxs)(j,{icon:(0,J.jsx)(W,{size:19}),title:a(`Recall by meaning`),summary:N,status:M,open:s,onToggle:()=>d(!s),children:[(0,J.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:a(`Memories are embedded once and a message finds the ones that mean the same thing, in any language: “写邮件给房东” finds “the landlord is Bob Li”. Keyword recall stays; the two are fused.`)}),(0,J.jsx)($,{label:a(`Mode`),hint:a(`Auto uses the model's endpoint when it has embeddings and falls back to keywords when it does not. On insists and warns. Off: keywords only.`),children:(0,J.jsx)(`div`,{className:`flex gap-1.5`,children:[`auto`,`on`,`off`].map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>p(e),className:L(f===e),children:a(e===`auto`?`Auto`:e===`on`?`On`:`Off`)},e))})}),f!==`off`&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)($,{label:a(`Endpoint`),hint:a(m?`Any OpenAI-compatible /embeddings: Ollama with an embedding model pulled, OpenAI, a gateway.`:`The model's endpoint and key. DeepSeek has no embeddings; pick another endpoint.`),children:(0,J.jsxs)(`div`,{className:`flex gap-1.5`,children:[(0,J.jsx)(`button`,{type:`button`,onClick:()=>h(!1),className:L(!m),children:a(`Same as the model`)}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>{h(!0),g||_(`http://127.0.0.1:11434/v1`)},className:L(m),children:a(`Another endpoint`)})]})}),m&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)($,{label:a(`Base URL`),children:(0,J.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`http://127.0.0.1:11434/v1`,inputMode:`url`})}),(0,J.jsx)($,{label:a(`API key`),hint:o.key_source===`vault`?a(`A key is in the vault. Leave blank to keep it.`):a(`Stored encrypted in the vault as EMBEDDINGS_API_KEY. Ollama needs none.`),children:(0,J.jsx)(`input`,{type:`password`,value:b,onChange:e=>x(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:o.key_source===`vault`?`••••••••`:`sk-…`,autoComplete:`off`})})]}),(0,J.jsx)($,{label:a(`Embedding model`),hint:a(`Blank uses the endpoint's default: {model}. On Ollama: ollama pull qwen3-embedding:0.6b (reads Chinese and English).`,{model:o.default_model}),children:(0,J.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:o.default_model,autoComplete:`off`})})]}),(0,J.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:S||m&&!/^https?:\/\//.test(g.trim()),onClick:()=>void F(),className:D,children:[S?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16}),` `,a(`Save`)]}),(0,J.jsxs)(`button`,{type:`button`,disabled:k||o.mode===`off`,onClick:()=>void I(),className:T,children:[k?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,a(`Test`)]})]}),E&&(0,J.jsx)(We,{result:E,okText:a(`{model} · {dims} dims · {n} memories indexed · {ms} ms`,{model:E.model??``,dims:E.dims??0,n:E.indexed??0,ms:E.ms??0})})]})}function Me({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.search,[s,d]=(0,G.useState)(!1),[f,p]=(0,G.useState)(o.provider),[m,h]=(0,G.useState)(o.base_url),[g,_]=(0,G.useState)(``),[v,y]=(0,G.useState)(!1),[b,x]=(0,G.useState)(null),[S,w]=(0,G.useState)(!1);(0,G.useEffect)(()=>{p(o.provider),h(o.base_url)},[e.search]);let E=o.providers.find(e=>e.id===o.provider),O=o.providers.find(e=>e.id===f),k=!!O?.needs_key,A=o.configured?{text:a(`On`),tone:`ok`}:{text:a(`Not set up`),tone:`warn`},M=o.configured?o.provider===`searxng`?a(`{provider} at {host}`,{provider:E?.label??o.provider,host:Ge(o.base_url)}):E?.label??o.provider:a(`{provider} needs {what}; searches use DuckDuckGo until then`,{provider:E?.label??o.provider,what:E?.needs_key?a(`a key`):a(`an instance URL`)}),N=async()=>{y(!0),x(null);try{await u.setSearch({provider:f,base_url:f===`searxng`?m.trim():``,api_key:k?g||void 0:``}),_(``),i(a(`Saved`)),n()}catch(e){i(e.message)}finally{y(!1)}},F=async()=>{w(!0);try{x(await u.testSearch())}catch(e){x({ok:!1,error:e.message})}finally{w(!1)}},I=e=>t(`rounded-full px-3 py-1.5 text-[13px] border`,e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),L=f!==`searxng`||/^https?:\/\//.test(m.trim());return(0,J.jsxs)(j,{icon:(0,J.jsx)(V,{size:19}),title:a(`Web search`),summary:M,status:A,open:s,onToggle:()=>d(!s),children:[(0,J.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:a(`DuckDuckGo needs nothing, but it is scraped and rate-limits now and then. For searches that always work, use a provider with an API. Whichever you pick, a failed search falls back to DuckDuckGo with a note.`)}),(0,J.jsx)($,{label:a(`Provider`),children:(0,J.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:o.providers.map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>p(e.id),className:I(f===e.id),children:e.label},e.id))})}),k&&(0,J.jsxs)($,{label:a(`API key`),hint:o.key_source===`vault`&&o.provider===f?a(`A key is in the vault. Leave blank to keep it.`):a(`Stored encrypted in the vault as SEARCH_API_KEY.`),children:[(0,J.jsx)(`input`,{type:`password`,value:g,onChange:e=>_(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:o.key_source===`vault`&&o.provider===f?`••••••••`:`…`,autoComplete:`off`}),O?.keys_url&&(0,J.jsx)(`a`,{href:O.keys_url,target:`_blank`,rel:`noreferrer`,className:`mt-1 inline-block text-[12px] text-accent`,children:a(`Get a key from {label}`,{label:O.label})})]}),f===`searxng`&&(0,J.jsx)($,{label:a(`Instance URL`),hint:a(`A SearXNG you run, with the JSON format enabled in its settings.`),children:(0,J.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`http://127.0.0.1:8080`,inputMode:`url`})}),(0,J.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:v||!L,onClick:()=>void N(),className:D,children:[v?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16}),` `,a(`Save`)]}),(0,J.jsxs)(`button`,{type:`button`,disabled:S,onClick:()=>void F(),className:T,children:[S?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,a(`Test`)]})]}),b&&(0,J.jsx)(We,{result:b,okText:a(`{provider} · {n} results · {ms} ms`,{provider:b.provider??``,n:b.results??0,ms:b.ms??0})})]})}var Ne=[{id:`gmail`,label:`Gmail`,imap:`imap.gmail.com`,smtp:`smtp.gmail.com`,port:587,starttls:!0},{id:`outlook`,label:`Outlook`,imap:`outlook.office365.com`,smtp:`smtp.office365.com`,port:587,starttls:!0},{id:`icloud`,label:`iCloud`,imap:`imap.mail.me.com`,smtp:`smtp.mail.me.com`,port:587,starttls:!0},{id:`qq`,label:`QQ`,imap:`imap.qq.com`,smtp:`smtp.qq.com`,port:587,starttls:!0},{id:`163`,label:`163`,imap:`imap.163.com`,smtp:`smtp.163.com`,port:465,starttls:!1},{id:`fastmail`,label:`Fastmail`,imap:`imap.fastmail.com`,smtp:`smtp.fastmail.com`,port:587,starttls:!0}];function Pe({data:e,onChange:n,compact:i}){let{toast:a}=c(),o=r(),s=e.email,[d,f]=(0,G.useState)(!!i),[p,m]=(0,G.useState)(s.address),[h,g]=(0,G.useState)(``),[_,v]=(0,G.useState)(s.imap_host),[y,b]=(0,G.useState)(s.imap_port),[x,S]=(0,G.useState)(s.smtp_host),[C,w]=(0,G.useState)(s.smtp_port),[T,E]=(0,G.useState)(s.smtp_starttls),[O,A]=(0,G.useState)(null),[M,N]=(0,G.useState)(null);(0,G.useEffect)(()=>{m(s.address),v(s.imap_host),b(s.imap_port),S(s.smtp_host),w(s.smtp_port),E(s.smtp_starttls)},[s]);let F=e=>{v(e.imap),b(993),S(e.smtp),w(e.port),E(e.starttls)},I=async()=>{A(`save`),N(null);try{await u.setEmail({enabled:!0,address:p.trim(),password:h||void 0,imap_host:_.trim(),imap_port:y,smtp_host:x.trim(),smtp_port:C,smtp_starttls:T}),g(``);let e=await u.testEmail();N(e),a(e.ok?o(`Email connected`):o(`Saved, but the test failed`)),n()}catch(e){a(e.message)}finally{A(null)}},L=async()=>{A(`off`);try{await u.disconnectEmail(),N(null),a(o(`Email disconnected`)),n()}catch(e){a(e.message)}finally{A(null)}},R=s.enabled&&s.configured?{text:o(`Connected`),tone:`ok`}:s.enabled?{text:o(`Incomplete`),tone:`warn`}:{text:o(`Not connected`),tone:`off`};return(0,J.jsxs)(j,{icon:(0,J.jsx)(se,{size:19}),title:o(`Email`),summary:s.configured&&s.enabled?`${s.address} · ${o(`reads and sends`)}`:o(`Read your inbox, draft and send mail`),status:R,open:d,onToggle:i?void 0:()=>f(!d),children:[(0,J.jsx)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:o(`Reading is a moderate action; sending always asks you first. Use an app password where your provider offers one.`)}),(0,J.jsx)($,{label:o(`Provider`),children:(0,J.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:Ne.map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>F(e),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,_===e.imap?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e.label},e.id))})}),(0,J.jsx)($,{label:o(`Address`),children:(0,J.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),className:k,placeholder:`you@example.com`,inputMode:`email`,autoComplete:`off`})}),(0,J.jsx)($,{label:o(`Password`),hint:s.password_set?o(`A password is in the vault. Leave blank to keep it.`):o(`Stored encrypted in the vault as EMAIL_PASSWORD.`),children:(0,J.jsx)(`input`,{type:`password`,value:h,onChange:e=>g(e.target.value),className:k,placeholder:s.password_set?`••••••••`:o(`app password`),autoComplete:`off`})}),(0,J.jsxs)(`div`,{className:`grid grid-cols-[1fr_84px] gap-2`,children:[(0,J.jsx)($,{label:o(`IMAP server`),children:(0,J.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),className:k,placeholder:`imap.example.com`})}),(0,J.jsx)($,{label:o(`Port`),children:(0,J.jsx)(`input`,{type:`number`,value:y,onChange:e=>b(Number(e.target.value)),className:k})}),(0,J.jsx)($,{label:o(`SMTP server`),children:(0,J.jsx)(`input`,{value:x,onChange:e=>S(e.target.value),className:k,placeholder:`smtp.example.com`})}),(0,J.jsx)($,{label:o(`Port`),children:(0,J.jsx)(`input`,{type:`number`,value:C,onChange:e=>w(Number(e.target.value)),className:k})})]}),(0,J.jsxs)(`label`,{className:`flex items-center gap-2 text-[13.5px]`,children:[(0,J.jsx)(`input`,{type:`checkbox`,checked:T,onChange:e=>E(e.target.checked),className:`accent-[var(--om-accent)]`}),` `,o(`STARTTLS for SMTP (off for port 465)`)]}),(0,J.jsxs)(`div`,{className:`flex gap-2 pt-1`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:O!==null||!p.trim()||!_.trim()||!x.trim()||!h&&!s.password_set,onClick:()=>void I(),className:D,children:[O===`save`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,s.configured?o(`Save & test`):o(`Connect`)]}),s.enabled&&(0,J.jsxs)(`button`,{type:`button`,disabled:O!==null,onClick:()=>void L(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[O===`off`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(fe,{size:16}),` `,o(`Disconnect`)]})]}),M&&(0,J.jsx)(We,{result:M,okText:o(`Signed in · {n} messages in the inbox`,{n:M.inbox??`?`})})]})}var Fe=[{id:`google`,label:`Google`,hint:`Settings → your calendar → Integrate calendar → Secret address in iCal format`},{id:`outlook`,label:`Outlook`,hint:`Settings → Calendar → Shared calendars → Publish a calendar → ICS link`},{id:`icloud`,label:`iCloud`,hint:`Share Calendar → Public Calendar → copy the webcal:// link`},{id:`fastmail`,label:`Fastmail`,hint:`Settings → Calendars → Export → Calendar URL`},{id:`file`,label:`.ics file`,hint:`A path on the machine where nanoMuse runs, for example ~/calendar.ics`}];function Ie({data:n,onChange:i,compact:a}){let{toast:o}=c(),s=r(),d=n.calendar,[f,p]=(0,G.useState)(!!a),[m,h]=(0,G.useState)(!1),[g,_]=(0,G.useState)(``),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null),[S,E]=(0,G.useState)(d.day_start),[A,M]=(0,G.useState)(d.day_end),[N,I]=(0,G.useState)(null),[R,ee]=(0,G.useState)(null),[te,z]=(0,G.useState)(null),[B,V]=(0,G.useState)(d.google.client_id),[ie,ae]=(0,G.useState)(``),[oe,se]=(0,G.useState)(d.google.write);(0,G.useEffect)(()=>{E(d.day_start),M(d.day_end)},[d.day_start,d.day_end]),(0,G.useEffect)(()=>{V(d.google.client_id),se(d.google.write)},[d.google.client_id,d.google.write]),(0,G.useEffect)(()=>{if(!d.google.connected){z(null);return}let e=!0;return(async()=>{try{let t=await u.googleCalendar();e&&z(t.calendars??[])}catch{e&&z([])}})(),()=>{e=!1}},[d.google.connected]);let ce=async()=>{I(`add`),ee(null);try{let e=await u.addCalendarFeed(g.trim(),v.trim());if(e.error)o(s(`Added, but it could not be read: {error}`,{error:e.error}));else{let t=e.feeds.find(e=>e.name===g.trim());o(`${s(`Calendar connected`)} · ${q(t?.events??0)}`),_(``),y(``),h(!1)}i()}catch(e){o(e.message)}finally{I(null)}},H=async e=>{I(e);try{await u.removeCalendarFeed(e),i()}catch(e){o(e.message)}finally{I(null)}},U=async()=>{I(`hours`);try{await u.setCalendar({day_start:S,day_end:A}),o(s(`Saved`)),i()}catch(e){o(e.message)}finally{I(null)}},W=async()=>{I(`test`);try{ee(await u.testCalendar()),i()}catch(e){o(e.message)}finally{I(null)}},le=async()=>{I(`gsave`);try{await u.setGoogleCalendar({client_id:B.trim(),client_secret:ie.trim()||void 0,write:oe,enabled:!0}),ae(``),o(s(`Saved`)),i()}catch(e){o(e.message)}finally{I(null)}},ue=async()=>{I(`gconnect`);try{let{url:e}=await u.connectGoogleCalendar(oe);window.open(e,`_blank`,`noopener`),o(s(`Finish signing in with Google in the window that opened.`)),i()}catch(e){o(e.message)}finally{I(null)}},de=async()=>{I(`gdisconnect`);try{await u.disconnectGoogleCalendar(),z(null),o(s(`Disconnected`)),i()}catch(e){o(e.message)}finally{I(null)}},pe=async e=>{I(`gpick`);try{await u.setGoogleCalendars({default_calendar:e,calendar_ids:[e]}),i()}catch(e){o(e.message)}finally{I(null)}},me=async e=>{se(e),I(`gwrite`);try{await u.setGoogleCalendar({write:e}),i()}catch(e){o(e.message)}finally{I(null)}},he=d.feeds.reduce((e,t)=>e+t.events,0),ge=d.feeds.filter(e=>e.error).length,K=d.google,q=e=>e===1?s(`1 event`):s(`{n} events`,{n:e}),_e=K.connected?{text:K.can_write?s(`Connected`):s(`Read-only`),tone:`ok`}:K.configured?{text:s(`Sign in needed`),tone:`warn`}:d.configured?ge?{text:s(`{n} not reading`,{n:ge}),tone:`warn`}:{text:s(`Connected`),tone:`ok`}:{text:s(`Not connected`),tone:`off`},ve=K.connected?`${K.account||s(`Google Calendar`)} · ${K.can_write?s(`read + write`):s(`read-only`)}`:d.configured?d.feeds.length===1?`${d.feeds[0].name} · ${q(he)}`:`${s(`{n} calendars`,{n:d.feeds.length})} · ${q(he)}`:s(`Your agenda, free time, and events it can draft`);return(0,J.jsxs)(j,{icon:(0,J.jsx)(re,{size:19}),title:s(`Calendar`),summary:K.connected||d.configured?ve:s(`Your agenda, free time, and events it can draft`),status:_e,open:f,onToggle:a?void 0:()=>p(!f),children:[(0,J.jsx)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:s(`Reads your calendar from its private .ics link; the link stays in the vault. It never changes your calendar; an event it proposes comes as a file you add with a tap.`)}),(0,J.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,J.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,J.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,K.connected?`bg-emerald-500`:K.configured?`bg-amber-500`:`bg-surface-2 border border-border`)}),(0,J.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,J.jsx)(`div`,{className:`text-[13.5px] font-medium`,children:s(`Google Calendar`)}),(0,J.jsx)(`div`,{className:`text-[11.5px] text-muted truncate`,children:K.connected?`${K.account||s(`Signed in`)} · ${K.can_write?s(`read + write`):s(`read-only`)}`:K.configured?s(`Client set — sign in with Google`):s(`Paste your Google OAuth client id and secret`)})]}),K.connected?(0,J.jsx)(`button`,{type:`button`,"aria-label":s(`Disconnect`),disabled:N===`gdisconnect`,onClick:()=>void de(),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:N===`gdisconnect`?(0,J.jsx)(l,{size:15,className:`animate-spin`}):(0,J.jsx)(fe,{size:15})}):null]}),!K.connected&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)($,{label:s(`Client ID`),hint:s(`From the OAuth client you downloaded in Google Cloud Console.`),children:(0,J.jsx)(`input`,{value:B,onChange:e=>V(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:`…apps.googleusercontent.com`,autoComplete:`off`})}),(0,J.jsx)($,{label:s(`Client secret`),hint:K.has_secret?s(`A secret is saved. Type a new one only to replace it.`):s(`Stored encrypted in the vault as GOOGLE_CALENDAR_SECRET.`),children:(0,J.jsx)(`input`,{type:`password`,value:ie,onChange:e=>ae(e.target.value),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,placeholder:K.has_secret?`••••••••`:`GOCSPX-…`,autoComplete:`off`})}),(0,J.jsx)($,{label:s(`Callback URL`),hint:s(`Register this exact URL on the OAuth client as an authorized redirect URI.`),children:(0,J.jsx)(`input`,{value:K.redirect_uri,readOnly:!0,className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`text-muted`)})}),(0,J.jsx)(O,{label:s(`Let the agent create and change events`),hint:s(`Off: read-only, the agent can look but not touch.`),checked:oe,onChange:e=>se(e)}),(0,J.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:N===`gsave`,onClick:()=>void le(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[N===`gsave`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16}),` `,s(`Save client`)]}),(0,J.jsxs)(`button`,{type:`button`,disabled:N===`gconnect`||!K.configured,onClick:()=>void ue(),className:`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,children:[N===`gconnect`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(w,{size:16}),` `,s(`Sign in with Google`)]})]})]}),K.connected&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(O,{label:s(`Let the agent create and change events`),hint:s(`Off: read-only, the agent can look but not touch.`),checked:oe,onChange:e=>void me(e)}),te&&te.length>0&&(0,J.jsx)($,{label:s(`Calendar to read and write`),hint:s(`Events the agent reads and creates land here.`),children:(0,J.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:te.map(e=>{let n=(K.default_calendar||K.calendar_ids[0]||``)===e.id;return(0,J.jsx)(`button`,{type:`button`,disabled:N===`gpick`,onClick:()=>void pe(e.id),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,n?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:e.primary?s(`{name} (primary)`,{name:e.name}):e.name},e.id)})})}),K.error&&(0,J.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:K.error})]})]}),d.feeds.length>0&&(0,J.jsx)(`ul`,{className:`space-y-1.5`,children:d.feeds.map(n=>(0,J.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,J.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,n.error?`bg-rose-500`:n.fetched_at?`bg-emerald-500`:`bg-amber-500`)}),(0,J.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,J.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:n.name}),(0,J.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[n.error?n.error:n.fetched_at?`${q(n.events)} · ${s(`read {when}`,{when:e(n.fetched_at)})}`:s(`not read yet`),!n.from_app&&` · ${s(`from config.toml`)}`]})]}),n.from_app&&(0,J.jsx)(`button`,{type:`button`,"aria-label":s(`Remove`),disabled:N===n.name,onClick:()=>void H(n.name),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:N===n.name?(0,J.jsx)(l,{size:15,className:`animate-spin`}):(0,J.jsx)(L,{size:15})})]},n.name))}),m?(0,J.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,J.jsxs)($,{label:s(`Where is the link?`),children:[(0,J.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:Fe.map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>x(b===e.id?null:e.id),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,b===e.id?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:s(e.label)},e.id))}),b&&(0,J.jsx)(`div`,{className:`mt-1.5 text-[11.5px] text-muted leading-snug`,children:s(Fe.find(e=>e.id===b).hint)})]}),(0,J.jsx)($,{label:s(`Name`),children:(0,J.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),className:k,placeholder:s(`Work`)})}),(0,J.jsx)($,{label:s(`Private .ics link or file path`),hint:s(`Stored encrypted in the vault as CALENDAR_{name}.`,{name:g.trim().toUpperCase().replace(/[^A-Z0-9]+/g,`_`).replace(/^_+|_+$/g,``)||`NAME`})+` `+s(`nanoMuse fetches whatever address you put here, from this machine; only paste links you trust.`),children:(0,J.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:k,placeholder:`https://calendar.google.com/calendar/ical/…/basic.ics`,inputMode:`url`,autoComplete:`off`})}),(0,J.jsxs)(`div`,{className:`flex gap-2`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:N===`add`||!g.trim()||!v.trim(),onClick:()=>void ce(),className:D,children:[N===`add`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,s(`Connect`)]}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>h(!1),className:T,children:s(`Cancel`)})]})]}):(0,J.jsxs)(`button`,{type:`button`,onClick:()=>h(!0),className:T,children:[(0,J.jsx)(ne,{size:16}),` `,s(`Add a calendar`)]}),d.configured&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)($,{label:s(`Working hours`),hint:s(`Free time is looked for inside these hours.`),children:(0,J.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,J.jsx)(`input`,{type:`time`,value:S,onChange:e=>E(e.target.value),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`w-auto`)}),(0,J.jsx)(`span`,{className:`text-muted`,children:`–`}),(0,J.jsx)(`input`,{type:`time`,value:A,onChange:e=>M(e.target.value),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`w-auto`)}),(S!==d.day_start||A!==d.day_end)&&(0,J.jsx)(`button`,{type:`button`,disabled:N===`hours`,onClick:()=>void U(),className:t(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`px-3`),"aria-label":s(`Save`),children:N===`hours`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16})})]})}),(0,J.jsxs)(`button`,{type:`button`,disabled:N===`test`,onClick:()=>void W(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[N===`test`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(F,{size:16}),` `,s(`Read again`)]}),R&&(0,J.jsx)(We,{result:R,okText:d.feeds.length===1?q(R.events??0):`${q(R.events??0)} · ${s(`{n} calendars`,{n:d.feeds.length})}`})]})]})}function Q({data:n,onChange:i,compact:a}){let{toast:o}=c(),s=r(),d=n.contacts,[f,p]=(0,G.useState)(!!a),[m,h]=(0,G.useState)(!1),[g,_]=(0,G.useState)(``),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null),[S,C]=(0,G.useState)(null),[w,E]=(0,G.useState)(``),[O,A]=(0,G.useState)(null),M=(0,G.useRef)(null),N=e=>e===1?s(`1 person`):s(`{n} people`,{n:e}),I=(e,t)=>{if(e.error)o(s(`Added, but it could not be read: {error}`,{error:e.error}));else{let n=e.sources.find(e=>e.name===t);o(`${s(`Address book connected`)} · ${N(n?.contacts??0)}`),_(``),y(``),h(!1)}i()},R=async()=>{x(`add`),C(null);try{I(await u.addContactsSource(g.trim(),v.trim()),g.trim())}catch(e){o(e.message)}finally{x(null)}},ee=async e=>{x(`upload`),C(null);try{let t=(g.trim()||e.name.replace(/\.vcf$/i,``)||`Imported`).slice(0,40);I(await u.importContacts(t,await e.text()),t)}catch(e){o(e.message)}finally{x(null),M.current&&(M.current.value=``)}},te=async e=>{x(e);try{await u.removeContactsSource(e),i()}catch(e){o(e.message)}finally{x(null)}},z=async()=>{x(`test`);try{C(await u.testContacts()),i()}catch(e){o(e.message)}finally{x(null)}};(0,G.useEffect)(()=>{if(!w.trim()){A(null);return}let e=setTimeout(()=>{u.contacts(w.trim(),5).then(e=>A(e.people)).catch(()=>A([]))},250);return()=>clearTimeout(e)},[w]);let B=d.sources.filter(e=>e.error).length,re=d.enabled?d.configured?B?{text:s(`{n} not reading`,{n:B}),tone:`warn`}:{text:s(`Connected`),tone:`ok`}:{text:s(`Not connected`),tone:`off`}:{text:s(`Off`),tone:`off`};return(0,J.jsxs)(j,{icon:(0,J.jsx)(le,{size:19}),title:s(`Contacts`),summary:d.configured?d.sources.length===0?`${N(d.count)} · ${s(`told to it in chat`)}`:`${N(d.count)} · ${d.sources.length===1?d.sources[0].name:s(`{n} address books`,{n:d.sources.length})}`:s(`Who is who, so it never guesses an address`),status:re,open:f,onToggle:a?void 0:()=>p(!f),children:[(0,J.jsx)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:s(`Import a .vcf export from Google Contacts, iCloud, Outlook or your phone, or paste a link to one. People you mention in chat go into its own book. It looks people up before writing to them and warns when an address is unknown.`)}),(d.sources.length>0||d.own>0)&&(0,J.jsxs)(`ul`,{className:`space-y-1.5`,children:[d.own>0&&(0,J.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,J.jsx)(`span`,{className:`h-2 w-2 rounded-full bg-emerald-500`}),(0,J.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,J.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:s(`My contacts`)}),(0,J.jsx)(`div`,{className:`text-[11.5px] text-muted truncate`,children:`${N(d.own)} · ${s(`added in chat`)}`})]})]}),d.sources.map(n=>(0,J.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,J.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,n.error?`bg-rose-500`:n.fetched_at?`bg-emerald-500`:`bg-amber-500`)}),(0,J.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,J.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:n.name}),(0,J.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[n.error?n.error:n.fetched_at?`${N(n.contacts)} · ${s(`read {when}`,{when:e(n.fetched_at)})}`:s(`not read yet`),!n.from_app&&` · ${s(`from config.toml`)}`]})]}),n.from_app&&(0,J.jsx)(`button`,{type:`button`,"aria-label":s(`Remove`),disabled:b===n.name,onClick:()=>void te(n.name),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:b===n.name?(0,J.jsx)(l,{size:15,className:`animate-spin`}):(0,J.jsx)(L,{size:15})})]},n.name))]}),m?(0,J.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,J.jsx)($,{label:s(`Name`),children:(0,J.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),className:k,placeholder:s(`iPhone`)})}),(0,J.jsxs)($,{label:s(`A .vcf file from your phone or computer`),hint:s(`Google Contacts: Export → vCard. iPhone: Contacts → select all → Share → Save to Files. Outlook: People → Manage → Export.`),children:[(0,J.jsx)(`input`,{ref:M,type:`file`,accept:`.vcf,text/vcard,text/x-vcard`,className:`hidden`,onChange:e=>e.target.files?.[0]&&void ee(e.target.files[0])}),(0,J.jsxs)(`button`,{type:`button`,disabled:b===`upload`,onClick:()=>M.current?.click(),className:T,children:[b===`upload`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(pe,{size:16}),` `,s(`Choose a .vcf file`)]})]}),(0,J.jsx)($,{label:s(`Or a link / path to one`),hint:s(`A link is stored encrypted in the vault as CONTACTS_{name}.`,{name:g.trim().toUpperCase().replace(/[^A-Z0-9]+/g,`_`).replace(/^_+|_+$/g,``)||`NAME`})+` `+s(`nanoMuse fetches whatever address you put here, from this machine; only paste links you trust.`),children:(0,J.jsx)(`input`,{value:v,onChange:e=>y(e.target.value),className:k,placeholder:`https://… /contacts.vcf`,inputMode:`url`,autoComplete:`off`})}),(0,J.jsxs)(`div`,{className:`flex gap-2`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:b===`add`||!g.trim()||!v.trim(),onClick:()=>void R(),className:D,children:[b===`add`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,s(`Connect`)]}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>h(!1),className:T,children:s(`Cancel`)})]})]}):(0,J.jsxs)(`button`,{type:`button`,onClick:()=>h(!0),className:T,children:[(0,J.jsx)(ne,{size:16}),` `,s(`Add an address book`)]}),d.configured&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsxs)(`div`,{className:`relative`,children:[(0,J.jsx)(V,{size:15,className:`absolute left-3 top-1/2 -translate-y-1/2 text-muted`}),(0,J.jsx)(`input`,{value:w,onChange:e=>E(e.target.value),className:t(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`pl-9`),placeholder:s(`Find someone, as the agent would`),autoComplete:`off`})]}),O!==null&&(0,J.jsxs)(`ul`,{className:`space-y-1`,children:[O.length===0&&(0,J.jsx)(`li`,{className:`text-[12.5px] text-muted px-1`,children:s(`No one matches.`)}),O.map(e=>(0,J.jsxs)(`li`,{className:`rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,J.jsxs)(`div`,{className:`text-[13.5px] font-medium`,children:[e.name,e.org&&(0,J.jsxs)(`span`,{className:`text-muted font-normal`,children:[` · `,e.org]})]}),(0,J.jsx)(`div`,{className:`text-[12px] text-muted break-words`,children:[...e.emails,...e.phones].join(` · `)})]},e.id))]}),d.sources.length>0&&(0,J.jsxs)(`button`,{type:`button`,disabled:b===`test`,onClick:()=>void z(),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:[b===`test`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(F,{size:16}),` `,s(`Read again`)]}),S&&(0,J.jsx)(We,{result:S,okText:N(S.contacts??0)})]})]})}function Le({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.browser,[s,l]=(0,G.useState)(!1),d=async()=>{l(!0);try{await u.setBrowser(!o.enabled),n()}catch(e){i(e.message)}finally{l(!1)}};return(0,J.jsx)(j,{icon:(0,J.jsx)(ae,{size:19}),title:a(`Browser`),summary:o.available?a(`Open pages, click, fill forms, screenshot`):a(`Playwright is not installed on the server`),status:o.enabled&&o.available?{text:a(`On`),tone:`ok`}:o.enabled?{text:a(`Unavailable`),tone:`warn`}:{text:a(`Off`),tone:`off`},open:!1,trailing:(0,J.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":o.enabled,disabled:s,onClick:()=>void d(),className:t(`relative h-7 w-12 shrink-0 rounded-full transition`,o.enabled?`bg-accent`:`bg-surface-2 border border-border`),children:(0,J.jsx)(`span`,{className:t(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,o.enabled?`left-[22px]`:`left-0.5`)})}),children:!o.available&&(0,J.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[a(`Install it where the server runs:`),` `,(0,J.jsx)(`code`,{className:`rounded bg-surface-2 px-1`,children:`pip install "nanomuse[browser]" && playwright install chromium`})]})})}function Re({data:e,onChange:n}){let{toast:i}=c(),a=r(),o=e.gui,[s,d]=(0,G.useState)(!1),[f,p]=(0,G.useState)(!1),[m,h]=(0,G.useState)(o.model),[g,_]=(0,G.useState)(o.base_url),[v,y]=(0,G.useState)(``),[b,x]=(0,G.useState)(null);(0,G.useEffect)(()=>{h(o.model),_(o.base_url)},[o.model,o.base_url]);let S=async()=>{p(!0);try{await u.setGui({enabled:!o.enabled}),n()}catch(e){i(e.message)}finally{p(!1)}},w=async()=>{p(!0),x(null);try{await u.setGui({model:m.trim(),base_url:g.trim(),...v?{api_key:v.trim()}:{}}),y(``),n(),x(await u.testGui())}catch(e){i(e.message)}finally{p(!1)}},T=o.phone,[E,O]=(0,G.useState)(ce),A=(0,G.useRef)(E);(0,G.useEffect)(()=>{if(!s)return;let e=()=>{let e=ce();e!==A.current&&(A.current=e,O(e),setTimeout(n,400))};return e(),document.addEventListener(`visibilitychange`,e),window.addEventListener(`focus`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`focus`,e)}},[s,n]);let M=T.connected?a(`{name} is connected`,{name:T.device?.name??a(`A phone`)}):o.enabled?a(`No phone connected. Open the app on the phone`):a(`Tap, type and swipe in the apps on your phone`);return(0,J.jsxs)(j,{icon:(0,J.jsx)(B,{size:19}),title:a(`Phone`),summary:M,status:o.enabled&&T.connected?{text:a(`On`),tone:`ok`}:o.enabled?{text:a(`Waiting`),tone:`warn`}:{text:a(`Off`),tone:`off`},open:s,onToggle:()=>d(e=>!e),children:[(0,J.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,J.jsxs)(`div`,{className:`text-[13px] leading-snug`,children:[(0,J.jsx)(`div`,{className:`font-medium`,children:a(`Operate the phone`)}),(0,J.jsx)(`div`,{className:`text-[12px] text-muted`,children:a(`When on, the agent can read the screen and act in the apps on the connected phone: 12306, WeChat, Alipay… It asks before paying, sending or deleting.`)})]}),(0,J.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":o.enabled,disabled:f,onClick:()=>void S(),className:t(`relative h-7 w-12 shrink-0 rounded-full transition`,o.enabled?`bg-accent`:`bg-surface-2 border border-border`),children:(0,J.jsx)(`span`,{className:t(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,o.enabled?`left-[22px]`:`left-0.5`)})})]}),E!==null&&(0,J.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 px-3 py-2.5 text-[12.5px] leading-snug space-y-1.5`,children:[(0,J.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,J.jsx)(`span`,{className:`font-medium`,children:a(`This phone`)}),(0,J.jsx)(`span`,{className:t(`rounded-full px-2 py-0.5 text-[11px] font-medium`,E===`on`&&`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`,E===`off`&&`bg-amber-500/15 text-amber-700 dark:text-amber-300`,E===`unsupported`&&`bg-surface text-muted`),children:a(E===`on`?`Ready`:E===`off`?`Not yet`:`Not on this Android`)})]}),E===`unsupported`&&(0,J.jsx)(`div`,{className:`text-muted`,children:a(`Operating the screen needs Android 11 or newer; this phone can still be used for everything else.`)}),E===`off`&&(0,J.jsxs)(J.Fragment,{children:[(0,J.jsx)(`div`,{className:`text-muted`,children:a(`Turn on the nanoMuse accessibility service; that is how it sees the screen and taps for you, only while a task runs, with a Stop button on screen.`)}),(0,J.jsxs)(`div`,{className:`flex flex-wrap gap-2 pt-0.5`,children:[(0,J.jsx)(`button`,{type:`button`,onClick:()=>z()?.openAccessibilitySettings?.(),className:`rounded-full bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white`,children:a(`Open Accessibility settings`)}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>z()?.openAppSettings?.(),className:`rounded-full border border-border px-3 py-1.5 text-[12.5px]`,children:a(`App settings`)})]}),(0,J.jsx)(`div`,{className:`text-muted`,children:a(`Greyed out with “Restricted setting”? Android 13+ does that for apps installed from a download: in App settings tap ⋮ → Allow restricted settings, then come back.`)})]}),E===`on`&&(0,J.jsx)(`div`,{className:`text-muted`,children:a(`Android may switch the service off after an update or a battery clean-up; if the phone stops answering, come back here.`)})]}),T.connected&&T.device&&(0,J.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 px-3 py-2 text-[12.5px] text-muted`,children:[a(`{name} ({platform}), {n} apps`,{name:T.device.name,platform:T.device.platform,n:String(T.device.apps)}),T.last_screen&&(0,J.jsxs)(J.Fragment,{children:[` `,`·`,` `,a(`last seen in {app}`,{app:T.last_screen.app_name||T.last_screen.app})]})]}),(0,J.jsx)(`div`,{className:`text-[12px] text-muted leading-snug`,children:a(`The operator's model looks at screens step by step: a small, fast model that takes pictures. The Hands model row under Chat model picks one on the same endpoint and key; the fields here are for a different endpoint. Empty = {model}.`,{model:o.default_model||a(`the chat model`)})}),(0,J.jsx)($,{label:a(`Model`),children:(0,J.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),placeholder:o.default_model||a(`same as the chat model`),className:k})}),(0,J.jsx)($,{label:a(`Base URL`),children:(0,J.jsx)(`input`,{value:g,onChange:e=>_(e.target.value),placeholder:a(`same as the chat model`),className:k})}),(0,J.jsx)($,{label:a(`API key`),hint:o.key_source===`vault`?a(`A key is in the vault; leave empty to keep it.`):o.key_source===`config`?a(`Set in config.toml.`):a(`Leave empty to use the main model's key.`),children:(0,J.jsx)(`input`,{type:`password`,value:v,onChange:e=>y(e.target.value),placeholder:`sk-…`,className:k,autoComplete:`off`})}),(0,J.jsxs)(`button`,{type:`button`,disabled:f,onClick:()=>void w(),className:D,children:[f?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(C,{size:16}),a(`Save and test`)]}),b&&(0,J.jsx)(We,{result:b,okText:a(`The operator's model answers.`)})]})}function ze({data:e,onChange:n}){let{toast:i}=c(),a=r(),[o,s]=(0,G.useState)(!1),[d,f]=(0,G.useState)(!1),[p,m]=(0,G.useState)(``),[h,g]=(0,G.useState)(``),[_,v]=(0,G.useState)(``),[y,b]=(0,G.useState)(`moderate`),[x,S]=(0,G.useState)(null),C=async()=>{S(`add`);try{let e=h.trim().split(/\s+/).filter(Boolean);await u.addMCP({name:p.trim(),command:e[0]??null,args:e.slice(1),url:_.trim()||null,risk:y}),m(``),g(``),v(``),f(!1),i(a(`Server connected`)),n()}catch(e){i(e.message)}finally{S(null)}},w=async e=>{S(e);try{await u.removeMCP(e),n()}catch(e){i(e.message)}finally{S(null)}},E=e.mcp.filter(e=>e.connected).length,O=(e.shared??[]).filter(e=>e.enabled&&!e.here),A=e=>{m(e.id),v(e.url),g(``),f(!0),s(!0)};return(0,J.jsxs)(j,{icon:(0,J.jsx)(P,{size:19}),title:a(`MCP servers`),summary:e.mcp.length?a(`{n} of {total} connected · {tools} tools`,{n:E,total:e.mcp.length,tools:e.mcp.reduce((e,t)=>e+t.tools,0)}):a(`Add tools from any Model Context Protocol server`),status:e.mcp.length?{text:a(`{n} on`,{n:E}),tone:E?`ok`:`warn`}:{text:a(`None`),tone:`off`},open:o,onToggle:()=>s(!o),children:[e.mcp.length>0&&(0,J.jsx)(`ul`,{className:`space-y-1.5`,children:e.mcp.map(e=>(0,J.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,J.jsx)(`span`,{className:t(`h-2 w-2 rounded-full`,e.connected?`bg-emerald-500`:`bg-rose-500`)}),(0,J.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,J.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.builtin?a(`This phone`):e.name}),(0,J.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[e.builtin?a(`clipboard, notifications, calendar, contacts, location, alarms, photos`):e.url??[e.command,...e.args].join(` `),` ·`,` `,a(`{n} tools`,{n:e.tools}),` · `,a(e.risk),!e.from_app&&!e.builtin&&` · ${a(`from config.toml`)}`]})]}),e.from_app&&(0,J.jsx)(`button`,{type:`button`,"aria-label":a(`Remove`),disabled:x===e.name,onClick:()=>void w(e.name),className:`p-1.5 rounded-full text-muted hover:bg-surface-2`,children:x===e.name?(0,J.jsx)(l,{size:15,className:`animate-spin`}):(0,J.jsx)(L,{size:15})})]},e.name))}),O.length>0&&(0,J.jsx)(`ul`,{className:`space-y-1.5`,children:O.map(e=>(0,J.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl border border-dashed border-border/70 px-3 py-2`,children:[(0,J.jsx)(`span`,{className:`h-2 w-2 rounded-full bg-border`}),(0,J.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,J.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.label||e.id}),(0,J.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[a(`Connected on {device}; sign in here to use it on this device`,{device:e.device||e.device_id}),e.url?` · ${e.url}`:``]})]}),e.url&&(0,J.jsx)(`button`,{type:`button`,onClick:()=>A(e),className:t(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`py-1.5 text-[12.5px]`),children:a(`Sign in here`)})]},`${e.device_id}:${e.id}`))}),d?(0,J.jsxs)(`div`,{className:`space-y-2.5 rounded-2xl border border-border/70 p-3`,children:[(0,J.jsx)($,{label:a(`Name`),children:(0,J.jsx)(`input`,{value:p,onChange:e=>m(e.target.value),className:k,placeholder:`filesystem`})}),(0,J.jsx)($,{label:a(`Command`),hint:a(`Run locally over stdio, for example: npx -y @modelcontextprotocol/server-filesystem ./workspace`),children:(0,J.jsx)(`input`,{value:h,onChange:e=>g(e.target.value),className:k,placeholder:`npx -y @modelcontextprotocol/server-…`})}),(0,J.jsx)($,{label:a(`or URL`),hint:a(`A remote server over Streamable HTTP.`),children:(0,J.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),className:k,placeholder:`https://host/mcp`,inputMode:`url`})}),(0,J.jsx)($,{label:a(`Risk of its tools`),hint:a(`Decides when the Sentinel asks you before a call.`),children:(0,J.jsx)(`div`,{className:`flex gap-1.5`,children:[`safe`,`moderate`,`high`].map(e=>(0,J.jsx)(`button`,{type:`button`,onClick:()=>b(e),className:t(`rounded-full px-3 py-1.5 text-[13px] border`,y===e?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:a(e)},e))})}),(0,J.jsxs)(`div`,{className:`flex gap-2`,children:[(0,J.jsxs)(`button`,{type:`button`,disabled:x===`add`||!p.trim()||!(h.trim()||_.trim()),onClick:()=>void C(),className:D,children:[x===`add`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(P,{size:16}),` `,a(`Connect`)]}),(0,J.jsx)(`button`,{type:`button`,onClick:()=>f(!1),className:T,children:a(`Cancel`)})]})]}):(0,J.jsxs)(`button`,{type:`button`,onClick:()=>f(!0),className:T,children:[(0,J.jsx)(ne,{size:16}),` `,a(`Add a server`)]})]})}function Be({data:e,onChange:n}){let{toast:i}=c(),a=r(),[o,s]=(0,G.useState)(!1),[d,f]=(0,G.useState)(``),[p,m]=(0,G.useState)(``),[h,g]=(0,G.useState)(null),_=async()=>{g(`add`);try{await u.vaultSet(d.trim(),p),f(``),m(``),i(a(`Stored in the vault`)),n()}catch(e){i(e.message)}finally{g(null)}},v=async e=>{g(e);try{await u.vaultDelete(e),n()}catch(e){i(e.message)}finally{g(null)}};return(0,J.jsxs)(j,{icon:(0,J.jsx)(oe,{size:19}),title:a(`Vault`),summary:e.vault.length?a(`{n} secrets · encrypted on your machine`,{n:e.vault.length}):a(`Encrypted secrets the model never sees`),status:{text:`${e.vault.length}`,tone:`off`},open:o,onToggle:()=>s(!o),children:[(0,J.jsxs)(`p`,{className:`text-[12.5px] text-muted -mt-1`,children:[a(`Refer to a secret as`),` `,(0,J.jsx)(`code`,{className:`rounded bg-surface-2 px-1`,children:`{{vault:NAME}}`}),` `,a(`in a tool call or config: the value is filled in after the Sentinel approves and is redacted from everything the model reads.`)]}),e.vault.length>0&&(0,J.jsx)(`ul`,{className:`flex flex-wrap gap-1.5`,children:e.vault.map(e=>(0,J.jsxs)(`li`,{className:`flex items-center gap-1 rounded-full bg-surface-2 pl-3 pr-1 py-1 text-[12.5px] font-mono`,children:[e,(0,J.jsx)(`button`,{type:`button`,"aria-label":a(`Delete {name}`,{name:e}),disabled:h===e,onClick:()=>void v(e),className:`p-1 rounded-full text-muted hover:text-rose-500`,children:h===e?(0,J.jsx)(l,{size:13,className:`animate-spin`}):(0,J.jsx)(L,{size:13})})]},e))}),(0,J.jsxs)(`div`,{className:`grid grid-cols-[1fr_1fr_auto] gap-2 items-end`,children:[(0,J.jsx)($,{label:a(`Name`),children:(0,J.jsx)(`input`,{value:d,onChange:e=>f(e.target.value.toUpperCase().replace(/[^A-Z0-9_.-]/g,`_`)),className:t(k,`font-mono`),placeholder:`GITHUB_TOKEN`})}),(0,J.jsx)($,{label:a(`Value`),children:(0,J.jsx)(`input`,{type:`password`,value:p,onChange:e=>m(e.target.value),className:k,placeholder:a(`secret`),autoComplete:`off`})}),(0,J.jsx)(`button`,{type:`button`,disabled:h===`add`||!d||!p,onClick:()=>void _(),className:t(D,`px-3`),"aria-label":a(`Store`),children:h===`add`?(0,J.jsx)(l,{size:16,className:`animate-spin`}):(0,J.jsx)(ne,{size:16})})]})]})}var Ve=`\0other`;function He({label:e,provider:n,value:i,options:a,more:o=[],onChange:s,other:c=!0,placeholder:l}){let u=r(),d=a.includes(i)||o.includes(i),[f,p]=(0,G.useState)(!1),m=l?u(`Automatic · {model}`,{model:l}):u(`Automatic`),h=e=>e.map(e=>({id:e,name:e})),g=o.length?[{key:`menu`,label:u(`Menu`),rows:h(a),...l?{default:l}:{}},{key:`more`,label:u(`More models on your account`),rows:h(o)}]:[{key:`list`,label:n,rows:h(a),...l?{default:l}:{}}],_=i&&d?{group:a.includes(i)?g[0].key:`more`,id:i}:void 0;return(0,J.jsxs)(`div`,{className:`block text-[12.5px] text-muted`,children:[(0,J.jsx)(`span`,{className:`block mb-1`,children:e}),(a.length||o.length)&&(d||!i)&&!f?(0,J.jsx)(ve,{label:e,text:i?`${n} · ${i}`:m,heads:[{value:``,label:m}],headValue:i?void 0:``,groups:g,current:_,onHead:()=>s(``),onPick:(e,t)=>s(t.id),tail:c?u(`Other model…`):void 0,onTail:()=>{p(!0),s(``)}}):(0,J.jsx)(`input`,{value:i,onChange:e=>s(e.target.value),placeholder:m,"aria-label":e,className:t(k,`text-fg`),spellCheck:!1})]})}function Ue(){let e=r();return I?(0,J.jsxs)(`a`,{href:I,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-0.5 text-accent`,children:[e(`How`),` `,(0,J.jsx)(w,{size:11})]}):null}function $({label:e,hint:t,children:n}){return(0,J.jsxs)(`div`,{children:[(0,J.jsx)(`label`,{className:`text-[12px] text-muted`,children:e}),(0,J.jsx)(`div`,{className:`mt-1`,children:n}),t&&(0,J.jsx)(`div`,{className:`mt-1 text-[11.5px] text-muted leading-snug`,children:t})]})}function We({result:e,okText:n}){let i=r(),[a,o]=(0,G.useState)(!1),s=!e.ok&&e.detail&&e.detail!==e.error?e.detail:null;return(0,J.jsxs)(`div`,{className:t(`rounded-2xl px-3 py-2 text-[12.5px]`,e.ok?`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`:`bg-rose-500/12 text-rose-700 dark:text-rose-300`),children:[e.ok?n:i(e.error??`The model provider did not answer.`),s&&(0,J.jsxs)(J.Fragment,{children:[` `,(0,J.jsx)(`button`,{type:`button`,onClick:()=>o(e=>!e),className:`underline decoration-dotted underline-offset-2 opacity-70 hover:opacity-100`,children:i(a?`Hide details`:`Details`)}),a&&(0,J.jsx)(`div`,{className:`mt-1.5 break-all font-mono text-[11px] opacity-80`,children:s})]})]})}function Ge(e){try{return new URL(e).host}catch{return e}}export{Ie as CalendarCard,Z as ConnectionsScreen,Q as ContactsCard,Pe as EmailCard,ke as ModelCard}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/DevicesScreen-IPKIub3e.js b/nanomuse/server/static/assets/DevicesScreen-BkPa89LS.js similarity index 97% rename from nanomuse/server/static/assets/DevicesScreen-IPKIub3e.js rename to nanomuse/server/static/assets/DevicesScreen-BkPa89LS.js index 98bbf880..88c876a3 100644 --- a/nanomuse/server/static/assets/DevicesScreen-IPKIub3e.js +++ b/nanomuse/server/static/assets/DevicesScreen-BkPa89LS.js @@ -1 +1 @@ -import{A as e,E as t,J as n,K as r,Q as i,Y as a,b as o,f as s,x as c}from"./MuseHeader-DHtaUVEa.js";import{n as l}from"./external-link-3iD-Bcbv.js";import{o as u}from"./Form-heEKMZyj.js";import{t as d}from"./refresh-cw-DQelymc2.js";import{$ as f,V as p,W as m,a as h,c as g,dt as _,ft as v,i as y,it as b,mt as x,o as S,r as C}from"./index-CrH9-E2E.js";import{t as w}from"./BackBar-B5YUZVK1.js";import{t as T}from"./CloudCard-D6djdKgw.js";var E=n(`laptop`,[[`path`,{d:`M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z`,key:`1pdavp`}],[`path`,{d:`M20.054 15.987H3.946`,key:`14rxg9`}]]),D=n(`wifi`,[[`path`,{d:`M12 20h.01`,key:`zekei9`}],[`path`,{d:`M2 8.82a15 15 0 0 1 20 0`,key:`dnpr2z`}],[`path`,{d:`M5 12.859a10 10 0 0 1 14 0`,key:`1x1e6c`}],[`path`,{d:`M8.5 16.429a5 5 0 0 1 7 0`,key:`1bycff`}]]),O=i(a(),1),k=o(),A=`https://github.com/zeeshanhaque21/nanoMuse/releases/latest`;function j(){let{state:e,dispatch:n,openThread:i,setTab:a,toast:o}=s(),l=r(),u=e.hub,f=e.hands,[p,m]=(0,O.useState)(!1),h=async()=>{try{let[e,t]=await Promise.all([c.hub(),c.hands()]);n({type:`ws`,msg:{kind:`hub`,hub:e}}),n({type:`ws`,msg:{kind:`hands_state`,hands:t}})}catch{}};(0,O.useEffect)(()=>{h()},[]);let g=async()=>{m(!0),await h(),m(!1)},_=async e=>{try{let t=await c.askDevice(e.id);n({type:`ws`,msg:{kind:`thread`,thread:t.thread}}),i(t.thread.id),a(`chat`)}catch(e){o(e.message)}},v=e=>{window.confirm(l(`Forget {name}? It can join again by signing in.`,{name:e.name}))&&c.forgetDevice(e.id).then(e=>n({type:`ws`,msg:{kind:`hub`,hub:e}})).catch(e=>o(e.message))},y=(u?.devices??[]).filter(e=>!e.this&&e.kind!==`web`),b=!!u?.account.signed_in,x=u?.state===`connected`,S=y.length+(u&&x?1:0),C=y.filter(e=>e.online).length+(u&&x?1:0);return(0,k.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,k.jsx)(w,{title:l(`Devices`),actions:(0,k.jsx)(`button`,{type:`button`,onClick:()=>void g(),"aria-label":l(`Refresh`),title:l(`Refresh`),className:`flex h-9 w-9 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,k.jsx)(d,{size:17,className:t(p&&`animate-spin`)})})}),(0,k.jsxs)(`div`,{className:`@container flex-1 overflow-y-auto pb-8`,children:[(0,k.jsxs)(`div`,{className:`px-5 pb-4 pt-1`,children:[(0,k.jsx)(`p`,{className:`text-[14px] leading-[20px] text-muted`,children:l(`Every device signed in to your account is a Muse of its own, on the same conversations. Type @ and a device's name in the chat to hand it work; a computer lends its hands.`)}),u&&b&&S>0&&(0,k.jsxs)(`p`,{className:`mt-1.5 text-[12.5px] font-medium uppercase tracking-wide text-muted/80`,children:[S===1?l(`1 device`):l(`{n} devices`,{n:S}),` · `,l(`{n} online`,{n:C})]})]}),(0,k.jsx)(`div`,{className:`px-4 pb-3`,children:(0,k.jsx)(T,{account:u?.account??null,onChange:()=>void h()})}),u&&(0,k.jsxs)(`div`,{className:`px-4`,children:[(0,k.jsx)(M,{children:l(`This device`)}),(0,k.jsx)(H,{hub:u,hands:f??void 0,onChange:()=>void h()})]}),(0,k.jsxs)(`div`,{className:`px-4 pt-4`,children:[(0,k.jsx)(M,{children:l(`Other devices`)}),y.length===0?(0,k.jsx)(G,{hub:u??void 0,signedIn:b}):(0,k.jsx)(`div`,{className:`grid grid-cols-1 gap-3 @2xl:grid-cols-2`,children:y.map(e=>(0,k.jsx)(U,{device:e,onAsk:()=>void _(e),onForget:()=>v(e)},e.id))}),(0,k.jsx)(`p`,{className:`px-1 pt-3 text-[12.5px] leading-[18px] text-muted`,children:l(`A chat addressed to a device runs there; you see every step here and answer its approvals.`)})]}),f&&!f.reason?.includes(`phone`)&&(0,k.jsxs)(`div`,{className:`pt-4`,children:[(0,k.jsx)(`div`,{className:`px-4`,children:(0,k.jsx)(M,{children:l(`Hands`)})}),(0,k.jsx)(K,{hands:f,onChange:()=>void h()})]})]})]})}function M({children:e}){return(0,k.jsx)(`p`,{className:`px-1 pb-2 text-[12.5px] font-medium uppercase tracking-wide text-muted/80`,children:e})}function N({children:e}){return(0,k.jsx)(`p`,{className:`px-4 pb-3 text-[12.5px] leading-[18px] text-muted`,children:e})}function P({children:e,className:n}){return(0,k.jsx)(`div`,{className:t(`relative rounded-2xl border border-border/70 bg-surface p-4`,n),children:e})}function F({kind:e,online:n}){return(0,k.jsx)(`span`,{className:t(`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl`,n?`bg-accent/10 text-accent`:`bg-surface-2 text-muted`),"aria-hidden":!0,children:q(e,22)})}function I({online:e}){return(0,k.jsx)(`span`,{"aria-hidden":!0,className:t(`inline-block h-2 w-2 shrink-0 rounded-full`,e?`bg-[#2fb344]`:`bg-muted/35`)})}function L(e){if(e==null||e===``)return``;let t=typeof e==`number`?e:/^\d+(\.\d+)?$/.test(e)?Number(e):NaN;return Number.isFinite(t)?new Date(t*1e3).toISOString():String(e)}function R(t,n){let r=L(t.last_seen),i=e(r);return t.online?r&&Date.now()-new Date(r).getTime()<12e4?`${n(`Online`)} · ${n(`just now`)}`:n(`Online`):i?n(`Last seen {when}`,{when:i}):n(`Offline`)}function z(e,t,n=[]){let r=new Set(e??[]),i=[...n];return r.has(`task`)&&i.push(t(`Tasks`)),r.has(`shell`)&&i.push(t(`Commands`)),(r.has(`files`)||r.has(`file.get`)||r.has(`file.put`))&&i.push(t(`Files`)),r.has(`screen`)&&i.push(t(`Screenshot`)),r.has(`open`)&&i.push(t(`Open links`)),[...r].some(e=>e.startsWith(`coding.`))&&i.push(t(`Coding agents`)),r.has(`notify`)&&i.push(t(`Notifications`)),i}function B({items:e}){return e.length===0?null:(0,k.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.map(e=>(0,k.jsx)(`span`,{className:`rounded-full bg-surface-2 px-2.5 py-1 text-[12px] font-medium text-fg/80`,children:e},e))})}function V(e){return[e.os||``,e.version||``].filter(Boolean).join(` · `)}function H({hub:e,hands:t,onChange:n}){let{dispatch:i,toast:a}=s(),o=r(),[d,p]=(0,O.useState)(!1),[m,_]=(0,O.useState)(e.device.name),[v,y]=(0,O.useState)(!1),b=e.account.signed_in,x=e.state===`connected`,S=e.devices.find(e=>e.this),C=async e=>{y(!0);try{let t=await c.updateHub(e);i({type:`ws`,msg:{kind:`hub`,hub:t}}),n()}catch(e){a(e.message)}finally{y(!1)}},w=b?e.enabled?x?o(`Online`):e.state===`refused`?o(`Refused`):o(`Connecting…`):o(`Off`):o(`Signed out`),T=z(e.device.actions,o,t?.enabled&&t.available?[o(`Hands`)]:[]);return(0,k.jsxs)(P,{children:[(0,k.jsxs)(`div`,{className:`flex items-start gap-3.5`,children:[(0,k.jsx)(F,{kind:e.device.kind,online:x}),(0,k.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[d?(0,k.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),m.trim()&&C({name:m.trim()}).then(()=>p(!1))},children:[(0,k.jsx)(`input`,{value:m,onChange:e=>_(e.target.value),maxLength:60,autoFocus:!0,"aria-label":o(`This device's name`),placeholder:o(`A name your other devices will see`),className:`h-9 min-w-0 flex-1 rounded-xl bg-surface-2 px-3 text-[15px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,k.jsx)(`button`,{type:`submit`,disabled:v||!m.trim(),"aria-label":o(`Save`),className:`flex h-9 w-9 items-center justify-center rounded-full bg-accent text-accent-fg disabled:opacity-40`,children:(0,k.jsx)(l,{size:16})}),(0,k.jsx)(`button`,{type:`button`,onClick:()=>p(!1),"aria-label":o(`Cancel`),className:`flex h-9 w-9 items-center justify-center rounded-full bg-surface-2 text-muted`,children:(0,k.jsx)(u,{size:16})})]}):(0,k.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,k.jsx)(`span`,{className:`truncate text-[16px] font-semibold leading-[22px]`,children:e.device.name}),(0,k.jsx)(`button`,{type:`button`,onClick:()=>{_(e.device.name),p(!0)},"aria-label":o(`Rename this device`),title:o(`Rename this device`),className:`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,k.jsx)(f,{size:13})})]}),(0,k.jsxs)(`p`,{className:`mt-0.5 flex items-center gap-1.5 text-[13px] leading-[18px] text-muted`,children:[(0,k.jsx)(I,{online:x}),(0,k.jsx)(`span`,{className:`truncate`,children:[w,V(S??{})].filter(Boolean).join(` · `)})]}),T.length>0&&(0,k.jsx)(`div`,{className:`mt-3`,children:(0,k.jsx)(B,{items:T})})]})]}),(0,k.jsxs)(`div`,{className:`-mx-4 -mb-2 mt-4 border-t border-border/60`,children:[(0,k.jsx)(g,{label:o(`Join the hub`),checked:e.enabled&&b,disabled:!b||v,onChange:e=>void C({enabled:e})}),(0,k.jsx)(N,{children:o(b?`Reachable by your other devices while this runs.`:`Sign in first.`)}),(0,k.jsx)(h,{inset:16}),(0,k.jsx)(g,{label:o(`My other devices may operate it`),checked:e.remote_control,disabled:v,onChange:e=>void C({remote_control:e})}),(0,k.jsx)(N,{children:o(`They can run commands, read files and see its screen here; each step still goes through the Sentinel.`)})]}),(e.state===`refused`||e.state===`disconnected`)&&e.detail&&(0,k.jsx)(`div`,{className:`mt-3 rounded-xl bg-amber-500/12 px-3 py-2 text-[12.5px] text-amber-700 dark:text-amber-300`,children:e.detail})]})}function U({device:e,onAsk:t,onForget:n}){let i=r(),a=z(e.actions,i),o=e.online&&(e.actions??[]).includes(`task`);return(0,k.jsxs)(P,{className:`flex flex-col gap-3`,children:[(0,k.jsxs)(`div`,{className:`flex items-start gap-3.5`,children:[(0,k.jsx)(F,{kind:e.kind,online:e.online}),(0,k.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,k.jsx)(`p`,{className:`truncate text-[16px] font-semibold leading-[22px]`,children:e.name}),(0,k.jsxs)(`p`,{className:`mt-0.5 flex items-center gap-1.5 text-[13px] leading-[18px] text-muted`,children:[(0,k.jsx)(I,{online:e.online}),(0,k.jsx)(`span`,{className:`truncate`,children:[R(e,i),V(e)].filter(Boolean).join(` · `)})]})]}),(0,k.jsx)(W,{label:i(`More about {name}`,{name:e.name}),items:[{label:i(`Forget this device`),onClick:n,danger:!0}]})]}),(0,k.jsx)(B,{items:a}),o&&(0,k.jsx)(`p`,{className:`text-[12.5px] leading-[18px] text-muted`,children:i(`Type @{name} in the chat to hand it work.`,{name:e.name})}),e.online&&(0,k.jsxs)(`button`,{type:`button`,onClick:t,className:`flex h-9 w-fit items-center gap-1.5 rounded-full bg-accent/10 px-3.5 text-[13.5px] font-medium text-accent hover:bg-accent/15`,children:[(0,k.jsx)(b,{size:14}),` `,i(`Ask`)]})]})}function W({label:e,items:n}){let[r,i]=(0,O.useState)(!1),a=(0,O.useRef)(null);return(0,O.useEffect)(()=>{if(!r)return;let e=e=>{a.current?.contains(e.target)||i(!1)},t=e=>{e.key===`Escape`&&i(!1)};return window.addEventListener(`pointerdown`,e),window.addEventListener(`keydown`,t),()=>{window.removeEventListener(`pointerdown`,e),window.removeEventListener(`keydown`,t)}},[r]),(0,k.jsxs)(`div`,{ref:a,className:`relative -mr-1.5 -mt-1`,children:[(0,k.jsx)(`button`,{type:`button`,onClick:()=>i(e=>!e),"aria-label":e,"aria-haspopup":`menu`,"aria-expanded":r,className:`flex h-8 w-8 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,k.jsx)(x,{size:18})}),r&&(0,k.jsx)(`ul`,{role:`menu`,className:`absolute right-0 top-9 z-30 min-w-[180px] overflow-hidden rounded-2xl border border-border bg-surface py-1 shadow-xl`,children:n.map(e=>(0,k.jsx)(`li`,{children:(0,k.jsx)(`button`,{type:`button`,role:`menuitem`,onClick:()=>{i(!1),e.onClick()},className:t(`w-full px-4 py-2.5 text-left text-[14px] hover:bg-surface-2`,e.danger&&`text-red-600 dark:text-red-400`),children:e.label})},e.label))})]})}function G({hub:e,signedIn:t}){let n=r(),i=t&&e?.state===`connected`;return(0,k.jsxs)(P,{className:`flex flex-col gap-3`,children:[(0,k.jsxs)(`div`,{className:`flex items-start gap-3.5`,children:[(0,k.jsx)(`span`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-muted`,"aria-hidden":!0,children:(0,k.jsx)(m,{size:22})}),(0,k.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,k.jsx)(`p`,{className:`text-[15px] font-semibold leading-[20px]`,children:n(i?`Add a device`:`No other device yet`)}),(0,k.jsx)(`p`,{className:`mt-1 text-[13px] leading-[18px] text-muted`,children:t?i?e?.account.hint?n(`Install nanoMuse on your phone, Mac or PC and sign in as {hint}; it appears here on its own.`,{hint:e.account.hint}):n(`Install nanoMuse on your phone, Mac or PC and sign in with the same account; it appears here on its own.`):J(e?.state??``,n):n(`Sign in above to see them.`)})]})]}),i&&(0,k.jsx)(`a`,{href:A,target:`_blank`,rel:`noopener noreferrer`,className:`flex h-9 w-fit items-center gap-1.5 rounded-full bg-accent/10 px-3.5 text-[13.5px] font-medium text-accent hover:bg-accent/15`,children:n(`Get the apps`)})]})}function K({hands:e,onChange:n}){let{dispatch:i,toast:a,state:o}=s(),l=r(),[u,d]=(0,O.useState)(!1),f=o.profile?.name??`nanoMuse`,m=async e=>{d(!0);try{let t=await c.setHands(e);i({type:`ws`,msg:{kind:`hands_state`,hands:t}}),n()}catch(e){a(e.message)}finally{d(!1)}},v=e.task_active?l(`Working`):e.enabled?e.available?l(`On`):l(`Unavailable`):l(`Off`);return(0,k.jsxs)(k.Fragment,{children:[(0,k.jsxs)(y,{className:`border border-border/70`,children:[(0,k.jsx)(g,{icon:(0,k.jsx)(_,{size:22}),label:l(`Hands on this computer`),value:v,checked:e.enabled,disabled:u,onChange:e=>void m({enabled:e})}),(0,k.jsx)(C,{className:`pb-3 pt-0`,children:e.task_active?e.task_text||l(`On the screen now`):e.available?l(`It looks at a screenshot and clicks by position; paying, sending and deleting ask you first.`):e.reason||l(`Nothing can drive this screen yet.`)}),(0,k.jsx)(h,{inset:16}),(0,k.jsxs)(`div`,{className:`flex items-center gap-2 px-4 py-3 text-[13px] text-muted`,children:[(0,k.jsxs)(`span`,{children:[l(`Driver`),`:`]}),[`auto`,`desktop`,`pyautogui`,`xdotool`].map(n=>(0,k.jsx)(`button`,{type:`button`,disabled:u,onClick:()=>void m({backend:n}),className:t(`rounded-full px-2.5 py-1`,(e.backend??`auto`)===n||n===`auto`&&!e.backend?`bg-fg text-bg`:`bg-surface-2 text-fg`),children:n},n))]}),(e.device?.platform===`darwin`||e.window?.available)&&(0,k.jsxs)(k.Fragment,{children:[(0,k.jsx)(h,{inset:16}),(0,k.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2 px-4 py-3 text-[13px] text-muted`,children:[(0,k.jsxs)(`span`,{children:[l(`Where`),`:`]}),[`auto`,`window`,`screen`].map(n=>(0,k.jsx)(`button`,{type:`button`,disabled:u,onClick:()=>void m({mode:n}),className:t(`rounded-full px-2.5 py-1`,(e.mode??`auto`)===n?`bg-fg text-bg`:`bg-surface-2 text-fg`),children:l(n===`auto`?`Auto`:n===`window`?`One window`:`Whole screen`)},n))]}),(0,k.jsx)(C,{className:`pb-3 pt-0`,children:e.window?.active&&e.window.app?l(`Working in {app}'s window; the mouse stays yours.`,{app:e.window.app}):e.window?.reason&&(e.mode??`auto`)!==`screen`?l(`On the whole screen for now: {reason}`,{reason:e.window.reason}):l(`One window: the hands work inside the app they were given, send clicks and keys to it alone and leave your mouse alone. Whole screen: the system mouse, like other platforms. Auto picks one window as soon as an app is named.`)})]}),e.task_active&&(0,k.jsxs)(k.Fragment,{children:[(0,k.jsx)(h,{inset:16}),(0,k.jsx)(S,{icon:(0,k.jsx)(p,{size:18,fill:`currentColor`}),label:l(`Stop the hands`),onClick:()=>void c.stopHands().catch(e=>a(e.message)),chevron:!1})]})]}),e.enabled&&e.device?.platform===`darwin`&&(0,k.jsx)(C,{children:l(`On a Mac, allow nanoMuse under System Settings → Privacy & Security → Accessibility and Screen Recording when macOS asks; without them clicks do nothing and the screenshot is black. The first action in each app asks you once ("Let {name} use Safari?") and the answer is kept under Permissions.`,{name:f})})]})}function q(e,t=18){switch(e){case`phone`:return(0,k.jsx)(m,{size:t});case`computer`:return(0,k.jsx)(E,{size:t});case`web`:return(0,k.jsx)(v,{size:t});default:return(0,k.jsx)(D,{size:t})}}function J(e,t){switch(e){case`connected`:return t(`On the hub`);case`connecting`:case`disconnected`:return t(`Connecting to the hub…`);case`refused`:return t(`The hub refused this device. Sign in again.`);case`signed_out`:return t(`Not signed in.`);case`off`:case`stopped`:return t(`The hub is off on this device.`);default:return e}}export{j as DevicesScreen}; \ No newline at end of file +import{A as e,E as t,J as n,K as r,Q as i,Y as a,b as o,f as s,x as c}from"./MuseHeader-RC--uR8o.js";import{n as l}from"./external-link-TvxP_dKX.js";import{o as u}from"./Form-H3s9oTHq.js";import{t as d}from"./refresh-cw-CxZ3ZdI3.js";import{$ as f,V as p,W as m,a as h,c as g,dt as _,ft as v,i as y,it as b,mt as x,o as S,r as C}from"./index-tYqPtwPX.js";import{t as w}from"./BackBar-JEuC8Q_e.js";import{t as T}from"./CloudCard-D5foJm1I.js";var E=n(`laptop`,[[`path`,{d:`M18 5a2 2 0 0 1 2 2v8.526a2 2 0 0 0 .212.897l1.068 2.127a1 1 0 0 1-.9 1.45H3.62a1 1 0 0 1-.9-1.45l1.068-2.127A2 2 0 0 0 4 15.526V7a2 2 0 0 1 2-2z`,key:`1pdavp`}],[`path`,{d:`M20.054 15.987H3.946`,key:`14rxg9`}]]),D=n(`wifi`,[[`path`,{d:`M12 20h.01`,key:`zekei9`}],[`path`,{d:`M2 8.82a15 15 0 0 1 20 0`,key:`dnpr2z`}],[`path`,{d:`M5 12.859a10 10 0 0 1 14 0`,key:`1x1e6c`}],[`path`,{d:`M8.5 16.429a5 5 0 0 1 7 0`,key:`1bycff`}]]),O=i(a(),1),k=o(),A=`https://github.com/zeeshanhaque21/nanoMuse/releases/latest`;function j(){let{state:e,dispatch:n,openThread:i,setTab:a,toast:o}=s(),l=r(),u=e.hub,f=e.hands,[p,m]=(0,O.useState)(!1),h=async()=>{try{let[e,t]=await Promise.all([c.hub(),c.hands()]);n({type:`ws`,msg:{kind:`hub`,hub:e}}),n({type:`ws`,msg:{kind:`hands_state`,hands:t}})}catch{}};(0,O.useEffect)(()=>{h()},[]);let g=async()=>{m(!0),await h(),m(!1)},_=async e=>{try{let t=await c.askDevice(e.id);n({type:`ws`,msg:{kind:`thread`,thread:t.thread}}),i(t.thread.id),a(`chat`)}catch(e){o(e.message)}},v=e=>{window.confirm(l(`Forget {name}? It can join again by signing in.`,{name:e.name}))&&c.forgetDevice(e.id).then(e=>n({type:`ws`,msg:{kind:`hub`,hub:e}})).catch(e=>o(e.message))},y=(u?.devices??[]).filter(e=>!e.this&&e.kind!==`web`),b=!!u?.account.signed_in,x=u?.state===`connected`,S=y.length+(u&&x?1:0),C=y.filter(e=>e.online).length+(u&&x?1:0);return(0,k.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,k.jsx)(w,{title:l(`Devices`),actions:(0,k.jsx)(`button`,{type:`button`,onClick:()=>void g(),"aria-label":l(`Refresh`),title:l(`Refresh`),className:`flex h-9 w-9 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,k.jsx)(d,{size:17,className:t(p&&`animate-spin`)})})}),(0,k.jsxs)(`div`,{className:`@container flex-1 overflow-y-auto pb-8`,children:[(0,k.jsxs)(`div`,{className:`px-5 pb-4 pt-1`,children:[(0,k.jsx)(`p`,{className:`text-[14px] leading-[20px] text-muted`,children:l(`Every device signed in to your account is a Muse of its own, on the same conversations. Type @ and a device's name in the chat to hand it work; a computer lends its hands.`)}),u&&b&&S>0&&(0,k.jsxs)(`p`,{className:`mt-1.5 text-[12.5px] font-medium uppercase tracking-wide text-muted/80`,children:[S===1?l(`1 device`):l(`{n} devices`,{n:S}),` · `,l(`{n} online`,{n:C})]})]}),(0,k.jsx)(`div`,{className:`px-4 pb-3`,children:(0,k.jsx)(T,{account:u?.account??null,onChange:()=>void h()})}),u&&(0,k.jsxs)(`div`,{className:`px-4`,children:[(0,k.jsx)(M,{children:l(`This device`)}),(0,k.jsx)(H,{hub:u,hands:f??void 0,onChange:()=>void h()})]}),(0,k.jsxs)(`div`,{className:`px-4 pt-4`,children:[(0,k.jsx)(M,{children:l(`Other devices`)}),y.length===0?(0,k.jsx)(G,{hub:u??void 0,signedIn:b}):(0,k.jsx)(`div`,{className:`grid grid-cols-1 gap-3 @2xl:grid-cols-2`,children:y.map(e=>(0,k.jsx)(U,{device:e,onAsk:()=>void _(e),onForget:()=>v(e)},e.id))}),(0,k.jsx)(`p`,{className:`px-1 pt-3 text-[12.5px] leading-[18px] text-muted`,children:l(`A chat addressed to a device runs there; you see every step here and answer its approvals.`)})]}),f&&!f.reason?.includes(`phone`)&&(0,k.jsxs)(`div`,{className:`pt-4`,children:[(0,k.jsx)(`div`,{className:`px-4`,children:(0,k.jsx)(M,{children:l(`Hands`)})}),(0,k.jsx)(K,{hands:f,onChange:()=>void h()})]})]})]})}function M({children:e}){return(0,k.jsx)(`p`,{className:`px-1 pb-2 text-[12.5px] font-medium uppercase tracking-wide text-muted/80`,children:e})}function N({children:e}){return(0,k.jsx)(`p`,{className:`px-4 pb-3 text-[12.5px] leading-[18px] text-muted`,children:e})}function P({children:e,className:n}){return(0,k.jsx)(`div`,{className:t(`relative rounded-2xl border border-border/70 bg-surface p-4`,n),children:e})}function F({kind:e,online:n}){return(0,k.jsx)(`span`,{className:t(`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl`,n?`bg-accent/10 text-accent`:`bg-surface-2 text-muted`),"aria-hidden":!0,children:q(e,22)})}function I({online:e}){return(0,k.jsx)(`span`,{"aria-hidden":!0,className:t(`inline-block h-2 w-2 shrink-0 rounded-full`,e?`bg-[#2fb344]`:`bg-muted/35`)})}function L(e){if(e==null||e===``)return``;let t=typeof e==`number`?e:/^\d+(\.\d+)?$/.test(e)?Number(e):NaN;return Number.isFinite(t)?new Date(t*1e3).toISOString():String(e)}function R(t,n){let r=L(t.last_seen),i=e(r);return t.online?r&&Date.now()-new Date(r).getTime()<12e4?`${n(`Online`)} · ${n(`just now`)}`:n(`Online`):i?n(`Last seen {when}`,{when:i}):n(`Offline`)}function z(e,t,n=[]){let r=new Set(e??[]),i=[...n];return r.has(`task`)&&i.push(t(`Tasks`)),r.has(`shell`)&&i.push(t(`Commands`)),(r.has(`files`)||r.has(`file.get`)||r.has(`file.put`))&&i.push(t(`Files`)),r.has(`screen`)&&i.push(t(`Screenshot`)),r.has(`open`)&&i.push(t(`Open links`)),[...r].some(e=>e.startsWith(`coding.`))&&i.push(t(`Coding agents`)),r.has(`notify`)&&i.push(t(`Notifications`)),i}function B({items:e}){return e.length===0?null:(0,k.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:e.map(e=>(0,k.jsx)(`span`,{className:`rounded-full bg-surface-2 px-2.5 py-1 text-[12px] font-medium text-fg/80`,children:e},e))})}function V(e){return[e.os||``,e.version||``].filter(Boolean).join(` · `)}function H({hub:e,hands:t,onChange:n}){let{dispatch:i,toast:a}=s(),o=r(),[d,p]=(0,O.useState)(!1),[m,_]=(0,O.useState)(e.device.name),[v,y]=(0,O.useState)(!1),b=e.account.signed_in,x=e.state===`connected`,S=e.devices.find(e=>e.this),C=async e=>{y(!0);try{let t=await c.updateHub(e);i({type:`ws`,msg:{kind:`hub`,hub:t}}),n()}catch(e){a(e.message)}finally{y(!1)}},w=b?e.enabled?x?o(`Online`):e.state===`refused`?o(`Refused`):o(`Connecting…`):o(`Off`):o(`Signed out`),T=z(e.device.actions,o,t?.enabled&&t.available?[o(`Hands`)]:[]);return(0,k.jsxs)(P,{children:[(0,k.jsxs)(`div`,{className:`flex items-start gap-3.5`,children:[(0,k.jsx)(F,{kind:e.device.kind,online:x}),(0,k.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[d?(0,k.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),m.trim()&&C({name:m.trim()}).then(()=>p(!1))},children:[(0,k.jsx)(`input`,{value:m,onChange:e=>_(e.target.value),maxLength:60,autoFocus:!0,"aria-label":o(`This device's name`),placeholder:o(`A name your other devices will see`),className:`h-9 min-w-0 flex-1 rounded-xl bg-surface-2 px-3 text-[15px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,k.jsx)(`button`,{type:`submit`,disabled:v||!m.trim(),"aria-label":o(`Save`),className:`flex h-9 w-9 items-center justify-center rounded-full bg-accent text-accent-fg disabled:opacity-40`,children:(0,k.jsx)(l,{size:16})}),(0,k.jsx)(`button`,{type:`button`,onClick:()=>p(!1),"aria-label":o(`Cancel`),className:`flex h-9 w-9 items-center justify-center rounded-full bg-surface-2 text-muted`,children:(0,k.jsx)(u,{size:16})})]}):(0,k.jsxs)(`div`,{className:`flex items-center gap-1.5`,children:[(0,k.jsx)(`span`,{className:`truncate text-[16px] font-semibold leading-[22px]`,children:e.device.name}),(0,k.jsx)(`button`,{type:`button`,onClick:()=>{_(e.device.name),p(!0)},"aria-label":o(`Rename this device`),title:o(`Rename this device`),className:`flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,k.jsx)(f,{size:13})})]}),(0,k.jsxs)(`p`,{className:`mt-0.5 flex items-center gap-1.5 text-[13px] leading-[18px] text-muted`,children:[(0,k.jsx)(I,{online:x}),(0,k.jsx)(`span`,{className:`truncate`,children:[w,V(S??{})].filter(Boolean).join(` · `)})]}),T.length>0&&(0,k.jsx)(`div`,{className:`mt-3`,children:(0,k.jsx)(B,{items:T})})]})]}),(0,k.jsxs)(`div`,{className:`-mx-4 -mb-2 mt-4 border-t border-border/60`,children:[(0,k.jsx)(g,{label:o(`Join the hub`),checked:e.enabled&&b,disabled:!b||v,onChange:e=>void C({enabled:e})}),(0,k.jsx)(N,{children:o(b?`Reachable by your other devices while this runs.`:`Sign in first.`)}),(0,k.jsx)(h,{inset:16}),(0,k.jsx)(g,{label:o(`My other devices may operate it`),checked:e.remote_control,disabled:v,onChange:e=>void C({remote_control:e})}),(0,k.jsx)(N,{children:o(`They can run commands, read files and see its screen here; each step still goes through the Sentinel.`)})]}),(e.state===`refused`||e.state===`disconnected`)&&e.detail&&(0,k.jsx)(`div`,{className:`mt-3 rounded-xl bg-amber-500/12 px-3 py-2 text-[12.5px] text-amber-700 dark:text-amber-300`,children:e.detail})]})}function U({device:e,onAsk:t,onForget:n}){let i=r(),a=z(e.actions,i),o=e.online&&(e.actions??[]).includes(`task`);return(0,k.jsxs)(P,{className:`flex flex-col gap-3`,children:[(0,k.jsxs)(`div`,{className:`flex items-start gap-3.5`,children:[(0,k.jsx)(F,{kind:e.kind,online:e.online}),(0,k.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,k.jsx)(`p`,{className:`truncate text-[16px] font-semibold leading-[22px]`,children:e.name}),(0,k.jsxs)(`p`,{className:`mt-0.5 flex items-center gap-1.5 text-[13px] leading-[18px] text-muted`,children:[(0,k.jsx)(I,{online:e.online}),(0,k.jsx)(`span`,{className:`truncate`,children:[R(e,i),V(e)].filter(Boolean).join(` · `)})]})]}),(0,k.jsx)(W,{label:i(`More about {name}`,{name:e.name}),items:[{label:i(`Forget this device`),onClick:n,danger:!0}]})]}),(0,k.jsx)(B,{items:a}),o&&(0,k.jsx)(`p`,{className:`text-[12.5px] leading-[18px] text-muted`,children:i(`Type @{name} in the chat to hand it work.`,{name:e.name})}),e.online&&(0,k.jsxs)(`button`,{type:`button`,onClick:t,className:`flex h-9 w-fit items-center gap-1.5 rounded-full bg-accent/10 px-3.5 text-[13.5px] font-medium text-accent hover:bg-accent/15`,children:[(0,k.jsx)(b,{size:14}),` `,i(`Ask`)]})]})}function W({label:e,items:n}){let[r,i]=(0,O.useState)(!1),a=(0,O.useRef)(null);return(0,O.useEffect)(()=>{if(!r)return;let e=e=>{a.current?.contains(e.target)||i(!1)},t=e=>{e.key===`Escape`&&i(!1)};return window.addEventListener(`pointerdown`,e),window.addEventListener(`keydown`,t),()=>{window.removeEventListener(`pointerdown`,e),window.removeEventListener(`keydown`,t)}},[r]),(0,k.jsxs)(`div`,{ref:a,className:`relative -mr-1.5 -mt-1`,children:[(0,k.jsx)(`button`,{type:`button`,onClick:()=>i(e=>!e),"aria-label":e,"aria-haspopup":`menu`,"aria-expanded":r,className:`flex h-8 w-8 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,k.jsx)(x,{size:18})}),r&&(0,k.jsx)(`ul`,{role:`menu`,className:`absolute right-0 top-9 z-30 min-w-[180px] overflow-hidden rounded-2xl border border-border bg-surface py-1 shadow-xl`,children:n.map(e=>(0,k.jsx)(`li`,{children:(0,k.jsx)(`button`,{type:`button`,role:`menuitem`,onClick:()=>{i(!1),e.onClick()},className:t(`w-full px-4 py-2.5 text-left text-[14px] hover:bg-surface-2`,e.danger&&`text-red-600 dark:text-red-400`),children:e.label})},e.label))})]})}function G({hub:e,signedIn:t}){let n=r(),i=t&&e?.state===`connected`;return(0,k.jsxs)(P,{className:`flex flex-col gap-3`,children:[(0,k.jsxs)(`div`,{className:`flex items-start gap-3.5`,children:[(0,k.jsx)(`span`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-muted`,"aria-hidden":!0,children:(0,k.jsx)(m,{size:22})}),(0,k.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,k.jsx)(`p`,{className:`text-[15px] font-semibold leading-[20px]`,children:n(i?`Add a device`:`No other device yet`)}),(0,k.jsx)(`p`,{className:`mt-1 text-[13px] leading-[18px] text-muted`,children:t?i?e?.account.hint?n(`Install nanoMuse on your phone, Mac or PC and sign in as {hint}; it appears here on its own.`,{hint:e.account.hint}):n(`Install nanoMuse on your phone, Mac or PC and sign in with the same account; it appears here on its own.`):J(e?.state??``,n):n(`Sign in above to see them.`)})]})]}),i&&(0,k.jsx)(`a`,{href:A,target:`_blank`,rel:`noopener noreferrer`,className:`flex h-9 w-fit items-center gap-1.5 rounded-full bg-accent/10 px-3.5 text-[13.5px] font-medium text-accent hover:bg-accent/15`,children:n(`Get the apps`)})]})}function K({hands:e,onChange:n}){let{dispatch:i,toast:a,state:o}=s(),l=r(),[u,d]=(0,O.useState)(!1),f=o.profile?.name??`nanoMuse`,m=async e=>{d(!0);try{let t=await c.setHands(e);i({type:`ws`,msg:{kind:`hands_state`,hands:t}}),n()}catch(e){a(e.message)}finally{d(!1)}},v=e.task_active?l(`Working`):e.enabled?e.available?l(`On`):l(`Unavailable`):l(`Off`);return(0,k.jsxs)(k.Fragment,{children:[(0,k.jsxs)(y,{className:`border border-border/70`,children:[(0,k.jsx)(g,{icon:(0,k.jsx)(_,{size:22}),label:l(`Hands on this computer`),value:v,checked:e.enabled,disabled:u,onChange:e=>void m({enabled:e})}),(0,k.jsx)(C,{className:`pb-3 pt-0`,children:e.task_active?e.task_text||l(`On the screen now`):e.available?l(`It looks at a screenshot and clicks by position; paying, sending and deleting ask you first.`):e.reason||l(`Nothing can drive this screen yet.`)}),(0,k.jsx)(h,{inset:16}),(0,k.jsxs)(`div`,{className:`flex items-center gap-2 px-4 py-3 text-[13px] text-muted`,children:[(0,k.jsxs)(`span`,{children:[l(`Driver`),`:`]}),[`auto`,`desktop`,`pyautogui`,`xdotool`].map(n=>(0,k.jsx)(`button`,{type:`button`,disabled:u,onClick:()=>void m({backend:n}),className:t(`rounded-full px-2.5 py-1`,(e.backend??`auto`)===n||n===`auto`&&!e.backend?`bg-fg text-bg`:`bg-surface-2 text-fg`),children:n},n))]}),(e.device?.platform===`darwin`||e.window?.available)&&(0,k.jsxs)(k.Fragment,{children:[(0,k.jsx)(h,{inset:16}),(0,k.jsxs)(`div`,{className:`flex flex-wrap items-center gap-2 px-4 py-3 text-[13px] text-muted`,children:[(0,k.jsxs)(`span`,{children:[l(`Where`),`:`]}),[`auto`,`window`,`screen`].map(n=>(0,k.jsx)(`button`,{type:`button`,disabled:u,onClick:()=>void m({mode:n}),className:t(`rounded-full px-2.5 py-1`,(e.mode??`auto`)===n?`bg-fg text-bg`:`bg-surface-2 text-fg`),children:l(n===`auto`?`Auto`:n===`window`?`One window`:`Whole screen`)},n))]}),(0,k.jsx)(C,{className:`pb-3 pt-0`,children:e.window?.active&&e.window.app?l(`Working in {app}'s window; the mouse stays yours.`,{app:e.window.app}):e.window?.reason&&(e.mode??`auto`)!==`screen`?l(`On the whole screen for now: {reason}`,{reason:e.window.reason}):l(`One window: the hands work inside the app they were given, send clicks and keys to it alone and leave your mouse alone. Whole screen: the system mouse, like other platforms. Auto picks one window as soon as an app is named.`)})]}),e.task_active&&(0,k.jsxs)(k.Fragment,{children:[(0,k.jsx)(h,{inset:16}),(0,k.jsx)(S,{icon:(0,k.jsx)(p,{size:18,fill:`currentColor`}),label:l(`Stop the hands`),onClick:()=>void c.stopHands().catch(e=>a(e.message)),chevron:!1})]})]}),e.enabled&&e.device?.platform===`darwin`&&(0,k.jsx)(C,{children:l(`On a Mac, allow nanoMuse under System Settings → Privacy & Security → Accessibility and Screen Recording when macOS asks; without them clicks do nothing and the screenshot is black. The first action in each app asks you once ("Let {name} use Safari?") and the answer is kept under Permissions.`,{name:f})})]})}function q(e,t=18){switch(e){case`phone`:return(0,k.jsx)(m,{size:t});case`computer`:return(0,k.jsx)(E,{size:t});case`web`:return(0,k.jsx)(v,{size:t});default:return(0,k.jsx)(D,{size:t})}}function J(e,t){switch(e){case`connected`:return t(`On the hub`);case`connecting`:case`disconnected`:return t(`Connecting to the hub…`);case`refused`:return t(`The hub refused this device. Sign in again.`);case`signed_out`:return t(`Not signed in.`);case`off`:case`stopped`:return t(`The hub is off on this device.`);default:return e}}export{j as DevicesScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/Form-heEKMZyj.js b/nanomuse/server/static/assets/Form-H3s9oTHq.js similarity index 97% rename from nanomuse/server/static/assets/Form-heEKMZyj.js rename to nanomuse/server/static/assets/Form-H3s9oTHq.js index dde0858e..bac2b3ed 100644 --- a/nanomuse/server/static/assets/Form-heEKMZyj.js +++ b/nanomuse/server/static/assets/Form-H3s9oTHq.js @@ -1 +1 @@ -import{E as e,J as t,b as n}from"./MuseHeader-DHtaUVEa.js";var r=t(`chevron-down`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),i=t(`chevron-up`,[[`path`,{d:`m18 15-6-6-6 6`,key:`153udz`}]]),a=t(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),o=n();function s({icon:t,title:n,summary:a,status:s,open:c,onToggle:l,trailing:u,children:d}){let f=(0,o.jsxs)(o.Fragment,{children:[(0,o.jsx)(`span`,{className:`text-accent mt-0.5`,children:t}),(0,o.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,o.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,o.jsx)(`span`,{className:`text-[15px] font-semibold`,children:n}),(0,o.jsx)(`span`,{className:e(`rounded-full px-2 py-0.5 text-[11px] font-medium`,s.tone===`ok`&&`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`,s.tone===`warn`&&`bg-amber-500/15 text-amber-700 dark:text-amber-300`,s.tone===`off`&&`bg-surface-2 text-muted`),children:s.text})]}),(0,o.jsx)(`span`,{className:`block text-[12.5px] text-muted truncate`,children:a})]}),u??(l?c?(0,o.jsx)(i,{size:18,className:`text-muted`}):(0,o.jsx)(r,{size:18,className:`text-muted`}):null)]});return(0,o.jsxs)(`section`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm`,children:[l?(0,o.jsx)(`button`,{type:`button`,onClick:l,className:`flex w-full items-start gap-3 px-4 py-3.5 text-left`,children:f}):(0,o.jsx)(`div`,{className:`flex w-full items-start gap-3 px-4 py-3.5`,children:f}),(c||!l&&d)&&d?(0,o.jsx)(`div`,{className:`px-4 pb-4 space-y-3`,children:d}):null]})}var c=`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,l=`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,u=`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`;function d({label:t,hint:n,checked:r,onChange:i,disabled:a}){return(0,o.jsxs)(`label`,{className:`flex items-start gap-3 cursor-pointer`,children:[(0,o.jsxs)(`div`,{className:`flex-1`,children:[(0,o.jsx)(`div`,{className:`text-[14px]`,children:t}),n&&(0,o.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:n})]}),(0,o.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":r,disabled:a,onClick:()=>i(!r),className:e(`relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition disabled:opacity-50`,r?`bg-accent`:`bg-surface-2 border border-border`),children:(0,o.jsx)(`span`,{className:e(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,r?`left-[22px]`:`left-0.5`)})})]})}export{u as a,r as c,l as i,d as n,a as o,c as r,i as s,s as t}; \ No newline at end of file +import{E as e,J as t,b as n}from"./MuseHeader-RC--uR8o.js";var r=t(`chevron-down`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]),i=t(`chevron-up`,[[`path`,{d:`m18 15-6-6-6 6`,key:`153udz`}]]),a=t(`x`,[[`path`,{d:`M18 6 6 18`,key:`1bl5f8`}],[`path`,{d:`m6 6 12 12`,key:`d8bk6v`}]]),o=n();function s({icon:t,title:n,summary:a,status:s,open:c,onToggle:l,trailing:u,children:d}){let f=(0,o.jsxs)(o.Fragment,{children:[(0,o.jsx)(`span`,{className:`text-accent mt-0.5`,children:t}),(0,o.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,o.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,o.jsx)(`span`,{className:`text-[15px] font-semibold`,children:n}),(0,o.jsx)(`span`,{className:e(`rounded-full px-2 py-0.5 text-[11px] font-medium`,s.tone===`ok`&&`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`,s.tone===`warn`&&`bg-amber-500/15 text-amber-700 dark:text-amber-300`,s.tone===`off`&&`bg-surface-2 text-muted`),children:s.text})]}),(0,o.jsx)(`span`,{className:`block text-[12.5px] text-muted truncate`,children:a})]}),u??(l?c?(0,o.jsx)(i,{size:18,className:`text-muted`}):(0,o.jsx)(r,{size:18,className:`text-muted`}):null)]});return(0,o.jsxs)(`section`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm`,children:[l?(0,o.jsx)(`button`,{type:`button`,onClick:l,className:`flex w-full items-start gap-3 px-4 py-3.5 text-left`,children:f}):(0,o.jsx)(`div`,{className:`flex w-full items-start gap-3 px-4 py-3.5`,children:f}),(c||!l&&d)&&d?(0,o.jsx)(`div`,{className:`px-4 pb-4 space-y-3`,children:d}):null]})}var c=`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,l=`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,u=`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`;function d({label:t,hint:n,checked:r,onChange:i,disabled:a}){return(0,o.jsxs)(`label`,{className:`flex items-start gap-3 cursor-pointer`,children:[(0,o.jsxs)(`div`,{className:`flex-1`,children:[(0,o.jsx)(`div`,{className:`text-[14px]`,children:t}),n&&(0,o.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:n})]}),(0,o.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":r,disabled:a,onClick:()=>i(!r),className:e(`relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition disabled:opacity-50`,r?`bg-accent`:`bg-surface-2 border border-border`),children:(0,o.jsx)(`span`,{className:e(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,r?`left-[22px]`:`left-0.5`)})})]})}export{u as a,r as c,l as i,d as n,a as o,c as r,i as s,s as t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/GoalsScreen-BsVUgvTc.js b/nanomuse/server/static/assets/GoalsScreen-yUUdUKV2.js similarity index 99% rename from nanomuse/server/static/assets/GoalsScreen-BsVUgvTc.js rename to nanomuse/server/static/assets/GoalsScreen-yUUdUKV2.js index 284a9446..823d01b6 100644 --- a/nanomuse/server/static/assets/GoalsScreen-BsVUgvTc.js +++ b/nanomuse/server/static/assets/GoalsScreen-yUUdUKV2.js @@ -1,2 +1,2 @@ -import{A as e,E as t,J as n,K as r,N as i,Q as a,Y as o,b as s,f as c,r as l,x as u}from"./MuseHeader-DHtaUVEa.js";import{t as d}from"./StarNudge-viN7_tiM.js";import{B as f,C as p,Ct as m,U as h,V as g,X as _,Z as v,_ as y,at as b,b as x,bt as S,d as C,f as w,g as T,h as E,m as D,p as O,v as k,x as A,y as j}from"./index-CrH9-E2E.js";var M=n(`bell-ring`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M22 8c0-2.3-.8-4.3-2-6`,key:`5bb3ad`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}],[`path`,{d:`M4 2C2.8 3.7 2 5.7 2 8`,key:`tap9e0`}]]),N=n(`circle-check`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),P=n(`circle-dashed`,[[`path`,{d:`M10.1 2.182a10 10 0 0 1 3.8 0`,key:`5ilxe3`}],[`path`,{d:`M13.9 21.818a10 10 0 0 1-3.8 0`,key:`11zvb9`}],[`path`,{d:`M17.609 3.721a10 10 0 0 1 2.69 2.7`,key:`1iw5b2`}],[`path`,{d:`M2.182 13.9a10 10 0 0 1 0-3.8`,key:`c0bmvh`}],[`path`,{d:`M20.279 17.609a10 10 0 0 1-2.7 2.69`,key:`1ruxm7`}],[`path`,{d:`M21.818 10.1a10 10 0 0 1 0 3.8`,key:`qkgqxc`}],[`path`,{d:`M3.721 6.391a10 10 0 0 1 2.7-2.69`,key:`1mcia2`}],[`path`,{d:`M6.391 20.279a10 10 0 0 1-2.69-2.7`,key:`1fvljs`}]]),F=n(`circle-slash`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`9`,x2:`15`,y1:`15`,y2:`9`,key:`1dfufj`}]]),I=n(`circle`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}]]),L=n(`ellipsis-vertical`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`12`,cy:`5`,r:`1`,key:`gxeob9`}],[`circle`,{cx:`12`,cy:`19`,r:`1`,key:`lyex9k`}]]),R=n(`octagon-alert`,[[`path`,{d:`M12 16h.01`,key:`1drbdi`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z`,key:`1fd625`}]]),z=n(`pause`,[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]),B=n(`square-check`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),V=a(o(),1),H=s(),U={pending:e=>(0,H.jsx)(I,{...e}),in_progress:e=>(0,H.jsx)(P,{...e,className:t(e.className,`text-accent`)}),done:e=>(0,H.jsx)(N,{...e,className:t(e.className,`text-emerald-500`)}),blocked:e=>(0,H.jsx)(R,{...e,className:t(e.className,`text-rose-500`)}),skipped:e=>(0,H.jsx)(F,{...e,className:t(e.className,`text-muted`)})},W={pending:`done`,in_progress:`done`,done:`pending`,blocked:`pending`,skipped:`pending`};function G(){let{state:e,refreshGoals:t,send:n,openThread:i,toast:a}=c(),[o,s]=(0,V.useState)(null),[f,m]=(0,V.useState)(!1),[h,g]=(0,V.useState)(null),[v,y]=(0,V.useState)(!1),x=(0,V.useRef)(null),S=e.profile?.name??`nanoMuse`,w=r();(0,V.useEffect)(()=>{t()},[t]);let T=e.goals.filter(e=>e.proposal&&e.status!==`cancelled`),E=[...e.goals.filter(e=>e.status===`active`),...e.goals.filter(e=>e.status===`paused`)],D=e.goals.filter(e=>e.status===`done`||e.status===`cancelled`),k=e.goals.find(e=>e.id===o)??null,A=async e=>{try{await u.advanceGoal(e.id),a(w(`{name} is working on “{title}” in the main chat`,{name:S,title:e.title})),i(`main`)}catch(e){a(e.message)}},[j,M]=(0,V.useState)(!1),N=async e=>{try{let n=e.status!==`done`;await u.patchGoal(e.id,{status:n?`done`:`active`}),n&&M(!0),t()}catch(e){a(e.message)}},P=e=>{g(null),n(`main`,w(`I'd like to create a {category} goal. Ask me a few short questions, one at a time (what exactly I want, why and by when, how often to check in), then create it with concrete steps using the goals tool.`,{category:w(e.label)})).catch(e=>a(e.message||w(`Could not send`))),i(`main`)};return(0,H.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,H.jsx)(C,{title:w(`Goals`),children:(0,H.jsx)(l,{small:!0,onClick:()=>m(!0),label:w(`New goal`),children:(0,H.jsx)(_,{size:22})})}),(0,H.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-6`,children:[T.length>0&&(0,H.jsxs)(`section`,{className:`px-4 pb-2`,children:[(0,H.jsx)(`div`,{className:`px-1 mb-2 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:w(`{name} suggests`,{name:S})}),(0,H.jsx)(`div`,{className:`space-y-2.5`,children:T.map(e=>(0,H.jsx)(Y,{goal:e,onChanged:t,onOpen:()=>s(e.id)},e.id))})]}),(0,H.jsx)(d,{moment:`goal_done`,due:j,className:`mx-4 mb-3`}),(0,H.jsx)(K,{label:w(`Tracking`),dot:!0,onAdd:()=>x.current?.scrollIntoView({behavior:`smooth`,block:`start`})}),E.length===0?(0,H.jsx)(`p`,{className:`px-5 pb-3 text-[13px] text-muted`,children:w(`Nothing tracked yet`)}):E.map(e=>(0,H.jsx)(J,{goal:e,onOpen:()=>s(e.id),onToggle:()=>void N(e)},e.id)),D.length>0&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsx)(`div`,{className:`h-1.5`}),(0,H.jsx)(K,{label:w(`Finished`)}),(v?D:D.slice(0,2)).map(e=>(0,H.jsx)(J,{goal:e,onOpen:()=>s(e.id),onToggle:()=>void N(e)},e.id)),!v&&D.length>2&&(0,H.jsx)(`button`,{type:`button`,onClick:()=>y(!0),className:`px-5 py-2 text-[13.5px] font-medium text-accent`,children:w(`Show {n} more`,{n:D.length-2})})]}),(0,H.jsxs)(`div`,{ref:x,className:`mt-2.5 border-t border-border/70 px-5 pt-3.5 pb-1.5`,children:[(0,H.jsx)(`h3`,{className:`text-[17px] font-semibold`,children:w(`Create a goal`)}),(0,H.jsx)(`p`,{className:`mt-1 text-[13px] leading-[18px] text-muted`,children:w(`Pick a category and tell me the goal you have in mind. I'll shape a plan with you and keep improving it as you go.`)})]}),O.map(e=>(0,H.jsxs)(`button`,{type:`button`,onClick:()=>g(e),className:`flex h-[50px] w-full items-center gap-3.5 pl-5 pr-2 text-left active:bg-surface-2/70`,children:[(0,H.jsx)(`span`,{className:`text-fg`,children:e.icon({size:22})}),(0,H.jsx)(`span`,{className:`flex-1 text-[15px] font-medium`,children:w(e.label)}),(0,H.jsx)(`span`,{className:`flex h-10 w-10 items-center justify-center text-muted`,children:(0,H.jsx)(_,{size:22})})]},e.id))]}),(0,H.jsx)(Q,{goal:k,onClose:()=>s(null),onAdvance:A,onChanged:t}),(0,H.jsx)($,{open:f,onClose:()=>m(!1),onCreated:()=>{m(!1),t()}}),(0,H.jsx)(p,{open:h!==null,onClose:()=>g(null),children:h&&(0,H.jsxs)(`div`,{className:`px-1 pb-2`,children:[(0,H.jsx)(`h3`,{className:`text-[20px] font-bold`,children:w(`Create a {category} goal`,{category:w(h.label)})}),(0,H.jsx)(`p`,{className:`mt-3.5 text-[15px] leading-[22px]`,children:w(`First, we'll shape the goal together in the chat. I'll ask a few questions so I understand exactly what you're after.`)}),(0,H.jsx)(`p`,{className:`mt-3.5 text-[15px] leading-[22px]`,children:w(`Once it's set, I'll track your progress here.`)}),(0,H.jsxs)(`button`,{type:`button`,onClick:()=>P(h),className:`mt-6 flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,H.jsx)(b,{size:18}),` `,w(`Start`)]})]})})]})}function K({label:e,dot:n=!1,onAdd:r}){return(0,H.jsxs)(`div`,{className:`flex items-center pl-5 pr-2 pt-1`,children:[n&&(0,H.jsx)(`span`,{className:`mr-2.5 h-2 w-2 rounded-full bg-emerald-500`}),(0,H.jsx)(`span`,{className:t(`flex-1 text-[15px] font-semibold`,n&&`text-emerald-600 dark:text-emerald-400`),children:e}),r?(0,H.jsx)(`button`,{type:`button`,onClick:r,"aria-label":e,className:`flex h-10 w-10 items-center justify-center text-muted`,children:(0,H.jsx)(_,{size:22})}):(0,H.jsx)(`span`,{className:`h-10`})]})}function q({id:e,size:n=`sm`}){let i=r(),a=E(e);return a?(0,H.jsxs)(`span`,{className:t(`inline-flex items-center gap-1 rounded-full font-medium`,a.tone,n===`sm`?`px-2 py-0.5 text-[11.5px]`:`px-2.5 py-1 text-[12.5px]`),children:[a.icon({size:n===`sm`?11:13}),` `,i(a.label)]}):null}function J({goal:n,onOpen:i,onToggle:a}){let o=r(),s=n.status===`done`,c=n.progress.total?Math.round(n.progress.done/n.progress.total*100):0,l=y(n.due,n.overdue),u=n.next_step?o(`Next: {step}`,{step:n.next_step}):n.description||``,d=n.status===`paused`?o(`Paused`):n.status===`cancelled`?o(`Cancelled`):s?o(`Done`):n.check_in?T(n.check_in):o(`Updated {when}`,{when:e(n.updated_at)});return(0,H.jsxs)(`div`,{className:`flex items-start pl-[18px] pr-1 pt-2 pb-2.5`,children:[(0,H.jsx)(`button`,{type:`button`,onClick:a,"aria-label":o(s?`Mark as not done`:`Mark as done`),className:`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center text-muted`,children:s?(0,H.jsx)(B,{size:22}):(0,H.jsx)(g,{size:22})}),(0,H.jsxs)(`button`,{type:`button`,onClick:i,className:`ml-2.5 min-w-0 flex-1 pt-[3px] text-left`,children:[(0,H.jsx)(`span`,{className:t(`line-clamp-2 block text-[15px] font-semibold leading-snug`,s&&`text-muted`),children:n.title}),u&&(0,H.jsx)(`span`,{className:`mt-0.5 line-clamp-2 block text-[13px] leading-[18px] text-muted`,children:u}),(0,H.jsxs)(`span`,{className:`mt-0.5 flex items-center gap-2 text-[12px] text-muted/80`,children:[(0,H.jsxs)(`span`,{className:`truncate`,children:[d,l&&n.status===`active`?` · ${l}`:``]}),c>0&&!s&&(0,H.jsxs)(`span`,{className:`shrink-0 font-medium text-emerald-600 dark:text-emerald-400`,children:[c,`%`]})]})]}),(0,H.jsx)(`button`,{type:`button`,onClick:i,"aria-label":o(`More`),className:`flex h-10 w-10 shrink-0 items-center justify-center text-muted`,children:(0,H.jsx)(L,{size:20})})]})}function Y({goal:e,onChanged:t,onOpen:n}){let{toast:i,state:a}=c(),[o,s]=(0,V.useState)(!1),l=a.profile?.name??`nanoMuse`,d=r(),f=e.proposal;if(!f)return null;let p=async n=>{s(!0);try{n?await u.acceptProposal(e.id):await u.dismissProposal(e.id),i(d(n?`Plan updated`:`Kept your plan`)),t()}catch(e){i(e.message)}finally{s(!1)}},m=e.steps.filter(e=>e.status===`done`||e.status===`skipped`).length;return(0,H.jsxs)(`div`,{className:`rounded-3xl border border-accent/30 bg-accent/5 px-4 py-3.5`,children:[(0,H.jsxs)(`button`,{type:`button`,onClick:n,className:`w-full text-left`,children:[(0,H.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] font-semibold uppercase tracking-wide text-accent`,children:[(0,H.jsx)(h,{size:13}),` `,d(`Adjust the plan for “{title}”?`,{title:e.title})]}),(0,H.jsx)(`p`,{className:`mt-1.5 text-[14px] leading-snug`,children:f.reason}),(0,H.jsxs)(`div`,{className:`mt-2 text-[12px] text-muted`,children:[m>0&&(0,H.jsxs)(`span`,{children:[m===1?d(`Keeps the 1 step already done.`):d(`Keeps the {n} steps already done.`,{n:m}),` `]}),d(`New remaining steps:`)]}),(0,H.jsx)(`ol`,{className:`mt-1 space-y-0.5 text-[13.5px] list-decimal pl-5`,children:f.steps.map((e,t)=>(0,H.jsx)(`li`,{children:e},t))})]}),(0,H.jsxs)(`div`,{className:`mt-3 flex gap-2`,children:[(0,H.jsx)(`button`,{type:`button`,disabled:o,onClick:()=>void p(!1),className:`flex-1 rounded-2xl border border-border py-2 text-[13.5px] font-medium disabled:opacity-50`,children:d(`Keep my plan`)}),(0,H.jsx)(`button`,{type:`button`,disabled:o,onClick:()=>void p(!0),className:`flex-1 rounded-2xl bg-accent text-accent-fg py-2 text-[13.5px] font-medium disabled:opacity-50`,children:d(`Use {name}'s plan`,{name:l})})]})]})}function X({value:e,onChange:n}){let i=r();return(0,H.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:O.map(r=>(0,H.jsxs)(`button`,{type:`button`,onClick:()=>n(e===r.id?``:r.id),className:t(`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px] font-medium transition border`,e===r.id?t(r.tone,`border-transparent ring-2 ring-accent/40`):`border-border text-muted`),children:[r.icon({size:12}),` `,i(r.label)]},r.id))})}function Z({value:e,onChange:n}){let i=r(),{cadence:a,anchor:o,time:s}=x(e),c=(e,t,r)=>n(k(e,t,r));return(0,H.jsxs)(`div`,{className:`space-y-2`,children:[(0,H.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:w.map(e=>(0,H.jsx)(`button`,{type:`button`,onClick:()=>c(e.id,e.id===`weekly`?o||`mon`:e.id===`monthly`?o||`1`:``,s),className:t(`rounded-full px-2.5 py-1 text-[12.5px] font-medium border transition`,a===e.id?`bg-accent text-accent-fg border-transparent`:`border-border text-muted`),children:i(e.label)},e.id))}),a&&(0,H.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px]`,children:[a===`weekly`&&(0,H.jsx)(`select`,{value:o||`mon`,onChange:e=>c(a,e.target.value,s),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`,children:D.map(e=>(0,H.jsx)(`option`,{value:e,children:A(e)},e))}),a===`monthly`&&(0,H.jsx)(`select`,{value:o||`1`,onChange:e=>c(a,e.target.value,s),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`,children:Array.from({length:28},(e,t)=>String(t+1)).map(e=>(0,H.jsx)(`option`,{value:e,children:j(Number(e))},e))}),(0,H.jsx)(`span`,{className:`text-muted`,children:i(`at`)}),(0,H.jsx)(`input`,{type:`time`,value:s,onChange:e=>c(a,o,e.target.value||`09:00`),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`})]})]})}function Q({goal:n,onClose:a,onAdvance:o,onChanged:s}){let{toast:l,send:d,openThread:h,state:g}=c(),[b,x]=(0,V.useState)(``),[C,w]=(0,V.useState)(!1),E=g.profile?.name??`nanoMuse`,D=r(),O=async e=>{if(n)try{await u.patchGoal(n.id,e),s()}catch(e){l(e.message)}},k=async()=>{if(n&&b.trim())try{await u.addStep(n.id,b.trim()),x(``),s()}catch(e){l(e.message)}},A=async()=>{if(n)try{await u.checkInGoal(n.id),l(D(`{name} will check in with you in the main chat`,{name:E})),h(`main`),a()}catch(e){l(e.message)}},j=async()=>{if(n&&window.confirm(D(`Delete “{title}”?`,{title:n.title})))try{await u.deleteGoal(n.id),s(),a()}catch(e){l(e.message)}},N=n?y(n.due,n.overdue):``;return(0,H.jsx)(p,{open:!!n,onClose:a,title:n?.title,footer:n&&(0,H.jsxs)(`div`,{className:`flex gap-2`,children:[n.status===`active`?(0,H.jsxs)(`button`,{type:`button`,onClick:()=>void O({status:`paused`}),className:`flex-1 rounded-2xl border border-border py-2.5 font-medium flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(z,{size:16}),` `,D(`Pause`)]}):n.status===`paused`?(0,H.jsxs)(`button`,{type:`button`,onClick:()=>void O({status:`active`}),className:`flex-1 rounded-2xl border border-border py-2.5 font-medium flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(v,{size:16}),` `,D(`Resume`)]}):null,n.status===`active`&&(0,H.jsxs)(`button`,{type:`button`,onClick:()=>o(n),className:`flex-1 rounded-2xl bg-accent text-accent-fg py-2.5 font-medium flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(v,{size:16}),` `,D(`Work on it now`)]}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>void j(),"aria-label":D(`Delete goal`),className:`rounded-2xl border border-border px-3 text-muted hover:text-rose-500`,children:(0,H.jsx)(f,{size:18})})]}),children:n&&(0,H.jsxs)(`div`,{className:`space-y-4`,children:[n.proposal&&(0,H.jsx)(Y,{goal:n,onChanged:s,onOpen:()=>void 0}),n.description&&(0,H.jsx)(`p`,{className:`text-[14px] text-muted leading-snug`,children:n.description}),(0,H.jsx)(`div`,{className:`rounded-2xl bg-surface-2/60 px-3.5 py-3`,children:C?(0,H.jsxs)(`div`,{className:`space-y-3`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Area of life`)}),(0,H.jsx)(X,{value:n.category,onChange:e=>void O({category:e})})]}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Target date`)}),(0,H.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,H.jsx)(`input`,{type:`date`,value:n.due,onChange:e=>void O({due:e.target.value}),className:`rounded-xl bg-surface px-2.5 py-1.5 text-[13.5px] outline-none`}),n.due&&(0,H.jsx)(`button`,{type:`button`,onClick:()=>void O({due:``}),className:`text-[12.5px] text-muted`,children:D(`Clear`)})]})]}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Reminders from {name}`,{name:E})}),(0,H.jsx)(Z,{value:n.check_in,onChange:e=>void O({check_in:e})})]}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>w(!1),className:`text-[12.5px] text-accent font-medium`,children:D(`Done`)})]}):(0,H.jsxs)(`button`,{type:`button`,onClick:()=>w(!0),className:`w-full text-left`,children:[(0,H.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5 text-[12.5px]`,children:[n.category?(0,H.jsx)(q,{id:n.category,size:`md`}):(0,H.jsx)(`span`,{className:`rounded-full border border-dashed border-border px-2.5 py-1 text-muted`,children:D(`No category`)}),(0,H.jsxs)(`span`,{className:t(`inline-flex items-center gap-1 rounded-full px-2.5 py-1`,n.overdue?`bg-rose-500/12 text-rose-600 dark:text-rose-300 font-medium`:`bg-surface text-muted`),children:[(0,H.jsx)(S,{size:13}),` `,N||D(`No target date`)]}),(0,H.jsxs)(`span`,{className:`inline-flex items-center gap-1 rounded-full bg-surface px-2.5 py-1 text-muted`,children:[(0,H.jsx)(m,{size:13}),` `,n.check_in?T(n.check_in):D(`No reminders`)]})]}),n.next_check_in&&n.status===`active`&&(0,H.jsxs)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:[D(`Next check-in {when}`,{when:e(n.next_check_in)}),` · `,i(n.next_check_in)]}),(0,H.jsx)(`div`,{className:`mt-1.5 text-[12px] text-accent font-medium`,children:D(`Edit`)})]})}),(0,H.jsxs)(`div`,{children:[(0,H.jsxs)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-2`,children:[D(`Plan`),` · `,n.progress.done,`/`,n.progress.total]}),(0,H.jsx)(`ul`,{className:`space-y-1`,children:n.steps.map(e=>{let n=U[e.status];return(0,H.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl px-2 py-1.5 hover:bg-surface-2/60`,children:[(0,H.jsx)(`button`,{type:`button`,"aria-label":D(`Mark step {n} {status}`,{n:e.idx,status:D(W[e.status])}),onClick:()=>void O({step_index:e.idx,step_status:W[e.status]}),className:`mt-0.5 text-muted`,children:(0,H.jsx)(n,{size:20})}),(0,H.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,H.jsxs)(`div`,{className:t(`text-[14.5px] leading-snug`,(e.status===`done`||e.status===`skipped`)&&`line-through text-muted`),children:[e.idx,`. `,e.title]}),e.note&&(0,H.jsx)(`div`,{className:`text-[12.5px] text-muted mt-0.5 whitespace-pre-wrap`,children:e.note})]})]},e.idx)})}),(0,H.jsxs)(`div`,{className:`mt-2 flex gap-2`,children:[(0,H.jsx)(`input`,{value:b,onChange:e=>x(e.target.value),onKeyDown:e=>e.key===`Enter`&&void k(),placeholder:D(`Add a step`),className:`flex-1 rounded-2xl bg-surface-2 px-3.5 py-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>void k(),className:`rounded-2xl bg-surface-2 px-3 text-accent`,"aria-label":D(`Add step`),children:(0,H.jsx)(_,{size:18})})]})]}),n.notes&&(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Notes from {name}`,{name:E})}),(0,H.jsx)(`pre`,{className:`whitespace-pre-wrap break-words text-[13px] leading-snug text-muted font-sans`,children:n.notes})]}),(0,H.jsxs)(`div`,{className:`flex flex-wrap gap-x-4 gap-y-1.5`,children:[(0,H.jsx)(`button`,{type:`button`,onClick:()=>{d(`main`,D(`About my goal “{title}” ({id}): what's the status, and what should we do next?`,{title:n.title,id:n.id})).catch(e=>l(e.message||D(`Could not send`))),h(`main`),a()},className:`text-[13.5px] text-accent font-medium`,children:D(`Discuss this goal in chat →`)}),n.status===`active`&&(0,H.jsxs)(`button`,{type:`button`,onClick:()=>void A(),className:`text-[13.5px] text-accent font-medium inline-flex items-center gap-1`,children:[(0,H.jsx)(M,{size:14}),` `,D(`Check in with me now`)]})]})]})})}function $({open:e,onClose:t,onCreated:n}){let{toast:i,send:a,openThread:o,state:s}=c(),[l,d]=(0,V.useState)(``),[f,m]=(0,V.useState)(``),[g,_]=(0,V.useState)(``),[v,y]=(0,V.useState)(``),[b,x]=(0,V.useState)(``),[S,C]=(0,V.useState)(``),[w,T]=(0,V.useState)(!1),E=s.profile?.name??`nanoMuse`,D=r(),O=()=>{d(``),m(``),_(``),y(``),x(``),C(``)},k=async()=>{if(l.trim()){T(!0);try{await u.createGoal({title:l.trim(),description:f.trim(),steps:g.split(` +import{A as e,E as t,J as n,K as r,N as i,Q as a,Y as o,b as s,f as c,r as l,x as u}from"./MuseHeader-RC--uR8o.js";import{t as d}from"./StarNudge-Df6LQyo9.js";import{B as f,C as p,Ct as m,U as h,V as g,X as _,Z as v,_ as y,at as b,b as x,bt as S,d as C,f as w,g as T,h as E,m as D,p as O,v as k,x as A,y as j}from"./index-tYqPtwPX.js";var M=n(`bell-ring`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M22 8c0-2.3-.8-4.3-2-6`,key:`5bb3ad`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}],[`path`,{d:`M4 2C2.8 3.7 2 5.7 2 8`,key:`tap9e0`}]]),N=n(`circle-check`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),P=n(`circle-dashed`,[[`path`,{d:`M10.1 2.182a10 10 0 0 1 3.8 0`,key:`5ilxe3`}],[`path`,{d:`M13.9 21.818a10 10 0 0 1-3.8 0`,key:`11zvb9`}],[`path`,{d:`M17.609 3.721a10 10 0 0 1 2.69 2.7`,key:`1iw5b2`}],[`path`,{d:`M2.182 13.9a10 10 0 0 1 0-3.8`,key:`c0bmvh`}],[`path`,{d:`M20.279 17.609a10 10 0 0 1-2.7 2.69`,key:`1ruxm7`}],[`path`,{d:`M21.818 10.1a10 10 0 0 1 0 3.8`,key:`qkgqxc`}],[`path`,{d:`M3.721 6.391a10 10 0 0 1 2.7-2.69`,key:`1mcia2`}],[`path`,{d:`M6.391 20.279a10 10 0 0 1-2.69-2.7`,key:`1fvljs`}]]),F=n(`circle-slash`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`line`,{x1:`9`,x2:`15`,y1:`15`,y2:`9`,key:`1dfufj`}]]),I=n(`circle`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}]]),L=n(`ellipsis-vertical`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`12`,cy:`5`,r:`1`,key:`gxeob9`}],[`circle`,{cx:`12`,cy:`19`,r:`1`,key:`lyex9k`}]]),R=n(`octagon-alert`,[[`path`,{d:`M12 16h.01`,key:`1drbdi`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M15.312 2a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586l-4.688-4.688A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2z`,key:`1fd625`}]]),z=n(`pause`,[[`rect`,{x:`14`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`kaeet6`}],[`rect`,{x:`5`,y:`3`,width:`5`,height:`18`,rx:`1`,key:`1wsw3u`}]]),B=n(`square-check`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),V=a(o(),1),H=s(),U={pending:e=>(0,H.jsx)(I,{...e}),in_progress:e=>(0,H.jsx)(P,{...e,className:t(e.className,`text-accent`)}),done:e=>(0,H.jsx)(N,{...e,className:t(e.className,`text-emerald-500`)}),blocked:e=>(0,H.jsx)(R,{...e,className:t(e.className,`text-rose-500`)}),skipped:e=>(0,H.jsx)(F,{...e,className:t(e.className,`text-muted`)})},W={pending:`done`,in_progress:`done`,done:`pending`,blocked:`pending`,skipped:`pending`};function G(){let{state:e,refreshGoals:t,send:n,openThread:i,toast:a}=c(),[o,s]=(0,V.useState)(null),[f,m]=(0,V.useState)(!1),[h,g]=(0,V.useState)(null),[v,y]=(0,V.useState)(!1),x=(0,V.useRef)(null),S=e.profile?.name??`nanoMuse`,w=r();(0,V.useEffect)(()=>{t()},[t]);let T=e.goals.filter(e=>e.proposal&&e.status!==`cancelled`),E=[...e.goals.filter(e=>e.status===`active`),...e.goals.filter(e=>e.status===`paused`)],D=e.goals.filter(e=>e.status===`done`||e.status===`cancelled`),k=e.goals.find(e=>e.id===o)??null,A=async e=>{try{await u.advanceGoal(e.id),a(w(`{name} is working on “{title}” in the main chat`,{name:S,title:e.title})),i(`main`)}catch(e){a(e.message)}},[j,M]=(0,V.useState)(!1),N=async e=>{try{let n=e.status!==`done`;await u.patchGoal(e.id,{status:n?`done`:`active`}),n&&M(!0),t()}catch(e){a(e.message)}},P=e=>{g(null),n(`main`,w(`I'd like to create a {category} goal. Ask me a few short questions, one at a time (what exactly I want, why and by when, how often to check in), then create it with concrete steps using the goals tool.`,{category:w(e.label)})).catch(e=>a(e.message||w(`Could not send`))),i(`main`)};return(0,H.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,H.jsx)(C,{title:w(`Goals`),children:(0,H.jsx)(l,{small:!0,onClick:()=>m(!0),label:w(`New goal`),children:(0,H.jsx)(_,{size:22})})}),(0,H.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-6`,children:[T.length>0&&(0,H.jsxs)(`section`,{className:`px-4 pb-2`,children:[(0,H.jsx)(`div`,{className:`px-1 mb-2 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:w(`{name} suggests`,{name:S})}),(0,H.jsx)(`div`,{className:`space-y-2.5`,children:T.map(e=>(0,H.jsx)(Y,{goal:e,onChanged:t,onOpen:()=>s(e.id)},e.id))})]}),(0,H.jsx)(d,{moment:`goal_done`,due:j,className:`mx-4 mb-3`}),(0,H.jsx)(K,{label:w(`Tracking`),dot:!0,onAdd:()=>x.current?.scrollIntoView({behavior:`smooth`,block:`start`})}),E.length===0?(0,H.jsx)(`p`,{className:`px-5 pb-3 text-[13px] text-muted`,children:w(`Nothing tracked yet`)}):E.map(e=>(0,H.jsx)(J,{goal:e,onOpen:()=>s(e.id),onToggle:()=>void N(e)},e.id)),D.length>0&&(0,H.jsxs)(H.Fragment,{children:[(0,H.jsx)(`div`,{className:`h-1.5`}),(0,H.jsx)(K,{label:w(`Finished`)}),(v?D:D.slice(0,2)).map(e=>(0,H.jsx)(J,{goal:e,onOpen:()=>s(e.id),onToggle:()=>void N(e)},e.id)),!v&&D.length>2&&(0,H.jsx)(`button`,{type:`button`,onClick:()=>y(!0),className:`px-5 py-2 text-[13.5px] font-medium text-accent`,children:w(`Show {n} more`,{n:D.length-2})})]}),(0,H.jsxs)(`div`,{ref:x,className:`mt-2.5 border-t border-border/70 px-5 pt-3.5 pb-1.5`,children:[(0,H.jsx)(`h3`,{className:`text-[17px] font-semibold`,children:w(`Create a goal`)}),(0,H.jsx)(`p`,{className:`mt-1 text-[13px] leading-[18px] text-muted`,children:w(`Pick a category and tell me the goal you have in mind. I'll shape a plan with you and keep improving it as you go.`)})]}),O.map(e=>(0,H.jsxs)(`button`,{type:`button`,onClick:()=>g(e),className:`flex h-[50px] w-full items-center gap-3.5 pl-5 pr-2 text-left active:bg-surface-2/70`,children:[(0,H.jsx)(`span`,{className:`text-fg`,children:e.icon({size:22})}),(0,H.jsx)(`span`,{className:`flex-1 text-[15px] font-medium`,children:w(e.label)}),(0,H.jsx)(`span`,{className:`flex h-10 w-10 items-center justify-center text-muted`,children:(0,H.jsx)(_,{size:22})})]},e.id))]}),(0,H.jsx)(Q,{goal:k,onClose:()=>s(null),onAdvance:A,onChanged:t}),(0,H.jsx)($,{open:f,onClose:()=>m(!1),onCreated:()=>{m(!1),t()}}),(0,H.jsx)(p,{open:h!==null,onClose:()=>g(null),children:h&&(0,H.jsxs)(`div`,{className:`px-1 pb-2`,children:[(0,H.jsx)(`h3`,{className:`text-[20px] font-bold`,children:w(`Create a {category} goal`,{category:w(h.label)})}),(0,H.jsx)(`p`,{className:`mt-3.5 text-[15px] leading-[22px]`,children:w(`First, we'll shape the goal together in the chat. I'll ask a few questions so I understand exactly what you're after.`)}),(0,H.jsx)(`p`,{className:`mt-3.5 text-[15px] leading-[22px]`,children:w(`Once it's set, I'll track your progress here.`)}),(0,H.jsxs)(`button`,{type:`button`,onClick:()=>P(h),className:`mt-6 flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,H.jsx)(b,{size:18}),` `,w(`Start`)]})]})})]})}function K({label:e,dot:n=!1,onAdd:r}){return(0,H.jsxs)(`div`,{className:`flex items-center pl-5 pr-2 pt-1`,children:[n&&(0,H.jsx)(`span`,{className:`mr-2.5 h-2 w-2 rounded-full bg-emerald-500`}),(0,H.jsx)(`span`,{className:t(`flex-1 text-[15px] font-semibold`,n&&`text-emerald-600 dark:text-emerald-400`),children:e}),r?(0,H.jsx)(`button`,{type:`button`,onClick:r,"aria-label":e,className:`flex h-10 w-10 items-center justify-center text-muted`,children:(0,H.jsx)(_,{size:22})}):(0,H.jsx)(`span`,{className:`h-10`})]})}function q({id:e,size:n=`sm`}){let i=r(),a=E(e);return a?(0,H.jsxs)(`span`,{className:t(`inline-flex items-center gap-1 rounded-full font-medium`,a.tone,n===`sm`?`px-2 py-0.5 text-[11.5px]`:`px-2.5 py-1 text-[12.5px]`),children:[a.icon({size:n===`sm`?11:13}),` `,i(a.label)]}):null}function J({goal:n,onOpen:i,onToggle:a}){let o=r(),s=n.status===`done`,c=n.progress.total?Math.round(n.progress.done/n.progress.total*100):0,l=y(n.due,n.overdue),u=n.next_step?o(`Next: {step}`,{step:n.next_step}):n.description||``,d=n.status===`paused`?o(`Paused`):n.status===`cancelled`?o(`Cancelled`):s?o(`Done`):n.check_in?T(n.check_in):o(`Updated {when}`,{when:e(n.updated_at)});return(0,H.jsxs)(`div`,{className:`flex items-start pl-[18px] pr-1 pt-2 pb-2.5`,children:[(0,H.jsx)(`button`,{type:`button`,onClick:a,"aria-label":o(s?`Mark as not done`:`Mark as done`),className:`mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center text-muted`,children:s?(0,H.jsx)(B,{size:22}):(0,H.jsx)(g,{size:22})}),(0,H.jsxs)(`button`,{type:`button`,onClick:i,className:`ml-2.5 min-w-0 flex-1 pt-[3px] text-left`,children:[(0,H.jsx)(`span`,{className:t(`line-clamp-2 block text-[15px] font-semibold leading-snug`,s&&`text-muted`),children:n.title}),u&&(0,H.jsx)(`span`,{className:`mt-0.5 line-clamp-2 block text-[13px] leading-[18px] text-muted`,children:u}),(0,H.jsxs)(`span`,{className:`mt-0.5 flex items-center gap-2 text-[12px] text-muted/80`,children:[(0,H.jsxs)(`span`,{className:`truncate`,children:[d,l&&n.status===`active`?` · ${l}`:``]}),c>0&&!s&&(0,H.jsxs)(`span`,{className:`shrink-0 font-medium text-emerald-600 dark:text-emerald-400`,children:[c,`%`]})]})]}),(0,H.jsx)(`button`,{type:`button`,onClick:i,"aria-label":o(`More`),className:`flex h-10 w-10 shrink-0 items-center justify-center text-muted`,children:(0,H.jsx)(L,{size:20})})]})}function Y({goal:e,onChanged:t,onOpen:n}){let{toast:i,state:a}=c(),[o,s]=(0,V.useState)(!1),l=a.profile?.name??`nanoMuse`,d=r(),f=e.proposal;if(!f)return null;let p=async n=>{s(!0);try{n?await u.acceptProposal(e.id):await u.dismissProposal(e.id),i(d(n?`Plan updated`:`Kept your plan`)),t()}catch(e){i(e.message)}finally{s(!1)}},m=e.steps.filter(e=>e.status===`done`||e.status===`skipped`).length;return(0,H.jsxs)(`div`,{className:`rounded-3xl border border-accent/30 bg-accent/5 px-4 py-3.5`,children:[(0,H.jsxs)(`button`,{type:`button`,onClick:n,className:`w-full text-left`,children:[(0,H.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] font-semibold uppercase tracking-wide text-accent`,children:[(0,H.jsx)(h,{size:13}),` `,d(`Adjust the plan for “{title}”?`,{title:e.title})]}),(0,H.jsx)(`p`,{className:`mt-1.5 text-[14px] leading-snug`,children:f.reason}),(0,H.jsxs)(`div`,{className:`mt-2 text-[12px] text-muted`,children:[m>0&&(0,H.jsxs)(`span`,{children:[m===1?d(`Keeps the 1 step already done.`):d(`Keeps the {n} steps already done.`,{n:m}),` `]}),d(`New remaining steps:`)]}),(0,H.jsx)(`ol`,{className:`mt-1 space-y-0.5 text-[13.5px] list-decimal pl-5`,children:f.steps.map((e,t)=>(0,H.jsx)(`li`,{children:e},t))})]}),(0,H.jsxs)(`div`,{className:`mt-3 flex gap-2`,children:[(0,H.jsx)(`button`,{type:`button`,disabled:o,onClick:()=>void p(!1),className:`flex-1 rounded-2xl border border-border py-2 text-[13.5px] font-medium disabled:opacity-50`,children:d(`Keep my plan`)}),(0,H.jsx)(`button`,{type:`button`,disabled:o,onClick:()=>void p(!0),className:`flex-1 rounded-2xl bg-accent text-accent-fg py-2 text-[13.5px] font-medium disabled:opacity-50`,children:d(`Use {name}'s plan`,{name:l})})]})]})}function X({value:e,onChange:n}){let i=r();return(0,H.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:O.map(r=>(0,H.jsxs)(`button`,{type:`button`,onClick:()=>n(e===r.id?``:r.id),className:t(`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[12.5px] font-medium transition border`,e===r.id?t(r.tone,`border-transparent ring-2 ring-accent/40`):`border-border text-muted`),children:[r.icon({size:12}),` `,i(r.label)]},r.id))})}function Z({value:e,onChange:n}){let i=r(),{cadence:a,anchor:o,time:s}=x(e),c=(e,t,r)=>n(k(e,t,r));return(0,H.jsxs)(`div`,{className:`space-y-2`,children:[(0,H.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:w.map(e=>(0,H.jsx)(`button`,{type:`button`,onClick:()=>c(e.id,e.id===`weekly`?o||`mon`:e.id===`monthly`?o||`1`:``,s),className:t(`rounded-full px-2.5 py-1 text-[12.5px] font-medium border transition`,a===e.id?`bg-accent text-accent-fg border-transparent`:`border-border text-muted`),children:i(e.label)},e.id))}),a&&(0,H.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px]`,children:[a===`weekly`&&(0,H.jsx)(`select`,{value:o||`mon`,onChange:e=>c(a,e.target.value,s),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`,children:D.map(e=>(0,H.jsx)(`option`,{value:e,children:A(e)},e))}),a===`monthly`&&(0,H.jsx)(`select`,{value:o||`1`,onChange:e=>c(a,e.target.value,s),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`,children:Array.from({length:28},(e,t)=>String(t+1)).map(e=>(0,H.jsx)(`option`,{value:e,children:j(Number(e))},e))}),(0,H.jsx)(`span`,{className:`text-muted`,children:i(`at`)}),(0,H.jsx)(`input`,{type:`time`,value:s,onChange:e=>c(a,o,e.target.value||`09:00`),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`})]})]})}function Q({goal:n,onClose:a,onAdvance:o,onChanged:s}){let{toast:l,send:d,openThread:h,state:g}=c(),[b,x]=(0,V.useState)(``),[C,w]=(0,V.useState)(!1),E=g.profile?.name??`nanoMuse`,D=r(),O=async e=>{if(n)try{await u.patchGoal(n.id,e),s()}catch(e){l(e.message)}},k=async()=>{if(n&&b.trim())try{await u.addStep(n.id,b.trim()),x(``),s()}catch(e){l(e.message)}},A=async()=>{if(n)try{await u.checkInGoal(n.id),l(D(`{name} will check in with you in the main chat`,{name:E})),h(`main`),a()}catch(e){l(e.message)}},j=async()=>{if(n&&window.confirm(D(`Delete “{title}”?`,{title:n.title})))try{await u.deleteGoal(n.id),s(),a()}catch(e){l(e.message)}},N=n?y(n.due,n.overdue):``;return(0,H.jsx)(p,{open:!!n,onClose:a,title:n?.title,footer:n&&(0,H.jsxs)(`div`,{className:`flex gap-2`,children:[n.status===`active`?(0,H.jsxs)(`button`,{type:`button`,onClick:()=>void O({status:`paused`}),className:`flex-1 rounded-2xl border border-border py-2.5 font-medium flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(z,{size:16}),` `,D(`Pause`)]}):n.status===`paused`?(0,H.jsxs)(`button`,{type:`button`,onClick:()=>void O({status:`active`}),className:`flex-1 rounded-2xl border border-border py-2.5 font-medium flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(v,{size:16}),` `,D(`Resume`)]}):null,n.status===`active`&&(0,H.jsxs)(`button`,{type:`button`,onClick:()=>o(n),className:`flex-1 rounded-2xl bg-accent text-accent-fg py-2.5 font-medium flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(v,{size:16}),` `,D(`Work on it now`)]}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>void j(),"aria-label":D(`Delete goal`),className:`rounded-2xl border border-border px-3 text-muted hover:text-rose-500`,children:(0,H.jsx)(f,{size:18})})]}),children:n&&(0,H.jsxs)(`div`,{className:`space-y-4`,children:[n.proposal&&(0,H.jsx)(Y,{goal:n,onChanged:s,onOpen:()=>void 0}),n.description&&(0,H.jsx)(`p`,{className:`text-[14px] text-muted leading-snug`,children:n.description}),(0,H.jsx)(`div`,{className:`rounded-2xl bg-surface-2/60 px-3.5 py-3`,children:C?(0,H.jsxs)(`div`,{className:`space-y-3`,children:[(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Area of life`)}),(0,H.jsx)(X,{value:n.category,onChange:e=>void O({category:e})})]}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Target date`)}),(0,H.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,H.jsx)(`input`,{type:`date`,value:n.due,onChange:e=>void O({due:e.target.value}),className:`rounded-xl bg-surface px-2.5 py-1.5 text-[13.5px] outline-none`}),n.due&&(0,H.jsx)(`button`,{type:`button`,onClick:()=>void O({due:``}),className:`text-[12.5px] text-muted`,children:D(`Clear`)})]})]}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Reminders from {name}`,{name:E})}),(0,H.jsx)(Z,{value:n.check_in,onChange:e=>void O({check_in:e})})]}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>w(!1),className:`text-[12.5px] text-accent font-medium`,children:D(`Done`)})]}):(0,H.jsxs)(`button`,{type:`button`,onClick:()=>w(!0),className:`w-full text-left`,children:[(0,H.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5 text-[12.5px]`,children:[n.category?(0,H.jsx)(q,{id:n.category,size:`md`}):(0,H.jsx)(`span`,{className:`rounded-full border border-dashed border-border px-2.5 py-1 text-muted`,children:D(`No category`)}),(0,H.jsxs)(`span`,{className:t(`inline-flex items-center gap-1 rounded-full px-2.5 py-1`,n.overdue?`bg-rose-500/12 text-rose-600 dark:text-rose-300 font-medium`:`bg-surface text-muted`),children:[(0,H.jsx)(S,{size:13}),` `,N||D(`No target date`)]}),(0,H.jsxs)(`span`,{className:`inline-flex items-center gap-1 rounded-full bg-surface px-2.5 py-1 text-muted`,children:[(0,H.jsx)(m,{size:13}),` `,n.check_in?T(n.check_in):D(`No reminders`)]})]}),n.next_check_in&&n.status===`active`&&(0,H.jsxs)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:[D(`Next check-in {when}`,{when:e(n.next_check_in)}),` · `,i(n.next_check_in)]}),(0,H.jsx)(`div`,{className:`mt-1.5 text-[12px] text-accent font-medium`,children:D(`Edit`)})]})}),(0,H.jsxs)(`div`,{children:[(0,H.jsxs)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-2`,children:[D(`Plan`),` · `,n.progress.done,`/`,n.progress.total]}),(0,H.jsx)(`ul`,{className:`space-y-1`,children:n.steps.map(e=>{let n=U[e.status];return(0,H.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl px-2 py-1.5 hover:bg-surface-2/60`,children:[(0,H.jsx)(`button`,{type:`button`,"aria-label":D(`Mark step {n} {status}`,{n:e.idx,status:D(W[e.status])}),onClick:()=>void O({step_index:e.idx,step_status:W[e.status]}),className:`mt-0.5 text-muted`,children:(0,H.jsx)(n,{size:20})}),(0,H.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,H.jsxs)(`div`,{className:t(`text-[14.5px] leading-snug`,(e.status===`done`||e.status===`skipped`)&&`line-through text-muted`),children:[e.idx,`. `,e.title]}),e.note&&(0,H.jsx)(`div`,{className:`text-[12.5px] text-muted mt-0.5 whitespace-pre-wrap`,children:e.note})]})]},e.idx)})}),(0,H.jsxs)(`div`,{className:`mt-2 flex gap-2`,children:[(0,H.jsx)(`input`,{value:b,onChange:e=>x(e.target.value),onKeyDown:e=>e.key===`Enter`&&void k(),placeholder:D(`Add a step`),className:`flex-1 rounded-2xl bg-surface-2 px-3.5 py-2 text-[14px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>void k(),className:`rounded-2xl bg-surface-2 px-3 text-accent`,"aria-label":D(`Add step`),children:(0,H.jsx)(_,{size:18})})]})]}),n.notes&&(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Notes from {name}`,{name:E})}),(0,H.jsx)(`pre`,{className:`whitespace-pre-wrap break-words text-[13px] leading-snug text-muted font-sans`,children:n.notes})]}),(0,H.jsxs)(`div`,{className:`flex flex-wrap gap-x-4 gap-y-1.5`,children:[(0,H.jsx)(`button`,{type:`button`,onClick:()=>{d(`main`,D(`About my goal “{title}” ({id}): what's the status, and what should we do next?`,{title:n.title,id:n.id})).catch(e=>l(e.message||D(`Could not send`))),h(`main`),a()},className:`text-[13.5px] text-accent font-medium`,children:D(`Discuss this goal in chat →`)}),n.status===`active`&&(0,H.jsxs)(`button`,{type:`button`,onClick:()=>void A(),className:`text-[13.5px] text-accent font-medium inline-flex items-center gap-1`,children:[(0,H.jsx)(M,{size:14}),` `,D(`Check in with me now`)]})]})]})})}function $({open:e,onClose:t,onCreated:n}){let{toast:i,send:a,openThread:o,state:s}=c(),[l,d]=(0,V.useState)(``),[f,m]=(0,V.useState)(``),[g,_]=(0,V.useState)(``),[v,y]=(0,V.useState)(``),[b,x]=(0,V.useState)(``),[S,C]=(0,V.useState)(``),[w,T]=(0,V.useState)(!1),E=s.profile?.name??`nanoMuse`,D=r(),O=()=>{d(``),m(``),_(``),y(``),x(``),C(``)},k=async()=>{if(l.trim()){T(!0);try{await u.createGoal({title:l.trim(),description:f.trim(),steps:g.split(` `).map(e=>e.trim()).filter(Boolean),category:v,due:b,check_in:S}),O(),n()}catch(e){i(e.message)}finally{T(!1)}}};return(0,H.jsx)(p,{open:e,onClose:t,title:D(`New goal`),footer:(0,H.jsxs)(`div`,{className:`flex gap-2`,children:[(0,H.jsxs)(`button`,{type:`button`,onClick:()=>{if(!l.trim())return;let e=[v?`category: ${v}`:``,b?`target date: ${b}`:``,S?`check_in "${S}"`:``].filter(Boolean);a(`main`,D(`Create a goal for me: "{title}"{description}{extras}. Break it into concrete steps with the goals tool, then tell me the plan.`,{title:l.trim(),description:f.trim()?` (${f.trim()})`:``,extras:e.length?` (${e.join(`, `)})`:``})).catch(e=>i(e.message||D(`Could not send`))),O(),t(),o(`main`)},disabled:!l.trim(),className:`flex-1 rounded-2xl border border-border py-2.5 font-medium disabled:opacity-50 flex items-center justify-center gap-1.5`,children:[(0,H.jsx)(h,{size:16}),` `,D(`Let {name} plan it`,{name:E})]}),(0,H.jsx)(`button`,{type:`button`,onClick:()=>void k(),disabled:!l.trim()||w,className:`flex-1 rounded-2xl bg-accent text-accent-fg py-2.5 font-medium disabled:opacity-50`,children:D(`Create`)})]}),children:(0,H.jsxs)(`div`,{className:`space-y-3.5`,children:[(0,H.jsx)(`input`,{value:l,onChange:e=>d(e.target.value),placeholder:D(`What do you want to achieve?`),className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[15px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Area of life`)}),(0,H.jsx)(X,{value:v,onChange:y})]}),(0,H.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted flex-1`,children:D(`Target date`)}),(0,H.jsx)(`input`,{type:`date`,value:b,onChange:e=>x(e.target.value),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 text-[13.5px] outline-none`})]}),(0,H.jsxs)(`div`,{children:[(0,H.jsx)(`div`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted mb-1.5`,children:D(`Reminders from {name}`,{name:E})}),(0,H.jsx)(Z,{value:S,onChange:C})]}),(0,H.jsx)(`textarea`,{value:f,onChange:e=>m(e.target.value),placeholder:D(`Why it matters, constraints (optional)`),rows:2,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14px] outline-none focus:ring-2 focus:ring-accent/40 resize-none`}),(0,H.jsx)(`textarea`,{value:g,onChange:e=>_(e.target.value),placeholder:D(`Steps, one per line (optional, or let {name} plan them)`,{name:E}),rows:3,className:`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14px] outline-none focus:ring-2 focus:ring-accent/40 resize-none`})]})})}export{G as GoalsScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/IdeasScreen-C-S1VdV7.js b/nanomuse/server/static/assets/IdeasScreen-CmecfnoP.js similarity index 99% rename from nanomuse/server/static/assets/IdeasScreen-C-S1VdV7.js rename to nanomuse/server/static/assets/IdeasScreen-CmecfnoP.js index dba8d8ec..41d9a84b 100644 --- a/nanomuse/server/static/assets/IdeasScreen-C-S1VdV7.js +++ b/nanomuse/server/static/assets/IdeasScreen-CmecfnoP.js @@ -1 +1 @@ -import{A as e,J as t,K as n,Q as r,W as i,Y as a,b as o,f as s,q as c,x as l}from"./MuseHeader-DHtaUVEa.js";import{t as u}from"./refresh-cw-DQelymc2.js";import{C as d,at as f,d as p,p as m,vt as h}from"./index-CrH9-E2E.js";var g=t(`flag`,[[`path`,{d:`M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528`,key:`1jaruq`}]]),_=r(a(),1),v={sections:[{id:`travel`,title:`Travel`,ideas:[{id:`train-watch`,emoji:`🚄`,title:`Tell me where you're going and when; I'll watch for seats and lower fares.`,body:`I set up the lookup tools in my sandbox, check trains, classes and availability as often as you like, and tell you the moment something opens up. Booking and paying stay in your hands.`,kind:`GOAL`,category:`other`,prompt:`I want to watch a train route: ask me for the origin, destination, date and acceptable departure window, then set up a goal that checks availability regularly.`},{id:`trip-plan`,emoji:`🗺️`,title:`Name a city and a number of days; I'll plan an itinerary you can follow as is.`,body:`Weather, opening hours and travel time between stops go into a day-by-day plan saved to your Library. Change one sentence and I'll rearrange the rest.`,kind:`CHAT`,prompt:`Help me plan a trip. Ask me where, how many days, with whom, a rough budget and the style I like, then give me a day-by-day itinerary as a file.`},{id:`commute-brief`,emoji:`🚦`,title:`Before you leave each morning, I'll tell you the traffic, the weather and the first thing on your plate.`,body:`A morning routine: I check the weather where you are, the traffic on your commute, glance at your calendar and boil the morning down to three sentences.`,kind:`ROUTINE`,time:`07:30`,prompt:`Morning brief: check today's weather and commute traffic, read my calendar for today, and tell me in at most five sentences the three things that matter most and what to bring.`},{id:`parcel-track`,emoji:`📦`,title:`Send me a tracking number; I'll tell you where the parcel is and when it lands.`,body:`I follow the shipment through the carrier's tracking, tell you about every new scan, and remind you to keep the phone nearby on delivery day.`,kind:`CHAT`,prompt:`Help me track a parcel. Ask me for the tracking number and carrier, check the current status once, then set a routine that checks every morning and reminds me the day before delivery.`}]},{id:`work`,title:`Work`,ideas:[{id:`meeting-prep`,emoji:`📝`,title:`Ten minutes before a meeting, I'll have the background and the questions ready.`,body:`I read the meeting's title and attendees from your calendar, look through what we've discussed and what you've put in the Library, and hand you one page: purpose, talking points, three questions worth asking.`,kind:`ROUTINE`,time:`08:50`,prompt:`Look at the meetings on my calendar today and prepare a one-page brief for each: the purpose, the points I should make, and three questions worth asking.`},{id:`weekly-review`,emoji:`📊`,title:`Every Friday afternoon, I'll review what you did this week and what next week needs.`,body:`From our conversations, your calendar and the notes I keep, I list what got done, what didn't, and three priorities for next week, as a draft you can send.`,kind:`ROUTINE`,time:`17:00`,prompt:`Write my weekly review: from this week's conversations, my calendar and what you remember, list what was completed, what is still open, and three priorities for next week. Save it as a Markdown file.`},{id:`inbox-triage`,emoji:`📬`,title:`Dump a pile of messages or emails on me; I'll sort them and draft the replies.`,body:`Paste text or screenshots. I sort them into 'reply today', 'this week' and 'just so you know', and draft each reply so you only need to edit and send.`,kind:`CHAT`,prompt:`I'm handing you a batch of messages to triage: sort them into 'reply today / this week / just so you know' and draft a reply for each one that needs it. Ask me to paste the content first.`},{id:`doc-digest`,emoji:`📄`,title:`Long documents, PDFs, web pages: I'll read them and give you the three-minute version.`,body:`Drop the file in a shared folder or send me the link. You get the conclusions, the key numbers and the decisions you need to make; ask follow-ups any time.`,kind:`CHAT`,prompt:`I have something long for you to read and summarise: ask me for the file or link, then give me conclusions, key numbers and decisions I need to make, at most five bullets each.`}]},{id:`life`,title:`Everyday`,ideas:[{id:`price-watch`,emoji:`🏷️`,title:`Tell me what you want to buy; the moment it hits your price, you'll know.`,body:`I check the product page on a schedule, keep the price history, and notify you at your target. Ordering and paying are yours; I don't touch them.`,kind:`GOAL`,category:`finance`,prompt:`I want to watch the price of a product: ask me for the link or name, my target price and how often to check, then create a goal that alerts me when it drops.`},{id:`photo-sort`,emoji:`🖼️`,title:`Mount your photos and I'll sort screenshots and pictures by what's in them.`,body:`Once your gallery is mounted as a shared folder I look at each image (receipts, chat screenshots, landscapes, documents), file it into the right folder and note anything important.`,kind:`CHAT`,prompt:`Help me sort my photos: ask me to mount the gallery in Shared Folders first, then sort by content (receipts, screenshots, scenery, documents, other) and list what you did.`},{id:`meal-log`,emoji:`🥗`,title:`Photograph what you eat; I'll estimate the calories and keep the log.`,body:`A photo or a sentence is enough: I estimate nutrition and calories, log it in the day's memory, and give you a summary each evening with one small suggestion for tomorrow.`,kind:`CHAT`,prompt:`From now on, log my meals: whenever I send a photo or a description, estimate calories and nutrition and write it into today's memory. Tell me first how best to send them.`},{id:`subscriptions`,emoji:`🔁`,title:`Send me your bills; I'll list every subscription that charges you each month, and you decide which to stop.`,body:`Give me bill screenshots or statements. I list every recurring charge, its next date and the yearly total; you decide what to cancel and I walk you through each cancellation.`,kind:`CHAT`,prompt:`Audit my subscriptions: ask me for bill screenshots or statements, then list every recurring charge, its next billing date and the yearly total, and flag the ones I may not need.`}]},{id:`learn`,title:`Learning`,ideas:[{id:`daily-words`,emoji:`🔤`,title:`Five new words every morning, a quick quiz every night.`,body:`You pick the language and level; I pick words by theme with examples and a memory hook, quiz you in the evening, and bring back the ones you missed.`,kind:`ROUTINE`,time:`08:00`,prompt:`Daily vocabulary: pick five new words for me (ask which language, level and topics first), each with a definition, one example sentence and a memory hook, plus a review quiz on yesterday's words.`},{id:`study-plan`,emoji:`🎯`,title:`Something you want to learn? I'll break it into weekly chunks and keep you honest.`,body:`Tell me the goal and the hours you can spare; I lay out four to eight weeks, check progress weekly, and adjust the pace so you actually finish.`,kind:`GOAL`,category:`interests`,prompt:`I want to learn something new and stick with it: ask me what, my current level, hours per week and the timeframe, then create a goal with a weekly progress check.`},{id:`explain`,emoji:`💡`,title:`Send me a concept you don't get; I'll explain it with things you already know.`,body:`I ask what you already understand and start from there, then check with two quick questions and leave you a flashcard if you want one.`,kind:`CHAT`,prompt:`There's a concept I don't understand: ask me what it is and what related things I already know, explain it with analogies, then check with two short questions.`},{id:`reading-notes`,emoji:`📚`,title:`Finished a book? I'll turn it into notes you'll actually reread.`,body:`Tell me the highlights, or send the passages you marked. I write structured notes into your Library and remember what stayed with you.`,kind:`CHAT`,prompt:`Help me write notes on a book: ask me the title, the three things that struck me most and the quotes I want to keep, then save structured Markdown notes.`}]},{id:`family`,title:`Family & friends`,ideas:[{id:`birthdays`,emoji:`🎂`,title:`Birthdays and anniversaries: I remind you a week ahead, gift ideas included.`,body:`Give me names and dates and I keep them. A week before each one I remind you, with a few gift directions based on what you've told me about them.`,kind:`CHAT`,prompt:`Remember the birthdays and anniversaries of the people who matter to me: ask for the list and dates, store them in long-term memory, and set a routine that reminds me a week before each.`},{id:`keep-in-touch`,emoji:`💬`,title:`Lost touch with old friends? Once a week I'll suggest who to reach out to, and how to start.`,body:`Give me a list of people you want to stay close to. I rotate through them and write a natural opening line based on what you last talked about.`,kind:`ROUTINE`,time:`19:00`,prompt:`Weekly keep-in-touch: from my list of people I want to stay close to, pick the one I've gone longest without contacting, remind me, and write a natural opening line based on what I noted about our last conversation.`},{id:`family-calendar`,emoji:`📅`,title:`Tell me the family's plans and I'll put them on the calendar and catch clashes.`,body:`Kids' lessons, a parent's appointment, a weekend get-together: one sentence and it's in the calendar; if two things collide, I say so first.`,kind:`CHAT`,prompt:`Help me run the family calendar: I'll tell you plans as they come up; write them into the calendar, confirm each before creating it, and warn me about conflicts.`},{id:`gift-ideas`,emoji:`🎁`,title:`Describe the person and I'll give you gift ideas that aren't the obvious ones.`,body:`Budget, relationship, what they're into lately: three questions, then five directions with one line each on why it fits.`,kind:`CHAT`,prompt:`Help me think of a gift: ask who it's for, my budget, their interests and what's going on in their life, then give me five directions with one line of reasoning each.`}]},{id:`more`,title:`More ideas`,ideas:[{id:`id-renewal`,emoji:`🪪`,title:`Tell me when your documents expire; I'll book the renewal and prep the paperwork list.`,body:`Passport, licence, visa: I note the dates, remind you three months ahead, list the documents and the office, and look up appointment slots where I can.`,kind:`CHAT`,prompt:`Help me track document expiry: ask which documents I have and when they expire, store them in memory, and set a reminder three months ahead of each with the renewal checklist.`},{id:`personality-quiz`,emoji:`❓`,title:`Two small questions a day; after a week I'll tell you what I've noticed about you.`,body:`Light questions, quick answers. I keep them and, once a week, write a short observation about your preferences and rhythm so I understand you better.`,kind:`ROUTINE`,time:`21:00`,prompt:`Two questions a day: ask me two small questions about preferences, habits or how things are going (never repeat one), store the answers in memory; every Sunday add a short observation about my week.`},{id:`event-plan`,emoji:`🎉`,title:`Name the occasion; I'll plan the run of show and draft the invitation.`,body:`Birthday, team outing, family dinner: give me the headcount, budget and date, and you get a timeline, a shopping list and an invitation you can send as is.`,kind:`CHAT`,prompt:`Help me plan an event: ask me the occasion, date, headcount and budget, then give me a timeline, a to-do list and an invitation text I can send.`},{id:`phone-cleanup`,emoji:`🧹`,title:`Let me look through the sandbox and shared folders; I'll clear the junk and organise the rest.`,body:`I list the big files and duplicates, show you the list first and only act when you say so; anything worth keeping goes into the Library.`,kind:`CHAT`,prompt:`Help me tidy the workspace and shared folders: list large files, duplicates and files untouched for a long time, and give me the list. Do not delete anything before I confirm.`}]}]},y={sections:[{id:`travel`,title:`出行`,ideas:[{id:`train-watch`,emoji:`🚄`,title:`告诉我你要去哪、哪天走,我盯着 12306 的余票和低价票。`,body:`我会在沙盒里装好查询工具,按你说的频率查车次、席别和余票,有票或价格合适时马上通知你;订票和付款那一步留给你自己点。`,kind:`GOAL`,category:`other`,prompt:`我想盯一趟火车票:请先问我出发地、目的地、日期和可接受的车次时段,然后为我建一个定期检查余票的目标。`},{id:`trip-plan`,emoji:`🗺️`,title:`说一个城市和几天,我给你排一份能直接照着走的行程。`,body:`我会查天气、开放时间和两点之间的路程,把每天的顺序排顺,写成一份文件放进资源库;你改一句,我就重排。`,kind:`CHAT`,prompt:`帮我规划一次出行。先问我去哪、几天、和谁、预算大概多少、喜欢什么风格,再给我一份按天排好的行程文件。`},{id:`commute-brief`,emoji:`🚦`,title:`每天出门前,我告诉你今天的路况、天气和第一件事。`,body:`一条早间例程:查你所在城市的天气、通勤路线的路况,翻一下日历,把上午的安排浓缩成三句话推给你。`,kind:`ROUTINE`,time:`07:30`,prompt:`早间简报:查询今天的天气和通勤路况,读取我今天的日历安排,用不超过五句话告诉我今天最重要的三件事和出门要带的东西。`},{id:`parcel-track`,emoji:`📦`,title:`把快递单号发我,到哪了、什么时候到,我随时告诉你。`,body:`我会用快递查询接口跟踪包裹,有新的物流节点就更新你;派送当天提醒你留意电话。`,kind:`CHAT`,prompt:`帮我跟踪一个快递。先问我单号和快递公司,然后查一次当前进度,并设一个每天早上检查一次、到派送前一天提醒我的例程。`}]},{id:`work`,title:`工作`,ideas:[{id:`meeting-prep`,emoji:`📝`,title:`开会前十分钟,我把这场会的背景和该问的问题整理好。`,body:`读日历里的会议主题和参会人,翻我们聊过的相关内容和你放进资源库的资料,给你一页纸:目的、要点、三个值得问的问题。`,kind:`ROUTINE`,time:`08:50`,prompt:`查看我今天日历里接下来的会议,为每一场准备一页会前提纲:这场会的目的、需要我发言的要点、三个值得提出的问题。`},{id:`weekly-review`,emoji:`📊`,title:`每周五下午,我帮你回顾这一周做了什么,下周该抓什么。`,body:`从我们的对话、你的日历和我记下的备忘里,整理出本周完成的事、没做完的事和下周的三个重点,写成一份周报草稿。`,kind:`ROUTINE`,time:`17:00`,prompt:`写一份本周回顾:根据我们这周的对话、我的日历和你记住的事项,列出完成了什么、什么没做完、下周三个重点,保存为一份 Markdown 文件。`},{id:`inbox-triage`,emoji:`📬`,title:`把一堆消息或邮件丢给我,我分好轻重缓急,写好回复草稿。`,body:`粘贴文字或截图给我,我按“今天必须回、这周处理、只需知道”分类,把每条要回的先拟好一版,你改一改就能发。`,kind:`CHAT`,prompt:`我要把一批消息交给你整理:请按“今天必须回 / 这周处理 / 只需知道”分类,并为需要回复的每一条写好回复草稿。请先让我粘贴内容。`},{id:`doc-digest`,emoji:`📄`,title:`长文档、PDF、网页,我读完给你三分钟版本。`,body:`把文件放进共享文件夹或把链接发我,我提炼结论、关键数字和你需要做的决定,问题可以接着追问。`,kind:`CHAT`,prompt:`我有一份长内容需要你读并总结:请先让我提供文件或链接,然后给我结论、关键数字、需要我做的决定三部分,每部分不超过五条。`}]},{id:`life`,title:`生活`,ideas:[{id:`price-watch`,emoji:`🏷️`,title:`想买的东西告诉我,降到你说的价格我立刻告诉你。`,body:`我会定期去商品页看价格,记录走势;到价就通知你。下单和付款由你来点,我不会碰。`,kind:`GOAL`,category:`finance`,prompt:`我想盯一件商品的价格:请先问我商品链接或名称、目标价格和检查频率,然后建一个到价提醒我的目标。`},{id:`photo-sort`,emoji:`🖼️`,title:`把相册挂给我,我按内容给截图和照片分类整理。`,body:`在「共享文件夹」里挂载相册后,我会看图识别内容(票据、聊天截图、风景、证件),归到对应文件夹,重要的信息顺手记下来。`,kind:`CHAT`,prompt:`帮我整理照片:请先让我在共享文件夹里挂载相册目录,然后按内容(票据、截图、风景、证件、其他)分类整理,并把整理结果列给我。`},{id:`meal-log`,emoji:`🥗`,title:`拍下你吃的,我帮你估热量、记下每一餐。`,body:`发一张照片或一句话,我估算营养和热量,记进当天的日记;晚上给你一天的小结和明天的一个小建议。`,kind:`CHAT`,prompt:`从现在起帮我记录饮食:每次我发照片或描述,估算热量与营养并记入当天记忆;请先告诉我怎么发给你最方便。`},{id:`subscriptions`,emoji:`🔁`,title:`帮你把每月自动扣费的订阅列出来,看看哪些该停。`,body:`把账单截图或消费记录给我,我列出所有周期性扣费、下一次扣款日和一年花多少;你决定停哪个,退订的步骤我一步步陪你走。`,kind:`CHAT`,prompt:`帮我盘点订阅扣费:请先让我提供账单截图或记录,然后列出所有周期性扣费、下一次扣款日、年费合计,并标出可能不再需要的。`}]},{id:`learn`,title:`学习`,ideas:[{id:`daily-words`,emoji:`🔤`,title:`每天早上五个新词,晚上考你一遍。`,body:`你定语言和难度,我按主题挑词、给例句和记忆点,晚上出五道小题,错的第二天再来。`,kind:`ROUTINE`,time:`08:00`,prompt:`每日词汇:为我挑五个新词(先问我学什么语言、什么水平和感兴趣的主题),每个给释义、一个例句和一个记忆点,并附上昨天的复习题。`},{id:`study-plan`,emoji:`🎯`,title:`有个想学会的东西?我陪你拆成每周能做完的小块。`,body:`告诉我目标和每周能投入的时间,我排一份四到八周的计划,每周检查进度、调整难度,帮你坚持到最后。`,kind:`GOAL`,category:`interests`,prompt:`我想学一样新东西并坚持下去:请先问我想学什么、现在的水平、每周能投入多少时间和希望多久见效,然后帮我建一个每周检查进度的目标。`},{id:`explain`,emoji:`💡`,title:`看不懂的概念发给我,我用你熟悉的东西讲明白。`,body:`先问你已经懂什么,再从那里出发讲;讲完出两道题确认你真懂了,需要的话给你一张记忆卡。`,kind:`CHAT`,prompt:`我有一个不太懂的概念想让你讲明白:请先问我这个概念是什么、我已经了解哪些相关知识,然后用类比讲解,最后出两道小题检验。`},{id:`reading-notes`,emoji:`📚`,title:`读完一本书,我帮你整理成能重读的笔记。`,body:`你说重点和感受,或者把划线的句子发我,我整理成结构化笔记,存进资源库,并记住你的收获。`,kind:`CHAT`,prompt:`帮我整理一本书的读书笔记:请先问我书名、最触动我的三点和我想记住的引文,然后整理成结构化的 Markdown 笔记并保存。`}]},{id:`family`,title:`家庭与关系`,ideas:[{id:`birthdays`,emoji:`🎂`,title:`重要的人的生日和纪念日,我提前一周提醒,顺手想好礼物。`,body:`把名字和日期告诉我,我记住;到日子前一周提醒你,并根据你说过的 TA 的喜好给几个礼物方向。`,kind:`CHAT`,prompt:`帮我记住重要的人的生日和纪念日:请先问我名单和日期,记入长期记忆,并为每个日期设一个提前一周提醒我的例程。`},{id:`keep-in-touch`,emoji:`💬`,title:`老朋友太久没联系?我每周提醒你该找谁聊聊,帮你开个头。`,body:`你给我一份想保持联系的名单,我按间隔轮着提醒,并根据上次聊的内容帮你写一句自然的开场。`,kind:`ROUTINE`,time:`19:00`,prompt:`每周联系提醒:从我告诉你的“想保持联系的人”名单里,挑一位最近最久没联系的,提醒我,并根据我记录过的上次聊天内容写一句自然的开场白。`},{id:`family-calendar`,emoji:`📅`,title:`把全家的安排告诉我,我帮你排进日历、发现冲突。`,body:`孩子的课、家人的门诊、周末的聚会,说一句我就写进日历,时间撞了我先提醒你。`,kind:`CHAT`,prompt:`帮我管理家庭日程:我会陆续告诉你家人的安排,请写进日历,创建前先复述确认,发现时间冲突时提醒我。`},{id:`gift-ideas`,emoji:`🎁`,title:`说说 TA 是什么样的人,我给你几个不落俗套的礼物点子。`,body:`预算、关系、TA 最近在忙什么,三个问题之后我给你五个方向,每个附一句为什么合适。`,kind:`CHAT`,prompt:`帮我想礼物:请先问我送给谁、预算、TA 的兴趣和最近的生活状态,然后给我五个礼物方向,每个附一句理由。`}]},{id:`more`,title:`更多点子`,ideas:[{id:`id-renewal`,emoji:`🪪`,title:`告诉我证件到期时间,我提前预约续签,把材料清单准备好。`,body:`护照、驾照、签证,我记下到期日,提前三个月提醒,列出所需材料和办理地点,能预约的帮你查好时段。`,kind:`CHAT`,prompt:`帮我管理证件到期:请先问我有哪些证件和到期日期,记入长期记忆,并为每一个设置提前三个月的提醒,附上续办所需材料清单。`},{id:`personality-quiz`,emoji:`❓`,title:`每天两个小问题,一周后我告诉你我发现了什么样的你。`,body:`问题很轻,回答很快;我把答案记下来,每周整理成一段关于你偏好和节奏的观察,方便我更懂你。`,kind:`ROUTINE`,time:`21:00`,prompt:`每日两问:问我两个关于偏好、习惯或近况的小问题(不重复以前问过的),把回答记入记忆;每周日额外给我一段关于我这周状态的观察。`},{id:`event-plan`,emoji:`🎉`,title:`说个场合,我帮你安排流程、拟好邀请。`,body:`生日、团建、家宴,人数、预算和日期告诉我,我给你时间线、采购清单和一段可以直接发出去的邀请文字。`,kind:`CHAT`,prompt:`帮我策划一次活动:请先问我场合、日期、人数和预算,然后给我时间线、待办清单和一段可直接发送的邀请文字。`},{id:`phone-cleanup`,emoji:`🧹`,title:`让我看看沙盒和共享文件夹里有什么,清掉没用的、整理有用的。`,body:`我列出占空间的大文件和重复文件,先给你看清单,你点头我再动;重要的整理进资源库。`,kind:`CHAT`,prompt:`帮我整理工作区和共享文件夹:先列出大文件、重复文件和很久没动的文件,给我一份清单;在我确认之前不要删除任何东西。`}]}]};function b(e){return e.toLowerCase().startsWith(`zh`)?y:v}function x(e){return{id:e.id,emoji:e.emoji,title:e.title,detail:e.body,prompt:e.prompt,kind:e.kind.toLowerCase(),time:e.time,category:e.category}}var S=o(),C=[{id:`planning`,label:`Planning`,emoji:`🗓️`},{id:`goals`,label:`Goals`,emoji:`🎯`},{id:`research`,label:`Research`,emoji:`🔍`},{id:`money`,label:`Money`,emoji:`💰`},{id:`health`,label:`Health`,emoji:`🌿`},{id:`home`,label:`Home`,emoji:`🏠`},{id:`learning`,label:`Learning`,emoji:`📚`},{id:`people`,label:`People`,emoji:`👥`},{id:`files`,label:`Files & tools`,emoji:`📄`},{id:`fun`,label:`Just for you`,emoji:`🎈`}];function w(){let{state:t,send:r,openThread:a,toast:o}=s(),[v,y]=(0,_.useState)(null),[C,w]=(0,_.useState)(!1),E=t.profile?.name??`nanoMuse`,D=n(),O=i(),k=async(e=!1)=>{w(!0);try{let t=await l.ideas(e);y(t),t.error&&o(D(`Could not refresh ideas: {error}`,{error:t.error}))}catch(e){o(e.message)}finally{w(!1)}};(0,_.useEffect)(()=>{k(!1)},[]);let A=v?.source===`model`&&v.ideas.length>0,j=A?T(v.ideas):b(O).sections.map(e=>({id:e.id,title:e.title,ideas:e.ideas.map(x)})),[M,N]=(0,_.useState)(null),P=e=>{N(null),r(`main`,e.prompt).catch(e=>o(e.message||D(`Could not send`))),a(`main`)},F=async e=>{N(null);let t=e.time&&/^([01]\d|2[0-3]):[0-5]\d$/.test(e.time)?e.time:`09:00`;try{await l.createReminder({text:e.prompt,kind:`task`,repeat:`daily ${t}`}),o(D(`Routine set: every day at {time}. It is listed under Goals.`,{time:t}))}catch(e){o(e.message)}},I=e=>{N(null);let t=m.find(t=>t.id===e.category)??m[m.length-1],n=D(`I'd like to create a {category} goal. Ask me a few short questions, one at a time (what exactly I want, why and by when, how often to check in), then create it with concrete steps using the goals tool.`,{category:D(t.label)});r(`main`,`${n} ${e.prompt}`).catch(e=>o(e.message||D(`Could not send`))),a(`main`)},L=e=>e.kind===`routine`||e.kind===`goal`?e.kind:`chat`;return(0,S.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,S.jsx)(p,{title:D(`Ideas`),children:(0,S.jsx)(`button`,{type:`button`,onClick:()=>void k(!0),disabled:C,"aria-label":D(`Refresh ideas`),className:`flex h-10 w-10 items-center justify-center rounded-full bg-surface text-fg shadow-[0_1px_4px_rgba(0,0,0,0.14)] disabled:opacity-60 dark:border dark:border-border dark:shadow-none`,children:C?(0,S.jsx)(c,{size:20,className:`animate-spin`}):(0,S.jsx)(u,{size:19})})}),(0,S.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-6`,children:[j.map(({id:e,title:t,ideas:n},r)=>(0,S.jsxs)(`section`,{children:[(r>0||!A)&&(0,S.jsx)(`h2`,{className:`px-5 pt-[22px] pb-1 text-[20px] font-bold`,children:A?D(t):t}),(0,S.jsx)(`ul`,{children:n.map(e=>(0,S.jsxs)(`li`,{children:[(0,S.jsxs)(`button`,{type:`button`,onClick:()=>N({idea:e,emoji:e.emoji}),className:`flex w-full items-start gap-4 px-5 py-3.5 text-left active:bg-surface-2/70`,children:[(0,S.jsx)(`span`,{className:`flex w-10 shrink-0 justify-center text-[28px] leading-[34px]`,children:e.emoji}),(0,S.jsxs)(`span`,{className:`min-w-0 flex-1 space-y-1`,children:[(0,S.jsx)(`span`,{className:`block text-[16px] font-semibold leading-[22px]`,children:e.title}),e.detail&&(0,S.jsx)(`span`,{className:`line-clamp-4 block text-[13.5px] leading-[19px] text-muted`,children:e.detail})]})]}),(0,S.jsx)(`div`,{className:`ml-[76px] mr-5 border-b border-border/70`})]},e.title))})]},e)),v&&(0,S.jsx)(`div`,{className:`px-5 pt-4 text-[12.5px] text-muted`,children:v.source===`model`?D(`Generated {when}`,{when:e(v.generated_at)}):D(`Starter ideas. Refresh once {name} knows you better.`,{name:E})})]}),(0,S.jsx)(d,{open:M!==null,onClose:()=>N(null),children:M&&(0,S.jsxs)(`div`,{className:`px-1 pb-2`,children:[(0,S.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,S.jsx)(`span`,{className:`text-[30px] leading-none`,children:M.emoji}),(0,S.jsx)(`h3`,{className:`flex-1 text-[18px] font-bold leading-6`,children:M.idea.title})]}),M.idea.detail&&(0,S.jsx)(`p`,{className:`mt-3 text-[15px] leading-[22px]`,children:M.idea.detail}),(0,S.jsxs)(`div`,{className:`mt-3.5 flex items-center gap-1.5 text-[13px] text-muted`,children:[L(M.idea)===`routine`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(h,{size:16}),` `,D(`Creates a routine`),M.idea.time?` · ${M.idea.time}`:``]}),L(M.idea)===`goal`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(g,{size:16}),` `,D(`Creates a goal`)]}),L(M.idea)===`chat`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(f,{size:16}),` `,D(`Starts a conversation`)]})]}),L(M.idea)===`routine`&&(0,S.jsxs)(`button`,{type:`button`,onClick:()=>void F(M.idea),className:`mt-[22px] flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,S.jsx)(h,{size:18}),` `,D(`Create routine`)]}),L(M.idea)===`goal`&&(0,S.jsxs)(`button`,{type:`button`,onClick:()=>I(M.idea),className:`mt-[22px] flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,S.jsx)(g,{size:18}),` `,D(`Start goal`)]}),L(M.idea)===`chat`&&(0,S.jsxs)(`button`,{type:`button`,onClick:()=>P(M.idea),className:`mt-[22px] flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,S.jsx)(f,{size:18}),` `,D(`Send to chat`)]})]})})]})}function T(e){let t=C[C.length-1],n=C.map(e=>({id:e.id,title:e.label,ideas:[]}));for(let r of e){let e=C.find(e=>e.id===(r.area||t.id))??t;(n.find(t=>t.id===e.id)??n[n.length-1]).ideas.push({...r,emoji:e.emoji})}return n.filter(e=>e.ideas.length>0)}export{w as IdeasScreen}; \ No newline at end of file +import{A as e,J as t,K as n,Q as r,W as i,Y as a,b as o,f as s,q as c,x as l}from"./MuseHeader-RC--uR8o.js";import{t as u}from"./refresh-cw-CxZ3ZdI3.js";import{C as d,at as f,d as p,p as m,vt as h}from"./index-tYqPtwPX.js";var g=t(`flag`,[[`path`,{d:`M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528`,key:`1jaruq`}]]),_=r(a(),1),v={sections:[{id:`travel`,title:`Travel`,ideas:[{id:`train-watch`,emoji:`🚄`,title:`Tell me where you're going and when; I'll watch for seats and lower fares.`,body:`I set up the lookup tools in my sandbox, check trains, classes and availability as often as you like, and tell you the moment something opens up. Booking and paying stay in your hands.`,kind:`GOAL`,category:`other`,prompt:`I want to watch a train route: ask me for the origin, destination, date and acceptable departure window, then set up a goal that checks availability regularly.`},{id:`trip-plan`,emoji:`🗺️`,title:`Name a city and a number of days; I'll plan an itinerary you can follow as is.`,body:`Weather, opening hours and travel time between stops go into a day-by-day plan saved to your Library. Change one sentence and I'll rearrange the rest.`,kind:`CHAT`,prompt:`Help me plan a trip. Ask me where, how many days, with whom, a rough budget and the style I like, then give me a day-by-day itinerary as a file.`},{id:`commute-brief`,emoji:`🚦`,title:`Before you leave each morning, I'll tell you the traffic, the weather and the first thing on your plate.`,body:`A morning routine: I check the weather where you are, the traffic on your commute, glance at your calendar and boil the morning down to three sentences.`,kind:`ROUTINE`,time:`07:30`,prompt:`Morning brief: check today's weather and commute traffic, read my calendar for today, and tell me in at most five sentences the three things that matter most and what to bring.`},{id:`parcel-track`,emoji:`📦`,title:`Send me a tracking number; I'll tell you where the parcel is and when it lands.`,body:`I follow the shipment through the carrier's tracking, tell you about every new scan, and remind you to keep the phone nearby on delivery day.`,kind:`CHAT`,prompt:`Help me track a parcel. Ask me for the tracking number and carrier, check the current status once, then set a routine that checks every morning and reminds me the day before delivery.`}]},{id:`work`,title:`Work`,ideas:[{id:`meeting-prep`,emoji:`📝`,title:`Ten minutes before a meeting, I'll have the background and the questions ready.`,body:`I read the meeting's title and attendees from your calendar, look through what we've discussed and what you've put in the Library, and hand you one page: purpose, talking points, three questions worth asking.`,kind:`ROUTINE`,time:`08:50`,prompt:`Look at the meetings on my calendar today and prepare a one-page brief for each: the purpose, the points I should make, and three questions worth asking.`},{id:`weekly-review`,emoji:`📊`,title:`Every Friday afternoon, I'll review what you did this week and what next week needs.`,body:`From our conversations, your calendar and the notes I keep, I list what got done, what didn't, and three priorities for next week, as a draft you can send.`,kind:`ROUTINE`,time:`17:00`,prompt:`Write my weekly review: from this week's conversations, my calendar and what you remember, list what was completed, what is still open, and three priorities for next week. Save it as a Markdown file.`},{id:`inbox-triage`,emoji:`📬`,title:`Dump a pile of messages or emails on me; I'll sort them and draft the replies.`,body:`Paste text or screenshots. I sort them into 'reply today', 'this week' and 'just so you know', and draft each reply so you only need to edit and send.`,kind:`CHAT`,prompt:`I'm handing you a batch of messages to triage: sort them into 'reply today / this week / just so you know' and draft a reply for each one that needs it. Ask me to paste the content first.`},{id:`doc-digest`,emoji:`📄`,title:`Long documents, PDFs, web pages: I'll read them and give you the three-minute version.`,body:`Drop the file in a shared folder or send me the link. You get the conclusions, the key numbers and the decisions you need to make; ask follow-ups any time.`,kind:`CHAT`,prompt:`I have something long for you to read and summarise: ask me for the file or link, then give me conclusions, key numbers and decisions I need to make, at most five bullets each.`}]},{id:`life`,title:`Everyday`,ideas:[{id:`price-watch`,emoji:`🏷️`,title:`Tell me what you want to buy; the moment it hits your price, you'll know.`,body:`I check the product page on a schedule, keep the price history, and notify you at your target. Ordering and paying are yours; I don't touch them.`,kind:`GOAL`,category:`finance`,prompt:`I want to watch the price of a product: ask me for the link or name, my target price and how often to check, then create a goal that alerts me when it drops.`},{id:`photo-sort`,emoji:`🖼️`,title:`Mount your photos and I'll sort screenshots and pictures by what's in them.`,body:`Once your gallery is mounted as a shared folder I look at each image (receipts, chat screenshots, landscapes, documents), file it into the right folder and note anything important.`,kind:`CHAT`,prompt:`Help me sort my photos: ask me to mount the gallery in Shared Folders first, then sort by content (receipts, screenshots, scenery, documents, other) and list what you did.`},{id:`meal-log`,emoji:`🥗`,title:`Photograph what you eat; I'll estimate the calories and keep the log.`,body:`A photo or a sentence is enough: I estimate nutrition and calories, log it in the day's memory, and give you a summary each evening with one small suggestion for tomorrow.`,kind:`CHAT`,prompt:`From now on, log my meals: whenever I send a photo or a description, estimate calories and nutrition and write it into today's memory. Tell me first how best to send them.`},{id:`subscriptions`,emoji:`🔁`,title:`Send me your bills; I'll list every subscription that charges you each month, and you decide which to stop.`,body:`Give me bill screenshots or statements. I list every recurring charge, its next date and the yearly total; you decide what to cancel and I walk you through each cancellation.`,kind:`CHAT`,prompt:`Audit my subscriptions: ask me for bill screenshots or statements, then list every recurring charge, its next billing date and the yearly total, and flag the ones I may not need.`}]},{id:`learn`,title:`Learning`,ideas:[{id:`daily-words`,emoji:`🔤`,title:`Five new words every morning, a quick quiz every night.`,body:`You pick the language and level; I pick words by theme with examples and a memory hook, quiz you in the evening, and bring back the ones you missed.`,kind:`ROUTINE`,time:`08:00`,prompt:`Daily vocabulary: pick five new words for me (ask which language, level and topics first), each with a definition, one example sentence and a memory hook, plus a review quiz on yesterday's words.`},{id:`study-plan`,emoji:`🎯`,title:`Something you want to learn? I'll break it into weekly chunks and keep you honest.`,body:`Tell me the goal and the hours you can spare; I lay out four to eight weeks, check progress weekly, and adjust the pace so you actually finish.`,kind:`GOAL`,category:`interests`,prompt:`I want to learn something new and stick with it: ask me what, my current level, hours per week and the timeframe, then create a goal with a weekly progress check.`},{id:`explain`,emoji:`💡`,title:`Send me a concept you don't get; I'll explain it with things you already know.`,body:`I ask what you already understand and start from there, then check with two quick questions and leave you a flashcard if you want one.`,kind:`CHAT`,prompt:`There's a concept I don't understand: ask me what it is and what related things I already know, explain it with analogies, then check with two short questions.`},{id:`reading-notes`,emoji:`📚`,title:`Finished a book? I'll turn it into notes you'll actually reread.`,body:`Tell me the highlights, or send the passages you marked. I write structured notes into your Library and remember what stayed with you.`,kind:`CHAT`,prompt:`Help me write notes on a book: ask me the title, the three things that struck me most and the quotes I want to keep, then save structured Markdown notes.`}]},{id:`family`,title:`Family & friends`,ideas:[{id:`birthdays`,emoji:`🎂`,title:`Birthdays and anniversaries: I remind you a week ahead, gift ideas included.`,body:`Give me names and dates and I keep them. A week before each one I remind you, with a few gift directions based on what you've told me about them.`,kind:`CHAT`,prompt:`Remember the birthdays and anniversaries of the people who matter to me: ask for the list and dates, store them in long-term memory, and set a routine that reminds me a week before each.`},{id:`keep-in-touch`,emoji:`💬`,title:`Lost touch with old friends? Once a week I'll suggest who to reach out to, and how to start.`,body:`Give me a list of people you want to stay close to. I rotate through them and write a natural opening line based on what you last talked about.`,kind:`ROUTINE`,time:`19:00`,prompt:`Weekly keep-in-touch: from my list of people I want to stay close to, pick the one I've gone longest without contacting, remind me, and write a natural opening line based on what I noted about our last conversation.`},{id:`family-calendar`,emoji:`📅`,title:`Tell me the family's plans and I'll put them on the calendar and catch clashes.`,body:`Kids' lessons, a parent's appointment, a weekend get-together: one sentence and it's in the calendar; if two things collide, I say so first.`,kind:`CHAT`,prompt:`Help me run the family calendar: I'll tell you plans as they come up; write them into the calendar, confirm each before creating it, and warn me about conflicts.`},{id:`gift-ideas`,emoji:`🎁`,title:`Describe the person and I'll give you gift ideas that aren't the obvious ones.`,body:`Budget, relationship, what they're into lately: three questions, then five directions with one line each on why it fits.`,kind:`CHAT`,prompt:`Help me think of a gift: ask who it's for, my budget, their interests and what's going on in their life, then give me five directions with one line of reasoning each.`}]},{id:`more`,title:`More ideas`,ideas:[{id:`id-renewal`,emoji:`🪪`,title:`Tell me when your documents expire; I'll book the renewal and prep the paperwork list.`,body:`Passport, licence, visa: I note the dates, remind you three months ahead, list the documents and the office, and look up appointment slots where I can.`,kind:`CHAT`,prompt:`Help me track document expiry: ask which documents I have and when they expire, store them in memory, and set a reminder three months ahead of each with the renewal checklist.`},{id:`personality-quiz`,emoji:`❓`,title:`Two small questions a day; after a week I'll tell you what I've noticed about you.`,body:`Light questions, quick answers. I keep them and, once a week, write a short observation about your preferences and rhythm so I understand you better.`,kind:`ROUTINE`,time:`21:00`,prompt:`Two questions a day: ask me two small questions about preferences, habits or how things are going (never repeat one), store the answers in memory; every Sunday add a short observation about my week.`},{id:`event-plan`,emoji:`🎉`,title:`Name the occasion; I'll plan the run of show and draft the invitation.`,body:`Birthday, team outing, family dinner: give me the headcount, budget and date, and you get a timeline, a shopping list and an invitation you can send as is.`,kind:`CHAT`,prompt:`Help me plan an event: ask me the occasion, date, headcount and budget, then give me a timeline, a to-do list and an invitation text I can send.`},{id:`phone-cleanup`,emoji:`🧹`,title:`Let me look through the sandbox and shared folders; I'll clear the junk and organise the rest.`,body:`I list the big files and duplicates, show you the list first and only act when you say so; anything worth keeping goes into the Library.`,kind:`CHAT`,prompt:`Help me tidy the workspace and shared folders: list large files, duplicates and files untouched for a long time, and give me the list. Do not delete anything before I confirm.`}]}]},y={sections:[{id:`travel`,title:`出行`,ideas:[{id:`train-watch`,emoji:`🚄`,title:`告诉我你要去哪、哪天走,我盯着 12306 的余票和低价票。`,body:`我会在沙盒里装好查询工具,按你说的频率查车次、席别和余票,有票或价格合适时马上通知你;订票和付款那一步留给你自己点。`,kind:`GOAL`,category:`other`,prompt:`我想盯一趟火车票:请先问我出发地、目的地、日期和可接受的车次时段,然后为我建一个定期检查余票的目标。`},{id:`trip-plan`,emoji:`🗺️`,title:`说一个城市和几天,我给你排一份能直接照着走的行程。`,body:`我会查天气、开放时间和两点之间的路程,把每天的顺序排顺,写成一份文件放进资源库;你改一句,我就重排。`,kind:`CHAT`,prompt:`帮我规划一次出行。先问我去哪、几天、和谁、预算大概多少、喜欢什么风格,再给我一份按天排好的行程文件。`},{id:`commute-brief`,emoji:`🚦`,title:`每天出门前,我告诉你今天的路况、天气和第一件事。`,body:`一条早间例程:查你所在城市的天气、通勤路线的路况,翻一下日历,把上午的安排浓缩成三句话推给你。`,kind:`ROUTINE`,time:`07:30`,prompt:`早间简报:查询今天的天气和通勤路况,读取我今天的日历安排,用不超过五句话告诉我今天最重要的三件事和出门要带的东西。`},{id:`parcel-track`,emoji:`📦`,title:`把快递单号发我,到哪了、什么时候到,我随时告诉你。`,body:`我会用快递查询接口跟踪包裹,有新的物流节点就更新你;派送当天提醒你留意电话。`,kind:`CHAT`,prompt:`帮我跟踪一个快递。先问我单号和快递公司,然后查一次当前进度,并设一个每天早上检查一次、到派送前一天提醒我的例程。`}]},{id:`work`,title:`工作`,ideas:[{id:`meeting-prep`,emoji:`📝`,title:`开会前十分钟,我把这场会的背景和该问的问题整理好。`,body:`读日历里的会议主题和参会人,翻我们聊过的相关内容和你放进资源库的资料,给你一页纸:目的、要点、三个值得问的问题。`,kind:`ROUTINE`,time:`08:50`,prompt:`查看我今天日历里接下来的会议,为每一场准备一页会前提纲:这场会的目的、需要我发言的要点、三个值得提出的问题。`},{id:`weekly-review`,emoji:`📊`,title:`每周五下午,我帮你回顾这一周做了什么,下周该抓什么。`,body:`从我们的对话、你的日历和我记下的备忘里,整理出本周完成的事、没做完的事和下周的三个重点,写成一份周报草稿。`,kind:`ROUTINE`,time:`17:00`,prompt:`写一份本周回顾:根据我们这周的对话、我的日历和你记住的事项,列出完成了什么、什么没做完、下周三个重点,保存为一份 Markdown 文件。`},{id:`inbox-triage`,emoji:`📬`,title:`把一堆消息或邮件丢给我,我分好轻重缓急,写好回复草稿。`,body:`粘贴文字或截图给我,我按“今天必须回、这周处理、只需知道”分类,把每条要回的先拟好一版,你改一改就能发。`,kind:`CHAT`,prompt:`我要把一批消息交给你整理:请按“今天必须回 / 这周处理 / 只需知道”分类,并为需要回复的每一条写好回复草稿。请先让我粘贴内容。`},{id:`doc-digest`,emoji:`📄`,title:`长文档、PDF、网页,我读完给你三分钟版本。`,body:`把文件放进共享文件夹或把链接发我,我提炼结论、关键数字和你需要做的决定,问题可以接着追问。`,kind:`CHAT`,prompt:`我有一份长内容需要你读并总结:请先让我提供文件或链接,然后给我结论、关键数字、需要我做的决定三部分,每部分不超过五条。`}]},{id:`life`,title:`生活`,ideas:[{id:`price-watch`,emoji:`🏷️`,title:`想买的东西告诉我,降到你说的价格我立刻告诉你。`,body:`我会定期去商品页看价格,记录走势;到价就通知你。下单和付款由你来点,我不会碰。`,kind:`GOAL`,category:`finance`,prompt:`我想盯一件商品的价格:请先问我商品链接或名称、目标价格和检查频率,然后建一个到价提醒我的目标。`},{id:`photo-sort`,emoji:`🖼️`,title:`把相册挂给我,我按内容给截图和照片分类整理。`,body:`在「共享文件夹」里挂载相册后,我会看图识别内容(票据、聊天截图、风景、证件),归到对应文件夹,重要的信息顺手记下来。`,kind:`CHAT`,prompt:`帮我整理照片:请先让我在共享文件夹里挂载相册目录,然后按内容(票据、截图、风景、证件、其他)分类整理,并把整理结果列给我。`},{id:`meal-log`,emoji:`🥗`,title:`拍下你吃的,我帮你估热量、记下每一餐。`,body:`发一张照片或一句话,我估算营养和热量,记进当天的日记;晚上给你一天的小结和明天的一个小建议。`,kind:`CHAT`,prompt:`从现在起帮我记录饮食:每次我发照片或描述,估算热量与营养并记入当天记忆;请先告诉我怎么发给你最方便。`},{id:`subscriptions`,emoji:`🔁`,title:`帮你把每月自动扣费的订阅列出来,看看哪些该停。`,body:`把账单截图或消费记录给我,我列出所有周期性扣费、下一次扣款日和一年花多少;你决定停哪个,退订的步骤我一步步陪你走。`,kind:`CHAT`,prompt:`帮我盘点订阅扣费:请先让我提供账单截图或记录,然后列出所有周期性扣费、下一次扣款日、年费合计,并标出可能不再需要的。`}]},{id:`learn`,title:`学习`,ideas:[{id:`daily-words`,emoji:`🔤`,title:`每天早上五个新词,晚上考你一遍。`,body:`你定语言和难度,我按主题挑词、给例句和记忆点,晚上出五道小题,错的第二天再来。`,kind:`ROUTINE`,time:`08:00`,prompt:`每日词汇:为我挑五个新词(先问我学什么语言、什么水平和感兴趣的主题),每个给释义、一个例句和一个记忆点,并附上昨天的复习题。`},{id:`study-plan`,emoji:`🎯`,title:`有个想学会的东西?我陪你拆成每周能做完的小块。`,body:`告诉我目标和每周能投入的时间,我排一份四到八周的计划,每周检查进度、调整难度,帮你坚持到最后。`,kind:`GOAL`,category:`interests`,prompt:`我想学一样新东西并坚持下去:请先问我想学什么、现在的水平、每周能投入多少时间和希望多久见效,然后帮我建一个每周检查进度的目标。`},{id:`explain`,emoji:`💡`,title:`看不懂的概念发给我,我用你熟悉的东西讲明白。`,body:`先问你已经懂什么,再从那里出发讲;讲完出两道题确认你真懂了,需要的话给你一张记忆卡。`,kind:`CHAT`,prompt:`我有一个不太懂的概念想让你讲明白:请先问我这个概念是什么、我已经了解哪些相关知识,然后用类比讲解,最后出两道小题检验。`},{id:`reading-notes`,emoji:`📚`,title:`读完一本书,我帮你整理成能重读的笔记。`,body:`你说重点和感受,或者把划线的句子发我,我整理成结构化笔记,存进资源库,并记住你的收获。`,kind:`CHAT`,prompt:`帮我整理一本书的读书笔记:请先问我书名、最触动我的三点和我想记住的引文,然后整理成结构化的 Markdown 笔记并保存。`}]},{id:`family`,title:`家庭与关系`,ideas:[{id:`birthdays`,emoji:`🎂`,title:`重要的人的生日和纪念日,我提前一周提醒,顺手想好礼物。`,body:`把名字和日期告诉我,我记住;到日子前一周提醒你,并根据你说过的 TA 的喜好给几个礼物方向。`,kind:`CHAT`,prompt:`帮我记住重要的人的生日和纪念日:请先问我名单和日期,记入长期记忆,并为每个日期设一个提前一周提醒我的例程。`},{id:`keep-in-touch`,emoji:`💬`,title:`老朋友太久没联系?我每周提醒你该找谁聊聊,帮你开个头。`,body:`你给我一份想保持联系的名单,我按间隔轮着提醒,并根据上次聊的内容帮你写一句自然的开场。`,kind:`ROUTINE`,time:`19:00`,prompt:`每周联系提醒:从我告诉你的“想保持联系的人”名单里,挑一位最近最久没联系的,提醒我,并根据我记录过的上次聊天内容写一句自然的开场白。`},{id:`family-calendar`,emoji:`📅`,title:`把全家的安排告诉我,我帮你排进日历、发现冲突。`,body:`孩子的课、家人的门诊、周末的聚会,说一句我就写进日历,时间撞了我先提醒你。`,kind:`CHAT`,prompt:`帮我管理家庭日程:我会陆续告诉你家人的安排,请写进日历,创建前先复述确认,发现时间冲突时提醒我。`},{id:`gift-ideas`,emoji:`🎁`,title:`说说 TA 是什么样的人,我给你几个不落俗套的礼物点子。`,body:`预算、关系、TA 最近在忙什么,三个问题之后我给你五个方向,每个附一句为什么合适。`,kind:`CHAT`,prompt:`帮我想礼物:请先问我送给谁、预算、TA 的兴趣和最近的生活状态,然后给我五个礼物方向,每个附一句理由。`}]},{id:`more`,title:`更多点子`,ideas:[{id:`id-renewal`,emoji:`🪪`,title:`告诉我证件到期时间,我提前预约续签,把材料清单准备好。`,body:`护照、驾照、签证,我记下到期日,提前三个月提醒,列出所需材料和办理地点,能预约的帮你查好时段。`,kind:`CHAT`,prompt:`帮我管理证件到期:请先问我有哪些证件和到期日期,记入长期记忆,并为每一个设置提前三个月的提醒,附上续办所需材料清单。`},{id:`personality-quiz`,emoji:`❓`,title:`每天两个小问题,一周后我告诉你我发现了什么样的你。`,body:`问题很轻,回答很快;我把答案记下来,每周整理成一段关于你偏好和节奏的观察,方便我更懂你。`,kind:`ROUTINE`,time:`21:00`,prompt:`每日两问:问我两个关于偏好、习惯或近况的小问题(不重复以前问过的),把回答记入记忆;每周日额外给我一段关于我这周状态的观察。`},{id:`event-plan`,emoji:`🎉`,title:`说个场合,我帮你安排流程、拟好邀请。`,body:`生日、团建、家宴,人数、预算和日期告诉我,我给你时间线、采购清单和一段可以直接发出去的邀请文字。`,kind:`CHAT`,prompt:`帮我策划一次活动:请先问我场合、日期、人数和预算,然后给我时间线、待办清单和一段可直接发送的邀请文字。`},{id:`phone-cleanup`,emoji:`🧹`,title:`让我看看沙盒和共享文件夹里有什么,清掉没用的、整理有用的。`,body:`我列出占空间的大文件和重复文件,先给你看清单,你点头我再动;重要的整理进资源库。`,kind:`CHAT`,prompt:`帮我整理工作区和共享文件夹:先列出大文件、重复文件和很久没动的文件,给我一份清单;在我确认之前不要删除任何东西。`}]}]};function b(e){return e.toLowerCase().startsWith(`zh`)?y:v}function x(e){return{id:e.id,emoji:e.emoji,title:e.title,detail:e.body,prompt:e.prompt,kind:e.kind.toLowerCase(),time:e.time,category:e.category}}var S=o(),C=[{id:`planning`,label:`Planning`,emoji:`🗓️`},{id:`goals`,label:`Goals`,emoji:`🎯`},{id:`research`,label:`Research`,emoji:`🔍`},{id:`money`,label:`Money`,emoji:`💰`},{id:`health`,label:`Health`,emoji:`🌿`},{id:`home`,label:`Home`,emoji:`🏠`},{id:`learning`,label:`Learning`,emoji:`📚`},{id:`people`,label:`People`,emoji:`👥`},{id:`files`,label:`Files & tools`,emoji:`📄`},{id:`fun`,label:`Just for you`,emoji:`🎈`}];function w(){let{state:t,send:r,openThread:a,toast:o}=s(),[v,y]=(0,_.useState)(null),[C,w]=(0,_.useState)(!1),E=t.profile?.name??`nanoMuse`,D=n(),O=i(),k=async(e=!1)=>{w(!0);try{let t=await l.ideas(e);y(t),t.error&&o(D(`Could not refresh ideas: {error}`,{error:t.error}))}catch(e){o(e.message)}finally{w(!1)}};(0,_.useEffect)(()=>{k(!1)},[]);let A=v?.source===`model`&&v.ideas.length>0,j=A?T(v.ideas):b(O).sections.map(e=>({id:e.id,title:e.title,ideas:e.ideas.map(x)})),[M,N]=(0,_.useState)(null),P=e=>{N(null),r(`main`,e.prompt).catch(e=>o(e.message||D(`Could not send`))),a(`main`)},F=async e=>{N(null);let t=e.time&&/^([01]\d|2[0-3]):[0-5]\d$/.test(e.time)?e.time:`09:00`;try{await l.createReminder({text:e.prompt,kind:`task`,repeat:`daily ${t}`}),o(D(`Routine set: every day at {time}. It is listed under Goals.`,{time:t}))}catch(e){o(e.message)}},I=e=>{N(null);let t=m.find(t=>t.id===e.category)??m[m.length-1],n=D(`I'd like to create a {category} goal. Ask me a few short questions, one at a time (what exactly I want, why and by when, how often to check in), then create it with concrete steps using the goals tool.`,{category:D(t.label)});r(`main`,`${n} ${e.prompt}`).catch(e=>o(e.message||D(`Could not send`))),a(`main`)},L=e=>e.kind===`routine`||e.kind===`goal`?e.kind:`chat`;return(0,S.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,S.jsx)(p,{title:D(`Ideas`),children:(0,S.jsx)(`button`,{type:`button`,onClick:()=>void k(!0),disabled:C,"aria-label":D(`Refresh ideas`),className:`flex h-10 w-10 items-center justify-center rounded-full bg-surface text-fg shadow-[0_1px_4px_rgba(0,0,0,0.14)] disabled:opacity-60 dark:border dark:border-border dark:shadow-none`,children:C?(0,S.jsx)(c,{size:20,className:`animate-spin`}):(0,S.jsx)(u,{size:19})})}),(0,S.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-6`,children:[j.map(({id:e,title:t,ideas:n},r)=>(0,S.jsxs)(`section`,{children:[(r>0||!A)&&(0,S.jsx)(`h2`,{className:`px-5 pt-[22px] pb-1 text-[20px] font-bold`,children:A?D(t):t}),(0,S.jsx)(`ul`,{children:n.map(e=>(0,S.jsxs)(`li`,{children:[(0,S.jsxs)(`button`,{type:`button`,onClick:()=>N({idea:e,emoji:e.emoji}),className:`flex w-full items-start gap-4 px-5 py-3.5 text-left active:bg-surface-2/70`,children:[(0,S.jsx)(`span`,{className:`flex w-10 shrink-0 justify-center text-[28px] leading-[34px]`,children:e.emoji}),(0,S.jsxs)(`span`,{className:`min-w-0 flex-1 space-y-1`,children:[(0,S.jsx)(`span`,{className:`block text-[16px] font-semibold leading-[22px]`,children:e.title}),e.detail&&(0,S.jsx)(`span`,{className:`line-clamp-4 block text-[13.5px] leading-[19px] text-muted`,children:e.detail})]})]}),(0,S.jsx)(`div`,{className:`ml-[76px] mr-5 border-b border-border/70`})]},e.title))})]},e)),v&&(0,S.jsx)(`div`,{className:`px-5 pt-4 text-[12.5px] text-muted`,children:v.source===`model`?D(`Generated {when}`,{when:e(v.generated_at)}):D(`Starter ideas. Refresh once {name} knows you better.`,{name:E})})]}),(0,S.jsx)(d,{open:M!==null,onClose:()=>N(null),children:M&&(0,S.jsxs)(`div`,{className:`px-1 pb-2`,children:[(0,S.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,S.jsx)(`span`,{className:`text-[30px] leading-none`,children:M.emoji}),(0,S.jsx)(`h3`,{className:`flex-1 text-[18px] font-bold leading-6`,children:M.idea.title})]}),M.idea.detail&&(0,S.jsx)(`p`,{className:`mt-3 text-[15px] leading-[22px]`,children:M.idea.detail}),(0,S.jsxs)(`div`,{className:`mt-3.5 flex items-center gap-1.5 text-[13px] text-muted`,children:[L(M.idea)===`routine`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(h,{size:16}),` `,D(`Creates a routine`),M.idea.time?` · ${M.idea.time}`:``]}),L(M.idea)===`goal`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(g,{size:16}),` `,D(`Creates a goal`)]}),L(M.idea)===`chat`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(f,{size:16}),` `,D(`Starts a conversation`)]})]}),L(M.idea)===`routine`&&(0,S.jsxs)(`button`,{type:`button`,onClick:()=>void F(M.idea),className:`mt-[22px] flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,S.jsx)(h,{size:18}),` `,D(`Create routine`)]}),L(M.idea)===`goal`&&(0,S.jsxs)(`button`,{type:`button`,onClick:()=>I(M.idea),className:`mt-[22px] flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,S.jsx)(g,{size:18}),` `,D(`Start goal`)]}),L(M.idea)===`chat`&&(0,S.jsxs)(`button`,{type:`button`,onClick:()=>P(M.idea),className:`mt-[22px] flex h-[50px] w-full items-center justify-center gap-2 rounded-full bg-accent text-[16px] font-semibold text-accent-fg`,children:[(0,S.jsx)(f,{size:18}),` `,D(`Send to chat`)]})]})})]})}function T(e){let t=C[C.length-1],n=C.map(e=>({id:e.id,title:e.label,ideas:[]}));for(let r of e){let e=C.find(e=>e.id===(r.area||t.id))??t;(n.find(t=>t.id===e.id)??n[n.length-1]).ideas.push({...r,emoji:e.emoji})}return n.filter(e=>e.ideas.length>0)}export{w as IdeasScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/IdentityForm-Da0A3oTS.js b/nanomuse/server/static/assets/IdentityForm-g-zvQ2IC.js similarity index 97% rename from nanomuse/server/static/assets/IdentityForm-Da0A3oTS.js rename to nanomuse/server/static/assets/IdentityForm-g-zvQ2IC.js index 66ede8eb..edd98223 100644 --- a/nanomuse/server/static/assets/IdentityForm-Da0A3oTS.js +++ b/nanomuse/server/static/assets/IdentityForm-g-zvQ2IC.js @@ -1 +1 @@ -import{E as e,J as t,K as n,Q as r,Y as i,b as a,i as o}from"./MuseHeader-DHtaUVEa.js";import{r as s}from"./AvatarPicker-zOz-S1nk.js";var c=t(`shuffle`,[[`path`,{d:`m18 14 4 4-4 4`,key:`10pe0f`}],[`path`,{d:`m18 2 4 4-4 4`,key:`pucp1d`}],[`path`,{d:`M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22`,key:`1ailkh`}],[`path`,{d:`M2 6h1.972a4 4 0 0 1 3.6 2.2`,key:`km57vx`}],[`path`,{d:`M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45`,key:`os18l9`}]]),l=r(i(),1),u=a(),d=[`Muse`,`Veda`,`Nova`,`Pip`,`Sage`,`Juno`,`Kit`,`Wren`,`Iris`,`Milo`,`Echo`,`Remy`,`Otto`,`Luna`,`Ari`,`Bea`,`Nico`,`Zed`,`小墨`,`阿柒`,`小满`,`知了`,`豆豆`,`小竹`],f=[`formal`,`casual`,`playful`,`concise`],p=[`short`,`detailed`,`bullets`],m={formal:`Formal`,casual:`Casual`,playful:`Playful`,concise:`Concise`},h={short:`Short replies`,detailed:`Detailed`,bullets:`Bullet points`};function g(e,t){let n=d.filter(t=>t!==e),r=[],i=t||1,a=[...n];for(;r.length<6&&a.length;)i=(i*9301+49297)%233280,r.push(a.splice(i%a.length,1)[0]);return r}function _({active:t,onClick:n,children:r}){return(0,u.jsx)(`button`,{type:`button`,onClick:n,className:e(`rounded-full px-3 py-1.5 text-[13px] border transition-colors`,t?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:r})}function v({value:t,onChange:r,suggestions:i=!0,askUserName:a=!0,inputCls:d,onStudio:v}){let y=n(),b=(0,l.useId)(),[x,S]=(0,l.useState)(7),C=(0,l.useMemo)(()=>g(t.name,x),[t.name,x]),w=e=>r({...t,...e}),T={...t,proactivity:`default`,proactive:!0,goal_interval_minutes:60,quiet_hours:``};return(0,u.jsxs)(`div`,{className:`space-y-5`,children:[(0,u.jsxs)(`div`,{className:`flex items-start gap-4`,children:[(0,u.jsx)(o,{profile:T,size:72}),(0,u.jsxs)(`div`,{className:`flex-1 min-w-0 space-y-2`,children:[(0,u.jsx)(`div`,{children:(0,u.jsx)(`input`,{value:t.name,onChange:e=>w({name:e.target.value.slice(0,20)}),maxLength:20,className:e(d,`text-[17px] font-medium`),placeholder:`nanoMuse`,"aria-label":y(`Name`),autoCapitalize:`words`})}),(0,u.jsx)(`input`,{value:t.tagline,onChange:e=>w({tagline:e.target.value.slice(0,60)}),maxLength:60,className:e(d,`text-[14px]`),placeholder:y(`A line under the name, e.g. Your day, sorted.`),"aria-label":y(`Tagline`)})]})]}),i&&(0,u.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[C.map(e=>(0,u.jsx)(_,{active:t.name===e,onClick:()=>w({name:e}),children:e},e)),(0,u.jsx)(`button`,{type:`button`,onClick:()=>S(e=>e+1),"aria-label":y(`More names`),className:`rounded-full border border-border p-1.5 text-muted`,children:(0,u.jsx)(c,{size:14})})]}),(0,u.jsx)(s,{value:{avatar:t.avatar,emoji:t.emoji,color:t.color},onChange:e=>w(e),onStudio:v}),(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{className:`text-[12px] text-muted`,children:y(`Tone`)}),(0,u.jsx)(`div`,{className:`mt-1.5 flex flex-wrap gap-1.5`,children:f.map(e=>(0,u.jsx)(_,{active:t.tone===e,onClick:()=>w({tone:t.tone===e?``:e}),children:y(m[e])},e))})]}),(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{className:`text-[12px] text-muted`,children:y(`How much it says`)}),(0,u.jsx)(`div`,{className:`mt-1.5 flex flex-wrap gap-1.5`,children:p.map(e=>(0,u.jsx)(_,{active:t.communication===e,onClick:()=>w({communication:t.communication===e?``:e}),children:y(h[e])},e))})]}),(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{htmlFor:`${b}-style`,className:`text-[12px] text-muted`,children:y(`Anything else about how it should be`)}),(0,u.jsx)(`textarea`,{id:`${b}-style`,value:t.style,onChange:e=>w({style:e.target.value}),rows:2,placeholder:y(`e.g. A little witty. Uses metric units. Never uses emoji.`),className:e(d,`mt-1 resize-none text-[14px]`)})]}),a&&(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{htmlFor:`${b}-user`,className:`text-[12px] text-muted`,children:y(`What it calls you`)}),(0,u.jsx)(`input`,{id:`${b}-user`,value:t.user_name,onChange:e=>w({user_name:e.target.value}),maxLength:60,placeholder:y(`Your name`),className:e(d,`mt-1 text-[14px]`)})]})]})}function y(e,t,n){return{name:e?.name??`nanoMuse`,tagline:e?.tagline??``,avatar:e?.avatar??t,emoji:e?.emoji??`✨`,color:e?.color??n,tone:e?.tone??``,communication:e?.communication??``,style:e?.style??``,user_name:e?.user_name??``}}function b(e){return{...e,name:e.name.trim()||`nanoMuse`,tagline:e.tagline.trim(),style:e.style.trim(),user_name:e.user_name.trim()}}export{b as n,y as r,v as t}; \ No newline at end of file +import{E as e,J as t,K as n,Q as r,Y as i,b as a,i as o}from"./MuseHeader-RC--uR8o.js";import{r as s}from"./AvatarPicker-BCihUURa.js";var c=t(`shuffle`,[[`path`,{d:`m18 14 4 4-4 4`,key:`10pe0f`}],[`path`,{d:`m18 2 4 4-4 4`,key:`pucp1d`}],[`path`,{d:`M2 18h1.973a4 4 0 0 0 3.3-1.7l5.454-8.6a4 4 0 0 1 3.3-1.7H22`,key:`1ailkh`}],[`path`,{d:`M2 6h1.972a4 4 0 0 1 3.6 2.2`,key:`km57vx`}],[`path`,{d:`M22 18h-6.041a4 4 0 0 1-3.3-1.8l-.359-.45`,key:`os18l9`}]]),l=r(i(),1),u=a(),d=[`Muse`,`Veda`,`Nova`,`Pip`,`Sage`,`Juno`,`Kit`,`Wren`,`Iris`,`Milo`,`Echo`,`Remy`,`Otto`,`Luna`,`Ari`,`Bea`,`Nico`,`Zed`,`小墨`,`阿柒`,`小满`,`知了`,`豆豆`,`小竹`],f=[`formal`,`casual`,`playful`,`concise`],p=[`short`,`detailed`,`bullets`],m={formal:`Formal`,casual:`Casual`,playful:`Playful`,concise:`Concise`},h={short:`Short replies`,detailed:`Detailed`,bullets:`Bullet points`};function g(e,t){let n=d.filter(t=>t!==e),r=[],i=t||1,a=[...n];for(;r.length<6&&a.length;)i=(i*9301+49297)%233280,r.push(a.splice(i%a.length,1)[0]);return r}function _({active:t,onClick:n,children:r}){return(0,u.jsx)(`button`,{type:`button`,onClick:n,className:e(`rounded-full px-3 py-1.5 text-[13px] border transition-colors`,t?`border-accent bg-accent/10 text-accent font-medium`:`border-border text-muted`),children:r})}function v({value:t,onChange:r,suggestions:i=!0,askUserName:a=!0,inputCls:d,onStudio:v}){let y=n(),b=(0,l.useId)(),[x,S]=(0,l.useState)(7),C=(0,l.useMemo)(()=>g(t.name,x),[t.name,x]),w=e=>r({...t,...e}),T={...t,proactivity:`default`,proactive:!0,goal_interval_minutes:60,quiet_hours:``};return(0,u.jsxs)(`div`,{className:`space-y-5`,children:[(0,u.jsxs)(`div`,{className:`flex items-start gap-4`,children:[(0,u.jsx)(o,{profile:T,size:72}),(0,u.jsxs)(`div`,{className:`flex-1 min-w-0 space-y-2`,children:[(0,u.jsx)(`div`,{children:(0,u.jsx)(`input`,{value:t.name,onChange:e=>w({name:e.target.value.slice(0,20)}),maxLength:20,className:e(d,`text-[17px] font-medium`),placeholder:`nanoMuse`,"aria-label":y(`Name`),autoCapitalize:`words`})}),(0,u.jsx)(`input`,{value:t.tagline,onChange:e=>w({tagline:e.target.value.slice(0,60)}),maxLength:60,className:e(d,`text-[14px]`),placeholder:y(`A line under the name, e.g. Your day, sorted.`),"aria-label":y(`Tagline`)})]})]}),i&&(0,u.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5`,children:[C.map(e=>(0,u.jsx)(_,{active:t.name===e,onClick:()=>w({name:e}),children:e},e)),(0,u.jsx)(`button`,{type:`button`,onClick:()=>S(e=>e+1),"aria-label":y(`More names`),className:`rounded-full border border-border p-1.5 text-muted`,children:(0,u.jsx)(c,{size:14})})]}),(0,u.jsx)(s,{value:{avatar:t.avatar,emoji:t.emoji,color:t.color},onChange:e=>w(e),onStudio:v}),(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{className:`text-[12px] text-muted`,children:y(`Tone`)}),(0,u.jsx)(`div`,{className:`mt-1.5 flex flex-wrap gap-1.5`,children:f.map(e=>(0,u.jsx)(_,{active:t.tone===e,onClick:()=>w({tone:t.tone===e?``:e}),children:y(m[e])},e))})]}),(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{className:`text-[12px] text-muted`,children:y(`How much it says`)}),(0,u.jsx)(`div`,{className:`mt-1.5 flex flex-wrap gap-1.5`,children:p.map(e=>(0,u.jsx)(_,{active:t.communication===e,onClick:()=>w({communication:t.communication===e?``:e}),children:y(h[e])},e))})]}),(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{htmlFor:`${b}-style`,className:`text-[12px] text-muted`,children:y(`Anything else about how it should be`)}),(0,u.jsx)(`textarea`,{id:`${b}-style`,value:t.style,onChange:e=>w({style:e.target.value}),rows:2,placeholder:y(`e.g. A little witty. Uses metric units. Never uses emoji.`),className:e(d,`mt-1 resize-none text-[14px]`)})]}),a&&(0,u.jsxs)(`div`,{children:[(0,u.jsx)(`label`,{htmlFor:`${b}-user`,className:`text-[12px] text-muted`,children:y(`What it calls you`)}),(0,u.jsx)(`input`,{id:`${b}-user`,value:t.user_name,onChange:e=>w({user_name:e.target.value}),maxLength:60,placeholder:y(`Your name`),className:e(d,`mt-1 text-[14px]`)})]})]})}function y(e,t,n){return{name:e?.name??`nanoMuse`,tagline:e?.tagline??``,avatar:e?.avatar??t,emoji:e?.emoji??`✨`,color:e?.color??n,tone:e?.tone??``,communication:e?.communication??``,style:e?.style??``,user_name:e?.user_name??``}}function b(e){return{...e,name:e.name.trim()||`nanoMuse`,tagline:e.tagline.trim(),style:e.style.trim(),user_name:e.user_name.trim()}}export{b as n,y as r,v as t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/LibraryScreen-Df3fNYTa.js b/nanomuse/server/static/assets/LibraryScreen-BoofKOos.js similarity index 97% rename from nanomuse/server/static/assets/LibraryScreen-Df3fNYTa.js rename to nanomuse/server/static/assets/LibraryScreen-BoofKOos.js index 7be4d8e0..931d4505 100644 --- a/nanomuse/server/static/assets/LibraryScreen-Df3fNYTa.js +++ b/nanomuse/server/static/assets/LibraryScreen-BoofKOos.js @@ -1 +1 @@ -import{D as e,E as t,J as n,K as r,Q as i,S as a,Y as o,b as s,f as c,q as l,x as u}from"./MuseHeader-DHtaUVEa.js";import{F as d,S as f,Y as p,bt as m,d as h,ft as g,gt as _,pt as v}from"./index-CrH9-E2E.js";var y=n(`file-image`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`circle`,{cx:`10`,cy:`12`,r:`2`,key:`737tya`}],[`path`,{d:`m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22`,key:`wt3hpn`}]]),b=n(`file-spreadsheet`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 13h2`,key:`yr2amv`}],[`path`,{d:`M14 13h2`,key:`un5t4a`}],[`path`,{d:`M8 17h2`,key:`2yhykz`}],[`path`,{d:`M14 17h2`,key:`10kma7`}]]),x=n(`library-big`,[[`rect`,{width:`8`,height:`18`,x:`3`,y:`3`,rx:`1`,key:`oynpb5`}],[`path`,{d:`M7 3v18`,key:`bbkbws`}],[`path`,{d:`M20.4 18.9c.2.5-.1 1.1-.6 1.3l-1.9.7c-.5.2-1.1-.1-1.3-.6L11.1 5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z`,key:`1qboyk`}]]),S=i(o(),1),C=s(),w=new Set([`png`,`jpg`,`jpeg`,`gif`,`webp`,`svg`,`heic`,`mp3`,`wav`,`m4a`,`ogg`,`flac`,`mp4`,`mov`,`webm`,`mkv`]);function T(e){return w.has(e.toLowerCase().split(`.`).pop()??``)}function E(){let{state:e,openFile:t}=c(),[n,i]=(0,S.useState)(null),[a,o]=(0,S.useState)(null),[s,d]=(0,S.useState)(0),[m,g]=(0,S.useState)(0),[_,v]=(0,S.useState)(``),y=r(),b=Object.values(e.events).reduce((e,t)=>e+t.length,0);(0,S.useEffect)(()=>{let e=!0;return u.files(500).then(t=>{e&&(i(t),o(null))}).catch(t=>e&&o(t.message)),()=>{e=!1}},[b,e.feedVersion,s]);let w=(0,S.useMemo)(()=>{let e=_.trim().toLowerCase();return(n??[]).filter(t=>T(t.name)===(m===1)&&(!e||t.path.toLowerCase().includes(e)))},[n,m,_]);return(0,C.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,C.jsx)(h,{title:y(`Library`)}),(0,C.jsxs)(`div`,{className:`shrink-0 px-4 pt-1 pb-2.5`,children:[(0,C.jsx)(D,{labels:[y(`Artifacts`),y(`Media`)],selected:m,onSelect:e=>g(e)}),(n?.length??0)>8&&(0,C.jsxs)(`label`,{className:`mt-2.5 flex items-center gap-2 rounded-full bg-surface-2 px-3.5 py-2`,children:[(0,C.jsx)(p,{size:16,className:`text-muted`}),(0,C.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),placeholder:y(`Search files`),className:`flex-1 bg-transparent text-[14.5px] outline-none`})]})]}),(0,C.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-6`,children:[n===null&&a&&(0,C.jsx)(f,{message:a,onRetry:()=>d(e=>e+1)}),n===null&&!a&&(0,C.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,C.jsx)(l,{className:`animate-spin`,size:20})}),n!==null&&w.length===0&&(0,C.jsxs)(`div`,{className:`flex flex-col items-center px-8 pt-9 text-center`,children:[(0,C.jsx)(x,{size:34,className:`text-muted`}),(0,C.jsx)(`div`,{className:`mt-3.5 text-[16px] font-medium text-muted`,children:y(_?`No files match.`:m===0?`Nothing created yet`:`No media yet`)}),!_&&(0,C.jsx)(`p`,{className:`mt-1 text-[12px] text-muted`,children:y(m===0?`Documents, tables and pages I write for you will show up here.`:`Photos, screenshots and recordings I produce will show up here.`)})]}),(0,C.jsx)(`ul`,{children:w.map(e=>(0,C.jsx)(`li`,{children:(0,C.jsx)(O,{file:e,onOpen:()=>t(e.path)})},e.path))})]})]})}function D({labels:e,selected:n,onSelect:r}){return(0,C.jsx)(`div`,{role:`tablist`,className:`flex h-12 w-full rounded-full border border-border bg-surface p-1`,children:e.map((e,i)=>(0,C.jsx)(`button`,{type:`button`,role:`tab`,"aria-selected":i===n,onClick:()=>r(i),className:t(`flex-1 rounded-full text-[14px] font-semibold transition`,i===n?`bg-surface-2`:`bg-transparent`),children:e},e))})}function O({file:t,onOpen:n}){let r=e(t.name),i=r===`html`?(0,C.jsx)(g,{size:22}):r===`image`?(0,C.jsx)(y,{size:22}):r===`data`?(0,C.jsx)(b,{size:22}):r===`code`?(0,C.jsx)(_,{size:22}):r===`event`?(0,C.jsx)(m,{size:22}):(0,C.jsx)(v,{size:22}),o=t.path.includes(`/`)?t.path.slice(0,t.path.lastIndexOf(`/`)):``;return(0,C.jsxs)(`button`,{type:`button`,onClick:n,className:`flex w-full items-center gap-3.5 px-4 py-2 text-left active:bg-surface-2/70`,children:[(0,C.jsx)(`span`,{className:`flex h-[46px] w-[46px] shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2 text-muted`,children:r===`image`?(0,C.jsx)(`img`,{src:a(t.path),alt:``,loading:`lazy`,className:`h-full w-full object-cover`}):i}),(0,C.jsxs)(`span`,{className:`min-w-0 flex-1 space-y-0.5`,children:[(0,C.jsx)(`span`,{className:`block truncate text-[15px] font-medium`,children:t.name}),(0,C.jsx)(`span`,{className:`block truncate text-[12px] text-muted`,children:[d(t.modified),k(t.size),o].filter(Boolean).join(` · `)})]})]})}function k(e){return e<1024?`${e} B`:e<1048576?`${(e/1024).toFixed(+(e<10240))} KB`:`${(e/1024/1024).toFixed(1)} MB`}export{E as LibraryScreen}; \ No newline at end of file +import{D as e,E as t,J as n,K as r,Q as i,S as a,Y as o,b as s,f as c,q as l,x as u}from"./MuseHeader-RC--uR8o.js";import{F as d,S as f,Y as p,bt as m,d as h,ft as g,gt as _,pt as v}from"./index-tYqPtwPX.js";var y=n(`file-image`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`circle`,{cx:`10`,cy:`12`,r:`2`,key:`737tya`}],[`path`,{d:`m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22`,key:`wt3hpn`}]]),b=n(`file-spreadsheet`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M8 13h2`,key:`yr2amv`}],[`path`,{d:`M14 13h2`,key:`un5t4a`}],[`path`,{d:`M8 17h2`,key:`2yhykz`}],[`path`,{d:`M14 17h2`,key:`10kma7`}]]),x=n(`library-big`,[[`rect`,{width:`8`,height:`18`,x:`3`,y:`3`,rx:`1`,key:`oynpb5`}],[`path`,{d:`M7 3v18`,key:`bbkbws`}],[`path`,{d:`M20.4 18.9c.2.5-.1 1.1-.6 1.3l-1.9.7c-.5.2-1.1-.1-1.3-.6L11.1 5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z`,key:`1qboyk`}]]),S=i(o(),1),C=s(),w=new Set([`png`,`jpg`,`jpeg`,`gif`,`webp`,`svg`,`heic`,`mp3`,`wav`,`m4a`,`ogg`,`flac`,`mp4`,`mov`,`webm`,`mkv`]);function T(e){return w.has(e.toLowerCase().split(`.`).pop()??``)}function E(){let{state:e,openFile:t}=c(),[n,i]=(0,S.useState)(null),[a,o]=(0,S.useState)(null),[s,d]=(0,S.useState)(0),[m,g]=(0,S.useState)(0),[_,v]=(0,S.useState)(``),y=r(),b=Object.values(e.events).reduce((e,t)=>e+t.length,0);(0,S.useEffect)(()=>{let e=!0;return u.files(500).then(t=>{e&&(i(t),o(null))}).catch(t=>e&&o(t.message)),()=>{e=!1}},[b,e.feedVersion,s]);let w=(0,S.useMemo)(()=>{let e=_.trim().toLowerCase();return(n??[]).filter(t=>T(t.name)===(m===1)&&(!e||t.path.toLowerCase().includes(e)))},[n,m,_]);return(0,C.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,C.jsx)(h,{title:y(`Library`)}),(0,C.jsxs)(`div`,{className:`shrink-0 px-4 pt-1 pb-2.5`,children:[(0,C.jsx)(D,{labels:[y(`Artifacts`),y(`Media`)],selected:m,onSelect:e=>g(e)}),(n?.length??0)>8&&(0,C.jsxs)(`label`,{className:`mt-2.5 flex items-center gap-2 rounded-full bg-surface-2 px-3.5 py-2`,children:[(0,C.jsx)(p,{size:16,className:`text-muted`}),(0,C.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),placeholder:y(`Search files`),className:`flex-1 bg-transparent text-[14.5px] outline-none`})]})]}),(0,C.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-6`,children:[n===null&&a&&(0,C.jsx)(f,{message:a,onRetry:()=>d(e=>e+1)}),n===null&&!a&&(0,C.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,C.jsx)(l,{className:`animate-spin`,size:20})}),n!==null&&w.length===0&&(0,C.jsxs)(`div`,{className:`flex flex-col items-center px-8 pt-9 text-center`,children:[(0,C.jsx)(x,{size:34,className:`text-muted`}),(0,C.jsx)(`div`,{className:`mt-3.5 text-[16px] font-medium text-muted`,children:y(_?`No files match.`:m===0?`Nothing created yet`:`No media yet`)}),!_&&(0,C.jsx)(`p`,{className:`mt-1 text-[12px] text-muted`,children:y(m===0?`Documents, tables and pages I write for you will show up here.`:`Photos, screenshots and recordings I produce will show up here.`)})]}),(0,C.jsx)(`ul`,{children:w.map(e=>(0,C.jsx)(`li`,{children:(0,C.jsx)(O,{file:e,onOpen:()=>t(e.path)})},e.path))})]})]})}function D({labels:e,selected:n,onSelect:r}){return(0,C.jsx)(`div`,{role:`tablist`,className:`flex h-12 w-full rounded-full border border-border bg-surface p-1`,children:e.map((e,i)=>(0,C.jsx)(`button`,{type:`button`,role:`tab`,"aria-selected":i===n,onClick:()=>r(i),className:t(`flex-1 rounded-full text-[14px] font-semibold transition`,i===n?`bg-surface-2`:`bg-transparent`),children:e},e))})}function O({file:t,onOpen:n}){let r=e(t.name),i=r===`html`?(0,C.jsx)(g,{size:22}):r===`image`?(0,C.jsx)(y,{size:22}):r===`data`?(0,C.jsx)(b,{size:22}):r===`code`?(0,C.jsx)(_,{size:22}):r===`event`?(0,C.jsx)(m,{size:22}):(0,C.jsx)(v,{size:22}),o=t.path.includes(`/`)?t.path.slice(0,t.path.lastIndexOf(`/`)):``;return(0,C.jsxs)(`button`,{type:`button`,onClick:n,className:`flex w-full items-center gap-3.5 px-4 py-2 text-left active:bg-surface-2/70`,children:[(0,C.jsx)(`span`,{className:`flex h-[46px] w-[46px] shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-surface-2 text-muted`,children:r===`image`?(0,C.jsx)(`img`,{src:a(t.path),alt:``,loading:`lazy`,className:`h-full w-full object-cover`}):i}),(0,C.jsxs)(`span`,{className:`min-w-0 flex-1 space-y-0.5`,children:[(0,C.jsx)(`span`,{className:`block truncate text-[15px] font-medium`,children:t.name}),(0,C.jsx)(`span`,{className:`block truncate text-[12px] text-muted`,children:[d(t.modified),k(t.size),o].filter(Boolean).join(` · `)})]})]})}function k(e){return e<1024?`${e} B`:e<1048576?`${(e/1024).toFixed(+(e<10240))} KB`:`${(e/1024/1024).toFixed(1)} MB`}export{E as LibraryScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/MemoryScreen-BJU-TDeO.js b/nanomuse/server/static/assets/MemoryScreen-DHNxjNpq.js similarity index 97% rename from nanomuse/server/static/assets/MemoryScreen-BJU-TDeO.js rename to nanomuse/server/static/assets/MemoryScreen-DHNxjNpq.js index 98551881..ec73c168 100644 --- a/nanomuse/server/static/assets/MemoryScreen-BJU-TDeO.js +++ b/nanomuse/server/static/assets/MemoryScreen-DHNxjNpq.js @@ -1 +1 @@ -import{A as e,J as t,K as n,Q as r,Y as i,b as a,f as o,q as s,x as c}from"./MuseHeader-DHtaUVEa.js";import{B as l,S as u,U as d,X as f,xt as p}from"./index-CrH9-E2E.js";import{t as m}from"./BackBar-B5YUZVK1.js";var h=t(`undo-2`,[[`path`,{d:`M9 14 4 9l5-5`,key:`102s5s`}],[`path`,{d:`M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11`,key:`f3b9sd`}]]),g=r(i(),1),_=a(),v=[`profile`,`preference`,`people`,`routine`,`constraint`,`general`];function y(){let{state:t,toast:r,openThread:i,setTab:a}=o(),[y,b]=(0,g.useState)(null),[x,S]=(0,g.useState)(null),[C,w]=(0,g.useState)([]),[T,E]=(0,g.useState)(``),[D,O]=(0,g.useState)(`profile`),[k,A]=(0,g.useState)(!1),[j,M]=(0,g.useState)(!1),N=t.profile?.name??`nanoMuse`,P=n(),F=()=>Promise.all([c.memory().then(b),c.memoryChanges().then(w)]).then(()=>S(null)).catch(e=>y===null?S(e.message):r(e.message));(0,g.useEffect)(()=>{F()},[t.memoryVersion]);let I=(0,g.useMemo)(()=>{let e=new Map;for(let t of y??[])e.set(t.category,[...e.get(t.category)??[],t]);return[...e.entries()].sort((e,t)=>t[1].length-e[1].length)},[y]),L=async()=>{if(T.trim()){A(!0);try{await c.addMemory(T.trim(),D),E(``),await F()}catch(e){r(e.message)}finally{A(!1)}}},R=async e=>{if(window.confirm(P(`Forget “{text}”?`,{text:e.content.slice(0,60)})))try{await c.forgetMemory(e.id),b(t=>t?.filter(t=>t.id!==e.id)??t)}catch(e){r(e.message)}},z=async()=>{M(!0);try{let e=await c.tidyMemory();r(e.changed===0?P(`Nothing to tidy: {n} memories, all distinct.`,{n:e.considered}):P(`Tidied: {merged} merged, {dropped} dropped. Undo below if needed.`,{merged:e.merged.length,dropped:e.dropped.length})),await F()}catch(e){r(e.message)}finally{M(!1)}},B=async e=>{try{await c.restoreMemoryChange(e.id),await F()}catch(e){r(e.message)}};return(0,_.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,_.jsx)(m,{title:P(`Memory`),description:P(`What {name} remembers about you. Read it, add to it, or make {name} forget; nothing here is hidden from you.`,{name:N}),actions:(y?.length??0)>=2&&(0,_.jsxs)(`button`,{type:`button`,disabled:j,onClick:()=>void z(),title:P(`Merge lines that say the same thing and drop what was never a fact about you. Every change can be undone.`),className:`flex h-10 items-center gap-1.5 rounded-full bg-surface px-3.5 text-[13.5px] font-medium shadow-[0_1px_4px_rgba(0,0,0,0.14)] disabled:opacity-50 dark:border dark:border-border dark:shadow-none`,children:[(0,_.jsx)(d,{size:15,className:j?`animate-pulse text-accent`:`text-accent`}),P(j?`Tidying…`:`Tidy up`)]})}),(0,_.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-6 space-y-5`,children:[(0,_.jsxs)(`div`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm p-3.5 space-y-2.5`,children:[(0,_.jsx)(`textarea`,{value:T,onChange:e=>E(e.target.value),rows:2,placeholder:P(`Tell {name} something to remember, e.g. “I'm vegetarian” or “My sister's birthday is 14 May”`,{name:N}),className:`w-full resize-none rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`select`,{value:D,onChange:e=>O(e.target.value),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:v.map(e=>(0,_.jsx)(`option`,{value:e,children:P(e)},e))}),(0,_.jsx)(`div`,{className:`flex-1`}),(0,_.jsxs)(`button`,{type:`button`,disabled:!T.trim()||k,onClick:()=>void L(),className:`rounded-2xl bg-accent text-accent-fg px-3.5 py-2 text-[14px] font-medium flex items-center gap-1.5 disabled:opacity-50`,children:[(0,_.jsx)(f,{size:16}),` `,P(`Remember`)]})]})]}),y===null&&x&&(0,_.jsx)(u,{message:x,onRetry:()=>void F()}),y===null&&!x&&(0,_.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,_.jsx)(s,{className:`animate-spin`,size:20})}),y!==null&&y.length===0&&(0,_.jsxs)(`div`,{className:`rounded-3xl border border-dashed border-border p-6 text-center`,children:[(0,_.jsx)(p,{className:`mx-auto text-accent`}),(0,_.jsx)(`div`,{className:`mt-2 font-semibold`,children:P(`Nothing remembered yet`)}),(0,_.jsx)(`p`,{className:`mt-1 text-[13.5px] text-muted`,children:P(`{name} saves durable facts you share in chat (preferences, people, routines) and never secrets.`,{name:N})}),(0,_.jsx)(`button`,{type:`button`,onClick:()=>{i(`main`),a(`chat`)},className:`mt-3 rounded-full bg-accent px-4 py-2 text-[13.5px] font-semibold text-accent-fg`,children:P(`Tell {name} something to remember`,{name:N})})]}),I.map(([t,n])=>(0,_.jsxs)(`section`,{children:[(0,_.jsxs)(`div`,{className:`px-1 mb-2 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:[P(t),` · `,n.length]}),(0,_.jsx)(`ul`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm divide-y divide-border/70 overflow-hidden`,children:n.map(t=>(0,_.jsxs)(`li`,{className:`flex items-start gap-3 px-4 py-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,_.jsx)(`div`,{className:`text-[14.5px] leading-snug break-words`,children:t.content}),(0,_.jsxs)(`div`,{className:`mt-0.5 text-[11.5px] text-muted`,children:[t.source===`user`?P(`added by you`):P(`saved by {name}`,{name:N}),` · `,e(t.created_at),t.updated_at?` · ${P(`updated {when}`,{when:e(t.updated_at)})}`:``]})]}),(0,_.jsx)(`button`,{type:`button`,"aria-label":P(`Forget`),onClick:()=>void R(t),className:`p-1.5 text-muted hover:text-rose-500`,children:(0,_.jsx)(l,{size:17})})]},t.id))})]},t)),C.length>0&&(0,_.jsxs)(`section`,{children:[(0,_.jsx)(`div`,{className:`px-1 mb-2 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:P(`Recent changes`)}),(0,_.jsx)(`ul`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm divide-y divide-border/70 overflow-hidden`,children:C.map(t=>(0,_.jsx)(`li`,{className:`px-4 py-3 ${t.restored?`opacity-60`:``}`,children:(0,_.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0 text-[13.5px] leading-snug`,children:[(0,_.jsxs)(`div`,{className:`text-muted`,children:[t.action===`merge`?P(`Merged`):t.action===`drop`?P(`Dropped`):P(`Updated`),t.reason?` · ${t.reason}`:``,` · `,e(t.at),t.restored?` · ${P(`restored`)}`:``]}),t.before.map(e=>(0,_.jsx)(`div`,{className:`break-words line-through text-muted`,children:e.content},e.id)),t.after&&(0,_.jsxs)(`div`,{className:`break-words`,children:[`→ `,t.after.content]})]}),!t.restored&&(0,_.jsxs)(`button`,{type:`button`,onClick:()=>void B(t),className:`shrink-0 rounded-xl bg-surface-2 px-2.5 py-1.5 text-[12.5px] font-medium flex items-center gap-1`,children:[(0,_.jsx)(h,{size:14}),` `,P(`Undo`)]})]})},t.id))})]})]})]})}export{y as MemoryScreen}; \ No newline at end of file +import{A as e,J as t,K as n,Q as r,Y as i,b as a,f as o,q as s,x as c}from"./MuseHeader-RC--uR8o.js";import{B as l,S as u,U as d,X as f,xt as p}from"./index-tYqPtwPX.js";import{t as m}from"./BackBar-JEuC8Q_e.js";var h=t(`undo-2`,[[`path`,{d:`M9 14 4 9l5-5`,key:`102s5s`}],[`path`,{d:`M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11`,key:`f3b9sd`}]]),g=r(i(),1),_=a(),v=[`profile`,`preference`,`people`,`routine`,`constraint`,`general`];function y(){let{state:t,toast:r,openThread:i,setTab:a}=o(),[y,b]=(0,g.useState)(null),[x,S]=(0,g.useState)(null),[C,w]=(0,g.useState)([]),[T,E]=(0,g.useState)(``),[D,O]=(0,g.useState)(`profile`),[k,A]=(0,g.useState)(!1),[j,M]=(0,g.useState)(!1),N=t.profile?.name??`nanoMuse`,P=n(),F=()=>Promise.all([c.memory().then(b),c.memoryChanges().then(w)]).then(()=>S(null)).catch(e=>y===null?S(e.message):r(e.message));(0,g.useEffect)(()=>{F()},[t.memoryVersion]);let I=(0,g.useMemo)(()=>{let e=new Map;for(let t of y??[])e.set(t.category,[...e.get(t.category)??[],t]);return[...e.entries()].sort((e,t)=>t[1].length-e[1].length)},[y]),L=async()=>{if(T.trim()){A(!0);try{await c.addMemory(T.trim(),D),E(``),await F()}catch(e){r(e.message)}finally{A(!1)}}},R=async e=>{if(window.confirm(P(`Forget “{text}”?`,{text:e.content.slice(0,60)})))try{await c.forgetMemory(e.id),b(t=>t?.filter(t=>t.id!==e.id)??t)}catch(e){r(e.message)}},z=async()=>{M(!0);try{let e=await c.tidyMemory();r(e.changed===0?P(`Nothing to tidy: {n} memories, all distinct.`,{n:e.considered}):P(`Tidied: {merged} merged, {dropped} dropped. Undo below if needed.`,{merged:e.merged.length,dropped:e.dropped.length})),await F()}catch(e){r(e.message)}finally{M(!1)}},B=async e=>{try{await c.restoreMemoryChange(e.id),await F()}catch(e){r(e.message)}};return(0,_.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,_.jsx)(m,{title:P(`Memory`),description:P(`What {name} remembers about you. Read it, add to it, or make {name} forget; nothing here is hidden from you.`,{name:N}),actions:(y?.length??0)>=2&&(0,_.jsxs)(`button`,{type:`button`,disabled:j,onClick:()=>void z(),title:P(`Merge lines that say the same thing and drop what was never a fact about you. Every change can be undone.`),className:`flex h-10 items-center gap-1.5 rounded-full bg-surface px-3.5 text-[13.5px] font-medium shadow-[0_1px_4px_rgba(0,0,0,0.14)] disabled:opacity-50 dark:border dark:border-border dark:shadow-none`,children:[(0,_.jsx)(d,{size:15,className:j?`animate-pulse text-accent`:`text-accent`}),P(j?`Tidying…`:`Tidy up`)]})}),(0,_.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-6 space-y-5`,children:[(0,_.jsxs)(`div`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm p-3.5 space-y-2.5`,children:[(0,_.jsx)(`textarea`,{value:T,onChange:e=>E(e.target.value),rows:2,placeholder:P(`Tell {name} something to remember, e.g. “I'm vegetarian” or “My sister's birthday is 14 May”`,{name:N}),className:`w-full resize-none rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`select`,{value:D,onChange:e=>O(e.target.value),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:v.map(e=>(0,_.jsx)(`option`,{value:e,children:P(e)},e))}),(0,_.jsx)(`div`,{className:`flex-1`}),(0,_.jsxs)(`button`,{type:`button`,disabled:!T.trim()||k,onClick:()=>void L(),className:`rounded-2xl bg-accent text-accent-fg px-3.5 py-2 text-[14px] font-medium flex items-center gap-1.5 disabled:opacity-50`,children:[(0,_.jsx)(f,{size:16}),` `,P(`Remember`)]})]})]}),y===null&&x&&(0,_.jsx)(u,{message:x,onRetry:()=>void F()}),y===null&&!x&&(0,_.jsx)(`div`,{className:`flex justify-center py-10 text-muted`,children:(0,_.jsx)(s,{className:`animate-spin`,size:20})}),y!==null&&y.length===0&&(0,_.jsxs)(`div`,{className:`rounded-3xl border border-dashed border-border p-6 text-center`,children:[(0,_.jsx)(p,{className:`mx-auto text-accent`}),(0,_.jsx)(`div`,{className:`mt-2 font-semibold`,children:P(`Nothing remembered yet`)}),(0,_.jsx)(`p`,{className:`mt-1 text-[13.5px] text-muted`,children:P(`{name} saves durable facts you share in chat (preferences, people, routines) and never secrets.`,{name:N})}),(0,_.jsx)(`button`,{type:`button`,onClick:()=>{i(`main`),a(`chat`)},className:`mt-3 rounded-full bg-accent px-4 py-2 text-[13.5px] font-semibold text-accent-fg`,children:P(`Tell {name} something to remember`,{name:N})})]}),I.map(([t,n])=>(0,_.jsxs)(`section`,{children:[(0,_.jsxs)(`div`,{className:`px-1 mb-2 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:[P(t),` · `,n.length]}),(0,_.jsx)(`ul`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm divide-y divide-border/70 overflow-hidden`,children:n.map(t=>(0,_.jsxs)(`li`,{className:`flex items-start gap-3 px-4 py-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,_.jsx)(`div`,{className:`text-[14.5px] leading-snug break-words`,children:t.content}),(0,_.jsxs)(`div`,{className:`mt-0.5 text-[11.5px] text-muted`,children:[t.source===`user`?P(`added by you`):P(`saved by {name}`,{name:N}),` · `,e(t.created_at),t.updated_at?` · ${P(`updated {when}`,{when:e(t.updated_at)})}`:``]})]}),(0,_.jsx)(`button`,{type:`button`,"aria-label":P(`Forget`),onClick:()=>void R(t),className:`p-1.5 text-muted hover:text-rose-500`,children:(0,_.jsx)(l,{size:17})})]},t.id))})]},t)),C.length>0&&(0,_.jsxs)(`section`,{children:[(0,_.jsx)(`div`,{className:`px-1 mb-2 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:P(`Recent changes`)}),(0,_.jsx)(`ul`,{className:`rounded-3xl bg-surface border border-border/70 shadow-sm divide-y divide-border/70 overflow-hidden`,children:C.map(t=>(0,_.jsx)(`li`,{className:`px-4 py-3 ${t.restored?`opacity-60`:``}`,children:(0,_.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0 text-[13.5px] leading-snug`,children:[(0,_.jsxs)(`div`,{className:`text-muted`,children:[t.action===`merge`?P(`Merged`):t.action===`drop`?P(`Dropped`):P(`Updated`),t.reason?` · ${t.reason}`:``,` · `,e(t.at),t.restored?` · ${P(`restored`)}`:``]}),t.before.map(e=>(0,_.jsx)(`div`,{className:`break-words line-through text-muted`,children:e.content},e.id)),t.after&&(0,_.jsxs)(`div`,{className:`break-words`,children:[`→ `,t.after.content]})]}),!t.restored&&(0,_.jsxs)(`button`,{type:`button`,onClick:()=>void B(t),className:`shrink-0 rounded-xl bg-surface-2 px-2.5 py-1.5 text-[12.5px] font-medium flex items-center gap-1`,children:[(0,_.jsx)(h,{size:14}),` `,P(`Undo`)]})]})},t.id))})]})]})]})}export{y as MemoryScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/MuseHeader-DHtaUVEa.js b/nanomuse/server/static/assets/MuseHeader-DHtaUVEa.js deleted file mode 100644 index dfdaa911..00000000 --- a/nanomuse/server/static/assets/MuseHeader-DHtaUVEa.js +++ /dev/null @@ -1 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,o)=>(o=n==null?{}:e(i(n)),c(r||!n||!n.__esModule||!a.call(n,`default`)?t(o,`default`,{value:n,enumerable:!0}):o,n)),u=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function ee(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=u()})),f=l(d()),p=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),m=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),h=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),g=e=>{let t=h(e);return t.charAt(0).toUpperCase()+t.slice(1)},_={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},v=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},y=(0,f.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,f.createElement)(`svg`,{ref:c,..._,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:p(`lucide`,i),...!a&&!v(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,f.createElement)(e,t)),...Array.isArray(a)?a:[a]])),b=(e,t)=>{let n=(0,f.forwardRef)(({className:n,...r},i)=>(0,f.createElement)(y,{ref:i,iconNode:t,className:p(`lucide-${m(g(e))}`,`lucide-${e}`,n),...r}));return n.displayName=g(e),n},x=b(`loader-circle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),S=21600,C=32;function w(e=Date.now()){return(Math.floor(e/1e3/S)+2)*S}function ee(e,t,n){let r=new TextEncoder;return E(te(r.encode(e),r.encode(`${n}\n${t}`))).slice(0,C)}function T(e,t,n=Date.now()){let r=w(n),i=new URLSearchParams;return i.set(`exp`,String(r)),i.set(`sig`,ee(e,t,r)),i}function E(e){let t=``;for(let n of e)t+=n.toString(16).padStart(2,`0`);return t}function te(e,t){let n=new Uint8Array(64);n.set(e.length>64?k(e):e);let r=new Uint8Array(64+t.length),i=new Uint8Array(96);for(let e=0;e<64;e++)r[e]=n[e]^54,i[e]=n[e]^92;return r.set(t,64),i.set(k(r),64),k(i)}var D=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),O=(e,t)=>e>>>t|e<<32-t;function k(e){let t=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),n=new Uint8Array(e.length+9+63>>6<<6);n.set(e),n[e.length]=128;let r=new DataView(n.buffer),i=e.length*8;r.setUint32(n.length-8,Math.floor(i/4294967296)),r.setUint32(n.length-4,i>>>0);let a=new Uint32Array(64);for(let e=0;e>>3,i=O(n,17)^O(n,19)^n>>>10;a[e]=a[e-16]+r+a[e-7]+i>>>0}let n=t[0],i=t[1],o=t[2],s=t[3],c=t[4],l=t[5],u=t[6],d=t[7];for(let e=0;e<64;e++){let t=O(c,6)^O(c,11)^O(c,25),r=c&l^~c&u,f=d+t+r+D[e]+a[e]>>>0,p=(O(n,2)^O(n,13)^O(n,22))+(n&i^n&o^i&o)>>>0;d=u,u=l,l=c,c=s+f>>>0,s=o,o=i,i=n,n=f+p>>>0}t[0]+=n,t[1]+=i,t[2]+=o,t[3]+=s,t[4]+=c,t[5]+=l,t[6]+=u,t[7]+=d}let o=new Uint8Array(32),s=new DataView(o.buffer);for(let e=0;e<8;e++)s.setUint32(e*4,t[e]);return o}var A={Chat:`聊天`,Feed:`动态`,Ideas:`点子`,Goals:`目标`,Library:`资源库`,"Main chat":`主要聊天`,"Side chat":`旁聊`,"Reconnecting to your nanoMuse…":`正在重新连接你的 nanoMuse…`,"This demo has ended.":`这次体验已经结束。`,"This nanoMuse has stopped. Reload the page once it is running again.":`这个 nanoMuse 已停止。等它重新运行后,刷新页面即可。`,"Reconnecting…":`重新连接中…`,"Connect to your nanoMuse":`连接到你的 nanoMuse`,"This app talks to the nanoMuse server you run yourself. Scan the QR code printed by":`这个应用连接的是你自己运行的 nanoMuse 服务器。扫描下面命令打印的二维码`,", or paste the access token below.":`,或在下方粘贴访问令牌。`,"Access token":`访问令牌`,Connect:`连接`,Connected:`已连接`,"Hi, I'm {name}.":`你好,我是 {name}。`,"I don't just answer; I get things done: research, plans, files, code, email, long-running goals. Everything I do shows up here, and anything hard to undo waits for your approval.":`我不只是回答问题,而是把事情做完:调研、计划、文件、代码、邮件、长期目标。我做的每件事都会显示在这里,任何难以撤销的操作都会先等你批准。`,"What can you do for me?":`你能为我做什么?`,"Plan my week: ask me what's on my plate":`帮我规划这一周:先问问我手头有什么`,"Research and compare two options for me":`帮我调研并比较两个选项`,"Set up a long-term goal and track it":`建立一个长期目标并持续跟进`,"Answer {name}…":`回复 {name}…`,"Reply below to continue.":`在下方回复以继续。`,"{name} is working; anything you send now is picked up right away.":`{name} 正在工作,你现在发的消息会马上接着处理。`,"Working…":`处理中…`,"On it":`正在处理`,"Show the agent's steps":`显示执行步骤`,"Every tool it uses becomes a chip in the chat. Off, the chat keeps to the conversation and the line under the name says what it is on. This device only.":`每用一个工具就在对话里多一个小标签。关闭时对话只保留对话本身,名字下方的一行字说明它正在做什么。仅本设备。`,"On it: {request}":`正在处理:{request}`,"A star on GitHub helps":`GitHub 上的一颗 Star 帮得上忙`,"Welcome. nanoMuse is free, open source and non-profit, a personal agent for anyone who runs it. If that is worth something to you, a star on GitHub is how the next person finds it.":`欢迎。nanoMuse 免费、开源、非营利,做的是一个属于每个人的个人智能体。如果你认同,GitHub 上的一颗 Star 能让下一个人也找到这里。`,"Three tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.":`三个任务完成了。如果 nanoMuse 用得上,到 GitHub 点一颗 Star,下一个人也能找到它。`,"{n} tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.":`已经完成 {n} 个任务了。如果 nanoMuse 用得上,到 GitHub 点一颗 Star,下一个人也能找到它。`,"A week with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.":`和 nanoMuse 一起一周了。如果它在你的日常里站住了脚,到 GitHub 点一颗 Star,下一个人也能找到它。`,"A month with nanoMuse. If it has become part of your routine, a star on GitHub tells others it is worth a try.":`和 nanoMuse 一起一个月了。如果它成了你习惯的一部分,一颗 Star 会让更多人愿意试一试。`,"{n} days with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.":`和 nanoMuse 一起 {n} 天了。如果它在你的日常里站住了脚,到 GitHub 点一颗 Star,下一个人也能找到它。`,"Goal reached. If nanoMuse helped you get there, a star on GitHub tells the people building it that it did.":`目标达成了。如果 nanoMuse 帮你走到了这里,到 GitHub 点一颗 Star,做它的人就知道了。`,"Ten tasks together. If nanoMuse has become part of your day, a star on GitHub tells others it is worth a try.":`已经一起完成十个任务了。如果 nanoMuse 成了你日常的一部分,一颗 Star 会让更多人愿意试一试。`,"A new face, drawn for you. If you like where nanoMuse is going, a star on GitHub helps more people find it.":`一张为你画的新面孔。如果你喜欢 nanoMuse 现在的样子,到 GitHub 点一颗 Star,更多人就能遇见它。`,"The free allowance is used up. Thank you for coming this far. If nanoMuse has earned it, a star on GitHub keeps the project in view for the next person.":`免费额度用完了,谢谢你一路用到这里。如果 nanoMuse 值得,到 GitHub 点一颗 Star,下一个人也能找到它。`,"Load earlier messages":`加载更早的消息`,"New side chat":`新建旁聊`,"New side chat, e.g. “Trip to Kyoto”":`新建旁聊,例如“京都之旅”`,Chats:`对话`,New:`新建`,Create:`创建`,Clear:`清空`,"Clear this conversation? Memory and goals are kept.":`清空这段对话?记忆和目标会保留。`,"Delete this side chat and its history?":`删除这个旁聊及其历史记录?`,"Could not send":`发送失败`,"Could not send decision":`无法提交决定`,Send:`发送`,"Show thinking":`显示思考过程`,"Hide thinking":`隐藏思考过程`,"You:":`你:`,"{name} asks":`{name} 提问`,"{name} suggests":`{name} 建议`,"{name} is on it":`{name} 正在处理`,"Browser: {title}":`浏览器:{title}`,safe:`安全`,moderate:`中等`,sensitive:`敏感`,high:`高`,"Exactly what will run":`将要执行的具体内容`,"Why it asks: {reasons}":`询问原因:{reasons}`,"{tool} is on your always-ask list":`{tool} 在你的「总是询问」列表里`,"{tool} is on your always-allow list":`{tool} 在你的「总是允许」列表里`,"{tool} is switched off in your settings":`{tool} 已在你的设置里关闭`,"an action rated {risk}, in {mode} mode":`这个操作的风险等级是「{risk}」,哨兵处于{mode}模式`,"auto mode: not asked":`自动模式:未询问`,"covered by your {scope} permission for {key}":`已在你对 {key} 的「{scope}」授权范围内`,"private data was read earlier in this conversation, and {tool} could send it to {target}, which is not on your allowed list":`这段对话早些时候读取过私密数据,而 {tool} 可能把它发给 {target},这个地址不在你的允许列表里`,ask:`询问`,auto:`自动`,strict:`严格`,once:`一次`,conversation:`本次对话`,session:`本次运行`,"24h":`24 小时`,low:`低`,"Stopped.":`已停止。`,"Use nanoMuse Cloud this time":`这次改用 nanoMuse Cloud`,"Answered by nanoMuse Cloud this time ({model})":`这次由 nanoMuse Cloud 回答({model})`,Deny:`拒绝`,"Allow once":`允许一次`,Allow:`允许`,"{name} wants to":`{name} 想要`,"at {host}":`站点:{host}`,"to {recipient}":`收件人:{recipient}`,"Allow {subject} for longer…":`更长时间允许 {subject}…`,"For this conversation":`本次对话`,"the device {name}":`设备「{name}」`,"Until restart":`直到重启`,"For 24 hours":`24 小时内`,"Always for {subject}":`始终允许 {subject}`,"Fewer options":`收起`,"Show all":`展开全部`,"This kind of action is approved one at a time.":`这类操作需要逐次批准。`,Approved:`已批准`,Denied:`已拒绝`,"Expired without an answer":`已过期,未作答`,"approved by you":`已由你批准`,"covered by your {scope} permission":`已由你的{scope}授权覆盖`,"for this conversation":`本次对话`,"until restart":`直到重启`,"for 24 hours":`24 小时内`,always:`始终`,"email to {who}":`发送给 {who} 的邮件`,"{what} commands":`{what} 命令`,browser:`浏览器`,email:`邮件`,secret:`密钥`,"Waiting for you":`等你确认`,Answer:`回答`,LIVE:`实时`,BROWSER:`浏览器`,"Frame no longer available":`画面已不可用`,Page:`页面`,Document:`文档`,Image:`图片`,Data:`数据`,Code:`代码`,Open:`打开`,Download:`下载`,"Type into the focused field…":`输入到当前聚焦的输入框…`,Type:`输入`,"Press Enter":`按回车`,"Open a URL…":`打开一个网址…`,Go:`前往`,"Tap the page to click. If the browser was closed, open a URL first.":`点击画面即可点击网页。如果浏览器已关闭,请先打开一个网址。`,Refresh:`刷新`,"Take over":`接管`,"This frame is no longer available.":`这一帧画面已不可用。`,"Look again":`重新查看`,"1 frame":`1 帧`,"{n} frames":`{n} 帧`,"Scroll up":`向上滚动`,"Scroll down":`向下滚动`,"You have the page. Press Done when you are finished.":`页面现在由你操作。完成后按「完成」。`,"{name} waits meanwhile, then looks at the page as you left it and carries on. Passwords you type here go to the website, never to the model.":`{name} 会先等着,然后从你离开时的页面接着做。你在这里输入的密码只发给网站,不会给模型。`,"{name} is waiting for you":`{name} 在等你`,"{name} is working":`{name} 正在工作`,"Your turn":`该你了`,"Your turn: {reason}":`该你了:{reason}`,"You took over {thing}":`你接管了{thing}`,"the browser":`浏览器`,"the phone":`手机`,"The browser":`浏览器`,"The phone":`手机`,"Do this part yourself, then press Done. {name} looks again and carries on from there.":`这一步请你自己来,做完按「完成」。{name} 会重新看一眼屏幕,从那里接着做。`,"{name} waits. Press Done when you are finished and it looks again.":`{name} 在等。你弄完后按「完成」,它会重新看一眼。`,"{name} waited ten minutes and went on without you.":`{name} 等了十分钟,没等到你,先继续了。`,"Ended when the runtime restarted.":`运行时重启,这次接管结束。`,"You did it; {name} carried on.":`你做完了,{name} 接着做了。`,"Handed back; {name} carried on.":`已交还,{name} 接着做了。`,Close:`关闭`,"Open in a new tab":`在新标签页打开`,"No preview for this file type.":`此类型文件无法预览。`,"Download it":`下载文件`,"Empty file.":`空文件。`,"Could not load the file: {error}":`无法加载文件:{error}`,"Writing…":`正在写…`,"While you were away":`你离开期间`,"Could not write posts: {error}":`无法生成动态:{error}`,"Nothing yet. Once {name} works on a goal in the background or needs your approval, it shows up here.":`暂时还没有内容。当 {name} 在后台推进目标或需要你批准时,会显示在这里。`,"Next up":`接下来`,"Background work is off. {name} only acts when you ask.":`后台工作已关闭。{name} 只在你要求时行动。`,"Turn it on":`开启`,"No goal has a next step to work on.":`没有目标有可执行的下一步。`,"Add one":`添加一个`,"Working now":`正在进行`,"Quiet hours after {time}":`免打扰时段,{time} 之后`,"Around {time}":`大约 {time}`,Soon:`即将`,overdue:`已逾期`,"{n} more in line":`还有 {n} 个排队中`,"Check-in on":`跟进`,"{n} more":`还有 {n} 个`,Reminder:`提醒`,Routine:`例程`,"Working on your goal: {title}":`推进目标:{title}`,"Check-in: {title}":`跟进:{title}`,"Reminder: {title}":`提醒:{title}`,"Routine: {title}":`例程:{title}`,"New mail: {title}":`新邮件:{title}`,"Coming up: {title}":`即将开始:{title}`,"Webhook: {title}":`Webhook:{title}`,"Tidied memory":`整理了记忆`,"{label}: nothing new":`{label}:没有新进展`,"Open chat":`打开对话`,Today:`今天`,Yesterday:`昨天`,Menu:`菜单`,Stop:`停止`,"Nothing to stop":`没有正在进行的任务`,Message:`发消息`,"An emoji instead":`改用表情符号`,"Refresh ideas":`刷新灵感`,Planning:`规划`,Research:`调研`,Money:`财务`,People:`人际`,"Files & tools":`文件与工具`,"Just for you":`专属于你`,Tracking:`追踪`,"Show {n} more":`还有 {n} 个`,Cancelled:`已取消`,"Starter ideas. Refresh once {name} knows you better.":`入门灵感。等 {name} 更了解你之后再刷新。`,"Generated {when}":`生成于 {when}`,"Could not refresh ideas: {error}":`无法刷新灵感:{error}`,"Do it for me":`帮我做`,Health:`健康`,Finance:`财务`,Career:`事业`,Learning:`学习`,Relationships:`关系`,Family:`家庭`,Home:`家居`,Travel:`旅行`,Creative:`创作`,Other:`其他`,"New goal":`新目标`,Paused:`已暂停`,Finished:`已完成`,active:`进行中`,paused:`已暂停`,done:`已完成`,cancelled:`已取消`,pending:`待处理`,blocked:`受阻`,"{done}/{total} done":`已完成 {done}/{total}`,"Next: {step}":`下一步:{step}`,"Next check-in {when}":`下次跟进 {when}`,"Due today":`今天到期`,"Due tomorrow":`明天到期`,"Due in {n} days":`{n} 天后到期`,"Was due {date}":`原定 {date} 到期`,"By {date}":`截止 {date}`,"No target date":`没有目标日期`,"No category":`未分类`,"Every day":`每天`,"Once a week":`每周一次`,"Once a month":`每月一次`,Weekdays:`工作日`,Weekly:`每周`,Monthly:`每月`,"Daily at {time}":`每天 {time}`,"Weekdays at {time}":`工作日 {time}`,"Weekly at {time}":`每周 {time}`,"{day}s at {time}":`每{day} {time}`,"Monthly on the {day} at {time}":`每月 {day} 日 {time}`,"Work on it now":`现在就推进`,"Work on {title} now":`现在推进「{title}」`,"{name} is working on “{title}” in the main chat":`{name} 正在主要聊天中推进“{title}”`,"{name} is working on it in the main chat":`{name} 正在主要聊天中处理`,"Check in with me now":`现在跟我确认进度`,"{name} will check in with you in the main chat":`{name} 会在主要聊天中跟你确认进度`,"Discuss this goal in chat →":`在对话中讨论这个目标 →`,"About my goal “{title}” ({id}): what's the status, and what should we do next?":`我们来聊聊我的目标“{title}”({id})。现在进展如何,下一步该做什么?`,Pause:`暂停`,Resume:`继续`,Done:`完成`,Delete:`删除`,"Delete goal":`删除目标`,"Delete “{title}”?":`删除“{title}”?`,Edit:`编辑`,Save:`保存`,"Saving…":`保存中…`,Saved:`已保存`,Cancel:`取消`,"Add step":`添加步骤`,"Add a step":`添加一个步骤`,"What should be done…":`要做什么…`,"Mark step {n} {status}":`把第 {n} 步标记为{status}`,"Adjust the plan for “{title}”?":`调整“{title}”的计划?`,"Keeps the 1 step already done.":`保留已完成的 1 个步骤。`,"Keeps the {n} steps already done.":`保留已完成的 {n} 个步骤。`,"New remaining steps:":`新的剩余步骤:`,"Keep my plan":`保留我的计划`,"Use {name}'s plan":`采用 {name} 的计划`,"Kept your plan":`已保留你的计划`,"Plan updated":`计划已更新`,"What do you want to achieve?":`你想实现什么?`,"Why it matters, constraints (optional)":`为什么重要、有哪些限制(可选)`,"Area of life":`生活领域`,"Target date":`目标日期`,"Steps, one per line (optional, or let {name} plan them)":`步骤,每行一个(可选,也可以让 {name} 来规划)`,"Let {name} plan it":`让 {name} 来规划`,'Create a goal for me: "{title}"{description}{extras}. Break it into concrete steps with the goals tool, then tell me the plan.':`帮我创建一个目标:“{title}”{description}{extras}。用 goals 工具把它拆成具体步骤,然后告诉我计划。`,"Day {d}":`第 {d} 天`,"Search files":`搜索文件`,"No files match.":`没有匹配的文件。`,Latest:`最新`,"Updated {when}":`更新于 {when}`,Memory:`记忆`,"What {name} remembers about you. Read it, add to it, or make {name} forget; nothing here is hidden from you.":`{name} 对你的了解都在这里。你可以翻看、补充,或让 {name} 忘掉,没有什么是瞒着你的。`,"Tell {name} something to remember, e.g. “I'm vegetarian” or “My sister's birthday is 14 May”":`告诉 {name} 要记住的事,例如“我是素食者”或“我妹妹的生日是 5 月 14 日”`,Remember:`记住`,Forget:`忘掉`,"Forget “{text}”?":`忘掉“{text}”?`,"Nothing remembered yet":`还没有记住任何内容`,"{name} saves durable facts you share in chat (preferences, people, routines) and never secrets.":`{name} 会保存你在对话中分享的长期事实(偏好、人物、习惯),绝不保存密钥或密码。`,"added by you":`由你添加`,"saved by {name}":`由 {name} 保存`,"updated {when}":`更新于{when}`,"Tidy up":`整理`,"Tidying…":`整理中…`,"Merge lines that say the same thing and drop what was never a fact about you. Every change can be undone.":`把意思相同的条目合并,删掉那些并不是你本人情况的内容。每处改动都能撤销。`,"Nothing to tidy: {n} memories, all distinct.":`无需整理:{n} 条记忆,互不重复。`,"Tidied: {merged} merged, {dropped} dropped. Undo below if needed.":`已整理:合并 {merged} 条,去掉 {dropped} 条。如有需要可在下方撤销。`,"Recent changes":`最近的改动`,Merged:`已合并`,Dropped:`已去掉`,Updated:`已更新`,restored:`已恢复`,Undo:`撤销`,profile:`个人资料`,preference:`偏好`,people:`人物`,routine:`习惯`,constraint:`限制`,general:`一般`,idle:`空闲`,working:`工作中`,waiting:`等待中`,Activity:`活动`,Approvals:`审批`,Connections:`连接`,Settings:`设置`,"Nothing is waiting for you.":`没有需要你处理的事项。`,"{n} events":`{n} 个事件`,failed:`失败`,updated:`已更新`,"background check":`后台检查`,"Every tool call goes through the Sentinel and is written to the audit log, including the ones it refused.":`每一次工具调用都经过 Sentinel 审查并写入审计日志,包括被拒绝的那些。`,"Sentinel mode:":`Sentinel 模式:`,balanced:`平衡`,cautious:`谨慎`,"hands-off":`放手`,"Safe and moderate actions run freely; anything sensitive (email, shell, purchases) waits for you.":`安全和中等风险的操作自由执行;任何敏感操作(邮件、终端命令、购买)都会等你批准。`,"Moderate and sensitive actions both wait for your approval.":`中等和敏感风险的操作都会等待你的批准。`,"Everything is approved automatically except explicit deny rules. Use with care.":`除明确的拒绝规则外,一切自动批准。请谨慎使用。`,"Always asks before: {tools}.":`以下操作总会先问你:{tools}。`,"Private data was read this session: network calls to new destinations need approval.":`本次会话已读取过私密数据:访问新的网络目标需要批准。`,"Commands run in a sandbox":`命令在沙箱中运行`,"Commands run without a sandbox":`命令未在沙箱中运行`,"Each shell or Python call gets its own namespace: only the workspace is writable, your home directory is not there, and there is no network unless the command needs it.":`每次 shell 或 Python 调用都有独立的命名空间:只有工作区可写,你的主目录不在其中,命令不需要网络就没有网络。`,"Each shell or Python call runs in the workspace with a scrubbed environment; the container is the boundary.":`每次 shell 或 Python 调用都在工作区中以清洗过的环境运行;容器就是边界。`,"Each shell or Python call runs in the workspace with a scrubbed environment. On Linux, installing bubblewrap gives each one its own namespace.":`每次 shell 或 Python 调用都在工作区中以清洗过的环境运行。在 Linux 上安装 bubblewrap 后,每次调用会有独立的命名空间。`,"After reading private data, new network destinations need approval.":`读取私密数据之后,访问新的网络目标需要批准。`,"Permissions you granted":`你授予的权限`,"No standing approvals. When you answer “always allow” to a risky step, it is listed here and can be revoked; approvals you give once are not kept.":`还没有长期批准。对有风险的操作选择“始终允许”后,会列在这里,随时可以撤回;“允许一次”的批准不会保留。`,"Nothing yet today. What you ask for and what {name} does about it shows up here.":`今天还没有动态。你提的要求和 {name} 做的事会显示在这里。`,"Share avatar":`分享形象`,"Creates a routine":`会创建一条例程`,"Creates a goal":`会创建一个目标`,"Create routine":`创建例程`,"Start goal":`开始这个目标`,"Routine set: every day at {time}. It is listed under Goals.":`例程已设好:每天 {time}。在「目标」页可以看到。`,"A morning brief":`早间简报`,"Before you leave each morning: the weather, the first thing on your plate and anything new worth knowing.":`每天出门前:天气、今天的第一件事,以及值得知道的新消息。`,"Morning brief: check today's weather, read my calendar and reminders for today, and tell me in five lines what matters.":`早间简报:查今天的天气,读我今天的日程和提醒,用五行告诉我要紧的事。`,"drawn from the picture you attached":`按你附的图片来画`,"Share my avatar":`分享我的虚拟形象`,"Pick a card. It goes out through the system share sheet, or saves as a picture where there is none.":`选一张卡片,用系统分享发出去;没有系统分享的浏览器会保存为图片。`,"Hi, I'm {name}, a personal AI agent. Meet nanoMuse: open source, runs on your phone.":`你好,我是你的个人 AI 智能体 {name}。认识一下 nanoMuse:开源,在手机上本地运行。`,"Your personal AI agent, open source · {repo}":`你的个人 AI 智能体,开源 · {repo}`,Later:`以后再说`,Share:`分享`,"Save picture":`保存图片`,"Saved the card as a picture.":`卡片已保存为图片。`,"The card could not be drawn.":`卡片没能画出来。`,"the hands in {app}":`在 {app} 里动手`,"granted {time}":`授予于 {time}`,"until {time}":`直到 {time}`,"Revoke {what}":`撤销 {what}`,"Revoked: {subject}":`已撤销:{subject}`,"Reset all granted permissions":`重置所有已授予的权限`,"Permissions reset":`权限已重置`,"Always ask (from config)":`始终询问(来自配置)`,Off:`关闭`,Low:`低`,Default:`默认`,High:`高`,"Off. {name} only works when you ask.":`已关闭。{name} 只在你要求时工作。`,"every {n} min {name} picks one active goal and works on its next step.":`每 {n} 分钟 {name} 挑选一个进行中的目标并推进它的下一步。`,"Quiet hours until {time}.":`免打扰时段直到 {time}。`,"Next around {time}.":`下一次大约在 {time}。`,"No background work in this window; anything due waits until it ends.":`此时段不进行后台工作;到期的事项会等到时段结束。`,"Level, interval and quiet hours in Settings":`在设置中调整级别、间隔和免打扰时段`,"In line":`排队中`,"No active goal has a next step. Add one in Goals and {name} will pick it up.":`没有进行中的目标有下一步。在目标中添加一个,{name} 会接手。`,"{n} queued":`{n} 个排队中`,"Check-ins":`跟进`,"Goal nudges you asked for; they arrive whatever the proactivity level, but wait out quiet hours.":`你要求的目标提醒;无论主动性级别如何都会送达,但会避开免打扰时段。`,"Reminders & routines":`提醒与例程`,"When something happens":`当某事发生时`,"mail matching “{match}”":`匹配“{match}”的邮件`,"any new mail":`任何新邮件`,"events matching “{match}”":`匹配“{match}”的日程`,"any event":`任何日程`,"{n} min before {what}":`{what}开始前 {n} 分钟`,"webhook “{match}”":`Webhook“{match}”`,webhook:`Webhook`,"Nothing yet. Tell {name} “when the landlord writes back, summarise it and draft a reply” or “half an hour before any review, brief me”.":`还没有。告诉 {name}“房东回信时,帮我总结并起草回复”或“任何评审开始前半小时,给我一份简报”。`,"fired {n}×":`触发 {n} 次`,"not fired yet":`尚未触发`,"Copy URL":`复制地址`,"Run now: {text}":`现在运行:{text}`,"Webhook URL copied":`已复制 Webhook 地址`,"The inbox could not be read: {error}":`无法读取收件箱:{error}`,"Inbox checked {when}; {name} looks every {n} min.":`收件箱{when}检查过;{name}每 {n} 分钟查看一次。`,"{name} will look at the inbox shortly, then every {n} min.":`{name}稍后会查看收件箱,之后每 {n} 分钟一次。`,"Each time it happens {name} does the work in the chat it was set from and reports in the Feed. A mail or a request is treated as data, never as instructions.":`每次触发时,{name} 会在设定它的那个对话里把事做完,再到动态里汇报。邮件和请求的内容只当资料看,绝不当作指令执行。`,"Webhook ready":`Webhook 已就绪`,"POST anything to this URL and the work starts. The key is in the URL; share it only with the program that will call it.":`向这个地址 POST 任意内容即可开始工作。密钥就在地址里,只交给要调用它的程序。`,Copy:`复制`,"New mail":`新邮件`,"Before an event":`日程开始前`,Webhook:`Webhook`,"Connect a mailbox under Connections first.":`请先在“连接”里接入邮箱。`,"Add a calendar under Connections first.":`请先在“连接”里添加日历。`,"Words in the sender or subject (empty: any mail)":`发件人或主题中的词(留空:任何邮件)`,"Words in the title or place (empty: any event)":`标题或地点中的词(留空:任何日程)`,"A name for this hook":`给这个 Webhook 起个名字`,"Minutes before":`提前分钟数`,"min before":`分钟前`,"What should be done each time…":`每次要做什么……`,"When {condition}":`当{condition}`,"Set trigger":`设置触发器`,"A time you named is kept whatever the level or the quiet hours. Reminders just say it; routines do the work and report.":`你指定的时间无论级别或免打扰时段都会遵守。提醒只是说一声;例程会执行工作并汇报。`,"No reminders":`没有提醒`,"Nothing scheduled. Tell {name} “remind me at six to call mum” or “every weekday morning, summarise my unread email”.":`没有安排。告诉 {name}“六点提醒我给妈妈打电话”或“每个工作日早上汇总我的未读邮件”。`,"{n} finished recently":`最近完成 {n} 个`,"Do “{text}” now":`现在执行“{text}”`,"Remind me now: {text}":`现在提醒我:{text}`,"Cancel: {text}":`取消:{text}`,"{name} will say it now":`{name} 现在就会说`,"+ Add":`+ 添加`,"Remind me":`提醒我`,Repeat:`重复`,"What to remind you of…":`提醒你什么…`,"Set reminder":`设置提醒`,"Set routine":`设置例程`,at:`于`,"1 approval waiting for you":`需要你批准`,"{n} approvals waiting for you":`{n} 项需要你批准`,"{n} approval":`{n} 项审批`,You:`你`,"Its name and look, how careful it is, how often it speaks up.":`名字和形象,有多谨慎,多主动。`,Name:`名字`,Avatar:`头像`,"What it calls you":`它怎么称呼你`,"Safety · Sentinel":`安全 · Sentinel`,"A separate gatekeeper reviews every action. Pick how often it should check in with you.":`一个独立的守门人审查每一次操作。选择它多久向你确认一次。`,Balanced:`平衡`,"Browse, read and write files freely; stop for anything hard to undo: email, purchases, shell commands.":`自由浏览、读写文件;遇到难以撤销的操作时停下:邮件、购买、终端命令。`,Cautious:`谨慎`,"Also ask before moderate actions like fetching web pages or writing files.":`对抓取网页或写入文件等中等风险操作也先询问。`,"Hands-off":`放手`,"Approve everything automatically except explicit deny rules. For trusted, unattended runs only.":`除明确的拒绝规则外一切自动批准。仅用于可信的无人值守运行。`,"Decides when the Sentinel asks you before a call.":`决定 Sentinel 在调用前何时向你询问。`,Proactivity:`主动性`,"Only works when you ask.":`只在你要求时工作。`,"Checks in half as often; speaks up only when a step is done or it needs you.":`检查频率减半;只在完成一步或需要你时才发声。`,"Works on goals on schedule; reports real progress, stays quiet otherwise.":`按计划推进目标;汇报实质进展,其余时间保持安静。`,"Checks in twice as often and always reports, even 'still on track'.":`检查频率加倍并总是汇报,即使只是“仍在正轨上”。`,"Background work":`后台工作`,"Check-in interval":`检查间隔`,"{n} min":`{n} 分钟`,"{n} h":`{n} 小时`,"{n} d":`{n} 天`,"Halved at this level":`此级别下减半`,"Doubled at this level":`此级别下加倍`,"Quiet hours":`免打扰时段`,From:`从`,to:`到`,Notifications:`通知`,"Approvals, questions, finished background work and check-ins; nothing while the app is on screen.":`审批、提问、后台完成的事和到点的跟进;应用在前台时不打扰。`,"Let {name} notify this device":`让 {name} 通知这台设备`,"Let {name} notify this phone":`让 {name} 通知这台手机`,"The app stays connected to your server in the background. Approvals, questions and finished background work arrive as notifications and open the right chat.":`应用在后台保持与服务器的连接。审批、提问和后台完成的工作会以通知送达,并打开对应的对话。`,"nanoMuse for Android {version}":`nanoMuse Android 版 {version}`,"Disconnect from this server":`断开此服务器`,"This device only":`仅这台设备`,"1 device subscribed":`1 台设备已订阅`,"{n} devices subscribed":`{n} 台设备已订阅`,"Send a test":`发送测试`,"Sent. It should arrive in a moment":`已发送,应该马上就到`,"Notifications need https:// (or localhost). Over plain http on your LAN the app works, this part stays off; see docs/deployment.md.":`通知需要 https://(或 localhost)。在局域网的普通 http 上应用可以使用,但这部分功能保持关闭,参见 docs/deployment.md。`,"This browser cannot receive push notifications.":`这个浏览器无法接收推送通知。`,"This browser has no push service (embedded browsers often don't). Use Chrome, Edge, Firefox or Safari 16.4+ on the phone.":`这个浏览器没有推送服务(内嵌浏览器通常没有)。请在手机上使用 Chrome、Edge、Firefox 或 Safari 16.4+。`,"Notifications are blocked for this site in the browser settings":`浏览器设置中已屏蔽此站点的通知`,"Blocked for this site. Allow notifications in the browser's site settings, then try again.":`通知在这个站点被关掉了。到浏览器的站点设置里允许通知,再试一次。`,"The server was installed without pywebpush.":`服务器安装时未包含 pywebpush。`,"On a phone, add the app to the home screen first; the icon then shows a badge and notifications open the right chat.":`手机上请先把应用添加到主屏幕;图标会显示角标,通知会打开对应的对话。`,Model:`模型`,"Provider / model":`服务商 / 模型`,"Reveal the model's reasoning under each reply when the provider exposes it.":`当服务商提供时,在每条回复下方显示模型的推理过程。`,"Reply language":`回复语言`,"What {name} writes in":`{name} 用什么语言回复`,Auto:`自动`,"Match mine":`跟随我的语言`,"App language":`应用语言`,Appearance:`外观`,Light:`浅色`,Dark:`深色`,"Follow the system":`跟随系统`,About:`关于`,"{version} is out":`{version} 已发布`,"nanoMuse {version}":`nanoMuse {version}`,"Tell {name} something to remember":`在对话里告诉 {name} 一件值得记住的事`,"Write one":`写一个`,"Latest {version}; you have it":`最新版 {version},你已经在用了`,Update:`更新`,"Check now":`现在检查`,"Checking…":`正在检查…`,"Could not check":`没能检查到最新版本`,"Checked {when}":`上次检查:{when}`,"The phone app checks for updates on its own.":`手机 App 会自己检查更新。`,"Update checks are off here.":`这里关闭了更新检查。`,"To update: pip install -U nanomuse, pull the new image, or use the desktop app's Update.":`更新方式:pip install -U nanomuse、拉取新镜像,或在桌面 App 里点“更新”。`,"Data:":`数据:`,"Workspace:":`工作区:`,"Forget this device's access token":`忘掉这台设备的访问令牌`,"does the work":`负责干活`,Calendar:`日历`,calendar:`日历`,Tomorrow:`明天`,"all day":`全天`,"{n} scheduled":`{n} 个安排`,"Nothing on the calendar today.":`今天日历上没有安排。`,"Ask about your week":`问问这周的安排`,"{name} could not be read":`{name} 读取失败`,"Your agenda, free time, and events it can draft":`你的日程、空闲时间,以及它起草的日程`,"{n} not reading":`{n} 个读取失败`,"1 event":`1 个事件`,"{n} calendars":`{n} 个日历`,"read {when}":`{when}读取`,"Calendar connected":`日历已连接`,"Reads your calendar from its private .ics link; the link stays in the vault. It never changes your calendar; an event it proposes comes as a file you add with a tap.":`通过私密 .ics 链接读取你的日历,链接存在保险库里。它不会改动你的日历;它建议的日程会做成文件给你,点一下就能加进去。`,"not read yet":`尚未读取`,"Where is the link?":`链接在哪里?`,"Settings → your calendar → Integrate calendar → Secret address in iCal format":`设置 → 你的日历 → 整合日历 → iCal 格式的私密地址`,"Settings → Calendar → Shared calendars → Publish a calendar → ICS link":`设置 → 日历 → 共享日历 → 发布日历 → ICS 链接`,"Share Calendar → Public Calendar → copy the webcal:// link":`共享日历 → 公共日历 → 复制 webcal:// 链接`,"Settings → Calendars → Export → Calendar URL":`设置 → 日历 → 导出 → 日历 URL`,"A path on the machine where nanoMuse runs, for example ~/calendar.ics":`nanoMuse 所在机器上的文件路径,例如 ~/calendar.ics`,".ics file":`.ics 文件`,Work:`工作`,"Private .ics link or file path":`私密 .ics 链接或文件路径`,"Stored encrypted in the vault as CALENDAR_{name}.":`加密保存在保险库中,名为 CALENDAR_{name}。`,"nanoMuse fetches whatever address you put here, from this machine; only paste links you trust.":`nanoMuse 会从这台机器上去取你填的任何地址,只粘贴你信任的链接。`,"Add a calendar":`添加日历`,"Added, but it could not be read: {error}":`已添加,但读取失败:{error}`,"Google Calendar":`Google 日历`,"Client set — sign in with Google":`客户端已配置,请用 Google 登录`,"Paste your Google OAuth client id and secret":`粘贴你的 Google OAuth 客户端 ID 和密钥`,"Client ID":`客户端 ID`,"From the OAuth client you downloaded in Google Cloud Console.":`来自你在 Google Cloud Console 下载的 OAuth 客户端。`,"Client secret":`客户端密钥`,"A secret is saved. Type a new one only to replace it.":`密钥已保存。只有在需要更换时才重新输入。`,"Stored encrypted in the vault as GOOGLE_CALENDAR_SECRET.":`加密保存在保险库中,名为 GOOGLE_CALENDAR_SECRET。`,"Callback URL":`回调 URL`,"Register this exact URL on the OAuth client as an authorized redirect URI.":`把这个 URL 原样注册为该 OAuth 客户端的已授权重定向 URI。`,"Let the agent create and change events":`允许它创建和修改日程`,"Off: read-only, the agent can look but not touch.":`关闭后为只读,它只能查看,不能改动。`,"Save client":`保存客户端`,"Sign in with Google":`用 Google 登录`,"Finish signing in with Google in the window that opened.":`在打开的窗口里完成 Google 登录。`,"Sign in needed":`需要登录`,"Read-only":`只读`,Disconnected:`已断开`,"Calendar to read and write":`读取和写入的日历`,"Events the agent reads and creates land here.":`它读取和创建的日程都会落在这个日历里。`,"{name} (primary)":`{name}(主日历)`,"read + write":`读写`,"read-only":`只读`,Contacts:`通讯录`,"1 person":`1 人`,"{n} people":`{n} 人`,"Address book connected":`通讯录已连接`,"Who is who, so it never guesses an address":`知道谁是谁,从不猜地址`,"told to it in chat":`在聊天中告诉它的`,"{n} address books":`{n} 个通讯录`,"Import a .vcf export from Google Contacts, iCloud, Outlook or your phone, or paste a link to one. People you mention in chat go into its own book. It looks people up before writing to them and warns when an address is unknown.":`导入 Google 通讯录、iCloud、Outlook 或手机导出的 .vcf 文件,或粘贴一个链接。你在聊天中提到的人会记进它自己的名册。写信前它会先查人,地址不认识时会提醒。`,"My contacts":`我的联系人`,"added in chat":`聊天中添加`,iPhone:`iPhone`,"A .vcf file from your phone or computer":`手机或电脑上的 .vcf 文件`,"Google Contacts: Export → vCard. iPhone: Contacts → select all → Share → Save to Files. Outlook: People → Manage → Export.":`Google 通讯录:导出 → vCard。iPhone:通讯录 → 全选 → 共享 → 存储到“文件”。Outlook:人员 → 管理 → 导出。`,"Choose a .vcf file":`选择 .vcf 文件`,"Or a link / path to one":`或一个链接 / 路径`,"A link is stored encrypted in the vault as CONTACTS_{name}.":`链接会加密保存在保险库中,名为 CONTACTS_{name}。`,"Add an address book":`添加通讯录`,"Find someone, as the agent would":`像 Agent 那样查一个人`,"No one matches.":`没有匹配的人。`,"Working hours":`工作时间`,"Free time is looked for inside these hours.":`空闲时间只在这个时段内查找。`,"Read again":`重新读取`,Event:`日程`,"No event in this file.":`这个文件里没有日程。`,"Untitled event":`未命名日程`,"Add to calendar":`加入日历`,"Opens in your calendar app, which asks before it saves anything.":`会在你的日历应用中打开,保存前会先询问你。`,"What {name} can reach; keys stay in the vault on this machine.":`{name} 能用到的东西;密钥留在这台机器的保险库里。`,"No key yet":`还没有密钥`,"Key missing from vault":`保险库中缺少密钥`,"Key in vault":`密钥已在保险库`,"Key from config":`密钥来自配置`,"No key needed":`无需密钥`,Provider:`服务商`,"Base URL":`Base URL`,"API key":`API 密钥`,"A key is in the vault. Leave blank to keep it.":`保险库中已有密钥。留空则保持不变。`,"Stored encrypted in the vault as LLM_API_KEY.":`以 LLM_API_KEY 加密存储在保险库中。`,"model name":`模型名称`,"Tool calling":`工具调用`,"Auto uses the API's function calling and falls back to describing tools in the prompt when the endpoint rejects them. Prompt: for endpoints that silently ignore tools.":`自动:使用 API 的函数调用,当端点拒绝时回退为在提示词中描述工具。提示词:用于会静默忽略工具的端点。`,Test:`测试`,"Model saved":`模型已保存`,'Replied "{reply}" in {ms} ms':`在 {ms} 毫秒内回复了“{reply}”`,"Email connected":`邮件已连接`,"Saved, but the test failed":`已保存,但测试失败`,"Email disconnected":`邮件已断开`,Incomplete:`未完成`,"Not connected":`未连接`,Email:`邮件`,"reads and sends":`读取和发送`,"Read your inbox, draft and send mail":`读取收件箱、起草和发送邮件`,"Reading is a moderate action; sending always asks you first. Use an app password where your provider offers one.":`读取是中等风险操作;发送总会先询问你。如果服务商提供应用专用密码,请使用它。`,Address:`邮箱地址`,Password:`密码`,"app password":`应用专用密码`,"A password is in the vault. Leave blank to keep it.":`保险库中已有密码。留空则保持不变。`,"Stored encrypted in the vault as EMAIL_PASSWORD.":`以 EMAIL_PASSWORD 加密存储在保险库中。`,"IMAP server":`IMAP 服务器`,Port:`端口`,"SMTP server":`SMTP 服务器`,"STARTTLS for SMTP (off for port 465)":`SMTP 使用 STARTTLS(465 端口请关闭)`,"Save & test":`保存并测试`,Disconnect:`断开`,"Signed in · {n} messages in the inbox":`已登录 · 收件箱有 {n} 封邮件`,Browser:`浏览器`,"Open pages, click, fill forms, screenshot":`打开页面、点击、填表、截图`,"Playwright is not installed on the server":`服务器上未安装 Playwright`,On:`开`,Unavailable:`不可用`,"Install it where the server runs:":`在服务器运行的机器上安装:`,"Server connected":`服务器已连接`,"MCP servers":`MCP 服务器`,"{n} of {total} connected · {tools} tools":`{total} 个中已连接 {n} 个 · {tools} 个工具`,"Add tools from any Model Context Protocol server":`从任意 Model Context Protocol 服务器添加工具`,"{n} on":`{n} 个已开启`,None:`无`,"{n} tools":`{n} 个工具`,"from config.toml":`来自 config.toml`,Remove:`移除`,Command:`命令`,"Run locally over stdio, for example: npx -y @modelcontextprotocol/server-filesystem ./workspace":`通过 stdio 在本地运行,例如:npx -y @modelcontextprotocol/server-filesystem ./workspace`,"or URL":`或 URL`,"A remote server over Streamable HTTP.":`通过 Streamable HTTP 连接的远程服务器。`,"Risk of its tools":`其工具的风险级别`,"Add a server":`添加服务器`,"Connected on {device}; sign in here to use it on this device":`已在 {device} 上连接,在这台设备上登录后即可使用`,"Sign in here":`在这里登录`,"Stored in the vault":`已存入保险库`,Vault:`保险库`,"{n} secrets · encrypted on your machine":`{n} 个密钥 · 在你的机器上加密`,"Encrypted secrets the model never sees":`模型永远看不到的加密密钥`,"Refer to a secret as":`在工具调用或配置中用`,"in a tool call or config: the value is filled in after the Sentinel approves and is redacted from everything the model reads.":`引用密钥:真实值会在 Sentinel 批准后填入,并从模型读到的所有内容中脱敏。`,"Delete {name}":`删除 {name}`,Value:`值`,Store:`存储`,"Skip setup":`跳过设置`,"Meet your nanoMuse":`认识你的 nanoMuse`,"Your name":`你的名字`,"The model behind it":`背后的模型`,"A model is already set up on the server. Keep it, or switch here.":`服务器上已经配置了模型。可以保留,也可以在这里切换。`,"Connect your mail, calendar and contacts":`连接你的邮箱、日历和通讯录`,"Optional. With a mailbox connected it can read what came in and draft replies; it will always ask before sending. With a calendar it knows your day and finds free time. With your contacts it knows who is who. The browser and MCP servers are under Connections later.":`可选。连上邮箱,它能读新邮件、起草回复,发之前一定先问你。连上日历,它知道你这一天的安排,也能找出空档。连上通讯录,它知道谁是谁。浏览器和 MCP 服务器之后在「连接」里设置。`,Back:`返回`,"Get started":`开始`,Skip:`跳过`,"Save a model to continue":`保存模型后继续`,"Skip for now":`暂时跳过`,"just now":`刚刚`,"any moment":`随时`,"in {span}":`{span}后`,"{span} ago":`{span}前`,"Checked on {label}: nothing new":`已检查 {label}:没有新进展`,"Notes from {name}":`来自 {name} 的笔记`,"Reminders from {name}":`来自 {name} 的提醒`,Once:`一次`,Plan:`计划`,Skills:`技能`,Yours:`你的`,"Built in":`内置`,Fetch:`获取`,"Use in chat":`在聊天中使用`,"Make your own copy":`复制一份改成你的`,"Edit skill":`编辑技能`,"New skill":`新建技能`,"From a link":`从链接导入`,"Files that come with it":`随附文件`,"Folders that could not be read":`无法读取的文件夹`,"Nothing of your own yet":`还没有你自己的技能`,"How {name} does a job, written down once. Start one in chat with /name, or just ask; it picks the skill that fits.":`把 {name} 做某件事的方法写下来,一次就够。在聊天里输入 /名称 直接开始,或者直接提要求,它会自己挑合适的技能。`,"After a job went well, tell {name} “save this as a skill”; it writes the steps down and asks you first. Or write one here, or paste a link to a SKILL.md.":`一件事做得顺利之后,对 {name} 说“把这个存成技能”,它会把步骤写下来,并先征求你的同意。也可以在这里手写一个,或粘贴一个 SKILL.md 的链接。`,"Switch one off to keep it out of {name}'s list. A skill of yours with the same name replaces it.":`关掉某一项,它就不会出现在 {name} 的清单里。你的同名技能会替代内置的那一个。`,"Yours live in {dir}, one folder each with a SKILL.md, the Agent Skills format, so recipes written for other agents work here too.":`你的技能保存在 {dir},每个一个文件夹,里面是一份 SKILL.md。这是 Agent Skills 通用格式,为其他智能体写的技能在这里同样可用。`,"Delete the skill “{name}”? Its folder goes with it.":`删除技能“{name}”?它的文件夹会一并删除。`,"Saving makes a copy of yours with the same name; it replaces the built-in one.":`保存后会生成一份你的同名技能,替代内置的那一个。`,"A SKILL.md: a name and a one-line description up top, then the steps in Markdown.":`一份 SKILL.md:开头是名称和一行说明,下面用 Markdown 写步骤。`,"A raw SKILL.md link, or a GitHub folder or file page.":`SKILL.md 的原始文件链接,或 GitHub 上的文件夹/文件页面。`,"Pick a name first; a template appears here.":`先起个名字,这里会出现一份模板。`,"Attach a file":`添加附件`,Attachments:`附件`,"Say what to do with it…":`说说要拿它做什么……`,"Upload failed":`上传失败`,"Web search":`网页搜索`,"Not set up":`未设置`,"{provider} at {host}":`{provider}({host})`,"{provider} needs {what}; searches use DuckDuckGo until then":`{provider}还需要{what},在此之前搜索走 DuckDuckGo`,"a key":`一个密钥`,"an instance URL":`实例地址`,"DuckDuckGo needs nothing, but it is scraped and rate-limits now and then. For searches that always work, use a provider with an API. Whichever you pick, a failed search falls back to DuckDuckGo with a note.":`DuckDuckGo 无需任何配置,但它是抓取来的结果,偶尔会被限流。想让搜索始终可用,选一个带 API 的服务。无论选哪个,搜索失败时都会退回 DuckDuckGo 并附上说明。`,"Stored encrypted in the vault as SEARCH_API_KEY.":`加密存入保险库,名为 SEARCH_API_KEY。`,"Get a key from {label}":`到 {label} 获取密钥`,"Instance URL":`实例地址`,"A SearXNG you run, with the JSON format enabled in its settings.":`你自己运行的 SearXNG,需在其设置中启用 JSON 格式。`,"{provider} · {n} results · {ms} ms":`{provider} · {n} 条结果 · {ms} 毫秒`,"Recall by meaning":`按含义召回`,"Keyword recall only":`仅按关键词召回`,"By keyword":`按关键词`,"Not reachable":`连不上`,"Not tried yet":`尚未尝试`,Mode:`模式`,Endpoint:`接口`,"Embedding model":`向量模型`,"Same as the model":`与模型相同`,"Another endpoint":`另一个接口`,"{model} at {host}":`{model}({host})`,"{model} · {n} of {total} memories indexed":`{model} · 已索引 {n}/{total} 条记忆`,"{model} · {dims} dims · {n} memories indexed · {ms} ms":`{model} · {dims} 维 · 已索引 {n} 条记忆 · {ms} 毫秒`,"Memories are embedded once and a message finds the ones that mean the same thing, in any language: “写邮件给房东” finds “the landlord is Bob Li”. Keyword recall stays; the two are fused.":`每条记忆只做一次向量化,一句话就能找到含义相同的记忆,不分语言:“写邮件给房东”能找到“the landlord is Bob Li”。关键词召回仍然保留,两者合并排序。`,"Auto uses the model's endpoint when it has embeddings and falls back to keywords when it does not. On insists and warns. Off: keywords only.":`自动:模型接口支持向量化就用,不支持就退回关键词。开:坚持使用,失败时给出警告。关:只用关键词。`,"Any OpenAI-compatible /embeddings: Ollama with an embedding model pulled, OpenAI, a gateway.":`任何 OpenAI 兼容的 /embeddings 接口:拉取了向量模型的 Ollama、OpenAI、网关。`,"The model's endpoint and key. DeepSeek has no embeddings; pick another endpoint.":`使用模型自己的接口和密钥。DeepSeek 没有向量化接口,请选另一个接口。`,"Stored encrypted in the vault as EMBEDDINGS_API_KEY. Ollama needs none.":`加密存在保险库里,名为 EMBEDDINGS_API_KEY。Ollama 不需要密钥。`,"Blank uses the endpoint's default: {model}. On Ollama: ollama pull qwen3-embedding:0.6b (reads Chinese and English).":`留空则用该接口的默认模型:{model}。Ollama 上:ollama pull qwen3-embedding:0.6b(中英文都能读)。`,Phone:`手机`,"{name} is connected":`{name} 已连接`,"A phone":`一台手机`,"No phone connected. Open the app on the phone":`没有连接的手机。在手机上打开 App`,"Tap, type and swipe in the apps on your phone":`在你手机上的 App 里点击、输入、滑动`,Waiting:`等待中`,"Operate the phone":`操作手机`,"When on, the agent can read the screen and act in the apps on the connected phone: 12306, WeChat, Alipay… It asks before paying, sending or deleting.":`打开后,智能体可以读取已连接手机的屏幕,并在上面的 App 里操作:12306、微信、支付宝……付款、发送、删除之前会先问你。`,"{name} ({platform}), {n} apps":`{name}({platform}),{n} 个 App`,Ready:`就绪`,"Not yet":`还没开`,"Not on this Android":`这个安卓版本不支持`,"Operating the screen needs Android 11 or newer; this phone can still be used for everything else.":`操作屏幕需要 Android 11 或更新;其他功能这台手机都能用。`,"Turn on the nanoMuse accessibility service; that is how it sees the screen and taps for you, only while a task runs, with a Stop button on screen.":`打开 nanoMuse 的无障碍服务,它靠这个看屏幕、替你点击,只在任务进行时工作,屏幕上始终有停止按钮。`,"Open Accessibility settings":`打开无障碍设置`,"App settings":`应用设置`,"Greyed out with “Restricted setting”? Android 13+ does that for apps installed from a download: in App settings tap ⋮ → Allow restricted settings, then come back.":`灰色且提示「受限设置」?Android 13+ 对下载安装的应用会这样:在应用设置里点 ⋮ → 允许受限设置,再回来。`,"Android may switch the service off after an update or a battery clean-up; if the phone stops answering, come back here.":`系统更新或电池清理后安卓可能会把服务关掉;手机不再响应时回到这里看看。`,"On the phone's screen: {gui} of {total} steps ({pct}%). The screen is the last resort; a skill, a fetch or the browser comes first.":`在手机屏幕上操作:{total} 步里有 {gui} 步({pct}%)。屏幕是最后一招,技能、抓取、浏览器优先。`,"last seen in {app}":`上次看到在 {app}`,"The operator's model looks at screens step by step: a small, fast model that takes pictures. The Hands model row under Chat model picks one on the same endpoint and key; the fields here are for a different endpoint. Empty = {model}.":`操作屏幕的模型一步步看截图:选一个小而快、能看图的模型。「对话模型」卡片里的「动手模型」一行在同一个接口和 key 上选;这里的字段用于另一个接口。留空 = {model}。`,"same as the chat model":`与对话模型相同`,"the chat model":`对话模型`,"Chat model":`对话模型`,"Hands model":`动手模型`,"Currently {value}":`当前为 {value}`,"The model that looks at screens when the hands run: the phone, this computer, a page the browser cannot read. Your account's hands model is {model}; the chat model above is a different pick.":`动手时看屏幕的模型:手机、这台电脑、浏览器读不了的页面。你账号的动手模型是 {model};上面的对话模型是另一项选择。`,"The model that looks at screens when the hands run: a small, fast one that takes pictures, on the same endpoint and key. Default: this provider's own hands model.":`动手时看屏幕的模型:小而快、能看图,用同一个接口和 key。默认:这个服务商自己的动手模型。`,"The model that looks at screens when the hands run. This provider has no model that sees, so the hands use your account's, {model}, unless you pick one here.":`动手时看屏幕的模型。这个服务商没有能看图的模型,所以不在这里选的话,双手用你账号里的 {model}。`,"{model} does not take pictures, so the hands would be blind with it. Pick a model that sees for them.":`{model} 不能看图,动手时会是盲的。给它选一个能看图的模型。`,"A key is in the vault; leave empty to keep it.":`保险库里已有密钥;留空则保留。`,"Set in config.toml.":`在 config.toml 里设置。`,"Leave empty to use the main model's key.":`留空则使用主模型的密钥。`,"Save and test":`保存并测试`,"The operator's model answers.":`操作手机的模型有回应。`,"A personal agent of your own. Three things to know:":`一个属于你自己的个人智能体。三件事:`,"It does things for you.":`它替你把事情做完。`,"Searches, browses, writes, books, reads mail, and hands you the result, not a list of links.":`搜索、浏览、写作、预订、读邮件,交给你的是结果,不是一堆链接。`,"It keeps working when you close the app.":`你关掉应用,它还在干活。`,"Goals move forward between your visits; it reports in the Feed and notifies you when something is worth it.":`目标在你不在的时候继续推进;它在动态里汇报,值得打扰你时才通知你。`,"It asks you first where it matters.":`要紧的事,它先问你。`,"A separate Sentinel reviews every action. Sending, paying, deleting: it stops and asks; your keys stay in a vault the model cannot read.":`独立的 Sentinel 审查每一个动作。发送、付款、删除:它会停下来问你;你的密钥放在模型读不到的保险库里。`,"Set it up":`设置一下`,"Add a model":`添加模型`,"Pick a provider and paste a key. Yours, stored in the vault.":`选一个服务商,粘贴密钥。密钥是你的,存在保险库里。`,"Connect mail, calendar, contacts":`连接邮箱、日历、通讯录`,"Connected. Tap to add more.":`已连接。点击添加更多。`,"Optional. It can read what came in, know your day, and who is who.":`可选。它就能读到来信、知道你的日程、认得谁是谁。`,Start:`开始`,"Needs a model first.":`先要有一个模型。`,"Add a model to start":`添加模型后开始`,optional:`可选`,"A line under the name, e.g. Your day, sorted.":`名字下面的一句话,例如:你的一天,安排好了。`,Tagline:`标语`,"More names":`换一批名字`,Tone:`语气`,Formal:`正式`,Casual:`随和`,Playful:`活泼`,Concise:`简洁`,"How much it says":`话说多少`,"Short replies":`回答简短`,Detailed:`详细`,"Bullet points":`要点列表`,"Anything else about how it should be":`还有什么想让它成为的样子`,"e.g. A little witty. Uses metric units. Never uses emoji.":`例如:有点风趣。用公制单位。从不用 emoji。`,"OpenAI-compatible · Chat Completions":`OpenAI 兼容 · Chat Completions`,"Responses API":`Responses API`,"Local or your own endpoint":`本地或你自己的端点`,"/v1 is added when the URL has no path.":`URL 没有路径时会自动补上 /v1。`,"The endpoint that serves /chat/completions, for example http://127.0.0.1:8000/v1.":`提供 /chat/completions 的端点,例如 http://127.0.0.1:8000/v1。`,"{n} models from the endpoint":`端点返回了 {n} 个模型`,"Asking the endpoint…":`正在询问端点…`,"Could not list the endpoint's models; these are the usual ones.":`无法列出端点的模型;这些是常见的几个。`,"Get a key from {vendor}":`去 {vendor} 获取密钥`,"No key":`无需密钥`,"No key will be sent.":`不会发送密钥。`,"Show key":`显示密钥`,"Hide key":`隐藏密钥`,"Actions {name} wants to take but cannot without you. Each one shows what will run and why; a permission you grant is bound to that exact tool and target.":`这些是 {name} 想做、但没有你点头就不能做的事。每一项都写明要运行什么、为什么;你给的许可只对那个具体的工具和对象有效。`,"Forget every permission you granted? {name} will ask again next time.":`忘掉你授予的所有权限?下次 {name} 会重新询问。`,"This phone":`这台手机`,"Keep it running":`保持运行`,"Android stops apps that seem idle. {name} runs as a foreground service and holds the phone awake only while a task runs; these switches let it keep that promise on this phone.":`安卓会停掉看起来闲着的应用。{name} 以前台服务运行,只在任务进行时让手机保持唤醒;下面这几个开关,是让它在这台手机上真能做到这一点。`,Battery:`电池`,Unrestricted:`无限制`,Optimised:`已优化`,"Optimised means Android may freeze the agent after a while with the screen off; routines and check-ins then wait until the phone wakes.":`「已优化」意味着熄屏一段时间后安卓可能冻结 Agent;例行任务和提醒要等手机醒来才跑。`,"Display over other apps":`显示在其他应用上层`,Allowed:`已允许`,"Not allowed":`未允许`,"Lets the agent bring an app to the front from the background and show its status capsule while it works in other apps.":`让 Agent 能从后台把应用调到前台,并在其他应用里工作时显示状态胶囊。`,"Alarms & reminders":`闹钟和提醒`,Exact:`精确`,Approximate:`大致`,"With exact alarms a reminder fires at the minute you named even when the phone is asleep; without them Android may deliver it up to a quarter of an hour late.":`有精确闹钟,提醒会在你说的那一分钟响起,哪怕手机在睡眠;没有的话安卓可能晚最多一刻钟送达。`,"Start after a reboot":`重启后自动启动`,"Bring the runtime back when the phone restarts, so routines and check-ins do not stop.":`手机重启后把运行时带回来,例行任务和提醒不中断。`,"Reconnect to your computer when the phone restarts, so approvals keep arriving.":`手机重启后重新连上你的电脑,审批照常送达。`,"On this phone's Android":`这台手机的安卓系统`,"Open the auto-start settings":`打开自启动设置`,"Xiaomi / Redmi / POCO: Settings → Apps → Manage apps → {name} → Autostart on; Battery saver → No restrictions; Other permissions → Display pop-up windows while running in the background on.":`小米 / Redmi / POCO:设置 → 应用设置 → 应用管理 → {name} → 自启动打开;省电策略 → 无限制;其他权限 → 后台弹出界面 允许。`,"Huawei: Settings → Battery → App launch → {name} → Manage manually, all three on (auto-launch, secondary launch, run in background); Settings → Apps → {name} → Battery → Launch: manage manually.":`华为:设置 → 电池 → 应用启动管理 → {name} → 手动管理,三项全开(自动启动、关联启动、后台活动);设置 → 应用 → {name} → 电池 → 启动管理:手动管理。`,"Honor: Settings → Battery → App launch → {name} → Manage manually, all three on; Battery → More battery settings → Stay connected while asleep on.":`荣耀:设置 → 电池 → 应用启动管理 → {name} → 手动管理,三项全开;电池 → 更多电池设置 → 休眠时始终保持网络连接 打开。`,"OPPO / realme / OnePlus: Settings → Battery → More settings → Optimise battery use → {name} → Don't optimise; Settings → Apps → {name} → Battery usage → Allow background activity, Allow auto launch.":`OPPO / realme / 一加:设置 → 电池 → 更多设置 → 应用耗电优化 → {name} → 不优化;设置 → 应用 → {name} → 耗电管理 → 允许后台活动、允许自动启动。`,"vivo / iQOO: i Manager → App manager → Autostart → {name} on; Settings → Battery → Background power consumption management → {name} → Allow high background power consumption.":`vivo / iQOO:i 管家 → 应用管理 → 自启动 → {name} 打开;设置 → 电池 → 后台耗电管理 → {name} → 允许后台高耗电。`,"Samsung: Settings → Battery → Background usage limits → {name} not in Sleeping or Deep sleeping apps (add it to Never sleeping apps); turn off Adaptive battery if it keeps stopping.":`三星:设置 → 电池 → 后台使用限制 → {name} 不在「休眠应用」或「深度休眠应用」里(加入「永不休眠的应用」);如果还是被停,关闭自适应电池。`,"Meizu: Settings → Apps → {name} → Background management → Keep running in the background; Battery → App power management → Allow.":`魅族:设置 → 应用管理 → {name} → 后台管理 → 允许后台运行;电池 → 应用耗电管理 → 允许。`,"Crashes are written to a file on this phone and nowhere else. Export bundles them with the runtime's log for someone you choose.":`崩溃只写到这台手机上的文件里,不上报任何地方。导出会把它们和运行时日志打成一个包,发给你选的人。`,"Export logs":`导出日志`,"clipboard, notifications, calendar, contacts, location, alarms, photos":`剪贴板、通知、日历、通讯录、位置、闹钟、照片`,Devices:`设备`,"Only this one":`只有这一台`,Idle:`空闲`,"A chat addressed to a device runs there; you see every step here and answer its approvals.":`对着某台设备的对话在那台设备上执行;每一步都在这里看得到,审批也在这里答。`,"nanoMuse Cloud":`nanoMuse Cloud`,"Free. One account for all your devices, and a model to start with.":`免费。一个账号连起你所有设备,还附带一个可以先用起来的模型。`,"Signed in":`已登录`,"Not signed in":`未登录`,"Not signed in.":`未登录。`,"Signed out":`已退出`,"{hint} · hub":`{hint} · 中继`,"{hint} · model and hub":`{hint} · 模型和中继`,"Signed in as {hint}. Your devices meet here; the Cloud model comes with a free allowance.":`已登录:{hint}。你的设备在这里互相看见;Cloud 模型自带一份免费额度。`,"Use the Cloud model":`使用 Cloud 模型`,"The Cloud model is in use.":`已切换到 Cloud 模型。`,"Sign out":`退出登录`,"Sign out of nanoMuse Cloud on this device? The hub and the Cloud model stop working here until you sign in again.":`在这台设备上退出 nanoMuse Cloud?重新登录之前,这里的中继和 Cloud 模型都会停用。`,"Mainland China phone number or e-mail":`中国大陆手机号或邮箱`,"138 0000 0000 or you@example.com":`138 0000 0000 或 you@example.com`,"One free account. It is what lets your phone and computers work as one and brings a model to start with.":`一个免费账号。有了它,手机和电脑就是同一个 nanoMuse,还自带一个可以先用起来的模型。`,"It uses your phone and computers for you":`替你操作手机和电脑`,"The relay keeps an account id, a masked identifier, usage counts and your agent's name and look; what else, and what is yours to switch off, is in the":`中继保存账号 id、打码后的手机号或邮箱、用量计数和你的智能体的名字与形象;其余保存什么、哪些由你决定,见`,"privacy policy":`隐私政策`,"Free, open source, non-profit":`免费、开源、非营利`,"nanoMuse is a non-profit open-source community project, free forever.":`nanoMuse 是非营利的开源社区项目,永久免费。`,"Chat apps":`聊天入口`,"nanoMuse is a non-profit open-source community project, free forever. Sign in with a phone number or an e-mail and the model comes with a free allowance, paid by the developer; when it is gone, use your own key (Alibaba Cloud Bailian in mainland China, OpenRouter elsewhere). Nothing is sold; what the relay keeps is in the privacy policy, and Settings → Data controls is yours. Delete the account whenever you like.":`nanoMuse 是非营利的开源社区项目,永久免费。用手机号或邮箱登录,模型自带一份免费额度,费用由开发者承担;用完可以换自己的 key(中国大陆用阿里云百炼,海外用 OpenRouter)。数据不会出售;服务器保存什么写在隐私政策里,「设置 → 数据控制」由你决定。账号随时可以删除。`,"The people building it use it every day. Report a bug, ask for a feature, send a pull request: that is what moves the project. A star on GitHub helps others find it.":`做它的人每天都在用。欢迎反馈问题、提需求、贡献代码,项目就是这样往前走的。在 GitHub 点个 Star,能让更多人找到它。`,"Star on GitHub":`去 GitHub 点个 Star`,"Report a bug or ask for a feature":`反馈问题 / 提需求`,"Data controls":`数据控制`,"Help improve nanoMuse's AI models":`帮助改进 nanoMuse 的 AI 模型`,"While this is on, the text of your chats with the nanoMuse Cloud models (what you wrote, what it answered and the tools it chose to call) is kept on the relay to train the community's own open model. Not your memory or SOUL (the system prompt), not what tools returned, not pictures, and never next to who you are. Your own API key never passes through the relay.":`开启后,你与 nanoMuse Cloud 模型对话的文字(你写的、它回答的,以及它选择调用的工具)会保存在中继上,用来训练社区自己的开源模型。不保存你的记忆和 SOUL(系统提示)、工具返回的内容和图片,也不和你的身份放在一起。用自己的 API key 时,对话不经过中继。`,"New accounts start with it on.":`新账号默认开启。`,"New accounts start with it off.":`新账号默认关闭。`,"Sign in to nanoMuse Cloud to use it.":`登录 nanoMuse Cloud 后可用。`,"{n} turns kept so far.":`目前已保存 {n} 轮对话。`,"{n} turns kept so far":`已保存 {n} 轮对话`,"{n} turns":`{n} 轮`,"On. Turn it off here at any time.":`已开启。随时可以在这里关闭。`,"Off. Nothing more is kept.":`已关闭。之后的对话不再保存。`,"Delete the kept conversations":`删除已保存的对话`,"Removes every turn kept from this account, whether the switch is on or off now. Turning the switch off keeps what was kept until you delete it here.":`删除这个账号已保存的全部对话,不管开关现在是开是关。只关掉开关不会删除已保存的内容,要在这里删。`,"The kept conversations are removed from the server. This cannot be undone.":`已保存的对话会从服务器上删除,不可恢复。`,"{n} turns deleted":`已删除 {n} 轮`,"Set a password":`设一个密码`,"Your account is in. With a password, your phone and your other computers sign in at once, without waiting for a code. Optional; you can set one later under Account.":`账号已登录。设了密码,手机和其他电脑就能直接登录,不用再等验证码。可选,以后也可以在「账号」里设置。`,"At least 8 characters":`至少 8 个字符`,"Set the password":`设置密码`,"Not now":`以后再说`,"I already starred":`已经点过了`,"Free allowance":`免费额度`,"left of ¥{grant}":`剩余 / 共 ¥{grant}`,"¥{spent} used{usd}; the allowance does not reset.":`已用 ¥{spent}{usd};额度不按天重置。`,"No limit on this account.":`这个账号不限额度。`,"¥{allowance} to start, +¥{invite} for each friend you invite, and +¥{invite} for them; after that, your own key keeps the model going.":`起始 ¥{allowance},每邀请一位朋友你 +¥{invite}、对方也 +¥{invite};用完后换自己的 key 继续。`,"The free allowance is used up.":`免费额度已用完。`,"The image provider is busy right now; try again in a minute.":`生图服务现在有点忙,请过一分钟再试。`,"Nearly used up: ¥{left} of ¥{grant} left.":`额度快用完了:还剩 ¥{left}(共 ¥{grant})。`,"Invite a friend (+¥{invite} for each of you) or bring your own key; your sign-in keeps working either way.":`邀请一位新用户(你和对方各 +¥{invite}),或者换成自己的 key;无论哪种,登录都不受影响。`,"See the ways":`看看怎么办`,Dismiss:`关闭`,"Three ways on; your sign-in and your devices keep working whichever you pick.":`三个办法继续用;无论选哪个,登录和多设备功能都不受影响。`,"Use your own model key":`换成自己的 key`,"Alibaba Cloud Bailian is a good start: a new account comes with a free quota, set-up takes about two minutes, and one key covers chat, the hands, pictures and clips.":`推荐阿里云百炼:新用户有免费额度,开通约 2 分钟,一个 key 就能对话、动手、画图、生成视频。`,"Alibaba Cloud Bailian only signs up accounts from mainland China. Outside, OpenRouter is the easy way: one account, one key, pay as you go, for chat, the hands and pictures; clips need Bailian.":`海外用户用不了百炼,推荐 OpenRouter:一个账号一把 key,按量付费,对话、动手、画图都行;生成视频需要百炼。`,"Other providers and what each key covers":`其他服务商,以及各自的 key 能做什么`,"Get a key":`去申请 key`,"covers {list}":`支持{list}`,", ":`、`," or ":`或`,"Sign in with a plan you already pay for":`用你已经在付费的套餐登录`,"{plans}: the plan's models answer here, no key to make. ChatGPT works on every device, the others on the phones. A ChatGPT sign-in covers chat and the hands; pictures and clips still want a key.":`{plans}:套餐里的模型直接在这里回答,不用申请 key。ChatGPT 在每台设备上都可以,其他的在手机上。ChatGPT 登录只覆盖对话和动手;画图和生成视频仍需要一把 key。`,"Sign in with ChatGPT":`用 ChatGPT 登录`,"Or sign in with a ChatGPT plan":`或者用 ChatGPT 套餐登录`,"Covers chat and the hands. Pictures and clips are not part of it; those need a provider with image or video models.":`覆盖对话和动手。画图和生成视频不在其中,那两项需要一个有图像或视频模型的服务商。`,"OpenAI's terms cover using a ChatGPT plan inside OpenAI's own Codex; other apps have had this access cut off before (OpenCode, January 2026). If it stops working, an API key does.":`OpenAI 的条款只允许在它自家的 Codex 里使用 ChatGPT 套餐;其他应用曾被切断过这条路(OpenCode,2026 年 1 月)。哪天它不能用了,API key 仍然可以。`,"This nanoMuse has no sign-in route yet. In a terminal on this computer, run":`这个 nanoMuse 还没有登录接口。在这台电脑的终端里运行`,"and reload this page.":`然后刷新本页。`,"Signed in with {label}.":`已用 {label} 登录。`,"Use it for the chat":`让它来回答对话`,"Sign out of ChatGPT":`退出 ChatGPT`,"A page from OpenAI opened in a new tab. Sign in there; this card follows on its own.":`已在新标签页打开 OpenAI 的页面。在那里登录,这张卡片会自己跟上。`,"Open the page again":`再次打开页面`,"The sign-in did not finish in ten minutes. Try again.":`登录十分钟内没有完成。再试一次。`,"A page from OpenAI opened in a new tab. Sign in there; when the browser ends on an address that will not load, paste that address below.":`已在新标签页打开 OpenAI 的页面。在那里登录;浏览器最后停在一个打不开的地址时,把那个地址粘贴到下面。`,"The address the browser ended on":`浏览器最后停在的地址`,"Use this address":`用这个地址`,"This nanoMuse runs where the browser cannot reach port 1455, so it cannot pick the sign-in up by itself.":`这个 nanoMuse 运行的机器上,浏览器连不到 1455 端口,所以它没法自己接住登录结果。`,"That address belongs to another sign-in attempt. Open the page again and paste the address it ends on.":`这个地址属于另一次登录尝试。再次打开页面,粘贴它最后停在的地址。`,"ChatGPT answers the chat now.":`现在由 ChatGPT 回答对话。`,"Pictures need a provider with image models: {providers}.":`画图需要一个有图像模型的服务商:{providers}。`,"Clips need a provider with video models: {providers}.":`生成视频需要一个有视频模型的服务商:{providers}。`,"The hands need a model that sees pictures: {providers}.":`动手需要一个能看图的模型:{providers}。`,"Chat needs a model: {providers}.":`对话需要一个模型:{providers}。`,How:`怎么做`,"Automatic · {model}":`自动 · {model}`,"E-mail (or a mainland China phone number)":`邮箱(或中国大陆手机号)`,"Text-message codes reach mainland-China numbers only. Use an e-mail address instead.":`短信验证码只能发到中国大陆手机号。其他地区请用邮箱登录。`,"Step-by-step guide":`图文教程`,"Invite a friend: +¥{bonus} for you and +¥{bonus} for them, for each new person who signs up with your link.":`邀请朋友:每有一位新用户通过你的链接注册,你 +¥{bonus},对方也 +¥{bonus}。`,"Your invite link is under Account":`邀请链接在「账号」页`,"Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my link: {link}":`来和我一起用 nanoMuse,完全开源的个人智能体,免费使用。用我的链接注册:{link}`,"Invite code (optional)":`邀请码(选填)`,"A friend's code counts for a new account: you both get ¥{invite} more allowance.":`朋友的邀请码只对新账号有效:你和对方的额度各 +¥{invite}。`,"Have an invite code?":`有邀请码?`,"Invite a friend":`邀请朋友`,"Each new person who signs up with your code adds ¥{bonus} to your allowance, and ¥{bonus} to theirs. It never expires.":`每有一位新用户用你的邀请码注册,你的额度 +¥{bonus},对方也 +¥{bonus},不过期。`,"Your code":`邀请码`,Copied:`已复制`,"Share the link":`分享链接`,"Share the code":`分享邀请码`,"Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}":`来和我一起用 nanoMuse,完全开源的个人智能体,免费使用。用我的邀请码 {code} 注册`,"relay privacy policy (ask your relay operator)":`中继的隐私政策(请询问你的中继运营者)`,"Copy the link":`复制链接`,"Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}: {link}":`来和我一起用 nanoMuse,完全开源的个人智能体,免费使用。用我的邀请码 {code} 注册:{link}`,"{n} friends joined":`已邀请 {n} 位`,"¥{amount} added by invitations":`邀请带来 ¥{amount}`,"The code you received":`收到的验证码`,"Send again":`重新发送`,"Send me a code":`发送验证码`,"Sign in":`登录`,"Sign in and use its model":`登录并使用它的模型`,"Signed in to nanoMuse Cloud.":`已登录 nanoMuse Cloud。`,"Or bring your own key":`或者用你自己的密钥`,"This device":`这台设备`,"A name your other devices will see":`其他设备看到的名字`,"On the hub":`已连上中继`,"Reachable by your other devices while this runs.":`运行期间,你的其他设备可以找到它。`,"Sign in first.":`先登录。`,"My other devices may operate it":`允许我的其他设备操作它`,"They can run commands, read files and see its screen here; each step still goes through the Sentinel.":`其他设备可以在这里运行命令、读文件、看屏幕;每一步仍经 Sentinel 审核。`,Refused:`被拒绝`,"Connecting…":`连接中…`,"Connecting to the hub…":`正在连接中继…`,"The hub refused this device. Sign in again.":`中继拒绝了这台设备。请重新登录。`,"The hub is off on this device.":`这台设备没有开启中继。`,"Sign in above to see them.":`先在上面登录,就能看到。`,"{n} online":`{n} 台在线`,online:`在线`,Ask:`去问它`,"Ask one of your devices":`让某台设备去做`,"Every device signed in to your account is a Muse of its own, on the same conversations. Type @ and a device's name in the chat to hand it work; a computer lends its hands.":`登录你账号的每台设备都是一个自己的 Muse,看到的是同一批对话。在聊天里输入 @ 加设备名,就能把活交给它;电脑还能出借它的双手。`,"1 device":`1 台设备`,"{n} devices":`{n} 台设备`,"Other devices":`其他设备`,Online:`在线`,"Last seen {when}":`最后在线 {when}`,Tasks:`任务`,Commands:`命令`,Screenshot:`截屏`,"Open links":`打开链接`,Hands:`双手`,"More about {name}":`{name} 的更多操作`,"Forget this device":`移除这台设备`,"Type @{name} in the chat to hand it work.":`在聊天里输入 @{name},就能把活交给它。`,"Add a device":`添加一台设备`,"No other device yet":`还没有其他设备`,"Install nanoMuse on your phone, Mac or PC and sign in as {hint}; it appears here on its own.":`在手机、Mac 或 PC 上安装 nanoMuse,用 {hint} 登录,它就会自己出现在这里。`,"Install nanoMuse on your phone, Mac or PC and sign in with the same account; it appears here on its own.":`在手机、Mac 或 PC 上安装 nanoMuse,用同一个账号登录,它就会自己出现在这里。`,"Get the apps":`下载应用`,"Forget {name}? It can join again by signing in.":`忘掉 {name}?它重新登录后还可以加入。`,"on {device}":`在 {device} 上`,"from {device}":`来自 {device}`,"asked from {device}":`从 {device} 发来`,"{name} on {device} wants to":`{device} 上的 {name} 想要`,"Hands on this computer":`操作这台电脑`,"On the screen now":`正在操作屏幕`,"Nothing can drive this screen yet.":`目前没有能驱动这块屏幕的组件。`,'On a Mac, allow nanoMuse under System Settings → Privacy & Security → Accessibility and Screen Recording when macOS asks; without them clicks do nothing and the screenshot is black. The first action in each app asks you once ("Let {name} use Safari?") and the answer is kept under Permissions.':`Mac 上,macOS 弹窗询问时请在「系统设置 → 隐私与安全性」的「辅助功能」和「屏幕录制」里允许 nanoMuse;不允许的话点击没有反应,截图也是黑的。每个应用的第一步会先问你一次(「让 {name} 使用 Safari 吗?」),答复记在「权限」里。`,Where:`范围`,"One window":`单个窗口`,"Whole screen":`整个屏幕`,"Working in {app}'s window; the mouse stays yours.":`正在 {app} 的窗口里操作;鼠标仍归你用。`,"On the whole screen for now: {reason}":`暂时在整个屏幕上操作:{reason}`,"One window: the hands work inside the app they were given, send clicks and keys to it alone and leave your mouse alone. Whole screen: the system mouse, like other platforms. Auto picks one window as soon as an app is named.":`单个窗口:只在指定的应用里操作,点击和按键只发给它,不动你的鼠标。整个屏幕:用系统鼠标,和其他平台一样。自动:一旦指定了应用就用单个窗口。`,"It looks at a screenshot and clicks by position; paying, sending and deleting ask you first.":`它看截图、按位置点击;支付、发送、删除前都会先问你。`,Driver:`驱动`,Working:`工作中`,"Stop the hands":`停止操作`,"{name} is using this computer":`{name} 正在操作这台电脑`,"Hands done":`操作完成`,"Hands stopped":`操作已停止`,"{n} steps":`{n} 步`,Clicked:`点击`,"Clicked {label}":`点击了 {label}`,"Double-clicked":`双击`,"Double-clicked {label}":`双击了 {label}`,"Right-clicked":`右键点击`,"Right-clicked {label}":`右键点击了 {label}`,"Pointed at {label}":`指向了 {label}`,"Moved the mouse":`移动了鼠标`,"Typed {n} characters":`输入了 {n} 个字符`,"Pressed {keys}":`按下了 {keys}`,Scrolled:`滚动`,Dragged:`拖动`,"Opened {label}":`打开了 {label}`,"Opened an application":`打开了一个应用`,Waited:`等待`,"The dragon":`小龙`,"The face drawn for you":`为你画的形象`,"Draw four":`画四张`,send_email:`发邮件`,shell:`运行命令`,"E-mail, calendar, browser, MCP servers":`邮件、日历、浏览器、MCP 服务器`,"Rename this device":`给这台设备改名`,"Use my own API key or a plan I pay for":`用我自己的 API key,或我在付费的套餐`,"{first} first, then {others}":`首选 {first},其次 {others}`,"DeepSeek, Kimi, OpenAI, ChatGPT…":`DeepSeek、Kimi、OpenAI、ChatGPT…`,"OpenAI, ChatGPT, Gemini, DeepSeek…":`OpenAI、ChatGPT、Gemini、DeepSeek…`,"Which model answers?":`让哪个模型来回答?`,"Your account already brings one. You can add your own API key as well and switch at any time.":`你的账号已经自带了一个。也可以加上自己的 API key,随时切换。`,"DeepSeek for the chat and Qwen for the hands, with a free allowance per account paid by the developer. Nothing to configure.":`对话用 DeepSeek,动手用 Qwen;每个账号都有一份由开发者承担的免费额度,不用配置。`,"I have my own API key":`我有自己的 API key`,"Alibaba Cloud Bailian first (one key covers chat, the hands, pictures and clips), then DeepSeek, Kimi, Zhipu, OpenAI, OpenRouter and any OpenAI-compatible endpoint. The key stays on this device.":`首选阿里云百炼(一个 key 就能对话、动手、画图、生成视频),其次是 DeepSeek、Kimi、智谱、OpenAI、OpenRouter,以及任何兼容 OpenAI 接口的服务。key 只留在这台设备上。`,"OpenRouter first (one account, one key, pay as you go), then OpenAI, Anthropic, Gemini, DeepSeek and any OpenAI-compatible endpoint. The key stays on this device.":`首选 OpenRouter(一个账号一把 key,按量付费),其次是 OpenAI、Anthropic、Gemini、DeepSeek,以及任何兼容 OpenAI 接口的服务。key 只留在这台设备上。`,"A ChatGPT plan signs in from Connections and answers the chat and the hands. Pictures and clips still want a key.":`ChatGPT 套餐可在「连接」里登录,回答对话并驱动双手。画图和生成视频仍需要一把 key。`,"3D toy (Muse)":`3D 玩偶(Muse)`,Flat:`扁平`,"3D clay":`3D 黏土`,Watercolour:`水彩`,Pixel:`像素`,Line:`线条`,Sticker:`贴纸`,Draw:`画`,"Making pictures":`生成图片`,"Making clips":`生成视频`,"Portraits of your Muse.":`形象的肖像。`,"Short clips of your Muse.":`形象的短视频。`,"The chat model's provider":`对话模型的服务商`,"Your key":`你的 key`,"Add a provider":`添加服务商`,"Other OpenAI-compatible endpoint":`其他 OpenAI 兼容接口`,"The endpoint that serves /images/generations, for example https://host/v1.":`提供 /images/generations 的接口,例如 https://host/v1。`,"Stored encrypted in the vault as {name}.":`以 {name} 加密存储在保险库中。`,"Uses the chat model's key.":`用对话模型的 key。`,"Your phone can now operate this computer: run commands, fetch files, hand over whole tasks. Each risky step still asks here first.":`你的手机现在可以操作这台电脑了:跑命令、取文件、把整件事交给它。有风险的每一步仍会先在这里问你。`,"Picture model":`图片模型`,"Clip model":`短片模型`,"Other model…":`其他模型…`,"More models on your account":`账号可用的更多模型`,"Search models":`搜索模型`,"No model matches":`没有匹配的模型`,"reads pictures":`能看图`,"Type a model id…":`手动输入模型 id…`,"Pick from the list":`从列表里选`,"{n} models your account may use: the menu first, then everything else the provider lists under the Cloud key. Pick one; the ones beyond the menu are priced as the dearest menu model of their kind.":`账号可用的模型共 {n} 个:先是菜单,然后是服务商在 Cloud 这把 key 下列出的其他模型。直接选就行;菜单之外的按同类里最贵的菜单模型计费。`,"Your account may name any model the provider has, not only these: type its id (a chat model here, a picture or clip model below) and it goes through as typed.":`你的账号可以使用服务商的任意模型,不限于这几个:直接输入模型 id(对话模型填在这里,图片或短片模型填在下面),会原样转发。`,Automatic:`自动`,"{n} pictures":`{n} 张图`,"{n} pictures and {c} clips":`{n} 张图加 {c} 段短片`,"{what}, made with your own key at your provider's prices.":`{what},用你自己的 key 按服务商价格计费。`,"The cost could not be checked: {error}":`费用暂时查不到:{error}`,"Checking the cost…":`正在查费用…`,"About ¥{cny} for {what}; your account has no limit.":`{what}约 ¥{cny};你的账号没有额度限制。`,"About ¥{cny} for {what}, more than the ¥{left} left in your allowance.":`{what}约 ¥{cny},超过了额度里剩下的 ¥{left}。`,"About ¥{cny} for {what}; ¥{left} left in your allowance.":`{what}约 ¥{cny};额度还剩 ¥{left}。`,"A new look for {name}":`给{name}换个形象`,"Drawing four to choose from…":`正在画四个供你挑选…`,"Pick one: tap it, or say which":`选一个:点它,或者说第几个`,"Drawing the poses…":`正在画各种姿态…`,"The new look is on; making the clips…":`新形象已经换上,正在做短片…`,"The new look is on":`新形象已经换上`,"Not this time":`这次先不换`,"That did not work":`没成功`,"Candidate {n}":`第 {n} 个`,Redraw:`重画一组`,"Idle, working, waiting, happy, sorry: the face follows what {name} is doing.":`待机、工作、等待、开心、抱歉:形象会跟着 {name} 正在做的事变化。`,"Four more pictures from the one you picked; this takes a minute or two.":`根据你选的这张再画四张;需要一两分钟。`,"Some poses could not be drawn and show the idle picture instead.":`有几个姿态没画出来,先用待机图代替。`,"The face is already on; four short clips are being made from the poses, a few minutes.":`形象已经换上了;正在根据这几张姿态图做四段短片,需要几分钟。`,"Some clips could not be made; those moods stay still.":`有几段短片没做出来,这些状态就用静态图。`,"The new look is on; the clips are being made.":`新形象已经换上;短片正在做。`,"The new look is on.":`新形象已经换上。`,"Not this time.":`这次先不换。`,"New look: {description}. Say what to change any time, or pick another under Settings.":`新形象:{description}。想改随时说,也可以在「设置」里换别的。`,"Join the hub":`加入中继`,"This chat goes to {device}.":`这个对话发给 {device}。`,"Whatever you ask here, the {name} on {device} does where it is: its shell, its files, its screen. Every step shows up here, and anything that needs an approval asks you here.":`在这里说的每件事,都由 {device} 上的 {name} 在它那边完成:它的命令行、它的文件、它的屏幕。每一步都显示在这里,需要批准的事也在这里问你。`,"What is on your screen right now?":`你屏幕上现在是什么?`,"Which folder are you in, and what is in it?":`你在哪个目录,里面有什么?`,"Check for updates and tell me what needs a restart":`检查一下更新,告诉我哪些需要重启`,Account:`账号`,"Account deleted.":`账号已删除。`,"Account id":`账号 ID`,"Sign in to nanoMuse Cloud":`登录 nanoMuse Cloud`,"Sign in to {name}":`登录 {name}`,"nanoMuse Cloud · one account for all your devices":`nanoMuse Cloud · 一个账号,所有设备`,"Every device of yours, one nanoMuse":`你的每台设备,同一个 nanoMuse`,"A model with a free allowance, or your own key":`自带免费额度的模型,或用你自己的 key`,"Keys in your vault; nothing you say is kept on the relay":`key 留在你的保险库里;你说的内容不会留在中继上`,"Free. One account for all your devices, with a model and ¥{allowance} of use to start; a code the first time, a password afterwards if you like.":`免费。一个账号连起你所有设备,自带模型和 ¥{allowance} 的起始额度;第一次用验证码登录,之后可以设密码。`,"With a code":`验证码登录`,"With a password":`密码登录`,"No password yet? Use a code first; set one under Account.":`还没有密码?先用验证码登录,再到「账号」里设置。`,"E-mail":`邮箱`,"Mobile number":`手机号`,"since {date}":`{date} 起`,Unlimited:`不限量`,"All time":`累计`,Usage:`用量`,"Every kind of use is counted, not just the chat model.":`每一类用量都会统计,不只是对话模型。`,Pictures:`图片`,Clips:`视频`,Calls:`通话`,"Nothing yet":`还没有`,"{n} answers · {tokens} tokens":`{n} 次回答 · {tokens} tokens`,"{n} requests · {tokens} tokens":`{n} 次请求 · {tokens} tokens`,"{n} requests":`{n} 次请求`,"By model, all time":`按模型(累计)`,"A password is set":`已设置密码`,"No password yet":`还没有密码`,"Sign in with it on a new device, or with a code as before.":`新设备可以用密码登录,也可以像以前一样用验证码。`,"Optional. With one, a new device can sign in without waiting for a code.":`可选。设置后,新设备不用等验证码就能登录。`,Change:`修改`,Set:`设置`,"Current password":`当前密码`,"New password (8 characters or more)":`新密码(至少 8 位)`,"Once more":`再输一次`,"The two do not match.":`两次输入不一致。`,"Change password":`修改密码`,"Set password":`设置密码`,"Password set.":`密码已设置。`,"Password changed.":`密码已修改。`,"Password removed.":`密码已移除。`,"Signed in on":`已登录的设备`,"Unnamed device":`未命名设备`,"This one":`本机`,code:`验证码`,password:`密码`,"used {when}":`{when}用过`,"Sign out {device}? It will need to sign in again.":`让 {device} 退出登录?它需要重新登录。`,"Recent activity":`最近动态`,"Show all {n}":`全部 {n} 条`,"Show less":`收起`,"Account created":`账号已创建`,"Signed in with a code":`用验证码登录`,"Signed in with the password":`用密码登录`,"A sign-in attempt failed":`一次登录失败`,"Signed out everywhere":`已在所有设备退出`,"Password removed":`密码已移除`,"Password changed":`密码已修改`,"Password set":`密码已设置`,"A request was refused: the allowance is used up":`一次请求被拒绝:免费额度已用完`,"Call ended":`通话结束`,"The model provider returned an error":`模型服务返回了错误`,"Allowance added":`额度已增加`,"Allowance adjusted":`额度已调整`,"Signed up with a friend's code":`用朋友的邀请码注册`,"A friend signed up with your code":`一位朋友用你的邀请码注册`,"An invite code was not recognised":`邀请码无法识别`,"Data controls: on for new accounts":`数据控制:新账号默认开启`,"Data controls: help improve turned on":`数据控制:已开启「帮助改进」`,"Data controls: help improve turned off":`数据控制:已关闭「帮助改进」`,"Data controls: kept conversations deleted":`数据控制:已删除保存的对话`,"Agent's name and look saved":`已保存智能体的名字与形象`,"Agent's name and look cleared":`已清除智能体的名字与形象`,"Sign out everywhere":`在所有设备退出`,"Sign out on every device, including this one?":`在所有设备退出登录,包括这一台?`,"Delete the account? The relay forgets your identifier, your devices and your usage. This cannot be undone.":`删除账号?中继会忘掉你的标识、设备和用量记录,且无法恢复。`,"Really delete it?":`真的要删除吗?`,"Signed out.":`已退出登录。`,"Delete the account":`删除账号`,"The Cloud model is in use; after signing out, pick another under Connections.":`当前使用的是 Cloud 模型;退出后请到「连接」里换一个。`,"{n} min ago":`{n} 分钟前`,"{n} h ago":`{n} 小时前`,"{n} d ago":`{n} 天前`,"Use the nanoMuse Cloud model":`使用 nanoMuse Cloud 模型`,"Signed in as {hint}. A free allowance, nothing to paste. Recommended to start.":`已登录 {hint}。自带免费额度,无需粘贴任何 key,推荐先用它。`,"Your account brings a model with a free allowance, nothing to set up. Or paste a key of your own; it is stored encrypted in the vault on the server, never shown to the model, and nothing you say passes through the relay.":`你的账号自带一个有免费额度的模型,不用再设置什么。也可以粘贴自己的 key:它加密存在服务端的保险库里,不会给模型看到,你说的内容也不经过中继。`,"Voice input":`语音输入`,"Stop voice input":`停止语音输入`,"The microphone is not allowed. Allow it in the browser and try again.":`没有拿到麦克风权限。在浏览器里允许后再试一次。`,"Voice input stopped ({code}).":`语音输入中断了({code})。`,"said on a call":`通话中说的`,"Coding agents":`编程助手`,"Cursor, Codex and Claude Code: see what they are doing, and tell them things from anywhere.":`Cursor、Codex 和 Claude Code:随时看它们在做什么,在任何地方给它们发消息。`,Coding:`编程`,"This computer":`这台电脑`,"This one · {name}":`本机 · {name}`,installed:`已安装`,"chats only":`仅记录`,"not found":`未找到`,"{agent} chats":`{agent} 的对话`,"Recent chats":`最近的对话`,"New chat":`新对话`,"No coding agent found on this computer. Install Cursor, Codex or Claude Code and their chats appear here.":`这台电脑上没有找到编程助手。安装 Cursor、Codex 或 Claude Code 后,它们的对话会出现在这里。`,"No chats yet.":`还没有对话。`,"Untitled chat":`未命名对话`,"{n} messages":`{n} 条消息`,"Sent through nanoMuse":`经 nanoMuse 发送的`,stopped:`已停止`,"Sent to {agent}.":`已发给 {agent}。`,"{agent} is working…":`{agent} 正在处理…`,"Tell {agent}…":`对 {agent} 说…`,"Nothing readable in this chat yet.":`这个对话里还没有可读的内容。`,"via nanoMuse":`经 nanoMuse`,"Continued as a new chat: the agent could not reopen the old one.":`原来的对话打不开了,已经另起一个新对话接着聊。`,"Made in the IDE: your message starts a new chat in the same workspace, with the last exchange quoted.":`这是 IDE 里的对话:你的消息会在同一工作区开一个新对话,并带上上一轮的内容。`,"Continue in a new chat…":`在新对话里接着说…`,"starting…":`启动中…`,"Send to {agent}":`发给 {agent}`,"Project folder":`项目目录`,"What to do":`要做什么`,"Add tests for the parser and run them":`给解析器补上测试并跑一遍`,"The agent runs on that computer with its own permissions; nanoMuse only carries the message and brings the answer back.":`助手在那台电脑上以它自己的权限运行;nanoMuse 只负责传话和带回结果。`,"The file is larger than {n} MB.":`文件超过了 {n} MB。`,"empty file":`文件是空的`,"thread is busy":`这个对话正忙`,"the main chat cannot be deleted":`主对话不能删除`,"no pending approval with that id":`这张卡片已经不在等待中`,"the device did not take the answer":`那台设备没有接受这个回答`,"already finished":`已经结束了`,"already cancelled":`已经取消了`,"memory is disabled":`记忆已关闭`,"memory is off":`记忆已关闭`,"recall by meaning is off":`按含义回忆已关闭`,"nothing proposed":`没有待处理的提议`,"that frame is gone":`这一帧已经不在了`,"no such file":`没有这个文件`,"no such post":`没有这条动态`,"no such skill of yours (built-in skills are switched off, not removed)":`没有这个你自己的技能(内置技能只能关闭,不能删除)`,"the file is larger than 16 MB":`文件超过了 16 MB`,"body too large (64 KB max)":`内容太大(最多 64 KB)`,"fill in the servers, the address and the password first":`先填好服务器、地址和密码`,"add a calendar link first":`先添加一个日历链接`,"signed out":`未登录`,"Sign in to nanoMuse Cloud first.":`请先登录 nanoMuse Cloud。`,"Enter the password.":`请输入密码。`,"The relay returned no key.":`中继没有返回 key。`,Details:`详情`,"Hide details":`收起详情`,"Today's share of the free allowance is used up; it comes back tomorrow. Your own model key under Connections keeps you going now.":`今天的免费额度用完了,明天恢复。在「连接」里填上你自己的模型 key,现在就能继续。`,"The free allowance is used up. Invite a friend (the relay adds to both your allowances) or add your own model key under Connections; your sign-in and your devices keep working either way.":`免费额度已用完。邀请一位新用户(你和对方的额度都会增加),或者在「连接」里填上自己的模型 key;无论选哪个,登录和多设备功能都不受影响。`,"The account's free allowance is used up. Add your own model key under Connections to keep going.":`这个账号的免费额度已经用完。在「连接」里填上你自己的模型 key 就能继续。`,"Too many requests at once; wait a moment and try again.":`请求太密集了,稍等一下再试。`,"The model provider is busy; try again in a moment.":`模型服务商正忙,稍后再试。`,"The model provider's content check declined this request; try different words.":`模型服务商的内容审核没有放行这次请求,换个说法再试。`,"The relay's model provider refused its key; the operator has been told. Your own key under Connections works meanwhile.":`中继的模型服务商拒绝了它的 key,运营者已收到通知。这期间可以在「连接」里用你自己的 key。`,"The model provider does not know this model right now; pick another under Connections.":`模型服务商暂时不认识这个模型,在「连接」里换一个。`,"nanoMuse Cloud has no model key configured; the operator has been told.":`nanoMuse Cloud 还没有配置模型 key,运营者已收到通知。`,"This nanoMuse Cloud account is disabled.":`这个 nanoMuse Cloud 账号已被停用。`,"The nanoMuse Cloud sign-in is no longer valid; sign in again under Account.":`nanoMuse Cloud 的登录已失效,请在「账号」里重新登录。`,"That model is not offered by nanoMuse Cloud; pick another under Connections.":`nanoMuse Cloud 不提供这个模型,在「连接」里换一个。`,"That message is too large for the model's window. Shorten it, leave out some attachments, or start a new chat.":`这条消息超出了模型的上下文窗口。精简一下、去掉部分附件,或者开一个新对话。`,"This relay takes new accounts by invitation only; sign in with an invite code under Account.":`这个中继只接受邀请注册;在「账号」里用邀请码登录。`,"Too many turns are running on this account at once; wait for one to finish and try again.":`这个账号同时进行的对话太多了,等一轮结束后再试。`,"New sign-ups are paused on this relay for now; existing accounts keep working. Try again later.":`这个中继暂时停止了新注册,已有账号照常使用。稍后再试。`,"nanoMuse Cloud is paused by its operator for now; your sign-in and your data are kept. Try again later.":`nanoMuse Cloud 被运营者暂时停用了,你的登录和数据都保留着。稍后再试。`,"Conversation sync is paused on this relay for now; what is stored is kept and your devices keep working on their own.":`这个中继暂时停止了对话同步,已存的内容保留着,各设备各自继续使用。`,"The device hub is paused on this relay for now; each device keeps working on its own.":`这个中继暂时停止了设备互联,各设备各自继续使用。`,"The free allowance is paused on this relay for now, not used up. Your own model key under Connections keeps you going; your sign-in, your devices and what is left stay as they are.":`这个中继暂时停发了免费额度,不是用完了。在「连接」里填上你自己的模型 key 就能继续;你的登录、设备和剩余额度都保持原样。`,"This demo Muse has used its share of the model for this visit. Start over for a new one, or install nanoMuse and use your own key.":`这个演示 Muse 这一次的模型额度用完了。点「重新开始」再来一个,或者安装 nanoMuse,用你自己的 key。`,"The showcase has used today's share of the model; it comes back tomorrow. Install nanoMuse and use your own key to keep going.":`在线体验今天的模型额度用完了,明天会恢复。安装 nanoMuse,用你自己的 key 可以继续。`,"The model provider is having trouble; try again in a moment.":`模型服务商出了点问题,稍后再试。`,"The model provider refused the API key. Check it under Connections.":`模型服务商拒绝了这个 API key,去「连接」里检查一下。`,"The endpoint does not know this model. Pick another under Connections.":`这个接口不认识这个模型,在「连接」里换一个。`,"The model provider is rate-limiting this key; wait a moment and try again.":`模型服务商正在限制这个 key 的请求频率,稍等一下再试。`,"The model took too long to answer. Try again.":`模型回答超时了,再试一次。`,"Could not reach the model provider. Check the connection and try again.":`连不上模型服务商,检查一下网络再试。`,"This conversation is too long for the model. Start a new one, or pick a model with a larger context.":`这段对话对模型来说太长了。开一个新对话,或者换一个上下文更大的模型。`,"Enter a phone number or an e-mail address.":`请输入手机号或邮箱。`,"Enter a mainland phone number or an e-mail address":`请输入中国大陆手机号或邮箱`,"Enter a mainland phone number or an e-mail address.":`请输入中国大陆手机号或邮箱。`,"Codes reach mainland China numbers only for now; elsewhere, sign in with an e-mail address.":`短信验证码目前只支持中国大陆手机号,海外用户请用邮箱登录。`,"That code is not right.":`验证码不对。`,"That code has expired; ask for a new one.":`验证码已过期,重新获取一个。`,"Too many codes were sent; wait a few minutes.":`验证码发得太频繁了,几分钟后再试。`,"This relay is private; that address is not on its list.":`这个中继是私有的,这个邮箱不在它的名单上。`,"The code could not be sent; try again in a moment.":`验证码没能发出,稍后再试。`,"Sign in again.":`请重新登录。`,"This account has used its tokens.":`这个账号的额度已经用完。`,"This account is disabled.":`这个账号已被停用。`,"That model is not offered here.":`这里不提供这个模型。`,"Too many requests; slow down a little.":`请求太频繁了,稍微慢一点。`,"The free allowance is used up. Invite a friend (the relay adds to both your allowances) or add your own model key; your sign-in and your devices keep working either way.":`免费额度已用完。邀请一位新用户(你和对方的额度都会增加),或者换成自己的 key;无论选哪个,登录和多设备功能都不受影响。`,"Today's token quota is used up; it comes back tomorrow.":`今天的 token 配额用完了,明天恢复。`,"The model provider did not answer.":`模型服务商没有响应。`,"nanoMuse Cloud has no model key configured.":`nanoMuse Cloud 还没有配置模型 key。`,"nanoMuse Cloud cannot be reached.":`连不上 nanoMuse Cloud。`,"That address and password do not match.":`邮箱与密码不匹配。`,"This account has no password yet; sign in with a code and set one under Account.":`这个账号还没有设置密码:先用验证码登录,再在「账号」里设置。`,"Too many wrong passwords; wait a while or sign in with a code.":`密码错误次数太多,等一会儿再试,或者改用验证码登录。`,"Use at least 8 characters.":`至少 8 个字符。`,"Choose a stronger password.":`换一个更强的密码。`,"That is not the current password.":`当前密码不正确。`,"Enter the current password.":`请输入当前密码。`,"That sign-in is already gone.":`那个登录已经不存在了。`,"Cannot reach your nanoMuse right now.":`现在连不上你的 nanoMuse。`,"Your nanoMuse hit a problem; try again in a moment.":`你的 nanoMuse 出了点问题,稍后再试。`,"Your nanoMuse could not do that.":`你的 nanoMuse 没能完成这个操作。`,"Could not reach your nanoMuse.":`连不上你的 nanoMuse。`,"Connecting to your nanoMuse…":`正在连接你的 nanoMuse…`,"Try again":`再试一次`,"Could not load.":`没能加载出来。`,"Use /{name}":`使用 /{name}`,Retry:`重试`,"Could not load the conversation. Pull to retry.":`对话没能加载出来,下拉重试。`,"Could not load the connections.":`连接信息没能加载出来。`,"Your account":`你的账号`,"your account's model, with a free allowance":`账号自带的模型,带免费额度`,"on this machine, no key":`跑在本机,不用 key`,"vLLM, LM Studio, a gateway, anything with /v1":`vLLM、LM Studio、网关,任何带 /v1 的服务`,"Your account's model":`账号自带的模型`,"Your account's model, or a key of your own.":`用账号自带的模型,或者你自己的 key。`,"Your account's model, with its free allowance. Keep it, or switch to a key of your own here.":`账号自带的模型,带免费额度。可以保留,也可以在这里换成你自己的 key。`,"Avatar studio":`形象工坊`,"Avatar studio…":`形象工坊…`,"Draw a look with your image model; it moves with what it is doing.":`用你的图像模型画一个形象;它会随着工作状态动起来。`,More:`更多`,"Name & style":`名字与风格`,"Image model":`图像模型`,"Redraw the poses":`重画状态`,"Back to the built-in look":`换回内置形象`,"Back to the built-in look.":`已换回内置形象。`,"Something went wrong":`出了点问题`,"Drawing the poses… {done}/{total}":`正在画各个状态… {done}/{total}`,"The new look is on; making the clips… {done}/{total}":`新形象已经用上;正在生成动态片段… {done}/{total}`,"All five poses are in.":`五个状态都画好了。`,"{done} of {total} poses are in; the rest show the main picture.":`画好了 {total} 个状态里的 {done} 个,其余先用主图。`,"Describe a new look":`描述一个新形象`,"One sentence is enough: what it is, what it wears, its air. Pick a style and your image model draws four to choose from.":`一句话就够:它是什么、穿什么、什么气质。选一种风格,你的图像模型会画四张让你挑。`,"a round, custard-yellow baby dragon with small orange horns and folded little wings":`一只圆滚滚的奶黄色小龙,橙色小角,折着小翅膀`,"Drawing…":`正在画…`,"Draw four more":`再画四张`,"Drawn by {provider} · {model}":`由 {provider} · {model} 绘制`,"Pick one":`挑一张`,"Draw four more any time; the old ones stay until you do.":`随时可以再画四张;没重画之前旧的会留着。`,"Use this one":`就用这个`,"Next, its poses are drawn from it in the background: working, waiting for you, done, something wrong.":`接下来会在后台以它为底画出各个状态:干活、等你、搞定、出错。`,"Keep the current look":`先不换了`,"Nothing came back.":`什么也没生成出来。`,"The pictures stay in this computer's workspace under avatar/. Each new look asks your provider for eight pictures (four to choose from and four poses) and four short clips where there is a video model.":`图片存在这台电脑的工作区 avatar/ 下。每换一次形象会向你的服务商要八张图(四张供你挑选,四个状态),有视频模型时还会生成四段短动画。`,"Or just say it in the chat: “change your avatar to a corgi”.":`也可以直接在对话里说:“把虚拟形象改成一只柯基”。`,"Change your avatar to ":`把虚拟形象改成`,"Alibaba Cloud Bailian":`阿里云百炼`,Zhipu:`智谱`,"Volcano Ark":`火山方舟`,"this computer":`这台电脑`,"Change model":`更换`,Search:`搜索`,"Search chats":`搜索对话`,"Side chats":`旁聊`,"Start a side chat":`发起旁聊`,"Side chats are an optional way to keep conversations organised by topic.":`旁聊是按主题整理对话的可选方式。`,Keep:`保留`,"No chats match.":`没有匹配的对话。`,"A side chat keeps one topic apart from the rest.":`一个旁聊,把一个话题和其他的分开。`,"Report a bug":`报告问题`,"Developer tools":`开发者工具`,Developer:`开发者`,"The Coding screen (Cursor, Codex and the other coding agents on this computer) in the sidebar, and the runtime's address below. This device only.":`在侧栏显示「编程」(这台电脑上的 Cursor、Codex 等编程智能体),并在下面显示运行时的地址。仅此设备。`,"Runtime:":`运行时:`,"the token is in the server_token file in the data folder":`令牌在数据目录的 server_token 文件里`,"The command line and the API":`命令行与 API`,"nanoMuse on DeepSeek Harness":`nanoMuse 的 DeepSeek Harness 版`,"Desktop app":`桌面应用`,"Start with the computer":`开机自动启动`,"Opens in the tray at sign-in; the agent's goals and check-ins run from then on.":`登录系统后在托盘里启动;从那时起,它的目标和定时检查就开始跑了。`,"In the tray":`托盘图标`,"The icon by the clock shows what the hands are doing and has Stop; closing the window keeps the agent running.":`时钟旁的图标显示它正在操作什么,并带「停止」;关掉窗口后它仍在后台运行。`,Shortcuts:`快捷键`,"Quick chat":`快速对话`,"Show the window":`打开窗口`,"Feed settings":`动态设置`,"About the feed":`动态版块说明`,"Your feed is driven by the instruction below. Any edit you make here applies to every post from now on.":`动态由下面这句话来定。在这里改动它,今后的每一条帖子都会照着来。`,"Build me a feed about what I care about. Keep it short and direct, easy to skim, no clickbait.":`给我做一个关于我关心的事的动态。短一点、直接一点,扫一眼就能看完,别搞标题党。`,"Got it":`知道了`,"Write it now":`现在写一版`,"Nothing in the feed yet":`动态里还没有内容`,"As we get to know each other, new posts will show up here. Every day at {time} I read what I remember about you (your memory files, the last week of diary, your goals) and write a few short posts.":`随着我们相互了解,新的帖子会显示在这里。每天 {time},我会读一遍记得的关于你的东西(记忆文件、最近一周的日记、你的目标),然后写几条简短的帖子。`,"Steer it with one sentence":`一句话就能调整`,"Tap the sliders at the top right to tell me what you want more of, switch the daily routine off, or have me write the first day now.":`点右上角的滑杆,告诉我你想多看什么、关掉每天的例程,或者让我现在就写第一天。`,"Short posts your agent writes for you from what it remembers: your memory files, the last week of diary, your goals. The sentence below steers every post from now on; edit it any time.":`这里是你的智能体为你写的短帖子,取材于它记得的东西:记忆文件、最近一周的日记、你的目标。下面这句话决定今后每一条帖子的方向,随时可以改。`,"Write it every day":`每天自动写`,"Time of day":`时间`,"Add a model first; the feed is written by your agent.":`先添加一个模型,动态由你的智能体来写。`,Discuss:`讨论`,"About this post":`这条帖子的信息`,"Delete this post":`删除这条`,Written:`写于`,Note:`随记`,Files:`文件`,Fun:`休闲`,"About this post from my feed:":`聊聊我动态里的这一条:`,"Post type":`类型`,morning:`上午`,afternoon:`下午`,evening:`晚上`,"{weekday} {part}":`{weekday}{part}`,"Starts a conversation":`会开始一段对话`,"Send to chat":`发到聊天`,"Nothing tracked yet":`尚未追踪任何内容`,"Create a goal":`创建目标`,"Pick a category and tell me the goal you have in mind. I'll shape a plan with you and keep improving it as you go.":`选一个类别,告诉我你心里的目标。我会和你一起定出计划,并在推进过程中不断调整。`,"Create a {category} goal":`创建{category}目标`,"First, we'll shape the goal together in the chat. I'll ask a few questions so I understand exactly what you're after.":`首先,我们将在聊天中一起完善目标。我会问几个问题,准确了解你的目标。`,"Once it's set, I'll track your progress here.":`目标设置后,我会在这里追踪你的进度。`,"I'd like to create a {category} goal. Ask me a few short questions, one at a time (what exactly I want, why and by when, how often to check in), then create it with concrete steps using the goals tool.":`我想创建一个{category}目标。请一次问我一个简短的问题(我具体想达成什么、为什么以及什么时候、多久检查一次),然后用 goals 工具把它建成带具体步骤的目标。`,"Mark as done":`标记为已完成`,"Mark as not done":`标记为未完成`,"Plan my week":`规划我的一周`,"Tell me what's on your plate and I'll turn it into a realistic plan with the important things first.":`告诉我你手头有什么,我把它排成一份切实可行的计划,重要的事放前面。`,"Help me plan my week. Ask me what I need to get done, then propose a schedule.":`帮我规划这一周。先问我有哪些事要做,然后给出一份日程安排。`,"Research & compare options":`调研并比较选项`,"Laptops, flights, insurance, a new phone plan: I'll gather the facts and compare them for you.":`笔记本、机票、保险、新的手机套餐,我帮你收集事实并逐项比较。`,"I need to make a purchase decision. Ask me what I'm choosing between, then research and compare the options.":`我想买个东西,拿不定主意。先问我在哪几个里面挑,然后帮我查一查、比一比。`,"Set up a long-term goal":`建立一个长期目标`,"Share a goal (learn a language, run a 10k, save for a trip) and I'll break it into steps and keep track.":`说一个目标(学一门语言、跑完 10 公里、攒一次旅行的钱),我把它拆成步骤并持续跟进。`,"I want to set up a long-term goal. Ask me about it, then create a plan with concrete steps and track it.":`我想建立一个长期目标。先问问我,然后制定一份带具体步骤的计划并跟进。`,"Tell me about yourself":`介绍一下你自己`,"The more I know about your preferences, routines and constraints, the more useful I get. I'll remember what matters.":`我越了解你的偏好、作息和限制,就越能帮上忙。重要的事我会记住。`,"Ask me a few questions about myself so you can help me better, and remember the answers.":`问我几个关于我的问题,好更了解我,然后把答案记住。`,"Build a quick tracker":`做一个简单的追踪表`,"Spending, habits, workouts, reading: I can write a small script or document to track it for you.":`开支、习惯、锻炼、阅读,我可以写一个小脚本或文档帮你记录。`,"Build me a simple tracker. Ask me what I want to track and how, then create it in the workspace.":`帮我做一个简单的追踪表。先问我想记录什么、怎么记,然后在工作区里建好。`,Artifacts:`构件`,Media:`影音内容`,"Nothing created yet":`还没有创建任何内容`,"Documents, tables and pages I write for you will show up here.":`我为你写的文档、表格和页面会显示在这里。`,"No media yet":`还没有影音内容`,"Photos, screenshots and recordings I produce will show up here.":`我生成的图片、截图和录音会显示在这里。`,"No model yet":`还没有模型`,"Add a provider and pick its models":`添加一个服务商并选择模型`,"Image & video models":`图像与视频模型`,"Name & personality":`名字与人格`,"About nanoMuse":`关于 nanoMuse`,"Privacy policy":`隐私政策`,Feedback:`反馈问题`,none:`无`,"Change avatar":`更换虚拟形象`,"Edit name":`编辑名称`,Daily:`每日`,"Soul & memory":`SOUL 与记忆`,"Handle with care":`请谨慎访问`,"This device's name":`这台设备的名字`,"Sync conversations between my devices":`在我的设备之间同步对话`,"The text of your chats is kept on nanoMuse Cloud so every device shows the same conversations. Files and images stay on the device they were made on.":`对话文字会保存在 nanoMuse Cloud,让每台设备看到同样的对话。文件和图片只留在产生它们的设备上。`,"Paused: sign in again to continue.":`已暂停:重新登录后继续。`,"{c} chats, {m} messages kept so far.":`目前保存了 {c} 个对话、{m} 条消息。`,"Delete synced conversations":`删除已同步的对话`,"{n} chats":`{n} 个对话`,"Removes what nanoMuse Cloud stores for this account; the chats on each device stay, and the switch stays on. Deleting a chat on one device deletes it on all of them.":`删除 nanoMuse Cloud 为这个账号保存的内容;每台设备上的对话保留,开关也保持打开。在一台设备上删除某个对话,会在所有设备上一起删除。`,"Off deletes the synced conversations from nanoMuse Cloud. The chats on each device stay.":`关闭后会从 nanoMuse Cloud 删除已同步的对话。每台设备上的对话保留。`,"On. Your devices show the same conversations from now on.":`已打开。从现在起,你的设备会看到同样的对话。`,"Off. Nothing is kept on nanoMuse Cloud any more.":`已关闭。nanoMuse Cloud 不再保存任何对话。`,"The synced conversations are removed from nanoMuse Cloud. The chats on each device stay. This cannot be undone.":`已同步的对话将从 nanoMuse Cloud 删除。每台设备上的对话保留。此操作无法撤销。`,"Synced conversations deleted.":`已删除已同步的对话。`,"Also sync side chats":`同时同步旁聊`,"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.":`关:旁聊只留在本机。开:本机的旁聊上传到账号,其他设备的旁聊也会同步过来。`,"On. This device's side chats go to the account; the other devices' are on their way.":`已打开。本机的旁聊会上传到账号,其他设备的旁聊正在同步过来。`,"Off. Side chats stay on this device from now on.":`已关闭。从现在起,旁聊只留在本机。`,"{device} is working…":`{device} 正在处理…`,"From {device}":`来自 {device}`,"to {device}":`发给 {device}`,"This goes to {device}.":`这条会发给 {device}。`,"Runs this there and reports back":`在那台设备上执行并汇报结果`,Offline:`离线`,"There is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you.":`不用填表:有了模型之后,它会在聊天里介绍自己,并问你希望怎么称呼你。`,"A model, then the chat. A minute.":`先接一个模型,再去聊天。一分钟。`,"Open the chat. It introduces itself and asks your name.":`打开聊天。它会介绍自己,并问你的名字。`,"Pick a name for me, or type one":`给我挑一个名字,或者自己输入一个`,"Something else":`自己起一个`,"Proxy (optional)":`代理(可选)`,"Only this provider's requests go through it. nanoMuse Cloud never does.":`只有这个服务商的请求走代理;nanoMuse Cloud 的请求从不经过它。`,"Talk to your Muse from the chat apps you already use. Nothing needs a public address: each app keeps a long connection open.":`在你已经在用的聊天软件里和 Muse 对话。不需要公网地址:每个应用各自保持一条长连接。`,"Waiting for a yes":`等待通过`,"{name} wrote to the bot. Approve to let them talk to your Muse.":`{name} 给机器人发了消息。通过后对方就能和 Muse 对话。`,Approve:`通过`,"Code {code}":`配对码 {code}`,"Needs settings":`还缺设置`,"Not installed":`未安装`,Error:`出错`,Missing:`缺少`,"Switched on":`已开启`,"Install on the computer running nanoMuse, then switch it on again:":`在运行 nanoMuse 的电脑上安装,再重新开启:`,"Saved.":`已保存。`,Saving:`保存中`,"Set; leave blank to keep":`已设置,留空则保持不变`,"Clear the saved value":`清除已保存的值`,"In groups, reply":`在群里回复`,"only when @-mentioned":`只在被 @ 时`,"to every message":`每条消息`,"Always allowed (ids, one per line)":`免配对的 ID(每行一个)`,"People listed here never need a pairing code. A lone * lets anyone in; do that only on a bot nobody else can reach.":`列在这里的人不需要配对码。单独一个 * 表示放行所有人,只在别人找不到的机器人上这样做。`,"Paired chats":`已配对的聊天`,"Nobody yet. Write to the bot from the app; a pairing code comes back, approve it here.":`还没有人。在聊天软件里给机器人发条消息,会收到配对码,在这里通过即可。`,"Deliver here":`推送到这里`,"Chats with “deliver here” on also get what the Muse does on its own: check-ins, reminders, the results of background work, and the questions it needs answered.":`开了「推送到这里」的聊天还会收到 Muse 主动做的事:问候、提醒、后台任务的结果,以及需要你回答的问题。`,"Remove {name}? They will need a new pairing code.":`移除 {name}?对方需要重新配对。`,"Send a test message":`发送测试消息`,Sending:`发送中`,"Create the bot by QR code":`扫码创建机器人`,"Open in Feishu":`在飞书里打开`,"Scan with Feishu, confirm the new bot, and the App ID and Secret are saved for you.":`用飞书扫码,确认创建机器人,App ID 和 Secret 会自动保存。`,"Lark (international) instead of 飞书":`用 Lark(国际版)而不是飞书`,"Waiting for the scan…":`等待扫码…`,"The bot was created and is connecting.":`机器人已创建,正在连接。`,"The login did not finish ({error}).":`登录没有完成({error})。`,"Set up":`设置步骤`,"Open the console":`打开控制台`,"Then write to the bot from the app. The first message gets a pairing code; approve it here or run":`然后在聊天软件里给机器人发消息。第一条消息会收到配对码,在这里通过,或运行`,"Each chat is one conversation of your Muse; its replies stream back as they are written.":`每个聊天对应 Muse 的一个会话;回复会边写边发回来。`,"In the Feishu developer console create a custom app and add the Bot capability.":`在飞书开放平台创建企业自建应用,添加「机器人」能力。`,"Permissions: im:message, im:message.p2p_msg:readonly, im:message.group_at_msg:readonly, im:resource.":`权限:im:message、im:message.p2p_msg:readonly、im:message.group_at_msg:readonly、im:resource。`,"Events & callbacks: subscription mode Long connection; add im.message.receive_v1; callback mode Long connection; add card.action.trigger.":`事件与回调:订阅方式选「长连接」,添加 im.message.receive_v1;回调方式选「长连接」,添加 card.action.trigger。`,"Copy App ID and App Secret from Credentials & Basic Info, then publish a version.":`在「凭证与基础信息」复制 App ID 和 App Secret,然后发布版本。`,"In the DingTalk developer console create an internal app and add the Robot capability.":`在钉钉开放平台创建企业内部应用,添加「机器人」能力。`,"Set the message receive mode to Stream.":`消息接收模式选「Stream 模式」。`,"Copy Client ID (AppKey) and Client Secret (AppSecret) from Credentials & Basic Info.":`在「凭证与基础信息」复制 Client ID(AppKey)和 Client Secret(AppSecret)。`,"Add the robot send-message permission and publish the version.":`添加机器人发消息权限,发布版本。`,"In the WeCom admin console open Intelligent robot and create a robot in API mode with a long connection.":`在企业微信管理后台打开「智能机器人」,创建 API 模式、长连接的机器人。`,"Copy the Bot ID and Secret.":`复制 Bot ID 和 Secret。`,"Add the robot to the chats where it should listen.":`把机器人加到需要它在的聊天里。`,"In Telegram talk to @BotFather, send /newbot and copy the token.":`在 Telegram 里找 @BotFather,发送 /newbot,复制 token。`,"Direct messages work at once. For a group, add the bot to it; with Group Privacy on it only hears @-mentions and replies.":`私聊立即可用。群聊需要先把机器人拉进群;开着 Group Privacy 时它只听 @ 和回复。`,"If api.telegram.org is out of reach from this computer, set a proxy below.":`如果这台电脑连不上 api.telegram.org,在下面填一个代理。`,"Use nanoMuse Cloud models":`使用 nanoMuse Cloud 模型`,"nanoMuse Cloud is one of the sources for the chat, the hands, pictures and clips; what runs on it comes off your allowance.":`nanoMuse Cloud 是聊天、双手、图片和短片的来源之一;在它上面运行的内容会消耗你的额度。`,"Off: nothing runs on nanoMuse Cloud unless you choose it yourself. You stay signed in for sync and your devices.":`关闭后,除非你自己选择,否则不会有任何内容在 nanoMuse Cloud 上运行。账号保持登录,用于同步和你的设备。`,"Add a provider of your own first; with nanoMuse Cloud off, nothing else could answer.":`先添加一个自己的服务商;关掉 nanoMuse Cloud 后没有别的模型能回答。`,"Use your own API key instead":`改用自己的 API key`,"You can sign in later under Connections, for sync and your devices.":`之后可以在「连接」里登录,用于同步和你的设备。`},ne=[{value:`auto`,label:`Auto`},{value:`en`,label:`English`},{value:`zh-CN`,label:`简体中文`}],j=`nanomuse_locale`,M={en:{},"zh-CN":A};function re(){try{let e=localStorage.getItem(j);if(e===`en`||e===`zh-CN`||e===`auto`)return e}catch{}return`auto`}var N=re(),P=new Set;function ie(){return(typeof navigator>`u`?[]:[navigator.language,...navigator.languages??[]]).some(e=>/^zh\b/i.test(e??``))?`zh-CN`:`en`}function ae(){return N}function F(){return N===`auto`?ie():N}function oe(e){N=e;try{localStorage.setItem(j,e)}catch{}se(),P.forEach(e=>e())}function se(){typeof document<`u`&&(document.documentElement.lang=F())}function ce(e){return P.add(e),()=>P.delete(e)}function le(){return(0,f.useSyncExternalStore)(ce,F,F)}function ue(){return(0,f.useSyncExternalStore)(ce,ae,ae)}function de(e,t){return t?e.replace(/\{(\w+)\}/g,(e,n)=>n in t?String(t[n]):e):e}function I(e,t){let n=M[F()];return de(n[e]??e,t)}function fe(e){let t=t=>({title:e.slice(t.length)});return e.startsWith(`Working on your goal: `)?I(`Working on your goal: {title}`,t(`Working on your goal: `)):e.startsWith(`Check-in: `)?I(`Check-in: {title}`,t(`Check-in: `)):e.startsWith(`Reminder: `)?I(`Reminder: {title}`,t(`Reminder: `)):e.startsWith(`Routine: `)?I(`Routine: {title}`,t(`Routine: `)):e.startsWith(`New mail: `)?I(`New mail: {title}`,t(`New mail: `)):e.startsWith(`Coming up: `)?I(`Coming up: {title}`,t(`Coming up: `)):e.startsWith(`Webhook: `)?I(`Webhook: {title}`,t(`Webhook: `)):e===`Tidied memory`?I(`Tidied memory`):e}function pe(e){let t=/^'([^']+)' is in always_ask_tools$/.exec(e);return t?I(`{tool} is on your always-ask list`,{tool:t[1]}):(t=/^'([^']+)' is in always_allow_tools$/.exec(e),t?I(`{tool} is on your always-allow list`,{tool:t[1]}):(t=/^'([^']+)' is in sentinel\.deny_tools$/.exec(e),t?I(`{tool} is switched off in your settings`,{tool:t[1]}):(t=/^risk level is '(\w+)' \(mode=(\w+)\)$/.exec(e),t?I(`an action rated {risk}, in {mode} mode`,{risk:I(t[1]),mode:I(t[2])}):e===`auto mode: approval skipped`?I(`auto mode: not asked`):(t=/^covered by your '([^']+)' permission for (.+)$/.exec(e),t?I(`covered by your {scope} permission for {key}`,{scope:I(t[1]),key:t[2]}):(t=/^private data was read earlier in this conversation and '([^']+)' can send data to (.+), which is not on sentinel\.egress_allowlist$/.exec(e),t?I(`private data was read earlier in this conversation, and {tool} could send it to {target}, which is not on your allowed list`,{tool:t[1],target:t[2]}):I(e))))))}function me(e){let t=/^the file is larger than (\d+) MB$/.exec(e);return t?I(`The file is larger than {n} MB.`,{n:t[1]}):I(e)}function he(){return le(),I}function L(){return F()===`zh-CN`?`zh-CN`:`en-US`}function ge(e){if(!e)return``;let t=new Date(e);if(Number.isNaN(t.getTime()))return``;let n=new Date,r=t.toDateString()===n.toDateString(),i=t.toLocaleTimeString(L(),{hour:`2-digit`,minute:`2-digit`});return r?i:`${t.toLocaleDateString(L(),{month:`short`,day:`numeric`})} ${i}`}function _e(e){if(!e)return``;let t=new Date(e);if(Number.isNaN(t.getTime()))return``;let n=t.toLocaleTimeString(L(),{hour:`numeric`,minute:`2-digit`}),r=new Date,i=new Date(r);return i.setDate(r.getDate()-1),t.toDateString()===r.toDateString()?`${I(`Today`)} ${n}`:t.toDateString()===i.toDateString()?`${I(`Yesterday`)} ${n}`:`${ve(e)} ${n}`}function ve(e,t={}){if(!e)return``;let n=new Date(e);if(Number.isNaN(n.getTime()))return``;let r=n.getFullYear()===new Date().getFullYear();return n.toLocaleDateString(L(),{month:`short`,day:`numeric`,...r?{}:{year:`numeric`},...t})}function ye(e){if(!e)return``;let t=Date.now()-new Date(e).getTime();if(Number.isNaN(t))return``;let n=Math.round(Math.abs(t)/1e3);if(n<45)return I(t>=0?`just now`:`any moment`);let r=Math.round(n/60),i=Math.round(r/60),a=Math.round(i/24),o=r<60?I(`{n} min`,{n:r}):i<24?I(`{n} h`,{n:i}):I(`{n} d`,{n:a});return I(t>=0?`{span} ago`:`in {span}`,{span:o})}function be(e){let t=Math.max(0,Math.floor(Date.now()/1e3-e));return t<60?I(`just now`):t<3600?I(`{n} min ago`,{n:Math.floor(t/60)}):t<86400?I(`{n} h ago`,{n:Math.floor(t/3600)}):t<604800?I(`{n} d ago`,{n:Math.floor(t/86400)}):new Date(e*1e3).toLocaleDateString(L())}function R(e){try{return localStorage.getItem(e)}catch{return null}}function z(e,t){try{t===null?localStorage.removeItem(e):localStorage.setItem(e,t)}catch{}}function B(...e){return e.filter(Boolean).join(` `)}function xe(e){try{return decodeURIComponent(e)}catch{return e}}function Se(e){let t=e.toLowerCase().split(`.`).pop()??``;return t===`ics`?`event`:[`md`,`txt`,`log`,`rtf`].includes(t)?`text`:[`py`,`js`,`ts`,`tsx`,`jsx`,`sh`,`toml`,`yaml`,`yml`,`sql`,`rs`,`go`,`java`,`c`,`cpp`].includes(t)?`code`:[`html`,`htm`].includes(t)?`html`:[`png`,`jpg`,`jpeg`,`gif`,`webp`,`svg`].includes(t)?`image`:t===`pdf`?`pdf`:[`csv`,`json`,`xlsx`,`xls`,`parquet`].includes(t)?`data`:`other`}var V=`nanomuse_token`,H=class extends Error{constructor(){super(`unauthorized`)}};function Ce(e){let t=e.hash.replace(/^#/,``).split(`&`).filter(Boolean),n=t.findIndex(e=>e.startsWith(`token=`));if(n>=0){let r=decodeURIComponent(t[n].slice(6));return t.splice(n,1),e.hash=t.length?`#${t.join(`&`)}`:``,r||null}let r=e.searchParams.get(`token`);return r?(e.searchParams.delete(`token`),r):null}function U(){let e=new URL(window.location.href),t=Ce(e);return t&&(z(V,t),window.history.replaceState(null,``,e.pathname+e.search+e.hash)),R(V)??``}function we(e){z(V,e||null)}function W(e,t=!1){let n=U(),r=n?T(n,`/api/files/${e}`):new URLSearchParams;t&&r.set(`download`,`1`);let i=r.toString();return`/api/files/${e.split(`/`).map(encodeURIComponent).join(`/`)}${i?`?${i}`:``}`}function Te(e,t){let n=U(),r=n?`?${T(n,`/api/browser/${e}/frames/${t}.jpg`)}`:``;return`/api/browser/${encodeURIComponent(e)}/frames/${encodeURIComponent(t)}.jpg${r}`}async function G(e,t={}){let n={...t.headers},r=U();r&&(n.Authorization=`Bearer ${r}`),t.body&&!n[`Content-Type`]&&(n[`Content-Type`]=`application/json`);let i;try{i=await fetch(e,{...t,headers:n})}catch{throw Error(`Cannot reach your nanoMuse right now.`)}if(i.status===401)throw new H;if(!i.ok){let e;try{e=await i.json()}catch{}throw Error(Ee(i.status,e))}return await i.json()}function Ee(e,t){let n=t&&typeof t==`object`?t.detail:void 0;return typeof n==`string`&&n.trim()?n:e>=500?`Your nanoMuse hit a problem; try again in a moment.`:`Your nanoMuse could not do that.`}var K=e=>({method:`POST`,body:JSON.stringify(e)}),q={state:()=>G(`/api/state`),cloud:()=>G(`/api/cloud`),cloudCode:e=>G(`/api/cloud/code`,K({identifier:e})),cloudVerify:(e,t,n=``)=>G(`/api/cloud/verify`,K(n?{identifier:e,code:t,invite:n}:{identifier:e,code:t})),cloudSignOut:()=>G(`/api/cloud/sign-out`,K({})),cloudModels:e=>G(`/api/cloud/models`,K({on:e})),cloudLogin:(e,t)=>G(`/api/cloud/login`,K({identifier:e,password:t})),cloudPassword:(e,t)=>G(`/api/cloud/password`,K({password:e,current:t??null})),cloudSessions:()=>G(`/api/cloud/sessions`),cloudRevokeSession:e=>G(`/api/cloud/sessions/${encodeURIComponent(e)}`,{method:`DELETE`}),cloudSignOutAll:(e=!1)=>G(`/api/cloud/sign-out-all`,K({all:e})),cloudEvents:(e=50)=>G(`/api/cloud/events?limit=${e}`),cloudDelete:()=>G(`/api/cloud/delete`,K({})),cloudMe:()=>G(`/api/cloud/me`),cloudConfig:()=>G(`/api/cloud/config`),cloudContribute:e=>G(`/api/cloud/contribute`,K({on:e})),cloudDeleteSamples:()=>G(`/api/cloud/samples`,{method:`DELETE`}),cloudUseAsModel:(e=``)=>G(`/api/cloud/use-as-model`,K({model:e})),syncState:()=>G(`/api/sync/state`),syncSetState:e=>G(`/api/sync/state`,{method:`PUT`,body:JSON.stringify({enabled:e})}),syncSetSideChats:e=>G(`/api/sync/state`,{method:`PUT`,body:JSON.stringify({side_chats:e})}),syncDelete:()=>G(`/api/sync/delete`,K({})),syncPull:()=>G(`/api/sync/pull`,K({})),avatarView:()=>G(`/api/avatar`),avatarBegin:(e,t=`main`,n=`muse`)=>G(`/api/avatar/begin`,K({description:e,thread:t,style:n})),avatarStart:e=>G(`/api/avatar/start`,K({session:e})),avatarChoose:(e,t)=>G(`/api/avatar/choose`,K({session:e,index:t})),avatarCancel:e=>G(`/api/avatar/cancel`,K({session:e})),avatarMoods:()=>G(`/api/avatar/moods`,K({})),coding:(e=``)=>G(`/api/coding${e?`?device=${encodeURIComponent(e)}`:``}`),codingSessions:(e={})=>{let t=new URLSearchParams;e.agent&&t.set(`agent`,e.agent),e.limit&&t.set(`limit`,String(e.limit)),e.workspace&&t.set(`workspace`,e.workspace),e.device&&t.set(`device`,e.device);let n=t.toString();return G(`/api/coding/sessions${n?`?${n}`:``}`)},codingSession:(e,t,n=``)=>G(`/api/coding/sessions/${encodeURIComponent(e)}/${encodeURIComponent(t)}${n?`?device=${encodeURIComponent(n)}`:``}`),codingSend:e=>G(`/api/coding/send`,K(e)),codingStop:(e,t=``)=>G(`/api/coding/stop`,K({run:e,device:t})),hub:()=>G(`/api/hub`),updateHub:e=>G(`/api/hub`,{method:`PUT`,body:JSON.stringify(e)}),joinHub:()=>G(`/api/hub/join`,K({})),leaveHub:()=>G(`/api/hub/leave`,K({})),refreshHub:()=>G(`/api/hub/refresh`,K({})),forgetDevice:e=>G(`/api/hub/devices/${encodeURIComponent(e)}`,{method:`DELETE`}),askDevice:(e,t=``)=>G(`/api/hub/ask`,K({device:e,text:t})),hands:()=>G(`/api/hands`),setHands:e=>G(`/api/connections/hands`,{method:`PUT`,body:JSON.stringify(e)}),stopHands:()=>G(`/api/hands/stop`,K({})),holds:()=>G(`/api/holds`),openHold:(e,t,n=``)=>G(`/api/holds`,K({thread:e,tool:t,reason:n})),doneHold:e=>G(`/api/holds/${encodeURIComponent(e)}/done`,K({})),health:()=>G(`/api/health`),threads:()=>G(`/api/threads`),createThread:e=>G(`/api/threads`,K({title:e})),renameThread:(e,t)=>G(`/api/threads/${e}`,{method:`PATCH`,body:JSON.stringify({title:t})}),deleteThread:e=>G(`/api/threads/${e}`,{method:`DELETE`}),clearThread:e=>G(`/api/threads/${e}/clear`,{method:`POST`}),stopThread:e=>G(`/api/threads/${e}/stop`,{method:`POST`}),events:(e,t=200,n)=>G(`/api/threads/${e}/events?limit=${t}${n?`&before=${encodeURIComponent(n)}`:``}`),send:(e,t,n=[],r=``,i=``)=>G(`/api/threads/${e}/send`,K(i?{text:t,files:n,language:r,via:i}:{text:t,files:n,language:r})),upload:e=>G(`/api/files/upload?name=${encodeURIComponent(e.name||`photo.jpg`)}`,{method:`POST`,headers:{"Content-Type":e.type||`application/octet-stream`},body:e}),decide:(e,t,n=`once`,r=``)=>G(`/api/approvals/${e}`,K({approved:t,scope:n,reason:r})),resetApprovals:()=>G(`/api/approvals`,{method:`DELETE`}),revokeGrant:e=>G(`/api/approvals/grants/${encodeURIComponent(e)}`,{method:`DELETE`}),goals:()=>G(`/api/goals`),createGoal:e=>G(`/api/goals`,K(e)),patchGoal:(e,t)=>G(`/api/goals/${e}`,{method:`PATCH`,body:JSON.stringify(t)}),addStep:(e,t)=>G(`/api/goals/${e}/steps`,K({title:t})),advanceGoal:e=>G(`/api/goals/${e}/advance`,{method:`POST`}),checkInGoal:e=>G(`/api/goals/${e}/check-in`,{method:`POST`}),acceptProposal:e=>G(`/api/goals/${e}/proposal/accept`,{method:`POST`}),dismissProposal:e=>G(`/api/goals/${e}/proposal`,{method:`DELETE`}),deleteGoal:e=>G(`/api/goals/${e}`,{method:`DELETE`}),memory:()=>G(`/api/memory`),addMemory:(e,t)=>G(`/api/memory`,K({content:e,category:t})),forgetMemory:e=>G(`/api/memory/${e}`,{method:`DELETE`}),tidyMemory:()=>G(`/api/memory/tidy`,{method:`POST`}),memoryChanges:(e=30)=>G(`/api/memory/changes?limit=${e}`),restoreMemoryChange:e=>G(`/api/memory/changes/${e}/restore`,{method:`POST`}),ideas:(e=!1)=>G(`/api/ideas${e?`?refresh=1`:``}`),activity:(e=150)=>G(`/api/activity?n=${e}`),feed:(e=60)=>G(`/api/feed?limit=${e}`),feedPosts:()=>G(`/api/feed/posts`),setFeedInstructions:e=>G(`/api/feed/instructions`,{method:`PUT`,body:JSON.stringify(e)}),refreshFeedPosts:()=>G(`/api/feed/posts/refresh`,{method:`POST`}),deleteFeedPost:e=>G(`/api/feed/posts/${e}`,{method:`DELETE`}),upcoming:()=>G(`/api/upcoming`),reminders:(e=!1)=>G(`/api/reminders${e?`?all=1`:``}`),createReminder:e=>G(`/api/reminders`,{method:`POST`,body:JSON.stringify(e)}),fireReminder:e=>G(`/api/reminders/${e}/fire`,{method:`POST`}),cancelReminder:e=>G(`/api/reminders/${e}`,{method:`DELETE`}),triggers:()=>G(`/api/triggers`),createTrigger:e=>G(`/api/triggers`,{method:`POST`,body:JSON.stringify(e)}),fireTrigger:e=>G(`/api/triggers/${e}/fire`,{method:`POST`}),cancelTrigger:e=>G(`/api/triggers/${e}`,{method:`DELETE`}),files:(e=300)=>G(`/api/files?limit=${e}`),fileText:async e=>{let t={},n=U();n&&(t.Authorization=`Bearer ${n}`);let r=await fetch(`/api/files/${e.split(`/`).map(encodeURIComponent).join(`/`)}`,{headers:t});if(r.status===401)throw new H;if(!r.ok)throw Error(Ee(r.status,await r.json().catch(()=>void 0)));return r.text()},settings:()=>G(`/api/settings`),update:(e=!1)=>G(`/api/update${e?`?refresh=1`:``}`),nudges:()=>G(`/api/nudges`),updateSettings:e=>G(`/api/settings`,{method:`PUT`,body:JSON.stringify(e)}),connections:()=>G(`/api/connections`),providers:e=>G(`/api/providers${e?`?region=${encodeURIComponent(e)}`:``}`),chatgptLogin:()=>G(`/api/chatgpt/login`,K({})),chatgptStatus:()=>G(`/api/chatgpt/status`),chatgptCallback:e=>G(`/api/chatgpt/callback`,K({url:e})),chatgptLogout:()=>G(`/api/chatgpt/logout`,K({})),setLLM:e=>G(`/api/connections/llm`,{method:`PUT`,body:JSON.stringify(e)}),testLLM:()=>G(`/api/connections/llm/test`,{method:`POST`}),llmModels:e=>G(`/api/llm/models`,K(e)),setEmbeddings:e=>G(`/api/connections/embeddings`,{method:`PUT`,body:JSON.stringify(e)}),testEmbeddings:()=>G(`/api/connections/embeddings/test`,{method:`POST`}),setSearch:e=>G(`/api/connections/search`,{method:`PUT`,body:JSON.stringify(e)}),testSearch:()=>G(`/api/connections/search/test`,{method:`POST`}),setEmail:e=>G(`/api/connections/email`,{method:`PUT`,body:JSON.stringify(e)}),disconnectEmail:()=>G(`/api/connections/email`,{method:`DELETE`}),testEmail:()=>G(`/api/connections/email/test`,{method:`POST`}),calendar:()=>G(`/api/calendar`),setCalendar:e=>G(`/api/connections/calendar`,{method:`PUT`,body:JSON.stringify(e)}),addCalendarFeed:(e,t)=>G(`/api/connections/calendar/feeds`,K({name:e,url:t})),removeCalendarFeed:e=>G(`/api/connections/calendar/feeds/${encodeURIComponent(e)}`,{method:`DELETE`}),testCalendar:()=>G(`/api/connections/calendar/test`,{method:`POST`}),googleCalendar:()=>G(`/api/connections/calendar/google`),setGoogleCalendar:e=>G(`/api/connections/calendar/google`,{method:`PUT`,body:JSON.stringify(e)}),connectGoogleCalendar:e=>G(`/api/connections/calendar/google/connect`,K({write:e})),setGoogleCalendars:e=>G(`/api/connections/calendar/google/calendars`,K(e)),disconnectGoogleCalendar:()=>G(`/api/connections/calendar/google/disconnect`,{method:`POST`}),contacts:(e=``,t=8)=>G(`/api/contacts?q=${encodeURIComponent(e)}&limit=${t}`),setContacts:e=>G(`/api/connections/contacts`,{method:`PUT`,body:JSON.stringify(e)}),addContactsSource:(e,t)=>G(`/api/connections/contacts/sources`,K({name:e,url:t})),importContacts:(e,t)=>G(`/api/connections/contacts/import?name=${encodeURIComponent(e)}`,{method:`POST`,headers:{"Content-Type":`text/vcard; charset=utf-8`},body:t}),removeContactsSource:e=>G(`/api/connections/contacts/sources/${encodeURIComponent(e)}`,{method:`DELETE`}),testContacts:()=>G(`/api/connections/contacts/test`,{method:`POST`}),skills:()=>G(`/api/skills`),skill:e=>G(`/api/skills/${encodeURIComponent(e)}`),saveSkill:(e,t)=>G(`/api/skills/${encodeURIComponent(e)}`,{method:`PUT`,body:JSON.stringify({content:t})}),deleteSkill:e=>G(`/api/skills/${encodeURIComponent(e)}`,{method:`DELETE`}),setSkillEnabled:(e,t)=>G(`/api/skills/${encodeURIComponent(e)}/enabled`,K({enabled:t})),importSkill:e=>G(`/api/skills/import`,K({url:e})),setGui:e=>G(`/api/connections/gui`,{method:`PUT`,body:JSON.stringify(e)}),testGui:()=>G(`/api/connections/gui/test`,{method:`POST`}),media:e=>G(`/api/connections/${e}`),setMedia:(e,t)=>G(`/api/connections/${e}`,{method:`PUT`,body:JSON.stringify(t)}),setBrowser:e=>G(`/api/connections/browser`,{method:`PUT`,body:JSON.stringify({enabled:e})}),addMCP:e=>G(`/api/connections/mcp`,K(e)),removeMCP:e=>G(`/api/connections/mcp/${encodeURIComponent(e)}`,{method:`DELETE`}),vaultSet:(e,t)=>G(`/api/vault/${encodeURIComponent(e)}`,{method:`PUT`,body:JSON.stringify({value:t})}),vaultDelete:e=>G(`/api/vault/${encodeURIComponent(e)}`,{method:`DELETE`}),onboarded:(e=!0)=>G(`/api/onboarded`,K({done:e})),firstrun:e=>G(`/api/firstrun?lang=${encodeURIComponent(e)}`),firstrunStart:e=>G(`/api/firstrun/start`,K({lang:e})),firstrunPick:e=>G(`/api/firstrun/pick`,K({name:e})),firstrunDismiss:()=>G(`/api/firstrun/dismiss`,K({})),browserControl:(e,t)=>G(`/api/browser/${encodeURIComponent(e)}/control`,K(t)),push:()=>G(`/api/push`),pushSubscribe:e=>G(`/api/push/subscribe`,K({subscription:e})),pushUnsubscribe:e=>G(`/api/push/unsubscribe`,K({endpoint:e})),pushTest:()=>G(`/api/push/test`,{method:`POST`})};function De(e){return e===4401?`auth`:e===4404?`gone`:`retry`}function Oe(e){let t=null,n=!1,r=0,i,a=()=>{let o=window.location.protocol===`https:`?`wss`:`ws`;t=new WebSocket(`${o}://${window.location.host}/ws`),t.onopen=()=>{let n=U();n&&t?.send(JSON.stringify({kind:`auth`,token:n})),r=0,e.onOpen?.()},t.onmessage=t=>{try{e.onMessage(JSON.parse(t.data))}catch{}},t.onclose=t=>{e.onClose?.();let o=De(t.code);if(o===`auth`){e.onAuthError?.();return}if(o===`gone`){n=!0,e.onGone?.();return}if(n)return;let s=Math.min(15e3,500*2**r++);i=window.setTimeout(a,s)},t.onerror=()=>t?.close()};return a(),{send:e=>t&&t.readyState===WebSocket.OPEN?(t.send(JSON.stringify(e)),!0):!1,close:()=>{n=!0,i&&window.clearTimeout(i),t?.close()}}}var ke=o((e=>{var t=d(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),Ae=o(((e,t)=>{t.exports=ke()})),je=6e5;function Me(e,t=Date.now()){let n={};for(let r of e??[])t-r.at*1e3<6e5&&(n[r.thread]=r);return n}function Ne(){return`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window}async function Pe(){if(!(`serviceWorker`in navigator)||!window.isSecureContext)return null;try{return await navigator.serviceWorker.register(`/sw.js`)}catch{return null}}async function Fe(){if(!Ne())return`unsupported`;if(!window.isSecureContext)return`insecure`;if(Notification.permission===`denied`)return`denied`;let e=await navigator.serviceWorker.getRegistration();return e&&await e.pushManager.getSubscription()?`on`:`off`}function Ie(e){let t=`=`.repeat((4-e.length%4)%4),n=atob((e+t).replace(/-/g,`+`).replace(/_/g,`/`)),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e0?t.setAppBadge?.(e):t.clearAppBadge?.()}catch{}}var Be=`nanomuse_theme`,Ve={light:`#fcfcfc`,dark:`#181819`};function He(){try{let e=localStorage.getItem(Be);if(e===`light`||e===`dark`||e===`system`)return e}catch{}return`light`}var J=He(),Y=new Set,Ue=typeof matchMedia==`function`?matchMedia(`(prefers-color-scheme: dark)`):null;function We(){return Ue?.matches?`dark`:`light`}function Ge(){return J}function X(){return J===`system`?We():J}function Ke(e){J=e;try{localStorage.setItem(Be,e)}catch{}Z(),Y.forEach(e=>e())}function Z(){if(typeof document>`u`)return;let e=X();document.documentElement.dataset.theme=e;let t=document.querySelector(`meta[name="theme-color"]`);t&&t.setAttribute(`content`,Ve[e])}Ue?.addEventListener?.(`change`,()=>{J===`system`&&(Z(),Y.forEach(e=>e()))});function qe(e){return Y.add(e),()=>Y.delete(e)}function Je(){return(0,f.useSyncExternalStore)(qe,Ge,Ge)}function Ye(){return(0,f.useSyncExternalStore)(qe,X,X)}var Q=Ae();function Xe(){if(window.parent===window)return null;let e=window.location.ancestorOrigins;if(e&&e.length>0&&e[0]!==`null`)return e[0];if(document.referrer)try{return new URL(document.referrer).origin}catch{return null}return null}function Ze(e){let t=Xe();t&&window.parent.postMessage(e,t)}var Qe=[`chat`,`feed`,`ideas`,`goals`,`library`,`memory`,`devices`,`connections`,`channels`,`skills`,`you`,`account`,`coding`,`avatar`],$e=`nanomuse_feed_seen`,et=`nanomuse_sign_in_skipped`,tt=`nanomuse.ui.lite`;function nt(){try{let e=new URL(window.location.href);return e.searchParams.get(`ui`)===`lite`?(sessionStorage.setItem(tt,`1`),!0):e.searchParams.get(`ui`)===`full`?(sessionStorage.removeItem(tt),!1):sessionStorage.getItem(tt)===`1`}catch{return!1}}var rt={connected:!1,loaded:!1,authError:!1,gone:!1,error:null,version:``,profile:null,status:{state:`idle`,detail:``,thread:`main`},threads:[],activeThread:`main`,events:{},hasMore:{},streams:{},goals:[],settings:null,goalsVersion:0,memoryVersion:0,remindersVersion:0,calendarVersion:0,pendingApprovals:[],feedVersion:0,feedSeenAt:R($e)??``,viewer:null,draft:null,draftFiles:null,connectionsVersion:0,skillsVersion:0,onboardingDismissed:!1,signInSkipped:R(et)===`1`,tab:`chat`,toast:null,drawer:!1,finishedAt:0,mishapAt:0,hub:null,codingLive:{},lite:nt(),hands:null,holds:[],handsLive:null,studio:null,working:{},firstrun:null};function it(e,t){if(t.type!==`approval`)return e;let n=e.filter(e=>e.id!==t.id);return t.status===`pending`?[...n,t]:n}function at(e){return e.type===`approval`||e.type===`question`||e.source===`background`||e.source===`goal`}function ot(e){let t=e.ts;return typeof t==`number`?{...e,ts:new Date(t*1e3).toISOString()}:e}function st(e,t){let n=e??[],r=n.findIndex(e=>e.id===t.id);if(r>=0){let e=n.slice();return e[r]=t,e}if(t.synced&&t.ts&&n.length>0&&(n[n.length-1]?.ts??``)>t.ts){let e=n.length;for(;e>0&&(n[e-1]?.ts??``)>t.ts;)--e;return[...n.slice(0,e),t,...n.slice(e)]}return[...n,t]}function ct(e,t){let n=e.filter(e=>e.id!==t.id);return t.status===`on`?[...n,t]:n}function lt(e,t){let n=e.findIndex(e=>e.id===t.id);if(n>=0){let r=e.slice();return r[n]=t,r}return[...e,t]}function ut(e,t){switch(t.type){case`hello`:{let n=t.state;for(let e of Object.keys($))delete $[e];return{...e,loaded:!0,authError:!1,version:n.version,profile:n.profile,status:n.status,streams:{},threads:n.threads,goals:n.goals,settings:n.settings,pendingApprovals:n.pending_approvals,feedVersion:e.feedVersion+1,activeThread:n.threads.some(t=>t.id===e.activeThread)?e.activeThread:`main`,hub:n.hub??e.hub,hands:n.hands??e.hands,holds:(n.holds??[]).map(ot),working:Me(n.working),firstrun:n.firstrun??e.firstrun}}case`connection`:return{...e,connected:t.connected};case`authError`:return{...e,authError:!0,connected:!1};case`gone`:return{...e,gone:!0,connected:!1};case`error`:return{...e,error:t.error};case`events`:{let n=e.events[t.thread]??[],r=t.events.map(ot),i=t.prepend?[...r,...n.filter(e=>!r.some(t=>t.id===e.id))]:r;return{...e,events:{...e.events,[t.thread]:i},hasMore:{...e.hasMore,[t.thread]:t.hasMore}}}case`eventsFailed`:return{...e,events:{...e.events,[t.thread]:e.events[t.thread]??[]},toast:I(`Could not load the conversation. Pull to retry.`)};case`activeThread`:return{...e,activeThread:t.thread,tab:`chat`};case`goals`:return{...e,goals:t.goals};case`settings`:return{...e,settings:t.settings,profile:t.settings.profile};case`tab`:return{...e,tab:t.tab};case`drawer`:return{...e,drawer:t.open};case`feedSeen`:return z($e,t.at),{...e,feedSeenAt:t.at};case`viewer`:return{...e,viewer:t.path};case`draft`:return{...e,draft:t.text};case`draftFiles`:return{...e,draftFiles:t.files};case`onboardingDismissed`:return{...e,onboardingDismissed:!0};case`signInSkipped`:return{...e,signInSkipped:!0};case`toast`:return{...e,toast:t.toast};case`ws`:return dt(e,t.msg);default:return e}}function dt(e,t){switch(t.kind){case`hello`:return ut(e,{type:`hello`,state:t.state});case`event`:case`update`:{let n=ot(t.event),r={...e.streams},i=r[n.thread];n.type===`assistant`&&i&&i.id===n.id&&(r[n.thread]=void 0);let a=e.threads.map(e=>e.id===n.thread&&t.kind===`event`?{...e,updated_at:n.ts}:e),o=e.events[n.thread]!==void 0,s=n.type===`tool`&&(n.status===`error`||n.status===`blocked`)||n.type===`notice`&&n.level===`error`,c=e.working,l=c[n.thread];return l&&n.type===`assistant`&&n.synced&&n.via_device===l.device&&(c={...c,[n.thread]:void 0}),{...e,streams:r,threads:a,working:c,events:o?{...e.events,[n.thread]:st(e.events[n.thread],n)}:e.events,holds:n.type===`hold`?ct(e.holds,n):e.holds,pendingApprovals:it(e.pendingApprovals,n),feedVersion:at(n)?e.feedVersion+1:e.feedVersion,mishapAt:s?Date.now():e.mishapAt}}case`stream_start`:return{...e,streams:{...e.streams,[t.thread]:{id:t.id,text:``,ended:!1}}};case`delta`:{let n=e.streams[t.thread],r=n&&n.id===t.id?{...n,text:n.text+t.text}:{id:t.id,text:t.text,ended:!1};return{...e,streams:{...e.streams,[t.thread]:r}}}case`stream_end`:{let n=e.streams[t.thread];return!n||n.id!==t.id?e:t.discard||!n.text.trim()?{...e,streams:{...e.streams,[t.thread]:void 0}}:{...e,streams:{...e.streams,[t.thread]:{...n,ended:!0}}}}case`status`:{let n=t.status,r={...e.streams};r[n.thread]?.ended&&n.state!==`working`&&(r[n.thread]=void 0);let i=ft(e.status,n),a=e.status.state===`working`&&i.state===`idle`;return{...e,status:i,streams:r,finishedAt:a?Date.now():e.finishedAt}}case`thread`:return{...e,threads:lt(e.threads,t.thread)};case`thread_deleted`:{let n={...e.events};return delete n[t.thread],{...e,events:n,holds:e.holds.filter(e=>e.thread!==t.thread),threads:e.threads.filter(e=>e.id!==t.thread),pendingApprovals:e.pendingApprovals.filter(e=>e.thread!==t.thread),activeThread:e.activeThread===t.thread?`main`:e.activeThread}}case`thread_cleared`:return{...e,events:{...e.events,[t.thread]:[]},holds:e.holds.filter(e=>e.thread!==t.thread),pendingApprovals:e.pendingApprovals.filter(e=>e.thread!==t.thread)};case`event_removed`:{let n=e.events[t.thread];return n?{...e,events:{...e.events,[t.thread]:n.filter(e=>e.id!==t.id)}}:e}case`firstrun`:return{...e,firstrun:t.firstrun};case`working`:{let{kind:n,working:r,...i}=t,a=e.working[i.thread];return r?{...e,working:{...e.working,[i.thread]:i}}:!a||a.device!==i.device?e:{...e,working:{...e.working,[i.thread]:void 0}}}case`goals`:return{...e,goalsVersion:e.goalsVersion+1};case`memory`:return{...e,memoryVersion:e.memoryVersion+1};case`reminders`:case`triggers`:return{...e,remindersVersion:e.remindersVersion+1};case`calendar`:return{...e,calendarVersion:e.calendarVersion+1};case`feed_posts`:return{...e,feedVersion:e.feedVersion+1};case`profile`:return{...e,profile:t.profile,settings:e.settings?{...e.settings,profile:t.profile}:e.settings};case`settings`:return{...e,settings:t.settings,profile:t.settings.profile};case`connections`:return{...e,connectionsVersion:e.connectionsVersion+1};case`hub`:return{...e,hub:t.hub};case`coding`:{let n=t.run?.id??t.event.run;if(!n)return e;let r=e.codingLive[n],i=t.run??r?.run;if(!i)return e;let a=t.event.kind===`run`?r?.events??[]:[...r?.events??[],t.event].slice(-400),o=r?.current??``;t.event.kind===`text`&&(o=t.event.partial?o+(t.event.text??``):``),(t.event.kind===`done`||t.event.kind===`error`)&&(o=``);let s={...e.codingLive,[n]:{run:{...i,device:t.device??i.device},events:a,current:o}},c=Object.keys(s);if(c.length>20)for(let e of c.sort((e,t)=>s[e].run.started_at-s[t].run.started_at).slice(0,c.length-20))delete s[e];return{...e,codingLive:s}}case`hands_state`:return{...e,hands:t.hands};case`studio`:return{...e,studio:t.current};case`hands`:{let{kind:n,...r}=t,i=r.event===`end`||r.event===`stop`;return{...e,handsLive:i?null:r}}case`skills`:return{...e,skillsVersion:e.skillsVersion+1};case`error`:return{...e,toast:I(t.error)};case`pong`:return{...e,status:t.status};default:return e}}var $={};function ft(e,t){$[t.thread]=t;let n=Object.values($).filter(e=>e.state!==`idle`);return n.length?(n.sort((e,t)=>Number(e.thread!==`main`)-Number(t.thread!==`main`)),n[0]??e):{state:`idle`,detail:``,thread:`main`}}var pt=(0,f.createContext)(null);function mt({children:e}){let[t,n]=(0,f.useReducer)(ut,rt),r=(0,f.useRef)(null);(0,f.useEffect)(()=>{U();let e=[],t=0,i=()=>{t=0;let r=e.splice(0);for(let e of r)n({type:`ws`,msg:e})},a=Oe({onMessage:n=>{e.push(n),t||=window.setTimeout(i,0)},onOpen:()=>n({type:`connection`,connected:!0}),onClose:()=>n({type:`connection`,connected:!1}),onAuthError:()=>n({type:`authError`}),onGone:()=>n({type:`gone`})});return r.current=a,q.state().then(e=>n({type:`hello`,state:e})).catch(e=>{e instanceof H?n({type:`authError`}):n({type:`error`,error:String(e.message??e)})}),()=>{t&&window.clearTimeout(t),a.close()}},[]);let i=(0,f.useRef)({}),a=(0,f.useCallback)(async(e,t)=>{let r=t?void 0:i.current[e]=(i.current[e]??0)+1;try{let a=await q.events(e,150,t);if(r!==void 0&&i.current[e]!==r)return;n({type:`events`,thread:e,events:a.events,hasMore:a.has_more,prepend:!!t})}catch(t){t instanceof H?n({type:`authError`}):n({type:`eventsFailed`,thread:e})}},[]),o=(0,f.useCallback)(async()=>{try{n({type:`ws`,msg:{kind:`hub`,hub:await q.hub()}})}catch{}},[]);(0,f.useEffect)(()=>{t.loaded&&a(t.activeThread)},[t.loaded,t.activeThread,a]);let s=(0,f.useRef)(!1);(0,f.useEffect)(()=>{t.connected&&s.current&&t.loaded&&a(t.activeThread),s.current=t.connected},[t.connected,t.loaded,t.activeThread,a]);let c=(0,f.useCallback)(async()=>{try{n({type:`goals`,goals:await q.goals()})}catch{}},[]);(0,f.useEffect)(()=>{t.loaded&&c()},[t.goalsVersion,t.loaded,c]);let l=(0,f.useCallback)(async()=>{try{n({type:`settings`,settings:await q.settings()})}catch{}},[]);(0,f.useEffect)(()=>{if(!t.toast)return;let e=window.setTimeout(()=>n({type:`toast`,toast:null}),3500);return()=>window.clearTimeout(e)},[t.toast]),(0,f.useEffect)(()=>{Pe();let e=e=>{let t=new URL(e,window.location.origin),r=t.searchParams.get(`thread`),i=t.searchParams.get(`tab`);r&&n({type:`activeThread`,thread:r}),i&&Qe.includes(i)&&!r?n({type:`tab`,tab:i}):n({type:`tab`,tab:`chat`});let a=t.searchParams.get(`draft`);a&&n({type:`draft`,text:a});let o=t.searchParams.get(`attach`);if(o)try{let e=JSON.parse(o);Array.isArray(e)&&e.length&&n({type:`draftFiles`,files:e.filter(e=>e&&typeof e.path==`string`)})}catch{}},t=new URL(window.location.href);if([`thread`,`tab`,`draft`,`attach`,`ui`].some(e=>t.searchParams.has(e))){e(t.href);for(let e of[`thread`,`tab`,`draft`,`attach`,`ui`])t.searchParams.delete(e);window.history.replaceState({},``,t.toString())}let r=t=>{t.data&&t.data.type===`open`&&e(String(t.data.url||`/`))};navigator.serviceWorker?.addEventListener(`message`,r);let i=e=>{if(window.parent===window||e.source!==window.parent)return;let t=e.data;t&&t.type===`nanomuse:draft`&&typeof t.text==`string`&&(n({type:`tab`,tab:`chat`}),n({type:`draft`,text:t.text.slice(0,4e3)}))};return window.addEventListener(`message`,i),()=>{navigator.serviceWorker?.removeEventListener(`message`,r),window.removeEventListener(`message`,i)}},[]);let u=Ye();(0,f.useEffect)(()=>{t.loaded&&Ze({type:`nanomuse:ready`,name:t.profile?.name??``,theme:u})},[t.loaded,t.profile?.name]),(0,f.useEffect)(()=>{t.loaded&&Ze({type:`nanomuse:theme`,theme:u})},[t.loaded,u]),(0,f.useEffect)(()=>{t.loaded&&ze(t.pendingApprovals.length)},[t.loaded,t.pendingApprovals.length]);let d=(0,f.useMemo)(()=>({state:t,dispatch:n,send:async(e,t,i=[])=>{if(!t.trim()&&i.length===0)return;let a=F();try{(i.length>0||!r.current?.send({kind:`send`,thread:e,text:t,language:a}))&&await q.send(e,t,i,a)}catch(e){throw e instanceof H&&n({type:`authError`}),e}},sendViaCloud:async(e,t,r=[])=>{if(t.trim()||r.length!==0)try{await q.send(e,t,r,F(),`cloud`)}catch(e){throw e instanceof H&&n({type:`authError`}),e}},decide:async(e,t,i=`once`)=>{try{r.current?.send({kind:`approval`,id:e,approved:t,scope:i})||await q.decide(e,t,i)}catch(e){throw e instanceof H&&n({type:`authError`}),e}},loadEvents:a,refreshGoals:c,refreshSettings:l,refreshHub:o,setTab:e=>n({type:`tab`,tab:e}),setDrawer:e=>n({type:`drawer`,open:e}),openThread:e=>n({type:`activeThread`,thread:e}),markFeedSeen:e=>n({type:`feedSeen`,at:e}),openFile:e=>n({type:`viewer`,path:e}),draftFiles:e=>n({type:`draftFiles`,files:e}),draft:e=>{n({type:`draft`,text:e}),e!==null&&n({type:`tab`,tab:`chat`})},dismissOnboarding:()=>n({type:`onboardingDismissed`}),skipSignIn:()=>{z(et,`1`),n({type:`signInSkipped`})},toast:e=>n({type:`toast`,toast:I(e)})}),[t,a,c,l,o]);return(0,Q.jsx)(pt.Provider,{value:d,children:e})}function ht(){let e=(0,f.useContext)(pt);if(!e)throw Error(`useStore outside provider`);return e}var gt=`dragon`,_t=new Set([`panda`,`sunny`,`moss`,`sky`,`fox`,`bolt`,`plum`]);function vt(e){return!e||e===`dragon`||_t.has(e)}function yt(e,t){return W(`avatar/${e}/${t===`working`||t===`waiting`||t===`happy`||t===`error`?t:`idle`}.webp`)}function bt(e){return`/avatars/dragon-${e===`working`||e===`waiting`||e===`happy`||e===`error`?e:`idle`}.webp`}function xt(e){return e===`working`||e===`waiting`||e===`happy`?e:`idle`}function St(e){return e===`error`?null:`/avatars/dragon-${xt(e)}.mp4`}function Ct(e,t){return t===`error`?null:W(`avatar/${e}/${xt(t)}.mp4`)}var wt=3e3,Tt=4e3;function Et(e){let{state:t}=ht(),n=Dt(e?[t.mishapAt+Tt,t.finishedAt+wt]:[]);return e?t.loaded&&!t.connected?`sleepy`:e.state===`waiting`?`waiting`:e.state===`working`?`working`:t.mishapAt&&n-t.mishapAt<4e3?`error`:t.finishedAt&&n-t.finishedAt<3e3?`happy`:`idle`:`idle`}function Dt(e){let[t,n]=(0,f.useState)(()=>Date.now()),r=e.join(`,`);(0,f.useEffect)(()=>n(Date.now()),[r]);let i=Math.min(...e.filter(e=>e>t),1/0);return(0,f.useEffect)(()=>{if(!Number.isFinite(i))return;let e=window.setTimeout(()=>n(Date.now()),Math.max(0,i-Date.now())+20);return()=>window.clearTimeout(e)},[i]),t}var Ot=44,kt=new Set,At=typeof window<`u`&&typeof window.matchMedia==`function`&&window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;function jt({profile:e,status:t,size:n=40,onClick:r,still:i=!1,className:a}){let[o,s]=(0,f.useState)(!1),[,c]=(0,f.useState)(0);(0,f.useEffect)(()=>{if(!o)return;let e=window.setTimeout(()=>s(!1),900);return()=>window.clearTimeout(e)},[o]);let l=Et(t),u=t?.state??`idle`,d=e?.color??`#0064d4`,p=!e||vt(e.avatar),m=!p&&e?.avatar?e.avatar:null,h=o?`happy`:i?`idle`:l,g=i||At||n{kt.add(_),c(e=>e+1)},className:`h-full w-full object-cover`},_):p||m?(0,Q.jsx)(`img`,{src:y,alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,Q.jsx)(`span`,{className:`flex h-full w-full items-center justify-center leading-none drop-shadow-sm`,style:{fontSize:n*.5},children:e?.emoji??`✨`})}),t&&u!==`idle`&&(0,Q.jsx)(`span`,{className:B(`absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-bg`,u===`working`?`bg-amber-400`:`bg-rose-500`),style:{width:Math.max(10,n*.28),height:Math.max(10,n*.28)}})]});return r?(0,Q.jsx)(`button`,{type:`button`,onClick:()=>{s(!0),r()},"aria-label":b,className:B(x,`transition active:scale-95`),style:{width:n,height:n},children:S}):(0,Q.jsx)(`span`,{role:`img`,"aria-label":b,onClick:()=>s(!0),className:x,style:{width:n,height:n},children:S})}function Mt({profile:e,status:t,name:n,statusLine:r,statusTone:i=`muted`,spinning:a=!1,badge:o,onAvatar:s,onName:c,leading:l,trailing:u,under:d,size:f=72,className:p}){return(0,Q.jsxs)(`header`,{className:B(`safe-top relative shrink-0 bg-bg pt-1.5 pb-1`,p),children:[(0,Q.jsxs)(`div`,{className:`flex flex-col items-center px-16`,children:[(0,Q.jsxs)(`button`,{type:`button`,onClick:s,"aria-label":n,className:`relative rounded-full`,children:[(0,Q.jsx)(jt,{profile:e,status:t,size:f}),o!==void 0&&o>0&&(0,Q.jsx)(`span`,{className:`pointer-events-none absolute -right-0.5 top-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border-2 border-bg bg-rose-500 px-1 text-[10.5px] font-bold text-white`,children:o})]}),(0,Q.jsxs)(`div`,{className:`relative z-10 -mt-2.5 flex min-h-[44px] flex-col items-center`,children:[(0,Q.jsx)(Nt,{name:n,statusLine:r,statusTone:i,spinning:a,onClick:c??s}),d&&(0,Q.jsx)(`div`,{className:`mt-1.5 flex max-w-full items-center`,children:d})]})]}),l&&(0,Q.jsx)(`div`,{className:`absolute left-4 top-1.5 safe-top`,children:l}),u&&(0,Q.jsx)(`div`,{className:`absolute right-4 top-1.5 safe-top flex items-center gap-2`,children:u})]})}function Nt({name:e,statusLine:t,statusTone:n=`muted`,spinning:r=!1,onClick:i}){let a=(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(`span`,{className:`max-w-[240px] truncate text-[14px] leading-[18px] text-fg`,children:e}),t&&(0,Q.jsxs)(`span`,{className:B(`mt-0.5 flex max-w-[240px] items-center gap-1 text-[11.5px] leading-[14px]`,n===`accent`?`text-accent`:`text-muted`),children:[r&&(0,Q.jsx)(`span`,{className:`h-2.5 w-2.5 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent`}),(0,Q.jsx)(`span`,{className:`truncate`,children:t})]})]}),o=`flex flex-col items-center rounded-[14px] bg-surface px-3 py-1 shadow-[0_1px_4px_rgba(0,0,0,0.16)] dark:border dark:border-border dark:shadow-none`;return i?(0,Q.jsx)(`button`,{type:`button`,onClick:i,className:o,children:a}):(0,Q.jsx)(`div`,{className:o,children:a})}function Pt({onClick:e,label:t,children:n,dot:r=!1,small:i=!1,className:a,disabled:o=!1,expanded:s}){return(0,Q.jsxs)(`button`,{type:`button`,onClick:e,"aria-label":t,"aria-expanded":s,disabled:o,className:B(`relative flex items-center justify-center rounded-full bg-surface text-fg shadow-[0_1px_4px_rgba(0,0,0,0.14)] hover:bg-surface-2 disabled:opacity-50 dark:border dark:border-border dark:shadow-none`,i?`h-10 w-10`:`h-11 w-11`,a),children:[n,r&&(0,Q.jsx)(`span`,{className:`absolute right-[3px] top-[3px] h-[9px] w-[9px] rounded-full border-[1.5px] border-surface bg-accent`})]})}function Ft({children:e,className:t}){return(0,Q.jsx)(`h1`,{className:B(`px-5 pt-1.5 pb-1.5 text-[26px] font-bold leading-8 tracking-tight`,t),children:e})}export{ye as A,fe as B,Te as C,Se as D,B as E,ne as F,ue as G,oe as H,se as I,b as J,he as K,F as L,_e as M,ge as N,R as O,z as P,l as Q,L as R,W as S,we as T,I as U,pe as V,le as W,o as X,d as Y,s as Z,Le as _,gt as a,Ae as b,vt as c,mt as d,ht as f,Re as g,Je as h,jt as i,xe as j,be as k,Ct as l,Ke as m,Ft as n,St as o,Z as p,x as q,Pt as r,bt as s,Mt as t,yt as u,Fe as v,G as w,q as x,je as y,me as z}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/MuseHeader-RC--uR8o.js b/nanomuse/server/static/assets/MuseHeader-RC--uR8o.js new file mode 100644 index 00000000..ab96fed9 --- /dev/null +++ b/nanomuse/server/static/assets/MuseHeader-RC--uR8o.js @@ -0,0 +1 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,o)=>(o=n==null?{}:e(i(n)),c(r||!n||!n.__esModule||!a.call(n,`default`)?t(o,`default`,{value:n,enumerable:!0}):o,n)),u=o((e=>{var t=Symbol.for(`react.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.provider`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.iterator;function p(e){return typeof e!=`object`||!e?null:(e=f&&e[f]||e[`@@iterator`],typeof e==`function`?e:null)}var m={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},h=Object.assign,g={};function _(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}_.prototype.isReactComponent={},_.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`setState(...): takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},_.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function v(){}v.prototype=_.prototype;function y(e,t,n){this.props=e,this.context=t,this.refs=g,this.updater=n||m}var b=y.prototype=new v;b.constructor=y,h(b,_.prototype),b.isPureReactComponent=!0;var x=Array.isArray,S=Object.prototype.hasOwnProperty,C={current:null},w={key:!0,ref:!0,__self:!0,__source:!0};function ee(e,n,r){var i,a={},o=null,s=null;if(n!=null)for(i in n.ref!==void 0&&(s=n.ref),n.key!==void 0&&(o=``+n.key),n)S.call(n,i)&&!w.hasOwnProperty(i)&&(a[i]=n[i]);var c=arguments.length-2;if(c===1)a.children=r;else if(1{t.exports=u()})),f=l(d()),p=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),m=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),h=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),g=e=>{let t=h(e);return t.charAt(0).toUpperCase()+t.slice(1)},_={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},v=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},y=(0,f.forwardRef)(({color:e=`currentColor`,size:t=24,strokeWidth:n=2,absoluteStrokeWidth:r,className:i=``,children:a,iconNode:o,...s},c)=>(0,f.createElement)(`svg`,{ref:c,..._,width:t,height:t,stroke:e,strokeWidth:r?Number(n)*24/Number(t):n,className:p(`lucide`,i),...!a&&!v(s)&&{"aria-hidden":`true`},...s},[...o.map(([e,t])=>(0,f.createElement)(e,t)),...Array.isArray(a)?a:[a]])),b=(e,t)=>{let n=(0,f.forwardRef)(({className:n,...r},i)=>(0,f.createElement)(y,{ref:i,iconNode:t,className:p(`lucide-${m(g(e))}`,`lucide-${e}`,n),...r}));return n.displayName=g(e),n},x=b(`loader-circle`,[[`path`,{d:`M21 12a9 9 0 1 1-6.219-8.56`,key:`13zald`}]]),S=21600,C=32;function w(e=Date.now()){return(Math.floor(e/1e3/S)+2)*S}function ee(e,t,n){let r=new TextEncoder;return E(te(r.encode(e),r.encode(`${n}\n${t}`))).slice(0,C)}function T(e,t,n=Date.now()){let r=w(n),i=new URLSearchParams;return i.set(`exp`,String(r)),i.set(`sig`,ee(e,t,r)),i}function E(e){let t=``;for(let n of e)t+=n.toString(16).padStart(2,`0`);return t}function te(e,t){let n=new Uint8Array(64);n.set(e.length>64?k(e):e);let r=new Uint8Array(64+t.length),i=new Uint8Array(96);for(let e=0;e<64;e++)r[e]=n[e]^54,i[e]=n[e]^92;return r.set(t,64),i.set(k(r),64),k(i)}var D=new Uint32Array([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),O=(e,t)=>e>>>t|e<<32-t;function k(e){let t=new Uint32Array([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),n=new Uint8Array(e.length+9+63>>6<<6);n.set(e),n[e.length]=128;let r=new DataView(n.buffer),i=e.length*8;r.setUint32(n.length-8,Math.floor(i/4294967296)),r.setUint32(n.length-4,i>>>0);let a=new Uint32Array(64);for(let e=0;e>>3,i=O(n,17)^O(n,19)^n>>>10;a[e]=a[e-16]+r+a[e-7]+i>>>0}let n=t[0],i=t[1],o=t[2],s=t[3],c=t[4],l=t[5],u=t[6],d=t[7];for(let e=0;e<64;e++){let t=O(c,6)^O(c,11)^O(c,25),r=c&l^~c&u,f=d+t+r+D[e]+a[e]>>>0,p=(O(n,2)^O(n,13)^O(n,22))+(n&i^n&o^i&o)>>>0;d=u,u=l,l=c,c=s+f>>>0,s=o,o=i,i=n,n=f+p>>>0}t[0]+=n,t[1]+=i,t[2]+=o,t[3]+=s,t[4]+=c,t[5]+=l,t[6]+=u,t[7]+=d}let o=new Uint8Array(32),s=new DataView(o.buffer);for(let e=0;e<8;e++)s.setUint32(e*4,t[e]);return o}var A={Chat:`聊天`,Feed:`动态`,Ideas:`点子`,Goals:`目标`,Library:`资源库`,"Main chat":`主要聊天`,"Side chat":`旁聊`,"Reconnecting to your nanoMuse…":`正在重新连接你的 nanoMuse…`,"This demo has ended.":`这次体验已经结束。`,"This nanoMuse has stopped. Reload the page once it is running again.":`这个 nanoMuse 已停止。等它重新运行后,刷新页面即可。`,"Reconnecting…":`重新连接中…`,"Connect to your nanoMuse":`连接到你的 nanoMuse`,"This app talks to the nanoMuse server you run yourself. Scan the QR code printed by":`这个应用连接的是你自己运行的 nanoMuse 服务器。扫描下面命令打印的二维码`,", or paste the access token below.":`,或在下方粘贴访问令牌。`,"Access token":`访问令牌`,Connect:`连接`,Connected:`已连接`,"Hi, I'm {name}.":`你好,我是 {name}。`,"I don't just answer; I get things done: research, plans, files, code, email, long-running goals. Everything I do shows up here, and anything hard to undo waits for your approval.":`我不只是回答问题,而是把事情做完:调研、计划、文件、代码、邮件、长期目标。我做的每件事都会显示在这里,任何难以撤销的操作都会先等你批准。`,"What can you do for me?":`你能为我做什么?`,"Plan my week: ask me what's on my plate":`帮我规划这一周:先问问我手头有什么`,"Research and compare two options for me":`帮我调研并比较两个选项`,"Set up a long-term goal and track it":`建立一个长期目标并持续跟进`,"Answer {name}…":`回复 {name}…`,"Reply below to continue.":`在下方回复以继续。`,"{name} is working; anything you send now is picked up right away.":`{name} 正在工作,你现在发的消息会马上接着处理。`,"Working…":`处理中…`,"On it":`正在处理`,"Show the agent's steps":`显示执行步骤`,"Every tool it uses becomes a chip in the chat. Off, the chat keeps to the conversation and the line under the name says what it is on. This device only.":`每用一个工具就在对话里多一个小标签。关闭时对话只保留对话本身,名字下方的一行字说明它正在做什么。仅本设备。`,"On it: {request}":`正在处理:{request}`,"A star on GitHub helps":`GitHub 上的一颗 Star 帮得上忙`,"Welcome. nanoMuse is free, open source and non-profit, a personal agent for anyone who runs it. If that is worth something to you, a star on GitHub is how the next person finds it.":`欢迎。nanoMuse 免费、开源、非营利,做的是一个属于每个人的个人智能体。如果你认同,GitHub 上的一颗 Star 能让下一个人也找到这里。`,"Three tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.":`三个任务完成了。如果 nanoMuse 用得上,到 GitHub 点一颗 Star,下一个人也能找到它。`,"{n} tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.":`已经完成 {n} 个任务了。如果 nanoMuse 用得上,到 GitHub 点一颗 Star,下一个人也能找到它。`,"A week with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.":`和 nanoMuse 一起一周了。如果它在你的日常里站住了脚,到 GitHub 点一颗 Star,下一个人也能找到它。`,"A month with nanoMuse. If it has become part of your routine, a star on GitHub tells others it is worth a try.":`和 nanoMuse 一起一个月了。如果它成了你习惯的一部分,一颗 Star 会让更多人愿意试一试。`,"{n} days with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.":`和 nanoMuse 一起 {n} 天了。如果它在你的日常里站住了脚,到 GitHub 点一颗 Star,下一个人也能找到它。`,"Goal reached. If nanoMuse helped you get there, a star on GitHub tells the people building it that it did.":`目标达成了。如果 nanoMuse 帮你走到了这里,到 GitHub 点一颗 Star,做它的人就知道了。`,"Ten tasks together. If nanoMuse has become part of your day, a star on GitHub tells others it is worth a try.":`已经一起完成十个任务了。如果 nanoMuse 成了你日常的一部分,一颗 Star 会让更多人愿意试一试。`,"A new face, drawn for you. If you like where nanoMuse is going, a star on GitHub helps more people find it.":`一张为你画的新面孔。如果你喜欢 nanoMuse 现在的样子,到 GitHub 点一颗 Star,更多人就能遇见它。`,"The free allowance is used up. Thank you for coming this far. If nanoMuse has earned it, a star on GitHub keeps the project in view for the next person.":`免费额度用完了,谢谢你一路用到这里。如果 nanoMuse 值得,到 GitHub 点一颗 Star,下一个人也能找到它。`,"Load earlier messages":`加载更早的消息`,"New side chat":`新建旁聊`,"New side chat, e.g. “Trip to Kyoto”":`新建旁聊,例如“京都之旅”`,Chats:`对话`,New:`新建`,Create:`创建`,Clear:`清空`,"Clear this conversation? Memory and goals are kept.":`清空这段对话?记忆和目标会保留。`,"Delete this side chat and its history?":`删除这个旁聊及其历史记录?`,"Could not send":`发送失败`,"Could not send decision":`无法提交决定`,Send:`发送`,"Show thinking":`显示思考过程`,"Hide thinking":`隐藏思考过程`,"You:":`你:`,"{name} asks":`{name} 提问`,"{name} suggests":`{name} 建议`,"{name} is on it":`{name} 正在处理`,"Browser: {title}":`浏览器:{title}`,safe:`安全`,moderate:`中等`,sensitive:`敏感`,high:`高`,"Exactly what will run":`将要执行的具体内容`,"Why it asks: {reasons}":`询问原因:{reasons}`,"{tool} is on your always-ask list":`{tool} 在你的「总是询问」列表里`,"{tool} is on your always-allow list":`{tool} 在你的「总是允许」列表里`,"{tool} is switched off in your settings":`{tool} 已在你的设置里关闭`,"an action rated {risk}, in {mode} mode":`这个操作的风险等级是「{risk}」,哨兵处于{mode}模式`,"auto mode: not asked":`自动模式:未询问`,"covered by your {scope} permission for {key}":`已在你对 {key} 的「{scope}」授权范围内`,"private data was read earlier in this conversation, and {tool} could send it to {target}, which is not on your allowed list":`这段对话早些时候读取过私密数据,而 {tool} 可能把它发给 {target},这个地址不在你的允许列表里`,ask:`询问`,auto:`自动`,strict:`严格`,once:`一次`,conversation:`本次对话`,session:`本次运行`,"24h":`24 小时`,low:`低`,"Stopped.":`已停止。`,"Use nanoMuse Cloud this time":`这次改用 nanoMuse Cloud`,"Answered by nanoMuse Cloud this time ({model})":`这次由 nanoMuse Cloud 回答({model})`,Deny:`拒绝`,"Allow once":`允许一次`,Allow:`允许`,"{name} wants to":`{name} 想要`,"at {host}":`站点:{host}`,"to {recipient}":`收件人:{recipient}`,"Allow {subject} for longer…":`更长时间允许 {subject}…`,"For this conversation":`本次对话`,"the device {name}":`设备「{name}」`,"Until restart":`直到重启`,"For 24 hours":`24 小时内`,"Always for {subject}":`始终允许 {subject}`,"Fewer options":`收起`,"Show all":`展开全部`,"This kind of action is approved one at a time.":`这类操作需要逐次批准。`,Approved:`已批准`,Denied:`已拒绝`,"Expired without an answer":`已过期,未作答`,"approved by you":`已由你批准`,"covered by your {scope} permission":`已由你的{scope}授权覆盖`,"for this conversation":`本次对话`,"until restart":`直到重启`,"for 24 hours":`24 小时内`,always:`始终`,"email to {who}":`发送给 {who} 的邮件`,"{what} commands":`{what} 命令`,browser:`浏览器`,email:`邮件`,secret:`密钥`,"Waiting for you":`等你确认`,Answer:`回答`,LIVE:`实时`,BROWSER:`浏览器`,"Frame no longer available":`画面已不可用`,Page:`页面`,Document:`文档`,Image:`图片`,Data:`数据`,Code:`代码`,Open:`打开`,Download:`下载`,"Type into the focused field…":`输入到当前聚焦的输入框…`,Type:`输入`,"Press Enter":`按回车`,"Open a URL…":`打开一个网址…`,Go:`前往`,"Tap the page to click. If the browser was closed, open a URL first.":`点击画面即可点击网页。如果浏览器已关闭,请先打开一个网址。`,Refresh:`刷新`,"Take over":`接管`,"This frame is no longer available.":`这一帧画面已不可用。`,"Look again":`重新查看`,"1 frame":`1 帧`,"{n} frames":`{n} 帧`,"Scroll up":`向上滚动`,"Scroll down":`向下滚动`,"You have the page. Press Done when you are finished.":`页面现在由你操作。完成后按「完成」。`,"{name} waits meanwhile, then looks at the page as you left it and carries on. Passwords you type here go to the website, never to the model.":`{name} 会先等着,然后从你离开时的页面接着做。你在这里输入的密码只发给网站,不会给模型。`,"{name} is waiting for you":`{name} 在等你`,"{name} is working":`{name} 正在工作`,"Your turn":`该你了`,"Your turn: {reason}":`该你了:{reason}`,"You took over {thing}":`你接管了{thing}`,"the browser":`浏览器`,"the phone":`手机`,"The browser":`浏览器`,"The phone":`手机`,"Do this part yourself, then press Done. {name} looks again and carries on from there.":`这一步请你自己来,做完按「完成」。{name} 会重新看一眼屏幕,从那里接着做。`,"{name} waits. Press Done when you are finished and it looks again.":`{name} 在等。你弄完后按「完成」,它会重新看一眼。`,"{name} waited ten minutes and went on without you.":`{name} 等了十分钟,没等到你,先继续了。`,"Ended when the runtime restarted.":`运行时重启,这次接管结束。`,"You did it; {name} carried on.":`你做完了,{name} 接着做了。`,"Handed back; {name} carried on.":`已交还,{name} 接着做了。`,Close:`关闭`,"Open in a new tab":`在新标签页打开`,"No preview for this file type.":`此类型文件无法预览。`,"Download it":`下载文件`,"Empty file.":`空文件。`,"Could not load the file: {error}":`无法加载文件:{error}`,"Writing…":`正在写…`,"While you were away":`你离开期间`,"Could not write posts: {error}":`无法生成动态:{error}`,"Nothing yet. Once {name} works on a goal in the background or needs your approval, it shows up here.":`暂时还没有内容。当 {name} 在后台推进目标或需要你批准时,会显示在这里。`,"Next up":`接下来`,"Background work is off. {name} only acts when you ask.":`后台工作已关闭。{name} 只在你要求时行动。`,"Turn it on":`开启`,"No goal has a next step to work on.":`没有目标有可执行的下一步。`,"Add one":`添加一个`,"Working now":`正在进行`,"Quiet hours after {time}":`免打扰时段,{time} 之后`,"Around {time}":`大约 {time}`,Soon:`即将`,overdue:`已逾期`,"{n} more in line":`还有 {n} 个排队中`,"Check-in on":`跟进`,"{n} more":`还有 {n} 个`,Reminder:`提醒`,Routine:`例程`,"Working on your goal: {title}":`推进目标:{title}`,"Check-in: {title}":`跟进:{title}`,"Reminder: {title}":`提醒:{title}`,"Routine: {title}":`例程:{title}`,"New mail: {title}":`新邮件:{title}`,"Coming up: {title}":`即将开始:{title}`,"Webhook: {title}":`Webhook:{title}`,"Tidied memory":`整理了记忆`,"{label}: nothing new":`{label}:没有新进展`,"Open chat":`打开对话`,Today:`今天`,Yesterday:`昨天`,Menu:`菜单`,Stop:`停止`,"Nothing to stop":`没有正在进行的任务`,Message:`发消息`,"An emoji instead":`改用表情符号`,"Refresh ideas":`刷新灵感`,Planning:`规划`,Research:`调研`,Money:`财务`,People:`人际`,"Files & tools":`文件与工具`,"Just for you":`专属于你`,Tracking:`追踪`,"Show {n} more":`还有 {n} 个`,Cancelled:`已取消`,"Starter ideas. Refresh once {name} knows you better.":`入门灵感。等 {name} 更了解你之后再刷新。`,"Generated {when}":`生成于 {when}`,"Could not refresh ideas: {error}":`无法刷新灵感:{error}`,"Do it for me":`帮我做`,Health:`健康`,Finance:`财务`,Career:`事业`,Learning:`学习`,Relationships:`关系`,Family:`家庭`,Home:`家居`,Travel:`旅行`,Creative:`创作`,Other:`其他`,"New goal":`新目标`,Paused:`已暂停`,Finished:`已完成`,active:`进行中`,paused:`已暂停`,done:`已完成`,cancelled:`已取消`,pending:`待处理`,blocked:`受阻`,"{done}/{total} done":`已完成 {done}/{total}`,"No next step":`没有下一步`,"Next: {step}":`下一步:{step}`,"Next check-in {when}":`下次跟进 {when}`,"Due today":`今天到期`,"Due tomorrow":`明天到期`,"Due in {n} days":`{n} 天后到期`,"Was due {date}":`原定 {date} 到期`,"By {date}":`截止 {date}`,"No target date":`没有目标日期`,"No category":`未分类`,"Every day":`每天`,"Once a week":`每周一次`,"Once a month":`每月一次`,Weekdays:`工作日`,Weekly:`每周`,Monthly:`每月`,"Daily at {time}":`每天 {time}`,"Weekdays at {time}":`工作日 {time}`,"Weekly at {time}":`每周 {time}`,"{day}s at {time}":`每{day} {time}`,"Monthly on the {day} at {time}":`每月 {day} 日 {time}`,"Work on it now":`现在就推进`,"Work on {title} now":`现在推进「{title}」`,"{name} is working on “{title}” in the main chat":`{name} 正在主要聊天中推进“{title}”`,"{name} is working on it in the main chat":`{name} 正在主要聊天中处理`,"Check in with me now":`现在跟我确认进度`,"{name} will check in with you in the main chat":`{name} 会在主要聊天中跟你确认进度`,"Discuss this goal in chat →":`在对话中讨论这个目标 →`,"About my goal “{title}” ({id}): what's the status, and what should we do next?":`我们来聊聊我的目标“{title}”({id})。现在进展如何,下一步该做什么?`,Pause:`暂停`,Resume:`继续`,Done:`完成`,Delete:`删除`,"Delete goal":`删除目标`,"Delete “{title}”?":`删除“{title}”?`,Edit:`编辑`,Save:`保存`,"Saving…":`保存中…`,Saved:`已保存`,Cancel:`取消`,"Add step":`添加步骤`,"Add a step":`添加一个步骤`,"What should be done…":`要做什么…`,"Mark step {n} {status}":`把第 {n} 步标记为{status}`,"Adjust the plan for “{title}”?":`调整“{title}”的计划?`,"Keeps the 1 step already done.":`保留已完成的 1 个步骤。`,"Keeps the {n} steps already done.":`保留已完成的 {n} 个步骤。`,"New remaining steps:":`新的剩余步骤:`,"Keep my plan":`保留我的计划`,"Use {name}'s plan":`采用 {name} 的计划`,"Kept your plan":`已保留你的计划`,"Plan updated":`计划已更新`,"What do you want to achieve?":`你想实现什么?`,"Why it matters, constraints (optional)":`为什么重要、有哪些限制(可选)`,"Area of life":`生活领域`,"Target date":`目标日期`,"Steps, one per line (optional, or let {name} plan them)":`步骤,每行一个(可选,也可以让 {name} 来规划)`,"Let {name} plan it":`让 {name} 来规划`,'Create a goal for me: "{title}"{description}{extras}. Break it into concrete steps with the goals tool, then tell me the plan.':`帮我创建一个目标:“{title}”{description}{extras}。用 goals 工具把它拆成具体步骤,然后告诉我计划。`,"Day {d}":`第 {d} 天`,"Search files":`搜索文件`,"No files match.":`没有匹配的文件。`,Latest:`最新`,"Updated {when}":`更新于 {when}`,Memory:`记忆`,"What {name} remembers about you. Read it, add to it, or make {name} forget; nothing here is hidden from you.":`{name} 对你的了解都在这里。你可以翻看、补充,或让 {name} 忘掉,没有什么是瞒着你的。`,"Tell {name} something to remember, e.g. “I'm vegetarian” or “My sister's birthday is 14 May”":`告诉 {name} 要记住的事,例如“我是素食者”或“我妹妹的生日是 5 月 14 日”`,Remember:`记住`,Forget:`忘掉`,"Forget “{text}”?":`忘掉“{text}”?`,"Nothing remembered yet":`还没有记住任何内容`,"{name} saves durable facts you share in chat (preferences, people, routines) and never secrets.":`{name} 会保存你在对话中分享的长期事实(偏好、人物、习惯),绝不保存密钥或密码。`,"added by you":`由你添加`,"saved by {name}":`由 {name} 保存`,"updated {when}":`更新于{when}`,"Tidy up":`整理`,"Tidying…":`整理中…`,"Merge lines that say the same thing and drop what was never a fact about you. Every change can be undone.":`把意思相同的条目合并,删掉那些并不是你本人情况的内容。每处改动都能撤销。`,"Nothing to tidy: {n} memories, all distinct.":`无需整理:{n} 条记忆,互不重复。`,"Tidied: {merged} merged, {dropped} dropped. Undo below if needed.":`已整理:合并 {merged} 条,去掉 {dropped} 条。如有需要可在下方撤销。`,"Recent changes":`最近的改动`,Merged:`已合并`,Dropped:`已去掉`,Updated:`已更新`,restored:`已恢复`,Undo:`撤销`,profile:`个人资料`,preference:`偏好`,people:`人物`,routine:`习惯`,constraint:`限制`,general:`一般`,idle:`空闲`,working:`工作中`,waiting:`等待中`,Activity:`活动`,Approvals:`审批`,Connections:`连接`,Settings:`设置`,"Nothing is waiting for you.":`没有需要你处理的事项。`,"{n} events":`{n} 个事件`,failed:`失败`,updated:`已更新`,"background check":`后台检查`,"Every tool call goes through the Sentinel and is written to the audit log, including the ones it refused.":`每一次工具调用都经过 Sentinel 审查并写入审计日志,包括被拒绝的那些。`,"Sentinel mode:":`Sentinel 模式:`,balanced:`平衡`,cautious:`谨慎`,"hands-off":`放手`,"Safe and moderate actions run freely; anything sensitive (email, shell, purchases) waits for you.":`安全和中等风险的操作自由执行;任何敏感操作(邮件、终端命令、购买)都会等你批准。`,"Moderate and sensitive actions both wait for your approval.":`中等和敏感风险的操作都会等待你的批准。`,"Everything is approved automatically except explicit deny rules. Use with care.":`除明确的拒绝规则外,一切自动批准。请谨慎使用。`,"Always asks before: {tools}.":`以下操作总会先问你:{tools}。`,"Private data was read this session: network calls to new destinations need approval.":`本次会话已读取过私密数据:访问新的网络目标需要批准。`,"Commands run in a sandbox":`命令在沙箱中运行`,"Commands run without a sandbox":`命令未在沙箱中运行`,"Each shell or Python call gets its own namespace: only the workspace is writable, your home directory is not there, and there is no network unless the command needs it.":`每次 shell 或 Python 调用都有独立的命名空间:只有工作区可写,你的主目录不在其中,命令不需要网络就没有网络。`,"Each shell or Python call runs in the workspace with a scrubbed environment; the container is the boundary.":`每次 shell 或 Python 调用都在工作区中以清洗过的环境运行;容器就是边界。`,"Each shell or Python call runs in the workspace with a scrubbed environment. On Linux, installing bubblewrap gives each one its own namespace.":`每次 shell 或 Python 调用都在工作区中以清洗过的环境运行。在 Linux 上安装 bubblewrap 后,每次调用会有独立的命名空间。`,"After reading private data, new network destinations need approval.":`读取私密数据之后,访问新的网络目标需要批准。`,"Permissions you granted":`你授予的权限`,"No standing approvals. When you answer “always allow” to a risky step, it is listed here and can be revoked; approvals you give once are not kept.":`还没有长期批准。对有风险的操作选择“始终允许”后,会列在这里,随时可以撤回;“允许一次”的批准不会保留。`,"Nothing yet today. What you ask for and what {name} does about it shows up here.":`今天还没有动态。你提的要求和 {name} 做的事会显示在这里。`,"Share avatar":`分享形象`,"Creates a routine":`会创建一条例程`,"Creates a goal":`会创建一个目标`,"Create routine":`创建例程`,"Start goal":`开始这个目标`,"Routine set: every day at {time}. It is listed under Goals.":`例程已设好:每天 {time}。在「目标」页可以看到。`,"A morning brief":`早间简报`,"Before you leave each morning: the weather, the first thing on your plate and anything new worth knowing.":`每天出门前:天气、今天的第一件事,以及值得知道的新消息。`,"Morning brief: check today's weather, read my calendar and reminders for today, and tell me in five lines what matters.":`早间简报:查今天的天气,读我今天的日程和提醒,用五行告诉我要紧的事。`,"drawn from the picture you attached":`按你附的图片来画`,"Share my avatar":`分享我的虚拟形象`,"Pick a card. It goes out through the system share sheet, or saves as a picture where there is none.":`选一张卡片,用系统分享发出去;没有系统分享的浏览器会保存为图片。`,"Hi, I'm {name}, a personal AI agent. Meet nanoMuse: open source, runs on your phone.":`你好,我是你的个人 AI 智能体 {name}。认识一下 nanoMuse:开源,在手机上本地运行。`,"Your personal AI agent, open source · {repo}":`你的个人 AI 智能体,开源 · {repo}`,Later:`以后再说`,Share:`分享`,"Save picture":`保存图片`,"Saved the card as a picture.":`卡片已保存为图片。`,"The card could not be drawn.":`卡片没能画出来。`,"the hands in {app}":`在 {app} 里动手`,"granted {time}":`授予于 {time}`,"until {time}":`直到 {time}`,"Revoke {what}":`撤销 {what}`,"Revoked: {subject}":`已撤销:{subject}`,"Reset all granted permissions":`重置所有已授予的权限`,"Permissions reset":`权限已重置`,"Always ask (from config)":`始终询问(来自配置)`,Off:`关闭`,Low:`低`,Default:`默认`,High:`高`,"Off. {name} only works when you ask.":`已关闭。{name} 只在你要求时工作。`,"every {n} min {name} picks one active goal and works on its next step.":`每 {n} 分钟 {name} 挑选一个进行中的目标并推进它的下一步。`,"Quiet hours until {time}.":`免打扰时段直到 {time}。`,"Next around {time}.":`下一次大约在 {time}。`,"No background work in this window; anything due waits until it ends.":`此时段不进行后台工作;到期的事项会等到时段结束。`,"Level, interval and quiet hours in Settings":`在设置中调整级别、间隔和免打扰时段`,"In line":`排队中`,"No active goal has a next step. Add one in Goals and {name} will pick it up.":`没有进行中的目标有下一步。在目标中添加一个,{name} 会接手。`,"{n} queued":`{n} 个排队中`,"Check-ins":`跟进`,"Goal nudges you asked for; they arrive whatever the proactivity level, but wait out quiet hours.":`你要求的目标提醒;无论主动性级别如何都会送达,但会避开免打扰时段。`,"Reminders & routines":`提醒与例程`,"When something happens":`当某事发生时`,"mail matching “{match}”":`匹配“{match}”的邮件`,"any new mail":`任何新邮件`,"events matching “{match}”":`匹配“{match}”的日程`,"any event":`任何日程`,"{n} min before {what}":`{what}开始前 {n} 分钟`,"webhook “{match}”":`Webhook“{match}”`,webhook:`Webhook`,"Nothing yet. Tell {name} “when the landlord writes back, summarise it and draft a reply” or “half an hour before any review, brief me”.":`还没有。告诉 {name}“房东回信时,帮我总结并起草回复”或“任何评审开始前半小时,给我一份简报”。`,"fired {n}×":`触发 {n} 次`,"not fired yet":`尚未触发`,"Copy URL":`复制地址`,"Run now: {text}":`现在运行:{text}`,"Webhook URL copied":`已复制 Webhook 地址`,"The inbox could not be read: {error}":`无法读取收件箱:{error}`,"Inbox checked {when}; {name} looks every {n} min.":`收件箱{when}检查过;{name}每 {n} 分钟查看一次。`,"{name} will look at the inbox shortly, then every {n} min.":`{name}稍后会查看收件箱,之后每 {n} 分钟一次。`,"Each time it happens {name} does the work in the chat it was set from and reports in the Feed. A mail or a request is treated as data, never as instructions.":`每次触发时,{name} 会在设定它的那个对话里把事做完,再到动态里汇报。邮件和请求的内容只当资料看,绝不当作指令执行。`,"Webhook ready":`Webhook 已就绪`,"POST anything to this URL and the work starts. The key is in the URL; share it only with the program that will call it.":`向这个地址 POST 任意内容即可开始工作。密钥就在地址里,只交给要调用它的程序。`,Copy:`复制`,"New mail":`新邮件`,"Before an event":`日程开始前`,Webhook:`Webhook`,"Connect a mailbox under Connections first.":`请先在“连接”里接入邮箱。`,"Add a calendar under Connections first.":`请先在“连接”里添加日历。`,"Words in the sender or subject (empty: any mail)":`发件人或主题中的词(留空:任何邮件)`,"Words in the title or place (empty: any event)":`标题或地点中的词(留空:任何日程)`,"A name for this hook":`给这个 Webhook 起个名字`,"Minutes before":`提前分钟数`,"min before":`分钟前`,"What should be done each time…":`每次要做什么……`,"When {condition}":`当{condition}`,"Set trigger":`设置触发器`,"A time you named is kept whatever the level or the quiet hours. Reminders just say it; routines do the work and report.":`你指定的时间无论级别或免打扰时段都会遵守。提醒只是说一声;例程会执行工作并汇报。`,"No reminders":`没有提醒`,"Nothing scheduled. Tell {name} “remind me at six to call mum” or “every weekday morning, summarise my unread email”.":`没有安排。告诉 {name}“六点提醒我给妈妈打电话”或“每个工作日早上汇总我的未读邮件”。`,"{n} finished recently":`最近完成 {n} 个`,"Do “{text}” now":`现在执行“{text}”`,"Remind me now: {text}":`现在提醒我:{text}`,"Cancel: {text}":`取消:{text}`,"{name} will say it now":`{name} 现在就会说`,"+ Add":`+ 添加`,"Remind me":`提醒我`,Repeat:`重复`,"What to remind you of…":`提醒你什么…`,"Set reminder":`设置提醒`,"Set routine":`设置例程`,at:`于`,"1 approval waiting for you":`需要你批准`,"{n} approvals waiting for you":`{n} 项需要你批准`,"{n} approval":`{n} 项审批`,You:`你`,"Its name and look, how careful it is, how often it speaks up.":`名字和形象,有多谨慎,多主动。`,Name:`名字`,Avatar:`头像`,"What it calls you":`它怎么称呼你`,"Safety · Sentinel":`安全 · Sentinel`,"A separate gatekeeper reviews every action. Pick how often it should check in with you.":`一个独立的守门人审查每一次操作。选择它多久向你确认一次。`,Balanced:`平衡`,"Browse, read and write files freely; stop for anything hard to undo: email, purchases, shell commands.":`自由浏览、读写文件;遇到难以撤销的操作时停下:邮件、购买、终端命令。`,Cautious:`谨慎`,"Also ask before moderate actions like fetching web pages or writing files.":`对抓取网页或写入文件等中等风险操作也先询问。`,"Hands-off":`放手`,"Approve everything automatically except explicit deny rules. For trusted, unattended runs only.":`除明确的拒绝规则外一切自动批准。仅用于可信的无人值守运行。`,"Decides when the Sentinel asks you before a call.":`决定 Sentinel 在调用前何时向你询问。`,Proactivity:`主动性`,"Only works when you ask.":`只在你要求时工作。`,"Checks in half as often; speaks up only when a step is done or it needs you.":`检查频率减半;只在完成一步或需要你时才发声。`,"Works on goals on schedule; reports real progress, stays quiet otherwise.":`按计划推进目标;汇报实质进展,其余时间保持安静。`,"Checks in twice as often and always reports, even 'still on track'.":`检查频率加倍并总是汇报,即使只是“仍在正轨上”。`,"Background work":`后台工作`,"Check-in interval":`检查间隔`,"{n} min":`{n} 分钟`,"{n} h":`{n} 小时`,"{n} d":`{n} 天`,"Halved at this level":`此级别下减半`,"Doubled at this level":`此级别下加倍`,"Quiet hours":`免打扰时段`,"Quiet hours start":`免打扰时段开始`,"Quiet hours end":`免打扰时段结束`,From:`从`,to:`到`,Notifications:`通知`,"Approvals, questions, finished background work and check-ins; nothing while the app is on screen.":`审批、提问、后台完成的事和到点的跟进;应用在前台时不打扰。`,"Let {name} notify this device":`让 {name} 通知这台设备`,"Let {name} notify this phone":`让 {name} 通知这台手机`,"The app stays connected to your server in the background. Approvals, questions and finished background work arrive as notifications and open the right chat.":`应用在后台保持与服务器的连接。审批、提问和后台完成的工作会以通知送达,并打开对应的对话。`,"nanoMuse for Android {version}":`nanoMuse Android 版 {version}`,"Disconnect from this server":`断开此服务器`,"This device only":`仅这台设备`,"1 device subscribed":`1 台设备已订阅`,"{n} devices subscribed":`{n} 台设备已订阅`,"Send a test":`发送测试`,"Sent. It should arrive in a moment":`已发送,应该马上就到`,"Notifications need https:// (or localhost). Over plain http on your LAN the app works, this part stays off; see docs/deployment.md.":`通知需要 https://(或 localhost)。在局域网的普通 http 上应用可以使用,但这部分功能保持关闭,参见 docs/deployment.md。`,"This browser cannot receive push notifications.":`这个浏览器无法接收推送通知。`,"This browser has no push service (embedded browsers often don't). Use Chrome, Edge, Firefox or Safari 16.4+ on the phone.":`这个浏览器没有推送服务(内嵌浏览器通常没有)。请在手机上使用 Chrome、Edge、Firefox 或 Safari 16.4+。`,"Notifications are blocked for this site in the browser settings":`浏览器设置中已屏蔽此站点的通知`,"Blocked for this site. Allow notifications in the browser's site settings, then try again.":`通知在这个站点被关掉了。到浏览器的站点设置里允许通知,再试一次。`,"The server was installed without pywebpush.":`服务器安装时未包含 pywebpush。`,"On a phone, add the app to the home screen first; the icon then shows a badge and notifications open the right chat.":`手机上请先把应用添加到主屏幕;图标会显示角标,通知会打开对应的对话。`,Model:`模型`,"Provider / model":`服务商 / 模型`,"Reveal the model's reasoning under each reply when the provider exposes it.":`当服务商提供时,在每条回复下方显示模型的推理过程。`,"Reply language":`回复语言`,"What {name} writes in":`{name} 用什么语言回复`,Auto:`自动`,"Match mine":`跟随我的语言`,"App language":`应用语言`,Appearance:`外观`,Light:`浅色`,Dark:`深色`,"Follow the system":`跟随系统`,About:`关于`,"{version} is out":`{version} 已发布`,"nanoMuse {version}":`nanoMuse {version}`,"Tell {name} something to remember":`在对话里告诉 {name} 一件值得记住的事`,"Write one":`写一个`,"Latest {version}; you have it":`最新版 {version},你已经在用了`,Update:`更新`,"Check now":`现在检查`,"Checking…":`正在检查…`,"Could not check":`没能检查到最新版本`,"Checked {when}":`上次检查:{when}`,"The phone app checks for updates on its own.":`手机 App 会自己检查更新。`,"Update checks are off here.":`这里关闭了更新检查。`,"To update: pip install -U nanomuse, pull the new image, or use the desktop app's Update.":`更新方式:pip install -U nanomuse、拉取新镜像,或在桌面 App 里点“更新”。`,"Data:":`数据:`,"Workspace:":`工作区:`,"Forget this device's access token":`忘掉这台设备的访问令牌`,"does the work":`负责干活`,"Not scheduled":`未安排时间`,Calendar:`日历`,calendar:`日历`,Tomorrow:`明天`,"all day":`全天`,"{n} scheduled":`{n} 个安排`,"Nothing on the calendar today.":`今天日历上没有安排。`,"Ask about your week":`问问这周的安排`,"{name} could not be read":`{name} 读取失败`,"Your agenda, free time, and events it can draft":`你的日程、空闲时间,以及它起草的日程`,"{n} not reading":`{n} 个读取失败`,"1 event":`1 个事件`,"{n} calendars":`{n} 个日历`,"read {when}":`{when}读取`,"Calendar connected":`日历已连接`,"Reads your calendar from its private .ics link; the link stays in the vault. It never changes your calendar; an event it proposes comes as a file you add with a tap.":`通过私密 .ics 链接读取你的日历,链接存在保险库里。它不会改动你的日历;它建议的日程会做成文件给你,点一下就能加进去。`,"not read yet":`尚未读取`,"Where is the link?":`链接在哪里?`,"Settings → your calendar → Integrate calendar → Secret address in iCal format":`设置 → 你的日历 → 整合日历 → iCal 格式的私密地址`,"Settings → Calendar → Shared calendars → Publish a calendar → ICS link":`设置 → 日历 → 共享日历 → 发布日历 → ICS 链接`,"Share Calendar → Public Calendar → copy the webcal:// link":`共享日历 → 公共日历 → 复制 webcal:// 链接`,"Settings → Calendars → Export → Calendar URL":`设置 → 日历 → 导出 → 日历 URL`,"A path on the machine where nanoMuse runs, for example ~/calendar.ics":`nanoMuse 所在机器上的文件路径,例如 ~/calendar.ics`,".ics file":`.ics 文件`,Work:`工作`,"Private .ics link or file path":`私密 .ics 链接或文件路径`,"Stored encrypted in the vault as CALENDAR_{name}.":`加密保存在保险库中,名为 CALENDAR_{name}。`,"nanoMuse fetches whatever address you put here, from this machine; only paste links you trust.":`nanoMuse 会从这台机器上去取你填的任何地址,只粘贴你信任的链接。`,"Add a calendar":`添加日历`,"Added, but it could not be read: {error}":`已添加,但读取失败:{error}`,"Google Calendar":`Google 日历`,"Client set — sign in with Google":`客户端已配置,请用 Google 登录`,"Paste your Google OAuth client id and secret":`粘贴你的 Google OAuth 客户端 ID 和密钥`,"Client ID":`客户端 ID`,"From the OAuth client you downloaded in Google Cloud Console.":`来自你在 Google Cloud Console 下载的 OAuth 客户端。`,"Client secret":`客户端密钥`,"A secret is saved. Type a new one only to replace it.":`密钥已保存。只有在需要更换时才重新输入。`,"Stored encrypted in the vault as GOOGLE_CALENDAR_SECRET.":`加密保存在保险库中,名为 GOOGLE_CALENDAR_SECRET。`,"Callback URL":`回调 URL`,"Register this exact URL on the OAuth client as an authorized redirect URI.":`把这个 URL 原样注册为该 OAuth 客户端的已授权重定向 URI。`,"Let the agent create and change events":`允许它创建和修改日程`,"Off: read-only, the agent can look but not touch.":`关闭后为只读,它只能查看,不能改动。`,"Save client":`保存客户端`,"Sign in with Google":`用 Google 登录`,"Finish signing in with Google in the window that opened.":`在打开的窗口里完成 Google 登录。`,"Sign in needed":`需要登录`,"Read-only":`只读`,Disconnected:`已断开`,"Calendar to read and write":`读取和写入的日历`,"Events the agent reads and creates land here.":`它读取和创建的日程都会落在这个日历里。`,"{name} (primary)":`{name}(主日历)`,"read + write":`读写`,"read-only":`只读`,Contacts:`通讯录`,"1 person":`1 人`,"{n} people":`{n} 人`,"Address book connected":`通讯录已连接`,"Who is who, so it never guesses an address":`知道谁是谁,从不猜地址`,"told to it in chat":`在聊天中告诉它的`,"{n} address books":`{n} 个通讯录`,"Import a .vcf export from Google Contacts, iCloud, Outlook or your phone, or paste a link to one. People you mention in chat go into its own book. It looks people up before writing to them and warns when an address is unknown.":`导入 Google 通讯录、iCloud、Outlook 或手机导出的 .vcf 文件,或粘贴一个链接。你在聊天中提到的人会记进它自己的名册。写信前它会先查人,地址不认识时会提醒。`,"My contacts":`我的联系人`,"added in chat":`聊天中添加`,iPhone:`iPhone`,"A .vcf file from your phone or computer":`手机或电脑上的 .vcf 文件`,"Google Contacts: Export → vCard. iPhone: Contacts → select all → Share → Save to Files. Outlook: People → Manage → Export.":`Google 通讯录:导出 → vCard。iPhone:通讯录 → 全选 → 共享 → 存储到“文件”。Outlook:人员 → 管理 → 导出。`,"Choose a .vcf file":`选择 .vcf 文件`,"Or a link / path to one":`或一个链接 / 路径`,"A link is stored encrypted in the vault as CONTACTS_{name}.":`链接会加密保存在保险库中,名为 CONTACTS_{name}。`,"Add an address book":`添加通讯录`,"Find someone, as the agent would":`像 Agent 那样查一个人`,"No one matches.":`没有匹配的人。`,"Working hours":`工作时间`,"Working hours start":`工作时间开始`,"Working hours end":`工作时间结束`,"Free time is looked for inside these hours.":`空闲时间只在这个时段内查找。`,"Read again":`重新读取`,Event:`日程`,"No event in this file.":`这个文件里没有日程。`,"Untitled event":`未命名日程`,"Add to calendar":`加入日历`,"Opens in your calendar app, which asks before it saves anything.":`会在你的日历应用中打开,保存前会先询问你。`,"What {name} can reach; keys stay in the vault on this machine.":`{name} 能用到的东西;密钥留在这台机器的保险库里。`,"No key yet":`还没有密钥`,"Key missing from vault":`保险库中缺少密钥`,"Key in vault":`密钥已在保险库`,"Key from config":`密钥来自配置`,"No key needed":`无需密钥`,Provider:`服务商`,"Base URL":`Base URL`,"API key":`API 密钥`,"A key is in the vault. Leave blank to keep it.":`保险库中已有密钥。留空则保持不变。`,"Stored encrypted in the vault as LLM_API_KEY.":`以 LLM_API_KEY 加密存储在保险库中。`,"model name":`模型名称`,"Tool calling":`工具调用`,"Auto uses the API's function calling and falls back to describing tools in the prompt when the endpoint rejects them. Prompt: for endpoints that silently ignore tools.":`自动:使用 API 的函数调用,当端点拒绝时回退为在提示词中描述工具。提示词:用于会静默忽略工具的端点。`,Test:`测试`,"Model saved":`模型已保存`,'Replied "{reply}" in {ms} ms':`在 {ms} 毫秒内回复了“{reply}”`,"Email connected":`邮件已连接`,"Saved, but the test failed":`已保存,但测试失败`,"Email disconnected":`邮件已断开`,Incomplete:`未完成`,"Not connected":`未连接`,Email:`邮件`,"reads and sends":`读取和发送`,"Read your inbox, draft and send mail":`读取收件箱、起草和发送邮件`,"Reading is a moderate action; sending always asks you first. Use an app password where your provider offers one.":`读取是中等风险操作;发送总会先询问你。如果服务商提供应用专用密码,请使用它。`,Address:`邮箱地址`,Password:`密码`,"app password":`应用专用密码`,"A password is in the vault. Leave blank to keep it.":`保险库中已有密码。留空则保持不变。`,"Stored encrypted in the vault as EMAIL_PASSWORD.":`以 EMAIL_PASSWORD 加密存储在保险库中。`,"IMAP server":`IMAP 服务器`,Port:`端口`,"SMTP server":`SMTP 服务器`,"STARTTLS for SMTP (off for port 465)":`SMTP 使用 STARTTLS(465 端口请关闭)`,"Save & test":`保存并测试`,Disconnect:`断开`,"Signed in · {n} messages in the inbox":`已登录 · 收件箱有 {n} 封邮件`,Browser:`浏览器`,"Open pages, click, fill forms, screenshot":`打开页面、点击、填表、截图`,"Playwright is not installed on the server":`服务器上未安装 Playwright`,On:`开`,Unavailable:`不可用`,"Install it where the server runs:":`在服务器运行的机器上安装:`,"Server connected":`服务器已连接`,"MCP servers":`MCP 服务器`,"{n} of {total} connected · {tools} tools":`{total} 个中已连接 {n} 个 · {tools} 个工具`,"Add tools from any Model Context Protocol server":`从任意 Model Context Protocol 服务器添加工具`,"{n} on":`{n} 个已开启`,None:`无`,"{n} tools":`{n} 个工具`,"from config.toml":`来自 config.toml`,Remove:`移除`,Command:`命令`,"Run locally over stdio, for example: npx -y @modelcontextprotocol/server-filesystem ./workspace":`通过 stdio 在本地运行,例如:npx -y @modelcontextprotocol/server-filesystem ./workspace`,"or URL":`或 URL`,"A remote server over Streamable HTTP.":`通过 Streamable HTTP 连接的远程服务器。`,"Risk of its tools":`其工具的风险级别`,"Add a server":`添加服务器`,"Connected on {device}; sign in here to use it on this device":`已在 {device} 上连接,在这台设备上登录后即可使用`,"Sign in here":`在这里登录`,"Stored in the vault":`已存入保险库`,Vault:`保险库`,"{n} secrets · encrypted on your machine":`{n} 个密钥 · 在你的机器上加密`,"Encrypted secrets the model never sees":`模型永远看不到的加密密钥`,"Refer to a secret as":`在工具调用或配置中用`,"in a tool call or config: the value is filled in after the Sentinel approves and is redacted from everything the model reads.":`引用密钥:真实值会在 Sentinel 批准后填入,并从模型读到的所有内容中脱敏。`,"Delete {name}":`删除 {name}`,Value:`值`,Store:`存储`,"Skip setup":`跳过设置`,"Meet your nanoMuse":`认识你的 nanoMuse`,"Your name":`你的名字`,"The model behind it":`背后的模型`,"A model is already set up on the server. Keep it, or switch here.":`服务器上已经配置了模型。可以保留,也可以在这里切换。`,"Connect your mail, calendar and contacts":`连接你的邮箱、日历和通讯录`,"Optional. With a mailbox connected it can read what came in and draft replies; it will always ask before sending. With a calendar it knows your day and finds free time. With your contacts it knows who is who. The browser and MCP servers are under Connections later.":`可选。连上邮箱,它能读新邮件、起草回复,发之前一定先问你。连上日历,它知道你这一天的安排,也能找出空档。连上通讯录,它知道谁是谁。浏览器和 MCP 服务器之后在「连接」里设置。`,Back:`返回`,"Get started":`开始`,Skip:`跳过`,"Save a model to continue":`保存模型后继续`,"Skip for now":`暂时跳过`,"just now":`刚刚`,"any moment":`随时`,"in {span}":`{span}后`,"{span} ago":`{span}前`,"Checked on {label}: nothing new":`已检查 {label}:没有新进展`,"Notes from {name}":`来自 {name} 的笔记`,"Reminders from {name}":`来自 {name} 的提醒`,Once:`一次`,Plan:`计划`,Skills:`技能`,Yours:`你的`,"Built in":`内置`,Fetch:`获取`,"Use in chat":`在聊天中使用`,"Make your own copy":`复制一份改成你的`,"Edit skill":`编辑技能`,"New skill":`新建技能`,"From a link":`从链接导入`,"Files that come with it":`随附文件`,"Folders that could not be read":`无法读取的文件夹`,"Nothing of your own yet":`还没有你自己的技能`,"How {name} does a job, written down once. Start one in chat with /name, or just ask; it picks the skill that fits.":`把 {name} 做某件事的方法写下来,一次就够。在聊天里输入 /名称 直接开始,或者直接提要求,它会自己挑合适的技能。`,"After a job went well, tell {name} “save this as a skill”; it writes the steps down and asks you first. Or write one here, or paste a link to a SKILL.md.":`一件事做得顺利之后,对 {name} 说“把这个存成技能”,它会把步骤写下来,并先征求你的同意。也可以在这里手写一个,或粘贴一个 SKILL.md 的链接。`,"Switch one off to keep it out of {name}'s list. A skill of yours with the same name replaces it.":`关掉某一项,它就不会出现在 {name} 的清单里。你的同名技能会替代内置的那一个。`,"Yours live in {dir}, one folder each with a SKILL.md, the Agent Skills format, so recipes written for other agents work here too.":`你的技能保存在 {dir},每个一个文件夹,里面是一份 SKILL.md。这是 Agent Skills 通用格式,为其他智能体写的技能在这里同样可用。`,"Delete the skill “{name}”? Its folder goes with it.":`删除技能“{name}”?它的文件夹会一并删除。`,"Saving makes a copy of yours with the same name; it replaces the built-in one.":`保存后会生成一份你的同名技能,替代内置的那一个。`,"A SKILL.md: a name and a one-line description up top, then the steps in Markdown.":`一份 SKILL.md:开头是名称和一行说明,下面用 Markdown 写步骤。`,"A raw SKILL.md link, or a GitHub folder or file page.":`SKILL.md 的原始文件链接,或 GitHub 上的文件夹/文件页面。`,"Pick a name first; a template appears here.":`先起个名字,这里会出现一份模板。`,"Attach a file":`添加附件`,Attachments:`附件`,"Say what to do with it…":`说说要拿它做什么……`,"Upload failed":`上传失败`,"Web search":`网页搜索`,"Not set up":`未设置`,"{provider} at {host}":`{provider}({host})`,"{provider} needs {what}; searches use DuckDuckGo until then":`{provider}还需要{what},在此之前搜索走 DuckDuckGo`,"a key":`一个密钥`,"an instance URL":`实例地址`,"DuckDuckGo needs nothing, but it is scraped and rate-limits now and then. For searches that always work, use a provider with an API. Whichever you pick, a failed search falls back to DuckDuckGo with a note.":`DuckDuckGo 无需任何配置,但它是抓取来的结果,偶尔会被限流。想让搜索始终可用,选一个带 API 的服务。无论选哪个,搜索失败时都会退回 DuckDuckGo 并附上说明。`,"Stored encrypted in the vault as SEARCH_API_KEY.":`加密存入保险库,名为 SEARCH_API_KEY。`,"Get a key from {label}":`到 {label} 获取密钥`,"Instance URL":`实例地址`,"A SearXNG you run, with the JSON format enabled in its settings.":`你自己运行的 SearXNG,需在其设置中启用 JSON 格式。`,"{provider} · {n} results · {ms} ms":`{provider} · {n} 条结果 · {ms} 毫秒`,"Recall by meaning":`按含义召回`,"Keyword recall only":`仅按关键词召回`,"By keyword":`按关键词`,"Not reachable":`连不上`,"Not tried yet":`尚未尝试`,Mode:`模式`,Endpoint:`接口`,"Embedding model":`向量模型`,"Same as the model":`与模型相同`,"Another endpoint":`另一个接口`,"{model} at {host}":`{model}({host})`,"{model} · {n} of {total} memories indexed":`{model} · 已索引 {n}/{total} 条记忆`,"{model} · {dims} dims · {n} memories indexed · {ms} ms":`{model} · {dims} 维 · 已索引 {n} 条记忆 · {ms} 毫秒`,"Memories are embedded once and a message finds the ones that mean the same thing, in any language: “写邮件给房东” finds “the landlord is Bob Li”. Keyword recall stays; the two are fused.":`每条记忆只做一次向量化,一句话就能找到含义相同的记忆,不分语言:“写邮件给房东”能找到“the landlord is Bob Li”。关键词召回仍然保留,两者合并排序。`,"Auto uses the model's endpoint when it has embeddings and falls back to keywords when it does not. On insists and warns. Off: keywords only.":`自动:模型接口支持向量化就用,不支持就退回关键词。开:坚持使用,失败时给出警告。关:只用关键词。`,"Any OpenAI-compatible /embeddings: Ollama with an embedding model pulled, OpenAI, a gateway.":`任何 OpenAI 兼容的 /embeddings 接口:拉取了向量模型的 Ollama、OpenAI、网关。`,"The model's endpoint and key. DeepSeek has no embeddings; pick another endpoint.":`使用模型自己的接口和密钥。DeepSeek 没有向量化接口,请选另一个接口。`,"Stored encrypted in the vault as EMBEDDINGS_API_KEY. Ollama needs none.":`加密存在保险库里,名为 EMBEDDINGS_API_KEY。Ollama 不需要密钥。`,"Blank uses the endpoint's default: {model}. On Ollama: ollama pull qwen3-embedding:0.6b (reads Chinese and English).":`留空则用该接口的默认模型:{model}。Ollama 上:ollama pull qwen3-embedding:0.6b(中英文都能读)。`,Phone:`手机`,"{name} is connected":`{name} 已连接`,"A phone":`一台手机`,"No phone connected. Open the app on the phone":`没有连接的手机。在手机上打开 App`,"Tap, type and swipe in the apps on your phone":`在你手机上的 App 里点击、输入、滑动`,Waiting:`等待中`,"Operate the phone":`操作手机`,"When on, the agent can read the screen and act in the apps on the connected phone: 12306, WeChat, Alipay… It asks before paying, sending or deleting.":`打开后,智能体可以读取已连接手机的屏幕,并在上面的 App 里操作:12306、微信、支付宝……付款、发送、删除之前会先问你。`,"{name} ({platform}), {n} apps":`{name}({platform}),{n} 个 App`,Ready:`就绪`,"Not yet":`还没开`,"Not on this Android":`这个安卓版本不支持`,"Operating the screen needs Android 11 or newer; this phone can still be used for everything else.":`操作屏幕需要 Android 11 或更新;其他功能这台手机都能用。`,"Turn on the nanoMuse accessibility service; that is how it sees the screen and taps for you, only while a task runs, with a Stop button on screen.":`打开 nanoMuse 的无障碍服务,它靠这个看屏幕、替你点击,只在任务进行时工作,屏幕上始终有停止按钮。`,"Open Accessibility settings":`打开无障碍设置`,"App settings":`应用设置`,"Greyed out with “Restricted setting”? Android 13+ does that for apps installed from a download: in App settings tap ⋮ → Allow restricted settings, then come back.":`灰色且提示「受限设置」?Android 13+ 对下载安装的应用会这样:在应用设置里点 ⋮ → 允许受限设置,再回来。`,"Android may switch the service off after an update or a battery clean-up; if the phone stops answering, come back here.":`系统更新或电池清理后安卓可能会把服务关掉;手机不再响应时回到这里看看。`,"On the phone's screen: {gui} of {total} steps ({pct}%). The screen is the last resort; a skill, a fetch or the browser comes first.":`在手机屏幕上操作:{total} 步里有 {gui} 步({pct}%)。屏幕是最后一招,技能、抓取、浏览器优先。`,"last seen in {app}":`上次看到在 {app}`,"The operator's model looks at screens step by step: a small, fast model that takes pictures. The Hands model row under Chat model picks one on the same endpoint and key; the fields here are for a different endpoint. Empty = {model}.":`操作屏幕的模型一步步看截图:选一个小而快、能看图的模型。「对话模型」卡片里的「动手模型」一行在同一个接口和 key 上选;这里的字段用于另一个接口。留空 = {model}。`,"same as the chat model":`与对话模型相同`,"the chat model":`对话模型`,"Chat model":`对话模型`,"Hands model":`动手模型`,"Currently {value}":`当前为 {value}`,"The model that looks at screens when the hands run: the phone, this computer, a page the browser cannot read. Your account's hands model is {model}; the chat model above is a different pick.":`动手时看屏幕的模型:手机、这台电脑、浏览器读不了的页面。你账号的动手模型是 {model};上面的对话模型是另一项选择。`,"The model that looks at screens when the hands run: a small, fast one that takes pictures, on the same endpoint and key. Default: this provider's own hands model.":`动手时看屏幕的模型:小而快、能看图,用同一个接口和 key。默认:这个服务商自己的动手模型。`,"The model that looks at screens when the hands run. This provider has no model that sees, so the hands use your account's, {model}, unless you pick one here.":`动手时看屏幕的模型。这个服务商没有能看图的模型,所以不在这里选的话,双手用你账号里的 {model}。`,"{model} does not take pictures, so the hands would be blind with it. Pick a model that sees for them.":`{model} 不能看图,动手时会是盲的。给它选一个能看图的模型。`,"A key is in the vault; leave empty to keep it.":`保险库里已有密钥;留空则保留。`,"Set in config.toml.":`在 config.toml 里设置。`,"Leave empty to use the main model's key.":`留空则使用主模型的密钥。`,"Save and test":`保存并测试`,"The operator's model answers.":`操作手机的模型有回应。`,"A personal agent of your own. Three things to know:":`一个属于你自己的个人智能体。三件事:`,"It does things for you.":`它替你把事情做完。`,"Searches, browses, writes, books, reads mail, and hands you the result, not a list of links.":`搜索、浏览、写作、预订、读邮件,交给你的是结果,不是一堆链接。`,"It keeps working when you close the app.":`你关掉应用,它还在干活。`,"Goals move forward between your visits; it reports in the Feed and notifies you when something is worth it.":`目标在你不在的时候继续推进;它在动态里汇报,值得打扰你时才通知你。`,"It asks you first where it matters.":`要紧的事,它先问你。`,"A separate Sentinel reviews every action. Sending, paying, deleting: it stops and asks; your keys stay in a vault the model cannot read.":`独立的 Sentinel 审查每一个动作。发送、付款、删除:它会停下来问你;你的密钥放在模型读不到的保险库里。`,"Set it up":`设置一下`,"Add a model":`添加模型`,"Pick a provider and paste a key. Yours, stored in the vault.":`选一个服务商,粘贴密钥。密钥是你的,存在保险库里。`,"Connect mail, calendar, contacts":`连接邮箱、日历、通讯录`,"Connected. Tap to add more.":`已连接。点击添加更多。`,"Optional. It can read what came in, know your day, and who is who.":`可选。它就能读到来信、知道你的日程、认得谁是谁。`,Start:`开始`,"Needs a model first.":`先要有一个模型。`,"Add a model to start":`添加模型后开始`,optional:`可选`,"A line under the name, e.g. Your day, sorted.":`名字下面的一句话,例如:你的一天,安排好了。`,Tagline:`标语`,"More names":`换一批名字`,Tone:`语气`,Formal:`正式`,Casual:`随和`,Playful:`活泼`,Concise:`简洁`,"How much it says":`话说多少`,"Short replies":`回答简短`,Detailed:`详细`,"Bullet points":`要点列表`,"Anything else about how it should be":`还有什么想让它成为的样子`,"e.g. A little witty. Uses metric units. Never uses emoji.":`例如:有点风趣。用公制单位。从不用 emoji。`,"OpenAI-compatible · Chat Completions":`OpenAI 兼容 · Chat Completions`,"Responses API":`Responses API`,"Local or your own endpoint":`本地或你自己的端点`,"/v1 is added when the URL has no path.":`URL 没有路径时会自动补上 /v1。`,"The endpoint that serves /chat/completions, for example http://127.0.0.1:8000/v1.":`提供 /chat/completions 的端点,例如 http://127.0.0.1:8000/v1。`,"{n} models from the endpoint":`端点返回了 {n} 个模型`,"Asking the endpoint…":`正在询问端点…`,"Could not list the endpoint's models; these are the usual ones.":`无法列出端点的模型;这些是常见的几个。`,"Get a key from {vendor}":`去 {vendor} 获取密钥`,"No key":`无需密钥`,"No key will be sent.":`不会发送密钥。`,"Show key":`显示密钥`,"Hide key":`隐藏密钥`,"Actions {name} wants to take but cannot without you. Each one shows what will run and why; a permission you grant is bound to that exact tool and target.":`这些是 {name} 想做、但没有你点头就不能做的事。每一项都写明要运行什么、为什么;你给的许可只对那个具体的工具和对象有效。`,"Forget every permission you granted? {name} will ask again next time.":`忘掉你授予的所有权限?下次 {name} 会重新询问。`,"This phone":`这台手机`,"Keep it running":`保持运行`,"Android stops apps that seem idle. {name} runs as a foreground service and holds the phone awake only while a task runs; these switches let it keep that promise on this phone.":`安卓会停掉看起来闲着的应用。{name} 以前台服务运行,只在任务进行时让手机保持唤醒;下面这几个开关,是让它在这台手机上真能做到这一点。`,Battery:`电池`,Unrestricted:`无限制`,Optimised:`已优化`,"Optimised means Android may freeze the agent after a while with the screen off; routines and check-ins then wait until the phone wakes.":`「已优化」意味着熄屏一段时间后安卓可能冻结 Agent;例行任务和提醒要等手机醒来才跑。`,"Display over other apps":`显示在其他应用上层`,Allowed:`已允许`,"Not allowed":`未允许`,"Lets the agent bring an app to the front from the background and show its status capsule while it works in other apps.":`让 Agent 能从后台把应用调到前台,并在其他应用里工作时显示状态胶囊。`,"Alarms & reminders":`闹钟和提醒`,Exact:`精确`,Approximate:`大致`,"With exact alarms a reminder fires at the minute you named even when the phone is asleep; without them Android may deliver it up to a quarter of an hour late.":`有精确闹钟,提醒会在你说的那一分钟响起,哪怕手机在睡眠;没有的话安卓可能晚最多一刻钟送达。`,"Start after a reboot":`重启后自动启动`,"Bring the runtime back when the phone restarts, so routines and check-ins do not stop.":`手机重启后把运行时带回来,例行任务和提醒不中断。`,"Reconnect to your computer when the phone restarts, so approvals keep arriving.":`手机重启后重新连上你的电脑,审批照常送达。`,"On this phone's Android":`这台手机的安卓系统`,"Open the auto-start settings":`打开自启动设置`,"Xiaomi / Redmi / POCO: Settings → Apps → Manage apps → {name} → Autostart on; Battery saver → No restrictions; Other permissions → Display pop-up windows while running in the background on.":`小米 / Redmi / POCO:设置 → 应用设置 → 应用管理 → {name} → 自启动打开;省电策略 → 无限制;其他权限 → 后台弹出界面 允许。`,"Huawei: Settings → Battery → App launch → {name} → Manage manually, all three on (auto-launch, secondary launch, run in background); Settings → Apps → {name} → Battery → Launch: manage manually.":`华为:设置 → 电池 → 应用启动管理 → {name} → 手动管理,三项全开(自动启动、关联启动、后台活动);设置 → 应用 → {name} → 电池 → 启动管理:手动管理。`,"Honor: Settings → Battery → App launch → {name} → Manage manually, all three on; Battery → More battery settings → Stay connected while asleep on.":`荣耀:设置 → 电池 → 应用启动管理 → {name} → 手动管理,三项全开;电池 → 更多电池设置 → 休眠时始终保持网络连接 打开。`,"OPPO / realme / OnePlus: Settings → Battery → More settings → Optimise battery use → {name} → Don't optimise; Settings → Apps → {name} → Battery usage → Allow background activity, Allow auto launch.":`OPPO / realme / 一加:设置 → 电池 → 更多设置 → 应用耗电优化 → {name} → 不优化;设置 → 应用 → {name} → 耗电管理 → 允许后台活动、允许自动启动。`,"vivo / iQOO: i Manager → App manager → Autostart → {name} on; Settings → Battery → Background power consumption management → {name} → Allow high background power consumption.":`vivo / iQOO:i 管家 → 应用管理 → 自启动 → {name} 打开;设置 → 电池 → 后台耗电管理 → {name} → 允许后台高耗电。`,"Samsung: Settings → Battery → Background usage limits → {name} not in Sleeping or Deep sleeping apps (add it to Never sleeping apps); turn off Adaptive battery if it keeps stopping.":`三星:设置 → 电池 → 后台使用限制 → {name} 不在「休眠应用」或「深度休眠应用」里(加入「永不休眠的应用」);如果还是被停,关闭自适应电池。`,"Meizu: Settings → Apps → {name} → Background management → Keep running in the background; Battery → App power management → Allow.":`魅族:设置 → 应用管理 → {name} → 后台管理 → 允许后台运行;电池 → 应用耗电管理 → 允许。`,"Crashes are written to a file on this phone and nowhere else. Export bundles them with the runtime's log for someone you choose.":`崩溃只写到这台手机上的文件里,不上报任何地方。导出会把它们和运行时日志打成一个包,发给你选的人。`,"Export logs":`导出日志`,"clipboard, notifications, calendar, contacts, location, alarms, photos":`剪贴板、通知、日历、通讯录、位置、闹钟、照片`,Devices:`设备`,"Only this one":`只有这一台`,Idle:`空闲`,"A chat addressed to a device runs there; you see every step here and answer its approvals.":`对着某台设备的对话在那台设备上执行;每一步都在这里看得到,审批也在这里答。`,"nanoMuse Cloud":`nanoMuse Cloud`,"Free. One account for all your devices, and a model to start with.":`免费。一个账号连起你所有设备,还附带一个可以先用起来的模型。`,"Signed in":`已登录`,"Not signed in":`未登录`,"Not signed in.":`未登录。`,"Signed out":`已退出`,"{hint} · hub":`{hint} · 中继`,"{hint} · model and hub":`{hint} · 模型和中继`,"Signed in as {hint}. Your devices meet here; the Cloud model comes with a free allowance.":`已登录:{hint}。你的设备在这里互相看见;Cloud 模型自带一份免费额度。`,"Use the Cloud model":`使用 Cloud 模型`,"The Cloud model is in use.":`已切换到 Cloud 模型。`,"Sign out":`退出登录`,"Sign out of nanoMuse Cloud on this device? The hub and the Cloud model stop working here until you sign in again.":`在这台设备上退出 nanoMuse Cloud?重新登录之前,这里的中继和 Cloud 模型都会停用。`,"Mainland China phone number or e-mail":`中国大陆手机号或邮箱`,"138 0000 0000 or you@example.com":`138 0000 0000 或 you@example.com`,"One free account. It is what lets your phone and computers work as one and brings a model to start with.":`一个免费账号。有了它,手机和电脑就是同一个 nanoMuse,还自带一个可以先用起来的模型。`,"It uses your phone and computers for you":`替你操作手机和电脑`,"The relay keeps an account id, a masked identifier, usage counts and your agent's name and look; what else, and what is yours to switch off, is in the":`中继保存账号 id、打码后的手机号或邮箱、用量计数和你的智能体的名字与形象;其余保存什么、哪些由你决定,见`,"privacy policy":`隐私政策`,"Free, open source, non-profit":`免费、开源、非营利`,"nanoMuse is a non-profit open-source community project, free forever.":`nanoMuse 是非营利的开源社区项目,永久免费。`,"Chat apps":`聊天入口`,"nanoMuse is a non-profit open-source community project, free forever. Sign in with a phone number or an e-mail and the model comes with a free allowance, paid by the developer; when it is gone, use your own key (Alibaba Cloud Bailian in mainland China, OpenRouter elsewhere). Nothing is sold; what the relay keeps is in the privacy policy, and Settings → Data controls is yours. Delete the account whenever you like.":`nanoMuse 是非营利的开源社区项目,永久免费。用手机号或邮箱登录,模型自带一份免费额度,费用由开发者承担;用完可以换自己的 key(中国大陆用阿里云百炼,海外用 OpenRouter)。数据不会出售;服务器保存什么写在隐私政策里,「设置 → 数据控制」由你决定。账号随时可以删除。`,"The people building it use it every day. Report a bug, ask for a feature, send a pull request: that is what moves the project. A star on GitHub helps others find it.":`做它的人每天都在用。欢迎反馈问题、提需求、贡献代码,项目就是这样往前走的。在 GitHub 点个 Star,能让更多人找到它。`,"Star on GitHub":`去 GitHub 点个 Star`,"Report a bug or ask for a feature":`反馈问题 / 提需求`,"Data controls":`数据控制`,"Help improve nanoMuse's AI models":`帮助改进 nanoMuse 的 AI 模型`,"While this is on, the text of your chats with the nanoMuse Cloud models (what you wrote, what it answered and the tools it chose to call) is kept on the relay to train the community's own open model. Not your memory or SOUL (the system prompt), not what tools returned, not pictures, and never next to who you are. Your own API key never passes through the relay.":`开启后,你与 nanoMuse Cloud 模型对话的文字(你写的、它回答的,以及它选择调用的工具)会保存在中继上,用来训练社区自己的开源模型。不保存你的记忆和 SOUL(系统提示)、工具返回的内容和图片,也不和你的身份放在一起。用自己的 API key 时,对话不经过中继。`,"New accounts start with it on.":`新账号默认开启。`,"New accounts start with it off.":`新账号默认关闭。`,"Sign in to nanoMuse Cloud to use it.":`登录 nanoMuse Cloud 后可用。`,"{n} turns kept so far.":`目前已保存 {n} 轮对话。`,"{n} turns kept so far":`已保存 {n} 轮对话`,"{n} turns":`{n} 轮`,"On. Turn it off here at any time.":`已开启。随时可以在这里关闭。`,"Off. Nothing more is kept.":`已关闭。之后的对话不再保存。`,"Delete the kept conversations":`删除已保存的对话`,"Removes every turn kept from this account, whether the switch is on or off now. Turning the switch off keeps what was kept until you delete it here.":`删除这个账号已保存的全部对话,不管开关现在是开是关。只关掉开关不会删除已保存的内容,要在这里删。`,"The kept conversations are removed from the server. This cannot be undone.":`已保存的对话会从服务器上删除,不可恢复。`,"{n} turns deleted":`已删除 {n} 轮`,"Set a password":`设一个密码`,"Your account is in. With a password, your phone and your other computers sign in at once, without waiting for a code. Optional; you can set one later under Account.":`账号已登录。设了密码,手机和其他电脑就能直接登录,不用再等验证码。可选,以后也可以在「账号」里设置。`,"At least 8 characters":`至少 8 个字符`,"Set the password":`设置密码`,"Not now":`以后再说`,"I already starred":`已经点过了`,"Free allowance":`免费额度`,"left of ¥{grant}":`剩余 / 共 ¥{grant}`,"¥{spent} used{usd}; the allowance does not reset.":`已用 ¥{spent}{usd};额度不按天重置。`,"No limit on this account.":`这个账号不限额度。`,"¥{allowance} to start, +¥{invite} for each friend you invite, and +¥{invite} for them; after that, your own key keeps the model going.":`起始 ¥{allowance},每邀请一位朋友你 +¥{invite}、对方也 +¥{invite};用完后换自己的 key 继续。`,"The free allowance is used up.":`免费额度已用完。`,"The image provider is busy right now; try again in a minute.":`生图服务现在有点忙,请过一分钟再试。`,"Nearly used up: ¥{left} of ¥{grant} left.":`额度快用完了:还剩 ¥{left}(共 ¥{grant})。`,"Invite a friend (+¥{invite} for each of you) or bring your own key; your sign-in keeps working either way.":`邀请一位新用户(你和对方各 +¥{invite}),或者换成自己的 key;无论哪种,登录都不受影响。`,"See the ways":`看看怎么办`,Dismiss:`关闭`,"Three ways on; your sign-in and your devices keep working whichever you pick.":`三个办法继续用;无论选哪个,登录和多设备功能都不受影响。`,"Use your own model key":`换成自己的 key`,"Alibaba Cloud Bailian is a good start: a new account comes with a free quota, set-up takes about two minutes, and one key covers chat, the hands, pictures and clips.":`推荐阿里云百炼:新用户有免费额度,开通约 2 分钟,一个 key 就能对话、动手、画图、生成视频。`,"Alibaba Cloud Bailian only signs up accounts from mainland China. Outside, OpenRouter is the easy way: one account, one key, pay as you go, for chat, the hands and pictures; clips need Bailian.":`海外用户用不了百炼,推荐 OpenRouter:一个账号一把 key,按量付费,对话、动手、画图都行;生成视频需要百炼。`,"Other providers and what each key covers":`其他服务商,以及各自的 key 能做什么`,"Get a key":`去申请 key`,"covers {list}":`支持{list}`,", ":`、`," or ":`或`,"Sign in with a plan you already pay for":`用你已经在付费的套餐登录`,"{plans}: the plan's models answer here, no key to make. ChatGPT works on every device, the others on the phones. A ChatGPT sign-in covers chat and the hands; pictures and clips still want a key.":`{plans}:套餐里的模型直接在这里回答,不用申请 key。ChatGPT 在每台设备上都可以,其他的在手机上。ChatGPT 登录只覆盖对话和动手;画图和生成视频仍需要一把 key。`,"Sign in with ChatGPT":`用 ChatGPT 登录`,"Or sign in with a ChatGPT plan":`或者用 ChatGPT 套餐登录`,"Covers chat and the hands. Pictures and clips are not part of it; those need a provider with image or video models.":`覆盖对话和动手。画图和生成视频不在其中,那两项需要一个有图像或视频模型的服务商。`,"OpenAI's terms cover using a ChatGPT plan inside OpenAI's own Codex; other apps have had this access cut off before (OpenCode, January 2026). If it stops working, an API key does.":`OpenAI 的条款只允许在它自家的 Codex 里使用 ChatGPT 套餐;其他应用曾被切断过这条路(OpenCode,2026 年 1 月)。哪天它不能用了,API key 仍然可以。`,"This nanoMuse has no sign-in route yet. In a terminal on this computer, run":`这个 nanoMuse 还没有登录接口。在这台电脑的终端里运行`,"and reload this page.":`然后刷新本页。`,"Signed in with {label}.":`已用 {label} 登录。`,"Use it for the chat":`让它来回答对话`,"Sign out of ChatGPT":`退出 ChatGPT`,"A page from OpenAI opened in a new tab. Sign in there; this card follows on its own.":`已在新标签页打开 OpenAI 的页面。在那里登录,这张卡片会自己跟上。`,"Open the page again":`再次打开页面`,"The sign-in did not finish in ten minutes. Try again.":`登录十分钟内没有完成。再试一次。`,"A page from OpenAI opened in a new tab. Sign in there; when the browser ends on an address that will not load, paste that address below.":`已在新标签页打开 OpenAI 的页面。在那里登录;浏览器最后停在一个打不开的地址时,把那个地址粘贴到下面。`,"The address the browser ended on":`浏览器最后停在的地址`,"Use this address":`用这个地址`,"This nanoMuse runs where the browser cannot reach port 1455, so it cannot pick the sign-in up by itself.":`这个 nanoMuse 运行的机器上,浏览器连不到 1455 端口,所以它没法自己接住登录结果。`,"That address belongs to another sign-in attempt. Open the page again and paste the address it ends on.":`这个地址属于另一次登录尝试。再次打开页面,粘贴它最后停在的地址。`,"ChatGPT answers the chat now.":`现在由 ChatGPT 回答对话。`,"Pictures need a provider with image models: {providers}.":`画图需要一个有图像模型的服务商:{providers}。`,"Clips need a provider with video models: {providers}.":`生成视频需要一个有视频模型的服务商:{providers}。`,"The hands need a model that sees pictures: {providers}.":`动手需要一个能看图的模型:{providers}。`,"Chat needs a model: {providers}.":`对话需要一个模型:{providers}。`,How:`怎么做`,"Automatic · {model}":`自动 · {model}`,"E-mail (or a mainland China phone number)":`邮箱(或中国大陆手机号)`,"Text-message codes reach mainland-China numbers only. Use an e-mail address instead.":`短信验证码只能发到中国大陆手机号。其他地区请用邮箱登录。`,"Step-by-step guide":`图文教程`,"Invite a friend: +¥{bonus} for you and +¥{bonus} for them, for each new person who signs up with your link.":`邀请朋友:每有一位新用户通过你的链接注册,你 +¥{bonus},对方也 +¥{bonus}。`,"Your invite link is under Account":`邀请链接在「账号」页`,"Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my link: {link}":`来和我一起用 nanoMuse,完全开源的个人智能体,免费使用。用我的链接注册:{link}`,"Invite code (optional)":`邀请码(选填)`,"A friend's code counts for a new account: you both get ¥{invite} more allowance.":`朋友的邀请码只对新账号有效:你和对方的额度各 +¥{invite}。`,"Have an invite code?":`有邀请码?`,"Invite a friend":`邀请朋友`,"Each new person who signs up with your code adds ¥{bonus} to your allowance, and ¥{bonus} to theirs. It never expires.":`每有一位新用户用你的邀请码注册,你的额度 +¥{bonus},对方也 +¥{bonus},不过期。`,"Your code":`邀请码`,Copied:`已复制`,"Share the link":`分享链接`,"Share the code":`分享邀请码`,"Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}":`来和我一起用 nanoMuse,完全开源的个人智能体,免费使用。用我的邀请码 {code} 注册`,"relay privacy policy (ask your relay operator)":`中继的隐私政策(请询问你的中继运营者)`,"Copy the link":`复制链接`,"Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my code {code}: {link}":`来和我一起用 nanoMuse,完全开源的个人智能体,免费使用。用我的邀请码 {code} 注册:{link}`,"{n} friends joined":`已邀请 {n} 位`,"¥{amount} added by invitations":`邀请带来 ¥{amount}`,"The code you received":`收到的验证码`,"Send again":`重新发送`,"Send me a code":`发送验证码`,"Sign in":`登录`,"Sign in and use its model":`登录并使用它的模型`,"Signed in to nanoMuse Cloud.":`已登录 nanoMuse Cloud。`,"Or bring your own key":`或者用你自己的密钥`,"This device":`这台设备`,"A name your other devices will see":`其他设备看到的名字`,"On the hub":`已连上中继`,"Reachable by your other devices while this runs.":`运行期间,你的其他设备可以找到它。`,"Sign in first.":`先登录。`,"My other devices may operate it":`允许我的其他设备操作它`,"They can run commands, read files and see its screen here; each step still goes through the Sentinel.":`其他设备可以在这里运行命令、读文件、看屏幕;每一步仍经 Sentinel 审核。`,Refused:`被拒绝`,"Connecting…":`连接中…`,"Connecting to the hub…":`正在连接中继…`,"The hub refused this device. Sign in again.":`中继拒绝了这台设备。请重新登录。`,"The hub is off on this device.":`这台设备没有开启中继。`,"Sign in above to see them.":`先在上面登录,就能看到。`,"{n} online":`{n} 台在线`,online:`在线`,Ask:`去问它`,"Ask one of your devices":`让某台设备去做`,"Every device signed in to your account is a Muse of its own, on the same conversations. Type @ and a device's name in the chat to hand it work; a computer lends its hands.":`登录你账号的每台设备都是一个自己的 Muse,看到的是同一批对话。在聊天里输入 @ 加设备名,就能把活交给它;电脑还能出借它的双手。`,"1 device":`1 台设备`,"{n} devices":`{n} 台设备`,"Other devices":`其他设备`,Online:`在线`,"Last seen {when}":`最后在线 {when}`,Tasks:`任务`,Commands:`命令`,Screenshot:`截屏`,"Open links":`打开链接`,Hands:`双手`,"More about {name}":`{name} 的更多操作`,"Forget this device":`移除这台设备`,"Type @{name} in the chat to hand it work.":`在聊天里输入 @{name},就能把活交给它。`,"Add a device":`添加一台设备`,"No other device yet":`还没有其他设备`,"Install nanoMuse on your phone, Mac or PC and sign in as {hint}; it appears here on its own.":`在手机、Mac 或 PC 上安装 nanoMuse,用 {hint} 登录,它就会自己出现在这里。`,"Install nanoMuse on your phone, Mac or PC and sign in with the same account; it appears here on its own.":`在手机、Mac 或 PC 上安装 nanoMuse,用同一个账号登录,它就会自己出现在这里。`,"Get the apps":`下载应用`,"Forget {name}? It can join again by signing in.":`忘掉 {name}?它重新登录后还可以加入。`,"on {device}":`在 {device} 上`,"from {device}":`来自 {device}`,"asked from {device}":`从 {device} 发来`,"{name} on {device} wants to":`{device} 上的 {name} 想要`,"Hands on this computer":`操作这台电脑`,"On the screen now":`正在操作屏幕`,"Nothing can drive this screen yet.":`目前没有能驱动这块屏幕的组件。`,'On a Mac, allow nanoMuse under System Settings → Privacy & Security → Accessibility and Screen Recording when macOS asks; without them clicks do nothing and the screenshot is black. The first action in each app asks you once ("Let {name} use Safari?") and the answer is kept under Permissions.':`Mac 上,macOS 弹窗询问时请在「系统设置 → 隐私与安全性」的「辅助功能」和「屏幕录制」里允许 nanoMuse;不允许的话点击没有反应,截图也是黑的。每个应用的第一步会先问你一次(「让 {name} 使用 Safari 吗?」),答复记在「权限」里。`,Where:`范围`,"One window":`单个窗口`,"Whole screen":`整个屏幕`,"Working in {app}'s window; the mouse stays yours.":`正在 {app} 的窗口里操作;鼠标仍归你用。`,"On the whole screen for now: {reason}":`暂时在整个屏幕上操作:{reason}`,"One window: the hands work inside the app they were given, send clicks and keys to it alone and leave your mouse alone. Whole screen: the system mouse, like other platforms. Auto picks one window as soon as an app is named.":`单个窗口:只在指定的应用里操作,点击和按键只发给它,不动你的鼠标。整个屏幕:用系统鼠标,和其他平台一样。自动:一旦指定了应用就用单个窗口。`,"It looks at a screenshot and clicks by position; paying, sending and deleting ask you first.":`它看截图、按位置点击;支付、发送、删除前都会先问你。`,Driver:`驱动`,Working:`工作中`,"Stop the hands":`停止操作`,"{name} is using this computer":`{name} 正在操作这台电脑`,"Hands done":`操作完成`,"Hands stopped":`操作已停止`,"{n} steps":`{n} 步`,Clicked:`点击`,"Clicked {label}":`点击了 {label}`,"Double-clicked":`双击`,"Double-clicked {label}":`双击了 {label}`,"Right-clicked":`右键点击`,"Right-clicked {label}":`右键点击了 {label}`,"Pointed at {label}":`指向了 {label}`,"Moved the mouse":`移动了鼠标`,"Typed {n} characters":`输入了 {n} 个字符`,"Pressed {keys}":`按下了 {keys}`,Scrolled:`滚动`,Dragged:`拖动`,"Opened {label}":`打开了 {label}`,"Opened an application":`打开了一个应用`,Waited:`等待`,"The dragon":`小龙`,"The face drawn for you":`为你画的形象`,"Draw four":`画四张`,send_email:`发邮件`,shell:`运行命令`,"E-mail, calendar, browser, MCP servers":`邮件、日历、浏览器、MCP 服务器`,"Rename this device":`给这台设备改名`,"Use my own API key or a plan I pay for":`用我自己的 API key,或我在付费的套餐`,"{first} first, then {others}":`首选 {first},其次 {others}`,"DeepSeek, Kimi, OpenAI, ChatGPT…":`DeepSeek、Kimi、OpenAI、ChatGPT…`,"OpenAI, ChatGPT, Gemini, DeepSeek…":`OpenAI、ChatGPT、Gemini、DeepSeek…`,"Which model answers?":`让哪个模型来回答?`,"Your account already brings one. You can add your own API key as well and switch at any time.":`你的账号已经自带了一个。也可以加上自己的 API key,随时切换。`,"DeepSeek for the chat and Qwen for the hands, with a free allowance per account paid by the developer. Nothing to configure.":`对话用 DeepSeek,动手用 Qwen;每个账号都有一份由开发者承担的免费额度,不用配置。`,"I have my own API key":`我有自己的 API key`,"Alibaba Cloud Bailian first (one key covers chat, the hands, pictures and clips), then DeepSeek, Kimi, Zhipu, OpenAI, OpenRouter and any OpenAI-compatible endpoint. The key stays on this device.":`首选阿里云百炼(一个 key 就能对话、动手、画图、生成视频),其次是 DeepSeek、Kimi、智谱、OpenAI、OpenRouter,以及任何兼容 OpenAI 接口的服务。key 只留在这台设备上。`,"OpenRouter first (one account, one key, pay as you go), then OpenAI, Anthropic, Gemini, DeepSeek and any OpenAI-compatible endpoint. The key stays on this device.":`首选 OpenRouter(一个账号一把 key,按量付费),其次是 OpenAI、Anthropic、Gemini、DeepSeek,以及任何兼容 OpenAI 接口的服务。key 只留在这台设备上。`,"A ChatGPT plan signs in from Connections and answers the chat and the hands. Pictures and clips still want a key.":`ChatGPT 套餐可在「连接」里登录,回答对话并驱动双手。画图和生成视频仍需要一把 key。`,"3D toy (Muse)":`3D 玩偶(Muse)`,Flat:`扁平`,"3D clay":`3D 黏土`,Watercolour:`水彩`,Pixel:`像素`,Line:`线条`,Sticker:`贴纸`,Draw:`画`,"Making pictures":`生成图片`,"Making clips":`生成视频`,"Portraits of your Muse.":`形象的肖像。`,"Short clips of your Muse.":`形象的短视频。`,"The chat model's provider":`对话模型的服务商`,"Your key":`你的 key`,"Add a provider":`添加服务商`,"Other OpenAI-compatible endpoint":`其他 OpenAI 兼容接口`,"The endpoint that serves /images/generations, for example https://host/v1.":`提供 /images/generations 的接口,例如 https://host/v1。`,"Stored encrypted in the vault as {name}.":`以 {name} 加密存储在保险库中。`,"Uses the chat model's key.":`用对话模型的 key。`,"Your phone can now operate this computer: run commands, fetch files, hand over whole tasks. Each risky step still asks here first.":`你的手机现在可以操作这台电脑了:跑命令、取文件、把整件事交给它。有风险的每一步仍会先在这里问你。`,"Picture model":`图片模型`,"Clip model":`短片模型`,"Other model…":`其他模型…`,"More models on your account":`账号可用的更多模型`,"Search models":`搜索模型`,"No model matches":`没有匹配的模型`,"reads pictures":`能看图`,"Type a model id…":`手动输入模型 id…`,"Pick from the list":`从列表里选`,"{n} models your account may use: the menu first, then everything else the provider lists under the Cloud key. Pick one; the ones beyond the menu are priced as the dearest menu model of their kind.":`账号可用的模型共 {n} 个:先是菜单,然后是服务商在 Cloud 这把 key 下列出的其他模型。直接选就行;菜单之外的按同类里最贵的菜单模型计费。`,"Your account may name any model the provider has, not only these: type its id (a chat model here, a picture or clip model below) and it goes through as typed.":`你的账号可以使用服务商的任意模型,不限于这几个:直接输入模型 id(对话模型填在这里,图片或短片模型填在下面),会原样转发。`,Automatic:`自动`,"{n} pictures":`{n} 张图`,"{n} pictures and {c} clips":`{n} 张图加 {c} 段短片`,"{what}, made with your own key at your provider's prices.":`{what},用你自己的 key 按服务商价格计费。`,"The cost could not be checked: {error}":`费用暂时查不到:{error}`,"Checking the cost…":`正在查费用…`,"About ¥{cny} for {what}; your account has no limit.":`{what}约 ¥{cny};你的账号没有额度限制。`,"About ¥{cny} for {what}, more than the ¥{left} left in your allowance.":`{what}约 ¥{cny},超过了额度里剩下的 ¥{left}。`,"About ¥{cny} for {what}; ¥{left} left in your allowance.":`{what}约 ¥{cny};额度还剩 ¥{left}。`,"A new look for {name}":`给{name}换个形象`,"Drawing four to choose from…":`正在画四个供你挑选…`,"Pick one: tap it, or say which":`选一个:点它,或者说第几个`,"Drawing the poses…":`正在画各种姿态…`,"The new look is on; making the clips…":`新形象已经换上,正在做短片…`,"The new look is on":`新形象已经换上`,"Not this time":`这次先不换`,"That did not work":`没成功`,"Candidate {n}":`第 {n} 个`,Redraw:`重画一组`,"Idle, working, waiting, happy, sorry: the face follows what {name} is doing.":`待机、工作、等待、开心、抱歉:形象会跟着 {name} 正在做的事变化。`,"Four more pictures from the one you picked; this takes a minute or two.":`根据你选的这张再画四张;需要一两分钟。`,"Some poses could not be drawn and show the idle picture instead.":`有几个姿态没画出来,先用待机图代替。`,"The face is already on; four short clips are being made from the poses, a few minutes.":`形象已经换上了;正在根据这几张姿态图做四段短片,需要几分钟。`,"Some clips could not be made; those moods stay still.":`有几段短片没做出来,这些状态就用静态图。`,"The new look is on; the clips are being made.":`新形象已经换上;短片正在做。`,"The new look is on.":`新形象已经换上。`,"Not this time.":`这次先不换。`,"New look: {description}. Say what to change any time, or pick another under Settings.":`新形象:{description}。想改随时说,也可以在「设置」里换别的。`,"Join the hub":`加入中继`,"This chat goes to {device}.":`这个对话发给 {device}。`,"Whatever you ask here, the {name} on {device} does where it is: its shell, its files, its screen. Every step shows up here, and anything that needs an approval asks you here.":`在这里说的每件事,都由 {device} 上的 {name} 在它那边完成:它的命令行、它的文件、它的屏幕。每一步都显示在这里,需要批准的事也在这里问你。`,"What is on your screen right now?":`你屏幕上现在是什么?`,"Which folder are you in, and what is in it?":`你在哪个目录,里面有什么?`,"Check for updates and tell me what needs a restart":`检查一下更新,告诉我哪些需要重启`,Account:`账号`,"Account deleted.":`账号已删除。`,"Account id":`账号 ID`,"Sign in to nanoMuse Cloud":`登录 nanoMuse Cloud`,"Sign in to {name}":`登录 {name}`,"nanoMuse Cloud · one account for all your devices":`nanoMuse Cloud · 一个账号,所有设备`,"Every device of yours, one nanoMuse":`你的每台设备,同一个 nanoMuse`,"A model with a free allowance, or your own key":`自带免费额度的模型,或用你自己的 key`,"Keys in your vault; nothing you say is kept on the relay":`key 留在你的保险库里;你说的内容不会留在中继上`,"Free. One account for all your devices, with a model and ¥{allowance} of use to start; a code the first time, a password afterwards if you like.":`免费。一个账号连起你所有设备,自带模型和 ¥{allowance} 的起始额度;第一次用验证码登录,之后可以设密码。`,"With a code":`验证码登录`,"With a password":`密码登录`,"No password yet? Use a code first; set one under Account.":`还没有密码?先用验证码登录,再到「账号」里设置。`,"E-mail":`邮箱`,"Mobile number":`手机号`,"since {date}":`{date} 起`,Unlimited:`不限量`,"All time":`累计`,Usage:`用量`,"Every kind of use is counted, not just the chat model.":`每一类用量都会统计,不只是对话模型。`,Pictures:`图片`,Clips:`视频`,Calls:`通话`,"Nothing yet":`还没有`,"{n} answers · {tokens} tokens":`{n} 次回答 · {tokens} tokens`,"{n} requests · {tokens} tokens":`{n} 次请求 · {tokens} tokens`,"{n} requests":`{n} 次请求`,"By model, all time":`按模型(累计)`,"A password is set":`已设置密码`,"No password yet":`还没有密码`,"Sign in with it on a new device, or with a code as before.":`新设备可以用密码登录,也可以像以前一样用验证码。`,"Optional. With one, a new device can sign in without waiting for a code.":`可选。设置后,新设备不用等验证码就能登录。`,Change:`修改`,Set:`设置`,"Current password":`当前密码`,"New password (8 characters or more)":`新密码(至少 8 位)`,"Once more":`再输一次`,"The two do not match.":`两次输入不一致。`,"Change password":`修改密码`,"Set password":`设置密码`,"Password set.":`密码已设置。`,"Password changed.":`密码已修改。`,"Password removed.":`密码已移除。`,"Signed in on":`已登录的设备`,"Unnamed device":`未命名设备`,"This one":`本机`,code:`验证码`,password:`密码`,"used {when}":`{when}用过`,"Sign out {device}? It will need to sign in again.":`让 {device} 退出登录?它需要重新登录。`,"Recent activity":`最近动态`,"Show all {n}":`全部 {n} 条`,"Show less":`收起`,"Account created":`账号已创建`,"Signed in with a code":`用验证码登录`,"Signed in with the password":`用密码登录`,"A sign-in attempt failed":`一次登录失败`,"Signed out everywhere":`已在所有设备退出`,"Password removed":`密码已移除`,"Password changed":`密码已修改`,"Password set":`密码已设置`,"A request was refused: the allowance is used up":`一次请求被拒绝:免费额度已用完`,"Call ended":`通话结束`,"The model provider returned an error":`模型服务返回了错误`,"Allowance added":`额度已增加`,"Allowance adjusted":`额度已调整`,"Signed up with a friend's code":`用朋友的邀请码注册`,"A friend signed up with your code":`一位朋友用你的邀请码注册`,"An invite code was not recognised":`邀请码无法识别`,"Data controls: on for new accounts":`数据控制:新账号默认开启`,"Data controls: help improve turned on":`数据控制:已开启「帮助改进」`,"Data controls: help improve turned off":`数据控制:已关闭「帮助改进」`,"Data controls: kept conversations deleted":`数据控制:已删除保存的对话`,"Agent's name and look saved":`已保存智能体的名字与形象`,"Agent's name and look cleared":`已清除智能体的名字与形象`,"Sign out everywhere":`在所有设备退出`,"Sign out on every device, including this one?":`在所有设备退出登录,包括这一台?`,"Delete the account? The relay forgets your identifier, your devices and your usage. This cannot be undone.":`删除账号?中继会忘掉你的标识、设备和用量记录,且无法恢复。`,"Really delete it?":`真的要删除吗?`,"Signed out.":`已退出登录。`,"Delete the account":`删除账号`,"The Cloud model is in use; after signing out, pick another under Connections.":`当前使用的是 Cloud 模型;退出后请到「连接」里换一个。`,"{n} min ago":`{n} 分钟前`,"{n} h ago":`{n} 小时前`,"{n} d ago":`{n} 天前`,"Use the nanoMuse Cloud model":`使用 nanoMuse Cloud 模型`,"Signed in as {hint}. A free allowance, nothing to paste. Recommended to start.":`已登录 {hint}。自带免费额度,无需粘贴任何 key,推荐先用它。`,"Your account brings a model with a free allowance, nothing to set up. Or paste a key of your own; it is stored encrypted in the vault on the server, never shown to the model, and nothing you say passes through the relay.":`你的账号自带一个有免费额度的模型,不用再设置什么。也可以粘贴自己的 key:它加密存在服务端的保险库里,不会给模型看到,你说的内容也不经过中继。`,"Voice input":`语音输入`,"Stop voice input":`停止语音输入`,"The microphone is not allowed. Allow it in the browser and try again.":`没有拿到麦克风权限。在浏览器里允许后再试一次。`,"Voice input stopped ({code}).":`语音输入中断了({code})。`,"said on a call":`通话中说的`,"Coding agents":`编程助手`,"Cursor, Codex and Claude Code: see what they are doing, and tell them things from anywhere.":`Cursor、Codex 和 Claude Code:随时看它们在做什么,在任何地方给它们发消息。`,Coding:`编程`,"This computer":`这台电脑`,"This one · {name}":`本机 · {name}`,installed:`已安装`,"chats only":`仅记录`,"not found":`未找到`,"{agent} chats":`{agent} 的对话`,"Recent chats":`最近的对话`,"New chat":`新对话`,"No coding agent found on this computer. Install Cursor, Codex or Claude Code and their chats appear here.":`这台电脑上没有找到编程助手。安装 Cursor、Codex 或 Claude Code 后,它们的对话会出现在这里。`,"No chats yet.":`还没有对话。`,"Untitled chat":`未命名对话`,"{n} messages":`{n} 条消息`,"Sent through nanoMuse":`经 nanoMuse 发送的`,stopped:`已停止`,"Sent to {agent}.":`已发给 {agent}。`,"{agent} is working…":`{agent} 正在处理…`,"Tell {agent}…":`对 {agent} 说…`,"Nothing readable in this chat yet.":`这个对话里还没有可读的内容。`,"via nanoMuse":`经 nanoMuse`,"Continued as a new chat: the agent could not reopen the old one.":`原来的对话打不开了,已经另起一个新对话接着聊。`,"Made in the IDE: your message starts a new chat in the same workspace, with the last exchange quoted.":`这是 IDE 里的对话:你的消息会在同一工作区开一个新对话,并带上上一轮的内容。`,"Continue in a new chat…":`在新对话里接着说…`,"starting…":`启动中…`,"Send to {agent}":`发给 {agent}`,"Project folder":`项目目录`,"What to do":`要做什么`,"Add tests for the parser and run them":`给解析器补上测试并跑一遍`,"The agent runs on that computer with its own permissions; nanoMuse only carries the message and brings the answer back.":`助手在那台电脑上以它自己的权限运行;nanoMuse 只负责传话和带回结果。`,"The file is larger than {n} MB.":`文件超过了 {n} MB。`,"empty file":`文件是空的`,"thread is busy":`这个对话正忙`,"the main chat cannot be deleted":`主对话不能删除`,"no pending approval with that id":`这张卡片已经不在等待中`,"the device did not take the answer":`那台设备没有接受这个回答`,"already finished":`已经结束了`,"already cancelled":`已经取消了`,"memory is disabled":`记忆已关闭`,"memory is off":`记忆已关闭`,"recall by meaning is off":`按含义回忆已关闭`,"nothing proposed":`没有待处理的提议`,"that frame is gone":`这一帧已经不在了`,"no such file":`没有这个文件`,"no such post":`没有这条动态`,"no such skill of yours (built-in skills are switched off, not removed)":`没有这个你自己的技能(内置技能只能关闭,不能删除)`,"the file is larger than 16 MB":`文件超过了 16 MB`,"body too large (64 KB max)":`内容太大(最多 64 KB)`,"fill in the servers, the address and the password first":`先填好服务器、地址和密码`,"add a calendar link first":`先添加一个日历链接`,"signed out":`未登录`,"Sign in to nanoMuse Cloud first.":`请先登录 nanoMuse Cloud。`,"Enter the password.":`请输入密码。`,"The relay returned no key.":`中继没有返回 key。`,Details:`详情`,"Hide details":`收起详情`,"Today's share of the free allowance is used up; it comes back tomorrow. Your own model key under Connections keeps you going now.":`今天的免费额度用完了,明天恢复。在「连接」里填上你自己的模型 key,现在就能继续。`,"The free allowance is used up. Invite a friend (the relay adds to both your allowances) or add your own model key under Connections; your sign-in and your devices keep working either way.":`免费额度已用完。邀请一位新用户(你和对方的额度都会增加),或者在「连接」里填上自己的模型 key;无论选哪个,登录和多设备功能都不受影响。`,"The account's free allowance is used up. Add your own model key under Connections to keep going.":`这个账号的免费额度已经用完。在「连接」里填上你自己的模型 key 就能继续。`,"Too many requests at once; wait a moment and try again.":`请求太密集了,稍等一下再试。`,"The model provider is busy; try again in a moment.":`模型服务商正忙,稍后再试。`,"The model provider's content check declined this request; try different words.":`模型服务商的内容审核没有放行这次请求,换个说法再试。`,"The relay's model provider refused its key; the operator has been told. Your own key under Connections works meanwhile.":`中继的模型服务商拒绝了它的 key,运营者已收到通知。这期间可以在「连接」里用你自己的 key。`,"The model provider does not know this model right now; pick another under Connections.":`模型服务商暂时不认识这个模型,在「连接」里换一个。`,"nanoMuse Cloud has no model key configured; the operator has been told.":`nanoMuse Cloud 还没有配置模型 key,运营者已收到通知。`,"This nanoMuse Cloud account is disabled.":`这个 nanoMuse Cloud 账号已被停用。`,"The nanoMuse Cloud sign-in is no longer valid; sign in again under Account.":`nanoMuse Cloud 的登录已失效,请在「账号」里重新登录。`,"That model is not offered by nanoMuse Cloud; pick another under Connections.":`nanoMuse Cloud 不提供这个模型,在「连接」里换一个。`,"That message is too large for the model's window. Shorten it, leave out some attachments, or start a new chat.":`这条消息超出了模型的上下文窗口。精简一下、去掉部分附件,或者开一个新对话。`,"This relay takes new accounts by invitation only; sign in with an invite code under Account.":`这个中继只接受邀请注册;在「账号」里用邀请码登录。`,"Too many turns are running on this account at once; wait for one to finish and try again.":`这个账号同时进行的对话太多了,等一轮结束后再试。`,"New sign-ups are paused on this relay for now; existing accounts keep working. Try again later.":`这个中继暂时停止了新注册,已有账号照常使用。稍后再试。`,"nanoMuse Cloud is paused by its operator for now; your sign-in and your data are kept. Try again later.":`nanoMuse Cloud 被运营者暂时停用了,你的登录和数据都保留着。稍后再试。`,"Conversation sync is paused on this relay for now; what is stored is kept and your devices keep working on their own.":`这个中继暂时停止了对话同步,已存的内容保留着,各设备各自继续使用。`,"The device hub is paused on this relay for now; each device keeps working on its own.":`这个中继暂时停止了设备互联,各设备各自继续使用。`,"The free allowance is paused on this relay for now, not used up. Your own model key under Connections keeps you going; your sign-in, your devices and what is left stay as they are.":`这个中继暂时停发了免费额度,不是用完了。在「连接」里填上你自己的模型 key 就能继续;你的登录、设备和剩余额度都保持原样。`,"This demo Muse has used its share of the model for this visit. Start over for a new one, or install nanoMuse and use your own key.":`这个演示 Muse 这一次的模型额度用完了。点「重新开始」再来一个,或者安装 nanoMuse,用你自己的 key。`,"The showcase has used today's share of the model; it comes back tomorrow. Install nanoMuse and use your own key to keep going.":`在线体验今天的模型额度用完了,明天会恢复。安装 nanoMuse,用你自己的 key 可以继续。`,"The model provider is having trouble; try again in a moment.":`模型服务商出了点问题,稍后再试。`,"The model provider refused the API key. Check it under Connections.":`模型服务商拒绝了这个 API key,去「连接」里检查一下。`,"The endpoint does not know this model. Pick another under Connections.":`这个接口不认识这个模型,在「连接」里换一个。`,"The model provider is rate-limiting this key; wait a moment and try again.":`模型服务商正在限制这个 key 的请求频率,稍等一下再试。`,"The model took too long to answer. Try again.":`模型回答超时了,再试一次。`,"Could not reach the model provider. Check the connection and try again.":`连不上模型服务商,检查一下网络再试。`,"This conversation is too long for the model. Start a new one, or pick a model with a larger context.":`这段对话对模型来说太长了。开一个新对话,或者换一个上下文更大的模型。`,"Enter a phone number or an e-mail address.":`请输入手机号或邮箱。`,"Enter a mainland phone number or an e-mail address":`请输入中国大陆手机号或邮箱`,"Enter a mainland phone number or an e-mail address.":`请输入中国大陆手机号或邮箱。`,"Codes reach mainland China numbers only for now; elsewhere, sign in with an e-mail address.":`短信验证码目前只支持中国大陆手机号,海外用户请用邮箱登录。`,"That code is not right.":`验证码不对。`,"That code has expired; ask for a new one.":`验证码已过期,重新获取一个。`,"Too many codes were sent; wait a few minutes.":`验证码发得太频繁了,几分钟后再试。`,"This relay is private; that address is not on its list.":`这个中继是私有的,这个邮箱不在它的名单上。`,"The code could not be sent; try again in a moment.":`验证码没能发出,稍后再试。`,"Sign in again.":`请重新登录。`,"This account has used its tokens.":`这个账号的额度已经用完。`,"This account is disabled.":`这个账号已被停用。`,"That model is not offered here.":`这里不提供这个模型。`,"Too many requests; slow down a little.":`请求太频繁了,稍微慢一点。`,"The free allowance is used up. Invite a friend (the relay adds to both your allowances) or add your own model key; your sign-in and your devices keep working either way.":`免费额度已用完。邀请一位新用户(你和对方的额度都会增加),或者换成自己的 key;无论选哪个,登录和多设备功能都不受影响。`,"Today's token quota is used up; it comes back tomorrow.":`今天的 token 配额用完了,明天恢复。`,"The model provider did not answer.":`模型服务商没有响应。`,"nanoMuse Cloud has no model key configured.":`nanoMuse Cloud 还没有配置模型 key。`,"nanoMuse Cloud cannot be reached.":`连不上 nanoMuse Cloud。`,"That address and password do not match.":`邮箱与密码不匹配。`,"This account has no password yet; sign in with a code and set one under Account.":`这个账号还没有设置密码:先用验证码登录,再在「账号」里设置。`,"Too many wrong passwords; wait a while or sign in with a code.":`密码错误次数太多,等一会儿再试,或者改用验证码登录。`,"Use at least 8 characters.":`至少 8 个字符。`,"Choose a stronger password.":`换一个更强的密码。`,"That is not the current password.":`当前密码不正确。`,"Enter the current password.":`请输入当前密码。`,"That sign-in is already gone.":`那个登录已经不存在了。`,"Cannot reach your nanoMuse right now.":`现在连不上你的 nanoMuse。`,"Your nanoMuse hit a problem; try again in a moment.":`你的 nanoMuse 出了点问题,稍后再试。`,"Your nanoMuse could not do that.":`你的 nanoMuse 没能完成这个操作。`,"Could not reach your nanoMuse.":`连不上你的 nanoMuse。`,"Connecting to your nanoMuse…":`正在连接你的 nanoMuse…`,"Try again":`再试一次`,"Could not load.":`没能加载出来。`,"Use /{name}":`使用 /{name}`,Retry:`重试`,"Could not load the conversation. Open the chat again to retry.":`对话没能加载出来,重新打开这个对话再试。`,"Could not load the connections.":`连接信息没能加载出来。`,"Your account":`你的账号`,"your account's model, with a free allowance":`账号自带的模型,带免费额度`,"on this machine, no key":`跑在本机,不用 key`,"vLLM, LM Studio, a gateway, anything with /v1":`vLLM、LM Studio、网关,任何带 /v1 的服务`,"Your account's model":`账号自带的模型`,"Your account's model, or a key of your own.":`用账号自带的模型,或者你自己的 key。`,"Your account's model, with its free allowance. Keep it, or switch to a key of your own here.":`账号自带的模型,带免费额度。可以保留,也可以在这里换成你自己的 key。`,"Avatar studio":`形象工坊`,"Avatar studio…":`形象工坊…`,"Draw a look with your image model; it moves with what it is doing.":`用你的图像模型画一个形象;它会随着工作状态动起来。`,More:`更多`,"Name & style":`名字与风格`,"Image model":`图像模型`,"Redraw the poses":`重画状态`,"Back to the built-in look":`换回内置形象`,"Back to the built-in look.":`已换回内置形象。`,"Something went wrong":`出了点问题`,"Drawing the poses… {done}/{total}":`正在画各个状态… {done}/{total}`,"The new look is on; making the clips… {done}/{total}":`新形象已经用上;正在生成动态片段… {done}/{total}`,"All five poses are in.":`五个状态都画好了。`,"{done} of {total} poses are in; the rest show the main picture.":`画好了 {total} 个状态里的 {done} 个,其余先用主图。`,"Describe a new look":`描述一个新形象`,"One sentence is enough: what it is, what it wears, its air. Pick a style and your image model draws four to choose from.":`一句话就够:它是什么、穿什么、什么气质。选一种风格,你的图像模型会画四张让你挑。`,"a round, custard-yellow baby dragon with small orange horns and folded little wings":`一只圆滚滚的奶黄色小龙,橙色小角,折着小翅膀`,"Drawing…":`正在画…`,"Draw four more":`再画四张`,"Drawn by {provider} · {model}":`由 {provider} · {model} 绘制`,"default model":`默认模型`,"your provider":`你的服务商`,"Pick one":`挑一张`,"Draw four more any time; the old ones stay until you do.":`随时可以再画四张;没重画之前旧的会留着。`,"Use this one":`就用这个`,"Next, its poses are drawn from it in the background: working, waiting for you, done, something wrong.":`接下来会在后台以它为底画出各个状态:干活、等你、搞定、出错。`,"Keep the current look":`先不换了`,"Nothing came back.":`什么也没生成出来。`,"The pictures stay in this computer's workspace under avatar/. Each new look asks your provider for eight pictures (four to choose from and four poses) and four short clips where there is a video model.":`图片存在这台电脑的工作区 avatar/ 下。每换一次形象会向你的服务商要八张图(四张供你挑选,四个状态),有视频模型时还会生成四段短动画。`,"Or just say it in the chat: “change your avatar to a corgi”.":`也可以直接在对话里说:“把虚拟形象改成一只柯基”。`,"Change your avatar to ":`把虚拟形象改成`,"Alibaba Cloud Bailian":`阿里云百炼`,Zhipu:`智谱`,"Volcano Ark":`火山方舟`,"this computer":`这台电脑`,"Change model":`更换`,Search:`搜索`,"Search chats":`搜索对话`,"Side chats":`旁聊`,"Start a side chat":`发起旁聊`,"Side chats are an optional way to keep conversations organised by topic.":`旁聊是按主题整理对话的可选方式。`,Keep:`保留`,"No chats match.":`没有匹配的对话。`,"A side chat keeps one topic apart from the rest.":`一个旁聊,把一个话题和其他的分开。`,"Report a bug":`报告问题`,"Developer tools":`开发者工具`,Developer:`开发者`,"The Coding screen (Cursor, Codex and the other coding agents on this computer) in the sidebar, and the runtime's address below. This device only.":`在侧栏显示「编程」(这台电脑上的 Cursor、Codex 等编程智能体),并在下面显示运行时的地址。仅此设备。`,"Runtime:":`运行时:`,"the token is in the server_token file in the data folder":`令牌在数据目录的 server_token 文件里`,"The command line and the API":`命令行与 API`,"nanoMuse on DeepSeek Harness":`nanoMuse 的 DeepSeek Harness 版`,"Desktop app":`桌面应用`,"Start with the computer":`开机自动启动`,"Opens in the tray at sign-in; the agent's goals and check-ins run from then on.":`登录系统后在托盘里启动;从那时起,它的目标和定时检查就开始跑了。`,"In the tray":`托盘图标`,"The icon by the clock shows what the hands are doing and has Stop; closing the window keeps the agent running.":`时钟旁的图标显示它正在操作什么,并带「停止」;关掉窗口后它仍在后台运行。`,Shortcuts:`快捷键`,"Quick chat":`快速对话`,"Show the window":`打开窗口`,"Feed settings":`动态设置`,"About the feed":`动态版块说明`,"Your feed is driven by the instruction below. Any edit you make here applies to every post from now on.":`动态由下面这句话来定。在这里改动它,今后的每一条帖子都会照着来。`,"Build me a feed about what I care about. Keep it short and direct, easy to skim, no clickbait.":`给我做一个关于我关心的事的动态。短一点、直接一点,扫一眼就能看完,别搞标题党。`,"Got it":`知道了`,"Write it now":`现在写一版`,"Nothing in the feed yet":`动态里还没有内容`,"As we get to know each other, new posts will show up here. Every day at {time} I read what I remember about you (your memory files, the last week of diary, your goals) and write a few short posts.":`随着我们相互了解,新的帖子会显示在这里。每天 {time},我会读一遍记得的关于你的东西(记忆文件、最近一周的日记、你的目标),然后写几条简短的帖子。`,"Steer it with one sentence":`一句话就能调整`,"Tap the sliders at the top right to tell me what you want more of, switch the daily routine off, or have me write the first day now.":`点右上角的滑杆,告诉我你想多看什么、关掉每天的例程,或者让我现在就写第一天。`,"Short posts your agent writes for you from what it remembers: your memory files, the last week of diary, your goals. The sentence below steers every post from now on; edit it any time.":`这里是你的智能体为你写的短帖子,取材于它记得的东西:记忆文件、最近一周的日记、你的目标。下面这句话决定今后每一条帖子的方向,随时可以改。`,"Write it every day":`每天自动写`,"Time of day":`时间`,"Add a model first; the feed is written by your agent.":`先添加一个模型,动态由你的智能体来写。`,Discuss:`讨论`,"About this post":`这条帖子的信息`,"Delete this post":`删除这条`,Written:`写于`,Note:`随记`,Files:`文件`,Fun:`休闲`,"About this post from my feed:":`聊聊我动态里的这一条:`,"Post type":`类型`,morning:`上午`,afternoon:`下午`,evening:`晚上`,"{weekday} {part}":`{weekday}{part}`,"Starts a conversation":`会开始一段对话`,"Send to chat":`发到聊天`,"Nothing tracked yet":`尚未追踪任何内容`,"Create a goal":`创建目标`,"Pick a category and tell me the goal you have in mind. I'll shape a plan with you and keep improving it as you go.":`选一个类别,告诉我你心里的目标。我会和你一起定出计划,并在推进过程中不断调整。`,"Create a {category} goal":`创建{category}目标`,"First, we'll shape the goal together in the chat. I'll ask a few questions so I understand exactly what you're after.":`首先,我们将在聊天中一起完善目标。我会问几个问题,准确了解你的目标。`,"Once it's set, I'll track your progress here.":`目标设置后,我会在这里追踪你的进度。`,"I'd like to create a {category} goal. Ask me a few short questions, one at a time (what exactly I want, why and by when, how often to check in), then create it with concrete steps using the goals tool.":`我想创建一个{category}目标。请一次问我一个简短的问题(我具体想达成什么、为什么以及什么时候、多久检查一次),然后用 goals 工具把它建成带具体步骤的目标。`,"Mark as done":`标记为已完成`,"Mark as not done":`标记为未完成`,"Plan my week":`规划我的一周`,"Tell me what's on your plate and I'll turn it into a realistic plan with the important things first.":`告诉我你手头有什么,我把它排成一份切实可行的计划,重要的事放前面。`,"Help me plan my week. Ask me what I need to get done, then propose a schedule.":`帮我规划这一周。先问我有哪些事要做,然后给出一份日程安排。`,"Research & compare options":`调研并比较选项`,"Laptops, flights, insurance, a new phone plan: I'll gather the facts and compare them for you.":`笔记本、机票、保险、新的手机套餐,我帮你收集事实并逐项比较。`,"I need to make a purchase decision. Ask me what I'm choosing between, then research and compare the options.":`我想买个东西,拿不定主意。先问我在哪几个里面挑,然后帮我查一查、比一比。`,"Set up a long-term goal":`建立一个长期目标`,"Share a goal (learn a language, run a 10k, save for a trip) and I'll break it into steps and keep track.":`说一个目标(学一门语言、跑完 10 公里、攒一次旅行的钱),我把它拆成步骤并持续跟进。`,"I want to set up a long-term goal. Ask me about it, then create a plan with concrete steps and track it.":`我想建立一个长期目标。先问问我,然后制定一份带具体步骤的计划并跟进。`,"Tell me about yourself":`介绍一下你自己`,"The more I know about your preferences, routines and constraints, the more useful I get. I'll remember what matters.":`我越了解你的偏好、作息和限制,就越能帮上忙。重要的事我会记住。`,"Ask me a few questions about myself so you can help me better, and remember the answers.":`问我几个关于我的问题,好更了解我,然后把答案记住。`,"Build a quick tracker":`做一个简单的追踪表`,"Spending, habits, workouts, reading: I can write a small script or document to track it for you.":`开支、习惯、锻炼、阅读,我可以写一个小脚本或文档帮你记录。`,"Build me a simple tracker. Ask me what I want to track and how, then create it in the workspace.":`帮我做一个简单的追踪表。先问我想记录什么、怎么记,然后在工作区里建好。`,Artifacts:`构件`,Media:`影音内容`,"Nothing created yet":`还没有创建任何内容`,"Documents, tables and pages I write for you will show up here.":`我为你写的文档、表格和页面会显示在这里。`,"No media yet":`还没有影音内容`,"Photos, screenshots and recordings I produce will show up here.":`我生成的图片、截图和录音会显示在这里。`,"No model yet":`还没有模型`,"Add a provider and pick its models":`添加一个服务商并选择模型`,"Image & video models":`图像与视频模型`,"Name & personality":`名字与人格`,"About nanoMuse":`关于 nanoMuse`,"Privacy policy":`隐私政策`,Feedback:`反馈问题`,none:`无`,"Change avatar":`更换虚拟形象`,"Edit name":`编辑名称`,Daily:`每日`,"Soul & memory":`SOUL 与记忆`,"Handle with care":`请谨慎访问`,"This device's name":`这台设备的名字`,"Sync conversations between my devices":`在我的设备之间同步对话`,"The text of your chats is kept on nanoMuse Cloud so every device shows the same conversations. Files and images stay on the device they were made on.":`对话文字会保存在 nanoMuse Cloud,让每台设备看到同样的对话。文件和图片只留在产生它们的设备上。`,"Paused: sign in again to continue.":`已暂停:重新登录后继续。`,"{c} chats, {m} messages kept so far.":`目前保存了 {c} 个对话、{m} 条消息。`,"Delete synced conversations":`删除已同步的对话`,"{n} chats":`{n} 个对话`,"Removes what nanoMuse Cloud stores for this account; the chats on each device stay, and the switch stays on. Deleting a chat on one device deletes it on all of them.":`删除 nanoMuse Cloud 为这个账号保存的内容;每台设备上的对话保留,开关也保持打开。在一台设备上删除某个对话,会在所有设备上一起删除。`,"Off deletes the synced conversations from nanoMuse Cloud. The chats on each device stay.":`关闭后会从 nanoMuse Cloud 删除已同步的对话。每台设备上的对话保留。`,"On. Your devices show the same conversations from now on.":`已打开。从现在起,你的设备会看到同样的对话。`,"Off. Nothing is kept on nanoMuse Cloud any more.":`已关闭。nanoMuse Cloud 不再保存任何对话。`,"The synced conversations are removed from nanoMuse Cloud. The chats on each device stay. This cannot be undone.":`已同步的对话将从 nanoMuse Cloud 删除。每台设备上的对话保留。此操作无法撤销。`,"Synced conversations deleted.":`已删除已同步的对话。`,"Also sync side chats":`同时同步旁聊`,"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.":`关:旁聊只留在本机。开:本机的旁聊上传到账号,其他设备的旁聊也会同步过来。`,"On. This device's side chats go to the account; the other devices' are on their way.":`已打开。本机的旁聊会上传到账号,其他设备的旁聊正在同步过来。`,"Off. Side chats stay on this device from now on.":`已关闭。从现在起,旁聊只留在本机。`,"{device} is working…":`{device} 正在处理…`,"From {device}":`来自 {device}`,"to {device}":`发给 {device}`,"This goes to {device}.":`这条会发给 {device}。`,"Runs this there and reports back":`在那台设备上执行并汇报结果`,Offline:`离线`,"There is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you.":`不用填表:有了模型之后,它会在聊天里介绍自己,并问你希望怎么称呼你。`,"A model, then the chat. A minute.":`先接一个模型,再去聊天。一分钟。`,"Open the chat. It introduces itself and asks your name.":`打开聊天。它会介绍自己,并问你的名字。`,"Pick a name for me, or type one":`给我挑一个名字,或者自己输入一个`,"Something else":`自己起一个`,"Proxy (optional)":`代理(可选)`,"Only this provider's requests go through it. nanoMuse Cloud never does.":`只有这个服务商的请求走代理;nanoMuse Cloud 的请求从不经过它。`,"Talk to your Muse from the chat apps you already use. Nothing needs a public address: each app keeps a long connection open.":`在你已经在用的聊天软件里和 Muse 对话。不需要公网地址:每个应用各自保持一条长连接。`,"Waiting for a yes":`等待通过`,"{name} wrote to the bot. Approve to let them talk to your Muse.":`{name} 给机器人发了消息。通过后对方就能和 Muse 对话。`,Approve:`通过`,"Code {code}":`配对码 {code}`,"Needs settings":`还缺设置`,"Not installed":`未安装`,Error:`出错`,Missing:`缺少`,"Switched on":`已开启`,"Install on the computer running nanoMuse, then switch it on again:":`在运行 nanoMuse 的电脑上安装,再重新开启:`,"Saved.":`已保存。`,Saving:`保存中`,"Set; leave blank to keep":`已设置,留空则保持不变`,"Clear the saved value":`清除已保存的值`,"In groups, reply":`在群里回复`,"only when @-mentioned":`只在被 @ 时`,"to every message":`每条消息`,"Always allowed (ids, one per line)":`免配对的 ID(每行一个)`,"People listed here never need a pairing code. A lone * lets anyone in; do that only on a bot nobody else can reach.":`列在这里的人不需要配对码。单独一个 * 表示放行所有人,只在别人找不到的机器人上这样做。`,"Paired chats":`已配对的聊天`,"Nobody yet. Write to the bot from the app; a pairing code comes back, approve it here.":`还没有人。在聊天软件里给机器人发条消息,会收到配对码,在这里通过即可。`,"Deliver here":`推送到这里`,"Chats with “deliver here” on also get what the Muse does on its own: check-ins, reminders, the results of background work, and the questions it needs answered.":`开了「推送到这里」的聊天还会收到 Muse 主动做的事:问候、提醒、后台任务的结果,以及需要你回答的问题。`,"Remove {name}? They will need a new pairing code.":`移除 {name}?对方需要重新配对。`,"Send a test message":`发送测试消息`,Sending:`发送中`,"Create the bot by QR code":`扫码创建机器人`,"Open in Feishu":`在飞书里打开`,"Scan with Feishu, confirm the new bot, and the App ID and Secret are saved for you.":`用飞书扫码,确认创建机器人,App ID 和 Secret 会自动保存。`,"Lark (international) instead of 飞书":`用 Lark(国际版)而不是飞书`,"Waiting for the scan…":`等待扫码…`,"The bot was created and is connecting.":`机器人已创建,正在连接。`,"The login did not finish ({error}).":`登录没有完成({error})。`,"Set up":`设置步骤`,"Open the console":`打开控制台`,"Then write to the bot from the app. The first message gets a pairing code; approve it here or run":`然后在聊天软件里给机器人发消息。第一条消息会收到配对码,在这里通过,或运行`,"Each chat is one conversation of your Muse; its replies stream back as they are written.":`每个聊天对应 Muse 的一个会话;回复会边写边发回来。`,"In the Feishu developer console create a custom app and add the Bot capability.":`在飞书开放平台创建企业自建应用,添加「机器人」能力。`,"Permissions: im:message, im:message.p2p_msg:readonly, im:message.group_at_msg:readonly, im:resource.":`权限:im:message、im:message.p2p_msg:readonly、im:message.group_at_msg:readonly、im:resource。`,"Events & callbacks: subscription mode Long connection; add im.message.receive_v1; callback mode Long connection; add card.action.trigger.":`事件与回调:订阅方式选「长连接」,添加 im.message.receive_v1;回调方式选「长连接」,添加 card.action.trigger。`,"Copy App ID and App Secret from Credentials & Basic Info, then publish a version.":`在「凭证与基础信息」复制 App ID 和 App Secret,然后发布版本。`,"In the DingTalk developer console create an internal app and add the Robot capability.":`在钉钉开放平台创建企业内部应用,添加「机器人」能力。`,"Set the message receive mode to Stream.":`消息接收模式选「Stream 模式」。`,"Copy Client ID (AppKey) and Client Secret (AppSecret) from Credentials & Basic Info.":`在「凭证与基础信息」复制 Client ID(AppKey)和 Client Secret(AppSecret)。`,"Add the robot send-message permission and publish the version.":`添加机器人发消息权限,发布版本。`,"In the WeCom admin console open Intelligent robot and create a robot in API mode with a long connection.":`在企业微信管理后台打开「智能机器人」,创建 API 模式、长连接的机器人。`,"Copy the Bot ID and Secret.":`复制 Bot ID 和 Secret。`,"Add the robot to the chats where it should listen.":`把机器人加到需要它在的聊天里。`,"In Telegram talk to @BotFather, send /newbot and copy the token.":`在 Telegram 里找 @BotFather,发送 /newbot,复制 token。`,"Direct messages work at once. For a group, add the bot to it; with Group Privacy on it only hears @-mentions and replies.":`私聊立即可用。群聊需要先把机器人拉进群;开着 Group Privacy 时它只听 @ 和回复。`,"If api.telegram.org is out of reach from this computer, set a proxy below.":`如果这台电脑连不上 api.telegram.org,在下面填一个代理。`,"Use nanoMuse Cloud models":`使用 nanoMuse Cloud 模型`,"nanoMuse Cloud is one of the sources for the chat, the hands, pictures and clips; what runs on it comes off your allowance.":`nanoMuse Cloud 是聊天、双手、图片和短片的来源之一;在它上面运行的内容会消耗你的额度。`,"Off: nothing runs on nanoMuse Cloud unless you choose it yourself. You stay signed in for sync and your devices.":`关闭后,除非你自己选择,否则不会有任何内容在 nanoMuse Cloud 上运行。账号保持登录,用于同步和你的设备。`,"Add a provider of your own first; with nanoMuse Cloud off, nothing else could answer.":`先添加一个自己的服务商;关掉 nanoMuse Cloud 后没有别的模型能回答。`,"Use your own API key instead":`改用自己的 API key`,"You can sign in later under Connections, for sync and your devices.":`之后可以在「连接」里登录,用于同步和你的设备。`,"Add an endpoint":`添加接口`,"Add endpoint":`添加接口`,"An endpoint joins your list with its own key and stays there; add as many as you use. Pick one as the chat model, or use its models for the hands, pictures and clips.":`接口带着自己的密钥加入列表并一直保留;用几个就加几个。可以选一个作为对话模型,也可以把它的模型用于双手、图片和短片。`,"Name (optional)":`名字(可选)`,"How the endpoint reads in the lists; its host when left empty.":`接口在列表里显示的名字;留空则显示主机名。`,"for example: my gateway":`例如:我的网关`,"Stored encrypted in the vault under this endpoint's own name.":`加密保存在保险库里,以这个接口自己的名字命名。`,"Models (optional)":`模型(可选)`,"Leave empty when the endpoint lists its models at /models. Otherwise type their ids, separated by commas.":`接口能在 /models 列出模型时留空;否则输入模型 id,用逗号分隔。`,"Endpoint added":`已添加接口`,"Endpoint renamed":`已给接口改名`,"Endpoint removed":`已移除接口`,"Key saved":`密钥已保存`,"Models saved":`模型已保存`,Models:`模型`,Rename:`改名`,"Change key":`换密钥`,"Edit models":`编辑模型`,"Ask for its models":`重新获取模型`,"{n} models":`{n} 个模型`,"{n} models saved with this endpoint":`这个接口保存了 {n} 个模型`,"Used by {slots}":`用于{slots}`,"the hands":`双手`,pictures:`图片`,clips:`短片`,"The ids of the models this endpoint serves, separated by commas; what it lists at /models is added after them.":`这个接口提供的模型 id,用逗号分隔;它在 /models 列出的模型会接在后面。`,"The endpoint did not list its models; the saved list stands.":`接口没有列出模型;沿用已保存的列表。`,"The chat model answers through this endpoint; pick another chat model first.":`对话模型正通过这个接口回答;请先换一个对话模型。`,"Its key goes with it, and the hands, pictures or clips set on it go back to automatic.":`它的密钥会一起删除;设在它上面的双手、图片或短片会回到自动。`,"Uses the key saved with this endpoint.":`使用这个接口保存的密钥。`,"Your endpoints":`你的接口`,"The model that looks at screens when the hands run: a small, fast one that takes pictures. Pick one of this provider's, or one from another endpoint of your list, which then lends its address and key.":`双手运行时看屏幕的模型:一个小而快、能看图的模型。可以选这个服务商的,也可以选你列表里另一个接口的,那个接口的地址和密钥会一并使用。`,"the endpoint did not list its models (GET /models); type the ids of the models it serves, separated by commas":`接口没有列出模型(GET /models);请输入它提供的模型 id,用逗号分隔`,"the chat model answers through this endpoint; pick another chat model first":`对话模型正通过这个接口回答;请先换一个对话模型`,"the address must start with http:// or https://":`地址必须以 http:// 或 https:// 开头`,"that is nanoMuse Cloud; it is the account, not an endpoint":`那是 nanoMuse Cloud;它是账号,不是接口`,"a key has no spaces in it":`密钥里不会有空格`},ne=[{value:`auto`,label:`Auto`},{value:`en`,label:`English`},{value:`zh-CN`,label:`简体中文`}],j=`nanomuse_locale`,M={en:{},"zh-CN":A};function re(){try{let e=localStorage.getItem(j);if(e===`en`||e===`zh-CN`||e===`auto`)return e}catch{}return`auto`}var N=re(),P=new Set;function ie(){return(typeof navigator>`u`?[]:[navigator.language,...navigator.languages??[]]).some(e=>/^zh\b/i.test(e??``))?`zh-CN`:`en`}function ae(){return N}function F(){return N===`auto`?ie():N}function oe(e){N=e;try{localStorage.setItem(j,e)}catch{}se(),P.forEach(e=>e())}function se(){typeof document<`u`&&(document.documentElement.lang=F())}function ce(e){return P.add(e),()=>P.delete(e)}function le(){return(0,f.useSyncExternalStore)(ce,F,F)}function ue(){return(0,f.useSyncExternalStore)(ce,ae,ae)}function de(e,t){return t?e.replace(/\{(\w+)\}/g,(e,n)=>n in t?String(t[n]):e):e}function I(e,t){let n=M[F()];return de(n[e]??e,t)}function fe(e){let t=t=>({title:e.slice(t.length)});return e.startsWith(`Working on your goal: `)?I(`Working on your goal: {title}`,t(`Working on your goal: `)):e.startsWith(`Check-in: `)?I(`Check-in: {title}`,t(`Check-in: `)):e.startsWith(`Reminder: `)?I(`Reminder: {title}`,t(`Reminder: `)):e.startsWith(`Routine: `)?I(`Routine: {title}`,t(`Routine: `)):e.startsWith(`New mail: `)?I(`New mail: {title}`,t(`New mail: `)):e.startsWith(`Coming up: `)?I(`Coming up: {title}`,t(`Coming up: `)):e.startsWith(`Webhook: `)?I(`Webhook: {title}`,t(`Webhook: `)):e===`Tidied memory`?I(`Tidied memory`):e}function pe(e){let t=/^'([^']+)' is in always_ask_tools$/.exec(e);return t?I(`{tool} is on your always-ask list`,{tool:t[1]}):(t=/^'([^']+)' is in always_allow_tools$/.exec(e),t?I(`{tool} is on your always-allow list`,{tool:t[1]}):(t=/^'([^']+)' is in sentinel\.deny_tools$/.exec(e),t?I(`{tool} is switched off in your settings`,{tool:t[1]}):(t=/^risk level is '(\w+)' \(mode=(\w+)\)$/.exec(e),t?I(`an action rated {risk}, in {mode} mode`,{risk:I(t[1]),mode:I(t[2])}):e===`auto mode: approval skipped`?I(`auto mode: not asked`):(t=/^covered by your '([^']+)' permission for (.+)$/.exec(e),t?I(`covered by your {scope} permission for {key}`,{scope:I(t[1]),key:t[2]}):(t=/^private data was read earlier in this conversation and '([^']+)' can send data to (.+), which is not on sentinel\.egress_allowlist$/.exec(e),t?I(`private data was read earlier in this conversation, and {tool} could send it to {target}, which is not on your allowed list`,{tool:t[1],target:t[2]}):I(e))))))}function me(e){let t=/^the file is larger than (\d+) MB$/.exec(e);return t?I(`The file is larger than {n} MB.`,{n:t[1]}):I(e)}function he(){return le(),I}function L(){return F()===`zh-CN`?`zh-CN`:`en-US`}function ge(e){if(!e)return``;let t=new Date(e);if(Number.isNaN(t.getTime()))return``;let n=new Date,r=t.toDateString()===n.toDateString(),i=t.toLocaleTimeString(L(),{hour:`2-digit`,minute:`2-digit`});return r?i:`${t.toLocaleDateString(L(),{month:`short`,day:`numeric`})} ${i}`}function _e(e){if(!e)return``;let t=new Date(e);if(Number.isNaN(t.getTime()))return``;let n=t.toLocaleTimeString(L(),{hour:`numeric`,minute:`2-digit`}),r=new Date,i=new Date(r);return i.setDate(r.getDate()-1),t.toDateString()===r.toDateString()?`${I(`Today`)} ${n}`:t.toDateString()===i.toDateString()?`${I(`Yesterday`)} ${n}`:`${ve(e)} ${n}`}function ve(e,t={}){if(!e)return``;let n=new Date(e);if(Number.isNaN(n.getTime()))return``;let r=n.getFullYear()===new Date().getFullYear();return n.toLocaleDateString(L(),{month:`short`,day:`numeric`,...r?{}:{year:`numeric`},...t})}function ye(e){if(!e)return``;let t=Date.now()-new Date(e).getTime();if(Number.isNaN(t))return``;let n=Math.round(Math.abs(t)/1e3);if(n<45)return I(t>=0?`just now`:`any moment`);let r=Math.round(n/60),i=Math.round(r/60),a=Math.round(i/24),o=r<60?I(`{n} min`,{n:r}):i<24?I(`{n} h`,{n:i}):I(`{n} d`,{n:a});return I(t>=0?`{span} ago`:`in {span}`,{span:o})}function be(e){let t=Math.max(0,Math.floor(Date.now()/1e3-e));return t<60?I(`just now`):t<3600?I(`{n} min ago`,{n:Math.floor(t/60)}):t<86400?I(`{n} h ago`,{n:Math.floor(t/3600)}):t<604800?I(`{n} d ago`,{n:Math.floor(t/86400)}):new Date(e*1e3).toLocaleDateString(L())}function R(e){try{return localStorage.getItem(e)}catch{return null}}function z(e,t){try{t===null?localStorage.removeItem(e):localStorage.setItem(e,t)}catch{}}function B(...e){return e.filter(Boolean).join(` `)}function xe(e){try{return decodeURIComponent(e)}catch{return e}}function Se(e){let t=e.toLowerCase().split(`.`).pop()??``;return t===`ics`?`event`:[`md`,`txt`,`log`,`rtf`].includes(t)?`text`:[`py`,`js`,`ts`,`tsx`,`jsx`,`sh`,`toml`,`yaml`,`yml`,`sql`,`rs`,`go`,`java`,`c`,`cpp`].includes(t)?`code`:[`html`,`htm`].includes(t)?`html`:[`png`,`jpg`,`jpeg`,`gif`,`webp`,`svg`].includes(t)?`image`:t===`pdf`?`pdf`:[`csv`,`json`,`xlsx`,`xls`,`parquet`].includes(t)?`data`:`other`}var V=`nanomuse_token`,H=class extends Error{constructor(){super(`unauthorized`)}};function Ce(e){let t=e.hash.replace(/^#/,``).split(`&`).filter(Boolean),n=t.findIndex(e=>e.startsWith(`token=`));if(n>=0){let r=decodeURIComponent(t[n].slice(6));return t.splice(n,1),e.hash=t.length?`#${t.join(`&`)}`:``,r||null}let r=e.searchParams.get(`token`);return r?(e.searchParams.delete(`token`),r):null}function U(){let e=new URL(window.location.href),t=Ce(e);return t&&(z(V,t),window.history.replaceState(null,``,e.pathname+e.search+e.hash)),R(V)??``}function we(e){z(V,e||null)}function W(e,t=!1){let n=U(),r=n?T(n,`/api/files/${e}`):new URLSearchParams;t&&r.set(`download`,`1`);let i=r.toString();return`/api/files/${e.split(`/`).map(encodeURIComponent).join(`/`)}${i?`?${i}`:``}`}function Te(e,t){let n=U(),r=n?`?${T(n,`/api/browser/${e}/frames/${t}.jpg`)}`:``;return`/api/browser/${encodeURIComponent(e)}/frames/${encodeURIComponent(t)}.jpg${r}`}async function G(e,t={}){let n={...t.headers},r=U();r&&(n.Authorization=`Bearer ${r}`),t.body&&!n[`Content-Type`]&&(n[`Content-Type`]=`application/json`);let i;try{i=await fetch(e,{...t,headers:n})}catch{throw Error(`Cannot reach your nanoMuse right now.`)}if(i.status===401)throw new H;if(!i.ok){let e;try{e=await i.json()}catch{}throw Error(Ee(i.status,e))}return await i.json()}function Ee(e,t){let n=t&&typeof t==`object`?t.detail:void 0;return typeof n==`string`&&n.trim()?n:e>=500?`Your nanoMuse hit a problem; try again in a moment.`:`Your nanoMuse could not do that.`}var K=e=>({method:`POST`,body:JSON.stringify(e)}),q={state:()=>G(`/api/state`),cloud:()=>G(`/api/cloud`),cloudCode:e=>G(`/api/cloud/code`,K({identifier:e})),cloudVerify:(e,t,n=``)=>G(`/api/cloud/verify`,K(n?{identifier:e,code:t,invite:n}:{identifier:e,code:t})),cloudSignOut:()=>G(`/api/cloud/sign-out`,K({})),cloudModels:e=>G(`/api/cloud/models`,K({on:e})),cloudLogin:(e,t)=>G(`/api/cloud/login`,K({identifier:e,password:t})),cloudPassword:(e,t)=>G(`/api/cloud/password`,K({password:e,current:t??null})),cloudSessions:()=>G(`/api/cloud/sessions`),cloudRevokeSession:e=>G(`/api/cloud/sessions/${encodeURIComponent(e)}`,{method:`DELETE`}),cloudSignOutAll:(e=!1)=>G(`/api/cloud/sign-out-all`,K({all:e})),cloudEvents:(e=50)=>G(`/api/cloud/events?limit=${e}`),cloudDelete:()=>G(`/api/cloud/delete`,K({})),cloudMe:()=>G(`/api/cloud/me`),cloudConfig:()=>G(`/api/cloud/config`),cloudContribute:e=>G(`/api/cloud/contribute`,K({on:e})),cloudDeleteSamples:()=>G(`/api/cloud/samples`,{method:`DELETE`}),cloudUseAsModel:(e=``)=>G(`/api/cloud/use-as-model`,K({model:e})),syncState:()=>G(`/api/sync/state`),syncSetState:e=>G(`/api/sync/state`,{method:`PUT`,body:JSON.stringify({enabled:e})}),syncSetSideChats:e=>G(`/api/sync/state`,{method:`PUT`,body:JSON.stringify({side_chats:e})}),syncDelete:()=>G(`/api/sync/delete`,K({})),syncPull:()=>G(`/api/sync/pull`,K({})),avatarView:()=>G(`/api/avatar`),avatarBegin:(e,t=`main`,n=`muse`)=>G(`/api/avatar/begin`,K({description:e,thread:t,style:n})),avatarStart:e=>G(`/api/avatar/start`,K({session:e})),avatarChoose:(e,t)=>G(`/api/avatar/choose`,K({session:e,index:t})),avatarCancel:e=>G(`/api/avatar/cancel`,K({session:e})),avatarMoods:()=>G(`/api/avatar/moods`,K({})),coding:(e=``)=>G(`/api/coding${e?`?device=${encodeURIComponent(e)}`:``}`),codingSessions:(e={})=>{let t=new URLSearchParams;e.agent&&t.set(`agent`,e.agent),e.limit&&t.set(`limit`,String(e.limit)),e.workspace&&t.set(`workspace`,e.workspace),e.device&&t.set(`device`,e.device);let n=t.toString();return G(`/api/coding/sessions${n?`?${n}`:``}`)},codingSession:(e,t,n=``)=>G(`/api/coding/sessions/${encodeURIComponent(e)}/${encodeURIComponent(t)}${n?`?device=${encodeURIComponent(n)}`:``}`),codingSend:e=>G(`/api/coding/send`,K(e)),codingStop:(e,t=``)=>G(`/api/coding/stop`,K({run:e,device:t})),hub:()=>G(`/api/hub`),updateHub:e=>G(`/api/hub`,{method:`PUT`,body:JSON.stringify(e)}),joinHub:()=>G(`/api/hub/join`,K({})),leaveHub:()=>G(`/api/hub/leave`,K({})),refreshHub:()=>G(`/api/hub/refresh`,K({})),forgetDevice:e=>G(`/api/hub/devices/${encodeURIComponent(e)}`,{method:`DELETE`}),askDevice:(e,t=``)=>G(`/api/hub/ask`,K({device:e,text:t})),hands:()=>G(`/api/hands`),setHands:e=>G(`/api/connections/hands`,{method:`PUT`,body:JSON.stringify(e)}),stopHands:()=>G(`/api/hands/stop`,K({})),holds:()=>G(`/api/holds`),openHold:(e,t,n=``)=>G(`/api/holds`,K({thread:e,tool:t,reason:n})),doneHold:e=>G(`/api/holds/${encodeURIComponent(e)}/done`,K({})),health:()=>G(`/api/health`),threads:()=>G(`/api/threads`),createThread:e=>G(`/api/threads`,K({title:e})),renameThread:(e,t)=>G(`/api/threads/${e}`,{method:`PATCH`,body:JSON.stringify({title:t})}),deleteThread:e=>G(`/api/threads/${e}`,{method:`DELETE`}),clearThread:e=>G(`/api/threads/${e}/clear`,{method:`POST`}),stopThread:e=>G(`/api/threads/${e}/stop`,{method:`POST`}),events:(e,t=200,n)=>G(`/api/threads/${e}/events?limit=${t}${n?`&before=${encodeURIComponent(n)}`:``}`),send:(e,t,n=[],r=``,i=``)=>G(`/api/threads/${e}/send`,K(i?{text:t,files:n,language:r,via:i}:{text:t,files:n,language:r})),upload:e=>G(`/api/files/upload?name=${encodeURIComponent(e.name||`photo.jpg`)}`,{method:`POST`,headers:{"Content-Type":e.type||`application/octet-stream`},body:e}),decide:(e,t,n=`once`,r=``)=>G(`/api/approvals/${e}`,K({approved:t,scope:n,reason:r})),resetApprovals:()=>G(`/api/approvals`,{method:`DELETE`}),revokeGrant:e=>G(`/api/approvals/grants/${encodeURIComponent(e)}`,{method:`DELETE`}),goals:()=>G(`/api/goals`),createGoal:e=>G(`/api/goals`,K(e)),patchGoal:(e,t)=>G(`/api/goals/${e}`,{method:`PATCH`,body:JSON.stringify(t)}),addStep:(e,t)=>G(`/api/goals/${e}/steps`,K({title:t})),advanceGoal:e=>G(`/api/goals/${e}/advance`,{method:`POST`}),checkInGoal:e=>G(`/api/goals/${e}/check-in`,{method:`POST`}),acceptProposal:e=>G(`/api/goals/${e}/proposal/accept`,{method:`POST`}),dismissProposal:e=>G(`/api/goals/${e}/proposal`,{method:`DELETE`}),deleteGoal:e=>G(`/api/goals/${e}`,{method:`DELETE`}),memory:()=>G(`/api/memory`),addMemory:(e,t)=>G(`/api/memory`,K({content:e,category:t})),forgetMemory:e=>G(`/api/memory/${e}`,{method:`DELETE`}),tidyMemory:()=>G(`/api/memory/tidy`,{method:`POST`}),memoryChanges:(e=30)=>G(`/api/memory/changes?limit=${e}`),restoreMemoryChange:e=>G(`/api/memory/changes/${e}/restore`,{method:`POST`}),ideas:(e=!1)=>G(`/api/ideas${e?`?refresh=1`:``}`),activity:(e=150)=>G(`/api/activity?n=${e}`),feed:(e=60)=>G(`/api/feed?limit=${e}`),feedPosts:()=>G(`/api/feed/posts`),setFeedInstructions:e=>G(`/api/feed/instructions`,{method:`PUT`,body:JSON.stringify(e)}),refreshFeedPosts:()=>G(`/api/feed/posts/refresh`,{method:`POST`}),deleteFeedPost:e=>G(`/api/feed/posts/${e}`,{method:`DELETE`}),upcoming:()=>G(`/api/upcoming`),reminders:(e=!1)=>G(`/api/reminders${e?`?all=1`:``}`),createReminder:e=>G(`/api/reminders`,{method:`POST`,body:JSON.stringify(e)}),fireReminder:e=>G(`/api/reminders/${e}/fire`,{method:`POST`}),cancelReminder:e=>G(`/api/reminders/${e}`,{method:`DELETE`}),triggers:()=>G(`/api/triggers`),createTrigger:e=>G(`/api/triggers`,{method:`POST`,body:JSON.stringify(e)}),fireTrigger:e=>G(`/api/triggers/${e}/fire`,{method:`POST`}),cancelTrigger:e=>G(`/api/triggers/${e}`,{method:`DELETE`}),files:(e=300)=>G(`/api/files?limit=${e}`),fileText:async e=>{let t={},n=U();n&&(t.Authorization=`Bearer ${n}`);let r=await fetch(`/api/files/${e.split(`/`).map(encodeURIComponent).join(`/`)}`,{headers:t});if(r.status===401)throw new H;if(!r.ok)throw Error(Ee(r.status,await r.json().catch(()=>void 0)));return r.text()},settings:()=>G(`/api/settings`),update:(e=!1)=>G(`/api/update${e?`?refresh=1`:``}`),nudges:()=>G(`/api/nudges`),updateSettings:e=>G(`/api/settings`,{method:`PUT`,body:JSON.stringify(e)}),connections:()=>G(`/api/connections`),providers:e=>G(`/api/providers${e?`?region=${encodeURIComponent(e)}`:``}`),chatgptLogin:()=>G(`/api/chatgpt/login`,K({})),chatgptStatus:()=>G(`/api/chatgpt/status`),chatgptCallback:e=>G(`/api/chatgpt/callback`,K({url:e})),chatgptLogout:()=>G(`/api/chatgpt/logout`,K({})),setLLM:e=>G(`/api/connections/llm`,{method:`PUT`,body:JSON.stringify(e)}),testLLM:()=>G(`/api/connections/llm/test`,{method:`POST`}),llmModels:e=>G(`/api/llm/models`,K(e)),addEndpoint:e=>G(`/api/connections/endpoints`,K(e)),updateEndpoint:(e,t)=>G(`/api/connections/endpoints/${encodeURIComponent(e)}`,{method:`PUT`,body:JSON.stringify(t)}),removeEndpoint:e=>G(`/api/connections/endpoints/${encodeURIComponent(e)}`,{method:`DELETE`}),refreshEndpointModels:e=>G(`/api/connections/endpoints/${encodeURIComponent(e)}/models`,K({})),setEmbeddings:e=>G(`/api/connections/embeddings`,{method:`PUT`,body:JSON.stringify(e)}),testEmbeddings:()=>G(`/api/connections/embeddings/test`,{method:`POST`}),setSearch:e=>G(`/api/connections/search`,{method:`PUT`,body:JSON.stringify(e)}),testSearch:()=>G(`/api/connections/search/test`,{method:`POST`}),setEmail:e=>G(`/api/connections/email`,{method:`PUT`,body:JSON.stringify(e)}),disconnectEmail:()=>G(`/api/connections/email`,{method:`DELETE`}),testEmail:()=>G(`/api/connections/email/test`,{method:`POST`}),calendar:()=>G(`/api/calendar`),setCalendar:e=>G(`/api/connections/calendar`,{method:`PUT`,body:JSON.stringify(e)}),addCalendarFeed:(e,t)=>G(`/api/connections/calendar/feeds`,K({name:e,url:t})),removeCalendarFeed:e=>G(`/api/connections/calendar/feeds/${encodeURIComponent(e)}`,{method:`DELETE`}),testCalendar:()=>G(`/api/connections/calendar/test`,{method:`POST`}),googleCalendar:()=>G(`/api/connections/calendar/google`),setGoogleCalendar:e=>G(`/api/connections/calendar/google`,{method:`PUT`,body:JSON.stringify(e)}),connectGoogleCalendar:e=>G(`/api/connections/calendar/google/connect`,K({write:e})),setGoogleCalendars:e=>G(`/api/connections/calendar/google/calendars`,K(e)),disconnectGoogleCalendar:()=>G(`/api/connections/calendar/google/disconnect`,{method:`POST`}),contacts:(e=``,t=8)=>G(`/api/contacts?q=${encodeURIComponent(e)}&limit=${t}`),setContacts:e=>G(`/api/connections/contacts`,{method:`PUT`,body:JSON.stringify(e)}),addContactsSource:(e,t)=>G(`/api/connections/contacts/sources`,K({name:e,url:t})),importContacts:(e,t)=>G(`/api/connections/contacts/import?name=${encodeURIComponent(e)}`,{method:`POST`,headers:{"Content-Type":`text/vcard; charset=utf-8`},body:t}),removeContactsSource:e=>G(`/api/connections/contacts/sources/${encodeURIComponent(e)}`,{method:`DELETE`}),testContacts:()=>G(`/api/connections/contacts/test`,{method:`POST`}),skills:()=>G(`/api/skills`),skill:e=>G(`/api/skills/${encodeURIComponent(e)}`),saveSkill:(e,t)=>G(`/api/skills/${encodeURIComponent(e)}`,{method:`PUT`,body:JSON.stringify({content:t})}),deleteSkill:e=>G(`/api/skills/${encodeURIComponent(e)}`,{method:`DELETE`}),setSkillEnabled:(e,t)=>G(`/api/skills/${encodeURIComponent(e)}/enabled`,K({enabled:t})),importSkill:e=>G(`/api/skills/import`,K({url:e})),setGui:e=>G(`/api/connections/gui`,{method:`PUT`,body:JSON.stringify(e)}),testGui:()=>G(`/api/connections/gui/test`,{method:`POST`}),media:e=>G(`/api/connections/${e}`),setMedia:(e,t)=>G(`/api/connections/${e}`,{method:`PUT`,body:JSON.stringify(t)}),setBrowser:e=>G(`/api/connections/browser`,{method:`PUT`,body:JSON.stringify({enabled:e})}),addMCP:e=>G(`/api/connections/mcp`,K(e)),removeMCP:e=>G(`/api/connections/mcp/${encodeURIComponent(e)}`,{method:`DELETE`}),vaultSet:(e,t)=>G(`/api/vault/${encodeURIComponent(e)}`,{method:`PUT`,body:JSON.stringify({value:t})}),vaultDelete:e=>G(`/api/vault/${encodeURIComponent(e)}`,{method:`DELETE`}),onboarded:(e=!0)=>G(`/api/onboarded`,K({done:e})),firstrun:e=>G(`/api/firstrun?lang=${encodeURIComponent(e)}`),firstrunStart:e=>G(`/api/firstrun/start`,K({lang:e})),firstrunPick:e=>G(`/api/firstrun/pick`,K({name:e})),firstrunDismiss:()=>G(`/api/firstrun/dismiss`,K({})),browserControl:(e,t)=>G(`/api/browser/${encodeURIComponent(e)}/control`,K(t)),push:()=>G(`/api/push`),pushSubscribe:e=>G(`/api/push/subscribe`,K({subscription:e})),pushUnsubscribe:e=>G(`/api/push/unsubscribe`,K({endpoint:e})),pushTest:()=>G(`/api/push/test`,{method:`POST`})};function De(e){return e===4401?`auth`:e===4404?`gone`:`retry`}function Oe(e){let t=null,n=!1,r=0,i,a=()=>{let o=window.location.protocol===`https:`?`wss`:`ws`;t=new WebSocket(`${o}://${window.location.host}/ws`),t.onopen=()=>{let n=U();n&&t?.send(JSON.stringify({kind:`auth`,token:n})),r=0,e.onOpen?.()},t.onmessage=t=>{try{e.onMessage(JSON.parse(t.data))}catch{}},t.onclose=t=>{e.onClose?.();let o=De(t.code);if(o===`auth`){e.onAuthError?.();return}if(o===`gone`){n=!0,e.onGone?.();return}if(n)return;let s=Math.min(15e3,500*2**r++);i=window.setTimeout(a,s)},t.onerror=()=>t?.close()};return a(),{send:e=>t&&t.readyState===WebSocket.OPEN?(t.send(JSON.stringify(e)),!0):!1,close:()=>{n=!0,i&&window.clearTimeout(i),t?.close()}}}var ke=o((e=>{var t=d(),n=Symbol.for(`react.element`),r=Symbol.for(`react.fragment`),i=Object.prototype.hasOwnProperty,a=t.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.ReactCurrentOwner,o={key:!0,ref:!0,__self:!0,__source:!0};function s(e,t,r){var s,c={},l=null,u=null;for(s in r!==void 0&&(l=``+r),t.key!==void 0&&(l=``+t.key),t.ref!==void 0&&(u=t.ref),t)i.call(t,s)&&!o.hasOwnProperty(s)&&(c[s]=t[s]);if(e&&e.defaultProps)for(s in t=e.defaultProps,t)c[s]===void 0&&(c[s]=t[s]);return{$$typeof:n,type:e,key:l,ref:u,props:c,_owner:a.current}}e.Fragment=r,e.jsx=s,e.jsxs=s})),Ae=o(((e,t)=>{t.exports=ke()})),je=6e5;function Me(e,t=Date.now()){let n={};for(let r of e??[])t-r.at*1e3<6e5&&(n[r.thread]=r);return n}function Ne(){return`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window}async function Pe(){if(!(`serviceWorker`in navigator)||!window.isSecureContext)return null;try{return await navigator.serviceWorker.register(`/sw.js`)}catch{return null}}async function Fe(){if(!Ne())return`unsupported`;if(!window.isSecureContext)return`insecure`;if(Notification.permission===`denied`)return`denied`;let e=await navigator.serviceWorker.getRegistration();return e&&await e.pushManager.getSubscription()?`on`:`off`}function Ie(e){let t=`=`.repeat((4-e.length%4)%4),n=atob((e+t).replace(/-/g,`+`).replace(/_/g,`/`)),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e0?t.setAppBadge?.(e):t.clearAppBadge?.()}catch{}}var Be=`nanomuse_theme`,Ve={light:`#ffffff`,dark:`#181819`};function He(){try{let e=localStorage.getItem(Be);if(e===`light`||e===`dark`||e===`system`)return e}catch{}return`light`}var J=He(),Y=new Set,Ue=typeof matchMedia==`function`?matchMedia(`(prefers-color-scheme: dark)`):null;function We(){return Ue?.matches?`dark`:`light`}function Ge(){return J}function X(){return J===`system`?We():J}function Ke(e){J=e;try{localStorage.setItem(Be,e)}catch{}Z(),Y.forEach(e=>e())}function Z(){if(typeof document>`u`)return;let e=X();document.documentElement.dataset.theme=e;let t=document.querySelector(`meta[name="theme-color"]`);t&&t.setAttribute(`content`,Ve[e])}Ue?.addEventListener?.(`change`,()=>{J===`system`&&(Z(),Y.forEach(e=>e()))});function qe(e){return Y.add(e),()=>Y.delete(e)}function Je(){return(0,f.useSyncExternalStore)(qe,Ge,Ge)}function Ye(){return(0,f.useSyncExternalStore)(qe,X,X)}var Q=Ae();function Xe(){if(window.parent===window)return null;let e=window.location.ancestorOrigins;if(e&&e.length>0&&e[0]!==`null`)return e[0];if(document.referrer)try{return new URL(document.referrer).origin}catch{return null}return null}function Ze(e){let t=Xe();t&&window.parent.postMessage(e,t)}var Qe=[`chat`,`feed`,`ideas`,`goals`,`library`,`memory`,`devices`,`connections`,`channels`,`skills`,`you`,`account`,`coding`,`avatar`],$e=`nanomuse_feed_seen`,et=`nanomuse_sign_in_skipped`,tt=`nanomuse.ui.lite`;function nt(){try{let e=new URL(window.location.href);return e.searchParams.get(`ui`)===`lite`?(sessionStorage.setItem(tt,`1`),!0):e.searchParams.get(`ui`)===`full`?(sessionStorage.removeItem(tt),!1):sessionStorage.getItem(tt)===`1`}catch{return!1}}var rt={connected:!1,loaded:!1,authError:!1,gone:!1,error:null,version:``,profile:null,status:{state:`idle`,detail:``,thread:`main`},threads:[],activeThread:`main`,events:{},hasMore:{},streams:{},goals:[],settings:null,goalsVersion:0,memoryVersion:0,remindersVersion:0,calendarVersion:0,pendingApprovals:[],feedVersion:0,feedSeenAt:R($e)??``,viewer:null,draft:null,draftFiles:null,connectionsVersion:0,skillsVersion:0,onboardingDismissed:!1,signInSkipped:R(et)===`1`,tab:`chat`,toast:null,drawer:!1,finishedAt:0,mishapAt:0,hub:null,codingLive:{},lite:nt(),hands:null,holds:[],handsLive:null,studio:null,working:{},firstrun:null};function it(e,t){if(t.type!==`approval`)return e;let n=e.filter(e=>e.id!==t.id);return t.status===`pending`?[...n,t]:n}function at(e){return e.type===`approval`||e.type===`question`||e.source===`background`||e.source===`goal`}function ot(e){let t=e.ts;return typeof t==`number`?{...e,ts:new Date(t*1e3).toISOString()}:e}function st(e,t){let n=e??[],r=n.findIndex(e=>e.id===t.id);if(r>=0){let e=n.slice();return e[r]=t,e}if(t.synced&&t.ts&&n.length>0&&(n[n.length-1]?.ts??``)>t.ts){let e=n.length;for(;e>0&&(n[e-1]?.ts??``)>t.ts;)--e;return[...n.slice(0,e),t,...n.slice(e)]}return[...n,t]}function ct(e,t){let n=e.filter(e=>e.id!==t.id);return t.status===`on`?[...n,t]:n}function lt(e,t){let n=e.findIndex(e=>e.id===t.id);if(n>=0){let r=e.slice();return r[n]=t,r}return[...e,t]}function ut(e,t){switch(t.type){case`hello`:{let n=t.state;for(let e of Object.keys($))delete $[e];return{...e,loaded:!0,authError:!1,version:n.version,profile:n.profile,status:n.status,streams:{},threads:n.threads,goals:n.goals,settings:n.settings,pendingApprovals:n.pending_approvals,feedVersion:e.feedVersion+1,activeThread:n.threads.some(t=>t.id===e.activeThread)?e.activeThread:`main`,hub:n.hub??e.hub,hands:n.hands??e.hands,holds:(n.holds??[]).map(ot),working:Me(n.working),firstrun:n.firstrun??e.firstrun}}case`connection`:return{...e,connected:t.connected};case`authError`:return{...e,authError:!0,connected:!1};case`gone`:return{...e,gone:!0,connected:!1};case`error`:return{...e,error:t.error};case`events`:{let n=e.events[t.thread]??[],r=t.events.map(ot),i=t.prepend?[...r,...n.filter(e=>!r.some(t=>t.id===e.id))]:r;return{...e,events:{...e.events,[t.thread]:i},hasMore:{...e.hasMore,[t.thread]:t.hasMore}}}case`eventsFailed`:return{...e,events:{...e.events,[t.thread]:e.events[t.thread]??[]},toast:I(`Could not load the conversation. Open the chat again to retry.`)};case`activeThread`:return{...e,activeThread:t.thread,tab:`chat`};case`goals`:return{...e,goals:t.goals};case`settings`:return{...e,settings:t.settings,profile:t.settings.profile};case`tab`:return{...e,tab:t.tab};case`drawer`:return{...e,drawer:t.open};case`feedSeen`:return z($e,t.at),{...e,feedSeenAt:t.at};case`viewer`:return{...e,viewer:t.path};case`draft`:return{...e,draft:t.text};case`draftFiles`:return{...e,draftFiles:t.files};case`onboardingDismissed`:return{...e,onboardingDismissed:!0};case`signInSkipped`:return{...e,signInSkipped:!0};case`toast`:return{...e,toast:t.toast};case`ws`:return dt(e,t.msg);default:return e}}function dt(e,t){switch(t.kind){case`hello`:return ut(e,{type:`hello`,state:t.state});case`event`:case`update`:{let n=ot(t.event),r={...e.streams},i=r[n.thread];n.type===`assistant`&&i&&i.id===n.id&&(r[n.thread]=void 0);let a=e.threads.map(e=>e.id===n.thread&&t.kind===`event`?{...e,updated_at:n.ts}:e),o=e.events[n.thread]!==void 0,s=n.type===`tool`&&(n.status===`error`||n.status===`blocked`)||n.type===`notice`&&n.level===`error`,c=e.working,l=c[n.thread];return l&&n.type===`assistant`&&n.synced&&n.via_device===l.device&&(c={...c,[n.thread]:void 0}),{...e,streams:r,threads:a,working:c,events:o?{...e.events,[n.thread]:st(e.events[n.thread],n)}:e.events,holds:n.type===`hold`?ct(e.holds,n):e.holds,pendingApprovals:it(e.pendingApprovals,n),feedVersion:at(n)?e.feedVersion+1:e.feedVersion,mishapAt:s?Date.now():e.mishapAt}}case`stream_start`:return{...e,streams:{...e.streams,[t.thread]:{id:t.id,text:``,ended:!1}}};case`delta`:{let n=e.streams[t.thread],r=n&&n.id===t.id?{...n,text:n.text+t.text}:{id:t.id,text:t.text,ended:!1};return{...e,streams:{...e.streams,[t.thread]:r}}}case`stream_end`:{let n=e.streams[t.thread];return!n||n.id!==t.id?e:t.discard||!n.text.trim()?{...e,streams:{...e.streams,[t.thread]:void 0}}:{...e,streams:{...e.streams,[t.thread]:{...n,ended:!0}}}}case`status`:{let n=t.status,r={...e.streams};r[n.thread]?.ended&&n.state!==`working`&&(r[n.thread]=void 0);let i=ft(e.status,n),a=e.status.state===`working`&&i.state===`idle`;return{...e,status:i,streams:r,finishedAt:a?Date.now():e.finishedAt}}case`thread`:return{...e,threads:lt(e.threads,t.thread)};case`thread_deleted`:{let n={...e.events};return delete n[t.thread],{...e,events:n,holds:e.holds.filter(e=>e.thread!==t.thread),threads:e.threads.filter(e=>e.id!==t.thread),pendingApprovals:e.pendingApprovals.filter(e=>e.thread!==t.thread),activeThread:e.activeThread===t.thread?`main`:e.activeThread}}case`thread_cleared`:return{...e,events:{...e.events,[t.thread]:[]},holds:e.holds.filter(e=>e.thread!==t.thread),pendingApprovals:e.pendingApprovals.filter(e=>e.thread!==t.thread)};case`event_removed`:{let n=e.events[t.thread];return n?{...e,events:{...e.events,[t.thread]:n.filter(e=>e.id!==t.id)}}:e}case`firstrun`:return{...e,firstrun:t.firstrun};case`working`:{let{kind:n,working:r,...i}=t,a=e.working[i.thread];return r?{...e,working:{...e.working,[i.thread]:i}}:!a||a.device!==i.device?e:{...e,working:{...e.working,[i.thread]:void 0}}}case`goals`:return{...e,goalsVersion:e.goalsVersion+1};case`memory`:return{...e,memoryVersion:e.memoryVersion+1};case`reminders`:case`triggers`:return{...e,remindersVersion:e.remindersVersion+1};case`calendar`:return{...e,calendarVersion:e.calendarVersion+1};case`feed_posts`:return{...e,feedVersion:e.feedVersion+1};case`profile`:return{...e,profile:t.profile,settings:e.settings?{...e.settings,profile:t.profile}:e.settings};case`settings`:return{...e,settings:t.settings,profile:t.settings.profile};case`connections`:return{...e,connectionsVersion:e.connectionsVersion+1};case`hub`:return{...e,hub:t.hub};case`coding`:{let n=t.run?.id??t.event.run;if(!n)return e;let r=e.codingLive[n],i=t.run??r?.run;if(!i)return e;let a=t.event.kind===`run`?r?.events??[]:[...r?.events??[],t.event].slice(-400),o=r?.current??``;t.event.kind===`text`&&(o=t.event.partial?o+(t.event.text??``):``),(t.event.kind===`done`||t.event.kind===`error`)&&(o=``);let s={...e.codingLive,[n]:{run:{...i,device:t.device??i.device},events:a,current:o}},c=Object.keys(s);if(c.length>20)for(let e of c.sort((e,t)=>s[e].run.started_at-s[t].run.started_at).slice(0,c.length-20))delete s[e];return{...e,codingLive:s}}case`hands_state`:return{...e,hands:t.hands};case`studio`:return{...e,studio:t.current};case`hands`:{let{kind:n,...r}=t,i=r.event===`end`||r.event===`stop`;return{...e,handsLive:i?null:r}}case`skills`:return{...e,skillsVersion:e.skillsVersion+1};case`error`:return{...e,toast:I(t.error)};case`pong`:return{...e,status:t.status};default:return e}}var $={};function ft(e,t){$[t.thread]=t;let n=Object.values($).filter(e=>e.state!==`idle`);return n.length?(n.sort((e,t)=>Number(e.thread!==`main`)-Number(t.thread!==`main`)),n[0]??e):{state:`idle`,detail:``,thread:`main`}}var pt=(0,f.createContext)(null);function mt({children:e}){let[t,n]=(0,f.useReducer)(ut,rt),r=(0,f.useRef)(null);(0,f.useEffect)(()=>{U();let e=[],t=0,i=()=>{t=0;let r=e.splice(0);for(let e of r)n({type:`ws`,msg:e})},a=Oe({onMessage:n=>{e.push(n),t||=window.setTimeout(i,0)},onOpen:()=>n({type:`connection`,connected:!0}),onClose:()=>n({type:`connection`,connected:!1}),onAuthError:()=>n({type:`authError`}),onGone:()=>n({type:`gone`})});return r.current=a,q.state().then(e=>n({type:`hello`,state:e})).catch(e=>{e instanceof H?n({type:`authError`}):n({type:`error`,error:String(e.message??e)})}),()=>{t&&window.clearTimeout(t),a.close()}},[]);let i=(0,f.useRef)({}),a=(0,f.useCallback)(async(e,t)=>{let r=t?void 0:i.current[e]=(i.current[e]??0)+1;try{let a=await q.events(e,150,t);if(r!==void 0&&i.current[e]!==r)return;n({type:`events`,thread:e,events:a.events,hasMore:a.has_more,prepend:!!t})}catch(t){t instanceof H?n({type:`authError`}):n({type:`eventsFailed`,thread:e})}},[]),o=(0,f.useCallback)(async()=>{try{n({type:`ws`,msg:{kind:`hub`,hub:await q.hub()}})}catch{}},[]);(0,f.useEffect)(()=>{t.loaded&&a(t.activeThread)},[t.loaded,t.activeThread,a]);let s=(0,f.useRef)(!1);(0,f.useEffect)(()=>{t.connected&&s.current&&t.loaded&&a(t.activeThread),s.current=t.connected},[t.connected,t.loaded,t.activeThread,a]);let c=(0,f.useCallback)(async()=>{try{n({type:`goals`,goals:await q.goals()})}catch{}},[]);(0,f.useEffect)(()=>{t.loaded&&c()},[t.goalsVersion,t.loaded,c]);let l=(0,f.useCallback)(async()=>{try{n({type:`settings`,settings:await q.settings()})}catch{}},[]);(0,f.useEffect)(()=>{if(!t.toast)return;let e=window.setTimeout(()=>n({type:`toast`,toast:null}),3500);return()=>window.clearTimeout(e)},[t.toast]),(0,f.useEffect)(()=>{Pe();let e=e=>{let t=new URL(e,window.location.origin),r=t.searchParams.get(`thread`),i=t.searchParams.get(`tab`);r&&n({type:`activeThread`,thread:r}),i&&Qe.includes(i)&&!r?n({type:`tab`,tab:i}):n({type:`tab`,tab:`chat`});let a=t.searchParams.get(`draft`);a&&n({type:`draft`,text:a});let o=t.searchParams.get(`attach`);if(o)try{let e=JSON.parse(o);Array.isArray(e)&&e.length&&n({type:`draftFiles`,files:e.filter(e=>e&&typeof e.path==`string`)})}catch{}},t=new URL(window.location.href);if([`thread`,`tab`,`draft`,`attach`,`ui`].some(e=>t.searchParams.has(e))){e(t.href);for(let e of[`thread`,`tab`,`draft`,`attach`,`ui`])t.searchParams.delete(e);window.history.replaceState({},``,t.toString())}let r=t=>{t.data&&t.data.type===`open`&&e(String(t.data.url||`/`))};navigator.serviceWorker?.addEventListener(`message`,r);let i=e=>{if(window.parent===window||e.source!==window.parent)return;let t=e.data;t&&t.type===`nanomuse:draft`&&typeof t.text==`string`&&(n({type:`tab`,tab:`chat`}),n({type:`draft`,text:t.text.slice(0,4e3)}))};return window.addEventListener(`message`,i),()=>{navigator.serviceWorker?.removeEventListener(`message`,r),window.removeEventListener(`message`,i)}},[]);let u=Ye();(0,f.useEffect)(()=>{t.loaded&&Ze({type:`nanomuse:ready`,name:t.profile?.name??``,theme:u})},[t.loaded,t.profile?.name]),(0,f.useEffect)(()=>{t.loaded&&Ze({type:`nanomuse:theme`,theme:u})},[t.loaded,u]),(0,f.useEffect)(()=>{t.loaded&&ze(t.pendingApprovals.length)},[t.loaded,t.pendingApprovals.length]);let d=(0,f.useMemo)(()=>({state:t,dispatch:n,send:async(e,t,i=[])=>{if(!t.trim()&&i.length===0)return;let a=F();try{(i.length>0||!r.current?.send({kind:`send`,thread:e,text:t,language:a}))&&await q.send(e,t,i,a)}catch(e){throw e instanceof H&&n({type:`authError`}),e}},sendViaCloud:async(e,t,r=[])=>{if(t.trim()||r.length!==0)try{await q.send(e,t,r,F(),`cloud`)}catch(e){throw e instanceof H&&n({type:`authError`}),e}},decide:async(e,t,i=`once`)=>{try{r.current?.send({kind:`approval`,id:e,approved:t,scope:i})||await q.decide(e,t,i)}catch(e){throw e instanceof H&&n({type:`authError`}),e}},loadEvents:a,refreshGoals:c,refreshSettings:l,refreshHub:o,setTab:e=>n({type:`tab`,tab:e}),setDrawer:e=>n({type:`drawer`,open:e}),openThread:e=>n({type:`activeThread`,thread:e}),markFeedSeen:e=>n({type:`feedSeen`,at:e}),openFile:e=>n({type:`viewer`,path:e}),draftFiles:e=>n({type:`draftFiles`,files:e}),draft:e=>{n({type:`draft`,text:e}),e!==null&&n({type:`tab`,tab:`chat`})},dismissOnboarding:()=>n({type:`onboardingDismissed`}),skipSignIn:()=>{z(et,`1`),n({type:`signInSkipped`})},toast:e=>n({type:`toast`,toast:I(e)})}),[t,a,c,l,o]);return(0,Q.jsx)(pt.Provider,{value:d,children:e})}function ht(){let e=(0,f.useContext)(pt);if(!e)throw Error(`useStore outside provider`);return e}var gt=`dragon`,_t=new Set([`panda`,`sunny`,`moss`,`sky`,`fox`,`bolt`,`plum`]);function vt(e){return!e||e===`dragon`||_t.has(e)}function yt(e,t){return W(`avatar/${e}/${t===`working`||t===`waiting`||t===`happy`||t===`error`?t:`idle`}.webp`)}function bt(e){return`/avatars/dragon-${e===`working`||e===`waiting`||e===`happy`||e===`error`?e:`idle`}.webp`}function xt(e){return e===`working`||e===`waiting`||e===`happy`?e:`idle`}function St(e){return e===`error`?null:`/avatars/dragon-${xt(e)}.mp4`}function Ct(e,t){return t===`error`?null:W(`avatar/${e}/${xt(t)}.mp4`)}var wt=3e3,Tt=4e3;function Et(e){let{state:t}=ht(),n=Dt(e?[t.mishapAt+Tt,t.finishedAt+wt]:[]);return e?t.loaded&&!t.connected?`sleepy`:e.state===`waiting`?`waiting`:e.state===`working`?`working`:t.mishapAt&&n-t.mishapAt<4e3?`error`:t.finishedAt&&n-t.finishedAt<3e3?`happy`:`idle`:`idle`}function Dt(e){let[t,n]=(0,f.useState)(()=>Date.now()),r=e.join(`,`);(0,f.useEffect)(()=>n(Date.now()),[r]);let i=Math.min(...e.filter(e=>e>t),1/0);return(0,f.useEffect)(()=>{if(!Number.isFinite(i))return;let e=window.setTimeout(()=>n(Date.now()),Math.max(0,i-Date.now())+20);return()=>window.clearTimeout(e)},[i]),t}var Ot=44,kt=new Set,At=typeof window<`u`&&typeof window.matchMedia==`function`&&window.matchMedia(`(prefers-reduced-motion: reduce)`).matches;function jt({profile:e,status:t,size:n=40,onClick:r,still:i=!1,className:a}){let[o,s]=(0,f.useState)(!1),[,c]=(0,f.useState)(0);(0,f.useEffect)(()=>{if(!o)return;let e=window.setTimeout(()=>s(!1),900);return()=>window.clearTimeout(e)},[o]);let l=Et(t),u=t?.state??`idle`,d=e?.color??`#0064d4`,p=!e||vt(e.avatar),m=!p&&e?.avatar?e.avatar:null,h=o?`happy`:i?`idle`:l,g=i||At||n{kt.add(_),c(e=>e+1)},className:`h-full w-full object-cover`},_):p||m?(0,Q.jsx)(`img`,{src:y,alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,Q.jsx)(`span`,{className:`flex h-full w-full items-center justify-center leading-none drop-shadow-sm`,style:{fontSize:n*.5},children:e?.emoji??`✨`})}),t&&u!==`idle`&&(0,Q.jsx)(`span`,{className:B(`absolute -bottom-0.5 -right-0.5 rounded-full border-2 border-bg`,u===`working`?`bg-amber-400`:`bg-rose-500`),style:{width:Math.max(10,n*.28),height:Math.max(10,n*.28)}})]});return r?(0,Q.jsx)(`button`,{type:`button`,onClick:()=>{s(!0),r()},"aria-label":b,className:B(x,`transition active:scale-95`),style:{width:n,height:n},children:S}):(0,Q.jsx)(`span`,{role:`img`,"aria-label":b,onClick:()=>s(!0),className:x,style:{width:n,height:n},children:S})}function Mt({profile:e,status:t,name:n,statusLine:r,statusTone:i=`muted`,spinning:a=!1,badge:o,onAvatar:s,onName:c,leading:l,trailing:u,under:d,size:f=72,className:p,scrim:m=!1}){return(0,Q.jsxs)(`header`,{className:B(`safe-top relative shrink-0 pt-1.5 pb-1`,m?`header-scrim`:`bg-bg`,p),children:[(0,Q.jsxs)(`div`,{className:`flex flex-col items-center px-16`,children:[(0,Q.jsxs)(`button`,{type:`button`,onClick:s,"aria-label":n,className:`relative rounded-full`,children:[(0,Q.jsx)(jt,{profile:e,status:t,size:f}),o!==void 0&&o>0&&(0,Q.jsx)(`span`,{className:`pointer-events-none absolute -right-0.5 top-0 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border-2 border-bg bg-rose-500 px-1 text-[10.5px] font-bold text-white`,children:o})]}),(0,Q.jsxs)(`div`,{className:`relative z-10 -mt-2.5 flex min-h-[44px] flex-col items-center`,children:[(0,Q.jsx)(Nt,{name:n,statusLine:r,statusTone:i,spinning:a,onClick:c??s}),d&&(0,Q.jsx)(`div`,{className:`mt-1.5 flex max-w-full items-center`,children:d})]})]}),l&&(0,Q.jsx)(`div`,{className:`absolute left-4 top-1.5 safe-top`,children:l}),u&&(0,Q.jsx)(`div`,{className:`absolute right-4 top-1.5 safe-top flex items-center gap-2`,children:u})]})}function Nt({name:e,statusLine:t,statusTone:n=`muted`,spinning:r=!1,onClick:i}){let a=(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(`span`,{className:`max-w-[240px] truncate text-[14px] leading-[18px] text-fg`,children:e}),t&&(0,Q.jsxs)(`span`,{className:B(`mt-0.5 flex max-w-[240px] items-center gap-1 text-[11.5px] leading-[14px]`,n===`accent`?`text-accent`:`text-muted`),children:[r&&(0,Q.jsx)(`span`,{className:`h-2.5 w-2.5 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent`}),(0,Q.jsx)(`span`,{className:`truncate`,children:t})]})]}),o=`flex flex-col items-center rounded-[14px] bg-surface px-3 py-1 shadow-[0_1px_4px_rgba(0,0,0,0.16)] dark:border dark:border-border dark:shadow-none`;return i?(0,Q.jsx)(`button`,{type:`button`,onClick:i,className:o,children:a}):(0,Q.jsx)(`div`,{className:o,children:a})}function Pt({onClick:e,label:t,children:n,dot:r=!1,small:i=!1,className:a,disabled:o=!1,expanded:s}){return(0,Q.jsxs)(`button`,{type:`button`,onClick:e,"aria-label":t,"aria-expanded":s,disabled:o,className:B(`relative flex items-center justify-center rounded-full bg-surface text-fg shadow-[0_1px_4px_rgba(0,0,0,0.14)] hover:bg-surface-2 disabled:opacity-50 dark:border dark:border-border dark:shadow-none`,i?`h-10 w-10`:`h-11 w-11`,a),children:[n,r&&(0,Q.jsx)(`span`,{className:`absolute right-[3px] top-[3px] h-[9px] w-[9px] rounded-full border-[1.5px] border-surface bg-accent`})]})}function Ft({children:e,className:t}){return(0,Q.jsx)(`h1`,{className:B(`px-5 pt-1.5 pb-1.5 text-[26px] font-bold leading-8 tracking-tight`,t),children:e})}export{ye as A,fe as B,Te as C,Se as D,B as E,ne as F,ue as G,oe as H,se as I,b as J,he as K,F as L,_e as M,ge as N,R as O,z as P,l as Q,L as R,W as S,we as T,I as U,pe as V,le as W,o as X,d as Y,s as Z,Le as _,gt as a,Ae as b,vt as c,mt as d,ht as f,Re as g,Je as h,jt as i,xe as j,be as k,Ct as l,Ke as m,Ft as n,St as o,Z as p,x as q,Pt as r,bt as s,Mt as t,yt as u,Fe as v,G as w,q as x,je as y,me as z}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/Onboarding-CgT7DiUR.js b/nanomuse/server/static/assets/Onboarding-CgT7DiUR.js deleted file mode 100644 index 74a5a96b..00000000 --- a/nanomuse/server/static/assets/Onboarding-CgT7DiUR.js +++ /dev/null @@ -1 +0,0 @@ -import{E as e,J as t,K as n,L as r,Q as i,Y as a,a as o,b as s,f as c,i as l,q as u,x as d}from"./MuseHeader-DHtaUVEa.js";import{k as f}from"./StarNudge-viN7_tiM.js";import{n as p}from"./external-link-3iD-Bcbv.js";import"./Form-heEKMZyj.js";import{r as m}from"./IdentityForm-Da0A3oTS.js";import{Tt as h,_t as g,t as _,yt as v}from"./index-CrH9-E2E.js";import{t as y}from"./AvatarPicker-zOz-S1nk.js";import{CalendarCard as b,ContactsCard as x,EmailCard as S,ModelCard as C}from"./ConnectionsScreen-DjHkABEK.js";var w=t(`lock`,[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]),T=i(a(),1),E=s();function D(){let{state:t,setTab:i,dismissOnboarding:a,refreshSettings:s,toast:p}=c(),[w,D]=(0,T.useState)(`welcome`),[M,N]=(0,T.useState)(null),[P,F]=(0,T.useState)(!1),[I,L]=(0,T.useState)(!1),R=n(),z=async()=>{L(!0);try{await d.cloudUseAsModel(),p(R(`The Cloud model is in use.`)),await H()}catch(e){p(e.message)}finally{L(!1)}},[B,V]=(0,T.useState)(null),H=async()=>{try{V(null),N(await d.connections())}catch(e){V(R(e.message)||R(`Could not load the connections.`))}};(0,T.useEffect)(()=>{H()},[t.connectionsVersion]);let U=async()=>{try{await d.onboarded(!0),await s()}catch{}a(),i(`chat`)},W=async()=>{F(!0);try{await d.firstrunStart(r()===`zh-CN`?`zh`:`en`)}catch{await d.onboarded(!0).catch(()=>void 0)}try{await s()}catch{}F(!1),a(),i(`chat`)},G={...m(t.profile,o,y[0]),proactivity:`default`,proactive:!0,goal_interval_minutes:60,quiet_hours:``},K=M?M.llm.key_source===`vault`||M.llm.key_source===`config`||!!M.providers[j(M)]?.no_key:!1,q=!!M&&(M.email.configured||M.calendar.feeds.length>0||M.contacts.sources.length>0),J=[`welcome`,`list`],Y=w===`welcome`?0:1;return(0,E.jsxs)(`div`,{className:`mx-auto flex h-[100dvh] max-w-[760px] flex-col bg-bg sm:border-x sm:border-border`,children:[(0,E.jsx)(`div`,{className:`titlebar-room`}),(0,E.jsxs)(`header`,{className:`safe-top shrink-0 px-5 pt-4 pb-2 flex items-center justify-between`,children:[(0,E.jsx)(`div`,{className:`flex gap-1`,children:J.map((t,n)=>(0,E.jsx)(`span`,{className:e(`h-1.5 rounded-full transition-all`,n<=Y?`w-5 bg-accent`:`w-1.5 bg-border`)},t))}),(0,E.jsx)(`button`,{type:`button`,onClick:()=>void U(),className:`text-[13px] text-muted`,children:R(`Skip setup`)})]}),(0,E.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-5 pb-6`,children:[w===`welcome`&&(0,E.jsxs)(`div`,{className:`flex h-full flex-col items-center justify-center text-center`,children:[(0,E.jsx)(l,{profile:G,size:96}),(0,E.jsx)(`h1`,{className:`mt-6 text-[28px] font-bold tracking-tight`,children:R(`Meet your nanoMuse`)}),(0,E.jsx)(`p`,{className:`mt-2 text-[15px] text-muted`,children:R(`A personal agent of your own. Three things to know:`)}),(0,E.jsxs)(`ul`,{className:`mt-6 w-full max-w-sm space-y-2.5 text-left`,children:[(0,E.jsx)(O,{n:1,title:R(`It does things for you.`),body:R(`Searches, browses, writes, books, reads mail, and hands you the result, not a list of links.`)}),(0,E.jsx)(O,{n:2,title:R(`It keeps working when you close the app.`),body:R(`Goals move forward between your visits; it reports in the Feed and notifies you when something is worth it.`)}),(0,E.jsx)(O,{n:3,title:R(`It asks you first where it matters.`),body:R(`A separate Sentinel reviews every action. Sending, paying, deleting: it stops and asks; your keys stay in a vault the model cannot read.`)})]}),(0,E.jsx)(`p`,{className:`mt-5 max-w-sm text-[13px] text-muted`,children:R(`There is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you.`)}),(0,E.jsx)(`p`,{className:`mt-2 max-w-sm text-[13px] text-muted`,children:(0,E.jsx)(`a`,{href:`https://github.com/zeeshanhaque21/nanoMuse`,target:`_blank`,rel:`noreferrer`,className:`underline decoration-dotted underline-offset-2 hover:text-accent`,children:R(`nanoMuse is a non-profit open-source community project, free forever.`)})})]}),w===`list`&&(0,E.jsxs)(`div`,{className:`pt-6`,children:[(0,E.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,E.jsx)(l,{profile:G,size:56}),(0,E.jsxs)(`div`,{className:`min-w-0`,children:[(0,E.jsx)(`h1`,{className:`text-[24px] font-bold tracking-tight truncate`,children:R(`Set it up`)}),(0,E.jsx)(`p`,{className:`text-[13.5px] text-muted truncate`,children:R(`A model, then the chat. A minute.`)})]})]}),(0,E.jsxs)(`ol`,{className:`mt-6 space-y-2`,children:[(0,E.jsx)(k,{done:K,title:R(`Add a model`),body:K?M?.llm.cloud?`nanoMuse Cloud · ${M.llm.model}`:`${M?.llm.model} · ${A(M?.llm.base_url??``)}`:t.hub?.account.signed_in?R(`Your account's model, or a key of your own.`):R(`Pick a provider and paste a key. Yours, stored in the vault.`),onClick:()=>D(`model`)}),(0,E.jsx)(k,{done:q,optional:!0,title:R(`Connect mail, calendar, contacts`),body:R(q?`Connected. Tap to add more.`:`Optional. It can read what came in, know your day, and who is who.`),onClick:()=>D(`connect`)}),(0,E.jsx)(k,{locked:!K,done:!1,title:R(`Start`),body:R(K?`Open the chat. It introduces itself and asks your name.`:`Needs a model first.`),onClick:()=>K&&!P&&void W()})]})]}),w===`model`&&(0,E.jsxs)(`div`,{className:`pt-6 space-y-4`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h1`,{className:`text-[26px] font-bold tracking-tight`,children:R(`The model behind it`)}),(0,E.jsx)(`p`,{className:`mt-1 text-[14px] text-muted`,children:K?M?.llm.cloud?R(`Your account's model, with its free allowance. Keep it, or switch to a key of your own here.`):R(`A model is already set up on the server. Keep it, or switch here.`):R(`Your account brings a model with a free allowance, nothing to set up. Or paste a key of your own; it is stored encrypted in the vault on the server, never shown to the model, and nothing you say passes through the relay.`)})]}),M?(0,E.jsxs)(E.Fragment,{children:[!K&&t.hub?.account.signed_in&&(0,E.jsxs)(`button`,{type:`button`,disabled:I,onClick:()=>void z(),className:`flex w-full items-center gap-3 rounded-3xl border border-accent/30 bg-accent/5 px-4 py-3.5 text-left`,children:[(0,E.jsx)(`span`,{className:`flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-accent text-white`,children:I?(0,E.jsx)(u,{size:18,className:`animate-spin`}):(0,E.jsx)(g,{size:18})}),(0,E.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,E.jsx)(`span`,{className:`block text-[15px] font-medium`,children:R(`Use the nanoMuse Cloud model`)}),(0,E.jsx)(`span`,{className:`block text-[12.5px] text-muted`,children:R(`Signed in as {hint}. A free allowance, nothing to paste. Recommended to start.`,{hint:t.hub.account.hint})})]}),(0,E.jsx)(v,{size:16,className:`shrink-0 text-muted`})]}),!K&&!t.hub?.account.signed_in&&(0,E.jsx)(`div`,{className:`rounded-3xl border border-border/70 bg-surface p-4`,children:(0,E.jsx)(_,{onSignedIn:()=>void H(),useAsModel:!0})}),!K&&(0,E.jsxs)(`p`,{className:`px-1 text-[12.5px] text-muted`,children:[(0,E.jsx)(`span`,{className:`font-medium`,children:R(`Or bring your own key`)}),` · `,f(R,t.hub?.account)]}),(0,E.jsx)(C,{data:M,onChange:()=>void H(),compact:!0})]}):B?(0,E.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface p-4 text-center`,children:[(0,E.jsx)(`p`,{className:`text-[13.5px] text-muted`,children:B}),(0,E.jsx)(`button`,{type:`button`,onClick:()=>void H(),className:`mt-3 rounded-full bg-surface-2 px-4 py-1.5 text-[13px] font-medium`,children:R(`Retry`)})]}):(0,E.jsx)(`div`,{className:`flex justify-center py-8 text-muted`,children:(0,E.jsx)(u,{className:`animate-spin`,size:20})})]}),w===`connect`&&(0,E.jsxs)(`div`,{className:`pt-6 space-y-4`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h1`,{className:`text-[26px] font-bold tracking-tight`,children:R(`Connect your mail, calendar and contacts`)}),(0,E.jsx)(`p`,{className:`mt-1 text-[14px] text-muted`,children:R(`Optional. With a mailbox connected it can read what came in and draft replies; it will always ask before sending. With a calendar it knows your day and finds free time. With your contacts it knows who is who. The browser and MCP servers are under Connections later.`)})]}),M?(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(S,{data:M,onChange:()=>void H(),compact:!0}),(0,E.jsx)(b,{data:M,onChange:()=>void H(),compact:!0}),(0,E.jsx)(x,{data:M,onChange:()=>void H(),compact:!0})]}):B?(0,E.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface p-4 text-center`,children:[(0,E.jsx)(`p`,{className:`text-[13.5px] text-muted`,children:B}),(0,E.jsx)(`button`,{type:`button`,onClick:()=>void H(),className:`mt-3 rounded-full bg-surface-2 px-4 py-1.5 text-[13px] font-medium`,children:R(`Retry`)})]}):(0,E.jsx)(`div`,{className:`flex justify-center py-8 text-muted`,children:(0,E.jsx)(u,{className:`animate-spin`,size:20})})]})]}),(0,E.jsxs)(`footer`,{className:`safe-bottom shrink-0 px-5 pb-5 pt-2 flex gap-2`,children:[(w===`model`||w===`connect`)&&(0,E.jsx)(`button`,{type:`button`,onClick:()=>D(`list`),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:R(`Back`)}),w===`welcome`&&(0,E.jsxs)(`button`,{type:`button`,onClick:()=>D(`list`),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[R(`Get started`),` `,(0,E.jsx)(h,{size:16})]}),w===`list`&&(0,E.jsxs)(`button`,{type:`button`,disabled:!K||P,onClick:()=>void W(),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[P?(0,E.jsx)(u,{size:16,className:`animate-spin`}):null,` `,R(K?`Start`:`Add a model to start`),` `,(0,E.jsx)(h,{size:16})]}),w===`model`&&(0,E.jsxs)(`button`,{type:`button`,disabled:!K,onClick:()=>D(`list`),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[R(K?`Done`:`Save a model to continue`),` `,(0,E.jsx)(h,{size:16})]}),w===`connect`&&(0,E.jsxs)(`button`,{type:`button`,onClick:()=>D(`list`),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[R(q?`Done`:`Skip for now`),` `,(0,E.jsx)(h,{size:16})]})]})]})}function O({n:e,title:t,body:n}){return(0,E.jsxs)(`li`,{className:`flex gap-3 rounded-3xl bg-surface border border-border/70 p-4`,children:[(0,E.jsx)(`span`,{className:`flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent/12 text-[13px] font-semibold text-accent`,children:e}),(0,E.jsxs)(`div`,{className:`text-[13.5px] leading-relaxed`,children:[(0,E.jsx)(`div`,{className:`font-medium text-fg`,children:t}),(0,E.jsx)(`div`,{className:`text-muted`,children:n})]})]})}function k({done:t,locked:r,optional:i,title:a,body:o,onClick:s}){let c=n();return(0,E.jsx)(`li`,{children:(0,E.jsxs)(`button`,{type:`button`,onClick:s,disabled:r,"aria-disabled":r,className:e(`w-full flex items-center gap-3 rounded-3xl border px-4 py-3.5 text-left transition-colors`,t?`border-accent/30 bg-accent/5`:`border-border/70 bg-surface`,r&&`opacity-50`),children:[(0,E.jsx)(`span`,{className:e(`flex h-7 w-7 shrink-0 items-center justify-center rounded-full`,t?`bg-accent text-white`:r?`bg-surface-2 text-muted`:`border border-border text-muted`),children:t?(0,E.jsx)(p,{size:15,strokeWidth:3}):r?(0,E.jsx)(w,{size:13}):null}),(0,E.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,E.jsxs)(`span`,{className:`flex items-center gap-2 text-[15px] font-medium`,children:[(0,E.jsx)(`span`,{className:`truncate`,children:a}),i&&(0,E.jsx)(`span`,{className:`rounded-full bg-surface-2 px-2 py-0.5 text-[10.5px] font-medium text-muted`,children:c(`optional`)})]}),(0,E.jsx)(`span`,{className:`block truncate text-[12.5px] text-muted`,children:o})]}),!r&&(0,E.jsx)(v,{size:16,className:`shrink-0 text-muted`})]})})}function A(e){try{return new URL(e).host}catch{return e}}function j(e){return Object.entries(e.providers).find(([t,n])=>t!==`custom`&&n.base_url&&e.llm.base_url.startsWith(n.base_url))?.[0]??`custom`}export{D as Onboarding}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/Onboarding-DBBDNzyW.js b/nanomuse/server/static/assets/Onboarding-DBBDNzyW.js new file mode 100644 index 00000000..348140cf --- /dev/null +++ b/nanomuse/server/static/assets/Onboarding-DBBDNzyW.js @@ -0,0 +1 @@ +import{E as e,J as t,K as n,L as r,Q as i,Y as a,a as o,b as s,f as c,i as l,q as u,x as d}from"./MuseHeader-RC--uR8o.js";import{k as f}from"./StarNudge-Df6LQyo9.js";import{n as p}from"./external-link-TvxP_dKX.js";import"./Form-H3s9oTHq.js";import{r as m}from"./IdentityForm-g-zvQ2IC.js";import{Tt as h,_t as g,t as _,yt as v}from"./index-tYqPtwPX.js";import{t as y}from"./AvatarPicker-BCihUURa.js";import{CalendarCard as b,ContactsCard as x,EmailCard as S,ModelCard as C}from"./ConnectionsScreen-CuIVN07d.js";var w=t(`lock`,[[`rect`,{width:`18`,height:`11`,x:`3`,y:`11`,rx:`2`,ry:`2`,key:`1w4ew1`}],[`path`,{d:`M7 11V7a5 5 0 0 1 10 0v4`,key:`fwvmzm`}]]),T=i(a(),1),E=s();function D(){let{state:t,setTab:i,dismissOnboarding:a,refreshSettings:s,toast:p}=c(),[w,D]=(0,T.useState)(`welcome`),[M,N]=(0,T.useState)(null),[P,F]=(0,T.useState)(!1),[I,L]=(0,T.useState)(!1),R=n(),z=async()=>{L(!0);try{await d.cloudUseAsModel(),p(R(`The Cloud model is in use.`)),await H()}catch(e){p(e.message)}finally{L(!1)}},[B,V]=(0,T.useState)(null),H=async()=>{try{V(null),N(await d.connections())}catch(e){V(R(e.message)||R(`Could not load the connections.`))}};(0,T.useEffect)(()=>{H()},[t.connectionsVersion]);let U=async()=>{try{await d.onboarded(!0),await s()}catch{}a(),i(`chat`)},W=async()=>{F(!0);try{await d.firstrunStart(r()===`zh-CN`?`zh`:`en`)}catch{await d.onboarded(!0).catch(()=>void 0)}try{await s()}catch{}F(!1),a(),i(`chat`)},G={...m(t.profile,o,y[0]),proactivity:`default`,proactive:!0,goal_interval_minutes:60,quiet_hours:``},K=M?M.llm.key_source===`vault`||M.llm.key_source===`config`||!!M.providers[j(M)]?.no_key||!!M.llm.endpoint&&M.llm.key_source===`none`:!1,q=!!M&&(M.email.configured||M.calendar.feeds.length>0||M.contacts.sources.length>0),J=[`welcome`,`list`],Y=w===`welcome`?0:1;return(0,E.jsxs)(`div`,{className:`mx-auto flex h-[100dvh] max-w-[760px] flex-col bg-bg sm:border-x sm:border-border`,children:[(0,E.jsx)(`div`,{className:`titlebar-room`}),(0,E.jsxs)(`header`,{className:`safe-top shrink-0 px-5 pt-4 pb-2 flex items-center justify-between`,children:[(0,E.jsx)(`div`,{className:`flex gap-1`,children:J.map((t,n)=>(0,E.jsx)(`span`,{className:e(`h-1.5 rounded-full transition-all`,n<=Y?`w-5 bg-accent`:`w-1.5 bg-border`)},t))}),(0,E.jsx)(`button`,{type:`button`,onClick:()=>void U(),className:`text-[13px] text-muted`,children:R(`Skip setup`)})]}),(0,E.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-5 pb-6`,children:[w===`welcome`&&(0,E.jsxs)(`div`,{className:`flex h-full flex-col items-center justify-center text-center`,children:[(0,E.jsx)(l,{profile:G,size:96}),(0,E.jsx)(`h1`,{className:`mt-6 text-[28px] font-bold tracking-tight`,children:R(`Meet your nanoMuse`)}),(0,E.jsx)(`p`,{className:`mt-2 text-[15px] text-muted`,children:R(`A personal agent of your own. Three things to know:`)}),(0,E.jsxs)(`ul`,{className:`mt-6 w-full max-w-sm space-y-2.5 text-left`,children:[(0,E.jsx)(O,{n:1,title:R(`It does things for you.`),body:R(`Searches, browses, writes, books, reads mail, and hands you the result, not a list of links.`)}),(0,E.jsx)(O,{n:2,title:R(`It keeps working when you close the app.`),body:R(`Goals move forward between your visits; it reports in the Feed and notifies you when something is worth it.`)}),(0,E.jsx)(O,{n:3,title:R(`It asks you first where it matters.`),body:R(`A separate Sentinel reviews every action. Sending, paying, deleting: it stops and asks; your keys stay in a vault the model cannot read.`)})]}),(0,E.jsx)(`p`,{className:`mt-5 max-w-sm text-[13px] text-muted`,children:R(`There is no form to fill in: once it has a model, it introduces itself in the chat and asks what to call you.`)}),(0,E.jsx)(`p`,{className:`mt-2 max-w-sm text-[13px] text-muted`,children:(0,E.jsx)(`a`,{href:`https://github.com/zeeshanhaque21/nanoMuse`,target:`_blank`,rel:`noreferrer`,className:`underline decoration-dotted underline-offset-2 hover:text-accent`,children:R(`nanoMuse is a non-profit open-source community project, free forever.`)})})]}),w===`list`&&(0,E.jsxs)(`div`,{className:`pt-6`,children:[(0,E.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,E.jsx)(l,{profile:G,size:56}),(0,E.jsxs)(`div`,{className:`min-w-0`,children:[(0,E.jsx)(`h1`,{className:`text-[24px] font-bold tracking-tight truncate`,children:R(`Set it up`)}),(0,E.jsx)(`p`,{className:`text-[13.5px] text-muted truncate`,children:R(`A model, then the chat. A minute.`)})]})]}),(0,E.jsxs)(`ol`,{className:`mt-6 space-y-2`,children:[(0,E.jsx)(k,{done:K,title:R(`Add a model`),body:K?M?.llm.cloud?`nanoMuse Cloud · ${M.llm.model}`:`${M?.llm.model} · ${A(M?.llm.base_url??``)}`:t.hub?.account.signed_in?R(`Your account's model, or a key of your own.`):R(`Pick a provider and paste a key. Yours, stored in the vault.`),onClick:()=>D(`model`)}),(0,E.jsx)(k,{done:q,optional:!0,title:R(`Connect mail, calendar, contacts`),body:R(q?`Connected. Tap to add more.`:`Optional. It can read what came in, know your day, and who is who.`),onClick:()=>D(`connect`)}),(0,E.jsx)(k,{locked:!K,done:!1,title:R(`Start`),body:R(K?`Open the chat. It introduces itself and asks your name.`:`Needs a model first.`),onClick:()=>K&&!P&&void W()})]})]}),w===`model`&&(0,E.jsxs)(`div`,{className:`pt-6 space-y-4`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h1`,{className:`text-[26px] font-bold tracking-tight`,children:R(`The model behind it`)}),(0,E.jsx)(`p`,{className:`mt-1 text-[14px] text-muted`,children:K?M?.llm.cloud?R(`Your account's model, with its free allowance. Keep it, or switch to a key of your own here.`):R(`A model is already set up on the server. Keep it, or switch here.`):R(`Your account brings a model with a free allowance, nothing to set up. Or paste a key of your own; it is stored encrypted in the vault on the server, never shown to the model, and nothing you say passes through the relay.`)})]}),M?(0,E.jsxs)(E.Fragment,{children:[!K&&t.hub?.account.signed_in&&(0,E.jsxs)(`button`,{type:`button`,disabled:I,onClick:()=>void z(),className:`flex w-full items-center gap-3 rounded-3xl border border-accent/30 bg-accent/5 px-4 py-3.5 text-left`,children:[(0,E.jsx)(`span`,{className:`flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-accent text-white`,children:I?(0,E.jsx)(u,{size:18,className:`animate-spin`}):(0,E.jsx)(g,{size:18})}),(0,E.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,E.jsx)(`span`,{className:`block text-[15px] font-medium`,children:R(`Use the nanoMuse Cloud model`)}),(0,E.jsx)(`span`,{className:`block text-[12.5px] text-muted`,children:R(`Signed in as {hint}. A free allowance, nothing to paste. Recommended to start.`,{hint:t.hub.account.hint})})]}),(0,E.jsx)(v,{size:16,className:`shrink-0 text-muted`})]}),!K&&!t.hub?.account.signed_in&&(0,E.jsx)(`div`,{className:`rounded-3xl border border-border/70 bg-surface p-4`,children:(0,E.jsx)(_,{onSignedIn:()=>void H(),useAsModel:!0})}),!K&&(0,E.jsxs)(`p`,{className:`px-1 text-[12.5px] text-muted`,children:[(0,E.jsx)(`span`,{className:`font-medium`,children:R(`Or bring your own key`)}),` · `,f(R,t.hub?.account)]}),(0,E.jsx)(C,{data:M,onChange:()=>void H(),compact:!0})]}):B?(0,E.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface p-4 text-center`,children:[(0,E.jsx)(`p`,{className:`text-[13.5px] text-muted`,children:B}),(0,E.jsx)(`button`,{type:`button`,onClick:()=>void H(),className:`mt-3 rounded-full bg-surface-2 px-4 py-1.5 text-[13px] font-medium`,children:R(`Retry`)})]}):(0,E.jsx)(`div`,{className:`flex justify-center py-8 text-muted`,children:(0,E.jsx)(u,{className:`animate-spin`,size:20})})]}),w===`connect`&&(0,E.jsxs)(`div`,{className:`pt-6 space-y-4`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h1`,{className:`text-[26px] font-bold tracking-tight`,children:R(`Connect your mail, calendar and contacts`)}),(0,E.jsx)(`p`,{className:`mt-1 text-[14px] text-muted`,children:R(`Optional. With a mailbox connected it can read what came in and draft replies; it will always ask before sending. With a calendar it knows your day and finds free time. With your contacts it knows who is who. The browser and MCP servers are under Connections later.`)})]}),M?(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(S,{data:M,onChange:()=>void H(),compact:!0}),(0,E.jsx)(b,{data:M,onChange:()=>void H(),compact:!0}),(0,E.jsx)(x,{data:M,onChange:()=>void H(),compact:!0})]}):B?(0,E.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface p-4 text-center`,children:[(0,E.jsx)(`p`,{className:`text-[13.5px] text-muted`,children:B}),(0,E.jsx)(`button`,{type:`button`,onClick:()=>void H(),className:`mt-3 rounded-full bg-surface-2 px-4 py-1.5 text-[13px] font-medium`,children:R(`Retry`)})]}):(0,E.jsx)(`div`,{className:`flex justify-center py-8 text-muted`,children:(0,E.jsx)(u,{className:`animate-spin`,size:20})})]})]}),(0,E.jsxs)(`footer`,{className:`safe-bottom shrink-0 px-5 pb-5 pt-2 flex gap-2`,children:[(w===`model`||w===`connect`)&&(0,E.jsx)(`button`,{type:`button`,onClick:()=>D(`list`),className:`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,children:R(`Back`)}),w===`welcome`&&(0,E.jsxs)(`button`,{type:`button`,onClick:()=>D(`list`),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[R(`Get started`),` `,(0,E.jsx)(h,{size:16})]}),w===`list`&&(0,E.jsxs)(`button`,{type:`button`,disabled:!K||P,onClick:()=>void W(),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[P?(0,E.jsx)(u,{size:16,className:`animate-spin`}):null,` `,R(K?`Start`:`Add a model to start`),` `,(0,E.jsx)(h,{size:16})]}),w===`model`&&(0,E.jsxs)(`button`,{type:`button`,disabled:!K,onClick:()=>D(`list`),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[R(K?`Done`:`Save a model to continue`),` `,(0,E.jsx)(h,{size:16})]}),w===`connect`&&(0,E.jsxs)(`button`,{type:`button`,onClick:()=>D(`list`),className:e(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`flex-1 py-3`),children:[R(q?`Done`:`Skip for now`),` `,(0,E.jsx)(h,{size:16})]})]})]})}function O({n:e,title:t,body:n}){return(0,E.jsxs)(`li`,{className:`flex gap-3 rounded-3xl bg-surface border border-border/70 p-4`,children:[(0,E.jsx)(`span`,{className:`flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-accent/12 text-[13px] font-semibold text-accent`,children:e}),(0,E.jsxs)(`div`,{className:`text-[13.5px] leading-relaxed`,children:[(0,E.jsx)(`div`,{className:`font-medium text-fg`,children:t}),(0,E.jsx)(`div`,{className:`text-muted`,children:n})]})]})}function k({done:t,locked:r,optional:i,title:a,body:o,onClick:s}){let c=n();return(0,E.jsx)(`li`,{children:(0,E.jsxs)(`button`,{type:`button`,onClick:s,disabled:r,"aria-disabled":r,className:e(`w-full flex items-center gap-3 rounded-3xl border px-4 py-3.5 text-left transition-colors`,t?`border-accent/30 bg-accent/5`:`border-border/70 bg-surface`,r&&`opacity-50`),children:[(0,E.jsx)(`span`,{className:e(`flex h-7 w-7 shrink-0 items-center justify-center rounded-full`,t?`bg-accent text-white`:r?`bg-surface-2 text-muted`:`border border-border text-muted`),children:t?(0,E.jsx)(p,{size:15,strokeWidth:3}):r?(0,E.jsx)(w,{size:13}):null}),(0,E.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,E.jsxs)(`span`,{className:`flex items-center gap-2 text-[15px] font-medium`,children:[(0,E.jsx)(`span`,{className:`truncate`,children:a}),i&&(0,E.jsx)(`span`,{className:`rounded-full bg-surface-2 px-2 py-0.5 text-[10.5px] font-medium text-muted`,children:c(`optional`)})]}),(0,E.jsx)(`span`,{className:`block truncate text-[12.5px] text-muted`,children:o})]}),!r&&(0,E.jsx)(v,{size:16,className:`shrink-0 text-muted`})]})})}function A(e){try{return new URL(e).host}catch{return e}}function j(e){return Object.entries(e.providers).find(([t,n])=>t!==`custom`&&n.base_url&&e.llm.base_url.startsWith(n.base_url))?.[0]??`custom`}export{D as Onboarding}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/SettingsScreen-B84xmoRg.js b/nanomuse/server/static/assets/SettingsScreen-B84xmoRg.js new file mode 100644 index 00000000..c4d68328 --- /dev/null +++ b/nanomuse/server/static/assets/SettingsScreen-B84xmoRg.js @@ -0,0 +1 @@ +import{A as e,E as t,F as n,G as r,H as i,J as a,K as o,L as s,Q as c,T as l,Y as u,_ as d,a as f,b as p,f as m,g as h,h as g,m as _,v,x as y}from"./MuseHeader-RC--uR8o.js";import{A as b,C as x,S,T as ee,g as C,u as w,w as T}from"./StarNudge-Df6LQyo9.js";import{n as te}from"./external-link-TvxP_dKX.js";import{n as E}from"./Form-H3s9oTHq.js";import{t as ne}from"./clapperboard-CuoVHGrc.js";import{t as re}from"./database-zap-Bce5E3JJ.js";import{t as D}from"./plug-DV_c5_Ny.js";import{n as ie,r as O,t as ae}from"./IdentityForm-g-zvQ2IC.js";import{Ct as k,D as oe,E as A,G as j,H as M,I as N,K as P,L as F,O as se,T as I,U as L,_t as R,a as z,et as ce,gt as B,i as V,it as le,j as ue,l as de,n as fe,nt as H,o as U,r as W,st as pe,tt as G,u as me,ut as K,xt as q,yt as J}from"./index-tYqPtwPX.js";import{t as Y}from"./BackBar-JEuC8Q_e.js";import{t as X}from"./AvatarPicker-BCihUURa.js";var he=a(`battery-full`,[[`path`,{d:`M10 10v4`,key:`1mb2ec`}],[`path`,{d:`M14 10v4`,key:`1nt88p`}],[`path`,{d:`M22 14v-4`,key:`14q9d5`}],[`path`,{d:`M6 10v4`,key:`1n77qd`}],[`rect`,{x:`2`,y:`6`,width:`16`,height:`12`,rx:`2`,key:`13zb55`}]]),ge=a(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]),_e=a(`chart-column`,[[`path`,{d:`M3 3v16a2 2 0 0 0 2 2h16`,key:`c24i48`}],[`path`,{d:`M18 17V9`,key:`2bz60n`}],[`path`,{d:`M13 17V5`,key:`1frdt8`}],[`path`,{d:`M8 17v-3`,key:`17ska0`}]]),ve=a(`message-square-warning`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M12 15h.01`,key:`q59x07`}],[`path`,{d:`M12 7v4`,key:`xawao1`}]]),ye=a(`puzzle`,[[`path`,{d:`M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z`,key:`w46dr5`}]]),be=a(`shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),xe=a(`smile`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M8 14s1.5 2 4 2 4-2 4-2`,key:`1y1vjs`}],[`line`,{x1:`9`,x2:`9.01`,y1:`9`,y2:`9`,key:`yxxnd0`}],[`line`,{x1:`15`,x2:`15.01`,y1:`9`,y2:`9`,key:`1p4y9e`}]]),Se=a(`zap`,[[`path`,{d:`M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z`,key:`1xq2db`}]]),Z=c(u(),1),Ce=`(min-width: 64rem)`;function we(){return typeof window>`u`?!1:document.documentElement.classList.contains(`desktop`)?!0:typeof window.matchMedia==`function`&&window.matchMedia(Ce).matches}function Te(){let[e,t]=(0,Z.useState)(we);return(0,Z.useEffect)(()=>{if(typeof window.matchMedia!=`function`)return;let e=window.matchMedia(Ce),n=()=>t(we());return e.addEventListener(`change`,n),()=>e.removeEventListener(`change`,n)},[]),e}var Q=p(),Ee=`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 outline-none focus:ring-2 focus:ring-accent/40`,De=[{id:`ask`,title:`Balanced`,text:`Browse, read and write files freely; stop for anything hard to undo: email, purchases, shell commands.`,icon:(0,Q.jsx)(j,{size:18})},{id:`strict`,title:`Cautious`,text:`Also ask before moderate actions like fetching web pages or writing files.`,icon:(0,Q.jsx)(be,{size:18})},{id:`auto`,title:`Hands-off`,text:`Approve everything automatically except explicit deny rules. For trusted, unattended runs only.`,icon:(0,Q.jsx)(P,{size:18})}];function Oe(){let{state:e,refreshSettings:a,setTab:s,toast:c}=m(),u=e.settings,[d,p]=(0,Z.useState)(()=>O(e.profile,f,X[0])),[h,v]=(0,Z.useState)(!1),x=o(),S=r(),C=g(),[T,ne]=(0,Z.useState)(null),[re,D]=(0,Z.useState)(!1),k=(0,Z.useId)(),j=`${k}-interval`,M=`${k}-theme`,N=`${k}-app-language`,P=`${k}-reply-language`;(0,Z.useEffect)(()=>{u||a()},[u,a]);let L=(0,Z.useCallback)(async(e=!1)=>{D(!0);try{ne(await y.update(e))}catch{ne(null)}finally{D(!1)}},[]);(0,Z.useEffect)(()=>{L()},[L]),(0,Z.useEffect)(()=>{e.profile&&p(O(e.profile,``,X[0]))},[e.profile]);let R=async(e,t)=>{v(!0);try{await y.updateSettings(e),await a(),t&&c(t)}catch(e){c(e.message)}finally{v(!1)}},z=O(e.profile,``,X[0]),ce=!!e.profile&&Object.keys(d).some(e=>d[e]!==z[e]),B=e.profile?.name??`nanoMuse`,V=se(),le=me(),H=Te()&&!e.lite,[U,W]=(0,Z.useState)(null),G=[{id:`who`,title:B},{id:`sentinel`,title:x(`Safety · Sentinel`)},{id:`proactivity`,title:x(`Proactivity`)},{id:`notifications`,title:x(`Notifications`)},...A()?[{id:`keep-running`,title:x(`Keep it running`)}]:[],{id:`model`,title:x(`Model`)},{id:`appearance`,title:x(`Appearance`)},...F()?[{id:`desktop`,title:x(`Desktop app`)}]:[],{id:`data`,title:x(`Data controls`)},{id:`developer`,title:x(`Developer`)},{id:`about`,title:x(`About`)}],K=e=>H||U===e,[q,he]=(0,Z.useState)(()=>{let e=window.location.hash.slice(1);return ke.includes(e)?e:null});if((0,Z.useEffect)(()=>{if(q&&u){if(he(null),history.replaceState(null,``,window.location.pathname+window.location.search),!H){W(q);return}requestAnimationFrame(()=>document.getElementById(q)?.scrollIntoView({behavior:`smooth`,block:`start`}))}},[q,u,H]),!H&&U===null)return(0,Q.jsx)(je,{release:T,onOpen:W});let _e=G.find(e=>e.id===U);return(0,Q.jsxs)(`div`,{className:`flex h-full flex-col`,children:[H?(0,Q.jsx)(Y,{title:x(`Settings`),description:x(`Its name and look, how careful it is, how often it speaks up.`)}):(0,Q.jsx)(Y,{title:_e?.title??x(`Settings`),onBack:()=>W(null),backLabel:x(`Settings`)}),(0,Q.jsxs)(`div`,{className:`flex min-h-0 flex-1`,children:[H&&(0,Q.jsx)(`nav`,{className:`flex w-[188px] shrink-0 flex-col gap-0.5 self-start pl-4 pr-2 pt-1`,"aria-label":x(`Settings`),children:G.map(e=>(0,Q.jsx)(`button`,{type:`button`,onClick:()=>document.getElementById(e.id)?.scrollIntoView({behavior:`smooth`,block:`start`}),className:`truncate rounded-xl px-3 py-1.5 text-left text-[13px] font-medium text-fg/75 hover:bg-surface-2 hover:text-fg`,children:e.title},e.id))}),(0,Q.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-5`,children:[K(`who`)&&(0,Q.jsxs)($,{title:B,id:`who`,plain:!H,children:[(0,Q.jsx)(ae,{value:d,onChange:p,suggestions:!1,inputCls:Ee,onStudio:()=>s(`avatar`)}),(0,Q.jsx)(`button`,{type:`button`,disabled:!ce||h,onClick:()=>void R({profile:ie(d)},x(`Saved`)),className:`w-full rounded-2xl bg-accent text-accent-fg py-2.5 font-medium disabled:opacity-40`,children:x(`Save`)})]}),K(`sentinel`)&&(0,Q.jsxs)($,{title:x(`Safety · Sentinel`),id:`sentinel`,plain:!H,children:[(0,Q.jsx)(`p`,{className:`text-[13px] text-muted -mt-1`,children:x(`A separate gatekeeper reviews every action. Pick how often it should check in with you.`)}),(0,Q.jsx)(`div`,{className:`space-y-2`,children:De.map(e=>(0,Q.jsxs)(`button`,{type:`button`,onClick:()=>void R({sentinel_mode:e.id}),"aria-pressed":u?.sentinel.mode===e.id,className:t(`w-full text-left rounded-2xl border px-3.5 py-3 flex items-start gap-3 transition`,u?.sentinel.mode===e.id?`border-accent bg-accent/8`:`border-border`),children:[(0,Q.jsx)(`div`,{className:t(`mt-0.5`,e.id===`auto`?`text-rose-500`:`text-accent`),children:e.icon}),(0,Q.jsxs)(`div`,{className:`flex-1`,children:[(0,Q.jsx)(`div`,{className:`font-medium text-[14.5px]`,children:x(e.title)}),(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug mt-0.5`,children:x(e.text)})]}),u?.sentinel.mode===e.id&&(0,Q.jsx)(te,{size:18,className:`text-accent mt-0.5`})]},e.id))}),u&&(0,Q.jsxs)(`div`,{className:`text-[12.5px] text-muted leading-relaxed`,children:[x(`Always asks before: {tools}.`,{tools:u.sentinel.always_ask_tools.map(e=>x(e)).join(`, `)||x(`none`)}),` `,u.sentinel.taint_tracking&&x(`After reading private data, new network destinations need approval.`)]}),u?.sandbox&&(0,Q.jsxs)(`div`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2/60 px-3.5 py-2.5 text-[12.5px] leading-snug`,children:[(0,Q.jsx)(ge,{size:15,className:t(`shrink-0 mt-[2px]`,u.sandbox.active?`text-emerald-500`:`text-muted`)}),(0,Q.jsxs)(`div`,{children:[(0,Q.jsxs)(`div`,{className:`font-medium text-fg`,children:[u.sandbox.active?x(`Commands run in a sandbox`):x(`Commands run without a sandbox`),(0,Q.jsxs)(`span`,{className:`text-muted font-normal`,children:[` · `,u.sandbox.status]})]}),(0,Q.jsx)(`div`,{className:`text-muted mt-0.5`,children:u.sandbox.active?x(`Each shell or Python call gets its own namespace: only the workspace is writable, your home directory is not there, and there is no network unless the command needs it.`):u.sandbox.status.includes(`container`)?x(`Each shell or Python call runs in the workspace with a scrubbed environment; the container is the boundary.`):x(`Each shell or Python call runs in the workspace with a scrubbed environment. On Linux, installing bubblewrap gives each one its own namespace.`)})]})]})]}),K(`proactivity`)&&(0,Q.jsxs)($,{title:x(`Proactivity`),id:`proactivity`,plain:!H,children:[(0,Q.jsx)(Le,{value:e.profile?.proactivity??`default`,onChange:e=>void R({profile:{proactivity:e}})}),(0,Q.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,Q.jsxs)(`label`,{htmlFor:j,className:`text-[13.5px] flex-1`,children:[x(`Check-in interval`),e.profile&&e.profile.proactivity!=="default"&&e.profile.proactivity!==`off`&&(0,Q.jsx)(`span`,{className:`block text-[12px] text-muted`,children:e.profile.proactivity===`low`?x(`Doubled at this level`):x(`Halved at this level`)})]}),(0,Q.jsx)(`select`,{id:j,value:e.profile?.goal_interval_minutes??60,onChange:e=>void R({profile:{goal_interval_minutes:Number(e.target.value)}}),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:[...new Set([15,30,60,120,240,480,1440,e.profile?.goal_interval_minutes??60])].sort((e,t)=>e-t).map(e=>(0,Q.jsx)(`option`,{value:e,children:e<60?x(`{n} min`,{n:e}):e%60?`${x(`{n} h`,{n:Math.floor(e/60)})} ${x(`{n} min`,{n:e%60})}`:x(`{n} h`,{n:e/60})},e))})]}),(0,Q.jsx)(Re,{value:e.profile?.quiet_hours??``,onChange:e=>void R({profile:{quiet_hours:e}})})]}),K(`notifications`)&&(0,Q.jsx)($,{title:x(`Notifications`),id:`notifications`,plain:!H,children:I()?(0,Q.jsx)(Me,{name:e.profile?.name??`nanoMuse`}):(0,Q.jsx)(Fe,{name:e.profile?.name??`nanoMuse`})}),A()&&K(`keep-running`)&&(0,Q.jsx)($,{title:x(`Keep it running`),id:`keep-running`,plain:!H,children:(0,Q.jsx)(Ne,{name:e.profile?.name??`nanoMuse`})}),K(`model`)&&(0,Q.jsxs)($,{title:x(`Model`),id:`model`,plain:!H,children:[u&&(0,Q.jsxs)(`button`,{type:`button`,onClick:()=>s(`connections`),className:`w-full text-[13.5px] flex items-center justify-between`,children:[(0,Q.jsx)(`span`,{className:`text-muted`,children:x(`Provider / model`)}),(0,Q.jsxs)(`span`,{className:`font-mono text-[12.5px] flex items-center gap-1`,children:[u.llm.model,` `,(0,Q.jsx)(J,{size:14,className:`text-muted`})]})]}),u?.llm.cloud&&(0,Q.jsxs)(`button`,{type:`button`,onClick:()=>ue(s,b(e.hub?.account).preset),className:`w-full text-[13.5px] flex items-center justify-between`,children:[(0,Q.jsx)(`span`,{className:`text-muted`,children:x(`Use my own API key or a plan I pay for`)}),(0,Q.jsxs)(`span`,{className:`text-[12.5px] text-muted flex items-center gap-1`,children:[x(`{first} first, then {others}`,{first:b(e.hub?.account).label,others:ee(e.hub?.account)?x(`DeepSeek, Kimi, OpenAI, ChatGPT…`):x(`OpenAI, ChatGPT, Gemini, DeepSeek…`)}),` `,(0,Q.jsx)(J,{size:14})]})]}),(0,Q.jsxs)(`button`,{type:`button`,onClick:()=>s(`connections`),className:`w-full text-[13.5px] flex items-center justify-between`,children:[(0,Q.jsx)(`span`,{className:`text-muted`,children:x(`Connections`)}),(0,Q.jsxs)(`span`,{className:`text-[12.5px] text-muted flex items-center gap-1`,children:[x(`E-mail, calendar, browser, MCP servers`),` `,(0,Q.jsx)(J,{size:14})]})]})]}),K(`appearance`)&&(0,Q.jsxs)($,{title:x(`Appearance`),id:`appearance`,plain:!H,children:[(0,Q.jsx)(E,{label:x(`Show the agent's steps`),hint:x(`Every tool it uses becomes a chip in the chat. Off, the chat keeps to the conversation and the line under the name says what it is on. This device only.`),checked:le,onChange:de}),(0,Q.jsx)(E,{label:x(`Show thinking`),hint:x(`Reveal the model's reasoning under each reply when the provider exposes it.`),checked:!!u?.agent.show_thinking,onChange:e=>void R({show_thinking:e})}),(0,Q.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,Q.jsxs)(`label`,{htmlFor:M,className:`text-[13.5px] flex-1`,children:[x(`Appearance`),(0,Q.jsx)(`span`,{className:`block text-[12px] text-muted`,children:x(`This device only`)})]}),(0,Q.jsxs)(`select`,{id:M,value:C,onChange:e=>_(e.target.value),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:[(0,Q.jsx)(`option`,{value:`light`,children:x(`Light`)}),(0,Q.jsx)(`option`,{value:`dark`,children:x(`Dark`)}),(0,Q.jsx)(`option`,{value:`system`,children:x(`Follow the system`)})]})]}),(0,Q.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,Q.jsxs)(`label`,{htmlFor:N,className:`text-[13.5px] flex-1`,children:[x(`App language`),(0,Q.jsx)(`span`,{className:`block text-[12px] text-muted`,children:x(`This device only`)})]}),(0,Q.jsx)(`select`,{id:N,value:S,onChange:e=>i(e.target.value),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:n.map(e=>(0,Q.jsx)(`option`,{value:e.value,children:e.value===`auto`?x(`Auto`):e.label},e.value))})]}),(0,Q.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,Q.jsxs)(`label`,{htmlFor:P,className:`text-[13.5px] flex-1`,children:[x(`Reply language`),(0,Q.jsx)(`span`,{className:`block text-[12px] text-muted`,children:x(`What {name} writes in`,{name:e.profile?.name??`nanoMuse`})})]}),(0,Q.jsxs)(`select`,{id:P,value:u?.agent.language??`auto`,onChange:e=>void R({language:e.target.value}),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:[(0,Q.jsx)(`option`,{value:`auto`,children:x(`Match mine`)}),(0,Q.jsx)(`option`,{value:`English`,children:`English`}),(0,Q.jsx)(`option`,{value:`中文`,children:`中文`}),(0,Q.jsx)(`option`,{value:`日本語`,children:`日本語`}),(0,Q.jsx)(`option`,{value:`Español`,children:`Español`}),(0,Q.jsx)(`option`,{value:`Deutsch`,children:`Deutsch`}),(0,Q.jsx)(`option`,{value:`Français`,children:`Français`})]})]})]}),F()&&K(`desktop`)&&(0,Q.jsx)($,{title:x(`Desktop app`),id:`desktop`,plain:!H,children:(0,Q.jsx)(ze,{})}),K(`data`)&&(0,Q.jsx)($,{title:x(`Data controls`),id:`data`,plain:!H,children:(0,Q.jsx)(fe,{flush:!0})}),K(`developer`)&&(0,Q.jsxs)($,{title:x(`Developer`),id:`developer`,plain:!H,children:[(0,Q.jsx)(E,{label:x(`Developer tools`),hint:x(`The Coding screen (Cursor, Codex and the other coding agents on this computer) in the sidebar, and the runtime's address below. This device only.`),checked:V,onChange:oe}),V&&(0,Q.jsxs)(`div`,{className:`text-[13px] text-muted space-y-1`,children:[(0,Q.jsxs)(`div`,{className:`break-all`,children:[x(`Runtime:`),` `,window.location.origin,` · `,(0,Q.jsx)(`span`,{className:`text-fg/70`,children:x(`the token is in the server_token file in the data folder`)})]}),(0,Q.jsxs)(`div`,{children:[(0,Q.jsx)(`a`,{href:`https://github.com/zeeshanhaque21/nanoMuse/blob/main/docs/cli.md`,target:`_blank`,rel:`noreferrer`,className:`text-accent underline-offset-2 hover:underline`,children:x(`The command line and the API`)}),` · `,(0,Q.jsx)(`a`,{href:`https://github.com/zeeshanhaque21/nanoMuse/blob/main/docs/harness.md`,target:`_blank`,rel:`noreferrer`,className:`text-accent underline-offset-2 hover:underline`,children:x(`nanoMuse on DeepSeek Harness`)})]})]})]}),K(`about`)&&(0,Q.jsxs)($,{title:x(`About`),id:`about`,plain:!H,children:[(0,Q.jsx)(w,{}),(0,Q.jsx)(Ae,{installed:e.version,release:T,checking:re,onCheck:()=>void L(!0)}),(0,Q.jsxs)(`div`,{className:`text-[13px] text-muted space-y-1`,children:[u&&(0,Q.jsxs)(`div`,{className:`break-all`,children:[x(`Data:`),` `,u.data_dir]}),u&&(0,Q.jsxs)(`div`,{className:`break-all`,children:[x(`Workspace:`),` `,u.agent.workspace]}),(0,Q.jsx)(`div`,{children:e.connected?x(`Connected`):x(`Reconnecting…`)})]}),(0,Q.jsxs)(`button`,{type:`button`,onClick:()=>{let e=I();if(e){e.disconnect();return}l(``),window.location.reload()},className:`w-full rounded-2xl border border-border py-2.5 text-[14px] font-medium flex items-center justify-center gap-2 text-muted`,children:[(0,Q.jsx)(pe,{size:16}),` `,I()?x(`Disconnect from this server`):x(`Forget this device's access token`)]})]})]})]})]})}var ke=[`who`,`sentinel`,`proactivity`,`notifications`,`keep-running`,`model`,`appearance`,`desktop`,`data`,`developer`,`about`];function Ae({installed:t,release:n,checking:r,onCheck:i}){let a=o(),s=!!I(),c=(0,Q.jsx)(`button`,{type:`button`,onClick:i,disabled:r,className:`text-accent underline-offset-2 hover:underline disabled:opacity-60`,children:a(r?`Checking…`:`Check now`)}),l;return l=s?a(`The phone app checks for updates on its own.`):n&&!n.enabled?a(`Update checks are off here.`):r&&!n?a(`Checking…`):n?.latest&&n.newer?(0,Q.jsxs)(Q.Fragment,{children:[a(`{version} is out`,{version:n.latest}),` · `,(0,Q.jsx)(`a`,{href:n.download_url||n.url,target:`_blank`,rel:`noreferrer`,className:`font-medium text-accent underline-offset-2 hover:underline`,children:a(`Update`)})]}):n?.latest?(0,Q.jsxs)(Q.Fragment,{children:[a(`Latest {version}; you have it`,{version:n.latest}),` · `,c]}):(0,Q.jsxs)(Q.Fragment,{children:[a(`Could not check`),` · `,c]}),(0,Q.jsxs)(`div`,{className:`rounded-2xl bg-surface-2/60 px-3.5 py-2.5 text-[13px]`,children:[(0,Q.jsx)(`div`,{className:`font-medium`,children:a(`nanoMuse {version}`,{version:t})}),(0,Q.jsx)(`div`,{className:`mt-0.5 text-muted`,children:l}),!s&&n?.latest&&n.newer&&(0,Q.jsx)(`div`,{className:`mt-1 text-[12px] text-muted`,children:a(`To update: pip install -U nanomuse, pull the new image, or use the desktop app's Update.`)}),!s&&n?.checked_at&&(0,Q.jsx)(`div`,{className:`mt-1 text-[12px] text-muted`,children:a(`Checked {when}`,{when:e(n.checked_at)})})]})}function je({release:e,onOpen:t}){let{state:n,setTab:r}=m(),i=o(),a=n.settings,c=n.hub,l=T(),u=n.profile?.name??`nanoMuse`,d=g(),f=c?.devices.filter(e=>!e.this&&e.kind!==`web`)??[],p=f.filter(e=>e.kind===`computer`),h=f.filter(e=>e.online&&e.actions?.includes(`coding.sessions`)),_=a?.sentinel.mode,v=_===`ask`?i(`Balanced`):_===`strict`?i(`Cautious`):_===`auto`?i(`Hands-off`):``,y=n.profile?.proactivity??`default`,b=i(y===`off`?`Off`:y===`low`?`Low`:y===`high`?`High`:`Default`),ee=i(d===`dark`?`Dark`:d===`light`?`Light`:`Follow the system`),w=i(`none`);return(0,Q.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,Q.jsx)(Y,{title:i(`Settings`)}),(0,Q.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-8 pt-2`,children:[(0,Q.jsxs)(V,{className:`mb-3`,children:[(0,Q.jsxs)(`button`,{type:`button`,onClick:()=>r(`connections`),className:`flex w-full items-center gap-3 px-4 py-3.5 text-left hover:bg-surface-2/60`,children:[(0,Q.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,Q.jsx)(`span`,{className:`block truncate text-[16px] font-medium leading-[21px]`,children:a?.llm.model||i(`No model yet`)}),(0,Q.jsx)(`span`,{className:`mt-0.5 block truncate text-[12.5px] leading-4 text-muted`,children:a?a.llm.cloud?`nanoMuse Cloud`:a.llm.provider:i(`Add a provider and pick its models`)})]}),(0,Q.jsx)(`span`,{className:`text-[14px] font-medium text-accent`,children:i(`Change`)})]}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(R,{size:22}),label:`nanoMuse Cloud`,value:c?.account.signed_in?c.account.hint:i(`Sign in`),onClick:()=>r(`account`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(ne,{size:22}),label:i(`Image & video models`),value:C(l,`image`)===!1?x(i,`image`,S(c?.account),s()):C(l,`video`)===!1?x(i,`video`,S(c?.account),s()):void 0,onClick:()=>r(`connections`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(_e,{size:22}),label:i(`Usage`),onClick:()=>r(`account`)})]}),(0,Q.jsxs)(V,{className:`mb-3`,children:[(0,Q.jsx)(U,{icon:(0,Q.jsx)(L,{size:22}),label:i(`Name & personality`),value:u,onClick:()=>t(`who`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(xe,{size:22}),label:i(`Avatar`),onClick:()=>r(`avatar`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(q,{size:22}),label:i(`Memory`),onClick:()=>r(`memory`)}),a?.skills?.enabled!==!1&&(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(ye,{size:22}),label:i(`Skills`),value:a?String(a.skills.count):void 0,onClick:()=>r(`skills`)})]}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(D,{size:22}),label:i(`Connections`),value:a?.connectors.mcp.length?`${a.connectors.mcp.length} MCP`:void 0,onClick:()=>r(`connections`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(le,{size:22}),label:i(`Chat apps`),onClick:()=>r(`channels`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(H,{size:22}),label:i(`Devices`),value:p.length?String(p.length):w,onClick:()=>r(`devices`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(M,{size:22}),label:i(`Coding agents`),value:h.length?String(h.length):w,onClick:()=>r(`coding`)})]}),(0,Q.jsxs)(V,{className:`mb-3`,children:[(0,Q.jsx)(U,{icon:(0,Q.jsx)(j,{size:22}),label:i(`Safety · Sentinel`),value:v,onClick:()=>t(`sentinel`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(Se,{size:22}),label:i(`Proactivity`),value:b,onClick:()=>t(`proactivity`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(k,{size:22}),label:i(`Notifications`),onClick:()=>t(`notifications`)}),A()&&(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(he,{size:22}),label:i(`Keep it running`),onClick:()=>t(`keep-running`)})]})]}),(0,Q.jsxs)(V,{className:`mb-3`,children:[(0,Q.jsx)(U,{icon:(0,Q.jsx)(ce,{size:22}),label:i(`Appearance`),value:ee,onClick:()=>t(`appearance`)}),F()&&(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(H,{size:22}),label:i(`Desktop app`),onClick:()=>t(`desktop`)})]}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(re,{size:22}),label:i(`Data controls`),onClick:()=>t(`data`)}),(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(B,{size:22}),label:i(`Developer`),onClick:()=>t(`developer`)})]}),(0,Q.jsxs)(V,{className:`mb-3`,children:[(0,Q.jsx)(U,{icon:(0,Q.jsx)(K,{size:22}),label:i(`About nanoMuse`),value:e?.newer&&e.latest?i(`{version} is out`,{version:e.latest}):void 0,onClick:()=>t(`about`)}),null,(0,Q.jsx)(z,{}),(0,Q.jsx)(U,{icon:(0,Q.jsx)(ve,{size:22}),label:i(`Feedback`),onClick:()=>window.open(`https://github.com/zeeshanhaque21/nanoMuse/issues`,`_blank`,`noopener`),external:!0})]}),(0,Q.jsxs)(W,{children:[`nanoMuse `,n.version]})]})]})}function Me({name:e}){let t=o(),n=I(),[r,i]=(0,Z.useState)(()=>n?.notificationsEnabled()??!1);return n?(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(E,{label:t(`Let {name} notify this phone`,{name:e}),hint:t(`The app stays connected to your server in the background. Approvals, questions and finished background work arrive as notifications and open the right chat.`),checked:r,onChange:e=>{n.setNotificationsEnabled(e),i(e)}}),(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:t(`nanoMuse for Android {version}`,{version:n.version()})})]}):null}function Ne({name:e}){let t=o(),n=I(),[r,i]=(0,Z.useState)(A);if((0,Z.useEffect)(()=>{let e=()=>i(A());return document.addEventListener(`visibilitychange`,e),window.addEventListener(`focus`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`focus`,e)}},[]),!n||!r)return null;let a=e=>n.openKeepRunning?.(e),s={xiaomi:t(`Xiaomi / Redmi / POCO: Settings → Apps → Manage apps → {name} → Autostart on; Battery saver → No restrictions; Other permissions → Display pop-up windows while running in the background on.`),huawei:t(`Huawei: Settings → Battery → App launch → {name} → Manage manually, all three on (auto-launch, secondary launch, run in background); Settings → Apps → {name} → Battery → Launch: manage manually.`),honor:t(`Honor: Settings → Battery → App launch → {name} → Manage manually, all three on; Battery → More battery settings → Stay connected while asleep on.`),oppo:t(`OPPO / realme / OnePlus: Settings → Battery → More settings → Optimise battery use → {name} → Don't optimise; Settings → Apps → {name} → Battery usage → Allow background activity, Allow auto launch.`),vivo:t(`vivo / iQOO: i Manager → App manager → Autostart → {name} on; Settings → Battery → Background power consumption management → {name} → Allow high background power consumption.`),samsung:t(`Samsung: Settings → Battery → Background usage limits → {name} not in Sleeping or Deep sleeping apps (add it to Never sleeping apps); turn off Adaptive battery if it keeps stopping.`),meizu:t(`Meizu: Settings → Apps → {name} → Background management → Keep running in the background; Battery → App power management → Allow.`)}[r.vendor];return(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:t(`Android stops apps that seem idle. {name} runs as a foreground service and holds the phone awake only while a task runs; these switches let it keep that promise on this phone.`,{name:e})}),(0,Q.jsx)(Pe,{label:t(`Battery`),value:r.battery_unrestricted?t(`Unrestricted`):t(`Optimised`),ok:r.battery_unrestricted,hint:t(`Optimised means Android may freeze the agent after a while with the screen off; routines and check-ins then wait until the phone wakes.`),action:r.battery_unrestricted?void 0:{label:t(`Allow`),onClick:()=>a(`battery`)}}),(0,Q.jsx)(Pe,{label:t(`Display over other apps`),value:r.overlay?t(`Allowed`):t(`Not allowed`),ok:r.overlay,hint:t(`Lets the agent bring an app to the front from the background and show its status capsule while it works in other apps.`),action:r.overlay?void 0:{label:t(`Allow`),onClick:()=>a(`overlay`)}}),r.exact_alarms!==`n/a`&&r.local&&(0,Q.jsx)(Pe,{label:t(`Alarms & reminders`),value:r.exact_alarms===`granted`?t(`Exact`):t(`Approximate`),ok:r.exact_alarms===`granted`,hint:t(`With exact alarms a reminder fires at the minute you named even when the phone is asleep; without them Android may deliver it up to a quarter of an hour late.`),action:r.exact_alarms===`granted`?void 0:{label:t(`Allow`),onClick:()=>a(`alarms`)}}),(0,Q.jsx)(E,{label:t(`Start after a reboot`),hint:r.local?t(`Bring the runtime back when the phone restarts, so routines and check-ins do not stop.`):t(`Reconnect to your computer when the phone restarts, so approvals keep arriving.`),checked:r.boot_start,onChange:e=>{n.setStartOnBoot?.(e),i({...r,boot_start:e})}}),s&&(0,Q.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 px-3 py-2.5 text-[12.5px] leading-snug space-y-2`,children:[(0,Q.jsx)(`div`,{className:`font-medium`,children:t(`On this phone's Android`)}),(0,Q.jsx)(`div`,{className:`text-muted`,children:s}),r.autostart_settings&&(0,Q.jsx)(`button`,{type:`button`,onClick:()=>a(`autostart`),className:`rounded-full border border-border px-3 py-1.5 text-[12.5px]`,children:t(`Open the auto-start settings`)})]}),(0,Q.jsxs)(`div`,{className:`flex items-center justify-between gap-3 pt-1`,children:[(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:t(`Crashes are written to a file on this phone and nowhere else. Export bundles them with the runtime's log for someone you choose.`)}),(0,Q.jsx)(`button`,{type:`button`,onClick:()=>n.exportLogs?.(),className:`shrink-0 rounded-full border border-border px-3 py-1.5 text-[12.5px]`,children:t(`Export logs`)})]})]})}function Pe({label:e,value:n,ok:r,hint:i,action:a}){return(0,Q.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,Q.jsxs)(`div`,{className:`flex-1`,children:[(0,Q.jsxs)(`div`,{className:`flex items-center gap-2 text-[14px]`,children:[(0,Q.jsx)(`span`,{children:e}),(0,Q.jsx)(`span`,{className:t(`rounded-full px-2 py-0.5 text-[11px] font-medium`,r?`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`:`bg-amber-500/15 text-amber-700 dark:text-amber-300`),children:n})]}),i&&(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:i})]}),a&&(0,Q.jsx)(`button`,{type:`button`,onClick:a.onClick,className:`shrink-0 rounded-full bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white`,children:a.label})]})}function Fe({name:e}){let{toast:t}=m(),[n,r]=(0,Z.useState)(null),[i,a]=(0,Z.useState)(`off`),[s,c]=(0,Z.useState)(!1),l=o(),u=async()=>{a(await v());try{r(await y.push())}catch{}};(0,Z.useEffect)(()=>{u()},[]);let f=async()=>{if(n){c(!0);try{let e=i===`on`?await h():await d(n.public_key);a(e),e===`denied`&&t(l(`Notifications are blocked for this site in the browser settings`)),await u()}catch(e){let n=e;t(n.name===`AbortError`?l(`This browser has no push service (embedded browsers often don't). Use Chrome, Edge, Firefox or Safari 16.4+ on the phone.`):n.message)}finally{c(!1)}}},p=async()=>{try{let e=await y.pushTest();t(e.ok?l(`Sent. It should arrive in a moment`):e.error??l(`Could not send`))}catch(e){t(e.message)}},g=i===`unsupported`?l(`This browser cannot receive push notifications.`):i===`insecure`?l(`Notifications need https:// (or localhost). Over plain http on your LAN the app works, this part stays off; see docs/deployment.md.`):i===`denied`?l(`Blocked for this site. Allow notifications in the browser's site settings, then try again.`):n&&!n.available?l(`The server was installed without pywebpush.`):``;return(0,Q.jsxs)(Q.Fragment,{children:[(0,Q.jsx)(E,{label:l(`Let {name} notify this device`,{name:e}),hint:l(`Approvals, questions, finished background work and check-ins; nothing while the app is on screen.`),checked:i===`on`,onChange:()=>void f(),disabled:s||!!g}),g&&(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:g}),n&&n.subscriptions>0&&(0,Q.jsxs)(`div`,{className:`flex items-center justify-between text-[12.5px] text-muted`,children:[(0,Q.jsx)(`span`,{children:n.subscriptions===1?l(`1 device subscribed`):l(`{n} devices subscribed`,{n:n.subscriptions})}),(0,Q.jsx)(`button`,{type:`button`,onClick:()=>void p(),className:`text-accent font-medium`,children:l(`Send a test`)})]}),!F()&&(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:l(`On a phone, add the app to the home screen first; the icon then shows a badge and notifications open the right chat.`)})]})}var Ie=[{id:`off`,title:`Off`,text:`Only works when you ask.`},{id:`low`,title:`Low`,text:`Checks in half as often; speaks up only when a step is done or it needs you.`},{id:`default`,title:`Default`,text:`Works on goals on schedule; reports real progress, stays quiet otherwise.`},{id:`high`,title:`High`,text:`Checks in twice as often and always reports, even 'still on track'.`}];function Le({value:e,onChange:n}){let r=o(),i=Ie.find(t=>t.id===e)??Ie[2];return(0,Q.jsxs)(`div`,{children:[(0,Q.jsx)(`div`,{className:`flex items-center gap-3`,children:(0,Q.jsx)(`div`,{className:`flex-1 text-[13px] text-muted leading-snug`,children:r(i.text)})}),(0,Q.jsx)(`div`,{className:`mt-2 grid grid-cols-4 gap-1 rounded-2xl bg-surface-2 p-1`,children:Ie.map(i=>(0,Q.jsx)(`button`,{type:`button`,onClick:()=>n(i.id),"aria-pressed":e===i.id,className:t(`rounded-xl py-1.5 text-[13px] font-medium transition`,e===i.id?`bg-surface shadow-sm text-accent`:`text-muted`),children:r(i.title)},i.id))})]})}function Re({value:e,onChange:n}){let[r,i]=e?e.split(`-`):[``,``],a=!!e,s=o(),c=(e,t)=>n(e&&t?`${e}-${t}`:``);return(0,Q.jsxs)(`div`,{children:[(0,Q.jsxs)(`label`,{className:`flex items-start gap-3 cursor-pointer`,children:[(0,Q.jsxs)(`div`,{className:`flex-1`,children:[(0,Q.jsxs)(`div`,{className:`text-[14px] flex items-center gap-1.5`,children:[(0,Q.jsx)(G,{size:15,className:`text-muted`}),` `,s(`Quiet hours`)]}),(0,Q.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:s(`No background work in this window; anything due waits until it ends.`)})]}),(0,Q.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":a,onClick:()=>c(a?``:`22:00`,a?``:`08:00`),className:t(`relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition`,a?`bg-accent`:`bg-surface-2 border border-border`),children:(0,Q.jsx)(`span`,{className:t(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,a?`left-[22px]`:`left-0.5`)})})]}),a&&(0,Q.jsxs)(`div`,{className:`mt-2 flex items-center gap-2 text-[13.5px]`,children:[(0,Q.jsx)(`span`,{className:`text-muted`,children:s(`From`)}),(0,Q.jsx)(`input`,{type:`time`,value:r,onChange:e=>c(e.target.value,i),"aria-label":s(`Quiet hours start`),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`}),(0,Q.jsx)(`span`,{className:`text-muted`,children:s(`to`)}),(0,Q.jsx)(`input`,{type:`time`,value:i,onChange:e=>c(r,e.target.value),"aria-label":s(`Quiet hours end`),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`})]})]})}function ze(){let e=o(),t=N(),[n,r]=(0,Z.useState)(null);(0,Z.useEffect)(()=>{let e=!0;return t?.loginItem?.().then(t=>{e&&r(t)}),()=>{e=!1}},[t]);let i=t?.platform===`darwin`,a=[[e(`Quick chat`),i?`⌥ Space`:`Ctrl+Shift+Space`],[e(`Show the window`),i?`⌘⇧M`:`Ctrl+Shift+M`],[e(`Stop the hands`),i?`⌘⇧Esc`:`Ctrl+Shift+Esc`]];return(0,Q.jsxs)(Q.Fragment,{children:[t?.setLoginItem&&n!==null&&t.platform!==`linux`&&(0,Q.jsx)(E,{label:e(`Start with the computer`),hint:e(`Opens in the tray at sign-in; the agent's goals and check-ins run from then on.`),checked:n,onChange:e=>{r(e),t.setLoginItem?.(e)}}),(0,Q.jsxs)(`div`,{className:`text-[13.5px]`,children:[e(`In the tray`),(0,Q.jsx)(`span`,{className:`block text-[12px] text-muted`,children:e(`The icon by the clock shows what the hands are doing and has Stop; closing the window keeps the agent running.`)})]}),(0,Q.jsxs)(`div`,{className:`space-y-1`,children:[(0,Q.jsx)(`div`,{className:`text-[13.5px]`,children:e(`Shortcuts`)}),a.map(([e,t])=>(0,Q.jsxs)(`div`,{className:`flex items-center justify-between text-[13px]`,children:[(0,Q.jsx)(`span`,{className:`text-muted`,children:e}),(0,Q.jsx)(`kbd`,{className:`rounded-md border border-border bg-surface-2 px-1.5 py-0.5 font-sans text-[12px] text-fg/80`,children:t})]},e))]})]})}function $({title:e,children:n,id:r,plain:i=!1}){return(0,Q.jsxs)(`section`,{id:r,className:t(`space-y-3 scroll-mt-4`,i?`pt-1`:`rounded-3xl bg-surface border border-border/70 shadow-sm p-4`),children:[!i&&(0,Q.jsx)(`h2`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted`,children:e}),n]})}export{Oe as SettingsScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/SettingsScreen-qmwHF2En.js b/nanomuse/server/static/assets/SettingsScreen-qmwHF2En.js deleted file mode 100644 index dd0d8edf..00000000 --- a/nanomuse/server/static/assets/SettingsScreen-qmwHF2En.js +++ /dev/null @@ -1 +0,0 @@ -import{A as e,E as t,F as n,G as r,H as i,J as a,K as o,L as s,Q as c,T as l,Y as u,_ as d,a as f,b as p,f as m,g as h,h as g,m as _,v,x as y}from"./MuseHeader-DHtaUVEa.js";import{A as b,C as x,S,T as C,g as w,u as T,w as E}from"./StarNudge-viN7_tiM.js";import{n as ee}from"./external-link-3iD-Bcbv.js";import{n as D}from"./Form-heEKMZyj.js";import{t as O}from"./clapperboard-vj4UL6pS.js";import{t as te}from"./database-zap-DrvRjpVx.js";import{t as k}from"./plug-DQfRcVG2.js";import{n as ne,r as A,t as re}from"./IdentityForm-Da0A3oTS.js";import{Ct as j,D as ie,E as M,G as N,H as P,I as ae,K as F,L as I,O as oe,T as L,U as R,_t as se,a as z,et as B,gt as V,i as H,it as U,j as ce,l as le,n as ue,nt as W,o as G,r as de,st as fe,tt as pe,u as me,ut as he,xt as ge,yt as K}from"./index-CrH9-E2E.js";import{t as q}from"./BackBar-B5YUZVK1.js";import{t as J}from"./AvatarPicker-zOz-S1nk.js";var _e=a(`battery-full`,[[`path`,{d:`M10 10v4`,key:`1mb2ec`}],[`path`,{d:`M14 10v4`,key:`1nt88p`}],[`path`,{d:`M22 14v-4`,key:`14q9d5`}],[`path`,{d:`M6 10v4`,key:`1n77qd`}],[`rect`,{x:`2`,y:`6`,width:`16`,height:`12`,rx:`2`,key:`13zb55`}]]),ve=a(`box`,[[`path`,{d:`M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z`,key:`hh9hay`}],[`path`,{d:`m3.3 7 8.7 5 8.7-5`,key:`g66t2b`}],[`path`,{d:`M12 22V12`,key:`d0xqtd`}]]),ye=a(`chart-column`,[[`path`,{d:`M3 3v16a2 2 0 0 0 2 2h16`,key:`c24i48`}],[`path`,{d:`M18 17V9`,key:`2bz60n`}],[`path`,{d:`M13 17V5`,key:`1frdt8`}],[`path`,{d:`M8 17v-3`,key:`17ska0`}]]),be=a(`message-square-warning`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M12 15h.01`,key:`q59x07`}],[`path`,{d:`M12 7v4`,key:`xawao1`}]]),xe=a(`puzzle`,[[`path`,{d:`M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z`,key:`w46dr5`}]]),Se=a(`shield`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}]]),Ce=a(`smile`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M8 14s1.5 2 4 2 4-2 4-2`,key:`1y1vjs`}],[`line`,{x1:`9`,x2:`9.01`,y1:`9`,y2:`9`,key:`yxxnd0`}],[`line`,{x1:`15`,x2:`15.01`,y1:`9`,y2:`9`,key:`1p4y9e`}]]),we=a(`zap`,[[`path`,{d:`M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z`,key:`1xq2db`}]]),Y=c(u(),1),Te=`(min-width: 64rem)`;function Ee(){return typeof window>`u`?!1:document.documentElement.classList.contains(`desktop`)?!0:typeof window.matchMedia==`function`&&window.matchMedia(Te).matches}function De(){let[e,t]=(0,Y.useState)(Ee);return(0,Y.useEffect)(()=>{if(typeof window.matchMedia!=`function`)return;let e=window.matchMedia(Te),n=()=>t(Ee());return e.addEventListener(`change`,n),()=>e.removeEventListener(`change`,n)},[]),e}var X=p(),Oe=`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 outline-none focus:ring-2 focus:ring-accent/40`,ke=[{id:`ask`,title:`Balanced`,text:`Browse, read and write files freely; stop for anything hard to undo: email, purchases, shell commands.`,icon:(0,X.jsx)(N,{size:18})},{id:`strict`,title:`Cautious`,text:`Also ask before moderate actions like fetching web pages or writing files.`,icon:(0,X.jsx)(Se,{size:18})},{id:`auto`,title:`Hands-off`,text:`Approve everything automatically except explicit deny rules. For trusted, unattended runs only.`,icon:(0,X.jsx)(F,{size:18})}];function Ae(){let{state:e,refreshSettings:a,setTab:s,toast:c}=m(),u=e.settings,[d,p]=(0,Y.useState)(()=>A(e.profile,f,J[0])),[h,v]=(0,Y.useState)(!1),x=o(),S=r(),w=g(),[E,O]=(0,Y.useState)(null),[te,k]=(0,Y.useState)(!1);(0,Y.useEffect)(()=>{u||a()},[u,a]);let j=(0,Y.useCallback)(async(e=!1)=>{k(!0);try{O(await y.update(e))}catch{O(null)}finally{k(!1)}},[]);(0,Y.useEffect)(()=>{j()},[j]),(0,Y.useEffect)(()=>{e.profile&&p(A(e.profile,``,J[0]))},[e.profile]);let N=async(e,t)=>{v(!0);try{await y.updateSettings(e),await a(),t&&c(t)}catch(e){c(e.message)}finally{v(!1)}},P=A(e.profile,``,J[0]),ae=!!e.profile&&Object.keys(d).some(e=>d[e]!==P[e]),F=e.profile?.name??`nanoMuse`,R=oe(),se=me(),z=De()&&!e.lite,[B,V]=(0,Y.useState)(null),H=[{id:`who`,title:F},{id:`sentinel`,title:x(`Safety · Sentinel`)},{id:`proactivity`,title:x(`Proactivity`)},{id:`notifications`,title:x(`Notifications`)},...M()?[{id:`keep-running`,title:x(`Keep it running`)}]:[],{id:`model`,title:x(`Model`)},{id:`appearance`,title:x(`Appearance`)},...I()?[{id:`desktop`,title:x(`Desktop app`)}]:[],{id:`data`,title:x(`Data controls`)},{id:`developer`,title:x(`Developer`)},{id:`about`,title:x(`About`)}],U=e=>z||B===e,[W,G]=(0,Y.useState)(()=>{let e=window.location.hash.slice(1);return je.includes(e)?e:null});if((0,Y.useEffect)(()=>{if(W&&u){if(G(null),history.replaceState(null,``,window.location.pathname+window.location.search),!z){V(W);return}requestAnimationFrame(()=>document.getElementById(W)?.scrollIntoView({behavior:`smooth`,block:`start`}))}},[W,u,z]),!z&&B===null)return(0,X.jsx)(Ne,{release:E,onOpen:V});let de=H.find(e=>e.id===B);return(0,X.jsxs)(`div`,{className:`flex h-full flex-col`,children:[z?(0,X.jsx)(q,{title:x(`Settings`),description:x(`Its name and look, how careful it is, how often it speaks up.`)}):(0,X.jsx)(q,{title:de?.title??x(`Settings`),onBack:()=>V(null),backLabel:x(`Settings`)}),(0,X.jsxs)(`div`,{className:`flex min-h-0 flex-1`,children:[z&&(0,X.jsx)(`nav`,{className:`flex w-[188px] shrink-0 flex-col gap-0.5 self-start pl-4 pr-2 pt-1`,"aria-label":x(`Settings`),children:H.map(e=>(0,X.jsx)(`button`,{type:`button`,onClick:()=>document.getElementById(e.id)?.scrollIntoView({behavior:`smooth`,block:`start`}),className:`truncate rounded-xl px-3 py-1.5 text-left text-[13px] font-medium text-fg/75 hover:bg-surface-2 hover:text-fg`,children:e.title},e.id))}),(0,X.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-4 pb-8 space-y-5`,children:[U(`who`)&&(0,X.jsxs)($,{title:F,id:`who`,plain:!z,children:[(0,X.jsx)(re,{value:d,onChange:p,suggestions:!1,inputCls:Oe,onStudio:()=>s(`avatar`)}),(0,X.jsx)(`button`,{type:`button`,disabled:!ae||h,onClick:()=>void N({profile:ne(d)},x(`Saved`)),className:`w-full rounded-2xl bg-accent text-accent-fg py-2.5 font-medium disabled:opacity-40`,children:x(`Save`)})]}),U(`sentinel`)&&(0,X.jsxs)($,{title:x(`Safety · Sentinel`),id:`sentinel`,plain:!z,children:[(0,X.jsx)(`p`,{className:`text-[13px] text-muted -mt-1`,children:x(`A separate gatekeeper reviews every action. Pick how often it should check in with you.`)}),(0,X.jsx)(`div`,{className:`space-y-2`,children:ke.map(e=>(0,X.jsxs)(`button`,{type:`button`,onClick:()=>void N({sentinel_mode:e.id}),"aria-pressed":u?.sentinel.mode===e.id,className:t(`w-full text-left rounded-2xl border px-3.5 py-3 flex items-start gap-3 transition`,u?.sentinel.mode===e.id?`border-accent bg-accent/8`:`border-border`),children:[(0,X.jsx)(`div`,{className:t(`mt-0.5`,e.id===`auto`?`text-rose-500`:`text-accent`),children:e.icon}),(0,X.jsxs)(`div`,{className:`flex-1`,children:[(0,X.jsx)(`div`,{className:`font-medium text-[14.5px]`,children:x(e.title)}),(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug mt-0.5`,children:x(e.text)})]}),u?.sentinel.mode===e.id&&(0,X.jsx)(ee,{size:18,className:`text-accent mt-0.5`})]},e.id))}),u&&(0,X.jsxs)(`div`,{className:`text-[12.5px] text-muted leading-relaxed`,children:[x(`Always asks before: {tools}.`,{tools:u.sentinel.always_ask_tools.map(e=>x(e)).join(`, `)||`—`}),` `,u.sentinel.taint_tracking&&x(`After reading private data, new network destinations need approval.`)]}),u?.sandbox&&(0,X.jsxs)(`div`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2/60 px-3.5 py-2.5 text-[12.5px] leading-snug`,children:[(0,X.jsx)(ve,{size:15,className:t(`shrink-0 mt-[2px]`,u.sandbox.active?`text-emerald-500`:`text-muted`)}),(0,X.jsxs)(`div`,{children:[(0,X.jsxs)(`div`,{className:`font-medium text-fg`,children:[u.sandbox.active?x(`Commands run in a sandbox`):x(`Commands run without a sandbox`),(0,X.jsxs)(`span`,{className:`text-muted font-normal`,children:[` · `,u.sandbox.status]})]}),(0,X.jsx)(`div`,{className:`text-muted mt-0.5`,children:u.sandbox.active?x(`Each shell or Python call gets its own namespace: only the workspace is writable, your home directory is not there, and there is no network unless the command needs it.`):u.sandbox.status.includes(`container`)?x(`Each shell or Python call runs in the workspace with a scrubbed environment; the container is the boundary.`):x(`Each shell or Python call runs in the workspace with a scrubbed environment. On Linux, installing bubblewrap gives each one its own namespace.`)})]})]})]}),U(`proactivity`)&&(0,X.jsxs)($,{title:x(`Proactivity`),id:`proactivity`,plain:!z,children:[(0,X.jsx)(Le,{value:e.profile?.proactivity??`default`,onChange:e=>void N({profile:{proactivity:e}})}),(0,X.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,X.jsxs)(`label`,{className:`text-[13.5px] flex-1`,children:[x(`Check-in interval`),e.profile&&e.profile.proactivity!=="default"&&e.profile.proactivity!==`off`&&(0,X.jsx)(`span`,{className:`block text-[12px] text-muted`,children:e.profile.proactivity===`low`?x(`Doubled at this level`):x(`Halved at this level`)})]}),(0,X.jsx)(`select`,{value:e.profile?.goal_interval_minutes??60,onChange:e=>void N({profile:{goal_interval_minutes:Number(e.target.value)}}),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:[...new Set([15,30,60,120,240,480,1440,e.profile?.goal_interval_minutes??60])].sort((e,t)=>e-t).map(e=>(0,X.jsx)(`option`,{value:e,children:e<60?x(`{n} min`,{n:e}):e%60?`${x(`{n} h`,{n:Math.floor(e/60)})} ${x(`{n} min`,{n:e%60})}`:x(`{n} h`,{n:e/60})},e))})]}),(0,X.jsx)(Re,{value:e.profile?.quiet_hours??``,onChange:e=>void N({profile:{quiet_hours:e}})})]}),U(`notifications`)&&(0,X.jsx)($,{title:x(`Notifications`),id:`notifications`,plain:!z,children:L()?(0,X.jsx)(Pe,{name:e.profile?.name??`nanoMuse`}):(0,X.jsx)(Ie,{name:e.profile?.name??`nanoMuse`})}),M()&&U(`keep-running`)&&(0,X.jsx)($,{title:x(`Keep it running`),id:`keep-running`,plain:!z,children:(0,X.jsx)(Fe,{name:e.profile?.name??`nanoMuse`})}),U(`model`)&&(0,X.jsxs)($,{title:x(`Model`),id:`model`,plain:!z,children:[u&&(0,X.jsxs)(`button`,{type:`button`,onClick:()=>s(`connections`),className:`w-full text-[13.5px] flex items-center justify-between`,children:[(0,X.jsx)(`span`,{className:`text-muted`,children:x(`Provider / model`)}),(0,X.jsxs)(`span`,{className:`font-mono text-[12.5px] flex items-center gap-1`,children:[u.llm.model,` `,(0,X.jsx)(K,{size:14,className:`text-muted`})]})]}),u?.llm.cloud&&(0,X.jsxs)(`button`,{type:`button`,onClick:()=>ce(s,b(e.hub?.account).preset),className:`w-full text-[13.5px] flex items-center justify-between`,children:[(0,X.jsx)(`span`,{className:`text-muted`,children:x(`Use my own API key or a plan I pay for`)}),(0,X.jsxs)(`span`,{className:`text-[12.5px] text-muted flex items-center gap-1`,children:[x(`{first} first, then {others}`,{first:b(e.hub?.account).label,others:C(e.hub?.account)?x(`DeepSeek, Kimi, OpenAI, ChatGPT…`):x(`OpenAI, ChatGPT, Gemini, DeepSeek…`)}),` `,(0,X.jsx)(K,{size:14})]})]}),(0,X.jsxs)(`button`,{type:`button`,onClick:()=>s(`connections`),className:`w-full text-[13.5px] flex items-center justify-between`,children:[(0,X.jsx)(`span`,{className:`text-muted`,children:x(`Connections`)}),(0,X.jsxs)(`span`,{className:`text-[12.5px] text-muted flex items-center gap-1`,children:[x(`E-mail, calendar, browser, MCP servers`),` `,(0,X.jsx)(K,{size:14})]})]})]}),U(`appearance`)&&(0,X.jsxs)($,{title:x(`Appearance`),id:`appearance`,plain:!z,children:[(0,X.jsx)(D,{label:x(`Show the agent's steps`),hint:x(`Every tool it uses becomes a chip in the chat. Off, the chat keeps to the conversation and the line under the name says what it is on. This device only.`),checked:se,onChange:le}),(0,X.jsx)(D,{label:x(`Show thinking`),hint:x(`Reveal the model's reasoning under each reply when the provider exposes it.`),checked:!!u?.agent.show_thinking,onChange:e=>void N({show_thinking:e})}),(0,X.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,X.jsxs)(`label`,{className:`text-[13.5px] flex-1`,children:[x(`Appearance`),(0,X.jsx)(`span`,{className:`block text-[12px] text-muted`,children:x(`This device only`)})]}),(0,X.jsxs)(`select`,{value:w,onChange:e=>_(e.target.value),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:[(0,X.jsx)(`option`,{value:`light`,children:x(`Light`)}),(0,X.jsx)(`option`,{value:`dark`,children:x(`Dark`)}),(0,X.jsx)(`option`,{value:`system`,children:x(`Follow the system`)})]})]}),(0,X.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,X.jsxs)(`label`,{className:`text-[13.5px] flex-1`,children:[x(`App language`),(0,X.jsx)(`span`,{className:`block text-[12px] text-muted`,children:x(`This device only`)})]}),(0,X.jsx)(`select`,{value:S,onChange:e=>i(e.target.value),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:n.map(e=>(0,X.jsx)(`option`,{value:e.value,children:e.value===`auto`?x(`Auto`):e.label},e.value))})]}),(0,X.jsxs)(`div`,{className:`flex items-center gap-3`,children:[(0,X.jsxs)(`label`,{className:`text-[13.5px] flex-1`,children:[x(`Reply language`),(0,X.jsx)(`span`,{className:`block text-[12px] text-muted`,children:x(`What {name} writes in`,{name:e.profile?.name??`nanoMuse`})})]}),(0,X.jsxs)(`select`,{value:u?.agent.language??`auto`,onChange:e=>void N({language:e.target.value}),className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13.5px] outline-none`,children:[(0,X.jsx)(`option`,{value:`auto`,children:x(`Match mine`)}),(0,X.jsx)(`option`,{value:`English`,children:`English`}),(0,X.jsx)(`option`,{value:`中文`,children:`中文`}),(0,X.jsx)(`option`,{value:`日本語`,children:`日本語`}),(0,X.jsx)(`option`,{value:`Español`,children:`Español`}),(0,X.jsx)(`option`,{value:`Deutsch`,children:`Deutsch`}),(0,X.jsx)(`option`,{value:`Français`,children:`Français`})]})]})]}),I()&&U(`desktop`)&&(0,X.jsx)($,{title:x(`Desktop app`),id:`desktop`,plain:!z,children:(0,X.jsx)(ze,{})}),U(`data`)&&(0,X.jsx)($,{title:x(`Data controls`),id:`data`,plain:!z,children:(0,X.jsx)(ue,{flush:!0})}),U(`developer`)&&(0,X.jsxs)($,{title:x(`Developer`),id:`developer`,plain:!z,children:[(0,X.jsx)(D,{label:x(`Developer tools`),hint:x(`The Coding screen (Cursor, Codex and the other coding agents on this computer) in the sidebar, and the runtime's address below. This device only.`),checked:R,onChange:ie}),R&&(0,X.jsxs)(`div`,{className:`text-[13px] text-muted space-y-1`,children:[(0,X.jsxs)(`div`,{className:`break-all`,children:[x(`Runtime:`),` `,window.location.origin,` · `,(0,X.jsx)(`span`,{className:`text-fg/70`,children:x(`the token is in the server_token file in the data folder`)})]}),(0,X.jsxs)(`div`,{children:[(0,X.jsx)(`a`,{href:`https://github.com/zeeshanhaque21/nanoMuse/blob/main/docs/cli.md`,target:`_blank`,rel:`noreferrer`,className:`text-accent underline-offset-2 hover:underline`,children:x(`The command line and the API`)}),` · `,(0,X.jsx)(`a`,{href:`https://github.com/zeeshanhaque21/nanoMuse/blob/main/docs/harness.md`,target:`_blank`,rel:`noreferrer`,className:`text-accent underline-offset-2 hover:underline`,children:x(`nanoMuse on DeepSeek Harness`)})]})]})]}),U(`about`)&&(0,X.jsxs)($,{title:x(`About`),id:`about`,plain:!z,children:[(0,X.jsx)(T,{}),(0,X.jsx)(Me,{installed:e.version,release:E,checking:te,onCheck:()=>void j(!0)}),(0,X.jsxs)(`div`,{className:`text-[13px] text-muted space-y-1`,children:[u&&(0,X.jsxs)(`div`,{className:`break-all`,children:[x(`Data:`),` `,u.data_dir]}),u&&(0,X.jsxs)(`div`,{className:`break-all`,children:[x(`Workspace:`),` `,u.agent.workspace]}),(0,X.jsx)(`div`,{children:e.connected?x(`Connected`):x(`Reconnecting…`)})]}),(0,X.jsxs)(`button`,{type:`button`,onClick:()=>{let e=L();if(e){e.disconnect();return}l(``),window.location.reload()},className:`w-full rounded-2xl border border-border py-2.5 text-[14px] font-medium flex items-center justify-center gap-2 text-muted`,children:[(0,X.jsx)(fe,{size:16}),` `,L()?x(`Disconnect from this server`):x(`Forget this device's access token`)]})]})]})]})]})}var je=[`who`,`sentinel`,`proactivity`,`notifications`,`keep-running`,`model`,`appearance`,`desktop`,`data`,`developer`,`about`];function Me({installed:t,release:n,checking:r,onCheck:i}){let a=o(),s=!!L(),c=(0,X.jsx)(`button`,{type:`button`,onClick:i,disabled:r,className:`text-accent underline-offset-2 hover:underline disabled:opacity-60`,children:a(r?`Checking…`:`Check now`)}),l;return l=s?a(`The phone app checks for updates on its own.`):n&&!n.enabled?a(`Update checks are off here.`):r&&!n?a(`Checking…`):n?.latest&&n.newer?(0,X.jsxs)(X.Fragment,{children:[a(`{version} is out`,{version:n.latest}),` · `,(0,X.jsx)(`a`,{href:n.download_url||n.url,target:`_blank`,rel:`noreferrer`,className:`font-medium text-accent underline-offset-2 hover:underline`,children:a(`Update`)})]}):n?.latest?(0,X.jsxs)(X.Fragment,{children:[a(`Latest {version}; you have it`,{version:n.latest}),` · `,c]}):(0,X.jsxs)(X.Fragment,{children:[a(`Could not check`),` · `,c]}),(0,X.jsxs)(`div`,{className:`rounded-2xl bg-surface-2/60 px-3.5 py-2.5 text-[13px]`,children:[(0,X.jsx)(`div`,{className:`font-medium`,children:a(`nanoMuse {version}`,{version:t})}),(0,X.jsx)(`div`,{className:`mt-0.5 text-muted`,children:l}),!s&&n?.latest&&n.newer&&(0,X.jsx)(`div`,{className:`mt-1 text-[12px] text-muted`,children:a(`To update: pip install -U nanomuse, pull the new image, or use the desktop app's Update.`)}),!s&&n?.checked_at&&(0,X.jsx)(`div`,{className:`mt-1 text-[12px] text-muted`,children:a(`Checked {when}`,{when:e(n.checked_at)})})]})}function Ne({release:e,onOpen:t}){let{state:n,setTab:r}=m(),i=o(),a=n.settings,c=n.hub,l=E(),u=n.profile?.name??`nanoMuse`,d=g(),f=c?.devices.filter(e=>!e.this&&e.kind!==`web`)??[],p=f.filter(e=>e.kind===`computer`),h=f.filter(e=>e.online&&e.actions?.includes(`coding.sessions`)),_=a?.sentinel.mode,v=_===`ask`?i(`Balanced`):_===`strict`?i(`Cautious`):_===`auto`?i(`Hands-off`):``,y=n.profile?.proactivity??`default`,b=i(y===`off`?`Off`:y===`low`?`Low`:y===`high`?`High`:`Default`),C=i(d===`dark`?`Dark`:d===`light`?`Light`:`Follow the system`),T=i(`none`);return(0,X.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,X.jsx)(q,{title:i(`Settings`)}),(0,X.jsxs)(`div`,{className:`flex-1 overflow-y-auto pb-8 pt-2`,children:[(0,X.jsxs)(H,{className:`mb-3`,children:[(0,X.jsxs)(`button`,{type:`button`,onClick:()=>r(`connections`),className:`flex w-full items-center gap-3 px-4 py-3.5 text-left hover:bg-surface-2/60`,children:[(0,X.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,X.jsx)(`span`,{className:`block truncate text-[16px] font-medium leading-[21px]`,children:a?.llm.model||i(`No model yet`)}),(0,X.jsx)(`span`,{className:`mt-0.5 block truncate text-[12.5px] leading-4 text-muted`,children:a?a.llm.cloud?`nanoMuse Cloud`:a.llm.provider:i(`Add a provider and pick its models`)})]}),(0,X.jsx)(`span`,{className:`text-[14px] font-medium text-accent`,children:i(`Change`)})]}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(se,{size:22}),label:`nanoMuse Cloud`,value:c?.account.signed_in?c.account.hint:i(`Sign in`),onClick:()=>r(`account`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(O,{size:22}),label:i(`Image & video models`),value:w(l,`image`)===!1?x(i,`image`,S(c?.account),s()):w(l,`video`)===!1?x(i,`video`,S(c?.account),s()):void 0,onClick:()=>r(`connections`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(ye,{size:22}),label:i(`Usage`),onClick:()=>r(`account`)})]}),(0,X.jsxs)(H,{className:`mb-3`,children:[(0,X.jsx)(G,{icon:(0,X.jsx)(R,{size:22}),label:i(`Name & personality`),value:u,onClick:()=>t(`who`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(Ce,{size:22}),label:i(`Avatar`),onClick:()=>r(`avatar`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(ge,{size:22}),label:i(`Memory`),onClick:()=>r(`memory`)}),a?.skills?.enabled!==!1&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(xe,{size:22}),label:i(`Skills`),value:a?String(a.skills.count):void 0,onClick:()=>r(`skills`)})]}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(k,{size:22}),label:i(`Connections`),value:a?.connectors.mcp.length?`${a.connectors.mcp.length} MCP`:void 0,onClick:()=>r(`connections`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(U,{size:22}),label:i(`Chat apps`),onClick:()=>r(`channels`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(W,{size:22}),label:i(`Devices`),value:p.length?String(p.length):T,onClick:()=>r(`devices`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(P,{size:22}),label:i(`Coding agents`),value:h.length?String(h.length):T,onClick:()=>r(`coding`)})]}),(0,X.jsxs)(H,{className:`mb-3`,children:[(0,X.jsx)(G,{icon:(0,X.jsx)(N,{size:22}),label:i(`Safety · Sentinel`),value:v,onClick:()=>t(`sentinel`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(we,{size:22}),label:i(`Proactivity`),value:b,onClick:()=>t(`proactivity`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(j,{size:22}),label:i(`Notifications`),onClick:()=>t(`notifications`)}),M()&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(_e,{size:22}),label:i(`Keep it running`),onClick:()=>t(`keep-running`)})]})]}),(0,X.jsxs)(H,{className:`mb-3`,children:[(0,X.jsx)(G,{icon:(0,X.jsx)(B,{size:22}),label:i(`Appearance`),value:C,onClick:()=>t(`appearance`)}),I()&&(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(W,{size:22}),label:i(`Desktop app`),onClick:()=>t(`desktop`)})]}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(te,{size:22}),label:i(`Data controls`),onClick:()=>t(`data`)}),(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(V,{size:22}),label:i(`Developer`),onClick:()=>t(`developer`)})]}),(0,X.jsxs)(H,{className:`mb-3`,children:[(0,X.jsx)(G,{icon:(0,X.jsx)(he,{size:22}),label:i(`About nanoMuse`),value:e?.newer&&e.latest?i(`{version} is out`,{version:e.latest}):void 0,onClick:()=>t(`about`)}),null,(0,X.jsx)(z,{}),(0,X.jsx)(G,{icon:(0,X.jsx)(be,{size:22}),label:i(`Feedback`),onClick:()=>window.open(`https://github.com/zeeshanhaque21/nanoMuse/issues`,`_blank`,`noopener`),external:!0})]}),(0,X.jsxs)(de,{children:[`nanoMuse `,n.version]})]})]})}function Pe({name:e}){let t=o(),n=L(),[r,i]=(0,Y.useState)(()=>n?.notificationsEnabled()??!1);return n?(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(D,{label:t(`Let {name} notify this phone`,{name:e}),hint:t(`The app stays connected to your server in the background. Approvals, questions and finished background work arrive as notifications and open the right chat.`),checked:r,onChange:e=>{n.setNotificationsEnabled(e),i(e)}}),(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:t(`nanoMuse for Android {version}`,{version:n.version()})})]}):null}function Fe({name:e}){let t=o(),n=L(),[r,i]=(0,Y.useState)(M);if((0,Y.useEffect)(()=>{let e=()=>i(M());return document.addEventListener(`visibilitychange`,e),window.addEventListener(`focus`,e),()=>{document.removeEventListener(`visibilitychange`,e),window.removeEventListener(`focus`,e)}},[]),!n||!r)return null;let a=e=>n.openKeepRunning?.(e),s={xiaomi:t(`Xiaomi / Redmi / POCO: Settings → Apps → Manage apps → {name} → Autostart on; Battery saver → No restrictions; Other permissions → Display pop-up windows while running in the background on.`),huawei:t(`Huawei: Settings → Battery → App launch → {name} → Manage manually, all three on (auto-launch, secondary launch, run in background); Settings → Apps → {name} → Battery → Launch: manage manually.`),honor:t(`Honor: Settings → Battery → App launch → {name} → Manage manually, all three on; Battery → More battery settings → Stay connected while asleep on.`),oppo:t(`OPPO / realme / OnePlus: Settings → Battery → More settings → Optimise battery use → {name} → Don't optimise; Settings → Apps → {name} → Battery usage → Allow background activity, Allow auto launch.`),vivo:t(`vivo / iQOO: i Manager → App manager → Autostart → {name} on; Settings → Battery → Background power consumption management → {name} → Allow high background power consumption.`),samsung:t(`Samsung: Settings → Battery → Background usage limits → {name} not in Sleeping or Deep sleeping apps (add it to Never sleeping apps); turn off Adaptive battery if it keeps stopping.`),meizu:t(`Meizu: Settings → Apps → {name} → Background management → Keep running in the background; Battery → App power management → Allow.`)}[r.vendor];return(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(`p`,{className:`text-[12.5px] text-muted leading-snug`,children:t(`Android stops apps that seem idle. {name} runs as a foreground service and holds the phone awake only while a task runs; these switches let it keep that promise on this phone.`,{name:e})}),(0,X.jsx)(Z,{label:t(`Battery`),value:r.battery_unrestricted?t(`Unrestricted`):t(`Optimised`),ok:r.battery_unrestricted,hint:t(`Optimised means Android may freeze the agent after a while with the screen off; routines and check-ins then wait until the phone wakes.`),action:r.battery_unrestricted?void 0:{label:t(`Allow`),onClick:()=>a(`battery`)}}),(0,X.jsx)(Z,{label:t(`Display over other apps`),value:r.overlay?t(`Allowed`):t(`Not allowed`),ok:r.overlay,hint:t(`Lets the agent bring an app to the front from the background and show its status capsule while it works in other apps.`),action:r.overlay?void 0:{label:t(`Allow`),onClick:()=>a(`overlay`)}}),r.exact_alarms!==`n/a`&&r.local&&(0,X.jsx)(Z,{label:t(`Alarms & reminders`),value:r.exact_alarms===`granted`?t(`Exact`):t(`Approximate`),ok:r.exact_alarms===`granted`,hint:t(`With exact alarms a reminder fires at the minute you named even when the phone is asleep; without them Android may deliver it up to a quarter of an hour late.`),action:r.exact_alarms===`granted`?void 0:{label:t(`Allow`),onClick:()=>a(`alarms`)}}),(0,X.jsx)(D,{label:t(`Start after a reboot`),hint:r.local?t(`Bring the runtime back when the phone restarts, so routines and check-ins do not stop.`):t(`Reconnect to your computer when the phone restarts, so approvals keep arriving.`),checked:r.boot_start,onChange:e=>{n.setStartOnBoot?.(e),i({...r,boot_start:e})}}),s&&(0,X.jsxs)(`div`,{className:`rounded-2xl bg-surface-2 px-3 py-2.5 text-[12.5px] leading-snug space-y-2`,children:[(0,X.jsx)(`div`,{className:`font-medium`,children:t(`On this phone's Android`)}),(0,X.jsx)(`div`,{className:`text-muted`,children:s}),r.autostart_settings&&(0,X.jsx)(`button`,{type:`button`,onClick:()=>a(`autostart`),className:`rounded-full border border-border px-3 py-1.5 text-[12.5px]`,children:t(`Open the auto-start settings`)})]}),(0,X.jsxs)(`div`,{className:`flex items-center justify-between gap-3 pt-1`,children:[(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:t(`Crashes are written to a file on this phone and nowhere else. Export bundles them with the runtime's log for someone you choose.`)}),(0,X.jsx)(`button`,{type:`button`,onClick:()=>n.exportLogs?.(),className:`shrink-0 rounded-full border border-border px-3 py-1.5 text-[12.5px]`,children:t(`Export logs`)})]})]})}function Z({label:e,value:n,ok:r,hint:i,action:a}){return(0,X.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,X.jsxs)(`div`,{className:`flex-1`,children:[(0,X.jsxs)(`div`,{className:`flex items-center gap-2 text-[14px]`,children:[(0,X.jsx)(`span`,{children:e}),(0,X.jsx)(`span`,{className:t(`rounded-full px-2 py-0.5 text-[11px] font-medium`,r?`bg-emerald-500/12 text-emerald-700 dark:text-emerald-300`:`bg-amber-500/15 text-amber-700 dark:text-amber-300`),children:n})]}),i&&(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:i})]}),a&&(0,X.jsx)(`button`,{type:`button`,onClick:a.onClick,className:`shrink-0 rounded-full bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white`,children:a.label})]})}function Ie({name:e}){let{toast:t}=m(),[n,r]=(0,Y.useState)(null),[i,a]=(0,Y.useState)(`off`),[s,c]=(0,Y.useState)(!1),l=o(),u=async()=>{a(await v());try{r(await y.push())}catch{}};(0,Y.useEffect)(()=>{u()},[]);let f=async()=>{if(n){c(!0);try{let e=i===`on`?await h():await d(n.public_key);a(e),e===`denied`&&t(l(`Notifications are blocked for this site in the browser settings`)),await u()}catch(e){let n=e;t(n.name===`AbortError`?l(`This browser has no push service (embedded browsers often don't). Use Chrome, Edge, Firefox or Safari 16.4+ on the phone.`):n.message)}finally{c(!1)}}},p=async()=>{try{let e=await y.pushTest();t(e.ok?l(`Sent. It should arrive in a moment`):e.error??l(`Could not send`))}catch(e){t(e.message)}},g=i===`unsupported`?l(`This browser cannot receive push notifications.`):i===`insecure`?l(`Notifications need https:// (or localhost). Over plain http on your LAN the app works, this part stays off; see docs/deployment.md.`):i===`denied`?l(`Blocked for this site. Allow notifications in the browser's site settings, then try again.`):n&&!n.available?l(`The server was installed without pywebpush.`):``;return(0,X.jsxs)(X.Fragment,{children:[(0,X.jsx)(D,{label:l(`Let {name} notify this device`,{name:e}),hint:l(`Approvals, questions, finished background work and check-ins; nothing while the app is on screen.`),checked:i===`on`,onChange:()=>void f(),disabled:s||!!g}),g&&(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:g}),n&&n.subscriptions>0&&(0,X.jsxs)(`div`,{className:`flex items-center justify-between text-[12.5px] text-muted`,children:[(0,X.jsx)(`span`,{children:n.subscriptions===1?l(`1 device subscribed`):l(`{n} devices subscribed`,{n:n.subscriptions})}),(0,X.jsx)(`button`,{type:`button`,onClick:()=>void p(),className:`text-accent font-medium`,children:l(`Send a test`)})]}),!I()&&(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:l(`On a phone, add the app to the home screen first; the icon then shows a badge and notifications open the right chat.`)})]})}var Q=[{id:`off`,title:`Off`,text:`Only works when you ask.`},{id:`low`,title:`Low`,text:`Checks in half as often; speaks up only when a step is done or it needs you.`},{id:`default`,title:`Default`,text:`Works on goals on schedule; reports real progress, stays quiet otherwise.`},{id:`high`,title:`High`,text:`Checks in twice as often and always reports, even 'still on track'.`}];function Le({value:e,onChange:n}){let r=o(),i=Q.find(t=>t.id===e)??Q[2];return(0,X.jsxs)(`div`,{children:[(0,X.jsx)(`div`,{className:`flex items-center gap-3`,children:(0,X.jsx)(`div`,{className:`flex-1 text-[13px] text-muted leading-snug`,children:r(i.text)})}),(0,X.jsx)(`div`,{className:`mt-2 grid grid-cols-4 gap-1 rounded-2xl bg-surface-2 p-1`,children:Q.map(i=>(0,X.jsx)(`button`,{type:`button`,onClick:()=>n(i.id),"aria-pressed":e===i.id,className:t(`rounded-xl py-1.5 text-[13px] font-medium transition`,e===i.id?`bg-surface shadow-sm text-accent`:`text-muted`),children:r(i.title)},i.id))})]})}function Re({value:e,onChange:n}){let[r,i]=e?e.split(`-`):[``,``],a=!!e,s=o(),c=(e,t)=>n(e&&t?`${e}-${t}`:``);return(0,X.jsxs)(`div`,{children:[(0,X.jsxs)(`label`,{className:`flex items-start gap-3 cursor-pointer`,children:[(0,X.jsxs)(`div`,{className:`flex-1`,children:[(0,X.jsxs)(`div`,{className:`text-[14px] flex items-center gap-1.5`,children:[(0,X.jsx)(pe,{size:15,className:`text-muted`}),` `,s(`Quiet hours`)]}),(0,X.jsx)(`div`,{className:`text-[12.5px] text-muted leading-snug`,children:s(`No background work in this window; anything due waits until it ends.`)})]}),(0,X.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":a,onClick:()=>c(a?``:`22:00`,a?``:`08:00`),className:t(`relative mt-0.5 h-7 w-12 shrink-0 rounded-full transition`,a?`bg-accent`:`bg-surface-2 border border-border`),children:(0,X.jsx)(`span`,{className:t(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,a?`left-[22px]`:`left-0.5`)})})]}),a&&(0,X.jsxs)(`div`,{className:`mt-2 flex items-center gap-2 text-[13.5px]`,children:[(0,X.jsx)(`span`,{className:`text-muted`,children:s(`From`)}),(0,X.jsx)(`input`,{type:`time`,value:r,onChange:e=>c(e.target.value,i),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`}),(0,X.jsx)(`span`,{className:`text-muted`,children:s(`to`)}),(0,X.jsx)(`input`,{type:`time`,value:i,onChange:e=>c(r,e.target.value),className:`rounded-xl bg-surface-2 px-2.5 py-1.5 outline-none`})]})]})}function ze(){let e=o(),t=ae(),[n,r]=(0,Y.useState)(null);(0,Y.useEffect)(()=>{let e=!0;return t?.loginItem?.().then(t=>{e&&r(t)}),()=>{e=!1}},[t]);let i=t?.platform===`darwin`,a=[[e(`Quick chat`),i?`⌥ Space`:`Ctrl+Shift+Space`],[e(`Show the window`),i?`⌘⇧M`:`Ctrl+Shift+M`],[e(`Stop the hands`),i?`⌘⇧Esc`:`Ctrl+Shift+Esc`]];return(0,X.jsxs)(X.Fragment,{children:[t?.setLoginItem&&n!==null&&t.platform!==`linux`&&(0,X.jsx)(D,{label:e(`Start with the computer`),hint:e(`Opens in the tray at sign-in; the agent's goals and check-ins run from then on.`),checked:n,onChange:e=>{r(e),t.setLoginItem?.(e)}}),(0,X.jsxs)(`div`,{className:`text-[13.5px]`,children:[e(`In the tray`),(0,X.jsx)(`span`,{className:`block text-[12px] text-muted`,children:e(`The icon by the clock shows what the hands are doing and has Stop; closing the window keeps the agent running.`)})]}),(0,X.jsxs)(`div`,{className:`space-y-1`,children:[(0,X.jsx)(`div`,{className:`text-[13.5px]`,children:e(`Shortcuts`)}),a.map(([e,t])=>(0,X.jsxs)(`div`,{className:`flex items-center justify-between text-[13px]`,children:[(0,X.jsx)(`span`,{className:`text-muted`,children:e}),(0,X.jsx)(`kbd`,{className:`rounded-md border border-border bg-surface-2 px-1.5 py-0.5 font-sans text-[12px] text-fg/80`,children:t})]},e))]})]})}function $({title:e,children:n,id:r,plain:i=!1}){return(0,X.jsxs)(`section`,{id:r,className:t(`space-y-3 scroll-mt-4`,i?`pt-1`:`rounded-3xl bg-surface border border-border/70 shadow-sm p-4`),children:[!i&&(0,X.jsx)(`h2`,{className:`text-[12px] font-semibold uppercase tracking-wide text-muted`,children:e}),n]})}export{Ae as SettingsScreen}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/SkillsScreen-BBsZ25e9.js b/nanomuse/server/static/assets/SkillsScreen-DsMpJe1f.js similarity index 98% rename from nanomuse/server/static/assets/SkillsScreen-BBsZ25e9.js rename to nanomuse/server/static/assets/SkillsScreen-DsMpJe1f.js index 314b7364..bb6a0994 100644 --- a/nanomuse/server/static/assets/SkillsScreen-BBsZ25e9.js +++ b/nanomuse/server/static/assets/SkillsScreen-DsMpJe1f.js @@ -1,4 +1,4 @@ -import{A as e,E as t,J as n,K as r,Q as i,Y as a,b as o,f as s,q as c,r as l,x as u}from"./MuseHeader-DHtaUVEa.js";import{$ as d,B as f,P as p,S as m,X as h,Z as g,yt as _,z as v}from"./index-CrH9-E2E.js";import{t as y}from"./BackBar-B5YUZVK1.js";var b=n(`file-code-corner`,[[`path`,{d:`M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35`,key:`1wthlu`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`m5 16-3 3 3 3`,key:`331omg`}],[`path`,{d:`m9 22 3-3-3-3`,key:`lsp7cz`}]]),x=n(`link-2`,[[`path`,{d:`M9 17H7A5 5 0 0 1 7 7h2`,key:`8i5ue5`}],[`path`,{d:`M15 7h2a5 5 0 1 1 0 10h-2`,key:`1b9ql8`}],[`line`,{x1:`8`,x2:`16`,y1:`12`,y2:`12`,key:`1jonct`}]]),S=i(a(),1),C=o(),w=e=>`--- +import{A as e,E as t,J as n,K as r,Q as i,Y as a,b as o,f as s,q as c,r as l,x as u}from"./MuseHeader-RC--uR8o.js";import{$ as d,B as f,P as p,S as m,X as h,Z as g,yt as _,z as v}from"./index-tYqPtwPX.js";import{t as y}from"./BackBar-JEuC8Q_e.js";var b=n(`file-code-corner`,[[`path`,{d:`M4 12.15V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-3.35`,key:`1wthlu`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`m5 16-3 3 3 3`,key:`331omg`}],[`path`,{d:`m9 22 3-3-3-3`,key:`lsp7cz`}]]),x=n(`link-2`,[[`path`,{d:`M9 17H7A5 5 0 0 1 7 7h2`,key:`8i5ue5`}],[`path`,{d:`M15 7h2a5 5 0 1 1 0 10h-2`,key:`1b9ql8`}],[`line`,{x1:`8`,x2:`16`,y1:`12`,y2:`12`,key:`1jonct`}]]),S=i(a(),1),C=o(),w=e=>`--- name: ${e} description: What this does, and when to use it; the model picks the skill from this line. --- diff --git a/nanomuse/server/static/assets/StarNudge-viN7_tiM.js b/nanomuse/server/static/assets/StarNudge-Df6LQyo9.js similarity index 99% rename from nanomuse/server/static/assets/StarNudge-viN7_tiM.js rename to nanomuse/server/static/assets/StarNudge-Df6LQyo9.js index 3123d528..05cb4e9f 100644 --- a/nanomuse/server/static/assets/StarNudge-viN7_tiM.js +++ b/nanomuse/server/static/assets/StarNudge-Df6LQyo9.js @@ -1 +1 @@ -import{E as e,J as t,K as n,L as r,Q as i,W as a,Y as o,b as s,x as c}from"./MuseHeader-DHtaUVEa.js";import{t as ee}from"./external-link-3iD-Bcbv.js";import{a as l,i as te}from"./Form-heEKMZyj.js";var u=t(`bug`,[[`path`,{d:`M12 20v-9`,key:`1qisl0`}],[`path`,{d:`M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z`,key:`uouzyp`}],[`path`,{d:`M14.12 3.88 16 2`,key:`qol33r`}],[`path`,{d:`M21 21a4 4 0 0 0-3.81-4`,key:`1b0z45`}],[`path`,{d:`M21 5a4 4 0 0 1-3.55 3.97`,key:`5cxbf6`}],[`path`,{d:`M22 13h-4`,key:`1jl80f`}],[`path`,{d:`M3 21a4 4 0 0 1 3.81-4`,key:`1fjd4g`}],[`path`,{d:`M3 5a4 4 0 0 0 3.55 3.97`,key:`1d7oge`}],[`path`,{d:`M6 13H2`,key:`82j7cp`}],[`path`,{d:`m8 2 1.88 1.88`,key:`fmnt4t`}],[`path`,{d:`M9 7.13V6a3 3 0 1 1 6 0v1.13`,key:`1vgav8`}]]),ne=t(`github`,[[`path`,{d:`M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4`,key:`tonef`}],[`path`,{d:`M9 18c-4.51 2-5-2-7-2`,key:`9comsn`}]]),d=t(`heart-handshake`,[[`path`,{d:`M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762`,key:`17lmqv`}]]),f=t(`star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),p=i(o(),1),m={version:1,updated:`2026-10`,capabilities:[`chat`,`vision`,`image`,`video`],auth_kinds:[`key`,`none`,`oauth-chatgpt`,`oauth-claude`,`oauth-openrouter`,`device-kimi`],providers:[{id:`bailian`,name:`Alibaba Cloud Bailian`,name_zh:`阿里云百炼`,protocol:`openai`,base_url:`https://dashscope.aliyuncs.com/compatible-mode/v1`,key_url:`https://bailian.console.aliyun.com/?apiKey=1`,key_hint:`sk-…`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`,`video`],defaults:{chat:`deepseek-v4.1-flash`,hands:`qwen3.8-27b`,image:`qwen-image-3.0`,video:`wan2.2-i2v-flash`},note:`One key for chat, hands, pictures and clips. Signs up accounts with a mainland China identity only; the international edition (dashscope-intl) does not carry these models. Pictures and clips go through Model Studio's own API, which nanoMuse speaks.`,note_zh:`一把 key 覆盖对话、手、画图和视频。只给中国大陆身份注册;国际版(dashscope-intl)没有这几个模型。画图和视频走百炼自己的接口,nanoMuse 已支持。`,verified:`2026-10`},{id:`deepseek`,name:`DeepSeek`,name_zh:`DeepSeek(深度求索)`,protocol:`openai`,base_url:`https://api.deepseek.com/v1`,key_url:`https://platform.deepseek.com/api_keys`,key_hint:`sk-…`,auth:[`key`],regions:[`cn`,`global`],capabilities:[`chat`,`vision`],defaults:{chat:`deepseek-flash`,hands:`deepseek-flash`},note:`deepseek-flash (DeepSeek-V4.1-Flash) reads pictures; deepseek-v4-pro is text only. No image or video models.`,note_zh:`deepseek-flash(DeepSeek-V4.1-Flash)能看图;deepseek-v4-pro 只处理文字。没有图像和视频模型。`,verified:`2026-10`,reasoning:{doc:`https://api-docs.deepseek.com/guides/thinking_mode`,wire:`thinking+reasoning_effort`,rules:[{models:`^deepseek-`,levels:[`low`,`high`,`max`],default:`high`}]}},{id:`moonshot`,name:`Kimi (Moonshot AI)`,name_zh:`Kimi(月之暗面)`,protocol:`openai`,base_url:`https://api.moonshot.cn/v1`,base_url_global:`https://api.moonshot.ai/v1`,key_url:`https://platform.moonshot.cn/console/api-keys`,key_url_global:`https://platform.kimi.ai/console`,key_hint:`sk-…`,auth:[`key`,`device-kimi`],regions:[`cn`,`global`],capabilities:[`chat`,`vision`],defaults:{chat:`kimi-k2.6`,hands:`kimi-k2.6`},note:`Two editions: api.moonshot.cn for mainland China, api.moonshot.ai elsewhere, each with its own console and keys. kimi-k2.6 and kimi-k3 read pictures. The phone apps can sign in with a Kimi account (device code) instead of a key.`,note_zh:`两个版本:中国大陆用 api.moonshot.cn,海外用 api.moonshot.ai,各自有控制台和 key。kimi-k2.6、kimi-k3 能看图。手机 App 可以用 Kimi 账号登录(设备码),不用贴 key。`,verified:`2026-10`,reasoning:{doc:`https://platform.moonshot.ai/docs/api/chat`,wire:`reasoning_effort`,rules:[{models:`^kimi-k3`,levels:[`low`,`high`,`max`],default:`max`}]}},{id:`zhipu`,name:`Zhipu GLM`,name_zh:`智谱 GLM`,protocol:`openai`,base_url:`https://open.bigmodel.cn/api/paas/v4`,key_url:`https://open.bigmodel.cn/usercenter/apikeys`,key_hint:`xxxxxxxx.xxxxxxxx`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`glm-5.3`,hands:`glm-4.6v`,image:`glm-image`},note:`glm-4.6v reads pictures; pictures are drawn by glm-image or cogview-4 through /images/generations.`,note_zh:`glm-4.6v 能看图;画图用 glm-image 或 cogview-4,走 /images/generations。`,verified:`2026-10`,reasoning:{doc:`https://docs.bigmodel.cn/api-reference/%E6%A8%A1%E5%9E%8B-api/%E5%AF%B9%E8%AF%9D%E8%A1%A5%E5%85%A8`,wire:`thinking+reasoning_effort`,rules:[{models:`^glm-5\\.3`,levels:[`low`,`high`,`max`],default:`max`},{models:`^glm-5\\.2`,levels:[`high`,`max`],default:`max`}]}},{id:`siliconflow`,name:`SiliconFlow`,name_zh:`硅基流动`,protocol:`openai`,base_url:`https://api.siliconflow.cn/v1`,key_url:`https://cloud.siliconflow.cn/account/ak`,key_hint:`sk-…`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`deepseek-ai/DeepSeek-V4-Flash`,hands:`Qwen/Qwen3-VL-32B-Instruct`,image:`Qwen/Qwen-Image`},note:`Many open models behind one key; model ids carry the maker's prefix. Pictures through /images/generations (Qwen-Image, Kolors, FLUX).`,note_zh:`一把 key 后面是许多开源模型,模型 id 带厂商前缀。画图走 /images/generations(Qwen-Image、Kolors、FLUX)。`,verified:`2026-10`,reasoning:{doc:`https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions`,wire:`reasoning_effort`,rules:[{models:`^(Pro/)?deepseek-ai/DeepSeek-V4(-|$)`,levels:[`high`,`max`],default:`high`},{models:`^Pro/zai-org/GLM-5\\.2`,levels:[`high`,`max`],default:`high`}]}},{id:`volcengine`,name:`Volcengine Ark (Doubao)`,name_zh:`火山方舟(豆包)`,protocol:`openai`,base_url:`https://ark.cn-beijing.volces.com/api/v3`,key_url:`https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey`,key_hint:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`doubao-seed-2-0-pro-260215`,hands:`doubao-seed-2-0-pro-260215`,image:`doubao-seedream-5-0-lite-260128`},note:`Model ids are dated deployments; the live list has the exact ones. Doubao Seed 2.0 reads pictures; Seedream draws them through /images/generations. Seedance video uses Ark's own task API, which nanoMuse does not speak, so clips are left out.`,note_zh:`模型 id 带日期后缀,以实时列表为准。豆包 Seed 2.0 能看图;Seedream 画图走 /images/generations。Seedance 视频走方舟自己的任务接口,nanoMuse 还不支持,所以不列视频。`,verified:`2026-10`,reasoning:{doc:`https://docs.volcengine.com/docs/ark/model-parameter-support?lang=zh`,wire:`reasoning_effort`,rules:[{models:`^doubao-seed-2-`,levels:[`minimal`,`low`,`medium`,`high`],default:`high`},{models:`^deepseek-v4-`,levels:[`low`,`high`,`max`],default:`high`},{models:`^glm-5-2`,levels:[`high`,`max`],default:`max`}]}},{id:`minimax`,name:`MiniMax`,name_zh:`MiniMax(稀宇科技)`,protocol:`openai`,base_url:`https://api.minimaxi.com/v1`,base_url_global:`https://api.minimax.io/v1`,key_url:`https://platform.minimaxi.com/user-center/basic-information/interface-key`,key_hint:`eyJ…`,auth:[`key`],regions:[`cn`,`global`],capabilities:[`chat`,`vision`],defaults:{chat:`MiniMax-M3`,hands:`MiniMax-M3`},note:`Two editions: api.minimaxi.com for mainland China, api.minimax.io elsewhere. MiniMax-M3 reads pictures; the M2 series is text only.`,note_zh:`两个版本:中国大陆用 api.minimaxi.com,海外用 api.minimax.io。MiniMax-M3 能看图;M2 系列只处理文字。`,verified:`2026-10`,reasoning:{doc:`https://platform.minimax.io/docs/api-reference/text-chat`,wire:`reasoning_effort`,rules:[{models:`^MiniMax-M3\\.1`,levels:[`low`,`medium`,`high`,`xhigh`,`max`],default:`max`}]}},{id:`openrouter`,name:`OpenRouter`,name_zh:`OpenRouter`,protocol:`openai`,base_url:`https://openrouter.ai/api/v1`,key_url:`https://openrouter.ai/keys`,key_hint:`sk-or-v1-…`,auth:[`key`,`oauth-openrouter`],regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`deepseek/deepseek-v4.1-flash`,hands:`qwen/qwen3.8-27b`,image:`openai/gpt-image-2`},note:`One account, one key, pay as you go, international cards; models carry the maker's prefix. Pictures only through image-output models (openai/gpt-image-2, google/gemini-2.5-flash-image, bytedance-seed/seedream-4.5) on OpenRouter's Image API (/api/v1/images). The phone apps can sign in to OpenRouter instead of pasting a key.`,note_zh:`一个账号一把 key,按量付费,国际信用卡;模型带厂商前缀。画图只能用出图模型(openai/gpt-image-2、google/gemini-2.5-flash-image、bytedance-seed/seedream-4.5),走 OpenRouter 的图像接口(/api/v1/images)。手机 App 可以直接登录 OpenRouter,不用贴 key。`,verified:`2026-10`,reasoning:{doc:`https://openrouter.ai/docs/use-cases/reasoning-tokens`,wire:`reasoning.effort`,listed:!0,rules:[]}},{id:`openai`,name:`OpenAI`,name_zh:`OpenAI`,protocol:`openai`,base_url:`https://api.openai.com/v1`,key_url:`https://platform.openai.com/api-keys`,key_hint:`sk-proj-…`,auth:[`key`,`oauth-chatgpt`],auth_capabilities:{"oauth-chatgpt":[`chat`,`vision`]},regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`gpt-5.4-mini`,hands:`gpt-5.4`,image:`gpt-image-2.5-flare`},note:`With an API key: chat, hands and pictures (gpt-image-2.5-flare / gpt-image-2 through /images/generations). Signed in with a ChatGPT plan (the Codex OAuth): chat and hands only; the Codex backend has no image or video endpoints. OpenAI's terms cover using a ChatGPT plan inside OpenAI's own Codex; other apps have had this access cut off before (OpenCode, January 2026). If it stops working, an API key does.`,note_zh:`用 API key:对话、手、画图(gpt-image-2.5-flare / gpt-image-2,走 /images/generations)。用 ChatGPT 订阅登录(Codex 的 OAuth):只有对话和手,Codex 后端没有图像和视频接口。OpenAI 的条款只允许在它自己的 Codex 里使用 ChatGPT 订阅;其他应用的这条路曾被切断过(OpenCode,2026 年 1 月)。如果哪天不能用了,API key 还能用。`,verified:`2026-10`,reasoning:{doc:`https://developers.openai.com/api/docs/guides/reasoning`,wire:`reasoning_effort`,rules:[{models:`^o[1-9]`,levels:[`low`,`medium`,`high`],default:`medium`},{models:`^gpt-5(-|$)`,levels:[`minimal`,`low`,`medium`,`high`],default:`medium`},{models:`^gpt-5\\.1(-|$)`,levels:[`low`,`medium`,`high`],default:`none`},{models:`^gpt-5\\.4`,levels:[`low`,`medium`,`high`,`xhigh`],default:`none`},{models:`^gpt-5\\.[235-9]`,levels:[`low`,`medium`,`high`,`xhigh`]},{models:`^gpt-6`,levels:[`low`,`medium`,`high`,`xhigh`]}]}},{id:`anthropic`,name:`Anthropic Claude`,name_zh:`Anthropic Claude`,protocol:`anthropic`,base_url:`https://api.anthropic.com`,key_url:`https://platform.claude.com/settings/keys`,key_hint:`sk-ant-…`,auth:[`key`,`oauth-claude`],regions:[`global`],capabilities:[`chat`,`vision`],defaults:{chat:`claude-sonnet-5-5`,hands:`claude-sonnet-5-5`},note:`The Messages API, not OpenAI's shape; every current Claude model reads pictures. No image or video models. The phone apps can sign in with a Claude account instead of a key.`,note_zh:`用的是 Messages 接口,不是 OpenAI 格式;现行 Claude 模型都能看图。没有图像和视频模型。手机 App 可以用 Claude 账号登录,不用贴 key。`,verified:`2026-10`,reasoning:{doc:`https://docs.claude.com/en/docs/build-with-claude/effort`,wire:`output_config.effort`,rules:[{models:`^claude-(opus|haiku)-5-5`,levels:[`low`,`medium`,`high`,`xhigh`,`max`],default:`medium`},{models:`^claude-(opus|sonnet)-4-6`,levels:[`low`,`medium`,`high`,`max`],default:`high`},{models:`^claude-(opus-4-[78]|opus-5|sonnet-5|haiku-5|fable-5|mythos-5)`,levels:[`low`,`medium`,`high`,`xhigh`,`max`],default:`high`}]}},{id:`gemini`,name:`Google Gemini`,name_zh:`Google Gemini`,protocol:`gemini`,base_url:`https://generativelanguage.googleapis.com/v1beta/openai`,key_url:`https://aistudio.google.com/apikey`,key_hint:`AIza…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`gemini-3.8-flash`,hands:`gemini-3.8-flash`,image:`gemini-2.5-flash-image`},note:`Google's OpenAI-compatible layer. Gemini models read pictures. Pictures through /images/generations work with gemini-2.5-flash-image and gemini-3-pro-image-preview only; the newer 3.1 image models are not served there. Veo video is not on the compatible layer, so clips are left out.`,note_zh:`Google 的 OpenAI 兼容层。Gemini 模型能看图。走 /images/generations 画图只支持 gemini-2.5-flash-image 和 gemini-3-pro-image-preview,更新的 3.1 出图模型不在兼容层上。Veo 视频不走兼容层,所以不列视频。`,verified:`2026-10`,reasoning:{doc:`https://ai.google.dev/gemini-api/docs/openai`,wire:`reasoning_effort`,rules:[{models:`^gemini-(2\\.5|3)`,levels:[`low`,`medium`,`high`]}]}},{id:`xai`,name:`xAI Grok`,name_zh:`xAI Grok`,protocol:`openai`,base_url:`https://api.x.ai/v1`,key_url:`https://console.x.ai`,key_hint:`xai-…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`grok-4.7`,hands:`grok-4.7`,image:`grok-imagine-image-2.0`},note:`grok-4.7 reads pictures (JPEG and PNG). Pictures through /images/generations with grok-imagine-image-2.0. Grok Imagine video uses xAI's own endpoints, so clips are left out.`,note_zh:`grok-4.7 能看图(JPEG、PNG)。画图走 /images/generations,模型 grok-imagine-image-2.0。Grok Imagine 视频走 xAI 自己的接口,所以不列视频。`,verified:`2026-10`,reasoning:{doc:`https://docs.x.ai/docs/guides/reasoning`,wire:`reasoning_effort`,rules:[{models:`^grok-4\\.5`,levels:[`low`,`medium`,`high`],default:`high`},{models:`^grok-4\\.[6-9]`,levels:[`low`,`medium`,`high`,`xhigh`],default:`high`}]}},{id:`groq`,name:`Groq`,name_zh:`Groq`,protocol:`openai`,base_url:`https://api.groq.com/openai/v1`,key_url:`https://console.groq.com/keys`,key_hint:`gsk_…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`],defaults:{chat:`openai/gpt-oss-120b`,hands:`qwen/qwen3.8-27b`},note:`Fast hosting of open models. qwen/qwen3.8-27b reads pictures; gpt-oss and Llama do not. No image or video models.`,note_zh:`开源模型的高速托管。qwen/qwen3.8-27b 能看图;gpt-oss 和 Llama 不能。没有图像和视频模型。`,verified:`2026-10`,reasoning:{doc:`https://console.groq.com/docs/reasoning`,wire:`reasoning_effort`,rules:[{models:`^openai/gpt-oss-`,levels:[`low`,`medium`,`high`]},{models:`^qwen/qwen3\\.8`,levels:[`low`,`medium`,`high`],default:`none`}]}},{id:`mistral`,name:`Mistral AI`,name_zh:`Mistral AI`,protocol:`openai`,base_url:`https://api.mistral.ai/v1`,key_url:`https://console.mistral.ai/api-keys`,key_hint:`…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`],defaults:{chat:`mistral-medium-2508`,hands:`mistral-medium-2508`},note:`mistral-large-2512, mistral-medium-2508, mistral-small-2506 and the Ministral 3 models read pictures. No image or video models.`,note_zh:`mistral-large-2512、mistral-medium-2508、mistral-small-2506 和 Ministral 3 系列能看图。没有图像和视频模型。`,verified:`2026-10`,reasoning:{doc:`https://docs.mistral.ai/capabilities/reasoning/`,wire:`reasoning_effort`,rules:[{models:`^mistral-(small-latest|medium-3-5|large-4-0)`,levels:[`high`],default:`none`},{models:`^zai-glm-5-3`,levels:[`low`,`high`,`max`]}]}},{id:`opencode-zen`,name:`OpenCode Zen`,name_zh:`OpenCode Zen`,protocol:`openai`,base_url:`https://opencode.ai/zen/v1`,key_url:`https://opencode.ai/auth`,auth:[`key`],regions:[`global`],capabilities:[`chat`],defaults:{chat:`deepseek-v4.1-flash`},note:`OpenCode's own gateway: pay per request from a balance, a key made in the OpenCode console, and a few free models beside the paid ones. Chat answers on the OpenAI shape; the GPT, Claude and Gemini ids on the same host speak the Responses, Messages and Google shapes, which this entry does not reach, and the vision model OpenCode lists is deepseek-v4-flash-vision-exp, the id DeepSeek has retired on its own platform, so the hands are not claimed here. OpenCode publishes no plan sign-in, only a key.`,note_zh:`OpenCode 自己的网关:按请求从余额里扣费,key 在 OpenCode 控制台申请,付费模型之外还有几个免费的。对话走 OpenAI 格式;同一域名上的 GPT、Claude、Gemini 走 Responses、Messages 和 Google 格式,本条目到不了;它列的视觉模型是 deepseek-v4-flash-vision-exp,这个 id 在 DeepSeek 自家平台上已经退役,所以「动手」不列入。OpenCode 没有套餐登录,只有 key。`,verified:`2026-10`},{id:`opencode-go`,name:`OpenCode Go`,name_zh:`OpenCode Go`,protocol:`openai`,base_url:`https://opencode.ai/zen/go/v1`,key_url:`https://opencode.ai/auth`,auth:[`key`],regions:[`global`],capabilities:[`chat`],defaults:{chat:`deepseek-v4.1-flash`},session_header:`x-opencode-session`,note:`A subscription for coding agents: Go ($10 a month) or Go Plus ($40) in the OpenCode console, then a key made there; usage is counted per model against monthly, weekly and five hour limits. OpenCode asks every client to name itself and to send one stable id per conversation in x-opencode-session: the runtime sends both, the desktop reaches Go through its model adapter's own OpenCode Go route, which sends the id and speaks each model's shape, and the phone apps send an id per chat (a hash of its first message, the same on every turn). Outside the desktop only the OpenAI shape is reached, which is where the open coding models answer; the GPT, Grok, Claude, MiniMax and Qwen ids speak the Responses and Messages shapes. The one vision model OpenCode lists (deepseek-v4-flash-vision-exp) is an id DeepSeek has retired on its own platform, so the hands are not claimed here. OpenCode publishes no plan sign-in, only a key.`,note_zh:`面向编程智能体的订阅:在 OpenCode 控制台订阅 Go(每月 10 美元)或 Go Plus(每月 40 美元),再在那里申请 key;用量按模型计,分每月、每周和每 5 小时三档上限。OpenCode 要求每个客户端说明自己是谁,并在 x-opencode-session 里为每个对话带上一个固定的 id:运行时两样都发;桌面版通过模型适配器自带的 OpenCode Go 路由访问,这条路由会带上 id,也按每个模型自己的格式说话;手机端为每个聊天带一个 id(由第一条消息算出,每一轮都一样)。桌面版以外只走 OpenAI 格式,开源编程模型都在这个格式上;GPT、Grok、Claude、MiniMax 和 Qwen 走 Responses 和 Messages 格式。它列的那个视觉模型(deepseek-v4-flash-vision-exp)在 DeepSeek 自家平台上已经退役,所以「动手」不列入。OpenCode 没有套餐登录,只有 key。`,verified:`2026-10`},{id:`ollama`,name:`Ollama`,name_zh:`Ollama(本机)`,protocol:`openai`,base_url:`http://127.0.0.1:11434/v1`,key_url:`https://ollama.com/download`,auth:[`none`],regions:[`cn`,`global`],capabilities:[`chat`],defaults:{chat:`qwen3:8b`},note:`Runs on this machine, no key, nothing leaves it. Hands need a model that reads pictures (a vision model such as a Qwen VL build); the model list says which.`,note_zh:`跑在本机,不要 key,什么都不出去。手需要能看图的模型(例如 Qwen 的 VL 版本),模型列表里能看出来。`,verified:`2026-10`,reasoning:{doc:`https://docs.ollama.com/api/openai-compatibility`,wire:`reasoning_effort`,rules:[{models:`gpt-oss`,levels:[`low`,`medium`,`high`]}]}},{id:`lm-studio`,name:`LM Studio`,name_zh:`LM Studio(本机)`,protocol:`openai`,base_url:`http://127.0.0.1:1234/v1`,key_url:`https://lmstudio.ai`,auth:[`none`],regions:[`cn`,`global`],capabilities:[`chat`],defaults:{},note:`LM Studio's local server, OpenAI-compatible, no key. Vision only if the loaded model has it.`,note_zh:`LM Studio 的本地服务,OpenAI 兼容,不要 key。装了能看图的模型才能用手。`,verified:`2026-10`},{id:`vllm`,name:`vLLM`,name_zh:`vLLM(自建)`,protocol:`openai`,base_url:`http://127.0.0.1:8000/v1`,key_url:`https://docs.vllm.ai`,auth:[`none`,`key`],regions:[`cn`,`global`],capabilities:[`chat`],defaults:{},note:`A vLLM server you run yourself; a key only if you started it with one. Vision only if the served model has it.`,note_zh:`自己跑的 vLLM 服务;启动时设了 key 才需要填。服务的模型能看图才能用手。`,verified:`2026-10`,reasoning:{doc:`https://docs.vllm.ai/en/latest/features/reasoning_outputs/`,wire:`reasoning_effort`,rules:[{models:`gpt-oss`,levels:[`low`,`medium`,`high`]}]}},{id:`custom`,name:`Any OpenAI-compatible endpoint`,name_zh:`任何 OpenAI 兼容接口`,protocol:`openai`,base_url:``,key_url:``,auth:[`key`,`none`],regions:[`cn`,`global`],capabilities:[`chat`],user_capabilities:!0,defaults:{},note:`A gateway, a relay, a server of your own: paste the address (usually ending in /v1) and say what it can do; the person sets the capabilities.`,note_zh:`网关、中转、自己的服务:贴地址(一般以 /v1 结尾),再说明它能做什么,能力由你勾选。`,verified:`2026-10`}]};function h(e){return e?.region?e.region===`cn`:e?.signed_in&&e.channel===`sms`?!0:r()===`zh-CN`}function re(e){return/^\s*(\+|00)(?!86\b)\d/.test(e)}var ie=new Map(m.providers.map(e=>[e.id,e]));function g(e,t){let n=ie.get(e);if(!n)throw Error(`providers.json has no ${e}`);return{id:e,preset:t,label:n.name,keyUrl:n.key_url,baseUrl:n.base_url,chatModel:n.defaults.chat??``,guiModel:n.defaults.hands??n.defaults.chat??``,covers:n.capabilities}}var ae=g(`bailian`,`qwen`),oe=g(`openrouter`,`openrouter`);function _(e){return h(e)?ae:oe}function se(e,t){return h(t)?e(`Alibaba Cloud Bailian is a good start: a new account comes with a free quota, set-up takes about two minutes, and one key covers chat, the hands, pictures and clips.`):e(`Alibaba Cloud Bailian only signs up accounts from mainland China. Outside, OpenRouter is the easy way: one account, one key, pay as you go, for chat, the hands and pictures; clips need Bailian.`)}function ce(e){let t=(e||``).toLowerCase().split(`/`).pop()??``;return t?t.startsWith(`deepseek`)?/v4\.1|vision|ocr/.test(t):t.startsWith(`qwen`)&&(t.includes(`-vl`)||t.startsWith(`qwen3.8`))?!0:null:null}function le(e,t,n){let r=_(n).preset;if(!e.includes(r))return e;let i=h(n),a=e.filter(e=>e!==r);if(i)return[r,...a];let o=a.filter(e=>t[e]?.region===`cn`);return[r,...a.filter(e=>!o.includes(e)),...o]}var ue=[`chat`,`vision`,`image`,`video`],v=m.providers,de=[`ollama`,`lm-studio`,`vllm`],fe={cn:[`bailian`],global:[`openrouter`,`openai`]},y={bailian:`qwen`,moonshot:`kimi`,zhipu:`glm`,volcengine:`doubao`};function pe(e){return y[e]??e}function me(e){return Object.entries(y).find(([,t])=>t===e)?.[0]??(e===`openai_responses`?`openai`:e)}function he(e){return h(e)?`cn`:`global`}function b(e,t){return t===`zh-CN`&&e.name_zh||e.name}function ge(e,t){return t===`global`&&e.regions.includes(`global`)&&e.base_url_global?{base_url:e.base_url_global,key_url:e.key_url_global||e.key_url}:{base_url:e.base_url,key_url:e.key_url}}function x(e,t){return e.regions.includes(t)}function S(e,t,n=v){let r=new Map(n.map(e=>[e.id,e])),i=fe[e].map(e=>r.get(e)).filter(t=>!!t&&x(t,e)),a=n.filter(t=>!i.includes(t)&&!de.includes(t.id)&&t.id!==`custom`&&x(t,e)).sort((e,t)=>t.capabilities.length-e.capabilities.length),o=[...i,...a];return t?o.filter(e=>e.capabilities.includes(t)):o}function _e(e=v){let t=[{id:`chatgpt`,provider:`openai`,name:`ChatGPT`,auth:`oauth-chatgpt`,here:!0},{id:`claude`,provider:`anthropic`,name:`Claude`,auth:`oauth-claude`,here:!1},{id:`kimi`,provider:`moonshot`,name:`Kimi`,auth:`device-kimi`,here:!1},{id:`openrouter`,provider:`openrouter`,name:`OpenRouter`,auth:`oauth-openrouter`,here:!1}],n=[];for(let r of t){let t=e.find(e=>e.id===r.provider);t&&t.auth.includes(r.auth)&&n.push({...r,covers:t.auth_capabilities?.[r.auth]??t.capabilities})}return n}function ve(e,t){switch(t){case`chat`:return e(`Chat`);case`vision`:return e(`Hands`);case`image`:return e(`Pictures`);case`video`:return e(`Clips`)}}function ye(e,t){return e(`covers {list}`,{list:ue.filter(e=>t.includes(e)).map(t=>ve(e,t).toLowerCase()).join(e(`, `))})}function be(e,t,n,r,i=v){let a=S(n,t,i).slice(0,4).map(e=>b(e,r)),o=a.pop()??``,s=a.length?`${a.join(e(`, `))}${e(` or `)}${o}`:o;switch(t){case`image`:return e(`Pictures need a provider with image models: {providers}.`,{providers:s});case`video`:return e(`Clips need a provider with video models: {providers}.`,{providers:s});case`vision`:return e(`The hands need a model that sees pictures: {providers}.`,{providers:s});case`chat`:return e(`Chat needs a model: {providers}.`,{providers:s})}}var C=null,w=null;async function xe(e=!1){return C&&!e?C:(w&&!e||(w=c.providers().then(e=>({...e,capabilities:e.capabilities??null,source:`runtime`})).catch(()=>({providers:v,region:``,configured:{},capabilities:null,unavailable:{},source:`bundled`})).then(e=>(C=e,w=null,e))),w)}function Se(){C=null}function Ce(e=0){let[t,n]=(0,p.useState)(C);return(0,p.useEffect)(()=>{let t=!0;return xe(e>0).then(e=>t&&n(e)),()=>{t=!1}},[e]),t}function we(e,t){return!e||e.capabilities===null?null:e.capabilities.includes(t)}var T={version:1,star:{enabled:!0,url:`https://github.com/zeeshanhaque21/nanoMuse`,moments:{signed_in:!0,tasks:[3,10,30],new_look:!0,exhausted:!0,days_used:[7,30],goal_done:!0},cooldown_days:7,max_asks:4,text:``,text_zh:``}},Te=864e5;function E(e){if(typeof e!=`string`)return``;let t=e.trim();return t.length<=200?t:``}function Ee(e,t,n){let{text:r,text_zh:i}=e.star;return t&&i?i:r||n}var D=e=>typeof e==`object`&&!!e&&!Array.isArray(e),O=(e,t)=>typeof e==`boolean`?e:t,k=(e,t,n,r)=>typeof e==`number`&&Number.isFinite(e)&&Math.trunc(e)>=t&&Math.trunc(e)<=n?Math.trunc(e):r;function A(e,t){if(!Array.isArray(e))return t;let n=new Set;for(let r of e){if(typeof r!=`number`||!Number.isFinite(r)||Math.trunc(r)<1)return t;n.add(Math.trunc(r))}return[...n].sort((e,t)=>e-t)}function De(e){let t=T.star;if(!D(e))return structuredClone(T);let n=k(e.version,1,1e9,1),r=D(e.star)?e.star:{},i=D(r.moments)?r.moments:{},a=typeof r.url==`string`&&/^https?:\/\//.test(r.url)&&r.url.length<=200?r.url:t.url;return{version:n,star:{enabled:O(r.enabled,t.enabled),url:a,moments:{signed_in:O(i.signed_in,t.moments.signed_in),tasks:A(i.tasks,t.moments.tasks),new_look:O(i.new_look,t.moments.new_look),exhausted:O(i.exhausted,t.moments.exhausted),days_used:A(i.days_used,t.moments.days_used),goal_done:O(i.goal_done,t.moments.goal_done)},cooldown_days:k(r.cooldown_days,0,365,t.cooldown_days),max_asks:k(r.max_asks,0,50,t.max_asks),text:E(r.text),text_zh:E(r.text_zh)}}}var j={starred:`nm.star.starred`,tasks:`nm.star.tasks`,asks:`nm.star.asks`,lastAsk:`nm.star.last_ask`,shown:`nm.star.shown`,days:`nm.star.days`,lastDay:`nm.star.last_day`,migrated:`nm.star.v2`},Oe=[`signed_in`,`first_task`,`tenth_task`,`new_look`],M=e=>{let t=Number(e);return Number.isFinite(t)&&t>=0?Math.trunc(t):0};function N(e){let t=[];try{let n=JSON.parse(e.getItem(j.shown)??`[]`);Array.isArray(n)&&(t=n.filter(e=>typeof e==`string`))}catch{t=[]}let n=e.getItem(j.lastAsk);return{starred:e.getItem(j.starred)===`1`,tasks:M(e.getItem(j.tasks)),asks:M(e.getItem(j.asks)),lastAsk:n&&M(n)||null,shown:t,days:M(e.getItem(j.days)),lastDay:e.getItem(j.lastDay)??``}}function P(e,t){e.setItem(j.starred,t.starred?`1`:`0`),e.setItem(j.tasks,String(t.tasks)),e.setItem(j.asks,String(t.asks)),t.lastAsk?e.setItem(j.lastAsk,String(t.lastAsk)):e.removeItem(j.lastAsk),e.setItem(j.shown,JSON.stringify(t.shown)),e.setItem(j.days,String(t.days)),e.setItem(j.lastDay,t.lastDay)}function ke(e){let t=N(e);if(e.getItem(j.migrated)===`1`)return t;for(let n of Oe){let r=`nm.star.${n}`;e.getItem(r)===`1`&&!t.shown.includes(n)&&(t.shown.push(n),t.asks+=1),e.removeItem(r)}return P(e,t),e.setItem(j.migrated,`1`),t}var F=(e,t)=>e===`tasks`||e===`days_used`?`${e}:${t??0}`:e;function Ae(e,t,n){let r=e.star;return!r.enabled||t.starred||t.asks>=r.max_asks?!1:t.lastAsk===null||n-t.lastAsk>=r.cooldown_days*Te}function je(e,t,n,r,i){if(!Ae(t,n,r))return!1;let a=t.star.moments;return(e===`tasks`||e===`days_used`?i!==void 0&&a[e].includes(i):a[e])&&!n.shown.includes(F(e,i))}function Me(e,t,n){let r=N(e);return r.shown.includes(t)||(r.shown.push(t),r.asks+=1,r.lastAsk=n,P(e,r)),r}function Ne(e){let t=N(e);t.starred=!0,P(e,t)}function Pe(e){let t=N(e);return t.tasks+=1,P(e,t),t.tasks}var Fe=e=>`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`;function Ie(e,t){let n=N(e),r=Fe(t);return n.lastDay===r?{days:n.days,fresh:!1}:(n.days+=1,n.lastDay=r,P(e,n),{days:n.days,fresh:!0})}var I=s(),Le=`https://github.com/zeeshanhaque21/nanoMuse`,Re=`https://github.com/zeeshanhaque21/nanoMuse/issues/new/choose`,L=`https://github.com/zeeshanhaque21/nanoMuse`;function ze({compact:t=!1,className:r}){let i=n();return(0,I.jsxs)(`section`,{className:e(`rounded-[22px] border border-accent/25 bg-accent/[0.06] p-4 dark:bg-accent/[0.10]`,r),children:[(0,I.jsxs)(`a`,{href:L,target:`_blank`,rel:`noreferrer`,className:`flex items-start gap-2 text-[13.5px] font-semibold hover:text-accent`,children:[(0,I.jsx)(d,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,I.jsx)(`span`,{className:`min-w-0 flex-1 leading-snug`,children:i(`Free, open source, non-profit`)}),(0,I.jsx)(ee,{size:12,className:`mt-1 shrink-0 text-muted`})]}),(0,I.jsx)(`p`,{className:e(`mt-1.5 leading-relaxed text-fg/85`,t?`text-[12.5px]`:`text-[13px]`),children:i(`nanoMuse is a non-profit open-source community project, free forever. Sign in with a phone number or an e-mail and the model comes with a free allowance, paid by the developer; when it is gone, use your own key (Alibaba Cloud Bailian in mainland China, OpenRouter elsewhere). Nothing is sold; what the relay keeps is in the privacy policy, and Settings → Data controls is yours. Delete the account whenever you like.`)}),(0,I.jsx)(`p`,{className:e(`mt-1.5 leading-relaxed text-fg/85`,t?`text-[12.5px]`:`text-[13px]`),children:i(`The people building it use it every day. Report a bug, ask for a feature, send a pull request: that is what moves the project. A star on GitHub helps others find it.`)}),(0,I.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-2 text-[12.5px] font-medium`,children:[(0,I.jsxs)(`a`,{href:Le,target:`_blank`,rel:`noreferrer`,onClick:K,className:`inline-flex items-center gap-1.5 rounded-full bg-fg px-3 py-1.5 text-bg hover:opacity-90`,children:[(0,I.jsx)(ne,{size:13}),` `,i(`Star on GitHub`)]}),(0,I.jsxs)(`a`,{href:Re,target:`_blank`,rel:`noreferrer`,className:`inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-fg hover:bg-surface-2`,children:[(0,I.jsx)(u,{size:13}),` `,i(`Report a bug or ask for a feature`)]})]})]})}var R=new Map,z={getItem:e=>{try{return localStorage.getItem(e)}catch{return R.get(e)??null}},setItem:(e,t)=>{try{localStorage.setItem(e,t)}catch{R.set(e,t)}},removeItem:e=>{try{localStorage.removeItem(e)}catch{R.delete(e)}}},B=!1;function Be(){return B?N(z):(B=!0,ke(z))}var V=T,H=!1,U=null,W=new Set;function Ve(){return U??=c.nudges().then(e=>{V=De(e?.policy)}).catch(()=>{V=T}).finally(()=>{H=!0,W.forEach(e=>e())}),U}function G(){let[,e]=(0,p.useState)(0);return(0,p.useEffect)(()=>{let t=()=>e(e=>e+1);return W.add(t),Ve(),()=>{W.delete(t)}},[]),{policy:V,ready:H}}var He=()=>Pe(z),Ue=()=>Ie(z,new Date),We=(e,t)=>H&&je(e,V,Be(),Date.now(),t),Ge=(e,t)=>{Me(z,F(e,t),Date.now())},K=()=>Ne(z);function q(e){K(),window.open(e||V.star.url||`https://github.com/zeeshanhaque21/nanoMuse`,`_blank`,`noopener,noreferrer`)}function J(e,t,n){switch(t){case`signed_in`:return e(`Welcome. nanoMuse is free, open source and non-profit, a personal agent for anyone who runs it. If that is worth something to you, a star on GitHub is how the next person finds it.`);case`tasks`:return n===3?e(`Three tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.`):n===10?e(`Ten tasks together. If nanoMuse has become part of your day, a star on GitHub tells others it is worth a try.`):e(`{n} tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.`,{n:n??0});case`new_look`:return e(`A new face, drawn for you. If you like where nanoMuse is going, a star on GitHub helps more people find it.`);case`exhausted`:return e(`The free allowance is used up. Thank you for coming this far. If nanoMuse has earned it, a star on GitHub keeps the project in view for the next person.`);case`days_used`:return n===7?e(`A week with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.`):n===30?e(`A month with nanoMuse. If it has become part of your routine, a star on GitHub tells others it is worth a try.`):e(`{n} days with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.`,{n:n??0});case`goal_done`:return e(`Goal reached. If nanoMuse helped you get there, a star on GitHub tells the people building it that it did.`)}}var Y=null,X=null;function Z(){let[e,t]=(0,p.useState)(Y??{});return(0,p.useEffect)(()=>{if(Y)return;X??=c.cloudConfig().then(e=>Y=e&&typeof e==`object`?e:{}).catch(()=>Y={});let e=!0;return X.then(n=>e&&t(n)),()=>{e=!1}},[]),e}function Q(e){let{policy:t}=G();return Ee(t,a()===`zh-CN`,e)}function Ke({text:t,onDone:r,className:i}){let a=n(),o=Z(),s=Q(t);return(0,I.jsxs)(`section`,{className:e(`rounded-[22px] border border-amber-400/40 bg-amber-50/70 p-4 dark:bg-amber-400/[0.08]`,i),children:[(0,I.jsxs)(`div`,{className:`flex items-start gap-2.5`,children:[(0,I.jsx)(f,{size:18,className:`mt-0.5 shrink-0 text-amber-500`}),(0,I.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,I.jsx)(`div`,{className:`text-[14px] font-semibold leading-snug`,children:a(`A star on GitHub helps`)}),(0,I.jsx)(`p`,{className:`mt-1 text-[13px] leading-relaxed text-fg/85`,children:s})]})]}),(0,I.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-2 pl-7`,children:[(0,I.jsx)(`button`,{type:`button`,className:e(te,`!w-auto !py-1.5 !px-3.5 text-[13px]`),onClick:()=>{q(o.repo_url),r()},children:a(`Star on GitHub`)}),(0,I.jsx)(`button`,{type:`button`,className:e(l,`!w-auto !py-1.5 !px-3.5 text-[13px]`),onClick:r,children:a(`Not now`)}),(0,I.jsx)(`button`,{type:`button`,className:e(l,`!w-auto !py-1.5 !px-3.5 text-[13px]`),onClick:()=>{K(),r()},children:a(`I already starred`)})]})]})}function qe({moment:e,n:t,text:r,className:i,due:a=!0}){let o=n(),[s,c]=$(e,a,t);return s?(0,I.jsx)(Ke,{text:r??J(o,e,t),onDone:()=>c(!1),className:i}):null}function $(e,t=!0,n){let{ready:r}=G(),[i,a]=(0,p.useState)(!1);return(0,p.useEffect)(()=>{t&&r&&!i&&We(e,n)&&(Ge(e,n),a(!0))},[t,r,e,n,i]),[i,a]}export{_ as A,be as C,ce as D,re as E,d as M,u as N,le as O,he as S,h as T,Se as _,q as a,b,$ as c,L as d,v as f,we as g,ge as h,K as i,f as j,se as k,Q as l,ye as m,Ue as n,J as o,me as p,He as r,Z as s,qe as t,ze as u,_e as v,Ce as w,S as x,pe as y}; \ No newline at end of file +import{E as e,J as t,K as n,L as r,Q as i,W as a,Y as o,b as s,x as c}from"./MuseHeader-RC--uR8o.js";import{t as ee}from"./external-link-TvxP_dKX.js";import{a as l,i as te}from"./Form-H3s9oTHq.js";var u=t(`bug`,[[`path`,{d:`M12 20v-9`,key:`1qisl0`}],[`path`,{d:`M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z`,key:`uouzyp`}],[`path`,{d:`M14.12 3.88 16 2`,key:`qol33r`}],[`path`,{d:`M21 21a4 4 0 0 0-3.81-4`,key:`1b0z45`}],[`path`,{d:`M21 5a4 4 0 0 1-3.55 3.97`,key:`5cxbf6`}],[`path`,{d:`M22 13h-4`,key:`1jl80f`}],[`path`,{d:`M3 21a4 4 0 0 1 3.81-4`,key:`1fjd4g`}],[`path`,{d:`M3 5a4 4 0 0 0 3.55 3.97`,key:`1d7oge`}],[`path`,{d:`M6 13H2`,key:`82j7cp`}],[`path`,{d:`m8 2 1.88 1.88`,key:`fmnt4t`}],[`path`,{d:`M9 7.13V6a3 3 0 1 1 6 0v1.13`,key:`1vgav8`}]]),ne=t(`github`,[[`path`,{d:`M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4`,key:`tonef`}],[`path`,{d:`M9 18c-4.51 2-5-2-7-2`,key:`9comsn`}]]),d=t(`heart-handshake`,[[`path`,{d:`M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762`,key:`17lmqv`}]]),f=t(`star`,[[`path`,{d:`M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z`,key:`r04s7s`}]]),p=i(o(),1),m={version:1,updated:`2026-10`,capabilities:[`chat`,`vision`,`image`,`video`],auth_kinds:[`key`,`none`,`oauth-chatgpt`,`oauth-claude`,`oauth-openrouter`,`device-kimi`],providers:[{id:`bailian`,name:`Alibaba Cloud Bailian`,name_zh:`阿里云百炼`,protocol:`openai`,base_url:`https://dashscope.aliyuncs.com/compatible-mode/v1`,key_url:`https://bailian.console.aliyun.com/?apiKey=1`,key_hint:`sk-…`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`,`video`],defaults:{chat:`deepseek-v4.1-flash`,hands:`qwen3.8-27b`,image:`qwen-image-3.0`,video:`wan2.2-i2v-flash`},note:`One key for chat, hands, pictures and clips. Signs up accounts with a mainland China identity only; the international edition (dashscope-intl) does not carry these models. Pictures and clips go through Model Studio's own API, which nanoMuse speaks.`,note_zh:`一把 key 覆盖对话、手、画图和视频。只给中国大陆身份注册;国际版(dashscope-intl)没有这几个模型。画图和视频走百炼自己的接口,nanoMuse 已支持。`,verified:`2026-10`},{id:`deepseek`,name:`DeepSeek`,name_zh:`DeepSeek(深度求索)`,protocol:`openai`,base_url:`https://api.deepseek.com/v1`,key_url:`https://platform.deepseek.com/api_keys`,key_hint:`sk-…`,auth:[`key`],regions:[`cn`,`global`],capabilities:[`chat`,`vision`],defaults:{chat:`deepseek-flash`,hands:`deepseek-flash`},note:`deepseek-flash (DeepSeek-V4.1-Flash) reads pictures; deepseek-v4-pro is text only. No image or video models.`,note_zh:`deepseek-flash(DeepSeek-V4.1-Flash)能看图;deepseek-v4-pro 只处理文字。没有图像和视频模型。`,verified:`2026-10`,reasoning:{doc:`https://api-docs.deepseek.com/guides/thinking_mode`,wire:`thinking+reasoning_effort`,rules:[{models:`^deepseek-`,levels:[`low`,`high`,`max`],default:`high`}]}},{id:`moonshot`,name:`Kimi (Moonshot AI)`,name_zh:`Kimi(月之暗面)`,protocol:`openai`,base_url:`https://api.moonshot.cn/v1`,base_url_global:`https://api.moonshot.ai/v1`,key_url:`https://platform.moonshot.cn/console/api-keys`,key_url_global:`https://platform.kimi.ai/console`,key_hint:`sk-…`,auth:[`key`,`device-kimi`],regions:[`cn`,`global`],capabilities:[`chat`,`vision`],defaults:{chat:`kimi-k2.6`,hands:`kimi-k2.6`},note:`Two editions: api.moonshot.cn for mainland China, api.moonshot.ai elsewhere, each with its own console and keys. kimi-k2.6 and kimi-k3 read pictures. The phone apps can sign in with a Kimi account (device code) instead of a key.`,note_zh:`两个版本:中国大陆用 api.moonshot.cn,海外用 api.moonshot.ai,各自有控制台和 key。kimi-k2.6、kimi-k3 能看图。手机 App 可以用 Kimi 账号登录(设备码),不用贴 key。`,verified:`2026-10`,reasoning:{doc:`https://platform.moonshot.ai/docs/api/chat`,wire:`reasoning_effort`,rules:[{models:`^kimi-k3`,levels:[`low`,`high`,`max`],default:`max`}]}},{id:`zhipu`,name:`Zhipu GLM`,name_zh:`智谱 GLM`,protocol:`openai`,base_url:`https://open.bigmodel.cn/api/paas/v4`,key_url:`https://open.bigmodel.cn/usercenter/apikeys`,key_hint:`xxxxxxxx.xxxxxxxx`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`glm-5.3`,hands:`glm-4.6v`,image:`glm-image`},note:`glm-4.6v reads pictures; pictures are drawn by glm-image or cogview-4 through /images/generations.`,note_zh:`glm-4.6v 能看图;画图用 glm-image 或 cogview-4,走 /images/generations。`,verified:`2026-10`,reasoning:{doc:`https://docs.bigmodel.cn/api-reference/%E6%A8%A1%E5%9E%8B-api/%E5%AF%B9%E8%AF%9D%E8%A1%A5%E5%85%A8`,wire:`thinking+reasoning_effort`,rules:[{models:`^glm-5\\.3`,levels:[`low`,`high`,`max`],default:`max`},{models:`^glm-5\\.2`,levels:[`high`,`max`],default:`max`}]}},{id:`siliconflow`,name:`SiliconFlow`,name_zh:`硅基流动`,protocol:`openai`,base_url:`https://api.siliconflow.cn/v1`,key_url:`https://cloud.siliconflow.cn/account/ak`,key_hint:`sk-…`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`deepseek-ai/DeepSeek-V4-Flash`,hands:`Qwen/Qwen3-VL-32B-Instruct`,image:`Qwen/Qwen-Image`},note:`Many open models behind one key; model ids carry the maker's prefix. Pictures through /images/generations (Qwen-Image, Kolors, FLUX).`,note_zh:`一把 key 后面是许多开源模型,模型 id 带厂商前缀。画图走 /images/generations(Qwen-Image、Kolors、FLUX)。`,verified:`2026-10`,reasoning:{doc:`https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions`,wire:`reasoning_effort`,rules:[{models:`^(Pro/)?deepseek-ai/DeepSeek-V4(-|$)`,levels:[`high`,`max`],default:`high`},{models:`^Pro/zai-org/GLM-5\\.2`,levels:[`high`,`max`],default:`high`}]}},{id:`volcengine`,name:`Volcengine Ark (Doubao)`,name_zh:`火山方舟(豆包)`,protocol:`openai`,base_url:`https://ark.cn-beijing.volces.com/api/v3`,key_url:`https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey`,key_hint:`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`,auth:[`key`],regions:[`cn`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`doubao-seed-2-0-pro-260215`,hands:`doubao-seed-2-0-pro-260215`,image:`doubao-seedream-5-0-lite-260128`},note:`Model ids are dated deployments; the live list has the exact ones. Doubao Seed 2.0 reads pictures; Seedream draws them through /images/generations. Seedance video uses Ark's own task API, which nanoMuse does not speak, so clips are left out.`,note_zh:`模型 id 带日期后缀,以实时列表为准。豆包 Seed 2.0 能看图;Seedream 画图走 /images/generations。Seedance 视频走方舟自己的任务接口,nanoMuse 还不支持,所以不列视频。`,verified:`2026-10`,reasoning:{doc:`https://docs.volcengine.com/docs/ark/model-parameter-support?lang=zh`,wire:`reasoning_effort`,rules:[{models:`^doubao-seed-2-`,levels:[`minimal`,`low`,`medium`,`high`],default:`high`},{models:`^deepseek-v4-`,levels:[`low`,`high`,`max`],default:`high`},{models:`^glm-5-2`,levels:[`high`,`max`],default:`max`}]}},{id:`minimax`,name:`MiniMax`,name_zh:`MiniMax(稀宇科技)`,protocol:`openai`,base_url:`https://api.minimaxi.com/v1`,base_url_global:`https://api.minimax.io/v1`,key_url:`https://platform.minimaxi.com/user-center/basic-information/interface-key`,key_hint:`eyJ…`,auth:[`key`],regions:[`cn`,`global`],capabilities:[`chat`,`vision`],defaults:{chat:`MiniMax-M3`,hands:`MiniMax-M3`},note:`Two editions: api.minimaxi.com for mainland China, api.minimax.io elsewhere. MiniMax-M3 reads pictures; the M2 series is text only.`,note_zh:`两个版本:中国大陆用 api.minimaxi.com,海外用 api.minimax.io。MiniMax-M3 能看图;M2 系列只处理文字。`,verified:`2026-10`,reasoning:{doc:`https://platform.minimax.io/docs/api-reference/text-chat`,wire:`reasoning_effort`,rules:[{models:`^MiniMax-M3\\.1`,levels:[`low`,`medium`,`high`,`xhigh`,`max`],default:`max`}]}},{id:`openrouter`,name:`OpenRouter`,name_zh:`OpenRouter`,protocol:`openai`,base_url:`https://openrouter.ai/api/v1`,key_url:`https://openrouter.ai/keys`,key_hint:`sk-or-v1-…`,auth:[`key`,`oauth-openrouter`],regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`deepseek/deepseek-v4.1-flash`,hands:`qwen/qwen3.8-27b`,image:`openai/gpt-image-2`},note:`One account, one key, pay as you go, international cards; models carry the maker's prefix. Pictures only through image-output models (openai/gpt-image-2, google/gemini-2.5-flash-image, bytedance-seed/seedream-4.5) on OpenRouter's Image API (/api/v1/images). The phone apps can sign in to OpenRouter instead of pasting a key.`,note_zh:`一个账号一把 key,按量付费,国际信用卡;模型带厂商前缀。画图只能用出图模型(openai/gpt-image-2、google/gemini-2.5-flash-image、bytedance-seed/seedream-4.5),走 OpenRouter 的图像接口(/api/v1/images)。手机 App 可以直接登录 OpenRouter,不用贴 key。`,verified:`2026-10`,reasoning:{doc:`https://openrouter.ai/docs/use-cases/reasoning-tokens`,wire:`reasoning.effort`,listed:!0,rules:[]}},{id:`openai`,name:`OpenAI`,name_zh:`OpenAI`,protocol:`openai`,base_url:`https://api.openai.com/v1`,key_url:`https://platform.openai.com/api-keys`,key_hint:`sk-proj-…`,auth:[`key`,`oauth-chatgpt`],auth_capabilities:{"oauth-chatgpt":[`chat`,`vision`]},regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`gpt-5.4-mini`,hands:`gpt-5.4`,image:`gpt-image-2.5-flare`},note:`With an API key: chat, hands and pictures (gpt-image-2.5-flare / gpt-image-2 through /images/generations). Signed in with a ChatGPT plan (the Codex OAuth): chat and hands only; the Codex backend has no image or video endpoints. OpenAI's terms cover using a ChatGPT plan inside OpenAI's own Codex; other apps have had this access cut off before (OpenCode, January 2026). If it stops working, an API key does.`,note_zh:`用 API key:对话、手、画图(gpt-image-2.5-flare / gpt-image-2,走 /images/generations)。用 ChatGPT 订阅登录(Codex 的 OAuth):只有对话和手,Codex 后端没有图像和视频接口。OpenAI 的条款只允许在它自己的 Codex 里使用 ChatGPT 订阅;其他应用的这条路曾被切断过(OpenCode,2026 年 1 月)。如果哪天不能用了,API key 还能用。`,verified:`2026-10`,reasoning:{doc:`https://developers.openai.com/api/docs/guides/reasoning`,wire:`reasoning_effort`,rules:[{models:`^o[1-9]`,levels:[`low`,`medium`,`high`],default:`medium`},{models:`^gpt-5(-|$)`,levels:[`minimal`,`low`,`medium`,`high`],default:`medium`},{models:`^gpt-5\\.1(-|$)`,levels:[`low`,`medium`,`high`],default:`none`},{models:`^gpt-5\\.4`,levels:[`low`,`medium`,`high`,`xhigh`],default:`none`},{models:`^gpt-5\\.[235-9]`,levels:[`low`,`medium`,`high`,`xhigh`]},{models:`^gpt-6`,levels:[`low`,`medium`,`high`,`xhigh`]}]}},{id:`anthropic`,name:`Anthropic Claude`,name_zh:`Anthropic Claude`,protocol:`anthropic`,base_url:`https://api.anthropic.com`,key_url:`https://platform.claude.com/settings/keys`,key_hint:`sk-ant-…`,auth:[`key`,`oauth-claude`],regions:[`global`],capabilities:[`chat`,`vision`],defaults:{chat:`claude-sonnet-5-5`,hands:`claude-sonnet-5-5`},note:`The Messages API, not OpenAI's shape; every current Claude model reads pictures. No image or video models. The phone apps can sign in with a Claude account instead of a key.`,note_zh:`用的是 Messages 接口,不是 OpenAI 格式;现行 Claude 模型都能看图。没有图像和视频模型。手机 App 可以用 Claude 账号登录,不用贴 key。`,verified:`2026-10`,reasoning:{doc:`https://docs.claude.com/en/docs/build-with-claude/effort`,wire:`output_config.effort`,rules:[{models:`^claude-(opus|haiku)-5-5`,levels:[`low`,`medium`,`high`,`xhigh`,`max`],default:`medium`},{models:`^claude-(opus|sonnet)-4-6`,levels:[`low`,`medium`,`high`,`max`],default:`high`},{models:`^claude-(opus-4-[78]|opus-5|sonnet-5|haiku-5|fable-5|mythos-5)`,levels:[`low`,`medium`,`high`,`xhigh`,`max`],default:`high`}]}},{id:`gemini`,name:`Google Gemini`,name_zh:`Google Gemini`,protocol:`gemini`,base_url:`https://generativelanguage.googleapis.com/v1beta/openai`,key_url:`https://aistudio.google.com/apikey`,key_hint:`AIza…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`gemini-3.8-flash`,hands:`gemini-3.8-flash`,image:`gemini-2.5-flash-image`},note:`Google's OpenAI-compatible layer. Gemini models read pictures. Pictures through /images/generations work with gemini-2.5-flash-image and gemini-3-pro-image-preview only; the newer 3.1 image models are not served there. Veo video is not on the compatible layer, so clips are left out.`,note_zh:`Google 的 OpenAI 兼容层。Gemini 模型能看图。走 /images/generations 画图只支持 gemini-2.5-flash-image 和 gemini-3-pro-image-preview,更新的 3.1 出图模型不在兼容层上。Veo 视频不走兼容层,所以不列视频。`,verified:`2026-10`,reasoning:{doc:`https://ai.google.dev/gemini-api/docs/openai`,wire:`reasoning_effort`,rules:[{models:`^gemini-(2\\.5|3)`,levels:[`low`,`medium`,`high`]}]}},{id:`xai`,name:`xAI Grok`,name_zh:`xAI Grok`,protocol:`openai`,base_url:`https://api.x.ai/v1`,key_url:`https://console.x.ai`,key_hint:`xai-…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`,`image`],defaults:{chat:`grok-4.7`,hands:`grok-4.7`,image:`grok-imagine-image-2.0`},note:`grok-4.7 reads pictures (JPEG and PNG). Pictures through /images/generations with grok-imagine-image-2.0. Grok Imagine video uses xAI's own endpoints, so clips are left out.`,note_zh:`grok-4.7 能看图(JPEG、PNG)。画图走 /images/generations,模型 grok-imagine-image-2.0。Grok Imagine 视频走 xAI 自己的接口,所以不列视频。`,verified:`2026-10`,reasoning:{doc:`https://docs.x.ai/docs/guides/reasoning`,wire:`reasoning_effort`,rules:[{models:`^grok-4\\.5`,levels:[`low`,`medium`,`high`],default:`high`},{models:`^grok-4\\.[6-9]`,levels:[`low`,`medium`,`high`,`xhigh`],default:`high`}]}},{id:`groq`,name:`Groq`,name_zh:`Groq`,protocol:`openai`,base_url:`https://api.groq.com/openai/v1`,key_url:`https://console.groq.com/keys`,key_hint:`gsk_…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`],defaults:{chat:`openai/gpt-oss-120b`,hands:`qwen/qwen3.8-27b`},note:`Fast hosting of open models. qwen/qwen3.8-27b reads pictures; gpt-oss and Llama do not. No image or video models.`,note_zh:`开源模型的高速托管。qwen/qwen3.8-27b 能看图;gpt-oss 和 Llama 不能。没有图像和视频模型。`,verified:`2026-10`,reasoning:{doc:`https://console.groq.com/docs/reasoning`,wire:`reasoning_effort`,rules:[{models:`^openai/gpt-oss-`,levels:[`low`,`medium`,`high`]},{models:`^qwen/qwen3\\.8`,levels:[`low`,`medium`,`high`],default:`none`}]}},{id:`mistral`,name:`Mistral AI`,name_zh:`Mistral AI`,protocol:`openai`,base_url:`https://api.mistral.ai/v1`,key_url:`https://console.mistral.ai/api-keys`,key_hint:`…`,auth:[`key`],regions:[`global`],capabilities:[`chat`,`vision`],defaults:{chat:`mistral-medium-2508`,hands:`mistral-medium-2508`},note:`mistral-large-2512, mistral-medium-2508, mistral-small-2506 and the Ministral 3 models read pictures. No image or video models.`,note_zh:`mistral-large-2512、mistral-medium-2508、mistral-small-2506 和 Ministral 3 系列能看图。没有图像和视频模型。`,verified:`2026-10`,reasoning:{doc:`https://docs.mistral.ai/capabilities/reasoning/`,wire:`reasoning_effort`,rules:[{models:`^mistral-(small-latest|medium-3-5|large-4-0)`,levels:[`high`],default:`none`},{models:`^zai-glm-5-3`,levels:[`low`,`high`,`max`]}]}},{id:`opencode-zen`,name:`OpenCode Zen`,name_zh:`OpenCode Zen`,protocol:`openai`,base_url:`https://opencode.ai/zen/v1`,key_url:`https://opencode.ai/auth`,auth:[`key`],regions:[`global`],capabilities:[`chat`],defaults:{chat:`deepseek-v4.1-flash`},note:`OpenCode's own gateway: pay per request from a balance, a key made in the OpenCode console, and a few free models beside the paid ones. Chat answers on the OpenAI shape; the GPT, Claude and Gemini ids on the same host speak the Responses, Messages and Google shapes, which this entry does not reach, and the vision model OpenCode lists is deepseek-v4-flash-vision-exp, the id DeepSeek has retired on its own platform, so the hands are not claimed here. OpenCode publishes no plan sign-in, only a key.`,note_zh:`OpenCode 自己的网关:按请求从余额里扣费,key 在 OpenCode 控制台申请,付费模型之外还有几个免费的。对话走 OpenAI 格式;同一域名上的 GPT、Claude、Gemini 走 Responses、Messages 和 Google 格式,本条目到不了;它列的视觉模型是 deepseek-v4-flash-vision-exp,这个 id 在 DeepSeek 自家平台上已经退役,所以「动手」不列入。OpenCode 没有套餐登录,只有 key。`,verified:`2026-10`},{id:`opencode-go`,name:`OpenCode Go`,name_zh:`OpenCode Go`,protocol:`openai`,base_url:`https://opencode.ai/zen/go/v1`,key_url:`https://opencode.ai/auth`,auth:[`key`],regions:[`global`],capabilities:[`chat`],defaults:{chat:`deepseek-v4.1-flash`},session_header:`x-opencode-session`,note:`A subscription for coding agents: Go ($10 a month) or Go Plus ($40) in the OpenCode console, then a key made there; usage is counted per model against monthly, weekly and five hour limits. OpenCode asks every client to name itself and to send one stable id per conversation in x-opencode-session: the runtime sends both, the desktop reaches Go through its model adapter's own OpenCode Go route, which sends the id and speaks each model's shape, and the phone apps send an id per chat (a hash of its first message, the same on every turn). Outside the desktop only the OpenAI shape is reached, which is where the open coding models answer; the GPT, Grok, Claude, MiniMax and Qwen ids speak the Responses and Messages shapes. The one vision model OpenCode lists (deepseek-v4-flash-vision-exp) is an id DeepSeek has retired on its own platform, so the hands are not claimed here. OpenCode publishes no plan sign-in, only a key.`,note_zh:`面向编程智能体的订阅:在 OpenCode 控制台订阅 Go(每月 10 美元)或 Go Plus(每月 40 美元),再在那里申请 key;用量按模型计,分每月、每周和每 5 小时三档上限。OpenCode 要求每个客户端说明自己是谁,并在 x-opencode-session 里为每个对话带上一个固定的 id:运行时两样都发;桌面版通过模型适配器自带的 OpenCode Go 路由访问,这条路由会带上 id,也按每个模型自己的格式说话;手机端为每个聊天带一个 id(由第一条消息算出,每一轮都一样)。桌面版以外只走 OpenAI 格式,开源编程模型都在这个格式上;GPT、Grok、Claude、MiniMax 和 Qwen 走 Responses 和 Messages 格式。它列的那个视觉模型(deepseek-v4-flash-vision-exp)在 DeepSeek 自家平台上已经退役,所以「动手」不列入。OpenCode 没有套餐登录,只有 key。`,verified:`2026-10`},{id:`ollama`,name:`Ollama`,name_zh:`Ollama(本机)`,protocol:`openai`,base_url:`http://127.0.0.1:11434/v1`,key_url:`https://ollama.com/download`,auth:[`none`],regions:[`cn`,`global`],capabilities:[`chat`],defaults:{chat:`qwen3:8b`},note:`Runs on this machine, no key, nothing leaves it. Hands need a model that reads pictures (a vision model such as a Qwen VL build); the model list says which.`,note_zh:`跑在本机,不要 key,什么都不出去。手需要能看图的模型(例如 Qwen 的 VL 版本),模型列表里能看出来。`,verified:`2026-10`,reasoning:{doc:`https://docs.ollama.com/api/openai-compatibility`,wire:`reasoning_effort`,rules:[{models:`gpt-oss`,levels:[`low`,`medium`,`high`]}]}},{id:`lm-studio`,name:`LM Studio`,name_zh:`LM Studio(本机)`,protocol:`openai`,base_url:`http://127.0.0.1:1234/v1`,key_url:`https://lmstudio.ai`,auth:[`none`],regions:[`cn`,`global`],capabilities:[`chat`],defaults:{},note:`LM Studio's local server, OpenAI-compatible, no key. Vision only if the loaded model has it.`,note_zh:`LM Studio 的本地服务,OpenAI 兼容,不要 key。装了能看图的模型才能用手。`,verified:`2026-10`},{id:`vllm`,name:`vLLM`,name_zh:`vLLM(自建)`,protocol:`openai`,base_url:`http://127.0.0.1:8000/v1`,key_url:`https://docs.vllm.ai`,auth:[`none`,`key`],regions:[`cn`,`global`],capabilities:[`chat`],defaults:{},note:`A vLLM server you run yourself; a key only if you started it with one. Vision only if the served model has it.`,note_zh:`自己跑的 vLLM 服务;启动时设了 key 才需要填。服务的模型能看图才能用手。`,verified:`2026-10`,reasoning:{doc:`https://docs.vllm.ai/en/latest/features/reasoning_outputs/`,wire:`reasoning_effort`,rules:[{models:`gpt-oss`,levels:[`low`,`medium`,`high`]}]}},{id:`custom`,name:`Any OpenAI-compatible endpoint`,name_zh:`任何 OpenAI 兼容接口`,protocol:`openai`,base_url:``,key_url:``,auth:[`key`,`none`],regions:[`cn`,`global`],capabilities:[`chat`],user_capabilities:!0,defaults:{},note:`A gateway, a relay, a server of your own: paste the address (usually ending in /v1) and say what it can do; the person sets the capabilities.`,note_zh:`网关、中转、自己的服务:贴地址(一般以 /v1 结尾),再说明它能做什么,能力由你勾选。`,verified:`2026-10`}]};function h(e){return e?.region?e.region===`cn`:e?.signed_in&&e.channel===`sms`?!0:r()===`zh-CN`}function re(e){return/^\s*(\+|00)(?!86\b)\d/.test(e)}var ie=new Map(m.providers.map(e=>[e.id,e]));function g(e,t){let n=ie.get(e);if(!n)throw Error(`providers.json has no ${e}`);return{id:e,preset:t,label:n.name,keyUrl:n.key_url,baseUrl:n.base_url,chatModel:n.defaults.chat??``,guiModel:n.defaults.hands??n.defaults.chat??``,covers:n.capabilities}}var ae=g(`bailian`,`qwen`),oe=g(`openrouter`,`openrouter`);function _(e){return h(e)?ae:oe}function se(e,t){return h(t)?e(`Alibaba Cloud Bailian is a good start: a new account comes with a free quota, set-up takes about two minutes, and one key covers chat, the hands, pictures and clips.`):e(`Alibaba Cloud Bailian only signs up accounts from mainland China. Outside, OpenRouter is the easy way: one account, one key, pay as you go, for chat, the hands and pictures; clips need Bailian.`)}function ce(e){let t=(e||``).toLowerCase().split(`/`).pop()??``;return t?t.startsWith(`deepseek`)?/v4\.1|vision|ocr/.test(t):t.startsWith(`qwen`)&&(t.includes(`-vl`)||t.startsWith(`qwen3.8`))?!0:null:null}function le(e,t,n){let r=_(n).preset;if(!e.includes(r))return e;let i=h(n),a=e.filter(e=>e!==r);if(i)return[r,...a];let o=a.filter(e=>t[e]?.region===`cn`);return[r,...a.filter(e=>!o.includes(e)),...o]}var ue=[`chat`,`vision`,`image`,`video`],v=m.providers,de=[`ollama`,`lm-studio`,`vllm`],fe={cn:[`bailian`],global:[`openrouter`,`openai`]},y={bailian:`qwen`,moonshot:`kimi`,zhipu:`glm`,volcengine:`doubao`};function pe(e){return y[e]??e}function me(e){return Object.entries(y).find(([,t])=>t===e)?.[0]??(e===`openai_responses`?`openai`:e)}function he(e){return h(e)?`cn`:`global`}function b(e,t){return t===`zh-CN`&&e.name_zh||e.name}function ge(e,t){return t===`global`&&e.regions.includes(`global`)&&e.base_url_global?{base_url:e.base_url_global,key_url:e.key_url_global||e.key_url}:{base_url:e.base_url,key_url:e.key_url}}function x(e,t){return e.regions.includes(t)}function S(e,t,n=v){let r=new Map(n.map(e=>[e.id,e])),i=fe[e].map(e=>r.get(e)).filter(t=>!!t&&x(t,e)),a=n.filter(t=>!i.includes(t)&&!de.includes(t.id)&&t.id!==`custom`&&x(t,e)).sort((e,t)=>t.capabilities.length-e.capabilities.length),o=[...i,...a];return t?o.filter(e=>e.capabilities.includes(t)):o}function _e(e=v){let t=[{id:`chatgpt`,provider:`openai`,name:`ChatGPT`,auth:`oauth-chatgpt`,here:!0},{id:`claude`,provider:`anthropic`,name:`Claude`,auth:`oauth-claude`,here:!1},{id:`kimi`,provider:`moonshot`,name:`Kimi`,auth:`device-kimi`,here:!1},{id:`openrouter`,provider:`openrouter`,name:`OpenRouter`,auth:`oauth-openrouter`,here:!1}],n=[];for(let r of t){let t=e.find(e=>e.id===r.provider);t&&t.auth.includes(r.auth)&&n.push({...r,covers:t.auth_capabilities?.[r.auth]??t.capabilities})}return n}function ve(e,t){switch(t){case`chat`:return e(`Chat`);case`vision`:return e(`Hands`);case`image`:return e(`Pictures`);case`video`:return e(`Clips`)}}function ye(e,t){return e(`covers {list}`,{list:ue.filter(e=>t.includes(e)).map(t=>ve(e,t).toLowerCase()).join(e(`, `))})}function be(e,t,n,r,i=v){let a=S(n,t,i).slice(0,4).map(e=>b(e,r)),o=a.pop()??``,s=a.length?`${a.join(e(`, `))}${e(` or `)}${o}`:o;switch(t){case`image`:return e(`Pictures need a provider with image models: {providers}.`,{providers:s});case`video`:return e(`Clips need a provider with video models: {providers}.`,{providers:s});case`vision`:return e(`The hands need a model that sees pictures: {providers}.`,{providers:s});case`chat`:return e(`Chat needs a model: {providers}.`,{providers:s})}}var C=null,w=null;async function xe(e=!1){return C&&!e?C:(w&&!e||(w=c.providers().then(e=>({...e,capabilities:e.capabilities??null,source:`runtime`})).catch(()=>({providers:v,region:``,configured:{},capabilities:null,unavailable:{},source:`bundled`})).then(e=>(C=e,w=null,e))),w)}function Se(){C=null}function Ce(e=0){let[t,n]=(0,p.useState)(C);return(0,p.useEffect)(()=>{let t=!0;return xe(e>0).then(e=>t&&n(e)),()=>{t=!1}},[e]),t}function we(e,t){return!e||e.capabilities===null?null:e.capabilities.includes(t)}var T={version:1,star:{enabled:!0,url:`https://github.com/zeeshanhaque21/nanoMuse`,moments:{signed_in:!0,tasks:[3,10,30],new_look:!0,exhausted:!0,days_used:[7,30],goal_done:!0},cooldown_days:7,max_asks:4,text:``,text_zh:``}},Te=864e5;function E(e){if(typeof e!=`string`)return``;let t=e.trim();return t.length<=200?t:``}function Ee(e,t,n){let{text:r,text_zh:i}=e.star;return t&&i?i:r||n}var D=e=>typeof e==`object`&&!!e&&!Array.isArray(e),O=(e,t)=>typeof e==`boolean`?e:t,k=(e,t,n,r)=>typeof e==`number`&&Number.isFinite(e)&&Math.trunc(e)>=t&&Math.trunc(e)<=n?Math.trunc(e):r;function A(e,t){if(!Array.isArray(e))return t;let n=new Set;for(let r of e){if(typeof r!=`number`||!Number.isFinite(r)||Math.trunc(r)<1)return t;n.add(Math.trunc(r))}return[...n].sort((e,t)=>e-t)}function De(e){let t=T.star;if(!D(e))return structuredClone(T);let n=k(e.version,1,1e9,1),r=D(e.star)?e.star:{},i=D(r.moments)?r.moments:{},a=typeof r.url==`string`&&/^https?:\/\//.test(r.url)&&r.url.length<=200?r.url:t.url;return{version:n,star:{enabled:O(r.enabled,t.enabled),url:a,moments:{signed_in:O(i.signed_in,t.moments.signed_in),tasks:A(i.tasks,t.moments.tasks),new_look:O(i.new_look,t.moments.new_look),exhausted:O(i.exhausted,t.moments.exhausted),days_used:A(i.days_used,t.moments.days_used),goal_done:O(i.goal_done,t.moments.goal_done)},cooldown_days:k(r.cooldown_days,0,365,t.cooldown_days),max_asks:k(r.max_asks,0,50,t.max_asks),text:E(r.text),text_zh:E(r.text_zh)}}}var j={starred:`nm.star.starred`,tasks:`nm.star.tasks`,asks:`nm.star.asks`,lastAsk:`nm.star.last_ask`,shown:`nm.star.shown`,days:`nm.star.days`,lastDay:`nm.star.last_day`,migrated:`nm.star.v2`},Oe=[`signed_in`,`first_task`,`tenth_task`,`new_look`],M=e=>{let t=Number(e);return Number.isFinite(t)&&t>=0?Math.trunc(t):0};function N(e){let t=[];try{let n=JSON.parse(e.getItem(j.shown)??`[]`);Array.isArray(n)&&(t=n.filter(e=>typeof e==`string`))}catch{t=[]}let n=e.getItem(j.lastAsk);return{starred:e.getItem(j.starred)===`1`,tasks:M(e.getItem(j.tasks)),asks:M(e.getItem(j.asks)),lastAsk:n&&M(n)||null,shown:t,days:M(e.getItem(j.days)),lastDay:e.getItem(j.lastDay)??``}}function P(e,t){e.setItem(j.starred,t.starred?`1`:`0`),e.setItem(j.tasks,String(t.tasks)),e.setItem(j.asks,String(t.asks)),t.lastAsk?e.setItem(j.lastAsk,String(t.lastAsk)):e.removeItem(j.lastAsk),e.setItem(j.shown,JSON.stringify(t.shown)),e.setItem(j.days,String(t.days)),e.setItem(j.lastDay,t.lastDay)}function ke(e){let t=N(e);if(e.getItem(j.migrated)===`1`)return t;for(let n of Oe){let r=`nm.star.${n}`;e.getItem(r)===`1`&&!t.shown.includes(n)&&(t.shown.push(n),t.asks+=1),e.removeItem(r)}return P(e,t),e.setItem(j.migrated,`1`),t}var F=(e,t)=>e===`tasks`||e===`days_used`?`${e}:${t??0}`:e;function Ae(e,t,n){let r=e.star;return!r.enabled||t.starred||t.asks>=r.max_asks?!1:t.lastAsk===null||n-t.lastAsk>=r.cooldown_days*Te}function je(e,t,n,r,i){if(!Ae(t,n,r))return!1;let a=t.star.moments;return(e===`tasks`||e===`days_used`?i!==void 0&&a[e].includes(i):a[e])&&!n.shown.includes(F(e,i))}function Me(e,t,n){let r=N(e);return r.shown.includes(t)||(r.shown.push(t),r.asks+=1,r.lastAsk=n,P(e,r)),r}function Ne(e){let t=N(e);t.starred=!0,P(e,t)}function Pe(e){let t=N(e);return t.tasks+=1,P(e,t),t.tasks}var Fe=e=>`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`;function Ie(e,t){let n=N(e),r=Fe(t);return n.lastDay===r?{days:n.days,fresh:!1}:(n.days+=1,n.lastDay=r,P(e,n),{days:n.days,fresh:!0})}var I=s(),Le=`https://github.com/zeeshanhaque21/nanoMuse`,Re=`https://github.com/zeeshanhaque21/nanoMuse/issues/new/choose`,L=`https://github.com/zeeshanhaque21/nanoMuse`;function ze({compact:t=!1,className:r}){let i=n();return(0,I.jsxs)(`section`,{className:e(`rounded-[22px] border border-accent/25 bg-accent/[0.06] p-4 dark:bg-accent/[0.10]`,r),children:[(0,I.jsxs)(`a`,{href:L,target:`_blank`,rel:`noreferrer`,className:`flex items-start gap-2 text-[13.5px] font-semibold hover:text-accent`,children:[(0,I.jsx)(d,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,I.jsx)(`span`,{className:`min-w-0 flex-1 leading-snug`,children:i(`Free, open source, non-profit`)}),(0,I.jsx)(ee,{size:12,className:`mt-1 shrink-0 text-muted`})]}),(0,I.jsx)(`p`,{className:e(`mt-1.5 leading-relaxed text-fg/85`,t?`text-[12.5px]`:`text-[13px]`),children:i(`nanoMuse is a non-profit open-source community project, free forever. Sign in with a phone number or an e-mail and the model comes with a free allowance, paid by the developer; when it is gone, use your own key (Alibaba Cloud Bailian in mainland China, OpenRouter elsewhere). Nothing is sold; what the relay keeps is in the privacy policy, and Settings → Data controls is yours. Delete the account whenever you like.`)}),(0,I.jsx)(`p`,{className:e(`mt-1.5 leading-relaxed text-fg/85`,t?`text-[12.5px]`:`text-[13px]`),children:i(`The people building it use it every day. Report a bug, ask for a feature, send a pull request: that is what moves the project. A star on GitHub helps others find it.`)}),(0,I.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-2 text-[12.5px] font-medium`,children:[(0,I.jsxs)(`a`,{href:Le,target:`_blank`,rel:`noreferrer`,onClick:K,className:`inline-flex items-center gap-1.5 rounded-full bg-fg px-3 py-1.5 text-bg hover:opacity-90`,children:[(0,I.jsx)(ne,{size:13}),` `,i(`Star on GitHub`)]}),(0,I.jsxs)(`a`,{href:Re,target:`_blank`,rel:`noreferrer`,className:`inline-flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-fg hover:bg-surface-2`,children:[(0,I.jsx)(u,{size:13}),` `,i(`Report a bug or ask for a feature`)]})]})]})}var R=new Map,z={getItem:e=>{try{return localStorage.getItem(e)}catch{return R.get(e)??null}},setItem:(e,t)=>{try{localStorage.setItem(e,t)}catch{R.set(e,t)}},removeItem:e=>{try{localStorage.removeItem(e)}catch{R.delete(e)}}},B=!1;function Be(){return B?N(z):(B=!0,ke(z))}var V=T,H=!1,U=null,W=new Set;function Ve(){return U??=c.nudges().then(e=>{V=De(e?.policy)}).catch(()=>{V=T}).finally(()=>{H=!0,W.forEach(e=>e())}),U}function G(){let[,e]=(0,p.useState)(0);return(0,p.useEffect)(()=>{let t=()=>e(e=>e+1);return W.add(t),Ve(),()=>{W.delete(t)}},[]),{policy:V,ready:H}}var He=()=>Pe(z),Ue=()=>Ie(z,new Date),We=(e,t)=>H&&je(e,V,Be(),Date.now(),t),Ge=(e,t)=>{Me(z,F(e,t),Date.now())},K=()=>Ne(z);function q(e){K(),window.open(e||V.star.url||`https://github.com/zeeshanhaque21/nanoMuse`,`_blank`,`noopener,noreferrer`)}function J(e,t,n){switch(t){case`signed_in`:return e(`Welcome. nanoMuse is free, open source and non-profit, a personal agent for anyone who runs it. If that is worth something to you, a star on GitHub is how the next person finds it.`);case`tasks`:return n===3?e(`Three tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.`):n===10?e(`Ten tasks together. If nanoMuse has become part of your day, a star on GitHub tells others it is worth a try.`):e(`{n} tasks done. If nanoMuse is useful, a star on GitHub helps the next person find it.`,{n:n??0});case`new_look`:return e(`A new face, drawn for you. If you like where nanoMuse is going, a star on GitHub helps more people find it.`);case`exhausted`:return e(`The free allowance is used up. Thank you for coming this far. If nanoMuse has earned it, a star on GitHub keeps the project in view for the next person.`);case`days_used`:return n===7?e(`A week with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.`):n===30?e(`A month with nanoMuse. If it has become part of your routine, a star on GitHub tells others it is worth a try.`):e(`{n} days with nanoMuse. If it has earned a place in your day, a star on GitHub helps the next person find it.`,{n:n??0});case`goal_done`:return e(`Goal reached. If nanoMuse helped you get there, a star on GitHub tells the people building it that it did.`)}}var Y=null,X=null;function Z(){let[e,t]=(0,p.useState)(Y??{});return(0,p.useEffect)(()=>{if(Y)return;X??=c.cloudConfig().then(e=>Y=e&&typeof e==`object`?e:{}).catch(()=>Y={});let e=!0;return X.then(n=>e&&t(n)),()=>{e=!1}},[]),e}function Q(e){let{policy:t}=G();return Ee(t,a()===`zh-CN`,e)}function Ke({text:t,onDone:r,className:i}){let a=n(),o=Z(),s=Q(t);return(0,I.jsxs)(`section`,{className:e(`rounded-[22px] border border-amber-400/40 bg-amber-50/70 p-4 dark:bg-amber-400/[0.08]`,i),children:[(0,I.jsxs)(`div`,{className:`flex items-start gap-2.5`,children:[(0,I.jsx)(f,{size:18,className:`mt-0.5 shrink-0 text-amber-500`}),(0,I.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,I.jsx)(`div`,{className:`text-[14px] font-semibold leading-snug`,children:a(`A star on GitHub helps`)}),(0,I.jsx)(`p`,{className:`mt-1 text-[13px] leading-relaxed text-fg/85`,children:s})]})]}),(0,I.jsxs)(`div`,{className:`mt-3 flex flex-wrap gap-2 pl-7`,children:[(0,I.jsx)(`button`,{type:`button`,className:e(te,`!w-auto !py-1.5 !px-3.5 text-[13px]`),onClick:()=>{q(o.repo_url),r()},children:a(`Star on GitHub`)}),(0,I.jsx)(`button`,{type:`button`,className:e(l,`!w-auto !py-1.5 !px-3.5 text-[13px]`),onClick:r,children:a(`Not now`)}),(0,I.jsx)(`button`,{type:`button`,className:e(l,`!w-auto !py-1.5 !px-3.5 text-[13px]`),onClick:()=>{K(),r()},children:a(`I already starred`)})]})]})}function qe({moment:e,n:t,text:r,className:i,due:a=!0}){let o=n(),[s,c]=$(e,a,t);return s?(0,I.jsx)(Ke,{text:r??J(o,e,t),onDone:()=>c(!1),className:i}):null}function $(e,t=!0,n){let{ready:r}=G(),[i,a]=(0,p.useState)(!1);return(0,p.useEffect)(()=>{t&&r&&!i&&We(e,n)&&(Ge(e,n),a(!0))},[t,r,e,n,i]),[i,a]}export{_ as A,be as C,ce as D,re as E,d as M,u as N,le as O,he as S,h as T,Se as _,q as a,b,$ as c,L as d,v as f,we as g,ge as h,K as i,f as j,se as k,Q as l,ye as m,Ue as n,J as o,me as p,He as r,Z as s,qe as t,ze as u,_e as v,Ce as w,S as x,pe as y}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/clapperboard-vj4UL6pS.js b/nanomuse/server/static/assets/clapperboard-CuoVHGrc.js similarity index 81% rename from nanomuse/server/static/assets/clapperboard-vj4UL6pS.js rename to nanomuse/server/static/assets/clapperboard-CuoVHGrc.js index 4d6560f7..6452d0c3 100644 --- a/nanomuse/server/static/assets/clapperboard-vj4UL6pS.js +++ b/nanomuse/server/static/assets/clapperboard-CuoVHGrc.js @@ -1 +1 @@ -import{J as e}from"./MuseHeader-DHtaUVEa.js";var t=e(`clapperboard`,[[`path`,{d:`m12.296 3.464 3.02 3.956`,key:`qash78`}],[`path`,{d:`M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z`,key:`1h7j8b`}],[`path`,{d:`M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`4lm6w1`}],[`path`,{d:`m6.18 5.276 3.1 3.899`,key:`zjj9t3`}]]);export{t}; \ No newline at end of file +import{J as e}from"./MuseHeader-RC--uR8o.js";var t=e(`clapperboard`,[[`path`,{d:`m12.296 3.464 3.02 3.956`,key:`qash78`}],[`path`,{d:`M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3z`,key:`1h7j8b`}],[`path`,{d:`M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`4lm6w1`}],[`path`,{d:`m6.18 5.276 3.1 3.899`,key:`zjj9t3`}]]);export{t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/database-zap-DrvRjpVx.js b/nanomuse/server/static/assets/database-zap-Bce5E3JJ.js similarity index 80% rename from nanomuse/server/static/assets/database-zap-DrvRjpVx.js rename to nanomuse/server/static/assets/database-zap-Bce5E3JJ.js index a94ab1a8..0e317180 100644 --- a/nanomuse/server/static/assets/database-zap-DrvRjpVx.js +++ b/nanomuse/server/static/assets/database-zap-Bce5E3JJ.js @@ -1 +1 @@ -import{J as e}from"./MuseHeader-DHtaUVEa.js";var t=e(`database-zap`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 15 21.84`,key:`14ibmq`}],[`path`,{d:`M21 5V8`,key:`1marbg`}],[`path`,{d:`M21 12L18 17H22L19 22`,key:`zafso`}],[`path`,{d:`M3 12A9 3 0 0 0 14.59 14.87`,key:`1y4wr8`}]]);export{t}; \ No newline at end of file +import{J as e}from"./MuseHeader-RC--uR8o.js";var t=e(`database-zap`,[[`ellipse`,{cx:`12`,cy:`5`,rx:`9`,ry:`3`,key:`msslwz`}],[`path`,{d:`M3 5V19A9 3 0 0 0 15 21.84`,key:`14ibmq`}],[`path`,{d:`M21 5V8`,key:`1marbg`}],[`path`,{d:`M21 12L18 17H22L19 22`,key:`zafso`}],[`path`,{d:`M3 12A9 3 0 0 0 14.59 14.87`,key:`1y4wr8`}]]);export{t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/external-link-3iD-Bcbv.js b/nanomuse/server/static/assets/external-link-TvxP_dKX.js similarity index 79% rename from nanomuse/server/static/assets/external-link-3iD-Bcbv.js rename to nanomuse/server/static/assets/external-link-TvxP_dKX.js index d4ee3889..39f8f325 100644 --- a/nanomuse/server/static/assets/external-link-3iD-Bcbv.js +++ b/nanomuse/server/static/assets/external-link-TvxP_dKX.js @@ -1 +1 @@ -import{J as e}from"./MuseHeader-DHtaUVEa.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),n=e(`external-link`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]);export{t as n,n as t}; \ No newline at end of file +import{J as e}from"./MuseHeader-RC--uR8o.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]),n=e(`external-link`,[[`path`,{d:`M15 3h6v6`,key:`1q9fwt`}],[`path`,{d:`M10 14 21 3`,key:`gplh6r`}],[`path`,{d:`M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6`,key:`a6xqqp`}]]);export{t as n,n as t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/image-pZaHCatu.js b/nanomuse/server/static/assets/image-nIygWCIp.js similarity index 75% rename from nanomuse/server/static/assets/image-pZaHCatu.js rename to nanomuse/server/static/assets/image-nIygWCIp.js index f234a3d9..365982c6 100644 --- a/nanomuse/server/static/assets/image-pZaHCatu.js +++ b/nanomuse/server/static/assets/image-nIygWCIp.js @@ -1 +1 @@ -import{J as e}from"./MuseHeader-DHtaUVEa.js";var t=e(`image`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}]]);export{t}; \ No newline at end of file +import{J as e}from"./MuseHeader-RC--uR8o.js";var t=e(`image`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}],[`circle`,{cx:`9`,cy:`9`,r:`2`,key:`af1f0g`}],[`path`,{d:`m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21`,key:`1xmnt7`}]]);export{t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/index-COCRJ3-N.css b/nanomuse/server/static/assets/index-COCRJ3-N.css new file mode 100644 index 00000000..600f4c56 --- /dev/null +++ b/nanomuse/server/static/assets/index-COCRJ3-N.css @@ -0,0 +1,2 @@ +/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:"Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-teal-700:oklch(51.1% .096 186.391);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-fuchsia-300:oklch(83.3% .145 321.434);--color-fuchsia-500:oklch(66.7% .295 322.15);--color-fuchsia-600:oklch(59.1% .293 322.896);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--drop-shadow-sm:0 1px 2px #00000026;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-2xl:40px;--blur-3xl:64px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:var(--om-bg);--color-surface:var(--om-surface);--color-surface-2:var(--om-surface-2);--color-border:var(--om-border);--color-fg:var(--om-fg);--color-muted:var(--om-muted);--color-accent:var(--om-accent);--color-accent-fg:#fff;--color-bubble-user:var(--om-bubble-user);--color-bubble-user-fg:var(--om-bubble-user-fg);--color-bubble-agent:var(--om-bubble-agent);--color-ink:var(--om-ink);--color-ink-fg:var(--om-ink-fg)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.\@container{container-type:inline-size}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.-top-1\.5{top:calc(var(--spacing) * -1.5)}.-top-20{top:calc(var(--spacing) * -20)}.top-0{top:0}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1{top:var(--spacing)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-9{top:calc(var(--spacing) * 9)}.top-12{top:calc(var(--spacing) * 12)}.top-\[3px\]{top:3px}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.-right-16{right:calc(var(--spacing) * -16)}.right-0{right:0}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-2\.5{right:calc(var(--spacing) * 2.5)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.right-\[3px\]{right:3px}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-\[calc\(6rem\+env\(safe-area-inset-bottom\)\)\]{bottom:calc(6rem + env(safe-area-inset-bottom))}.tabbar-float{bottom:calc(12px + env(safe-area-inset-bottom))}.left-0{left:0}.left-0\.5{left:calc(var(--spacing) * .5)}.left-1\.5{left:calc(var(--spacing) * 1.5)}.left-1\/2{left:50%}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-4{left:calc(var(--spacing) * 4)}.left-\[18px\]{left:18px}.left-\[22px\]{left:22px}.left-\[26px\]{left:26px}.left-\[68px\]{left:68px}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-50{z-index:50}.z-\[5\]{z-index:5}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-1{margin-inline:var(--spacing)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-8{margin-block:calc(var(--spacing) * 8)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-2\.5{margin-top:calc(var(--spacing) * -2.5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[22px\]{margin-top:22px}.mt-auto{margin-top:auto}.-mr-1\.5{margin-right:calc(var(--spacing) * -1.5)}.-mr-2{margin-right:calc(var(--spacing) * -2)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-2\.5{margin-right:calc(var(--spacing) * 2.5)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-5{margin-right:calc(var(--spacing) * 5)}.-mb-2{margin-bottom:calc(var(--spacing) * -2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-2\.5{margin-left:calc(var(--spacing) * 2.5)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[52px\]{margin-left:52px}.ml-\[76px\]{margin-left:76px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-\[16\/10\]{aspect-ratio:16/10}.aspect-square{aspect-ratio:1}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-20{height:calc(var(--spacing) * 20)}.h-28{height:calc(var(--spacing) * 28)}.h-40{height:calc(var(--spacing) * 40)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-\[9px\]{height:9px}.h-\[17px\]{height:17px}.h-\[18px\]{height:18px}.h-\[46px\]{height:46px}.h-\[46vh\]{height:46vh}.h-\[50px\]{height:50px}.h-\[72px\]{height:72px}.h-\[100dvh\]{height:100dvh}.h-\[130px\]{height:130px}.h-\[148px\]{height:148px}.h-\[480px\]{height:480px}.h-full{height:100%}.max-h-28{max-height:calc(var(--spacing) * 28)}.max-h-32{max-height:calc(var(--spacing) * 32)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-\[60vh\]{max-height:60vh}.max-h-\[92dvh\]{max-height:92dvh}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-\[40vh\]{min-height:40vh}.min-h-\[44px\]{min-height:44px}.min-h-\[54px\]{min-height:54px}.min-h-\[56px\]{min-height:56px}.\!w-auto{width:auto!important}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-\[9px\]{width:9px}.w-\[46px\]{width:46px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[72px\]{width:72px}.w-\[74px\]{width:74px}.w-\[76px\]{width:76px}.w-\[82vw\]{width:82vw}.w-\[86px\]{width:86px}.w-\[148px\]{width:148px}.w-\[188px\]{width:188px}.w-\[232px\]{width:232px}.w-\[248px\]{width:248px}.w-\[260px\]{width:260px}.w-\[300px\]{width:300px}.w-\[900px\]{width:900px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\[60\%\]{max-width:60%}.max-w-\[60px\]{max-width:60px}.max-w-\[68px\]{max-width:68px}.max-w-\[86\%\]{max-width:86%}.max-w-\[240px\]{max-width:240px}.max-w-\[320px\]{max-width:320px}.max-w-\[340px\]{max-width:340px}.max-w-\[360px\]{max-width:360px}.max-w-\[420px\]{max-width:420px}.max-w-\[520px\]{max-width:520px}.max-w-\[560px\]{max-width:560px}.max-w-\[600px\]{max-width:600px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.max-w-\[calc\(100\%-128px\)\]{max-width:calc(100% - 128px)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-\[17px\]{min-width:17px}.min-w-\[18px\]{min-width:18px}.min-w-\[180px\]{min-width:180px}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-separate{border-collapse:separate}.border-spacing-0{--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-top-left{transform-origin:0 0}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-\[0\.3333\]{scale:.3333}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-crosshair{cursor:crosshair}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.scroll-mt-4{scroll-margin-top:calc(var(--spacing) * 4)}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.grid-cols-\[1fr_1fr_auto\]{grid-template-columns:1fr 1fr auto}.grid-cols-\[1fr_84px\]{grid-template-columns:1fr 84px}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-\[3px\]{gap:3px}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)),:where(.divide-border\/60>:not(:last-child)){border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-border\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-border) 60%, transparent)}}:where(.divide-border\/70>:not(:last-child)){border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-border\/70>:not(:last-child)){border-color:color-mix(in oklab, var(--color-border) 70%, transparent)}}.self-center{align-self:center}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[10px\]{border-radius:10px}.rounded-\[13px\]{border-radius:13px}.rounded-\[14px\]{border-radius:14px}.rounded-\[18px\]{border-radius:18px}.rounded-\[20px\]{border-radius:20px}.rounded-\[22px\]{border-radius:22px}.rounded-\[26px\]{border-radius:26px}.rounded-\[28px\]{border-radius:28px}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-3xl{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl)}.rounded-t-\[28px\]{border-top-left-radius:28px;border-top-right-radius:28px}.rounded-tl-lg{border-top-left-radius:var(--radius-lg)}.rounded-br-lg{border-bottom-right-radius:var(--radius-lg)}.rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.rounded-bl-md{border-bottom-left-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-accent,.border-accent\/15{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/15{border-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.border-accent\/25{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/25{border-color:color-mix(in oklab, var(--color-accent) 25%, transparent)}}.border-accent\/30{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/30{border-color:color-mix(in oklab, var(--color-accent) 30%, transparent)}}.border-accent\/40{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/40{border-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}.border-accent\/50{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/50{border-color:color-mix(in oklab, var(--color-accent) 50%, transparent)}}.border-amber-400\/40{border-color:#fcbb0066}@supports (color:color-mix(in lab, red, red)){.border-amber-400\/40{border-color:color-mix(in oklab, var(--color-amber-400) 40%, transparent)}}.border-amber-500\/50{border-color:#f99c0080}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/50{border-color:color-mix(in oklab, var(--color-amber-500) 50%, transparent)}}.border-bg{border-color:var(--color-bg)}.border-border,.border-border\/60{border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.border-border\/60{border-color:color-mix(in oklab, var(--color-border) 60%, transparent)}}.border-border\/70{border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.border-border\/70{border-color:color-mix(in oklab, var(--color-border) 70%, transparent)}}.border-border\/80{border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.border-border\/80{border-color:color-mix(in oklab, var(--color-border) 80%, transparent)}}.border-current{border-color:currentColor}.border-fg{border-color:var(--color-fg)}.border-rose-400{border-color:var(--color-rose-400)}.border-rose-400\/60{border-color:#ff667f99}@supports (color:color-mix(in lab, red, red)){.border-rose-400\/60{border-color:color-mix(in oklab, var(--color-rose-400) 60%, transparent)}}.border-rose-500\/25{border-color:#ff235740}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/25{border-color:color-mix(in oklab, var(--color-rose-500) 25%, transparent)}}.border-surface{border-color:var(--color-surface)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.bg-\[\#2fb344\]{background-color:#2fb344}.bg-\[\#f1efeb\]{background-color:#f1efeb}.bg-accent,.bg-accent\/5{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/5{background-color:color-mix(in oklab, var(--color-accent) 5%, transparent)}}.bg-accent\/8{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/8{background-color:color-mix(in oklab, var(--color-accent) 8%, transparent)}}.bg-accent\/10{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/10{background-color:color-mix(in oklab, var(--color-accent) 10%, transparent)}}.bg-accent\/12{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/12{background-color:color-mix(in oklab, var(--color-accent) 12%, transparent)}}.bg-accent\/15{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/15{background-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.bg-accent\/70{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/70{background-color:color-mix(in oklab, var(--color-accent) 70%, transparent)}}.bg-accent\/\[0\.06\]{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/\[0\.06\]{background-color:color-mix(in oklab, var(--color-accent) 6%, transparent)}}.bg-amber-50\/70{background-color:#fffbebb3}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/70{background-color:color-mix(in oklab, var(--color-amber-50) 70%, transparent)}}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-400\/15{background-color:#fcbb0026}@supports (color:color-mix(in lab, red, red)){.bg-amber-400\/15{background-color:color-mix(in oklab, var(--color-amber-400) 15%, transparent)}}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/12{background-color:#f99c001f}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/12{background-color:color-mix(in oklab, var(--color-amber-500) 12%, transparent)}}.bg-amber-500\/15{background-color:#f99c0026}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/15{background-color:color-mix(in oklab, var(--color-amber-500) 15%, transparent)}}.bg-bg,.bg-bg\/50{background-color:var(--color-bg)}@supports (color:color-mix(in lab, red, red)){.bg-bg\/50{background-color:color-mix(in oklab, var(--color-bg) 50%, transparent)}}.bg-bg\/60{background-color:var(--color-bg)}@supports (color:color-mix(in lab, red, red)){.bg-bg\/60{background-color:color-mix(in oklab, var(--color-bg) 60%, transparent)}}.bg-bg\/80{background-color:var(--color-bg)}@supports (color:color-mix(in lab, red, red)){.bg-bg\/80{background-color:color-mix(in oklab, var(--color-bg) 80%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab, red, red)){.bg-black\/45{background-color:color-mix(in oklab, var(--color-black) 45%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-border{background-color:var(--color-border)}.bg-bubble-agent{background-color:var(--color-bubble-agent)}.bg-bubble-user{background-color:var(--color-bubble-user)}.bg-cyan-500\/12{background-color:#00b7d71f}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/12{background-color:color-mix(in oklab, var(--color-cyan-500) 12%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/12{background-color:#00bb7f1f}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/12{background-color:color-mix(in oklab, var(--color-emerald-500) 12%, transparent)}}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-fg,.bg-fg\/40{background-color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.bg-fg\/40{background-color:color-mix(in oklab, var(--color-fg) 40%, transparent)}}.bg-fuchsia-500\/12{background-color:#e12afb1f}@supports (color:color-mix(in lab, red, red)){.bg-fuchsia-500\/12{background-color:color-mix(in oklab, var(--color-fuchsia-500) 12%, transparent)}}.bg-ink{background-color:var(--color-ink)}.bg-muted,.bg-muted\/35{background-color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/35{background-color:color-mix(in oklab, var(--color-muted) 35%, transparent)}}.bg-orange-500\/12{background-color:#fe6e001f}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/12{background-color:color-mix(in oklab, var(--color-orange-500) 12%, transparent)}}.bg-pink-500{background-color:var(--color-pink-500)}.bg-pink-500\/12{background-color:#f6339a1f}@supports (color:color-mix(in lab, red, red)){.bg-pink-500\/12{background-color:color-mix(in oklab, var(--color-pink-500) 12%, transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-rose-400{background-color:var(--color-rose-400)}.bg-rose-500{background-color:var(--color-rose-500)}.bg-rose-500\/12{background-color:#ff23571f}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/12{background-color:color-mix(in oklab, var(--color-rose-500) 12%, transparent)}}.bg-rose-500\/15{background-color:#ff235726}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/15{background-color:color-mix(in oklab, var(--color-rose-500) 15%, transparent)}}.bg-rose-500\/\[0\.04\]{background-color:#ff23570a}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/\[0\.04\]{background-color:color-mix(in oklab, var(--color-rose-500) 4%, transparent)}}.bg-rose-600{background-color:var(--color-rose-600)}.bg-sky-500\/12{background-color:#00a5ef1f}@supports (color:color-mix(in lab, red, red)){.bg-sky-500\/12{background-color:color-mix(in oklab, var(--color-sky-500) 12%, transparent)}}.bg-surface{background-color:var(--color-surface)}.bg-surface-2,.bg-surface-2\/40{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/40{background-color:color-mix(in oklab, var(--color-surface-2) 40%, transparent)}}.bg-surface-2\/55{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/55{background-color:color-mix(in oklab, var(--color-surface-2) 55%, transparent)}}.bg-surface-2\/60{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/60{background-color:color-mix(in oklab, var(--color-surface-2) 60%, transparent)}}.bg-surface-2\/70{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/70{background-color:color-mix(in oklab, var(--color-surface-2) 70%, transparent)}}.bg-surface-2\/80{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/80{background-color:color-mix(in oklab, var(--color-surface-2) 80%, transparent)}}.bg-surface\/40{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/40{background-color:color-mix(in oklab, var(--color-surface) 40%, transparent)}}.bg-surface\/70{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/70{background-color:color-mix(in oklab, var(--color-surface) 70%, transparent)}}.bg-surface\/90{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/90{background-color:color-mix(in oklab, var(--color-surface) 90%, transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500{background-color:var(--color-violet-500)}.bg-violet-500\/12{background-color:#8d54ff1f}@supports (color:color-mix(in lab, red, red)){.bg-violet-500\/12{background-color:color-mix(in oklab, var(--color-violet-500) 12%, transparent)}}.bg-white{background-color:var(--color-white)}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-\[radial-gradient\(ellipse_at_top\,rgba\(0\,100\,212\,0\.14\)\,transparent_65\%\)\]{background-image:radial-gradient(at top,#0064d424,#0000 65%)}.from-\[\#7d6bc7\]{--tw-gradient-from:#7d6bc7;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-\[\#b0413e\]{--tw-gradient-from:#b0413e;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-accent{--tw-gradient-from:var(--color-accent);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500{--tw-gradient-from:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-orange-400{--tw-gradient-from:var(--color-orange-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-400{--tw-gradient-from:var(--color-slate-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-700{--tw-gradient-from:var(--color-slate-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-\[\#7a1f1f\]{--tw-gradient-to:#7a1f1f;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-\[\#574494\]{--tw-gradient-to:#574494;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-amber-600{--tw-gradient-to:var(--color-amber-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-sky-400{--tw-gradient-to:var(--color-sky-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-600{--tw-gradient-to:var(--color-slate-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-900{--tw-gradient-to:var(--color-slate-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-teal-700{--tw-gradient-to:var(--color-teal-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-3\.5{padding-inline:calc(var(--spacing) * 3.5)!important}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-16{padding-inline:calc(var(--spacing) * 16)}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-0{padding-top:0}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-9{padding-top:calc(var(--spacing) * 9)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pt-\[3px\]{padding-top:3px}.pt-\[18px\]{padding-top:18px}.pt-\[22px\]{padding-top:22px}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-2\.5{padding-bottom:calc(var(--spacing) * 2.5)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-3\.5{padding-bottom:calc(var(--spacing) * 3.5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-7{padding-bottom:calc(var(--spacing) * 7)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.tabbar-room{padding-bottom:calc(70px + env(safe-area-inset-bottom))}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-3\.5{padding-left:calc(var(--spacing) * 3.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-7{padding-left:calc(var(--spacing) * 7)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-12{padding-left:calc(var(--spacing) * 12)}.pl-\[18px\]{padding-left:18px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13\.5px\]{font-size:13.5px}.text-\[13px\]{font-size:13px}.text-\[14\.5px\]{font-size:14.5px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[17px\]{font-size:17px}.text-\[18px\]{font-size:18px}.text-\[20px\]{font-size:20px}.text-\[21px\]{font-size:21px}.text-\[22px\]{font-size:22px}.text-\[24px\]{font-size:24px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-\[30px\]{font-size:30px}.text-\[64px\]{font-size:64px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-8{--tw-leading:calc(var(--spacing) * 8);line-height:calc(var(--spacing) * 8)}.leading-\[1\.4\]{--tw-leading:1.4;line-height:1.4}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.45\]{--tw-leading:1.45;line-height:1.45}.leading-\[14px\]{--tw-leading:14px;line-height:14px}.leading-\[17px\]{--tw-leading:17px;line-height:17px}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-\[19px\]{--tw-leading:19px;line-height:19px}.leading-\[20px\]{--tw-leading:20px;line-height:20px}.leading-\[21px\]{--tw-leading:21px;line-height:21px}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-\[26px\]{--tw-leading:26px;line-height:26px}.leading-\[34px\]{--tw-leading:34px;line-height:34px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-accent-fg{color:var(--color-accent-fg)}.text-accent\/80{color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.text-accent\/80{color:color-mix(in oklab, var(--color-accent) 80%, transparent)}}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-bg{color:var(--color-bg)}.text-bubble-user-fg{color:var(--color-bubble-user-fg)}.text-cyan-600{color:var(--color-cyan-600)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-fg,.text-fg\/60{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/60{color:color-mix(in oklab, var(--color-fg) 60%, transparent)}}.text-fg\/65{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/65{color:color-mix(in oklab, var(--color-fg) 65%, transparent)}}.text-fg\/70{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/70{color:color-mix(in oklab, var(--color-fg) 70%, transparent)}}.text-fg\/75{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/75{color:color-mix(in oklab, var(--color-fg) 75%, transparent)}}.text-fg\/80{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/80{color:color-mix(in oklab, var(--color-fg) 80%, transparent)}}.text-fg\/85{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/85{color:color-mix(in oklab, var(--color-fg) 85%, transparent)}}.text-fg\/90{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/90{color:color-mix(in oklab, var(--color-fg) 90%, transparent)}}.text-fuchsia-600{color:var(--color-fuchsia-600)}.text-ink-fg{color:var(--color-ink-fg)}.text-muted,.text-muted\/70{color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.text-muted\/70{color:color-mix(in oklab, var(--color-muted) 70%, transparent)}}.text-muted\/80{color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.text-muted\/80{color:color-mix(in oklab, var(--color-muted) 80%, transparent)}}.text-orange-600{color:var(--color-orange-600)}.text-pink-600{color:var(--color-pink-600)}.text-red-600{color:var(--color-red-600)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-rose-700{color:var(--color-rose-700)}.text-sky-600{color:var(--color-sky-600)}.text-violet-600{color:var(--color-violet-600)}.text-white{color:var(--color-white)}.text-white\/85{color:#ffffffd9}@supports (color:color-mix(in lab, red, red)){.text-white\/85{color:color-mix(in oklab, var(--color-white) 85%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.decoration-border{-webkit-text-decoration-color:var(--color-border);-webkit-text-decoration-color:var(--color-border);-webkit-text-decoration-color:var(--color-border);text-decoration-color:var(--color-border)}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.accent-\[var\(--om-accent\)\]{accent-color:var(--om-accent)}.accent-accent{accent-color:var(--color-accent)}.opacity-0{opacity:0}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(0\,0\,0\,0\.14\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#00000024);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(0\,0\,0\,0\.16\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#00000029);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#0000002e);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_12px_-6px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 12px -6px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_12px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 12px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_20px_-12px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow:0 4px 20px -12px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_12px_40px_-12px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 12px 40px -12px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring,.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-\[2\.5px\]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2.5px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-\[3px\]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-accent,.ring-accent\/40{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.ring-accent\/40{--tw-ring-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}.ring-border\/50{--tw-ring-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.ring-border\/50{--tw-ring-color:color-mix(in oklab, var(--color-border) 50%, transparent)}}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-bg{--tw-ring-offset-color:var(--color-bg)}.blur-3xl{--tw-blur:blur(var(--blur-3xl));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow-\[0_20px_40px_rgba\(0\,100\,212\,0\.28\)\]{--tw-drop-shadow-size:drop-shadow(0 20px 40px var(--tw-drop-shadow-color,#0064d447));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow-sm{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-sm));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-2xl{--tw-backdrop-blur:blur(var(--blur-2xl));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-muted::placeholder{color:var(--color-muted)}.odd\:bg-surface:nth-child(odd){background-color:var(--color-surface)}.focus-within\:border-fg\/20:focus-within{border-color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.focus-within\:border-fg\/20:focus-within{border-color:color-mix(in oklab, var(--color-fg) 20%, transparent)}}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-accent\/40:focus-within{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-accent\/40:focus-within{--tw-ring-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}@media (hover:hover){.hover\:scale-\[1\.04\]:hover{scale:1.04}.hover\:bg-accent\/15:hover{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/15:hover{background-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.hover\:bg-border\/60:hover{background-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-border\/60:hover{background-color:color-mix(in oklab, var(--color-border) 60%, transparent)}}.hover\:bg-rose-500\/10:hover{background-color:#ff23571a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-rose-500\/10:hover{background-color:color-mix(in oklab, var(--color-rose-500) 10%, transparent)}}.hover\:bg-rose-600\/90:hover{background-color:#e70044e6}@supports (color:color-mix(in lab, red, red)){.hover\:bg-rose-600\/90:hover{background-color:color-mix(in oklab, var(--color-rose-600) 90%, transparent)}}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:bg-surface-2:hover,.hover\:bg-surface-2\/60:hover{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface-2\/60:hover{background-color:color-mix(in oklab, var(--color-surface-2) 60%, transparent)}}.hover\:bg-surface-2\/70:hover{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface-2\/70:hover{background-color:color-mix(in oklab, var(--color-surface-2) 70%, transparent)}}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-fg:hover{color:var(--color-fg)}.hover\:text-rose-500:hover{color:var(--color-rose-500)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-accent:hover,.hover\:ring-accent\/60:hover{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:ring-accent\/60:hover{--tw-ring-color:color-mix(in oklab, var(--color-accent) 60%, transparent)}}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:bg-surface-2:focus{background-color:var(--color-surface-2)}.focus\:opacity-100:focus{opacity:1}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-accent\/40:focus{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.focus\:ring-accent\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-\[0\.98\]:active{scale:.98}.active\:scale-\[0\.99\]:active{scale:.99}.active\:bg-surface-2:active,.active\:bg-surface-2\/70:active{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.active\:bg-surface-2\/70:active{background-color:color-mix(in oklab, var(--color-surface-2) 70%, transparent)}}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:opacity-70:disabled{opacity:.7}@media (width>=40rem){.sm\:hidden{display:none}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:items-center{align-items:center}.sm\:justify-center{justify-content:center}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[28px\]{border-radius:28px}.sm\:border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.sm\:border-border{border-color:var(--color-border)}}@container (width>=42rem){.\@2xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.dark\:border:where([data-theme=dark],[data-theme=dark] *){border-style:var(--tw-border-style);border-width:1px}.dark\:border-border:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-border)}.dark\:border-rose-400\/60:where([data-theme=dark],[data-theme=dark] *){border-color:#ff667f99}@supports (color:color-mix(in lab, red, red)){.dark\:border-rose-400\/60:where([data-theme=dark],[data-theme=dark] *){border-color:color-mix(in oklab, var(--color-rose-400) 60%, transparent)}}.dark\:bg-accent\/\[0\.10\]:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-accent\/\[0\.10\]:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-accent) 10%, transparent)}}.dark\:bg-amber-400\/\[0\.08\]:where([data-theme=dark],[data-theme=dark] *){background-color:#fcbb0014}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-400\/\[0\.08\]:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-amber-400) 8%, transparent)}}.dark\:bg-\[radial-gradient\(ellipse_at_top\,rgba\(23\,147\,255\,0\.2\)\,transparent_65\%\)\]:where([data-theme=dark],[data-theme=dark] *){background-image:radial-gradient(at top,#1793ff33,#0000 65%)}.dark\:text-amber-200:where([data-theme=dark],[data-theme=dark] *){color:var(--color-amber-200)}.dark\:text-amber-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-amber-300)}.dark\:text-cyan-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-cyan-300)}.dark\:text-emerald-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-emerald-300)}.dark\:text-emerald-400:where([data-theme=dark],[data-theme=dark] *){color:var(--color-emerald-400)}.dark\:text-fuchsia-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-fuchsia-300)}.dark\:text-orange-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-orange-300)}.dark\:text-pink-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-pink-300)}.dark\:text-red-400:where([data-theme=dark],[data-theme=dark] *){color:var(--color-red-400)}.dark\:text-rose-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-rose-300)}.dark\:text-rose-400:where([data-theme=dark],[data-theme=dark] *){color:var(--color-rose-400)}.dark\:text-sky-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-sky-300)}.dark\:text-violet-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-violet-300)}.dark\:shadow-none:where([data-theme=dark],[data-theme=dark] *){--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (width>=64rem){.wide\:invisible{visibility:hidden}}:where(html.desktop) .wide\:invisible{visibility:hidden}@media (width>=64rem){.wide\:inset-y-0{inset-block:0}}:where(html.desktop) .wide\:inset-y-0{inset-block:0}@media (width>=64rem){.wide\:mt-0{margin-top:0}}:where(html.desktop) .wide\:mt-0{margin-top:0}@media (width>=64rem){.wide\:mt-5{margin-top:calc(var(--spacing) * 5)}}:where(html.desktop) .wide\:mt-5{margin-top:calc(var(--spacing) * 5)}@media (width>=64rem){.wide\:block{display:block}}:where(html.desktop) .wide\:block{display:block}@media (width>=64rem){.wide\:flex{display:flex}}:where(html.desktop) .wide\:flex{display:flex}@media (width>=64rem){.wide\:grid{display:grid}}:where(html.desktop) .wide\:grid{display:grid}@media (width>=64rem){.wide\:hidden{display:none}}:where(html.desktop) .wide\:hidden{display:none}@media (width>=64rem){.wide\:h-auto{height:auto}}:where(html.desktop) .wide\:h-auto{height:auto}@media (width>=64rem){.wide\:h-full{height:100%}}:where(html.desktop) .wide\:h-full{height:100%}@media (width>=64rem){.wide\:max-w-\[600px\]{max-width:600px}}:where(html.desktop) .wide\:max-w-\[600px\]{max-width:600px}@media (width>=64rem){.wide\:max-w-\[900px\]{max-width:900px}}:where(html.desktop) .wide\:max-w-\[900px\]{max-width:900px}@media (width>=64rem){.wide\:max-w-\[980px\]{max-width:980px}}:where(html.desktop) .wide\:max-w-\[980px\]{max-width:980px}@media (width>=64rem){.wide\:max-w-md{max-width:var(--container-md)}}:where(html.desktop) .wide\:max-w-md{max-width:var(--container-md)}@media (width>=64rem){.wide\:max-w-none{max-width:none}}:where(html.desktop) .wide\:max-w-none{max-width:none}@media (width>=64rem){.wide\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(html.desktop) .wide\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}@media (width>=64rem){.wide\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(html.desktop) .wide\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}@media (width>=64rem){.wide\:grid-cols-\[1\.1fr_1fr\]{grid-template-columns:1.1fr 1fr}}:where(html.desktop) .wide\:grid-cols-\[1\.1fr_1fr\]{grid-template-columns:1.1fr 1fr}@media (width>=64rem){.wide\:flex-row{flex-direction:row}}:where(html.desktop) .wide\:flex-row{flex-direction:row}@media (width>=64rem){.wide\:items-center{align-items:center}}:where(html.desktop) .wide\:items-center{align-items:center}@media (width>=64rem){.wide\:items-start{align-items:flex-start}}:where(html.desktop) .wide\:items-start{align-items:flex-start}@media (width>=64rem){.wide\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}}:where(html.desktop) .wide\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}@media (width>=64rem){.wide\:gap-16{gap:calc(var(--spacing) * 16)}}:where(html.desktop) .wide\:gap-16{gap:calc(var(--spacing) * 16)}@media (width>=64rem){.wide\:border-0{border-style:var(--tw-border-style);border-width:0}}:where(html.desktop) .wide\:border-0{border-style:var(--tw-border-style);border-width:0}@media (width>=64rem){.wide\:border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}}:where(html.desktop) .wide\:border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}@media (width>=64rem){.wide\:bg-transparent{background-color:#0000}}:where(html.desktop) .wide\:bg-transparent{background-color:#0000}@media (width>=64rem){.wide\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(0\,100\,212\,0\.12\)\,transparent_60\%\)\]{background-image:radial-gradient(at 0,#0064d41f,#0000 60%)}}:where(html.desktop) .wide\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(0\,100\,212\,0\.12\)\,transparent_60\%\)\]{background-image:radial-gradient(at 0,#0064d41f,#0000 60%)}@media (width>=64rem){.wide\:p-7{padding:calc(var(--spacing) * 7)}}:where(html.desktop) .wide\:p-7{padding:calc(var(--spacing) * 7)}@media (width>=64rem){.wide\:px-0{padding-inline:0}}:where(html.desktop) .wide\:px-0{padding-inline:0}@media (width>=64rem){.wide\:px-12{padding-inline:calc(var(--spacing) * 12)}}:where(html.desktop) .wide\:px-12{padding-inline:calc(var(--spacing) * 12)}@media (width>=64rem){.wide\:py-1{padding-block:var(--spacing)}}:where(html.desktop) .wide\:py-1{padding-block:var(--spacing)}@media (width>=64rem){.wide\:pt-0{padding-top:0}}:where(html.desktop) .wide\:pt-0{padding-top:0}@media (width>=64rem){.wide\:pb-0{padding-bottom:0}}:where(html.desktop) .wide\:pb-0{padding-bottom:0}@media (width>=64rem){.wide\:text-left{text-align:left}}:where(html.desktop) .wide\:text-left{text-align:left}@media (width>=64rem){.wide\:text-\[13\.5px\]{font-size:13.5px}}:where(html.desktop) .wide\:text-\[13\.5px\]{font-size:13.5px}@media (width>=64rem){.wide\:text-\[15\.5px\]{font-size:15.5px}}:where(html.desktop) .wide\:text-\[15\.5px\]{font-size:15.5px}@media (width>=64rem){.wide\:text-\[34px\]{font-size:34px}}:where(html.desktop) .wide\:text-\[34px\]{font-size:34px}@media (width>=64rem){.wide\:dark\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(23\,147\,255\,0\.16\)\,transparent_60\%\)\]:where([data-theme=dark],[data-theme=dark] *){background-image:radial-gradient(at 0,#1793ff29,#0000 60%)}}:where(html.desktop) .wide\:dark\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(23\,147\,255\,0\.16\)\,transparent_60\%\)\]:where([data-theme=dark],[data-theme=dark] *){background-image:radial-gradient(at 0,#1793ff29,#0000 60%)}.\[\&\+\&\]\:mt-1\.5+.\[\&\+\&\]\:mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}}@font-face{font-family:Figtree;font-style:normal;font-weight:300 900;font-display:swap;src:url(/fonts/figtree-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Figtree;font-style:normal;font-weight:300 900;font-display:swap;src:url(/fonts/figtree-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}:root{--om-bg:#fff;--om-surface:#fff;--om-surface-2:#f2f2f4;--om-border:#e7e7ea;--om-fg:#111112;--om-muted:#6b6b71;--om-accent:#0064d4;--om-accent-soft:#e5f0ff;--om-accent-soft-fg:#012f80;--om-ink:#000;--om-ink-fg:#fff;--om-bubble-user:#000;--om-bubble-user-fg:#fff;--om-bubble-user-selection:#ffffff4d;--om-bubble-agent:#f2f2f7;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}html[data-theme=dark]{--om-bg:#181819;--om-surface:#202022;--om-surface-2:#2a2a2d;--om-border:#333337;--om-fg:#fff;--om-muted:#9d9da4;--om-accent:#1793ff;--om-accent-soft:#1a2b4a;--om-accent-soft-fg:#e5f0ff;--om-ink:#fff;--om-ink-fg:#000;--om-bubble-user:#fff;--om-bubble-user-fg:#000;--om-bubble-user-selection:#0000004d;--om-bubble-agent:#2a2a2d;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}html,body,#root{height:100%}body{background:var(--om-bg);color:var(--om-fg);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;overscroll-behavior-y:none;margin:0}.safe-top{padding-top:env(safe-area-inset-top)}.titlebar-room{display:none}html.desktop[data-platform=darwin] .titlebar-room{-webkit-app-region:drag;flex-shrink:0;height:28px;display:block}.safe-bottom{padding-bottom:env(safe-area-inset-bottom)}.header-scrim{background:linear-gradient(to bottom, var(--om-bg) 0%, var(--om-bg) 55%, transparent 100%)}@supports (color:color-mix(in lab, red, red)){.header-scrim{background:linear-gradient(to bottom, color-mix(in srgb, var(--om-bg) 80%, transparent) 0%, color-mix(in srgb, var(--om-bg) 35%, transparent) 55%, transparent 100%)}}.header-scrim{pointer-events:none}.header-scrim :is(button,a,[role=menu]){pointer-events:auto}.list-fade-top{background:linear-gradient(to bottom, var(--om-bg), transparent);transition:opacity .25s}@media (prefers-reduced-motion:reduce){.list-fade-top{transition:none}}*{scrollbar-width:thin;scrollbar-color:var(--om-border) transparent}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:var(--om-border);border-radius:999px}::-webkit-scrollbar-track{background:0 0}@media (pointer:coarse),(width<=480px){*{scrollbar-width:none}::-webkit-scrollbar{display:none}}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{scrollbar-width:none}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:.35em 0}.md ul,.md ol{margin:.35em 0;padding-left:1.3em}.md ul{list-style:outside}.md ol{list-style:decimal}.md li{margin:.15em 0}.md h1,.md h2,.md h3,.md h4{margin:.7em 0 .3em;font-weight:650;line-height:1.3}.md h1{font-size:1.15em}.md h2{font-size:1.08em}.md h3,.md h4{font-size:1em}.md a{color:var(--om-accent);text-underline-offset:2px;word-break:break-all;text-decoration:underline}.bubble-user .md a{color:var(--om-bubble-user-fg)}.bubble-user ::selection{background:var(--om-bubble-user-selection);color:var(--om-bubble-user-fg)}.md code{background:#7f7fa029;border-radius:6px;padding:.1em .35em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em}.md pre{color:#eaeaf5;background:#0f0f19e6;border-radius:12px;margin:.5em 0;padding:.7em .85em;font-size:.82em;line-height:1.45;overflow-x:auto}.md pre code{font-size:inherit;background:0 0;padding:0}.md .md-file{color:var(--om-accent);background:var(--om-accent);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em}@supports (color:color-mix(in lab, red, red)){.md .md-file{background:color-mix(in srgb, var(--om-accent) 12%, transparent)}}.md .md-file{text-underline-offset:2px;word-break:break-all;border-radius:6px;padding:.1em .4em;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.md blockquote{border-left:3px solid var(--om-border);color:var(--om-muted);margin:.5em 0;padding-left:.8em}.md table{border-collapse:collapse;max-width:100%;margin:.5em 0;font-size:.9em;display:block;overflow-x:auto}.md th,.md td{border:1px solid var(--om-border);text-align:left;padding:.3em .55em}.md hr{border:0;border-top:1px solid var(--om-border);margin:.7em 0}.md img{border-radius:10px;max-width:100%}@keyframes om-breathe{0%,to{transform:translateY(0)scale(1)}50%{transform:translateY(-1.5%)scale(1.025)}}@keyframes om-sway{0%,to{transform:rotate(-4deg)translateY(0)}25%{transform:rotate(0)translateY(-2%)}50%{transform:rotate(4deg)translateY(0)}75%{transform:rotate(0)translateY(-2%)}}@keyframes om-hop{0%,70%,to{transform:translateY(0)scale(1)}78%{transform:translateY(-9%)scale(1.04,.97)}86%{transform:translateY(0)scale(.98,1.03)}92%{transform:translateY(-3%)scale(1)}}@keyframes om-wiggle{0%,to{transform:rotate(0)}20%{transform:rotate(-10deg)scale(1.06)}45%{transform:rotate(9deg)scale(1.06)}70%{transform:rotate(-5deg)}}.avatar-idle{animation:4.5s ease-in-out infinite om-breathe}.avatar-working{animation:2.6s ease-in-out infinite om-sway}.avatar-waiting{animation:3.2s ease-in-out infinite om-hop}.avatar-wiggle{animation:.6s ease-in-out om-wiggle}@keyframes om-pop{0%{transform:scale(.6)}70%{transform:scale(1.06)}to{transform:scale(1)}}.avatar-pop{animation:.55s cubic-bezier(.34,1.56,.64,1) om-pop}@media (prefers-reduced-motion:reduce){.avatar-idle,.avatar-working,.avatar-waiting,.avatar-wiggle,.avatar-pop{animation:none}}@keyframes om-blink{50%{opacity:.25}}.typing-dot{animation:1.1s infinite om-blink}.typing-dot:nth-child(2){animation-delay:.18s}.typing-dot:nth-child(3){animation-delay:.36s}@keyframes om-rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}.rise{animation:.18s ease-out om-rise}@keyframes om-sheet{0%{transform:translateY(100%)}to{transform:none}}.sheet-in{animation:.22s cubic-bezier(.2,.8,.2,1) om-sheet}@keyframes om-drawer-in{0%{transform:translate(-100%)}to{transform:none}}.drawer-in{animation:.2s ease-out om-drawer-in}@media (prefers-reduced-motion:reduce){.rise,.sheet-in,.drawer-in,.typing-dot{animation:none}}@property --tw-border-spacing-x{syntax:"";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:"";inherits:false;initial-value:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/nanomuse/server/static/assets/index-CrH9-E2E.js b/nanomuse/server/static/assets/index-CrH9-E2E.js deleted file mode 100644 index 3f35cd4a..00000000 --- a/nanomuse/server/static/assets/index-CrH9-E2E.js +++ /dev/null @@ -1,44 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/AccountScreen-COuDQPDa.js","assets/MuseHeader-DHtaUVEa.js","assets/StarNudge-viN7_tiM.js","assets/external-link-3iD-Bcbv.js","assets/Form-heEKMZyj.js","assets/clapperboard-vj4UL6pS.js","assets/database-zap-DrvRjpVx.js","assets/image-pZaHCatu.js","assets/BackBar-B5YUZVK1.js","assets/AvatarStudioScreen-B7jrVEOY.js","assets/refresh-cw-DQelymc2.js","assets/AvatarPicker-zOz-S1nk.js","assets/ChannelsScreen-BbqVSzaT.js","assets/CodingScreen-BpFj4V_H.js","assets/ConnectionsScreen-DjHkABEK.js","assets/plug-DQfRcVG2.js","assets/CloudCard-D6djdKgw.js","assets/DevicesScreen-IPKIub3e.js","assets/GoalsScreen-BsVUgvTc.js","assets/IdeasScreen-C-S1VdV7.js","assets/LibraryScreen-Df3fNYTa.js","assets/MemoryScreen-BJU-TDeO.js","assets/Onboarding-CgT7DiUR.js","assets/IdentityForm-Da0A3oTS.js","assets/SettingsScreen-qmwHF2En.js","assets/SkillsScreen-BBsZ25e9.js"])))=>i.map(i=>d[i]); -import{A as e,B as t,C as n,D as r,E as i,I as a,J as o,K as s,L as c,M as l,N as u,O as d,P as f,Q as p,R as m,S as h,T as g,U as _,V as v,W as y,X as b,Y as x,Z as S,b as C,c as w,d as T,f as E,i as D,j as O,n as k,p as ee,q as A,r as te,s as j,t as ne,u as re,x as M,y as N,z as P}from"./MuseHeader-DHtaUVEa.js";import{A as ie,E as ae,M as oe,N as se,S as ce,T as le,_ as ue,a as de,b as fe,c as pe,i as me,j as he,k as ge,l as _e,m as ve,n as ye,o as be,r as xe,s as Se,t as Ce,u as we,v as Te,x as Ee,y as De}from"./StarNudge-viN7_tiM.js";import{n as Oe,t as ke}from"./external-link-3iD-Bcbv.js";import{a as Ae,c as je,i as Me,n as Ne,o as Pe,r as Fe,s as Ie}from"./Form-heEKMZyj.js";import{t as Le}from"./refresh-cw-DQelymc2.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var Re=b((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m){if(n(c)!==null)m=!0,te(x);else{var t=n(l);t!==null&&j(b,t.startTime-e)}}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&j(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,j(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,te(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),ze=b(((e,t)=>{t.exports=Re()})),Be=b((e=>{var t=x(),n=ze();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;nt}return!1}function g(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var _={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){_[e]=new g(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];_[t]=new g(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){_[e]=new g(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){_[e]=new g(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){_[e]=new g(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){_[e]=new g(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){_[e]=new g(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){_[e]=new g(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){_[e]=new g(e,5,!1,e.toLowerCase(),null,!1,!1)});var v=/[\-:]([a-z])/g;function y(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(v,y);_[t]=new g(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(v,y);_[t]=new g(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(v,y);_[t]=new g(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){_[e]=new g(e,1,!1,e.toLowerCase(),null,!1,!1)}),_.xlinkHref=new g(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){_[e]=new g(e,1,!1,e.toLowerCase(),null,!0,!0)});function b(e,t,n,r){var i=_.hasOwnProperty(t)?_[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` -`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{oe=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?ae(e):``}function ce(e){switch(e.tag){case 5:return ae(e.type);case 16:return ae(`Lazy`);case 13:return ae(`Suspense`);case 19:return ae(`SuspenseList`);case 0:case 2:case 15:return e=se(e.type,!1),e;case 11:return e=se(e.type.render,!1),e;case 1:return e=se(e.type,!0),e;default:return``}}function le(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case T:return`Fragment`;case w:return`Portal`;case D:return`Profiler`;case E:return`StrictMode`;case A:return`Suspense`;case te:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case k:return(e.displayName||`Context`)+`.Consumer`;case O:return(e._context.displayName||`Context`)+`.Provider`;case ee:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case j:return t=e.displayName||null,t===null?le(e.type)||`Memo`:t;case ne:t=e._payload,e=e._init;try{return le(e(t))}catch{}}return null}function ue(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return le(t);case 8:return t===E?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function de(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function fe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function pe(e){var t=fe(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function me(e){e._valueTracker||=pe(e)}function he(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=fe(e)?e.checked?`true`:`false`:e.value),e=r,e!==n&&(t.setValue(e),!0)}function ge(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function _e(e,t){var n=t.checked;return P({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ve(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=de(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function ye(e,t){t=t.checked,t!=null&&b(e,`checked`,t,!1)}function be(e,t){ye(e,t);var n=de(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?Se(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&Se(e,t.type,de(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function xe(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function Se(e,t,n){(t!==`number`||ge(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var Ce=Array.isArray;function we(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=je.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Ne(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Pe={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Fe=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Pe).forEach(function(e){Fe.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Pe[t]=Pe[e]})});function Ie(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Pe.hasOwnProperty(e)&&Pe[e]?(``+t).trim():t+`px`}function Le(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Ie(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var Re=P({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Be(e,t){if(t){if(Re[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function Ve(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var He=null;function Ue(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var We=null,Ge=null,Ke=null;function qe(e){if(e=Ii(e)){if(typeof We!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=Ri(t),We(e.stateNode,e.type,t))}}function Je(e){Ge?Ke?Ke.push(e):Ke=[e]:Ge=e}function Ye(){if(Ge){var e=Ge,t=Ke;if(Ke=Ge=null,qe(e),t)for(e=0;e>>=0,e===0?32:31-(jt(e)/Mt|0)|0}var Pt=64,Ft=4194304;function It(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Lt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=It(a))):r=It(s)}else o=n&~i,o===0?a!==0&&(r=It(a)):r=It(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Ut(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-At(t),e[t]=n}function Wt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=er),rr=` `,ir=!1;function ar(e,t){switch(e){case`keyup`:return Qn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function or(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var sr=!1;function cr(e,t){switch(e){case`compositionend`:return or(t);case`keypress`:return t.which===32?(ir=!0,rr):null;case`textInput`:return e=t.data,e===rr&&ir?null:e;default:return null}}function lr(e,t){if(sr)return e===`compositionend`||!$n&&ar(e,t)?(e=En(),Tn=wn=Cn=null,sr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=jr(n)}}function Nr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Nr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Pr(){for(var e=window,t=ge();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=ge(e.document)}return t}function Fr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Ir(e){var t=Pr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Nr(n.ownerDocument.documentElement,n)){if(r!==null&&Fr(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Mr(n,a);var o=Mr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,Rr=null,zr=null,Br=null,Vr=!1;function Hr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Vr||Rr==null||Rr!==ge(r)||(r=Rr,`selectionStart`in r&&Fr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Br&&Ar(Br,r)||(Br=r,r=pi(zr,`onSelect`),0Bi||(e.current=zi[Bi],zi[Bi]=null,Bi--)}function J(e,t){Bi++,zi[Bi]=e.current,e.current=t}var Hi={},Ui=Vi(Hi),Wi=Vi(!1),Gi=Hi;function Ki(e,t){var n=e.type.contextTypes;if(!n)return Hi;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function qi(e){return e=e.childContextTypes,e!=null}function Ji(){q(Wi),q(Ui)}function Yi(e,t,n){if(Ui.current!==Hi)throw Error(r(168));J(Ui,t),J(Wi,n)}function Xi(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,ue(e)||`Unknown`,a));return P({},n,i)}function Zi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Hi,Gi=Ui.current,J(Ui,e),J(Wi,Wi.current),!0}function Qi(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=Xi(e,t,Gi),i.__reactInternalMemoizedMergedChildContext=e,q(Wi),q(Ui),J(Ui,e)):q(Wi),J(Wi,n)}var $i=null,ea=!1,ta=!1;function na(e){$i===null?$i=[e]:$i.push(e)}function ra(e){ea=!0,na(e)}function ia(){if(!ta&&$i!==null){ta=!0;var e=0,t=I;try{var n=$i;for(I=1;e>=o,i-=o,fa=1<<32-At(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),Y&&ma(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),Y&&ma(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return Y&&ma(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),Y&&ma(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===T&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case C:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===T){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===ne&&Na(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=ja(e,l,i),r.return=e,e=r;break a}n(e,l);break}t(e,l),l=l.sibling}i.type===T?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=ja(e,r,i),o.return=e,e=o)}return s(e);case w:a:{for(l=i.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}n(e,r);break}t(e,r),r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case ne:return l=i._init,_(e,r,l(i._payload),o)}if(Ce(i))return h(e,r,i,o);if(N(i))return g(e,r,i,o);Ma(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Fa=Pa(!0),Ia=Pa(!1),La=Vi(null),Ra=null,za=null,Ba=null;function Va(){Ba=za=Ra=null}function Ha(e){var t=La.current;q(La),e._currentValue=t}function Ua(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Wa(e,t){Ra=e,Ba=za=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ms=!0),e.firstContext=null)}function Ga(e){var t=e._currentValue;if(Ba!==e){if(e={context:e,memoizedValue:t,next:null},za===null){if(Ra===null)throw Error(r(308));za=e,Ra.dependencies={lanes:0,firstContext:e}}else za=za.next=e}return t}var Ka=null;function qa(e){Ka===null?Ka=[e]:Ka.push(e)}function Ja(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,qa(t)):(n.next=i.next,i.next=n),t.interleaved=n,Ya(e,r)}function Ya(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Xa=!1;function Za(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Qa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function $a(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function eo(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,$&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,Ya(e,n)}return i=r.interleaved,i===null?(t.next=t,qa(r)):(t.next=i.next,i.next=t),r.interleaved=t,Ya(e,n)}function to(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gt(e,n)}}function no(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function ro(e,t,n,r){var i=e.updateQueue;Xa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=P({},d,f);break a;case 2:Xa=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Jc|=o,e.lanes=o,e.memoizedState=d}}function io(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=yo.transition;yo.transition={};try{e(!1),t()}finally{I=n,yo.transition=r}}function as(){return Mo().memoizedState}function os(e,t,n){var r=pl(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},cs(e))ls(t,n);else if(n=Ja(e,t,n,r),n!==null){var i=fl();ml(n,e,r,i),us(n,t,r)}}function ss(e,t,n){var r=pl(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(cs(e))ls(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,kr(s,o)){var c=t.interleaved;c===null?(i.next=i,qa(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ja(e,t,i,r),n!==null&&(i=fl(),ml(n,e,r,i),us(n,t,r))}}function cs(e){var t=e.alternate;return e===Z||t!==null&&t===Z}function ls(e,t){wo=Co=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function us(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Gt(e,n)}}var ds={readContext:Ga,useCallback:Do,useContext:Do,useEffect:Do,useImperativeHandle:Do,useInsertionEffect:Do,useLayoutEffect:Do,useMemo:Do,useReducer:Do,useRef:Do,useState:Do,useDebugValue:Do,useDeferredValue:Do,useTransition:Do,useMutableSource:Do,useSyncExternalStore:Do,useId:Do,unstable_isNewReconciler:!1},fs={readContext:Ga,useCallback:function(e,t){return jo().memoizedState=[e,t===void 0?null:t],e},useContext:Ga,useEffect:Jo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),Ko(4194308,4,Qo.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ko(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ko(4,2,e,t)},useMemo:function(e,t){var n=jo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=jo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=os.bind(null,Z,e),[r.memoizedState,e]},useRef:function(e){var t=jo();return e={current:e},t.memoizedState=e},useState:Uo,useDebugValue:es,useDeferredValue:function(e){return jo().memoizedState=e},useTransition:function(){var e=Uo(!1),t=e[0];return e=is.bind(null,e[1]),jo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=Z,a=jo();if(Y){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Vc===null)throw Error(r(349));bo&30||Ro(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Jo(Bo.bind(null,i,o,e),[e]),i.flags|=2048,Wo(9,zo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=jo(),t=Vc.identifierPrefix;if(Y){var n=pa,r=fa;n=(r&~(1<<32-At(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=To++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[Ai]=t,e[ji]=i,nc(e,t,!1,!1),t.stateNode=e;a:{switch(c=Ve(n,i),n){case`dialog`:H(`cancel`,e),H(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:H(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;oel&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304)}}else{if(!i){if(e=ho(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),ac(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!Y)return oc(t),null}else 2*F()-s.renderingStartTime>el&&n!==1073741824&&(t.flags|=128,i=!0,ac(s,!1),t.lanes=4194304)}s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(oc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=F(),t.sibling=null,n=X.current,J(X,i?n&1|2:n&1),t);case 22:case 23:return wl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Wc&1073741824&&(oc(t),t.subtreeFlags&6&&(t.flags|=8192)):oc(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function cc(e,t){switch(_a(t),t.tag){case 1:return qi(t.type)&&Ji(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return fo(),q(Wi),q(Ui),_o(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return mo(t),null;case 13:if(q(X),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));Oa()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return q(X),null;case 4:return fo(),null;case 10:return Ha(t.type._context),null;case 22:case 23:return wl(),null;case 24:return null;default:return null}}var lc=!1,uc=!1,dc=typeof WeakSet==`function`?WeakSet:Set,Q=null;function fc(e,t){var n=e.ref;if(n!==null){if(typeof n==`function`)try{n(null)}catch(n){Rl(e,t,n)}else n.current=null}}function pc(e,t,n){try{n()}catch(n){Rl(e,t,n)}}var mc=!1;function hc(e,t){if(xi=vn,e=Pr(),Fr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Si={focusedElem:e,selectionRange:n},vn=!1,Q=t;Q!==null;)if(t=Q,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Q=e;else for(;Q!==null;){t=Q;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:hs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){Rl(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,Q=e;break}Q=t.return}return h=mc,mc=!1,h}function gc(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&pc(t,n,a)}i=i.next}while(i!==r)}}function _c(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function vc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function yc(e){var t=e.alternate;t!==null&&(e.alternate=null,yc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ai],delete t[ji],delete t[K],delete t[Ni],delete t[Pi])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function bc(e){return e.tag===5||e.tag===3||e.tag===4}function xc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||bc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Sc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=bi));else if(r!==4&&(e=e.child,e!==null))for(Sc(e,t,n),e=e.sibling;e!==null;)Sc(e,t,n),e=e.sibling}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}var wc=null,Tc=!1;function Ec(e,t,n){for(n=n.child;n!==null;)Dc(e,t,n),n=n.sibling}function Dc(e,t,n){if(Ot&&typeof Ot.onCommitFiberUnmount==`function`)try{Ot.onCommitFiberUnmount(Dt,n)}catch{}switch(n.tag){case 5:uc||fc(n,t);case 6:var r=wc,i=Tc;wc=null,Ec(e,t,n),wc=r,Tc=i,wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):wc.removeChild(n.stateNode));break;case 18:wc!==null&&(Tc?(e=wc,n=n.stateNode,e.nodeType===8?U(e.parentNode,n):e.nodeType===1&&U(e,n),gn(e)):U(wc,n.stateNode));break;case 4:r=wc,i=Tc,wc=n.stateNode.containerInfo,Tc=!0,Ec(e,t,n),wc=r,Tc=i;break;case 0:case 11:case 14:case 15:if(!uc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&pc(n,t,o),i=i.next}while(i!==r)}Ec(e,t,n);break;case 1:if(!uc&&(fc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){Rl(n,t,e)}Ec(e,t,n);break;case 21:Ec(e,t,n);break;case 22:n.mode&1?(uc=(r=uc)||n.memoizedState!==null,Ec(e,t,n),uc=r):Ec(e,t,n);break;default:Ec(e,t,n)}}function Oc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new dc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function kc(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=F()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Lc(i/1960))-i,10e?16:e,ol===null)var i=!1;else{if(e=ol,ol=null,sl=0,$&6)throw Error(r(331));var a=$;for($|=4,Q=e.current;Q!==null;){var o=Q,s=o.child;if(Q.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lF()-$c?Tl(e,0):Xc|=n),hl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=Ft,Ft<<=1,!(Ft&130023424)&&(Ft=4194304)):t=1);var n=fl();e=Ya(e,t),e!==null&&(Ut(e,t,n),hl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null){if(e.memoizedProps!==t.pendingProps||Wi.current)Ms=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ms=!1,tc(e,t,n);Ms=!!(e.flags&131072)}}else Ms=!1,Y&&t.flags&1048576&&ha(t,ca,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;$s(e,t),e=t.pendingProps;var a=Ki(t,Ui.current);Wa(t,n),a=ko(null,t,i,e,a,n);var o=Ao();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,qi(i)?(o=!0,Zi(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Za(t),a.updater=_s,t.stateNode=a,a._reactInternals=t,xs(t,i,e,n),t=Vs(null,t,i,!0,o,n)):(t.tag=0,Y&&o&&ga(t),Ns(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch($s(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=hs(i,e),a){case 0:t=zs(null,t,i,e,n);break a;case 1:t=Bs(null,t,i,e,n);break a;case 11:t=Ps(null,t,i,e,n);break a;case 14:t=Fs(null,t,i,hs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),zs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Bs(e,t,i,a,n);case 3:a:{if(Hs(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,Qa(e,t),ro(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated){if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=Ss(Error(r(423)),t),t=Us(e,t,i,n,a);break a}if(i!==a){a=Ss(Error(r(424)),t),t=Us(e,t,i,n,a);break a}for(ya=W(t.stateNode.containerInfo.firstChild),va=t,Y=!0,ba=null,n=Ia(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(Oa(),i===a){t=ec(e,t,n);break a}Ns(e,t,i,n)}t=t.child}return t;case 5:return po(t),e===null&&wa(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,Ci(i,a)?s=null:o!==null&&Ci(i,o)&&(t.flags|=32),Rs(e,t),Ns(e,t,s,n),t.child;case 6:return e===null&&wa(t),null;case 13:return Ks(e,t,n);case 4:return uo(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Fa(t,null,i,n):Ns(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),Ps(e,t,i,a,n);case 7:return Ns(e,t,t.pendingProps,n),t.child;case 8:return Ns(e,t,t.pendingProps.children,n),t.child;case 12:return Ns(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,J(La,i._currentValue),i._currentValue=s,o!==null){if(kr(o.value,s)){if(o.children===a.children&&!Wi.current){t=ec(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=$a(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Ua(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Ua(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}}Ns(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Wa(t,n),a=Ga(a),i=i(a),t.flags|=1,Ns(e,t,i,n),t.child;case 14:return i=t.type,a=hs(i,t.pendingProps),a=hs(i.type,a),Fs(e,t,i,a,n);case 15:return Is(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:hs(i,a),$s(e,t),t.tag=1,qi(i)?(e=!0,Zi(t)):e=!1,Wa(t,n),ys(t,i,a),xs(t,i,a,n),Vs(null,t,i,!0,e,n);case 19:return Qs(e,t,n);case 22:return Ls(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return _t(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===ee)return 11;if(e===j)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case T:return Zl(n.children,a,o,t);case E:s=8,a|=8;break;case D:return e=Kl(12,n,t,a|2),e.elementType=D,e.lanes=o,e;case A:return e=Kl(13,n,t,a),e.elementType=A,e.lanes=o,e;case te:return e=Kl(19,n,t,a),e.elementType=te,e.lanes=o,e;case re:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case O:s=10;break a;case k:s=9;break a;case ee:s=11;break a;case j:s=14;break a;case ne:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ht(0),this.expirationTimes=Ht(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ht(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Za(a),e}function ru(e,t,n){var r=3{function n(){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<`u`&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE==`function`)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=Be()})),He=b((e=>{var t=Ve();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),Ue=o(`alarm-clock`,[[`circle`,{cx:`12`,cy:`13`,r:`8`,key:`3y4lt7`}],[`path`,{d:`M12 9v4l2 2`,key:`1c63tq`}],[`path`,{d:`M5 3 2 6`,key:`18tl5t`}],[`path`,{d:`m22 6-3-3`,key:`1opdir`}],[`path`,{d:`M6.38 18.7 4 21`,key:`17xu3x`}],[`path`,{d:`M17.64 18.67 20 21`,key:`kv2oe2`}]]),We=o(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]),Ge=o(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),Ke=o(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]),qe=o(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]),Je=o(`ban`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M4.929 4.929 19.07 19.071`,key:`196cmz`}]]),Ye=o(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]),Xe=o(`bot`,[[`path`,{d:`M12 8V4H8`,key:`hb8ula`}],[`rect`,{width:`16`,height:`12`,x:`4`,y:`8`,rx:`2`,key:`enze0r`}],[`path`,{d:`M2 14h2`,key:`vft8re`}],[`path`,{d:`M20 14h2`,key:`4cs60a`}],[`path`,{d:`M15 13v2`,key:`1xurst`}],[`path`,{d:`M9 13v2`,key:`rq6x2g`}]]),Ze=o(`brain`,[[`path`,{d:`M12 18V5`,key:`adv99a`}],[`path`,{d:`M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4`,key:`1e3is1`}],[`path`,{d:`M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5`,key:`1gqd8o`}],[`path`,{d:`M17.997 5.125a4 4 0 0 1 2.526 5.77`,key:`iwvgf7`}],[`path`,{d:`M18 18a4 4 0 0 0 2-7.464`,key:`efp6ie`}],[`path`,{d:`M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517`,key:`1gq6am`}],[`path`,{d:`M6 18a4 4 0 0 1-2-7.464`,key:`k1g0md`}],[`path`,{d:`M6.003 5.125a4 4 0 0 0-2.526 5.77`,key:`q97ue3`}]]),Qe=o(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]),$e=o(`calendar-clock`,[[`path`,{d:`M16 14v2.2l1.6 1`,key:`fo4ql5`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`path`,{d:`M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5`,key:`1osxxc`}],[`path`,{d:`M3 10h5`,key:`r794hk`}],[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`circle`,{cx:`16`,cy:`16`,r:`6`,key:`qoo3c4`}]]),et=o(`calendar-days`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}],[`path`,{d:`M8 14h.01`,key:`6423bh`}],[`path`,{d:`M12 14h.01`,key:`1etili`}],[`path`,{d:`M16 14h.01`,key:`1gbofw`}],[`path`,{d:`M8 18h.01`,key:`lrp35t`}],[`path`,{d:`M12 18h.01`,key:`mhygvu`}],[`path`,{d:`M16 18h.01`,key:`kzsmim`}]]),tt=o(`calendar-plus`,[[`path`,{d:`M16 19h6`,key:`xwg31i`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`path`,{d:`M19 16v6`,key:`tddt3s`}],[`path`,{d:`M21 12.598V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5`,key:`1glfrc`}],[`path`,{d:`M3 10h18`,key:`8toen8`}],[`path`,{d:`M8 2v4`,key:`1cmpym`}]]),nt=o(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),rt=o(`clipboard-list`,[[`rect`,{width:`8`,height:`4`,x:`8`,y:`2`,rx:`1`,ry:`1`,key:`tgr4d6`}],[`path`,{d:`M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2`,key:`116196`}],[`path`,{d:`M12 11h4`,key:`1jrz19`}],[`path`,{d:`M12 16h4`,key:`n85exb`}],[`path`,{d:`M8 11h.01`,key:`1dfujw`}],[`path`,{d:`M8 16h.01`,key:`18s6g9`}]]),it=o(`clock`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 6v6l4 2`,key:`mmk7yg`}]]),at=o(`cloud-cog`,[[`path`,{d:`m10.852 19.772-.383.924`,key:`r7sl7d`}],[`path`,{d:`m13.148 14.228.383-.923`,key:`1d5zpm`}],[`path`,{d:`M13.148 19.772a3 3 0 1 0-2.296-5.544l-.383-.923`,key:`1ydik7`}],[`path`,{d:`m13.53 20.696-.382-.924a3 3 0 1 1-2.296-5.544`,key:`1m1vsf`}],[`path`,{d:`m14.772 15.852.923-.383`,key:`660p6e`}],[`path`,{d:`m14.772 18.148.923.383`,key:`hrcpis`}],[`path`,{d:`M4.2 15.1a7 7 0 1 1 9.93-9.858A7 7 0 0 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.2`,key:`j2q98n`}],[`path`,{d:`m9.228 15.852-.923-.383`,key:`1p9ong`}],[`path`,{d:`m9.228 18.148-.923.383`,key:`6558rz`}]]),ot=o(`cloud`,[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]]),st=o(`code-xml`,[[`path`,{d:`m18 16 4-4-4-4`,key:`1inbqp`}],[`path`,{d:`m6 8-4 4 4 4`,key:`15zrgr`}],[`path`,{d:`m14.5 4-5 16`,key:`e7oirm`}]]),ct=o(`compass`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z`,key:`9ktpf1`}]]),lt=o(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),ut=o(`corner-down-left`,[[`path`,{d:`M20 4v7a4 4 0 0 1-4 4H4`,key:`6o5b7l`}],[`path`,{d:`m9 10-5 5 5 5`,key:`1kshq7`}]]),dt=o(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),ft=o(`ellipsis`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`19`,cy:`12`,r:`1`,key:`1wjl8i`}],[`circle`,{cx:`5`,cy:`12`,r:`1`,key:`1pcz8c`}]]),pt=o(`file-text`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]),mt=o(`fingerprint-pattern`,[[`path`,{d:`M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4`,key:`1nerag`}],[`path`,{d:`M14 13.12c0 2.38 0 6.38-1 8.88`,key:`o46ks0`}],[`path`,{d:`M17.29 21.02c.12-.6.43-2.3.5-3.02`,key:`ptglia`}],[`path`,{d:`M2 12a10 10 0 0 1 18-6`,key:`ydlgp0`}],[`path`,{d:`M2 16h.01`,key:`1gqxmh`}],[`path`,{d:`M21.8 16c.2-2 .131-5.354 0-6`,key:`drycrb`}],[`path`,{d:`M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2`,key:`1tidbn`}],[`path`,{d:`M8.65 22c.21-.66.45-1.32.57-2`,key:`13wd9y`}],[`path`,{d:`M9 6.8a6 6 0 0 1 9 5.2v2`,key:`1fr1j5`}]]),ht=o(`globe`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]]),gt=o(`graduation-cap`,[[`path`,{d:`M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z`,key:`j76jl0`}],[`path`,{d:`M22 10v6`,key:`1lu8f3`}],[`path`,{d:`M6 12.5V16a6 3 0 0 0 12 0v-3.5`,key:`1r8lef`}]]),_t=o(`hand`,[[`path`,{d:`M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2`,key:`1fvzgz`}],[`path`,{d:`M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2`,key:`1kc0my`}],[`path`,{d:`M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8`,key:`10h0bg`}],[`path`,{d:`M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15`,key:`1s1gnw`}]]),vt=o(`heart`,[[`path`,{d:`M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5`,key:`mvr1a0`}]]),yt=o(`house`,[[`path`,{d:`M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8`,key:`5wwlr5`}],[`path`,{d:`M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`r6nss1`}]]),bt=o(`info`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 16v-4`,key:`1dtifu`}],[`path`,{d:`M12 8h.01`,key:`e9boi3`}]]),F=o(`key-round`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),xt=o(`layout-grid`,[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`3`,rx:`1`,key:`6d4xhi`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`14`,rx:`1`,key:`nxv5o0`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}]]),St=o(`lightbulb`,[[`path`,{d:`M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5`,key:`1gvzjb`}],[`path`,{d:`M9 18h6`,key:`x1upvd`}],[`path`,{d:`M10 22h4`,key:`ceow96`}]]),Ct=o(`log-in`,[[`path`,{d:`m10 17 5-5-5-5`,key:`1bsop3`}],[`path`,{d:`M15 12H3`,key:`6jk70r`}],[`path`,{d:`M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4`,key:`u53s6r`}]]),wt=o(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),Tt=o(`mail`,[[`path`,{d:`m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7`,key:`132q7q`}],[`rect`,{x:`2`,y:`4`,width:`20`,height:`16`,rx:`2`,key:`izxlao`}]]),Et=o(`map-pin`,[[`path`,{d:`M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0`,key:`1r0f0z`}],[`circle`,{cx:`12`,cy:`10`,r:`3`,key:`ilqhr7`}]]),Dt=o(`menu`,[[`path`,{d:`M4 5h16`,key:`1tepv9`}],[`path`,{d:`M4 12h16`,key:`1lakjw`}],[`path`,{d:`M4 19h16`,key:`1djgab`}]]),Ot=o(`message-circle-question-mark`,[[`path`,{d:`M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719`,key:`1sd12s`}],[`path`,{d:`M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3`,key:`1u773s`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),kt=o(`message-circle`,[[`path`,{d:`M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719`,key:`1sd12s`}]]),At=o(`message-square-plus`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M12 8v6`,key:`1ib9pf`}],[`path`,{d:`M9 11h6`,key:`1fldmi`}]]),jt=o(`message-square-text`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M7 11h10`,key:`1twpyw`}],[`path`,{d:`M7 15h6`,key:`d9of3u`}],[`path`,{d:`M7 7h8`,key:`af5zfr`}]]),Mt=o(`message-square`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]),Nt=o(`mic-off`,[[`path`,{d:`M12 19v3`,key:`npa21l`}],[`path`,{d:`M15 9.34V5a3 3 0 0 0-5.68-1.33`,key:`1gzdoj`}],[`path`,{d:`M16.95 16.95A7 7 0 0 1 5 12v-2`,key:`cqa7eg`}],[`path`,{d:`M18.89 13.23A7 7 0 0 0 19 12v-2`,key:`16hl24`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M9 9v3a3 3 0 0 0 5.12 2.12`,key:`r2i35w`}]]),Pt=o(`mic`,[[`path`,{d:`M12 19v3`,key:`npa21l`}],[`path`,{d:`M19 10v2a7 7 0 0 1-14 0v-2`,key:`1vc78b`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`13`,rx:`3`,key:`s6n7sd`}]]),Ft=o(`monitor-smartphone`,[[`path`,{d:`M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8`,key:`10dyio`}],[`path`,{d:`M10 19v-3.96 3.15`,key:`1irgej`}],[`path`,{d:`M7 19h5`,key:`qswx4l`}],[`rect`,{width:`6`,height:`10`,x:`16`,y:`12`,rx:`2`,key:`1egngj`}]]),It=o(`monitor`,[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]),Lt=o(`moon`,[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]),Rt=o(`mouse-pointer-click`,[[`path`,{d:`M14 4.1 12 6`,key:`ita8i4`}],[`path`,{d:`m5.1 8-2.9-.8`,key:`1go3kf`}],[`path`,{d:`m6 12-1.9 2`,key:`mnht97`}],[`path`,{d:`M7.2 2.2 8 5.1`,key:`1cfko1`}],[`path`,{d:`M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z`,key:`s0h3yz`}]]),zt=o(`newspaper`,[[`path`,{d:`M15 18h-5`,key:`95g1m2`}],[`path`,{d:`M18 14h-8`,key:`sponae`}],[`path`,{d:`M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2`,key:`39pd36`}],[`rect`,{width:`8`,height:`4`,x:`10`,y:`6`,rx:`1`,key:`aywv1n`}]]),Bt=o(`palette`,[[`path`,{d:`M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z`,key:`e79jfc`}],[`circle`,{cx:`13.5`,cy:`6.5`,r:`.5`,fill:`currentColor`,key:`1okk4w`}],[`circle`,{cx:`17.5`,cy:`10.5`,r:`.5`,fill:`currentColor`,key:`f64h9f`}],[`circle`,{cx:`6.5`,cy:`12.5`,r:`.5`,fill:`currentColor`,key:`qy21gx`}],[`circle`,{cx:`8.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`fotxhn`}]]),Vt=o(`pencil`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}],[`path`,{d:`m15 5 4 4`,key:`1mk7zo`}]]),Ht=o(`phone`,[[`path`,{d:`M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384`,key:`9njp5v`}]]),Ut=o(`piggy-bank`,[[`path`,{d:`M11 17h3v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3a3.16 3.16 0 0 0 2-2h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-1a5 5 0 0 0-2-4V3a4 4 0 0 0-3.2 1.6l-.3.4H11a6 6 0 0 0-6 6v1a5 5 0 0 0 2 4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1z`,key:`1piglc`}],[`path`,{d:`M16 10h.01`,key:`1m94wz`}],[`path`,{d:`M2 8v1a2 2 0 0 0 2 2h1`,key:`1env43`}]]),Wt=o(`play`,[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]),Gt=o(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),I=o(`repeat`,[[`path`,{d:`m17 2 4 4-4 4`,key:`nntrym`}],[`path`,{d:`M3 11v-1a4 4 0 0 1 4-4h14`,key:`84bu3i`}],[`path`,{d:`m7 22-4-4 4-4`,key:`1wqhfi`}],[`path`,{d:`M21 13v1a4 4 0 0 1-4 4H3`,key:`1rx37r`}]]),Kt=o(`search`,[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]),qt=o(`send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),Jt=o(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),Yt=o(`share-2`,[[`circle`,{cx:`18`,cy:`5`,r:`3`,key:`gq8acd`}],[`circle`,{cx:`6`,cy:`12`,r:`3`,key:`w7nqdw`}],[`circle`,{cx:`18`,cy:`19`,r:`3`,key:`1xt0gg`}],[`line`,{x1:`8.59`,x2:`15.42`,y1:`13.51`,y2:`17.49`,key:`47mynk`}],[`line`,{x1:`15.41`,x2:`8.59`,y1:`6.51`,y2:`10.49`,key:`1n3mei`}]]),Xt=o(`shield-alert`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]]),Zt=o(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),Qt=o(`shield-off`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71`,key:`1jlk70`}],[`path`,{d:`M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264`,key:`18rp1v`}]]),$t=o(`sliders-horizontal`,[[`path`,{d:`M10 5H3`,key:`1qgfaw`}],[`path`,{d:`M12 19H3`,key:`yhmn1j`}],[`path`,{d:`M14 3v4`,key:`1sua03`}],[`path`,{d:`M16 17v4`,key:`1q0r14`}],[`path`,{d:`M21 12h-9`,key:`1o4lsq`}],[`path`,{d:`M21 19h-5`,key:`1rlt1p`}],[`path`,{d:`M21 5h-7`,key:`1oszz2`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M8 12H3`,key:`a7s4jb`}]]),en=o(`smartphone`,[[`rect`,{width:`14`,height:`20`,x:`5`,y:`2`,rx:`2`,ry:`2`,key:`1yt0o3`}],[`path`,{d:`M12 18h.01`,key:`mhygvu`}]]),tn=o(`sparkles`,[[`path`,{d:`M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z`,key:`1s2grr`}],[`path`,{d:`M20 2v4`,key:`1rf3ol`}],[`path`,{d:`M22 4h-4`,key:`gwowj6`}],[`circle`,{cx:`4`,cy:`20`,r:`2`,key:`6kqj1y`}]]),nn=o(`square-check-big`,[[`path`,{d:`M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.344`,key:`2acyp4`}],[`path`,{d:`m9 11 3 3L22 4`,key:`1pflzl`}]]),rn=o(`square-pen`,[[`path`,{d:`M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7`,key:`1m0v6g`}],[`path`,{d:`M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z`,key:`ohrbg2`}]]),an=o(`square-terminal`,[[`path`,{d:`m7 11 2-2-2-2`,key:`1lz0vl`}],[`path`,{d:`M11 13h4`,key:`1p7l4v`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}]]),on=o(`square`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}]]),sn=o(`table-2`,[[`path`,{d:`M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18`,key:`gugj83`}]]),cn=o(`tag`,[[`path`,{d:`M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z`,key:`vktsd0`}],[`circle`,{cx:`7.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`kqv944`}]]),ln=o(`target`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`circle`,{cx:`12`,cy:`12`,r:`6`,key:`1vlfrh`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}]]),un=o(`terminal`,[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]),dn=o(`trash-2`,[[`path`,{d:`M10 11v6`,key:`nco0om`}],[`path`,{d:`M14 11v6`,key:`outv1u`}],[`path`,{d:`M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6`,key:`miytrc`}],[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2`,key:`e791ji`}]]),fn=o(`triangle-alert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),pn=o(`user-round`,[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]]),mn=o(`users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]),hn=o(`wand-sparkles`,[[`path`,{d:`m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72`,key:`ul74o6`}],[`path`,{d:`m14 7 3 3`,key:`1r5n42`}],[`path`,{d:`M5 6v4`,key:`ilb8ba`}],[`path`,{d:`M19 14v4`,key:`blhpug`}],[`path`,{d:`M10 2v2`,key:`7u0qdc`}],[`path`,{d:`M7 8H3`,key:`zfb6yr`}],[`path`,{d:`M21 16h-4`,key:`1cnmox`}],[`path`,{d:`M11 3H9`,key:`1obp7u`}]]),gn=o(`webhook`,[[`path`,{d:`M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2`,key:`q3hayz`}],[`path`,{d:`m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06`,key:`1go1hn`}],[`path`,{d:`m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8`,key:`qlwsc0`}]]),_n=o(`wifi-off`,[[`path`,{d:`M12 20h.01`,key:`zekei9`}],[`path`,{d:`M8.5 16.429a5 5 0 0 1 7 0`,key:`1bycff`}],[`path`,{d:`M5 12.859a10 10 0 0 1 5.17-2.69`,key:`1dl1wf`}],[`path`,{d:`M19 12.859a10 10 0 0 0-2.007-1.523`,key:`4k23kn`}],[`path`,{d:`M2 8.82a15 15 0 0 1 4.177-2.643`,key:`1grhjp`}],[`path`,{d:`M22 8.82a15 15 0 0 0-11.288-3.764`,key:`z3jwby`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]),vn=o(`wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z`,key:`1ngwbx`}]]),L=p(x(),1),yn=p(He(),1),R=C();function bn({size:e=72,className:t}){return(0,R.jsx)(`img`,{src:`/icon.svg`,alt:`nanoMuse`,width:e,height:e,draggable:!1,className:i(`block shrink-0 select-none`,t),style:{width:e,height:e}})}function xn(){return window.nanomuseDesktop??null}function Sn(){return!!window.nanomuseDesktop}function Cn(){let e=xn();e&&(document.documentElement.classList.add(`desktop`),document.documentElement.dataset.platform=e.platform)}var wn=`nm.desktop.remote_hint`;function Tn(){let{state:e,setTab:t}=E(),n=s(),[r,i]=(0,L.useState)(()=>!Sn()||d(wn)===`1`),a=e.hub,o=!r&&!!a&&a.state===`connected`&&a.remote_control&&a.account.signed_in;if((0,L.useEffect)(()=>{o&&f(wn,`1`)},[o]),!o)return null;let c=()=>i(!0);return(0,R.jsxs)(`div`,{className:`flex items-center gap-2.5 border-b border-border bg-accent/10 px-4 py-2 text-[12.5px] text-fg`,children:[(0,R.jsx)(Ft,{size:16,className:`shrink-0 text-accent`}),(0,R.jsx)(`span`,{className:`min-w-0 flex-1`,children:n(`Your phone can now operate this computer: run commands, fetch files, hand over whole tasks. Each risky step still asks here first.`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>{c(),t(`devices`)},className:`shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-[12px] font-medium hover:bg-surface-2`,children:n(`Devices`)}),(0,R.jsx)(`button`,{type:`button`,onClick:c,"aria-label":n(`Dismiss`),className:`shrink-0 rounded-full p-1 text-muted hover:text-fg`,children:(0,R.jsx)(Pe,{size:15})})]})}var En=`a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])`;function Dn(e,t){(0,L.useEffect)(()=>{if(!t)return;let n=e.current;if(!n)return;let r=document.activeElement,i=()=>Array.from(n.querySelectorAll(En)).filter(e=>e.offsetParent!==null||e===document.activeElement);if(!n.contains(document.activeElement)){let e=i()[0];e?e.focus():(n.tabIndex=-1,n.focus())}let a=e=>{if(e.key!==`Tab`)return;let t=i();if(t.length===0){e.preventDefault();return}let r=t[0],a=t[t.length-1],o=document.activeElement;e.shiftKey&&(o===r||!n.contains(o))?(e.preventDefault(),a.focus()):!e.shiftKey&&(o===a||!n.contains(o))&&(e.preventDefault(),r.focus())};return document.addEventListener(`keydown`,a,!0),()=>{document.removeEventListener(`keydown`,a,!0),r&&r.isConnected&&typeof r.focus==`function`&&r.focus()}},[e,t])}function On(){let{state:e,setDrawer:t,setTab:n,openThread:r,toast:i,dispatch:a}=E(),o=s(),c=e.drawer,[l,u]=(0,L.useState)(``),[d,f]=(0,L.useState)(null),p=(0,L.useRef)(null);Dn(p,c),(0,L.useEffect)(()=>{if(!c)return;let e=e=>e.key===`Escape`&&t(!1);return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[c,t]),(0,L.useEffect)(()=>{c||(u(``),f(null))},[c]);let m=e.profile?.name||`nanoMuse`,h=(0,L.useMemo)(()=>{let t=l.trim().toLowerCase();return e.threads.filter(e=>e.id!==`main`).filter(e=>!t||(e.title||``).toLowerCase().includes(t)).sort((e,t)=>(t.updated_at||``).localeCompare(e.updated_at||``))},[e.threads,l]),g=(e.hub?.devices??[]).filter(e=>!e.this&&e.kind!==`web`),_=g.filter(e=>e.online).length,v=e.hub?.state===`connected`,y=g.some(e=>e.online&&e.kind===`computer`),b=e.tab===`chat`,x=e=>{r(e),n(`chat`),t(!1)},S=e=>{n(e),t(!1)},C=async()=>{try{let e=await M.createThread(o(`New chat`));a({type:`ws`,msg:{kind:`thread`,thread:e}}),x(e.id)}catch(e){i(e.message)}},w=async t=>{try{await M.deleteThread(t),a({type:`ws`,msg:{kind:`thread_deleted`,thread:t}}),e.activeThread===t&&r(`main`)}catch(e){i(e.message)}finally{f(null)}},T=t=>e.hub?.devices.find(e=>e.id===t)?.kind??`phone`,D=v?_===0?o(`Only this one`):o(`{n} online`,{n:_}):o(`Off`);return c?(0,R.jsxs)(`div`,{className:`fixed inset-0 z-50`,children:[(0,R.jsx)(`div`,{className:`rise absolute inset-0 bg-black/40`,onClick:()=>t(!1)}),(0,R.jsxs)(`nav`,{ref:p,role:`dialog`,"aria-modal":`true`,"aria-label":o(`Chats`),className:`drawer-in safe-top safe-bottom absolute inset-y-0 left-0 flex w-[82vw] max-w-[320px] flex-col bg-bg shadow-2xl`,children:[(0,R.jsx)(`div`,{className:`px-5 pt-5 pb-2 text-[24px] font-bold leading-7 tracking-tight`,children:m}),(0,R.jsx)(kn,{icon:(0,R.jsx)(yt,{size:20}),label:o(`Main chat`),selected:b&&e.activeThread===`main`,onClick:()=>x(`main`)}),(0,R.jsx)(kn,{icon:(0,R.jsx)(Ft,{size:20}),label:o(`Devices`),detail:D,selected:e.tab===`devices`,onClick:()=>S(`devices`)}),y&&(0,R.jsx)(kn,{icon:(0,R.jsx)(un,{size:20}),label:o(`Coding agents`),selected:e.tab===`coding`,onClick:()=>S(`coding`)}),(0,R.jsx)(`div`,{className:`mt-3 px-5 pb-1`,children:(0,R.jsx)(`span`,{className:`text-[14px] font-medium text-muted`,children:o(`Side chats`)})}),h.length===0&&!l?(0,R.jsxs)(`div`,{className:`flex flex-1 flex-col items-center justify-center px-8 text-center`,children:[(0,R.jsx)(Mt,{size:28,className:`mb-3 text-muted/70`}),(0,R.jsx)(`div`,{className:`text-[17px] font-semibold`,children:o(`Start a side chat`)}),(0,R.jsx)(`div`,{className:`mt-1 text-[13px] leading-snug text-muted`,children:o(`Side chats are an optional way to keep conversations organised by topic.`)})]}):(0,R.jsxs)(`ul`,{className:`min-h-0 flex-1 overflow-y-auto py-1`,children:[h.map(t=>(0,R.jsx)(An,{thread:t,kind:t.device?T(t.device):void 0,selected:b&&e.activeThread===t.id,confirming:d===t.id,onPick:()=>x(t.id),onAskDelete:()=>f(t.id),onDelete:()=>void w(t.id),onKeep:()=>f(null)},t.id)),h.length===0&&(0,R.jsx)(`li`,{className:`px-5 py-3 text-[13px] text-muted`,children:o(`No chats match.`)})]}),(0,R.jsxs)(`div`,{className:`flex items-center gap-2 border-t border-border px-3 pt-2 pb-2`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>S(`you`),"aria-label":o(`Settings`),className:`flex h-10 w-10 items-center justify-center rounded-full text-fg hover:bg-surface-2`,children:(0,R.jsx)(Jt,{size:20})}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>S(`memory`),"aria-label":o(`Memory`),className:`-ml-1 flex h-10 w-10 items-center justify-center rounded-full text-fg hover:bg-surface-2`,children:(0,R.jsx)(pt,{size:20})}),(0,R.jsxs)(`label`,{className:`flex min-w-0 flex-1 items-center gap-2 rounded-full bg-surface-2 px-3 py-2`,children:[(0,R.jsx)(Kt,{size:16,className:`shrink-0 text-muted`}),(0,R.jsx)(`input`,{value:l,onChange:e=>u(e.target.value),placeholder:o(`Search`),className:`min-w-0 flex-1 bg-transparent text-[15px] outline-none placeholder:text-muted`}),l&&(0,R.jsx)(`button`,{type:`button`,onClick:()=>u(``),"aria-label":o(`Clear`),className:`text-muted`,children:(0,R.jsx)(Pe,{size:14})})]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void C(),"aria-label":o(`New side chat`),className:`flex h-10 w-10 items-center justify-center rounded-full text-fg hover:bg-surface-2`,children:(0,R.jsx)(rn,{size:20})})]})]})]}):null}function kn({icon:e,label:t,detail:n,selected:r,onClick:a}){return(0,R.jsxs)(`button`,{type:`button`,onClick:a,className:i(`mx-2 flex items-center gap-3 rounded-2xl px-3 py-2.5 text-left`,r?`bg-surface-2`:`hover:bg-surface-2/60`),children:[(0,R.jsx)(`span`,{className:`text-fg`,children:e}),(0,R.jsx)(`span`,{className:`min-w-0 flex-1 truncate text-[16px]`,children:t}),n&&(0,R.jsx)(`span`,{className:`shrink-0 text-[13px] text-muted`,children:n})]})}function An({thread:e,kind:t,selected:n,confirming:r,onPick:a,onAskDelete:o,onDelete:c,onKeep:l}){let u=s();return r?(0,R.jsxs)(`li`,{className:`mx-2 flex items-center gap-2 rounded-2xl bg-surface-2 px-3 py-2`,children:[(0,R.jsx)(`span`,{className:`min-w-0 flex-1 truncate text-[13.5px]`,children:u(`Delete this side chat and its history?`)}),(0,R.jsx)(`button`,{type:`button`,onClick:c,className:`rounded-full bg-rose-500 px-3 py-1 text-[12.5px] font-semibold text-white`,children:u(`Delete`)}),(0,R.jsx)(`button`,{type:`button`,onClick:l,className:`rounded-full px-2 py-1 text-[12.5px] text-muted`,children:u(`Keep`)})]}):(0,R.jsxs)(`li`,{className:i(`group mx-2 flex items-center gap-2 rounded-2xl px-3`,n?`bg-surface-2`:`hover:bg-surface-2/60`),children:[(0,R.jsxs)(`button`,{type:`button`,onClick:a,className:`flex min-w-0 flex-1 items-center gap-2 py-2.5 text-left`,children:[e.device?(0,R.jsx)(`span`,{className:`shrink-0 text-accent`,children:t===`computer`?(0,R.jsx)(It,{size:15}):(0,R.jsx)(en,{size:15})}):e.remote_from?(0,R.jsx)(Ft,{size:15,className:`shrink-0 text-muted`}):null,(0,R.jsx)(`span`,{className:`min-w-0 flex-1 truncate text-[15px]`,children:e.title||u(`New chat`)}),e.busy&&(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-amber-400`}),(0,R.jsx)(`span`,{className:`shrink-0 text-[12px] text-muted`,children:jn(e.updated_at)})]}),(0,R.jsx)(`button`,{type:`button`,"aria-label":u(`Delete`),onClick:o,className:`p-1.5 text-muted/70 opacity-0 hover:text-rose-500 focus:opacity-100 group-hover:opacity-100`,children:(0,R.jsx)(dn,{size:15})})]})}function jn(e,t=new Date){if(!e)return``;let n=new Date(e);if(Number.isNaN(n.getTime()))return``;let r=t.getTime()-n.getTime();return n.toDateString()===t.toDateString()?n.toLocaleTimeString(m(),{hour:`2-digit`,minute:`2-digit`}):r<6048e5?n.toLocaleDateString(m(),{weekday:`short`}):n.toLocaleDateString(m(),{month:`short`,day:`numeric`})}function Mn(e,t){let n=t||{};return(e[e.length-1]===``?[...e,``]:e).join((n.padRight?` `:``)+`,`+(n.padLeft===!1?``:` `)).trim()}var Nn=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Pn=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Fn={};function In(e,t){return((t||Fn).jsx?Pn:Nn).test(e)}var Ln=/[ \t\n\f\r]/g;function Rn(e){return typeof e==`object`?e.type===`text`&&zn(e.value):zn(e)}function zn(e){return e.replace(Ln,``)===``}var Bn=class{constructor(e,t,n){this.normal=t,this.property=e,n&&(this.space=n)}};Bn.prototype.normal={},Bn.prototype.property={},Bn.prototype.space=void 0;function Vn(e,t){let n={},r={};for(let t of e)Object.assign(n,t.property),Object.assign(r,t.normal);return new Bn(n,r,t)}function Hn(e){return e.toLowerCase()}var Un=class{constructor(e,t){this.attribute=t,this.property=e}};Un.prototype.attribute=``,Un.prototype.booleanish=!1,Un.prototype.boolean=!1,Un.prototype.commaOrSpaceSeparated=!1,Un.prototype.commaSeparated=!1,Un.prototype.defined=!1,Un.prototype.mustUseProperty=!1,Un.prototype.number=!1,Un.prototype.overloadedBoolean=!1,Un.prototype.property=``,Un.prototype.spaceSeparated=!1,Un.prototype.space=void 0;var Wn=S({boolean:()=>z,booleanish:()=>Kn,commaOrSpaceSeparated:()=>Yn,commaSeparated:()=>Jn,number:()=>B,overloadedBoolean:()=>qn,spaceSeparated:()=>V}),Gn=0,z=Xn(),Kn=Xn(),qn=Xn(),B=Xn(),V=Xn(),Jn=Xn(),Yn=Xn();function Xn(){return 2**++Gn}var Zn=Object.keys(Wn),Qn=class extends Un{constructor(e,t,n,r){let i=-1;if(super(e,t),$n(this,`space`,r),typeof n==`number`)for(;++i4&&n.slice(0,4)===`data`&&fr.test(t)){if(t.charAt(4)===`-`){let e=t.slice(5).replace(dr,hr);r=`data`+e.charAt(0).toUpperCase()+e.slice(1)}else{let e=t.slice(4);if(!dr.test(e)){let n=e.replace(ur,mr);n.charAt(0)!==`-`&&(n=`-`+n),t=`data`+n}}i=Qn}return new i(r,t)}function mr(e){return`-`+e.toLowerCase()}function hr(e){return e.charAt(1).toUpperCase()}var gr=Vn([tr,ir,or,sr,cr],`html`),_r=Vn([tr,ar,or,sr,cr],`svg`);function vr(e){return e.join(` `).trim()}var yr=b(((e,t)=>{var n=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,r=/\n/g,i=/^\s*/,a=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,o=/^:\s*/,s=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,c=/^[;\s]*/,l=/^\s+|\s+$/g;function u(e,t){if(typeof e!=`string`)throw TypeError(`First argument must be a string`);if(!e)return[];t||={};var l=1,u=1;function f(e){var t=e.match(r);t&&(l+=t.length);var n=e.lastIndexOf(` -`);u=~n?e.length-n:u+e.length}function p(){var e={line:l,column:u};return function(t){return t.position=new m(e),_(),t}}function m(e){this.start=e,this.end={line:l,column:u},this.source=t.source}m.prototype.content=e;function h(n){var r=Error(t.source+`:`+l+`:`+u+`: `+n);if(r.reason=n,r.filename=t.source,r.line=l,r.column=u,r.source=e,!t.silent)throw r}function g(t){var n=t.exec(e);if(n){var r=n[0];return f(r),e=e.slice(r.length),n}}function _(){g(i)}function v(e){var t;for(e||=[];t=y();)t!==!1&&e.push(t);return e}function y(){var t=p();if(e.charAt(0)==`/`&&e.charAt(1)==`*`){for(var n=2;e.charAt(n)!=``&&(e.charAt(n)!=`*`||e.charAt(n+1)!=`/`);)++n;if(n+=2,e.charAt(n-1)===``)return h(`End of comment missing`);var r=e.slice(2,n-2);return u+=2,f(r),e=e.slice(n),u+=2,t({type:`comment`,comment:r})}}function b(){var e=p(),t=g(a);if(t){if(y(),!g(o))return h(`property missing ':'`);var r=g(s),i=e({type:`declaration`,property:d(t[0].replace(n,``)),value:r?d(r[0].replace(n,``)):``});return g(c),i}}function x(){var e=[];v(e);for(var t;t=b();)t!==!1&&(e.push(t),v(e));return e}return _(),x()}function d(e){return e?e.replace(l,``):``}t.exports=u})),br=b((e=>{var t=e&&e.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(e,"__esModule",{value:!0}),e.default=r;var n=t(yr());function r(e,t){let r=null;if(!e||typeof e!=`string`)return r;let i=(0,n.default)(e),a=typeof t==`function`;return i.forEach(e=>{if(e.type!==`declaration`)return;let{property:n,value:i}=e;a?t(n,i,e):i&&(r||={},r[n]=i)}),r}})),xr=b((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.camelCase=void 0;var t=/^--[a-zA-Z0-9_-]+$/,n=/-([a-z])/g,r=/^[^-]+$/,i=/^-(webkit|moz|ms|o|khtml)-/,a=/^-(ms)-/,o=function(e){return!e||r.test(e)||t.test(e)},s=function(e,t){return t.toUpperCase()},c=function(e,t){return`${t}-`};e.camelCase=function(e,t){return t===void 0&&(t={}),o(e)?e:(e=e.toLowerCase(),e=t.reactCompat?e.replace(a,c):e.replace(i,c),e.replace(n,s))}})),Sr=b(((e,t)=>{var n=(e&&e.__importDefault||function(e){return e&&e.__esModule?e:{default:e}})(br()),r=xr();function i(e,t){var i={};return!e||typeof e!=`string`||(0,n.default)(e,function(e,n){e&&n&&(i[(0,r.camelCase)(e,t)]=n)}),i}i.default=i,t.exports=i})),Cr=Tr(`end`),wr=Tr(`start`);function Tr(e){return t;function t(t){let n=t&&t.position&&t.position[e]||{};if(typeof n.line==`number`&&n.line>0&&typeof n.column==`number`&&n.column>0)return{line:n.line,column:n.column,offset:typeof n.offset==`number`&&n.offset>-1?n.offset:void 0}}}function Er(e){let t=wr(e),n=Cr(e);if(t&&n)return{start:t,end:n}}function Dr(e){return!e||typeof e!=`object`?``:`position`in e||`type`in e?kr(e.position):`start`in e||`end`in e?kr(e):`line`in e||`column`in e?Or(e):``}function Or(e){return Ar(e&&e.line)+`:`+Ar(e&&e.column)}function kr(e){return Or(e&&e.start)+`-`+Or(e&&e.end)}function Ar(e){return e&&typeof e==`number`?e:1}var jr=class extends Error{constructor(e,t,n){super(),typeof t==`string`&&(n=t,t=void 0);let r=``,i={},a=!1;if(t&&(i=`line`in t&&`column`in t||`start`in t&&`end`in t?{place:t}:`type`in t?{ancestors:[t],place:t.position}:{...t}),typeof e==`string`?r=e:!i.cause&&e&&(a=!0,r=e.message,i.cause=e),!i.ruleId&&!i.source&&typeof n==`string`){let e=n.indexOf(`:`);e===-1?i.ruleId=n:(i.source=n.slice(0,e),i.ruleId=n.slice(e+1))}if(!i.place&&i.ancestors&&i.ancestors){let e=i.ancestors[i.ancestors.length-1];e&&(i.place=e.position)}let o=i.place&&`start`in i.place?i.place.start:i.place;this.ancestors=i.ancestors||void 0,this.cause=i.cause||void 0,this.column=o?o.column:void 0,this.fatal=void 0,this.file=``,this.message=r,this.line=o?o.line:void 0,this.name=Dr(i.place)||`1:1`,this.place=i.place||void 0,this.reason=this.message,this.ruleId=i.ruleId||void 0,this.source=i.source||void 0,this.stack=a&&i.cause&&typeof i.cause.stack==`string`?i.cause.stack:``,this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}};jr.prototype.file=``,jr.prototype.name=``,jr.prototype.reason=``,jr.prototype.message=``,jr.prototype.stack=``,jr.prototype.column=void 0,jr.prototype.line=void 0,jr.prototype.ancestors=void 0,jr.prototype.cause=void 0,jr.prototype.fatal=void 0,jr.prototype.place=void 0,jr.prototype.ruleId=void 0,jr.prototype.source=void 0;var Mr=p(Sr(),1),Nr={}.hasOwnProperty,Pr=new Map,Fr=/[A-Z]/g,Ir=new Set([`table`,`tbody`,`thead`,`tfoot`,`tr`]),Lr=new Set([`td`,`th`]),Rr=`https://github.com/syntax-tree/hast-util-to-jsx-runtime`;function zr(e,t){if(!t||t.Fragment===void 0)throw TypeError("Expected `Fragment` in options");let n=t.filePath||void 0,r;if(t.development){if(typeof t.jsxDEV!=`function`)throw TypeError("Expected `jsxDEV` in options when `development: true`");r=Xr(n,t.jsxDEV)}else{if(typeof t.jsx!=`function`)throw TypeError("Expected `jsx` in production options");if(typeof t.jsxs!=`function`)throw TypeError("Expected `jsxs` in production options");r=Yr(n,t.jsx,t.jsxs)}let i={Fragment:t.Fragment,ancestors:[],components:t.components||{},create:r,elementAttributeNameCase:t.elementAttributeNameCase||`react`,evaluater:t.createEvaluater?t.createEvaluater():void 0,filePath:n,ignoreInvalidStyle:t.ignoreInvalidStyle||!1,passKeys:t.passKeys!==!1,passNode:t.passNode||!1,schema:t.space===`svg`?_r:gr,stylePropertyNameCase:t.stylePropertyNameCase||`dom`,tableCellAlignToStyle:t.tableCellAlignToStyle!==!1},a=Br(i,e,void 0);return a&&typeof a!=`string`?a:i.create(e,i.Fragment,{children:a||void 0},void 0)}function Br(e,t,n){if(t.type===`element`)return Vr(e,t,n);if(t.type===`mdxFlowExpression`||t.type===`mdxTextExpression`)return Hr(e,t);if(t.type===`mdxJsxFlowElement`||t.type===`mdxJsxTextElement`)return Wr(e,t,n);if(t.type===`mdxjsEsm`)return Ur(e,t);if(t.type===`root`)return Gr(e,t,n);if(t.type===`text`)return Kr(e,t)}function Vr(e,t,n){let r=e.schema,i=r;t.tagName.toLowerCase()===`svg`&&r.space===`html`&&(i=_r,e.schema=i),e.ancestors.push(t);let a=ni(e,t.tagName,!1),o=Zr(e,t),s=$r(e,t);return Ir.has(t.tagName)&&(s=s.filter(function(e){return typeof e!=`string`||!Rn(e)})),qr(e,o,a,t),Jr(o,s),e.ancestors.pop(),e.schema=r,e.create(t,a,o,n)}function Hr(e,t){if(t.data&&t.data.estree&&e.evaluater){let n=t.data.estree.body[0];return n.type,e.evaluater.evaluateExpression(n.expression)}ri(e,t.position)}function Ur(e,t){if(t.data&&t.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(t.data.estree);ri(e,t.position)}function Wr(e,t,n){let r=e.schema,i=r;t.name===`svg`&&r.space===`html`&&(i=_r,e.schema=i),e.ancestors.push(t);let a=t.name===null?e.Fragment:ni(e,t.name,!0),o=Qr(e,t),s=$r(e,t);return qr(e,o,a,t),Jr(o,s),e.ancestors.pop(),e.schema=r,e.create(t,a,o,n)}function Gr(e,t,n){let r={};return Jr(r,$r(e,t)),e.create(t,e.Fragment,r,n)}function Kr(e,t){return t.value}function qr(e,t,n,r){typeof n!=`string`&&n!==e.Fragment&&e.passNode&&(t.node=r)}function Jr(e,t){if(t.length>0){let n=t.length>1?t:t[0];n&&(e.children=n)}}function Yr(e,t,n){return r;function r(e,r,i,a){let o=Array.isArray(i.children)?n:t;return a?o(r,i,a):o(r,i)}}function Xr(e,t){return n;function n(n,r,i,a){let o=Array.isArray(i.children),s=wr(n);return t(r,i,a,o,{columnNumber:s?s.column-1:void 0,fileName:e,lineNumber:s?s.line:void 0},void 0)}}function Zr(e,t){let n={},r,i;for(i in t.properties)if(i!==`children`&&Nr.call(t.properties,i)){let a=ei(e,i,t.properties[i]);if(a){let[i,o]=a;e.tableCellAlignToStyle&&i===`align`&&typeof o==`string`&&Lr.has(t.tagName)?r=o:n[i]=o}}if(r){let t=n.style||={};t[e.stylePropertyNameCase===`css`?`text-align`:`textAlign`]=r}return n}function Qr(e,t){let n={};for(let r of t.attributes)if(r.type===`mdxJsxExpressionAttribute`){if(r.data&&r.data.estree&&e.evaluater){let t=r.data.estree.body[0];t.type;let i=t.expression;i.type;let a=i.properties[0];a.type,Object.assign(n,e.evaluater.evaluateExpression(a.argument))}else ri(e,t.position)}else{let i=r.name,a;if(r.value&&typeof r.value==`object`){if(r.value.data&&r.value.data.estree&&e.evaluater){let t=r.value.data.estree.body[0];t.type,a=e.evaluater.evaluateExpression(t.expression)}else ri(e,t.position)}else a=r.value===null||r.value;n[i]=a}return n}function $r(e,t){let n=[],r=-1,i=e.passKeys?new Map:Pr;for(;++ri?0:i+t:t>i?i:t,n=n>0?n:0,r.length<1e4)o=Array.from(r),o.unshift(t,n),e.splice(...o);else for(n&&e.splice(t,n);a0?(mi(e,e.length,0,t),e):t}var gi={}.hasOwnProperty;function _i(e){let t={},n=-1;for(;++n13&&n<32||n>126&&n<160||n>55295&&n<57344||n>64975&&n<65008||(n&65535)==65535||(n&65535)==65534||n>1114111?`�`:String.fromCodePoint(n)}function xi(e){return e.replace(/[\t\n\r ]+/g,` `).replace(/^ | $/g,``).toLowerCase().toUpperCase()}var Si=ji(/[A-Za-z]/),Ci=ji(/[\dA-Za-z]/),wi=ji(/[#-'*+\--9=?A-Z^-~]/);function Ti(e){return e!==null&&(e<32||e===127)}var Ei=ji(/\d/),Di=ji(/[\dA-Fa-f]/),Oi=ji(/[!-/:-@[-`{-~]/);function U(e){return e!==null&&e<-2}function W(e){return e!==null&&(e<0||e===32)}function G(e){return e===-2||e===-1||e===32}var ki=ji(/\p{P}|\p{S}/u),Ai=ji(/\s/);function ji(e){return t;function t(t){return t!==null&&t>-1&&e.test(String.fromCharCode(t))}}function Mi(e){let t=[],n=-1,r=0,i=0;for(;++n55295&&a<57344){let t=e.charCodeAt(n+1);a<56320&&t>56319&&t<57344?(o=String.fromCharCode(a,t),i=1):o=`�`}else o=String.fromCharCode(a);o&&=(t.push(e.slice(r,n),encodeURIComponent(o)),r=n+i+1,``),i&&=(n+=i,0)}return t.join(``)+e.slice(r)}function K(e,t,n,r){let i=r?r-1:1/0,a=0;return o;function o(r){return G(r)?(e.enter(n),s(r)):t(r)}function s(r){return G(r)&&a++o))return;let n=t.events.length,a=n,s,c;for(;a--;)if(t.events[a][0]===`exit`&&t.events[a][1].type===`chunkFlow`){if(s){c=t.events[a][1].end;break}s=!0}for(_(r),e=n;er;){let r=n[i];t.containerState=r[1],r[0].exit.call(t,e)}n.length=r}function v(){i.write([null]),a=void 0,i=void 0,t.containerState._closeFlow=void 0}}function Ri(e,t,n){return K(e,e.attempt(this.parser.constructs.document,t,n),`linePrefix`,this.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)}function zi(e){if(e===null||W(e)||Ai(e))return 1;if(ki(e))return 2}function Bi(e,t,n){let r=[],i=-1;for(;++i1&&e[n][1].end.offset-e[n][1].start.offset>1?2:1;let d={...e[r][1].end},f={...e[n][1].start};Hi(d,-c),Hi(f,c),o={type:c>1?`strongSequence`:`emphasisSequence`,start:d,end:{...e[r][1].end}},s={type:c>1?`strongSequence`:`emphasisSequence`,start:{...e[n][1].start},end:f},a={type:c>1?`strongText`:`emphasisText`,start:{...e[r][1].end},end:{...e[n][1].start}},i={type:c>1?`strong`:`emphasis`,start:{...o.start},end:{...s.end}},e[r][1].end={...o.start},e[n][1].start={...s.end},l=[],e[r][1].end.offset-e[r][1].start.offset&&(l=hi(l,[[`enter`,e[r][1],t],[`exit`,e[r][1],t]])),l=hi(l,[[`enter`,i,t],[`enter`,o,t],[`exit`,o,t],[`enter`,a,t]]),l=hi(l,Bi(t.parser.constructs.insideSpan.null,e.slice(r+1,n),t)),l=hi(l,[[`exit`,a,t],[`enter`,s,t],[`exit`,s,t],[`exit`,i,t]]),e[n][1].end.offset-e[n][1].start.offset?(u=2,l=hi(l,[[`enter`,e[n][1],t],[`exit`,e[n][1],t]])):u=0,mi(e,r-1,n-r+3,l),n=r+l.length-u-2;break}}for(n=-1;++n0&&G(t)?K(e,v,`linePrefix`,a+1)(t):v(t)}function v(t){return t===null||U(t)?e.check(ta,h,b)(t):(e.enter(`codeFlowValue`),y(t))}function y(t){return t===null||U(t)?(e.exit(`codeFlowValue`),v(t)):(e.consume(t),y)}function b(n){return e.exit(`codeFenced`),t(n)}function x(e,t,n){let i=0;return a;function a(t){return e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),c}function c(t){return e.enter(`codeFencedFence`),G(t)?K(e,l,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):l(t)}function l(t){return t===s?(e.enter(`codeFencedFenceSequence`),u(t)):n(t)}function u(t){return t===s?(i++,e.consume(t),u):i>=o?(e.exit(`codeFencedFenceSequence`),G(t)?K(e,d,`whitespace`)(t):d(t)):n(t)}function d(r){return r===null||U(r)?(e.exit(`codeFencedFence`),t(r)):n(r)}}}function ia(e,t,n){let r=this;return i;function i(t){return t===null?n(t):(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),a)}function a(e){return r.parser.lazy[r.now().line]?n(e):t(e)}}var aa={name:`codeIndented`,tokenize:sa},oa={partial:!0,tokenize:ca};function sa(e,t,n){let r=this;return i;function i(t){return e.enter(`codeIndented`),K(e,a,`linePrefix`,5)(t)}function a(e){let t=r.events[r.events.length-1];return t&&t[1].type===`linePrefix`&&t[2].sliceSerialize(t[1],!0).length>=4?o(e):n(e)}function o(t){return t===null?c(t):U(t)?e.attempt(oa,o,c)(t):(e.enter(`codeFlowValue`),s(t))}function s(t){return t===null||U(t)?(e.exit(`codeFlowValue`),o(t)):(e.consume(t),s)}function c(n){return e.exit(`codeIndented`),t(n)}}function ca(e,t,n){let r=this;return i;function i(t){return r.parser.lazy[r.now().line]?n(t):U(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),i):K(e,a,`linePrefix`,5)(t)}function a(e){let a=r.events[r.events.length-1];return a&&a[1].type===`linePrefix`&&a[2].sliceSerialize(a[1],!0).length>=4?t(e):U(e)?i(e):n(e)}}var la={name:`codeText`,previous:da,resolve:ua,tokenize:fa};function ua(e){let t=e.length-4,n=3,r,i;if((e[n][1].type===`lineEnding`||e[n][1].type===`space`)&&(e[t][1].type===`lineEnding`||e[t][1].type===`space`)){for(r=n;++r=this.left.length+this.right.length)throw RangeError("Cannot access index `"+e+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return ethis.left.length?this.right.slice(this.right.length-n+this.left.length,this.right.length-e+this.left.length).reverse():this.left.slice(e).concat(this.right.slice(this.right.length-n+this.left.length).reverse())}splice(e,t,n){let r=t||0;this.setCursor(Math.trunc(e));let i=this.right.splice(this.right.length-r,1/0);return n&&ma(this.left,n),i.reverse()}pop(){return this.setCursor(1/0),this.left.pop()}push(e){this.setCursor(1/0),this.left.push(e)}pushMany(e){this.setCursor(1/0),ma(this.left,e)}unshift(e){this.setCursor(0),this.right.push(e)}unshiftMany(e){this.setCursor(0),ma(this.right,e.reverse())}setCursor(e){if(!(e===this.left.length||e>this.left.length&&this.right.length===0||e<0&&this.left.length===0)){if(e=4?t(i):e.interrupt(r.parser.constructs.flow,n,t)(i)}}function xa(e,t,n,r,i,a,o,s,c){let l=c||1/0,u=0;return d;function d(t){return t===60?(e.enter(r),e.enter(i),e.enter(a),e.consume(t),e.exit(a),f):t===null||t===32||t===41||Ti(t)?n(t):(e.enter(r),e.enter(o),e.enter(s),e.enter(`chunkString`,{contentType:`string`}),h(t))}function f(n){return n===62?(e.enter(a),e.consume(n),e.exit(a),e.exit(i),e.exit(r),t):(e.enter(s),e.enter(`chunkString`,{contentType:`string`}),p(n))}function p(t){return t===62?(e.exit(`chunkString`),e.exit(s),f(t)):t===null||t===60||U(t)?n(t):(e.consume(t),t===92?m:p)}function m(t){return t===60||t===62||t===92?(e.consume(t),p):p(t)}function h(i){return!u&&(i===null||i===41||W(i))?(e.exit(`chunkString`),e.exit(s),e.exit(o),e.exit(r),t(i)):u999||l===null||l===91||l===93&&!c||l===94&&!s&&`_hiddenFootnoteSupport`in o.parser.constructs?n(l):l===93?(e.exit(a),e.enter(i),e.consume(l),e.exit(i),e.exit(r),t):U(l)?(e.enter(`lineEnding`),e.consume(l),e.exit(`lineEnding`),u):(e.enter(`chunkString`,{contentType:`string`}),d(l))}function d(t){return t===null||t===91||t===93||U(t)||s++>999?(e.exit(`chunkString`),u(t)):(e.consume(t),c||=!G(t),t===92?f:d)}function f(t){return t===91||t===92||t===93?(e.consume(t),s++,d):d(t)}}function Ca(e,t,n,r,i,a){let o;return s;function s(t){return t===34||t===39||t===40?(e.enter(r),e.enter(i),e.consume(t),e.exit(i),o=t===40?41:t,c):n(t)}function c(n){return n===o?(e.enter(i),e.consume(n),e.exit(i),e.exit(r),t):(e.enter(a),l(n))}function l(t){return t===o?(e.exit(a),c(o)):t===null?n(t):U(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),K(e,l,`linePrefix`)):(e.enter(`chunkString`,{contentType:`string`}),u(t))}function u(t){return t===o||t===null||U(t)?(e.exit(`chunkString`),l(t)):(e.consume(t),t===92?d:u)}function d(t){return t===o||t===92?(e.consume(t),u):u(t)}}function wa(e,t){let n;return r;function r(i){return U(i)?(e.enter(`lineEnding`),e.consume(i),e.exit(`lineEnding`),n=!0,r):G(i)?K(e,r,n?`linePrefix`:`lineSuffix`)(i):t(i)}}var Ta={name:`definition`,tokenize:Da},Ea={partial:!0,tokenize:Oa};function Da(e,t,n){let r=this,i;return a;function a(t){return e.enter(`definition`),o(t)}function o(t){return Sa.call(r,e,s,n,`definitionLabel`,`definitionLabelMarker`,`definitionLabelString`)(t)}function s(t){return i=xi(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),c):n(t)}function c(t){return W(t)?wa(e,l)(t):l(t)}function l(t){return xa(e,u,n,`definitionDestination`,`definitionDestinationLiteral`,`definitionDestinationLiteralMarker`,`definitionDestinationRaw`,`definitionDestinationString`)(t)}function u(t){return e.attempt(Ea,d,d)(t)}function d(t){return G(t)?K(e,f,`whitespace`)(t):f(t)}function f(a){return a===null||U(a)?(e.exit(`definition`),r.parser.defined.push(i),t(a)):n(a)}}function Oa(e,t,n){return r;function r(t){return W(t)?wa(e,i)(t):n(t)}function i(t){return Ca(e,a,n,`definitionTitle`,`definitionTitleMarker`,`definitionTitleString`)(t)}function a(t){return G(t)?K(e,o,`whitespace`)(t):o(t)}function o(e){return e===null||U(e)?t(e):n(e)}}var ka={name:`hardBreakEscape`,tokenize:Aa};function Aa(e,t,n){return r;function r(t){return e.enter(`hardBreakEscape`),e.consume(t),i}function i(r){return U(r)?(e.exit(`hardBreakEscape`),t(r)):n(r)}}var ja={name:`headingAtx`,resolve:Ma,tokenize:Na};function Ma(e,t){let n=e.length-2,r=3,i,a;return e[r][1].type===`whitespace`&&(r+=2),n-2>r&&e[n][1].type===`whitespace`&&(n-=2),e[n][1].type===`atxHeadingSequence`&&(r===n-1||n-4>r&&e[n-2][1].type===`whitespace`)&&(n-=r+1===n?2:4),n>r&&(i={type:`atxHeadingText`,start:e[r][1].start,end:e[n][1].end},a={type:`chunkText`,start:e[r][1].start,end:e[n][1].end,contentType:`text`},mi(e,r,n-r+1,[[`enter`,i,t],[`enter`,a,t],[`exit`,a,t],[`exit`,i,t]])),e}function Na(e,t,n){let r=0;return i;function i(t){return e.enter(`atxHeading`),a(t)}function a(t){return e.enter(`atxHeadingSequence`),o(t)}function o(t){return t===35&&r++<6?(e.consume(t),o):t===null||W(t)?(e.exit(`atxHeadingSequence`),s(t)):n(t)}function s(n){return n===35?(e.enter(`atxHeadingSequence`),c(n)):n===null||U(n)?(e.exit(`atxHeading`),t(n)):G(n)?K(e,s,`whitespace`)(n):(e.enter(`atxHeadingText`),l(n))}function c(t){return t===35?(e.consume(t),c):(e.exit(`atxHeadingSequence`),s(t))}function l(t){return t===null||t===35||W(t)?(e.exit(`atxHeadingText`),s(t)):(e.consume(t),l)}}var Pa=`address.article.aside.base.basefont.blockquote.body.caption.center.col.colgroup.dd.details.dialog.dir.div.dl.dt.fieldset.figcaption.figure.footer.form.frame.frameset.h1.h2.h3.h4.h5.h6.head.header.hr.html.iframe.legend.li.link.main.menu.menuitem.nav.noframes.ol.optgroup.option.p.param.search.section.summary.table.tbody.td.tfoot.th.thead.title.tr.track.ul`.split(`.`),Fa=[`pre`,`script`,`style`,`textarea`],Ia={concrete:!0,name:`htmlFlow`,resolveTo:za,tokenize:Ba},La={partial:!0,tokenize:Ha},Ra={partial:!0,tokenize:Va};function za(e){let t=e.length;for(;t--&&(e[t][0]!==`enter`||e[t][1].type!==`htmlFlow`););return t>1&&e[t-2][1].type===`linePrefix`&&(e[t][1].start=e[t-2][1].start,e[t+1][1].start=e[t-2][1].start,e.splice(t-2,2)),e}function Ba(e,t,n){let r=this,i,a,o,s,c;return l;function l(e){return u(e)}function u(t){return e.enter(`htmlFlow`),e.enter(`htmlFlowData`),e.consume(t),d}function d(s){return s===33?(e.consume(s),f):s===47?(e.consume(s),a=!0,h):s===63?(e.consume(s),i=3,r.interrupt?t:M):Si(s)?(e.consume(s),o=String.fromCharCode(s),g):n(s)}function f(a){return a===45?(e.consume(a),i=2,p):a===91?(e.consume(a),i=5,s=0,m):Si(a)?(e.consume(a),i=4,r.interrupt?t:M):n(a)}function p(i){return i===45?(e.consume(i),r.interrupt?t:M):n(i)}function m(i){return i===`CDATA[`.charCodeAt(s++)?(e.consume(i),s===6?r.interrupt?t:O:m):n(i)}function h(t){return Si(t)?(e.consume(t),o=String.fromCharCode(t),g):n(t)}function g(s){if(s===null||s===47||s===62||W(s)){let c=s===47,l=o.toLowerCase();return!c&&!a&&Fa.includes(l)?(i=1,r.interrupt?t(s):O(s)):Pa.includes(o.toLowerCase())?(i=6,c?(e.consume(s),_):r.interrupt?t(s):O(s)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?n(s):a?v(s):y(s))}return s===45||Ci(s)?(e.consume(s),o+=String.fromCharCode(s),g):n(s)}function _(i){return i===62?(e.consume(i),r.interrupt?t:O):n(i)}function v(t){return G(t)?(e.consume(t),v):E(t)}function y(t){return t===47?(e.consume(t),E):t===58||t===95||Si(t)?(e.consume(t),b):G(t)?(e.consume(t),y):E(t)}function b(t){return t===45||t===46||t===58||t===95||Ci(t)?(e.consume(t),b):x(t)}function x(t){return t===61?(e.consume(t),S):G(t)?(e.consume(t),x):y(t)}function S(t){return t===null||t===60||t===61||t===62||t===96?n(t):t===34||t===39?(e.consume(t),c=t,C):G(t)?(e.consume(t),S):w(t)}function C(t){return t===c?(e.consume(t),c=null,T):t===null||U(t)?n(t):(e.consume(t),C)}function w(t){return t===null||t===34||t===39||t===47||t===60||t===61||t===62||t===96||W(t)?x(t):(e.consume(t),w)}function T(e){return e===47||e===62||G(e)?y(e):n(e)}function E(t){return t===62?(e.consume(t),D):n(t)}function D(t){return t===null||U(t)?O(t):G(t)?(e.consume(t),D):n(t)}function O(t){return t===45&&i===2?(e.consume(t),te):t===60&&i===1?(e.consume(t),j):t===62&&i===4?(e.consume(t),N):t===63&&i===3?(e.consume(t),M):t===93&&i===5?(e.consume(t),re):U(t)&&(i===6||i===7)?(e.exit(`htmlFlowData`),e.check(La,P,k)(t)):t===null||U(t)?(e.exit(`htmlFlowData`),k(t)):(e.consume(t),O)}function k(t){return e.check(Ra,ee,P)(t)}function ee(t){return e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),A}function A(t){return t===null||U(t)?k(t):(e.enter(`htmlFlowData`),O(t))}function te(t){return t===45?(e.consume(t),M):O(t)}function j(t){return t===47?(e.consume(t),o=``,ne):O(t)}function ne(t){if(t===62){let n=o.toLowerCase();return Fa.includes(n)?(e.consume(t),N):O(t)}return Si(t)&&o.length<8?(e.consume(t),o+=String.fromCharCode(t),ne):O(t)}function re(t){return t===93?(e.consume(t),M):O(t)}function M(t){return t===62?(e.consume(t),N):t===45&&i===2?(e.consume(t),M):O(t)}function N(t){return t===null||U(t)?(e.exit(`htmlFlowData`),P(t)):(e.consume(t),N)}function P(n){return e.exit(`htmlFlow`),t(n)}}function Va(e,t,n){let r=this;return i;function i(t){return U(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),a):n(t)}function a(e){return r.parser.lazy[r.now().line]?n(e):t(e)}}function Ha(e,t,n){return r;function r(r){return e.enter(`lineEnding`),e.consume(r),e.exit(`lineEnding`),e.attempt(Gi,t,n)}}var Ua={name:`htmlText`,tokenize:Wa};function Wa(e,t,n){let r=this,i,a,o;return s;function s(t){return e.enter(`htmlText`),e.enter(`htmlTextData`),e.consume(t),c}function c(t){return t===33?(e.consume(t),l):t===47?(e.consume(t),x):t===63?(e.consume(t),y):Si(t)?(e.consume(t),w):n(t)}function l(t){return t===45?(e.consume(t),u):t===91?(e.consume(t),a=0,m):Si(t)?(e.consume(t),v):n(t)}function u(t){return t===45?(e.consume(t),p):n(t)}function d(t){return t===null?n(t):t===45?(e.consume(t),f):U(t)?(o=d,j(t)):(e.consume(t),d)}function f(t){return t===45?(e.consume(t),p):d(t)}function p(e){return e===62?te(e):e===45?f(e):d(e)}function m(t){return t===`CDATA[`.charCodeAt(a++)?(e.consume(t),a===6?h:m):n(t)}function h(t){return t===null?n(t):t===93?(e.consume(t),g):U(t)?(o=h,j(t)):(e.consume(t),h)}function g(t){return t===93?(e.consume(t),_):h(t)}function _(t){return t===62?te(t):t===93?(e.consume(t),_):h(t)}function v(t){return t===null||t===62?te(t):U(t)?(o=v,j(t)):(e.consume(t),v)}function y(t){return t===null?n(t):t===63?(e.consume(t),b):U(t)?(o=y,j(t)):(e.consume(t),y)}function b(e){return e===62?te(e):y(e)}function x(t){return Si(t)?(e.consume(t),S):n(t)}function S(t){return t===45||Ci(t)?(e.consume(t),S):C(t)}function C(t){return U(t)?(o=C,j(t)):G(t)?(e.consume(t),C):te(t)}function w(t){return t===45||Ci(t)?(e.consume(t),w):t===47||t===62||W(t)?T(t):n(t)}function T(t){return t===47?(e.consume(t),te):t===58||t===95||Si(t)?(e.consume(t),E):U(t)?(o=T,j(t)):G(t)?(e.consume(t),T):te(t)}function E(t){return t===45||t===46||t===58||t===95||Ci(t)?(e.consume(t),E):D(t)}function D(t){return t===61?(e.consume(t),O):U(t)?(o=D,j(t)):G(t)?(e.consume(t),D):T(t)}function O(t){return t===null||t===60||t===61||t===62||t===96?n(t):t===34||t===39?(e.consume(t),i=t,k):U(t)?(o=O,j(t)):G(t)?(e.consume(t),O):(e.consume(t),ee)}function k(t){return t===i?(e.consume(t),i=void 0,A):t===null?n(t):U(t)?(o=k,j(t)):(e.consume(t),k)}function ee(t){return t===null||t===34||t===39||t===60||t===61||t===96?n(t):t===47||t===62||W(t)?T(t):(e.consume(t),ee)}function A(e){return e===47||e===62||W(e)?T(e):n(e)}function te(r){return r===62?(e.consume(r),e.exit(`htmlTextData`),e.exit(`htmlText`),t):n(r)}function j(t){return e.exit(`htmlTextData`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),ne}function ne(t){return G(t)?K(e,re,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):re(t)}function re(t){return e.enter(`htmlTextData`),o(t)}}var Ga={name:`labelEnd`,resolveAll:Ya,resolveTo:Xa,tokenize:Za},Ka={tokenize:Qa},qa={tokenize:$a},Ja={tokenize:eo};function Ya(e){let t=-1,n=[];for(;++t=3&&(a===null||U(a))?(e.exit(`thematicBreak`),t(a)):n(a)}function c(t){return t===i?(e.consume(t),r++,c):(e.exit(`thematicBreakSequence`),G(t)?K(e,s,`whitespace`)(t):s(t))}}var lo={continuation:{tokenize:mo},exit:ho,name:`list`,tokenize:po},uo={partial:!0,tokenize:go},fo={partial:!0,tokenize:X};function po(e,t,n){let r=this,i=r.events[r.events.length-1],a=i&&i[1].type===`linePrefix`?i[2].sliceSerialize(i[1],!0).length:0,o=0;return s;function s(t){let i=r.containerState.type||(t===42||t===43||t===45?`listUnordered`:`listOrdered`);if(i===`listUnordered`?!r.containerState.marker||t===r.containerState.marker:Ei(t)){if(r.containerState.type||(r.containerState.type=i,e.enter(i,{_container:!0})),i===`listUnordered`)return e.enter(`listItemPrefix`),t===42||t===45?e.check(so,n,l)(t):l(t);if(!r.interrupt||t===49)return e.enter(`listItemPrefix`),e.enter(`listItemValue`),c(t)}return n(t)}function c(t){return Ei(t)&&++o<10?(e.consume(t),c):(!r.interrupt||o<2)&&(r.containerState.marker?t===r.containerState.marker:t===41||t===46)?(e.exit(`listItemValue`),l(t)):n(t)}function l(t){return e.enter(`listItemMarker`),e.consume(t),e.exit(`listItemMarker`),r.containerState.marker=r.containerState.marker||t,e.check(Gi,r.interrupt?n:u,e.attempt(uo,f,d))}function u(e){return r.containerState.initialBlankLine=!0,a++,f(e)}function d(t){return G(t)?(e.enter(`listItemPrefixWhitespace`),e.consume(t),e.exit(`listItemPrefixWhitespace`),f):n(t)}function f(n){return r.containerState.size=a+r.sliceSerialize(e.exit(`listItemPrefix`),!0).length,t(n)}}function mo(e,t,n){let r=this;return r.containerState._closeFlow=void 0,e.check(Gi,i,a);function i(n){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,K(e,t,`listItemIndent`,r.containerState.size+1)(n)}function a(n){return r.containerState.furtherBlankLines||!G(n)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,o(n)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(fo,t,o)(n))}function o(i){return r.containerState._closeFlow=!0,r.interrupt=void 0,K(e,e.attempt(lo,t,n),`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(i)}}function X(e,t,n){let r=this;return K(e,i,`listItemIndent`,r.containerState.size+1);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`listItemIndent`&&i[2].sliceSerialize(i[1],!0).length===r.containerState.size?t(e):n(e)}}function ho(e){e.exit(this.containerState.type)}function go(e,t,n){let r=this;return K(e,i,`listItemPrefixWhitespace`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:5);function i(e){let i=r.events[r.events.length-1];return!G(e)&&i&&i[1].type===`listItemPrefixWhitespace`?t(e):n(e)}}var _o={name:`setextUnderline`,resolveTo:vo,tokenize:yo};function vo(e,t){let n=e.length,r,i,a;for(;n--;)if(e[n][0]===`enter`){if(e[n][1].type===`content`){r=n;break}e[n][1].type===`paragraph`&&(i=n)}else e[n][1].type===`content`&&e.splice(n,1),!a&&e[n][1].type===`definition`&&(a=n);let o={type:`setextHeading`,start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type=`setextHeadingText`,a?(e.splice(i,0,[`enter`,o,t]),e.splice(a+1,0,[`exit`,e[r][1],t]),e[r][1].end={...e[a][1].end}):e[r][1]=o,e.push([`exit`,o,t]),e}function yo(e,t,n){let r=this,i;return a;function a(t){let a=r.events.length,s;for(;a--;)if(r.events[a][1].type!==`lineEnding`&&r.events[a][1].type!==`linePrefix`&&r.events[a][1].type!==`content`){s=r.events[a][1].type===`paragraph`;break}return!r.parser.lazy[r.now().line]&&(r.interrupt||s)?(e.enter(`setextHeadingLine`),i=t,o(t)):n(t)}function o(t){return e.enter(`setextHeadingLineSequence`),s(t)}function s(t){return t===i?(e.consume(t),s):(e.exit(`setextHeadingLineSequence`),G(t)?K(e,c,`lineSuffix`)(t):c(t))}function c(r){return r===null||U(r)?(e.exit(`setextHeadingLine`),t(r)):n(r)}}var bo={tokenize:Z};function Z(e){let t=this,n=e.attempt(Gi,r,e.attempt(this.parser.constructs.flowInitial,i,K(e,e.attempt(this.parser.constructs.flow,i,e.attempt(_a,i)),`linePrefix`)));return n;function r(r){if(r===null){e.consume(r);return}return e.enter(`lineEndingBlank`),e.consume(r),e.exit(`lineEndingBlank`),t.currentConstruct=void 0,n}function i(r){if(r===null){e.consume(r);return}return e.enter(`lineEnding`),e.consume(r),e.exit(`lineEnding`),t.currentConstruct=void 0,n}}var xo={resolveAll:To()},So=wo(`string`),Co=wo(`text`);function wo(e){return{resolveAll:To(e===`text`?Eo:void 0),tokenize:t};function t(t){let n=this,r=this.parser.constructs[e],i=t.attempt(r,a,o);return a;function a(e){return c(e)?i(e):o(e)}function o(e){if(e===null){t.consume(e);return}return t.enter(`data`),t.consume(e),s}function s(e){return c(e)?(t.exit(`data`),i(e)):(t.consume(e),s)}function c(e){if(e===null)return!0;let t=r[e],i=-1;if(t)for(;++iFo,contentInitial:()=>ko,disable:()=>Io,document:()=>Oo,flow:()=>jo,flowInitial:()=>Ao,insideSpan:()=>Po,string:()=>Mo,text:()=>No}),Oo={42:lo,43:lo,45:lo,48:lo,49:lo,50:lo,51:lo,52:lo,53:lo,54:lo,55:lo,56:lo,57:lo,62:qi},ko={91:Ta},Ao={[-2]:aa,[-1]:aa,32:aa},jo={35:ja,42:so,45:[_o,so],60:Ia,61:_o,95:so,96:na,126:na},Mo={38:$i,92:Zi},No={[-5]:ao,[-4]:ao,[-3]:ao,33:to,38:$i,42:Vi,60:[Ui,Ua],91:ro,92:[ka,Zi],93:Ga,95:Vi,96:la},Po={null:[Vi,xo]},Fo={null:[42,95]},Io={null:[]};function Lo(e,t,n){let r={_bufferIndex:-1,_index:0,line:n&&n.line||1,column:n&&n.column||1,offset:n&&n.offset||0},i={},a=[],o=[],s=[],c={attempt:C(x),check:C(S),consume:v,enter:y,exit:b,interrupt:C(S,{interrupt:!0})},l={code:null,containerState:{},defineSkip:h,events:[],now:m,parser:e,previous:null,sliceSerialize:f,sliceStream:p,write:d},u=t.tokenize.call(l,c);return t.resolveAll&&a.push(t),l;function d(e){return o=hi(o,e),g(),o[o.length-1]===null?(w(t,0),l.events=Bi(a,l.events,l),l.events):[]}function f(e,t){return zo(p(e),t)}function p(e){return Ro(o,e)}function m(){let{_bufferIndex:e,_index:t,line:n,column:i,offset:a}=r;return{_bufferIndex:e,_index:t,line:n,column:i,offset:a}}function h(e){i[e.line]=e.column,E()}function g(){let e;for(;r._index-1){let e=o[0];typeof e==`string`?o[0]=e.slice(r):o.shift()}a>0&&o.push(e[i].slice(0,a))}return o}function zo(e,t){let n=-1,r=[],i;for(;++n0){let e=a.tokenStack[a.tokenStack.length-1];(e[1]||$o).call(a,void 0,e[0])}for(r.position={start:Xo(e.length>0?e[0][1].start:{line:1,column:1,offset:0}),end:Xo(e.length>0?e[e.length-2][1].end:{line:1,column:1,offset:0})},d=-1;++d0&&(r.className=[`language-`+i[0]]);let a={type:`element`,tagName:`code`,properties:r,children:[{type:`text`,value:n}]};return t.meta&&(a.data={meta:t.meta}),e.patch(t,a),a=e.applyData(t,a),a={type:`element`,tagName:`pre`,properties:{},children:[a]},e.patch(t,a),a}function is(e,t){let n={type:`element`,tagName:`del`,properties:{},children:e.all(t)};return e.patch(t,n),e.applyData(t,n)}function as(e,t){let n={type:`element`,tagName:`em`,properties:{},children:e.all(t)};return e.patch(t,n),e.applyData(t,n)}function os(e,t){let n=typeof e.options.clobberPrefix==`string`?e.options.clobberPrefix:`user-content-`,r=String(t.identifier).toUpperCase(),i=Mi(r.toLowerCase()),a=e.footnoteOrder.indexOf(r),o,s=e.footnoteCounts.get(r);s===void 0?(s=0,e.footnoteOrder.push(r),o=e.footnoteOrder.length):o=a+1,s+=1,e.footnoteCounts.set(r,s);let c={type:`element`,tagName:`a`,properties:{href:`#`+n+`fn-`+i,id:n+`fnref-`+i+(s>1?`-`+s:``),dataFootnoteRef:!0,ariaDescribedBy:[`footnote-label`]},children:[{type:`text`,value:String(o)}]};e.patch(t,c);let l={type:`element`,tagName:`sup`,properties:{},children:[c]};return e.patch(t,l),e.applyData(t,l)}function ss(e,t){let n={type:`element`,tagName:`h`+t.depth,properties:{},children:e.all(t)};return e.patch(t,n),e.applyData(t,n)}function cs(e,t){if(e.options.allowDangerousHtml){let n={type:`raw`,value:t.value};return e.patch(t,n),e.applyData(t,n)}}function ls(e,t){let n=t.referenceType,r=`]`;if(n===`collapsed`?r+=`[]`:n===`full`&&(r+=`[`+(t.label||t.identifier)+`]`),t.type===`imageReference`)return[{type:`text`,value:`![`+t.alt+r}];let i=e.all(t),a=i[0];a&&a.type===`text`?a.value=`[`+a.value:i.unshift({type:`text`,value:`[`});let o=i[i.length-1];return o&&o.type===`text`?o.value+=r:i.push({type:`text`,value:r}),i}function us(e,t){let n=String(t.identifier).toUpperCase(),r=e.definitionById.get(n);if(!r)return ls(e,t);let i={src:Mi(r.url||``),alt:t.alt};r.title!==null&&r.title!==void 0&&(i.title=r.title);let a={type:`element`,tagName:`img`,properties:i,children:[]};return e.patch(t,a),e.applyData(t,a)}function ds(e,t){let n={src:Mi(t.url)};t.alt!==null&&t.alt!==void 0&&(n.alt=t.alt),t.title!==null&&t.title!==void 0&&(n.title=t.title);let r={type:`element`,tagName:`img`,properties:n,children:[]};return e.patch(t,r),e.applyData(t,r)}function fs(e,t){let n={type:`text`,value:t.value.replace(/\r?\n|\r/g,` `)};e.patch(t,n);let r={type:`element`,tagName:`code`,properties:{},children:[n]};return e.patch(t,r),e.applyData(t,r)}function ps(e,t){let n=String(t.identifier).toUpperCase(),r=e.definitionById.get(n);if(!r)return ls(e,t);let i={href:Mi(r.url||``)};r.title!==null&&r.title!==void 0&&(i.title=r.title);let a={type:`element`,tagName:`a`,properties:i,children:e.all(t)};return e.patch(t,a),e.applyData(t,a)}function ms(e,t){let n={href:Mi(t.url)};t.title!==null&&t.title!==void 0&&(n.title=t.title);let r={type:`element`,tagName:`a`,properties:n,children:e.all(t)};return e.patch(t,r),e.applyData(t,r)}function hs(e,t,n){let r=e.all(t),i=n?gs(n):_s(t),a={},o=[];if(typeof t.checked==`boolean`){let e=r[0],n;e&&e.type===`element`&&e.tagName===`p`?n=e:(n={type:`element`,tagName:`p`,properties:{},children:[]},r.unshift(n)),n.children.length>0&&n.children.unshift({type:`text`,value:` `}),n.children.unshift({type:`element`,tagName:`input`,properties:{type:`checkbox`,checked:t.checked,disabled:!0},children:[]}),a.className=[`task-list-item`]}let s=-1;for(;++s1}function vs(e,t){let n={},r=e.all(t),i=-1;for(typeof t.start==`number`&&t.start!==1&&(n.start=t.start);++i0){let r={type:`element`,tagName:`tbody`,properties:{},children:e.wrap(n,!0)},a=wr(t.children[1]),o=Cr(t.children[t.children.length-1]);a&&o&&(r.position={start:a,end:o}),i.push(r)}let a={type:`element`,tagName:`table`,properties:{},children:e.wrap(i,!0)};return e.patch(t,a),e.applyData(t,a)}function Cs(e,t,n){let r=n?n.children:void 0,i=(r?r.indexOf(t):1)===0?`th`:`td`,a=n&&n.type===`table`?n.align:void 0,o=a?a.length:t.children.length,s=-1,c=[];for(;++s0,!0),r[0]),i=r.index+r[0].length,r=n.exec(t);return a.push(Os(t.slice(i),i>0,!1)),a.join(``)}function Os(e,t,n){let r=0,i=e.length;if(t){let t=e.codePointAt(r);for(;t===Ts||t===Es;)r++,t=e.codePointAt(r)}if(n){let t=e.codePointAt(i-1);for(;t===Ts||t===Es;)i--,t=e.codePointAt(i-1)}return i>r?e.slice(r,i):``}function ks(e,t){let n={type:`text`,value:Ds(String(t.value))};return e.patch(t,n),e.applyData(t,n)}function As(e,t){let n={type:`element`,tagName:`hr`,properties:{},children:[]};return e.patch(t,n),e.applyData(t,n)}var js={blockquote:ts,break:ns,code:rs,delete:is,emphasis:as,footnoteReference:os,heading:ss,html:cs,imageReference:us,image:ds,inlineCode:fs,linkReference:ps,link:ms,listItem:hs,list:vs,paragraph:ys,root:bs,strong:xs,table:Ss,tableCell:ws,tableRow:Cs,text:ks,thematicBreak:As,toml:Ms,yaml:Ms,definition:Ms,footnoteDefinition:Ms};function Ms(){}var{defineProperty:Ns}=Object,Ps=typeof self==`object`?self:globalThis,Fs=(e,t)=>{switch(e){case`Function`:case`SharedWorker`:case`Worker`:case`eval`:case`setInterval`:case`setTimeout`:throw TypeError(`unable to deserialize `+e)}return new Ps[e](t)},Is=(e,t)=>{let n=(t,n)=>(e.set(n,t),t),r=i=>{if(e.has(i))return e.get(i);let[a,o]=t[i];switch(a){case 0:case-1:return n(o,i);case 1:{let e=n([],i);for(let t of o)e.push(r(t));return e}case 2:{let e=n({},i);for(let[t,n]of o){let i=r(t),a=r(n);i===`__proto__`?Ns(e,i,{value:a,configurable:!0,enumerable:!0,writable:!0}):e[i]=a}return e}case 3:return n(new Date(o),i);case 4:{let{source:e,flags:t}=o;return n(new RegExp(e,t),i)}case 5:{let e=n(new Map,i);for(let[t,n]of o)e.set(r(t),r(n));return e}case 6:{let e=n(new Set,i);for(let t of o)e.add(r(t));return e}case 7:{let{name:e,message:t}=o;return n(typeof Ps[e]==`function`?Fs(e,t):Error(t),i)}case 8:return n(BigInt(o),i);case`BigInt`:return n(Object(BigInt(o)),i);case`ArrayBuffer`:return n(new Uint8Array(o).buffer,o);case`DataView`:{let{buffer:e}=new Uint8Array(o);return n(new DataView(e),o)}case`-0`:return-0}return n(Fs(a,o),i)};return r},Ls=e=>Is(new Map,e)(0),Rs=``,{toString:zs}={},{keys:Bs,is:Vs}=Object,Hs=e=>{let t=typeof e;if(t!==`object`||!e)return[0,t];let n=zs.call(e).slice(8,-1);switch(n){case`Array`:return[1,Rs];case`Object`:return[2,Rs];case`Date`:return[3,Rs];case`RegExp`:return[4,Rs];case`Map`:return[5,Rs];case`Set`:return[6,Rs];case`DataView`:return[1,n]}return n.includes(`Array`)?[1,n]:e instanceof Error?[7,e.name||`Error`]:[2,n]},Us=([e,t])=>e===0&&(t===`function`||t===`symbol`),Ws=(e,t,n,r)=>{let i=(e,t)=>{let i=r.push(e)-1;return n.set(t,i),i},a=o=>{if(n.has(o))return n.get(o);let[s,c]=Hs(o);switch(s){case 0:{let t=o;switch(c){case`bigint`:s=8,t=o.toString();break;case`number`:if(!o&&Vs(o,-0))return r.push([`-0`])-1;break;case`function`:case`symbol`:if(e)throw TypeError(`unable to serialize `+c);t=null;break;case`undefined`:return i([-1],o)}return i([s,t],o)}case 1:{if(c){let e=o;return c===`DataView`?e=new Uint8Array(o.buffer):c===`ArrayBuffer`&&(e=new Uint8Array(o)),i([c,[...e]],o)}let e=[],t=i([s,e],o);for(let t of o)e.push(a(t));return t}case 2:{if(c)switch(c){case`BigInt`:return i([c,o.toString()],o);case`Boolean`:case`Number`:case`String`:return i([c,o.valueOf()],o)}if(t&&`toJSON`in o)return a(o.toJSON());let n=[],r=i([s,n],o);for(let t of Bs(o))(e||!Us(Hs(o[t])))&&n.push([a(t),a(o[t])]);return r}case 3:return i([s,isNaN(o.getTime())?Rs:o.toISOString()],o);case 4:{let{source:e,flags:t}=o;return i([s,{source:e,flags:t}],o)}case 5:{let t=[],n=i([s,t],o);for(let[n,r]of o)(e||!(Us(Hs(n))||Us(Hs(r))))&&t.push([a(n),a(r)]);return n}case 6:{let t=[],n=i([s,t],o);for(let n of o)(e||!Us(Hs(n)))&&t.push(a(n));return n}}let{message:l}=o;return i([s,{name:c,message:l}],o)};return a},Gs=(e,{json:t,lossy:n}={})=>{let r=[];return Ws(!(t||n),!!t,new Map,r)(e),r},Ks=typeof structuredClone==`function`?(e,t)=>t&&(`json`in t||`lossy`in t)?Ls(Gs(e,t)):structuredClone(e):(e,t)=>Ls(Gs(e,t));function qs(e,t){let n=[{type:`text`,value:`↩`}];return t>1&&n.push({type:`element`,tagName:`sup`,properties:{},children:[{type:`text`,value:String(t)}]}),n}function Js(e,t){return`Back to reference `+(e+1)+(t>1?`-`+t:``)}function Ys(e){let t=typeof e.options.clobberPrefix==`string`?e.options.clobberPrefix:`user-content-`,n=e.options.footnoteBackContent||qs,r=e.options.footnoteBackLabel||Js,i=e.options.footnoteLabel||`Footnotes`,a=e.options.footnoteLabelTagName||`h2`,o=e.options.footnoteLabelProperties||{className:[`sr-only`]},s=[],c=-1;for(;++c0&&d.push({type:`text`,value:` `});let e=typeof n==`string`?n:n(c,u);typeof e==`string`&&(e={type:`text`,value:e}),d.push({type:`element`,tagName:`a`,properties:{href:`#`+t+`fnref-`+l+(u>1?`-`+u:``),dataFootnoteBackref:``,ariaLabel:typeof r==`string`?r:r(c,u),className:[`data-footnote-backref`]},children:Array.isArray(e)?e:[e]})}let p=a[a.length-1];if(p&&p.type===`element`&&p.tagName===`p`){let e=p.children[p.children.length-1];e&&e.type===`text`?e.value+=` `:p.children.push({type:`text`,value:` `}),p.children.push(...d)}else a.push(...d);let m={type:`element`,tagName:`li`,properties:{id:t+`fn-`+l},children:e.wrap(a,!0)};e.patch(i,m),s.push(m)}if(s.length!==0)return{type:`element`,tagName:`section`,properties:{dataFootnotes:!0,className:[`footnotes`]},children:[{type:`element`,tagName:a,properties:{...Ks(o),id:`footnote-label`},children:[{type:`text`,value:i}]},{type:`text`,value:` -`},{type:`element`,tagName:`ol`,properties:{},children:e.wrap(s,!0)},{type:`text`,value:` -`}]}}var Xs=(function(e){if(e==null)return tc;if(typeof e==`function`)return ec(e);if(typeof e==`object`)return Array.isArray(e)?Zs(e):Qs(e);if(typeof e==`string`)return $s(e);throw Error(`Expected function, string, or object as test`)});function Zs(e){let t=[],n=-1;for(;++n`:``))+`)`})}return u;function u(){let l=ic,u,d,f;if((!t||a(e,i,c[c.length-1]||void 0))&&(l=oc(n(e,c)),l[0]===!1))return l;if(`children`in e&&e.children){let t=e;if(t.children&&l[0]!==`skip`)for(d=(r?t.children.length:-1)+o,f=c.concat(t);d>-1&&d0&&n.push({type:`text`,value:` -`}),n}function mc(e){let t=0,n=e.charCodeAt(t);for(;n===9||n===32;)t++,n=e.charCodeAt(t);return e.slice(t)}function hc(e,t){let n=uc(e,t),r=n.one(e,void 0),i=Ys(n),a=Array.isArray(r)?{type:`root`,children:r}:r||{type:`root`,children:[]};return i&&(`children`in a,a.children.push({type:`text`,value:` -`},i)),a}function gc(e,t){return e&&`run`in e?async function(n,r){let i=hc(n,{file:r,...t});await e.run(i,r)}:function(n,r){return hc(n,{file:r,...e||t})}}function _c(e){if(e)throw e}var vc=b(((e,t)=>{var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=function(e){return typeof Array.isArray==`function`?Array.isArray(e):r.call(e)===`[object Array]`},s=function(e){if(!e||r.call(e)!==`[object Object]`)return!1;var t=n.call(e,`constructor`),i=e.constructor&&e.constructor.prototype&&n.call(e.constructor.prototype,`isPrototypeOf`);if(e.constructor&&!t&&!i)return!1;for(var a in e);return a===void 0||n.call(e,a)},c=function(e,t){i&&t.name===`__proto__`?i(e,t.name,{enumerable:!0,configurable:!0,value:t.newValue,writable:!0}):e[t.name]=t.newValue},l=function(e,t){if(t===`__proto__`){if(!n.call(e,t))return;if(a)return a(e,t).value}return e[t]};t.exports=function e(){var t,n,r,i,a,u,d=arguments[0],f=1,p=arguments.length,m=!1;for(typeof d==`boolean`&&(m=d,d=arguments[1]||{},f=2),(d==null||typeof d!=`object`&&typeof d!=`function`)&&(d={});ft.length,o;r&&t.push(i);try{o=e.apply(this,t)}catch(e){let t=e;if(r&&n)throw t;return i(t)}r||(o&&o.then&&typeof o.then==`function`?o.then(a,i):o instanceof Error?i(o):a(o))}function i(e,...r){n||(n=!0,t(e,...r))}function a(e){i(null,e)}}var Sc={basename:Cc,dirname:wc,extname:Tc,join:Ec,sep:`/`};function Cc(e,t){if(t!==void 0&&typeof t!=`string`)throw TypeError(`"ext" argument must be a string`);kc(e);let n=0,r=-1,i=e.length,a;if(t===void 0||t.length===0||t.length>e.length){for(;i--;)if(e.codePointAt(i)===47){if(a){n=i+1;break}}else r<0&&(a=!0,r=i+1);return r<0?``:e.slice(n,r)}if(t===e)return``;let o=-1,s=t.length-1;for(;i--;)if(e.codePointAt(i)===47){if(a){n=i+1;break}}else o<0&&(a=!0,o=i+1),s>-1&&(e.codePointAt(i)===t.codePointAt(s--)?s<0&&(r=i):(s=-1,r=o));return n===r?r=o:r<0&&(r=e.length),e.slice(n,r)}function wc(e){if(kc(e),e.length===0)return`.`;let t=-1,n=e.length,r;for(;--n;)if(e.codePointAt(n)===47){if(r){t=n;break}}else r||=!0;return t<0?e.codePointAt(0)===47?`/`:`.`:t===1&&e.codePointAt(0)===47?`//`:e.slice(0,t)}function Tc(e){kc(e);let t=e.length,n=-1,r=0,i=-1,a=0,o;for(;t--;){let s=e.codePointAt(t);if(s===47){if(o){r=t+1;break}continue}n<0&&(o=!0,n=t+1),s===46?i<0?i=t:a!==1&&(a=1):i>-1&&(a=-1)}return i<0||n<0||a===0||a===1&&i===n-1&&i===r+1?``:e.slice(i,n)}function Ec(...e){let t=-1,n;for(;++t0&&e.codePointAt(e.length-1)===47&&(n+=`/`),t?`/`+n:n}function Oc(e,t){let n=``,r=0,i=-1,a=0,o=-1,s,c;for(;++o<=e.length;){if(o2){if(c=n.lastIndexOf(`/`),c!==n.length-1){c<0?(n=``,r=0):(n=n.slice(0,c),r=n.length-1-n.lastIndexOf(`/`)),i=o,a=0;continue}}else if(n.length>0){n=``,r=0,i=o,a=0;continue}}t&&(n=n.length>0?n+`/..`:`..`,r=2)}else n.length>0?n+=`/`+e.slice(i+1,o):n=e.slice(i+1,o),r=o-i-1}i=o,a=0}else s===46&&a>-1?a++:a=-1}return n}function kc(e){if(typeof e!=`string`)throw TypeError(`Path must be a string. Received `+JSON.stringify(e))}var Ac={cwd:jc};function jc(){return`/`}function Mc(e){return!!(typeof e==`object`&&e&&`href`in e&&e.href&&`protocol`in e&&e.protocol&&e.auth===void 0)}function Nc(e){if(typeof e==`string`)e=new URL(e);else if(!Mc(e)){let t=TypeError('The "path" argument must be of type string or an instance of URL. Received `'+e+"`");throw t.code=`ERR_INVALID_ARG_TYPE`,t}if(e.protocol!==`file:`){let e=TypeError(`The URL must be of scheme file`);throw e.code=`ERR_INVALID_URL_SCHEME`,e}return Pc(e)}function Pc(e){if(e.hostname!==``){let e=TypeError(`File URL host must be "localhost" or empty on darwin`);throw e.code=`ERR_INVALID_FILE_URL_HOST`,e}let t=e.pathname,n=-1;for(;++n0){let[r,...a]=t,o=n[i][1];yc(o)&&yc(r)&&(r=(0,Vc.default)(!0,o,r)),n[i]=[e,r,...a]}}}}().freeze();function Wc(e,t){if(typeof t!=`function`)throw TypeError("Cannot `"+e+"` without `parser`")}function Gc(e,t){if(typeof t!=`function`)throw TypeError("Cannot `"+e+"` without `compiler`")}function Kc(e,t){if(t)throw Error("Cannot call `"+e+"` on a frozen processor.\nCreate a new processor first, by calling it: use `processor()` instead of `processor`.")}function qc(e){if(!yc(e)||typeof e.type!=`string`)throw TypeError("Expected node, got `"+e+"`")}function Jc(e,t,n){if(!n)throw Error("`"+e+"` finished async. Use `"+t+"` instead")}function Yc(e){return Xc(e)?e:new Ic(e)}function Xc(e){return!!(e&&typeof e==`object`&&`message`in e&&`messages`in e)}function Zc(e){return typeof e==`string`||Qc(e)}function Qc(e){return!!(e&&typeof e==`object`&&`byteLength`in e&&`byteOffset`in e)}var $c=[],el={allowDangerousHtml:!0},tl=/^(https?|ircs?|mailto|xmpp)$/i,nl=[{from:`astPlugins`,id:`remove-buggy-html-in-markdown-parser`},{from:`allowDangerousHtml`,id:`remove-buggy-html-in-markdown-parser`},{from:`allowNode`,id:`replace-allownode-allowedtypes-and-disallowedtypes`,to:`allowElement`},{from:`allowedTypes`,id:`replace-allownode-allowedtypes-and-disallowedtypes`,to:`allowedElements`},{from:`disallowedTypes`,id:`replace-allownode-allowedtypes-and-disallowedtypes`,to:`disallowedElements`},{from:`escapeHtml`,id:`remove-buggy-html-in-markdown-parser`},{from:`includeElementIndex`,id:`#remove-includeelementindex`},{from:`includeNodeIndex`,id:`change-includenodeindex-to-includeelementindex`},{from:`linkTarget`,id:`remove-linktarget`},{from:`plugins`,id:`change-plugins-to-remarkplugins`,to:`remarkPlugins`},{from:`rawSourcePos`,id:`#remove-rawsourcepos`},{from:`renderers`,id:`change-renderers-to-components`,to:`components`},{from:`source`,id:`change-source-to-children`,to:`children`},{from:`sourcePos`,id:`#remove-sourcepos`},{from:`transformImageUri`,id:`#add-urltransform`,to:`urlTransform`},{from:`transformLinkUri`,id:`#add-urltransform`,to:`urlTransform`}];function rl(e){let t=il(e),n=al(e);return ol(t.runSync(t.parse(n),n),e)}function il(e){let t=e.rehypePlugins||$c,n=e.remarkPlugins||$c,r=e.remarkRehypeOptions?{...e.remarkRehypeOptions,...el}:el;return Uc().use(es).use(n).use(gc,r).use(t)}function al(e){let t=e.children||``,n=new Ic;return typeof t==`string`?n.value=t:``+t,n}function ol(e,t){let n=t.allowedElements,r=t.allowElement,i=t.components,a=t.disallowedElements,o=t.skipHtml,s=t.unwrapDisallowed,c=t.urlTransform||sl;for(let e of nl)Object.hasOwn(t,e.from)&&``+e.from+(e.to?"use `"+e.to+"` instead":`remove it`)+e.id;return t.className&&(e={type:`element`,tagName:`div`,properties:{className:t.className},children:e.type===`root`?e.children:[e]}),sc(e,l),zr(e,{Fragment:R.Fragment,components:i,ignoreInvalidStyle:!0,jsx:R.jsx,jsxs:R.jsxs,passKeys:!0,passNode:!0});function l(e,t,i){if(e.type===`raw`&&i&&typeof t==`number`)return o?i.children.splice(t,1):i.children[t]={type:`text`,value:e.value},t;if(e.type===`element`){let t;for(t in H)if(Object.hasOwn(H,t)&&Object.hasOwn(e.properties,t)){let n=e.properties[t],r=H[t];(r===null||r.includes(e.tagName))&&(e.properties[t]=c(String(n||``),t,e))}}if(e.type===`element`){let o=n?!n.includes(e.tagName):a?a.includes(e.tagName):!1;if(!o&&r&&typeof t==`number`&&(o=!r(e,t,i)),o&&i&&typeof t==`number`)return s&&e.children?i.children.splice(t,1,...e.children):i.children.splice(t,1),t}}}function sl(e){let t=e.indexOf(`:`),n=e.indexOf(`?`),r=e.indexOf(`#`),i=e.indexOf(`/`);return t===-1||i!==-1&&t>i||n!==-1&&t>n||r!==-1&&t>r||tl.test(e.slice(0,t))?e:``}function cl(e,t){let n=String(e);if(typeof t!=`string`)throw TypeError(`Expected character`);let r=0,i=n.indexOf(t);for(;i!==-1;)r++,i=n.indexOf(t,i+t.length);return r}function ll(e){if(typeof e!=`string`)throw TypeError(`Expected a string`);return e.replace(/[|\\{}()[\]^$+*?.]/g,`\\$&`).replace(/-/g,`\\x2d`)}function ul(e,t,n){let r=Xs((n||{}).ignore||[]),i=dl(t),a=-1;for(;++a0?{type:`text`,value:a}:void 0),a===!1?r.lastIndex=n+1:(s!==n&&u.push({type:`text`,value:e.value.slice(s,n)}),Array.isArray(a)?u.push(...a):a&&u.push(a),s=n+d[0].length,l=!0),!r.global)break;d=r.exec(e.value)}return l?(s?\]}]+$/.exec(e);if(!t)return[e,void 0];e=e.slice(0,t.index);let n=t[0],r=n.indexOf(`)`),i=cl(e,`(`),a=cl(e,`)`);for(;r!==-1&&i>a;)e+=n.slice(0,r+1),n=n.slice(r+1),r=n.indexOf(`)`),a++;return[e,n]}function kl(e,t){let n=e.input.charCodeAt(e.index-1);return(e.index===0||Ai(n)||ki(n))&&(!t||n!==47)}zl.peek=Rl;function Al(){this.buffer()}function jl(e){this.enter({type:`footnoteReference`,identifier:``,label:``},e)}function Ml(){this.buffer()}function Nl(e){this.enter({type:`footnoteDefinition`,identifier:``,label:``,children:[]},e)}function Pl(e){let t=this.resume(),n=this.stack[this.stack.length-1];n.type,n.identifier=xi(this.sliceSerialize(e)).toLowerCase(),n.label=t}function Fl(e){this.exit(e)}function Il(e){let t=this.resume(),n=this.stack[this.stack.length-1];n.type,n.identifier=xi(this.sliceSerialize(e)).toLowerCase(),n.label=t}function Ll(e){this.exit(e)}function Rl(){return`[`}function zl(e,t,n,r){let i=n.createTracker(r),a=i.move(`[^`),o=n.enter(`footnoteReference`),s=n.enter(`reference`);return a+=i.move(n.safe(n.associationId(e),{after:`]`,before:a})),s(),o(),a+=i.move(`]`),a}function Bl(){return{enter:{gfmFootnoteCallString:Al,gfmFootnoteCall:jl,gfmFootnoteDefinitionLabelString:Ml,gfmFootnoteDefinition:Nl},exit:{gfmFootnoteCallString:Pl,gfmFootnoteCall:Fl,gfmFootnoteDefinitionLabelString:Il,gfmFootnoteDefinition:Ll}}}function Vl(e){let t=!1;return e&&e.firstLineBlank&&(t=!0),{handlers:{footnoteDefinition:n,footnoteReference:zl},unsafe:[{character:`[`,inConstruct:[`label`,`phrasing`,`reference`]}]};function n(e,n,r,i){let a=r.createTracker(i),o=a.move(`[^`),s=r.enter(`footnoteDefinition`),c=r.enter(`label`);return o+=a.move(r.safe(r.associationId(e),{before:o,after:`]`})),c(),o+=a.move(`]:`),e.children&&e.children.length>0&&(a.shift(4),o+=a.move((t?` -`:` `)+r.indentLines(r.containerFlow(e,a.current()),t?Ul:Hl))),s(),o}}function Hl(e,t,n){return t===0?e:Ul(e,t,n)}function Ul(e,t,n){return(n?``:` `)+e}var Wl=[`autolink`,`destinationLiteral`,`destinationRaw`,`reference`,`titleQuote`,`titleApostrophe`];Yl.peek=Xl;function Gl(){return{canContainEols:[`delete`],enter:{strikethrough:ql},exit:{strikethrough:Jl}}}function Kl(){return{unsafe:[{character:`~`,inConstruct:`phrasing`,notInConstruct:Wl}],handlers:{delete:Yl}}}function ql(e){this.enter({type:`delete`,children:[]},e)}function Jl(e){this.exit(e)}function Yl(e,t,n,r){let i=n.createTracker(r),a=n.enter(`strikethrough`),o=i.move(`~~`);return o+=n.containerPhrasing(e,{...i.current(),before:o,after:`~`}),o+=i.move(`~~`),a(),o}function Xl(){return`~`}function Zl(e){return e.length}function Ql(e,t){let n=t||{},r=(n.align||[]).concat(),i=n.stringLength||Zl,a=[],o=[],s=[],c=[],l=0,u=-1;for(;++ul&&(l=e[u].length);++ac[a])&&(c[a]=e)}t.push(o)}o[u]=t,s[u]=r}let d=-1;if(typeof r==`object`&&`length`in r)for(;++dc[d]&&(c[d]=i),p[d]=i),f[d]=o}o.splice(1,0,f),s.splice(1,0,p),u=-1;let m=[];for(;++u `),a.shift(2);let o=n.indentLines(n.containerFlow(e,a.current()),nu);return i(),o}function nu(e,t,n){return`>`+(n?``:` `)+e}function ru(e,t){return iu(e,t.inConstruct,!0)&&!iu(e,t.notInConstruct,!1)}function iu(e,t,n){if(typeof t==`string`&&(t=[t]),!t||t.length===0)return n;let r=-1;for(;++ro&&(o=a):a=1,i=r+t.length,r=n.indexOf(t,i);return o}function su(e,t){return!(t.options.fences!==!1||!e.value||e.lang||!/[^ \r\n]/.test(e.value)||/^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$/.test(e.value))}function cu(e){let t=e.options.fence||"`";if(t!=="`"&&t!==`~`)throw Error("Cannot serialize code with `"+t+"` for `options.fence`, expected `` ` `` or `~`");return t}function lu(e,t,n,r){let i=cu(n),a=e.value||``,o=i==="`"?`GraveAccent`:`Tilde`;if(su(e,n)){let e=n.enter(`codeIndented`),t=n.indentLines(a,uu);return e(),t}let s=n.createTracker(r),c=i.repeat(Math.max(ou(a,i)+1,3)),l=n.enter(`codeFenced`),u=s.move(c);if(e.lang){let t=n.enter(`codeFencedLang${o}`);u+=s.move(n.safe(e.lang,{before:u,after:` `,encode:["`"],...s.current()})),t()}if(e.lang&&e.meta){let t=n.enter(`codeFencedMeta${o}`);u+=s.move(` `),u+=s.move(n.safe(e.meta,{before:u,after:` -`,encode:["`"],...s.current()})),t()}return u+=s.move(` -`),a&&(u+=s.move(a+` -`)),u+=s.move(c),l(),u}function uu(e,t,n){return(n?``:` `)+e}function du(e){let t=e.options.quote||`"`;if(t!==`"`&&t!==`'`)throw Error("Cannot serialize title with `"+t+"` for `options.quote`, expected `\"`, or `'`");return t}function fu(e,t,n,r){let i=du(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.enter(`definition`),s=n.enter(`label`),c=n.createTracker(r),l=c.move(`[`);return l+=c.move(n.safe(n.associationId(e),{before:l,after:`]`,...c.current()})),l+=c.move(`]: `),s(),!e.url||/[\0- \u007F]/.test(e.url)?(s=n.enter(`destinationLiteral`),l+=c.move(`<`),l+=c.move(n.safe(e.url,{before:l,after:`>`,...c.current()})),l+=c.move(`>`)):(s=n.enter(`destinationRaw`),l+=c.move(n.safe(e.url,{before:l,after:e.title?` `:` -`,...c.current()}))),s(),e.title&&(s=n.enter(`title${a}`),l+=c.move(` `+i),l+=c.move(n.safe(e.title,{before:l,after:i,...c.current()})),l+=c.move(i),s()),o(),l}function pu(e){let t=e.options.emphasis||`*`;if(t!==`*`&&t!==`_`)throw Error("Cannot serialize emphasis with `"+t+"` for `options.emphasis`, expected `*`, or `_`");return t}function mu(e){return`&#x`+e.toString(16).toUpperCase()+`;`}function hu(e,t,n){let r=zi(e),i=zi(t);return r===void 0?i===void 0?n===`_`?{inside:!0,outside:!0}:{inside:!1,outside:!1}:i===1?{inside:!0,outside:!0}:{inside:!1,outside:!0}:r===1?i===void 0?{inside:!1,outside:!1}:i===1?{inside:!0,outside:!0}:{inside:!1,outside:!1}:i===void 0?{inside:!1,outside:!1}:i===1?{inside:!0,outside:!1}:{inside:!1,outside:!1}}gu.peek=_u;function gu(e,t,n,r){let i=pu(n),a=n.enter(`emphasis`),o=n.createTracker(r),s=o.move(i),c=o.move(n.containerPhrasing(e,{after:i,before:s,...o.current()})),l=c.charCodeAt(0),u=hu(r.before.charCodeAt(r.before.length-1),l,i);u.inside&&(c=mu(l)+c.slice(1));let d=c.charCodeAt(c.length-1),f=hu(r.after.charCodeAt(0),d,i);f.inside&&(c=c.slice(0,-1)+mu(d));let p=o.move(i);return a(),n.attentionEncodeSurroundingInfo={after:f.outside,before:u.outside},s+c+p}function _u(e,t,n){return n.options.emphasis||`*`}function vu(e,t){let n=!1;return sc(e,function(e){if(`value`in e&&/\r?\n|\r/.test(e.value)||e.type===`break`)return n=!0,!1}),!!((!e.depth||e.depth<3)&&ci(e)&&(t.options.setext||n))}function yu(e,t,n,r){let i=Math.max(Math.min(6,e.depth||1),1),a=n.createTracker(r);if(vu(e,n)){let t=n.enter(`headingSetext`),r=n.enter(`phrasing`),o=n.containerPhrasing(e,{...a.current(),before:` -`,after:` -`});return r(),t(),o+` -`+(i===1?`=`:`-`).repeat(o.length-(Math.max(o.lastIndexOf(`\r`),o.lastIndexOf(` -`))+1))}let o=`#`.repeat(i),s=n.enter(`headingAtx`),c=n.enter(`phrasing`);a.move(o+` `);let l=n.containerPhrasing(e,{before:`# `,after:` -`,...a.current()});return/^[\t ]/.test(l)&&(l=mu(l.charCodeAt(0))+l.slice(1)),l=l?o+` `+l:o,n.options.closeAtx&&(l+=` `+o),c(),s(),l}bu.peek=xu;function bu(e){return e.value||``}function xu(){return`<`}Su.peek=Cu;function Su(e,t,n,r){let i=du(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.enter(`image`),s=n.enter(`label`),c=n.createTracker(r),l=c.move(`![`);return l+=c.move(n.safe(e.alt,{before:l,after:`]`,...c.current()})),l+=c.move(`](`),s(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(s=n.enter(`destinationLiteral`),l+=c.move(`<`),l+=c.move(n.safe(e.url,{before:l,after:`>`,...c.current()})),l+=c.move(`>`)):(s=n.enter(`destinationRaw`),l+=c.move(n.safe(e.url,{before:l,after:e.title?` `:`)`,...c.current()}))),s(),e.title&&(s=n.enter(`title${a}`),l+=c.move(` `+i),l+=c.move(n.safe(e.title,{before:l,after:i,...c.current()})),l+=c.move(i),s()),l+=c.move(`)`),o(),l}function Cu(){return`!`}wu.peek=Tu;function wu(e,t,n,r){let i=e.referenceType,a=n.enter(`imageReference`),o=n.enter(`label`),s=n.createTracker(r),c=s.move(`![`),l=n.safe(e.alt,{before:c,after:`]`,...s.current()});c+=s.move(l+`][`),o();let u=n.stack;n.stack=[],o=n.enter(`reference`);let d=n.safe(n.associationId(e),{before:c,after:`]`,...s.current()});return o(),n.stack=u,a(),i===`full`||!l||l!==d?c+=s.move(d+`]`):i===`shortcut`?c=c.slice(0,-1):c+=s.move(`]`),c}function Tu(){return`!`}Eu.peek=Du;function Eu(e,t,n){let r=e.value||``,i="`",a=-1;for(;RegExp("(^|[^`])"+i+"([^`]|$)").test(r);)i+="`";for(/[^ \r\n]/.test(r)&&(/^[ \r\n]/.test(r)&&/[ \r\n]$/.test(r)||/^`|`$/.test(r))&&(r=` `+r+` `);++a\u007F]/.test(e.url))}ku.peek=Au;function ku(e,t,n,r){let i=du(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.createTracker(r),s,c;if(Ou(e,n)){let t=n.stack;n.stack=[],s=n.enter(`autolink`);let r=o.move(`<`);return r+=o.move(n.containerPhrasing(e,{before:r,after:`>`,...o.current()})),r+=o.move(`>`),s(),n.stack=t,r}s=n.enter(`link`),c=n.enter(`label`);let l=o.move(`[`);return l+=o.move(n.containerPhrasing(e,{before:l,after:`](`,...o.current()})),l+=o.move(`](`),c(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(c=n.enter(`destinationLiteral`),l+=o.move(`<`),l+=o.move(n.safe(e.url,{before:l,after:`>`,...o.current()})),l+=o.move(`>`)):(c=n.enter(`destinationRaw`),l+=o.move(n.safe(e.url,{before:l,after:e.title?` `:`)`,...o.current()}))),c(),e.title&&(c=n.enter(`title${a}`),l+=o.move(` `+i),l+=o.move(n.safe(e.title,{before:l,after:i,...o.current()})),l+=o.move(i),c()),l+=o.move(`)`),s(),l}function Au(e,t,n){return Ou(e,n)?`<`:`[`}ju.peek=Mu;function ju(e,t,n,r){let i=e.referenceType,a=n.enter(`linkReference`),o=n.enter(`label`),s=n.createTracker(r),c=s.move(`[`),l=n.containerPhrasing(e,{before:c,after:`]`,...s.current()});c+=s.move(l+`][`),o();let u=n.stack;n.stack=[],o=n.enter(`reference`);let d=n.safe(n.associationId(e),{before:c,after:`]`,...s.current()});return o(),n.stack=u,a(),i===`full`||!l||l!==d?c+=s.move(d+`]`):i===`shortcut`?c=c.slice(0,-1):c+=s.move(`]`),c}function Mu(){return`[`}function Nu(e){let t=e.options.bullet||`*`;if(t!==`*`&&t!==`+`&&t!==`-`)throw Error("Cannot serialize items with `"+t+"` for `options.bullet`, expected `*`, `+`, or `-`");return t}function Pu(e){let t=Nu(e),n=e.options.bulletOther;if(!n)return t===`*`?`-`:`*`;if(n!==`*`&&n!==`+`&&n!==`-`)throw Error("Cannot serialize items with `"+n+"` for `options.bulletOther`, expected `*`, `+`, or `-`");if(n===t)throw Error("Expected `bullet` (`"+t+"`) and `bulletOther` (`"+n+"`) to be different");return n}function Fu(e){let t=e.options.bulletOrdered||`.`;if(t!==`.`&&t!==`)`)throw Error("Cannot serialize items with `"+t+"` for `options.bulletOrdered`, expected `.` or `)`");return t}function Iu(e){let t=e.options.rule||`*`;if(t!==`*`&&t!==`-`&&t!==`_`)throw Error("Cannot serialize rules with `"+t+"` for `options.rule`, expected `*`, `-`, or `_`");return t}function Lu(e,t,n,r){let i=n.enter(`list`),a=n.bulletCurrent,o=e.ordered?Fu(n):Nu(n),s=e.ordered?o===`.`?`)`:`.`:Pu(n),c=t&&n.bulletLastUsed?o===n.bulletLastUsed:!1;if(!e.ordered){let t=e.children?e.children[0]:void 0;if((o===`*`||o===`-`)&&t&&(!t.children||!t.children[0])&&n.stack[n.stack.length-1]===`list`&&n.stack[n.stack.length-2]===`listItem`&&n.stack[n.stack.length-3]===`list`&&n.stack[n.stack.length-4]===`listItem`&&n.indexStack[n.indexStack.length-1]===0&&n.indexStack[n.indexStack.length-2]===0&&n.indexStack[n.indexStack.length-3]===0&&(c=!0),Iu(n)===o&&t){let t=-1;for(;++t-1?t.start:1)+(n.options.incrementListMarker===!1?0:t.children.indexOf(e))+a);let o=a.length+1;(i===`tab`||i===`mixed`&&(t&&t.type===`list`&&t.spread||e.spread))&&(o=Math.ceil(o/4)*4);let s=n.createTracker(r);s.move(a+` `.repeat(o-a.length)),s.shift(o);let c=n.enter(`listItem`),l=n.indentLines(n.containerFlow(e,s.current()),u);return c(),l;function u(e,t,n){return t?(n?``:` `.repeat(o))+e:(n?a:a+` `.repeat(o-a.length))+e}}function Bu(e,t,n,r){let i=n.enter(`paragraph`),a=n.enter(`phrasing`),o=n.containerPhrasing(e,r);return a(),i(),o}var Vu=Xs([`break`,`delete`,`emphasis`,`footnote`,`footnoteReference`,`image`,`imageReference`,`inlineCode`,`inlineMath`,`link`,`linkReference`,`mdxJsxTextElement`,`mdxTextExpression`,`strong`,`text`,`textDirective`]);function Hu(e,t,n,r){return(e.children.some(function(e){return Vu(e)})?n.containerPhrasing:n.containerFlow).call(n,e,r)}function Uu(e){let t=e.options.strong||`*`;if(t!==`*`&&t!==`_`)throw Error("Cannot serialize strong with `"+t+"` for `options.strong`, expected `*`, or `_`");return t}Wu.peek=Gu;function Wu(e,t,n,r){let i=Uu(n),a=n.enter(`strong`),o=n.createTracker(r),s=o.move(i+i),c=o.move(n.containerPhrasing(e,{after:i,before:s,...o.current()})),l=c.charCodeAt(0),u=hu(r.before.charCodeAt(r.before.length-1),l,i);u.inside&&(c=mu(l)+c.slice(1));let d=c.charCodeAt(c.length-1),f=hu(r.after.charCodeAt(0),d,i);f.inside&&(c=c.slice(0,-1)+mu(d));let p=o.move(i+i);return a(),n.attentionEncodeSurroundingInfo={after:f.outside,before:u.outside},s+c+p}function Gu(e,t,n){return n.options.strong||`*`}function Ku(e,t,n,r){return n.safe(e.value,r)}function qu(e){let t=e.options.ruleRepetition||3;if(t<3)throw Error("Cannot serialize rules with repetition `"+t+"` for `options.ruleRepetition`, expected `3` or more");return t}function Ju(e,t,n){let r=(Iu(n)+(n.options.ruleSpaces?` `:``)).repeat(qu(n));return n.options.ruleSpaces?r.slice(0,-1):r}var Yu={blockquote:tu,break:au,code:lu,definition:fu,emphasis:gu,hardBreak:au,heading:yu,html:bu,image:Su,imageReference:wu,inlineCode:Eu,link:ku,linkReference:ju,list:Lu,listItem:zu,paragraph:Bu,root:Hu,strong:Wu,text:Ku,thematicBreak:Ju};function Xu(){return{enter:{table:Zu,tableData:td,tableHeader:td,tableRow:$u},exit:{codeText:nd,table:Qu,tableData:ed,tableHeader:ed,tableRow:ed}}}function Zu(e){let t=e._align;this.enter({type:`table`,align:t.map(function(e){return e===`none`?null:e}),children:[]},e),this.data.inTable=!0}function Qu(e){this.exit(e),this.data.inTable=void 0}function $u(e){this.enter({type:`tableRow`,children:[]},e)}function ed(e){this.exit(e)}function td(e){this.enter({type:`tableCell`,children:[]},e)}function nd(e){let t=this.resume();this.data.inTable&&(t=t.replace(/\\([\\|])/g,rd));let n=this.stack[this.stack.length-1];n.type,n.value=t,this.exit(e)}function rd(e,t){return t===`|`?t:e}function id(e){let t=e||{},n=t.tableCellPadding,r=t.tablePipeAlign,i=t.stringLength,a=n?` `:`|`;return{unsafe:[{character:`\r`,inConstruct:`tableCell`},{character:` -`,inConstruct:`tableCell`},{atBreak:!0,character:`|`,after:`[ :-]`},{character:`|`,inConstruct:`tableCell`},{atBreak:!0,character:`:`,after:`-`},{atBreak:!0,character:`-`,after:`[:|-]`}],handlers:{inlineCode:f,table:o,tableCell:c,tableRow:s}};function o(e,t,n,r){return l(u(e,n,r),e.align)}function s(e,t,n,r){let i=l([d(e,n,r)]);return i.slice(0,i.indexOf(` -`))}function c(e,t,n,r){let i=n.enter(`tableCell`),o=n.enter(`phrasing`),s=n.containerPhrasing(e,{...r,before:a,after:a});return o(),i(),s}function l(e,t){return Ql(e,{align:t,alignDelimiters:r,padding:n,stringLength:i})}function u(e,t,n){let r=e.children,i=-1,a=[],o=t.enter(`table`);for(;++i0&&!n&&(e[e.length-1][1]._gfmAutolinkLiteralWalkedInto=!0),n}var Id={tokenize:Wd,partial:!0};function Ld(){return{document:{91:{name:`gfmFootnoteDefinition`,tokenize:Vd,continuation:{tokenize:Hd},exit:Ud}},text:{91:{name:`gfmFootnoteCall`,tokenize:Bd},93:{name:`gfmPotentialFootnoteCall`,add:`after`,tokenize:Rd,resolveTo:zd}}}}function Rd(e,t,n){let r=this,i=r.events.length,a=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),o;for(;i--;){let e=r.events[i][1];if(e.type===`labelImage`){o=e;break}if(e.type===`gfmFootnoteCall`||e.type===`labelLink`||e.type===`label`||e.type===`image`||e.type===`link`)break}return s;function s(i){if(!o||!o._balanced)return n(i);let s=xi(r.sliceSerialize({start:o.end,end:r.now()}));return s.codePointAt(0)!==94||!a.includes(s.slice(1))?n(i):(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(i),e.exit(`gfmFootnoteCallLabelMarker`),t(i))}}function zd(e,t){let n=e.length;for(;n--;)if(e[n][1].type===`labelImage`&&e[n][0]===`enter`){e[n][1];break}e[n+1][1].type=`data`,e[n+3][1].type=`gfmFootnoteCallLabelMarker`;let r={type:`gfmFootnoteCall`,start:Object.assign({},e[n+3][1].start),end:Object.assign({},e[e.length-1][1].end)},i={type:`gfmFootnoteCallMarker`,start:Object.assign({},e[n+3][1].end),end:Object.assign({},e[n+3][1].end)};i.end.column++,i.end.offset++,i.end._bufferIndex++;let a={type:`gfmFootnoteCallString`,start:Object.assign({},i.end),end:Object.assign({},e[e.length-1][1].start)},o={type:`chunkString`,contentType:`string`,start:Object.assign({},a.start),end:Object.assign({},a.end)},s=[e[n+1],e[n+2],[`enter`,r,t],e[n+3],e[n+4],[`enter`,i,t],[`exit`,i,t],[`enter`,a,t],[`enter`,o,t],[`exit`,o,t],[`exit`,a,t],e[e.length-2],e[e.length-1],[`exit`,r,t]];return e.splice(n,e.length-n+1,...s),e}function Bd(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a=0,o;return s;function s(t){return e.enter(`gfmFootnoteCall`),e.enter(`gfmFootnoteCallLabelMarker`),e.consume(t),e.exit(`gfmFootnoteCallLabelMarker`),c}function c(t){return t===94?(e.enter(`gfmFootnoteCallMarker`),e.consume(t),e.exit(`gfmFootnoteCallMarker`),e.enter(`gfmFootnoteCallString`),e.enter(`chunkString`).contentType=`string`,l):n(t)}function l(s){if(a>999||s===93&&!o||s===null||s===91||W(s))return n(s);if(s===93){e.exit(`chunkString`);let a=e.exit(`gfmFootnoteCallString`);return i.includes(xi(r.sliceSerialize(a)))?(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(s),e.exit(`gfmFootnoteCallLabelMarker`),e.exit(`gfmFootnoteCall`),t):n(s)}return W(s)||(o=!0),a++,e.consume(s),s===92?u:l}function u(t){return t===91||t===92||t===93?(e.consume(t),a++,l):l(t)}}function Vd(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a,o=0,s;return c;function c(t){return e.enter(`gfmFootnoteDefinition`)._container=!0,e.enter(`gfmFootnoteDefinitionLabel`),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),l}function l(t){return t===94?(e.enter(`gfmFootnoteDefinitionMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionMarker`),e.enter(`gfmFootnoteDefinitionLabelString`),e.enter(`chunkString`).contentType=`string`,u):n(t)}function u(t){if(o>999||t===93&&!s||t===null||t===91||W(t))return n(t);if(t===93){e.exit(`chunkString`);let n=e.exit(`gfmFootnoteDefinitionLabelString`);return a=xi(r.sliceSerialize(n)),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),e.exit(`gfmFootnoteDefinitionLabel`),f}return W(t)||(s=!0),o++,e.consume(t),t===92?d:u}function d(t){return t===91||t===92||t===93?(e.consume(t),o++,u):u(t)}function f(t){return t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),i.includes(a)||i.push(a),K(e,p,`gfmFootnoteDefinitionWhitespace`)):n(t)}function p(e){return t(e)}}function Hd(e,t,n){return e.check(Gi,t,e.attempt(Id,t,n))}function Ud(e){e.exit(`gfmFootnoteDefinition`)}function Wd(e,t,n){let r=this;return K(e,i,`gfmFootnoteDefinitionIndent`,5);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`gfmFootnoteDefinitionIndent`&&i[2].sliceSerialize(i[1],!0).length===4?t(e):n(e)}}function Gd(e){let t=(e||{}).singleTilde,n={name:`strikethrough`,tokenize:i,resolveAll:r};return t??=!0,{text:{126:n},insideSpan:{null:[n]},attentionMarkers:{null:[126]}};function r(e,t){let n=-1;for(;++n1?r(a):(e.consume(a),o++,c);if(o<2&&!t)return r(a);let l=e.exit(`strikethroughSequenceTemporary`),u=zi(a);return l._open=!u||u===2&&!!s,l._close=!s||s===2&&!!u,n(a)}}}var Kd=class{constructor(){this.map=[],this.index=new Map}add(e,t,n){qd(this,e,t,n)}consume(e){if(this.map.sort(function(e,t){return e[0]-t[0]}),this.map.length===0)return;let t=this.map.length,n=[];for(;t>0;)--t,n.push(e.slice(this.map[t][0]+this.map[t][1]),this.map[t][2]),e.length=this.map[t][0];n.push(e.slice()),e.length=0;let r=n.pop();for(;r;){for(let t of r)e.push(t);r=n.pop()}this.map.length=0,this.index.clear()}};function qd(e,t,n,r){if(n===0&&r.length===0)return;let i=e.index.get(t);if(i){i[1]+=n,i[2].push(...r);return}let a=[t,n,r];e.map.push(a),e.index.set(t,a)}function Jd(e,t){let n=!1,r=[];for(;t-1;){let{type:e}=r.events[t][1];if(e===`lineEnding`||e===`linePrefix`)t--;else break}let i=t>-1?r.events[t][1].type:null,a=i===`tableHead`||i===`tableRow`?S:c;return a===S&&r.parser.lazy[r.now().line]?n(e):a(e)}function c(t){return e.enter(`tableHead`),e.enter(`tableRow`),l(t)}function l(e){return e===124?u(e):(o=!0,a+=1,u(e))}function u(t){return t===null?n(t):U(t)?a>1?(a=0,r.interrupt=!0,e.exit(`tableRow`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),p):n(t):G(t)?K(e,u,`whitespace`)(t):(a+=1,o&&(o=!1,i+=1),t===124?(e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),o=!0,u):(e.enter(`data`),d(t)))}function d(t){return t===null||t===124||W(t)?(e.exit(`data`),u(t)):(e.consume(t),t===92?f:d)}function f(t){return t===92||t===124?(e.consume(t),d):d(t)}function p(t){return r.interrupt=!1,r.parser.lazy[r.now().line]?n(t):(e.enter(`tableDelimiterRow`),o=!1,G(t)?K(e,m,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):m(t))}function m(t){return t===45||t===58?g(t):t===124?(o=!0,e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),h):x(t)}function h(t){return G(t)?K(e,g,`whitespace`)(t):g(t)}function g(t){return t===58?(a+=1,o=!0,e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),_):t===45?(a+=1,_(t)):t===null||U(t)?b(t):x(t)}function _(t){return t===45?(e.enter(`tableDelimiterFiller`),v(t)):x(t)}function v(t){return t===45?(e.consume(t),v):t===58?(o=!0,e.exit(`tableDelimiterFiller`),e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),y):(e.exit(`tableDelimiterFiller`),y(t))}function y(t){return G(t)?K(e,b,`whitespace`)(t):b(t)}function b(n){return n===124?m(n):n===null||U(n)?!o||i!==a?x(n):(e.exit(`tableDelimiterRow`),e.exit(`tableHead`),t(n)):x(n)}function x(e){return n(e)}function S(t){return e.enter(`tableRow`),C(t)}function C(n){return n===124?(e.enter(`tableCellDivider`),e.consume(n),e.exit(`tableCellDivider`),C):n===null||U(n)?(e.exit(`tableRow`),t(n)):G(n)?K(e,C,`whitespace`)(n):(e.enter(`data`),w(n))}function w(t){return t===null||t===124||W(t)?(e.exit(`data`),C(t)):(e.consume(t),t===92?T:w)}function T(t){return t===92||t===124?(e.consume(t),w):w(t)}}function Zd(e,t){let n=-1,r=!0,i=0,a=[0,0,0,0],o=[0,0,0,0],s=!1,c=0,l,u,d,f=new Kd;for(;++nn[2]+1){let t=n[2]+1,r=n[3]-n[2]-1;e.add(t,r,[])}}e.add(n[3]+1,0,[[`exit`,o,t]])}return i!==void 0&&(a.end=Object.assign({},ef(t.events,i)),e.add(i,0,[[`exit`,a,t]]),a=void 0),a}function $d(e,t,n,r,i){let a=[],o=ef(t.events,n);i&&(i.end=Object.assign({},o),a.push([`exit`,i,t])),r.end=Object.assign({},o),a.push([`exit`,r,t]),e.add(n+1,0,a)}function ef(e,t){let n=e[t],r=n[0]===`enter`?`start`:`end`;return n[1][r]}var tf={name:`tasklistCheck`,tokenize:rf};function nf(){return{text:{91:tf}}}function rf(e,t,n){let r=this;return i;function i(t){return r.previous!==null||!r._gfmTasklistFirstContentOfListItem?n(t):(e.enter(`taskListCheck`),e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),a)}function a(t){return W(t)?(e.enter(`taskListCheckValueUnchecked`),e.consume(t),e.exit(`taskListCheckValueUnchecked`),o):t===88||t===120?(e.enter(`taskListCheckValueChecked`),e.consume(t),e.exit(`taskListCheckValueChecked`),o):n(t)}function o(t){return t===93?(e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),e.exit(`taskListCheck`),s):n(t)}function s(r){return U(r)?t(r):G(r)?e.check({tokenize:af},t,n)(r):n(r)}}function af(e,t,n){return K(e,r,`whitespace`);function r(e){return e===null?n(e):t(e)}}function of(e){return _i([xd(),Ld(),Gd(e),Yd(),nf()])}var sf={};function cf(e){let t=this,n=e||sf,r=t.data(),i=r.micromarkExtensions||=[],a=r.fromMarkdownExtensions||=[],o=r.toMarkdownExtensions||=[];i.push(of(n)),a.push(ud()),o.push(dd(n))}function lf(e,t){let n=e.trim().replace(/^\.?\//,``);if(!n||t.length===0)return null;if(t.includes(n))return n;let r=t.filter(e=>e.endsWith(`/${n}`));return r.length===1?r[0]:null}var uf=e=>e.replace(/[.*+?^${}()|[\]\\/-]/g,`\\$&`);function df(e,t){if(!e||t.length===0)return e;let n=new Set(t);for(let e of t){let r=e.slice(e.lastIndexOf(`/`)+1);r.includes(`.`)&&lf(r,t)===e&&n.add(r)}let r=[...n].sort((e,t)=>t.length-e.length).map(uf),i=RegExp(`(?]*>)/).map((e,t)=>t%2==1?e:e.replace(i,"`$1`")).join(``)}function ff(e){let t=typeof e.children==`string`?e.children:``;return!String(e.className??``).includes(`language-`)&&!t.includes(` -`)}function pf(e){let t=[],n=[],r=null,i=()=>{let e=n.join(` -`).trim();if(n=[],!e)return;let r=e.trimStart(),i=r.startsWith("```")||r.startsWith(`~~~`)||r.startsWith(`|`)||r.startsWith(`<`),a=t[t.length-1];if(a&&!a.bare&&/^#{1,6}\s/.test(a.text)&&!a.text.includes(` -`)&&!i){a.text=`${a.text}\n\n${e}`;return}t.push({text:e,bare:i})};for(let t of e.split(` -`)){let e=/^\s*(```|~~~)/.exec(t);if(r){n.push(t),e&&e[1]===r&&(r=null);continue}if(e){n.length&&i(),r=e[1],n.push(t);continue}if(t.trim()===``){i();continue}n.push(t)}return i(),t}function mf({text:e,files:t=[],onOpenFile:n}){let r=e=>n&&e&&t.length>0?lf(e,t):null,i=e=>n?.(e),a=n&&t.length>0?df(e,t):e;return(0,R.jsx)(`div`,{className:`md text-[15px] leading-[1.5] break-words`,children:(0,R.jsx)(rl,{remarkPlugins:[cf],components:{a:({href:e,children:t,...n})=>{let a=e&&!/^[a-z]+:/i.test(e)?r(O(e)):null;return a?(0,R.jsx)(`button`,{type:`button`,className:`md-file`,onClick:()=>i(a),children:t}):(0,R.jsx)(`a`,{href:e,...n,target:`_blank`,rel:`noreferrer noopener`,children:t})},code:({children:e,...t})=>{let n=ff({children:e,...t})&&typeof e==`string`?r(e):null;return n?(0,R.jsx)(`button`,{type:`button`,className:`md-file`,onClick:()=>i(n),title:n,children:e}):(0,R.jsx)(`code`,{...t,children:e})}},children:a})})}function hf({path:e,onClose:t}){let n=s(),i=(0,L.useRef)(null);if(Dn(i,!!e),(0,L.useEffect)(()=>{if(!e)return;let n=e=>e.key===`Escape`&&t();return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),!e)return null;let a=e.split(`/`).pop()??e,o=r(a);return(0,R.jsxs)(`div`,{ref:i,role:`dialog`,"aria-modal":`true`,"aria-label":a,className:`fixed inset-0 z-[60] flex flex-col bg-bg`,children:[(0,R.jsxs)(`header`,{className:`safe-top shrink-0 flex items-center gap-2 border-b border-border bg-surface/90 px-3 py-2 backdrop-blur`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:t,"aria-label":n(`Close`),className:`p-2 rounded-full text-muted hover:bg-surface-2`,children:(0,R.jsx)(Pe,{size:20})}),(0,R.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,R.jsx)(`div`,{className:`font-semibold text-[15px] truncate`,children:a}),(0,R.jsx)(`div`,{className:`text-[12px] text-muted truncate`,children:e})]}),o!==`html`&&(0,R.jsx)(`a`,{href:h(e),target:`_blank`,rel:`noreferrer noopener`,"aria-label":n(`Open in a new tab`),className:`p-2 rounded-full text-muted hover:bg-surface-2`,children:(0,R.jsx)(ke,{size:19})}),(0,R.jsx)(`a`,{href:h(e,!0),"aria-label":n(`Download`),className:`p-2 rounded-full text-muted hover:bg-surface-2`,children:(0,R.jsx)(dt,{size:19})})]}),(0,R.jsx)(`div`,{className:`min-h-0 flex-1 overflow-auto`,children:(0,R.jsx)(gf,{path:e,kind:o})})]})}function gf({path:e,kind:t}){let n=s();return t===`image`?(0,R.jsx)(`div`,{className:`flex h-full items-center justify-center p-3`,children:(0,R.jsx)(`img`,{src:h(e),alt:e,className:`max-h-full max-w-full rounded-2xl object-contain`})}):t===`pdf`?(0,R.jsx)(`iframe`,{title:e,src:h(e),className:`h-full w-full bg-white`}):t===`html`?(0,R.jsx)(vf,{path:e}):t===`event`?(0,R.jsx)(yf,{path:e}):t===`text`||t===`code`||t===`data`?(0,R.jsx)(Sf,{path:e,kind:t}):(0,R.jsxs)(`div`,{className:`p-8 text-center text-muted text-[14px]`,children:[n(`No preview for this file type.`),` `,(0,R.jsx)(`a`,{href:h(e,!0),className:`text-accent font-medium`,children:n(`Download it`)})]})}function _f(e){let[t,n]=(0,L.useState)(null),[r,i]=(0,L.useState)(null);return(0,L.useEffect)(()=>{let t=!0;return n(null),i(null),M.fileText(e).then(e=>t&&n(e)).catch(e=>t&&i(e.message)),()=>{t=!1}},[e]),{text:t,error:r}}function vf({path:e}){let{text:t,error:n}=_f(e);return n?(0,R.jsx)(Tf,{error:n}):t===null?(0,R.jsx)(wf,{}):(0,R.jsx)(`iframe`,{title:e,srcDoc:t,sandbox:`allow-scripts allow-popups allow-forms allow-modals`,referrerPolicy:`no-referrer`,className:`h-full w-full bg-white`})}function yf({path:e}){let t=s(),{text:n,error:r}=_f(e);if(r)return(0,R.jsx)(Tf,{error:r});if(n===null)return(0,R.jsx)(wf,{});let i=bf(n);return(0,R.jsxs)(`div`,{className:`mx-auto max-w-[560px] px-5 py-6 space-y-4`,children:[i.length===0&&(0,R.jsx)(`div`,{className:`text-center text-muted text-[14px]`,children:t(`No event in this file.`)}),i.map((e,n)=>(0,R.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface shadow-sm p-5`,children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,R.jsx)(`div`,{className:`rounded-2xl bg-accent/12 text-accent p-2.5`,children:(0,R.jsx)(et,{size:22})}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[18px] font-semibold leading-snug`,children:e.summary||t(`Untitled event`)}),(0,R.jsx)(`div`,{className:`mt-1 text-[14px] text-muted`,children:xf(e)}),e.location&&(0,R.jsxs)(`div`,{className:`mt-1 flex items-center gap-1 text-[14px] text-muted`,children:[(0,R.jsx)(Et,{size:14}),` `,e.location]})]})]}),e.description&&(0,R.jsx)(`div`,{className:`mt-4 whitespace-pre-wrap text-[14px] leading-relaxed`,children:e.description})]},n)),(0,R.jsxs)(`a`,{href:h(e,!0),download:!0,className:`flex items-center justify-center gap-2 rounded-2xl bg-accent text-accent-fg px-4 py-3 text-[15px] font-medium`,children:[(0,R.jsx)(tt,{size:18}),` `,t(`Add to calendar`)]}),(0,R.jsx)(`p`,{className:`text-center text-[12.5px] text-muted`,children:t(`Opens in your calendar app, which asks before it saves anything.`)})]})}function bf(e){let t=e.replace(/\r\n/g,` -`).replace(/\n[ \t]/g,``).split(` -`),n=[],r=null,i=e=>e.replace(/\\n/gi,` -`).replace(/\\([,;\\])/g,`$1`),a=(e,t)=>{if(/VALUE=DATE(?![-])/i.test(t)||/^\d{8}$/.test(e)){let t=/^(\d{4})(\d{2})(\d{2})/.exec(e);return{at:t?new Date(+t[1],t[2]-1,+t[3]):null,allDay:!0}}let n=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z?)$/.exec(e);if(!n)return{at:null,allDay:!1};let r=[+n[1],n[2]-1,+n[3],+n[4],+n[5],+(n[6]??0)];return{at:n[7]?new Date(Date.UTC(...r)):new Date(...r),allDay:!1}};for(let e of t)if(e===`BEGIN:VEVENT`)r={summary:``,location:``,description:``,start:null,end:null,allDay:!1};else if(e===`END:VEVENT`&&r)n.push(r),r=null;else if(r){let t=e.indexOf(`:`);if(t<0)continue;let[n,...o]=e.slice(0,t).split(`;`),s=e.slice(t+1),c=o.join(`;`);if(n===`SUMMARY`)r.summary=i(s);else if(n===`LOCATION`)r.location=i(s);else if(n===`DESCRIPTION`)r.description=i(s);else if(n===`DTSTART`){let e=a(s,c);r.start=e.at,r.allDay=e.allDay}else n===`DTEND`&&(r.end=a(s,c).at)}return n}function xf(e){if(!e.start)return``;let t=m(),n=e=>e.toLocaleDateString(t,{weekday:`short`,year:`numeric`,month:`short`,day:`numeric`}),r=e=>e.toLocaleTimeString(t,{hour:`numeric`,minute:`2-digit`});if(e.allDay){let t=e.end?new Date(e.end.getTime()-864e5):e.start;return t>e.start?`${n(e.start)} – ${n(t)}`:`${n(e.start)} · ${_(`all day`)}`}return e.end?e.start.toDateString()===e.end.toDateString()?`${n(e.start)} ${r(e.start)}–${r(e.end)}`:`${n(e.start)} ${r(e.start)} – ${n(e.end)} ${r(e.end)}`:`${n(e.start)} ${r(e.start)}`}function Sf({path:e,kind:t}){let{text:n,error:r}=_f(e);return r?(0,R.jsx)(Tf,{error:r}):n===null?(0,R.jsx)(wf,{}):t===`text`&&/\.md$/i.test(e)?(0,R.jsx)(`div`,{className:`mx-auto max-w-[720px] px-5 py-4`,children:(0,R.jsx)(mf,{text:n})}):t===`data`&&/\.csv$/i.test(e)?(0,R.jsx)(Cf,{text:n}):(0,R.jsx)(`pre`,{className:`whitespace-pre-wrap break-words px-4 py-3 font-mono text-[12.5px] leading-relaxed`,children:n})}function Cf({text:e}){let t=s(),n=e.split(/\r?\n/).filter(e=>e.trim()).slice(0,500).map(e=>e.split(`,`));if(!n.length)return(0,R.jsx)(`div`,{className:`p-6 text-muted text-center`,children:t(`Empty file.`)});let[r,...i]=n;return(0,R.jsx)(`div`,{className:`overflow-auto p-3`,children:(0,R.jsxs)(`table`,{className:`min-w-full text-[13px] border-separate border-spacing-0`,children:[(0,R.jsx)(`thead`,{children:(0,R.jsx)(`tr`,{children:r.map((e,t)=>(0,R.jsx)(`th`,{className:`sticky top-0 bg-surface-2 text-left font-semibold px-3 py-2 border-b border-border whitespace-nowrap`,children:e},t))})}),(0,R.jsx)(`tbody`,{children:i.map((e,t)=>(0,R.jsx)(`tr`,{className:`odd:bg-surface`,children:e.map((e,t)=>(0,R.jsx)(`td`,{className:`px-3 py-1.5 border-b border-border/60 whitespace-nowrap`,children:e},t))},t))})]})})}function wf(){return(0,R.jsx)(`div`,{className:`flex h-full items-center justify-center text-muted`,children:(0,R.jsx)(A,{className:`animate-spin`,size:22})})}function Tf({error:e}){let t=s();return(0,R.jsx)(`div`,{className:`p-8 text-center text-rose-600 dark:text-rose-300 text-[14px]`,children:t(`Could not load the file: {error}`,{error:e})})}var Ef=`OpenAI's terms cover using a ChatGPT plan inside OpenAI's own Codex; other apps have had this access cut off before (OpenCode, January 2026). If it stops working, an API key does.`,Df=2e3,Of=6e5;function kf({compact:e,onChanged:t}){let n=s(),{toast:r}=E(),[a,o]=(0,L.useState)(null),[c,l]=(0,L.useState)(`unknown`),[u,d]=(0,L.useState)(null),[f,p]=(0,L.useState)(null),[m,h]=(0,L.useState)(!0),[g,_]=(0,L.useState)(``),[v,y]=(0,L.useState)(null),b=(0,L.useRef)(0),x=(0,L.useCallback)(async()=>{try{let e=await M.chatgptStatus();return o(e),l(`yes`),e}catch(e){return/not found/i.test(e.message)?l(`no`):y(e.message),null}},[]);(0,L.useEffect)(()=>{x().then(e=>{e?.pending&&e.url&&(b.current=Date.now(),h(e.port_bound!==!1),p(e.url))})},[x]),(0,L.useEffect)(()=>{if(!f)return;let e=setInterval(()=>{x().then(e=>{e?.signed_in?(p(null),ue(),r(n(`Signed in with {label}.`,{label:e.label||`ChatGPT`})),t?.()):e&&e.pending===!1&&e.error?(p(null),y(n(e.error))):Date.now()-b.current>Of&&(p(null),y(n(`The sign-in did not finish in ten minutes. Try again.`)))})},Df);return()=>clearInterval(e)},[f,x,t,r,n]);let S=async()=>{d(`login`),y(null);try{let e=await M.chatgptLogin();b.current=Date.now(),h(e.port_bound!==!1),_(``),p(e.url),window.open(e.url,`_blank`,`noopener`)}catch(e){/not found/i.test(e.message)?l(`no`):y(e.message)}finally{d(null)}},C=async()=>{let e=g.trim();if(e){d(`paste`),y(null);try{await M.chatgptCallback(e),_(``)}catch(e){let t=e.message;/state/i.test(t)?y(n(`That address belongs to another sign-in attempt. Open the page again and paste the address it ends on.`)):/no sign-in|409/i.test(t)?(p(null),y(n(`The sign-in did not finish in ten minutes. Try again.`))):y(t)}finally{d(null)}}},w=async()=>{d(`use`),y(null);try{await M.setLLM({provider:`chatgpt`,model:a?.models?.[0]??``,base_url:``,api_key:``}),ue(),r(n(`ChatGPT answers the chat now.`)),t?.()}catch(e){y(e.message)}finally{d(null)}},T=async()=>{d(`logout`),y(null);try{await M.chatgptLogout(),ue(),await x(),t?.()}catch(e){y(e.message)}finally{d(null)}},D=n(`Covers chat and the hands. Pictures and clips are not part of it; those need a provider with image or video models.`);return(0,R.jsxs)(`div`,{className:i(`space-y-2`,!e&&`rounded-2xl bg-surface-2/60 p-3`),children:[!e&&(0,R.jsxs)(`div`,{className:`flex items-center gap-2 text-[13px] font-medium`,children:[(0,R.jsx)(jt,{size:15,className:`text-accent`}),` `,n(`Sign in with ChatGPT`)]}),c===`no`?(0,R.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[n(`This nanoMuse has no sign-in route yet. In a terminal on this computer, run`),` `,(0,R.jsx)(`code`,{className:`rounded bg-surface-2 px-1 py-0.5 text-[12px]`,children:`nanomuse chatgpt login`}),` `,n(`and reload this page.`)]}):a?.signed_in?(0,R.jsxs)(`div`,{className:`space-y-2`,children:[(0,R.jsxs)(`p`,{className:`text-[12.5px]`,children:[(0,R.jsx)(`span`,{className:`font-medium`,children:n(`Signed in with {label}.`,{label:a.label||`ChatGPT`})}),` `,(0,R.jsx)(`span`,{className:`text-muted`,children:D})]}),(0,R.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,R.jsxs)(`button`,{type:`button`,disabled:u!==null,onClick:()=>void w(),className:i(Me,`inline-flex items-center gap-1.5 py-2`),children:[u===`use`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):(0,R.jsx)(jt,{size:14}),` `,n(`Use it for the chat`)]}),(0,R.jsxs)(`button`,{type:`button`,disabled:u!==null,onClick:()=>void T(),className:i(Ae,`inline-flex items-center gap-1.5`),children:[u===`logout`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):(0,R.jsx)(wt,{size:14}),` `,n(`Sign out of ChatGPT`)]})]})]}):f?(0,R.jsxs)(`div`,{className:`space-y-2`,children:[(0,R.jsxs)(`p`,{className:`flex items-start gap-2 text-[12.5px] text-muted`,children:[(0,R.jsx)(A,{size:14,className:`mt-0.5 shrink-0 animate-spin`}),(0,R.jsx)(`span`,{children:n(m?`A page from OpenAI opened in a new tab. Sign in there; this card follows on its own.`:`A page from OpenAI opened in a new tab. Sign in there; when the browser ends on an address that will not load, paste that address below.`)})]}),(0,R.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,R.jsxs)(`a`,{href:f,target:`_blank`,rel:`noopener noreferrer`,className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(ke,{size:14}),` `,n(`Open the page again`)]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>p(null),className:Ae,children:n(`Cancel`)})]}),(0,R.jsxs)(`form`,{className:`flex flex-wrap items-center gap-2`,onSubmit:e=>{e.preventDefault(),C()},children:[(0,R.jsx)(`input`,{type:`url`,value:g,onChange:e=>_(e.target.value),placeholder:`http://localhost:1455/auth/callback?code=…`,"aria-label":n(`The address the browser ended on`),className:`min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-[12.5px]`}),(0,R.jsxs)(`button`,{type:`submit`,disabled:u!==null||!g.trim(),className:i(Ae,`inline-flex items-center gap-1.5`),children:[u===`paste`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):null,` `,n(`Use this address`)]})]}),!m&&(0,R.jsx)(`p`,{className:`text-[11.5px] text-muted`,children:n(`This nanoMuse runs where the browser cannot reach port 1455, so it cannot pick the sign-in up by itself.`)})]}):(0,R.jsxs)(`div`,{className:`space-y-2`,children:[(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:D}),(0,R.jsxs)(`button`,{type:`button`,disabled:u!==null||c===`unknown`,onClick:()=>void S(),className:i(Me,`inline-flex items-center gap-1.5 py-2`),children:[u===`login`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):(0,R.jsx)(Ct,{size:14}),` `,n(`Sign in with ChatGPT`)]})]}),(0,R.jsx)(`p`,{className:`text-[11.5px] leading-snug text-muted`,children:n(Ef)}),v&&(0,R.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:v})]})}var Af=``,jf=`nm.connections.preset`;function Mf(e,t=ie().preset){try{sessionStorage.setItem(jf,t)}catch{}e(`connections`)}function Nf(){try{let e=sessionStorage.getItem(jf);return e&&sessionStorage.removeItem(jf),e}catch{return null}}function Pf({info:e,exhausted:t,compact:n}){let r=s(),{toast:a,setTab:o,state:l}=E(),u=l.hub?.account,d=ie(u),f=ce(u),p=c(),m=e.invite_bonus_cny??5,h=e.own_key_docs||e.guidance?.docs||Af,g=e.invite_url||``,[_,v]=(0,L.useState)(!1),[y,b]=(0,L.useState)(!1),x=(e.guidance?.providers??Ee(f)).filter(e=>e.id!==d.id).map(e=>({id:e.id,name:fe(e,p),keyUrl:e.key_url,covers:`covers`in e?e.covers:e.capabilities})),S=Te().map(e=>e.name),C=async e=>{try{await navigator.clipboard.writeText(e),a(r(`Copied`))}catch{a(e)}},w=async()=>{let e=r(`Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my link: {link}`,{link:g}),t=navigator;if(t.share)try{await t.share({text:e});return}catch{}await C(e)},T=Se(),[D,O]=pe(`exhausted`,t),k=_e(be(r,`exhausted`)),ee=t?r(`The free allowance is used up.`):r(`Nearly used up: ¥{left} of ¥{grant} left.`,{left:(e.left??0).toFixed(2),grant:(e.grant??0).toFixed(0)});return(0,R.jsxs)(`div`,{className:i(`space-y-2.5`,!n&&`pt-1`),children:[(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:i(`font-semibold`,n?`text-[13.5px]`:`text-[15px]`),children:ee}),(0,R.jsx)(`div`,{className:`mt-0.5 text-[12.5px] text-muted`,children:r(`Three ways on; your sign-in and your devices keep working whichever you pick.`)})]}),(0,R.jsxs)(Ff,{icon:(0,R.jsx)(F,{size:16}),tone:`bg-accent/12 text-accent`,title:r(`Use your own model key`),children:[(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:ge(r,u)}),(0,R.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>Mf(o,d.preset),className:i(Me,`inline-flex items-center gap-1.5 py-2`),children:[(0,R.jsx)(F,{size:14}),` `,r(`Set it up`)]}),(0,R.jsxs)(`a`,{href:d.keyUrl,target:`_blank`,rel:`noopener noreferrer`,className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(ke,{size:14}),` `,r(`Get a key from {vendor}`,{vendor:d.label})]}),h?(0,R.jsxs)(`a`,{href:h,target:`_blank`,rel:`noopener noreferrer`,className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(ke,{size:14}),` `,r(`Step-by-step guide`)]}):null]}),x.length>0&&(0,R.jsxs)(`div`,{children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>v(!_),className:`inline-flex items-center gap-1 text-[12px] text-muted`,children:[_?(0,R.jsx)(Ie,{size:13}):(0,R.jsx)(je,{size:13}),` `,r(`Other providers and what each key covers`)]}),_&&(0,R.jsx)(`ul`,{className:`mt-1.5 space-y-1 text-[12.5px]`,children:x.map(e=>(0,R.jsxs)(`li`,{className:`flex flex-wrap items-baseline justify-between gap-x-3`,children:[(0,R.jsxs)(`span`,{children:[(0,R.jsx)(`span`,{className:`font-medium`,children:e.name}),` `,(0,R.jsxs)(`span`,{className:`text-muted`,children:[`· `,ve(r,e.covers)]})]}),(0,R.jsxs)(`span`,{className:`flex gap-2`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>Mf(o,De(e.id)),className:`text-accent`,children:r(`Set it up`)}),(0,R.jsxs)(`a`,{href:e.keyUrl,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-0.5 text-accent`,children:[r(`Get a key`),` `,(0,R.jsx)(ke,{size:11})]})]})]},e.id))})]})]}),(0,R.jsxs)(Ff,{icon:(0,R.jsx)(Ct,{size:16}),tone:`bg-emerald-500/12 text-emerald-600 dark:text-emerald-300`,title:r(`Sign in with a plan you already pay for`),children:[(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:r(`{plans}: the plan's models answer here, no key to make. ChatGPT works on every device, the others on the phones. A ChatGPT sign-in covers chat and the hands; pictures and clips still want a key.`,{plans:S.join(r(`, `))})}),y?(0,R.jsx)(kf,{compact:!0,onChanged:()=>b(!1)}):(0,R.jsxs)(`button`,{type:`button`,onClick:()=>b(!0),className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(Ct,{size:14}),` `,r(`Sign in with ChatGPT`)]})]}),(0,R.jsx)(Ff,{icon:(0,R.jsx)(mn,{size:16}),tone:`bg-violet-500/12 text-violet-600 dark:text-violet-300`,title:r(`Invite a friend: +¥{bonus} for you and +¥{bonus} for them, for each new person who signs up with your link.`,{bonus:m.toFixed(0)}),children:g?(0,R.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void w(),className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(Yt,{size:14}),` `,r(`Share the link`)]}),(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void C(g),className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(lt,{size:14}),` `,r(`Copy the link`)]})]}):(0,R.jsxs)(`button`,{type:`button`,onClick:()=>o(`account`),className:i(Ae,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(Yt,{size:14}),` `,r(`Your invite link is under Account`)]})}),t&&D&&(0,R.jsxs)(Ff,{icon:(0,R.jsx)(he,{size:16}),tone:`bg-amber-400/15 text-amber-600 dark:text-amber-300`,title:k,children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>de(T.repo_url),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(he,{size:14}),` `,r(`Star on GitHub`)]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>{me(),O(!1)},className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:r(`I already starred`)})]})]})}function Ff({icon:e,tone:t,title:n,children:r}){return(0,R.jsxs)(`div`,{className:`flex gap-3 rounded-2xl bg-surface-2/60 p-3`,children:[(0,R.jsx)(`span`,{className:i(`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl`,t),children:e}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1 space-y-2`,children:[(0,R.jsx)(`div`,{className:`text-[13px] font-medium leading-snug`,children:n}),r]})]})}var If=`nm.cloud.warned_grant`;function Lf(){let e=s(),{state:t,setTab:n}=E(),[r,i]=(0,L.useState)(null),a=(0,L.useRef)(0),o=t.hub?.account,c=!!o?.signed_in&&!!o.is_model,l=t.finishedAt;if((0,L.useEffect)(()=>{if(!c||!l||r||Date.now()-a.current<6e4)return;a.current=Date.now();let e=!1;return M.cloudMe().then(t=>{if(e)return;let n=t.spend,r=n.grant??n.allowance_cny??0,a=n.left??null;if(n.unlimited||!n.warn||a===null||a<=0)return;let o=null;try{o=localStorage.getItem(If)}catch{}o!==String(r)&&i({left:a,grant:r,invite_url:t.invite?.url,invite_bonus_cny:n.invite_bonus_cny,invitee_bonus_cny:n.invitee_bonus_cny,own_key_docs:n.own_key_docs,guidance:n.guidance})}).catch(()=>{}),()=>{e=!0}},[c,l,r]),!r)return null;let u=()=>{try{localStorage.setItem(If,String(r.grant??0))}catch{}i(null)};return(0,R.jsxs)(`div`,{className:`rise mx-3 mb-1.5 flex items-start gap-2 rounded-2xl bg-amber-500/12 px-3 py-2 text-[12.5px] leading-snug text-amber-800 dark:text-amber-200`,children:[(0,R.jsx)(tn,{size:14,className:`mt-0.5 shrink-0`}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[e(`Nearly used up: ¥{left} of ¥{grant} left.`,{left:(r.left??0).toFixed(2),grant:(r.grant??0).toFixed(0)}),` `,e(`Invite a friend (+¥{invite} for each of you) or bring your own key; your sign-in keeps working either way.`,{invite:(r.invite_bonus_cny??5).toFixed(0)}),` `,(0,R.jsx)(`button`,{type:`button`,onClick:()=>{u(),n(`account`)},className:`font-semibold underline decoration-dotted underline-offset-2`,children:e(`See the ways`)})]}),(0,R.jsx)(`button`,{type:`button`,onClick:u,"aria-label":e(`Dismiss`),className:`shrink-0 rounded-full p-0.5 opacity-70 hover:opacity-100`,children:(0,R.jsx)(Pe,{size:14})})]})}var Rf=`nm.cloud.first_sign_in`,zf=`nm:first-sign-in`;function Bf(){try{sessionStorage.setItem(Rf,`1`)}catch{}window.dispatchEvent(new Event(zf))}function Vf(){try{sessionStorage.removeItem(Rf)}catch{}window.dispatchEvent(new Event(zf))}function Hf(){try{return sessionStorage.getItem(Rf)===`1`}catch{return!1}}function Uf(){let[e,t]=(0,L.useState)(Hf);return(0,L.useEffect)(()=>{let e=()=>t(Hf());return window.addEventListener(zf,e),()=>window.removeEventListener(zf,e)},[]),e}function Wf(){let e=s(),{toast:t,refreshHub:n,setTab:r,state:a}=E(),o=ie(a.hub?.account),[c,l]=(0,L.useState)(`password`),[u,d]=(0,L.useState)(``),[f,p]=(0,L.useState)(``),[m,h]=(0,L.useState)(!1),[g,_]=(0,L.useState)(null),v=async()=>{Vf(),await n().catch(()=>void 0)},y=async()=>{if(!(u.length<8||u!==f)){h(!0),_(null);try{await M.cloudPassword(u),t(e(`Password set.`)),d(``),p(``),l(`source`)}catch(t){_(e(t.message))}finally{h(!1)}}},b=async()=>{await v(),Mf(r,o.preset)},x=(0,L.useRef)(null);Dn(x,!0);let S=f.length>0&&f!==u;return(0,R.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-[2px] sm:items-center`,role:`dialog`,"aria-modal":`true`,children:(0,R.jsxs)(`div`,{ref:x,className:`safe-bottom w-full max-w-[520px] rounded-t-[28px] bg-bg p-6 shadow-2xl sm:rounded-[28px]`,children:[(0,R.jsxs)(`div`,{className:`mb-4 flex gap-1`,children:[(0,R.jsx)(`span`,{className:i(`h-1.5 w-5 rounded-full bg-accent`)}),(0,R.jsx)(`span`,{className:i(`h-1.5 rounded-full transition-all`,c===`source`?`w-5 bg-accent`:`w-1.5 bg-border`)})]}),c===`password`?(0,R.jsxs)(`form`,{className:`space-y-4`,onSubmit:e=>{e.preventDefault(),y()},children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,R.jsx)(`span`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-accent/12 text-accent`,children:(0,R.jsx)(F,{size:20})}),(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`h2`,{className:`text-[20px] font-bold tracking-tight`,children:e(`Set a password`)}),(0,R.jsx)(`p`,{className:`mt-1 text-[13.5px] leading-relaxed text-muted`,children:e(`Your account is in. With a password, your phone and your other computers sign in at once, without waiting for a code. Optional; you can set one later under Account.`)})]})]}),(0,R.jsxs)(`div`,{className:`space-y-2`,children:[(0,R.jsx)(`input`,{type:`password`,value:u,onChange:e=>d(e.target.value),autoComplete:`new-password`,autoFocus:!0,placeholder:e(`At least 8 characters`),className:Fe}),(0,R.jsx)(`input`,{type:`password`,value:f,onChange:e=>p(e.target.value),autoComplete:`new-password`,placeholder:e(`Once more`),className:i(Fe,S&&`border-rose-400`)}),S&&(0,R.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:e(`The two do not match.`)})]}),g&&(0,R.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:g}),(0,R.jsxs)(`div`,{className:`flex gap-2`,children:[(0,R.jsx)(`button`,{type:`button`,disabled:m,onClick:()=>l(`source`),className:Ae,children:e(`Skip`)}),(0,R.jsxs)(`button`,{type:`submit`,disabled:m||u.length<8||u!==f,className:i(Me,`flex-1 py-3`),children:[m?(0,R.jsx)(A,{size:15,className:`animate-spin`}):null,` `,e(`Set the password`),` `,(0,R.jsx)(Ke,{size:16})]})]})]}):(0,R.jsxs)(`div`,{className:`space-y-4`,children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,R.jsx)(`span`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-accent/12 text-accent`,children:(0,R.jsx)(at,{size:20})}),(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`h2`,{className:`text-[20px] font-bold tracking-tight`,children:e(`Which model answers?`)}),(0,R.jsx)(`p`,{className:`mt-1 text-[13.5px] leading-relaxed text-muted`,children:e(`Your account already brings one. You can add your own API key as well and switch at any time.`)})]})]}),(0,R.jsxs)(`ul`,{className:`space-y-2 text-[13px] leading-snug`,children:[(0,R.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2 px-3 py-2.5`,children:[(0,R.jsx)(at,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,R.jsxs)(`span`,{children:[(0,R.jsx)(`span`,{className:`font-medium`,children:e(`Use the nanoMuse Cloud model`)}),(0,R.jsx)(`span`,{className:`block text-muted`,children:e(`DeepSeek for the chat and Qwen for the hands, with a free allowance per account paid by the developer. Nothing to configure.`)})]})]}),(0,R.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2 px-3 py-2.5`,children:[(0,R.jsx)(F,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,R.jsxs)(`span`,{children:[(0,R.jsx)(`span`,{className:`font-medium`,children:e(`I have my own API key`)}),(0,R.jsx)(`span`,{className:`block text-muted`,children:o.preset===`qwen`?e(`Alibaba Cloud Bailian first (one key covers chat, the hands, pictures and clips), then DeepSeek, Kimi, Zhipu, OpenAI, OpenRouter and any OpenAI-compatible endpoint. The key stays on this device.`):e(`OpenRouter first (one account, one key, pay as you go), then OpenAI, Anthropic, Gemini, DeepSeek and any OpenAI-compatible endpoint. The key stays on this device.`)})]})]}),(0,R.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2 px-3 py-2.5`,children:[(0,R.jsx)(Ct,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,R.jsxs)(`span`,{children:[(0,R.jsx)(`span`,{className:`font-medium`,children:e(`Sign in with a plan you already pay for`)}),(0,R.jsx)(`span`,{className:`block text-muted`,children:e(`A ChatGPT plan signs in from Connections and answers the chat and the hands. Pictures and clips still want a key.`)})]})]})]}),(0,R.jsxs)(`div`,{className:`flex gap-2`,children:[(0,R.jsx)(`button`,{type:`button`,disabled:m,onClick:()=>void b(),className:Ae,children:e(`I have my own API key`)}),(0,R.jsxs)(`button`,{type:`button`,disabled:m,onClick:()=>void v(),className:i(Me,`flex-1 py-3`),children:[e(`Use the nanoMuse Cloud model`),` `,(0,R.jsx)(Ke,{size:16})]})]})]})]})})}var Gf=`nanomuse_devtools`;function Kf(){try{return localStorage.getItem(Gf)===`1`}catch{return!1}}var qf=Kf(),Jf=new Set;function Yf(){return qf}function Xf(e){qf=e;try{localStorage.setItem(Gf,e?`1`:`0`)}catch{}Jf.forEach(e=>e())}function Zf(e){return Jf.add(e),()=>Jf.delete(e)}function Qf(){return(0,L.useSyncExternalStore)(Zf,Yf,Yf)}function $f(e,t,n){if(!n.signedIn||n.chatOnCloud||n.deviceChat)return;let r=e[t];if(r?.type===`notice`&&r.level===`error`&&r.code&&r.code!==`allowance`&&!r.model_used)for(let n=t-1;n>=0;n--){let t=e[n];if(t?.type===`user`){let e=(t.files??[]).map(e=>e.path);return t.text||e.length>0?{text:t.text,files:e}:void 0}}}var ep=[`idle`,`working`,`waiting`,`happy`,`error`];function tp({event:e,name:t=`nanoMuse`}){let n=s(),{toast:r}=E(),[a,o]=(0,L.useState)(null),c=e.cost??{},l=e.candidates??[null,null,null,null],u=e.moods??{},d=e.clips??{},f=async(e,t)=>{o(e);try{await t()}catch(e){r(e.message)}finally{o(null)}},p=e.stage===`estimate`||e.stage===`drawing`||e.stage===`choose`||e.stage===`posing`||e.stage===`animating`,m=c.clips?n(`{n} pictures and {c} clips`,{n:c.pictures??8,c:c.clips}):n(`{n} pictures`,{n:c.pictures??8}),g=(()=>{if(c.cloud===!1)return n(`{what}, made with your own key at your provider's prices.`,{what:m});if(c.error)return n(`The cost could not be checked: {error}`,{error:c.error});if(typeof c.cny!=`number`)return n(`Checking the cost…`);if(c.unlimited)return n(`About ¥{cny} for {what}; your account has no limit.`,{cny:c.cny.toFixed(2),what:m});let e=typeof c.left_cny==`number`?c.left_cny.toFixed(2):`?`;return c.affordable===!1?n(`About ¥{cny} for {what}, more than the ¥{left} left in your allowance.`,{cny:c.cny.toFixed(2),what:m,left:e}):n(`About ¥{cny} for {what}; ¥{left} left in your allowance.`,{cny:c.cny.toFixed(2),what:m,left:e})})();return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`div`,{className:i(`w-full max-w-[360px] overflow-hidden rounded-3xl rounded-tl-lg border bg-surface shadow-sm`,p?`border-accent/50`:`border-border/70`),children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-3.5 pb-2`,children:[(0,R.jsx)(`div`,{className:i(`mt-0.5 rounded-full p-2`,p?`bg-accent/12 text-accent`:`bg-surface-2 text-fg/80`),children:(0,R.jsx)(tn,{size:18})}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsxs)(`div`,{className:`text-[12.5px] font-medium text-muted`,children:[e.stage===`estimate`&&n(`A new look for {name}`,{name:t}),e.stage===`drawing`&&n(`Drawing four to choose from…`),e.stage===`choose`&&n(`Pick one: tap it, or say which`),e.stage===`posing`&&n(`Drawing the poses…`),e.stage===`animating`&&n(`The new look is on; making the clips…`),e.stage===`done`&&n(`The new look is on`),e.stage===`cancelled`&&n(`Not this time`),e.stage===`failed`&&n(`That did not work`)]}),(0,R.jsx)(`div`,{className:`mt-0.5 text-[14px] leading-snug`,children:e.description}),e.reference&&(0,R.jsxs)(`div`,{className:`mt-1.5 flex items-center gap-2 text-[12px] text-muted`,children:[(0,R.jsx)(`img`,{src:h(e.reference),alt:``,draggable:!1,className:`h-9 w-9 rounded-lg object-cover`}),n(`drawn from the picture you attached`)]})]})]}),e.stage===`estimate`&&(0,R.jsxs)(`div`,{className:`px-4 pb-3.5 space-y-2.5`,children:[(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:g}),(0,R.jsxs)(`div`,{className:`flex gap-2`,children:[(0,R.jsxs)(`button`,{type:`button`,disabled:a!==null||c.cloud!==!1&&typeof c.cny!=`number`&&!c.error,onClick:()=>f(`start`,()=>M.avatarStart(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`inline-flex items-center gap-1.5 py-2`),children:[a===`start`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):(0,R.jsx)(tn,{size:14}),` `,n(`Draw`)]}),(0,R.jsxs)(`button`,{type:`button`,disabled:a!==null,onClick:()=>f(`cancel`,()=>M.avatarCancel(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(Pe,{size:14}),` `,n(`Not now`)]})]})]}),(e.stage===`drawing`||e.stage===`choose`)&&(0,R.jsxs)(`div`,{className:`px-4 pb-3.5 space-y-2.5`,children:[(0,R.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:l.map((t,r)=>(0,R.jsxs)(`button`,{type:`button`,disabled:!t||e.stage!==`choose`||a!==null,onClick:()=>f(`pick${r}`,()=>M.avatarChoose(e.session,r)),"aria-label":n(`Candidate {n}`,{n:r+1}),className:i(`relative aspect-square overflow-hidden rounded-2xl bg-[#f1efeb] transition`,t&&e.stage===`choose`?`hover:ring-2 hover:ring-accent`:``),children:[t?(0,R.jsx)(`img`,{src:h(t),alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,R.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center text-muted`,children:e.stage===`drawing`?(0,R.jsx)(A,{size:20,className:`animate-spin`}):(0,R.jsx)(Pe,{size:18,className:`opacity-50`})}),a===`pick${r}`&&(0,R.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center bg-bg/60`,children:(0,R.jsx)(A,{size:20,className:`animate-spin`})}),(0,R.jsx)(`span`,{className:`absolute left-1.5 top-1.5 rounded-full bg-bg/80 px-1.5 text-[10.5px] font-semibold text-fg/80`,children:r+1})]},r))}),e.errors&&e.errors.length>0&&(0,R.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:n(e.errors[0])}),e.stage===`choose`&&(0,R.jsxs)(`div`,{className:`flex gap-2`,children:[(0,R.jsxs)(`button`,{type:`button`,disabled:a!==null,onClick:()=>f(`redraw`,()=>M.avatarStart(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[a===`redraw`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):(0,R.jsx)(Le,{size:14}),` `,n(`Redraw`)]}),(0,R.jsxs)(`button`,{type:`button`,disabled:a!==null,onClick:()=>f(`cancel`,()=>M.avatarCancel(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[(0,R.jsx)(Pe,{size:14}),` `,n(`Not now`)]})]})]}),(e.stage===`posing`||e.stage===`animating`||e.stage===`done`)&&(0,R.jsxs)(`div`,{className:`px-4 pb-3.5 space-y-2`,children:[(0,R.jsx)(`div`,{className:`flex gap-1.5`,children:ep.map(t=>(0,R.jsxs)(`div`,{className:`relative aspect-square flex-1 overflow-hidden rounded-xl bg-[#f1efeb]`,children:[d[t]?(0,R.jsx)(`video`,{src:h(d[t]),poster:u[t]?h(u[t]):void 0,autoPlay:!0,loop:!0,muted:!0,playsInline:!0,disablePictureInPicture:!0,className:`h-full w-full object-cover`},d[t]):u[t]?(0,R.jsx)(`img`,{src:h(u[t]),alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,R.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center text-muted`,children:(0,R.jsx)(A,{size:14,className:`animate-spin`})}),e.stage===`animating`&&u[t]&&!d[t]&&t!==`error`&&(0,R.jsx)(`span`,{className:`absolute bottom-1 right-1 rounded-full bg-white/80 p-0.5 text-muted`,children:(0,R.jsx)(A,{size:10,className:`animate-spin`})})]},t))}),e.stage===`done`?(0,R.jsxs)(`p`,{className:`flex items-center gap-1.5 text-[12.5px] text-muted`,children:[(0,R.jsx)(Oe,{size:14,className:`text-emerald-600`}),` `,n(`Idle, working, waiting, happy, sorry: the face follows what {name} is doing.`,{name:t})]}):e.stage===`animating`?(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:n(`The face is already on; four short clips are being made from the poses, a few minutes.`)}):(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:n(`Four more pictures from the one you picked; this takes a minute or two.`)}),e.errors&&e.errors.length>0&&e.stage===`done`&&(0,R.jsxs)(`p`,{className:`text-[12px] text-muted`,children:[e.errors.some(e=>!e.includes(` clip:`))&&n(`Some poses could not be drawn and show the idle picture instead.`),` `,e.errors.some(e=>e.includes(` clip:`))&&n(`Some clips could not be made; those moods stay still.`)]})]}),(e.stage===`failed`||e.stage===`cancelled`)&&e.message&&(0,R.jsx)(`p`,{className:`px-4 pb-3.5 text-[12.5px] text-muted`,children:n(e.message)})]})})}function np(){let e=rp();if(!e||typeof e.keepRunning!=`function`)return null;try{return JSON.parse(e.keepRunning())}catch{return null}}function rp(){return window.NanoMuseAndroid??null}function ip(){let e=rp();return!!e&&typeof e.takeOverBrowser==`function`&&(typeof e.hasBrowser!=`function`||e.hasBrowser())}function ap(){let e=rp();if(!e||typeof e.accessibilityState!=`function`)return null;let t=e.accessibilityState();return t===`on`||t===`off`||t===`unsupported`?t:null}function op(e,t=15){switch(e){case`web_search`:return(0,R.jsx)(Kt,{size:t});case`web_fetch`:case`browser`:return(0,R.jsx)(ht,{size:t});case`files`:return(0,R.jsx)(pt,{size:t});case`shell`:return(0,R.jsx)(un,{size:t});case`python_execute`:return(0,R.jsx)(st,{size:t});case`read_emails`:case`send_email`:return(0,R.jsx)(Tt,{size:t});case`goals`:return(0,R.jsx)(ln,{size:t});case`remember`:case`recall`:case`forget`:return(0,R.jsx)(Ze,{size:t});case`ask_user`:return(0,R.jsx)(Ot,{size:t});case`devices`:return(0,R.jsx)(Ft,{size:t});case`device_shell`:return(0,R.jsx)(un,{size:t});case`device_files`:case`device_get`:case`device_put`:return(0,R.jsx)(pt,{size:t});case`device_open`:return(0,R.jsx)(ke,{size:t});case`device_screen`:return(0,R.jsx)(It,{size:t});case`device_notify`:return(0,R.jsx)(Ye,{size:t});case`delegate`:return(0,R.jsx)(qt,{size:t});case`computer_screen`:return(0,R.jsx)(It,{size:t});case`computer_act`:return(0,R.jsx)(Rt,{size:t});case`computer_task`:case`computer_app`:return(0,R.jsx)(_t,{size:t});case`phone_screen`:case`phone_act`:case`phone_task`:return(0,R.jsx)(en,{size:t});default:return(0,R.jsx)(Xe,{size:t})}}function sp({device:e,className:t}){let n=s();return(0,R.jsxs)(`span`,{className:i(`inline-flex max-w-full items-center gap-1 rounded-full bg-accent/12 px-2 py-0.5 text-[11px] font-medium text-accent`,t),children:[(0,R.jsx)(Ft,{size:11,className:`shrink-0`}),` `,(0,R.jsx)(`span`,{className:`truncate`,children:n(`on {device}`,{device:e})})]})}var cp={safe:`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300`,moderate:`bg-amber-500/15 text-amber-700 dark:text-amber-300`,sensitive:`bg-rose-500/15 text-rose-700 dark:text-rose-300`};function lp({risk:e}){return(0,R.jsx)(`span`,{className:i(`px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide`,cp[e]),children:_(e)})}function up({args:e}){let t=Object.entries(e??{}).filter(([,e])=>e!=null&&e!==``);return t.length?(0,R.jsx)(`dl`,{className:`mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[13px]`,children:t.map(([e,t])=>(0,R.jsxs)(`div`,{className:`contents`,children:[(0,R.jsx)(`dt`,{className:`text-muted`,children:e}),(0,R.jsx)(`dd`,{className:`break-words whitespace-pre-wrap font-mono text-[12.5px] leading-snug`,children:String(t)})]},e))}):null}function dp({event:e}){let[t,n]=(0,L.useState)(!1),r=e.status===`running`?(0,R.jsx)(A,{size:14,className:`animate-spin text-accent`}):e.status===`ok`?(0,R.jsx)(Oe,{size:14,className:`text-emerald-500`}):e.status===`blocked`?(0,R.jsx)(Je,{size:14,className:`text-rose-500`}):(0,R.jsx)(Pe,{size:14,className:`text-rose-500`});return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>n(e=>!e),className:`max-w-full text-left rounded-2xl bg-surface-2/80 border border-border/60 px-3 py-1.5 text-[13px] text-muted hover:text-fg transition`,children:[(0,R.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,R.jsx)(`span`,{className:`text-muted`,children:op(e.tool)}),(0,R.jsx)(`span`,{className:`truncate flex-1`,children:e.title||e.summary||e.tool}),e.device&&(0,R.jsx)(sp,{device:e.device}),r,e.output?t?(0,R.jsx)(je,{size:13}):(0,R.jsx)(nt,{size:13}):null]}),t&&(0,R.jsxs)(`div`,{className:`mt-2 text-fg`,children:[(0,R.jsx)(up,{args:e.args}),e.output&&(0,R.jsx)(`pre`,{className:`mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-bg p-2 text-[12px] leading-snug`,children:e.output})]})]})})}function fp(e,t,n){if(!t)return e.replace(/_/g,` `);switch(e){case`shell`:return _(`{what} commands`,{what:t.split(`,`).join(`, `)});case`send_email`:return _(`email to {who}`,{who:t.split(`,`).join(`, `)});case`remote_control`:return _(`the device {name}`,{name:typeof n?.device==`string`&&n.device?n.device:t});case`computer_app`:return _(`the hands in {app}`,{app:typeof n?.name==`string`&&n.name?n.name:t});default:return t}}function pp(e,t,n,r){switch(e){case`once`:return _(`Once`);case`conversation`:return _(`For this conversation`);case`session`:return _(`Until restart`);case`24h`:return _(`For 24 hours`);case`always`:return _(`Always for {subject}`,{subject:fp(t,n,r)});default:return e}}function mp({event:e,name:t=`nanoMuse`,onDecide:n}){let[r,a]=(0,L.useState)(!1),[o,c]=(0,L.useState)(!1),[l,d]=(0,L.useState)(!1),[f,p]=(0,L.useState)(!1),m=(e,t)=>{f||(p(!0),Promise.resolve(n(e,t)).finally(()=>p(!1)))},h=(0,L.useRef)(null),g=(0,L.useRef)(null),_=s(),y=e.status===`pending`,b=e.risk===`sensitive`,x=(e.grant_options??[`once`]).filter(e=>e!==`once`),S=e.egress_target||([`web_fetch`,`browser`,`web_search`].includes(e.tool)?e.target:null),C=e.tool===`send_email`?e.target:null,w=Object.values(e.args??{}).some(e=>e!=null&&e!==``);return(0,L.useEffect)(()=>{let e=g.current;e&&!r&&d(e.scrollHeight>e.clientHeight+2)},[e.args,r]),(0,L.useEffect)(()=>{(r||o)&&h.current?.scrollIntoView({block:`end`,behavior:`smooth`})},[r,o]),(0,R.jsx)(`div`,{className:`rise flex justify-start`,ref:h,children:(0,R.jsxs)(`div`,{className:i(`w-full max-w-[600px] overflow-hidden rounded-[22px] bg-surface shadow-[0_2px_12px_rgba(0,0,0,0.12)] dark:border dark:border-border dark:shadow-none`,y&&b&&`border border-rose-400/60`),children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-4 pb-2`,children:[(0,R.jsx)(`div`,{className:i(`flex h-10 w-10 shrink-0 items-center justify-center rounded-full`,b?`bg-rose-500/12 text-rose-500`:`bg-surface-2 text-fg`),children:b?(0,R.jsx)(Xt,{size:22}):op(e.tool,22)}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5 text-[12.5px] font-medium text-muted`,children:[(0,R.jsx)(`span`,{children:e.remote?_(`{name} on {device} wants to`,{name:t,device:e.remote.name}):_(`{name} wants to`,{name:t})}),e.remote&&(0,R.jsx)(sp,{device:e.remote.name})]}),(0,R.jsx)(`div`,{className:`mt-0.5 break-words text-[17px] font-semibold leading-[22px]`,children:e.summary}),S?(0,R.jsxs)(`div`,{className:`mt-1 flex items-center gap-1 text-[13px] text-muted`,children:[(0,R.jsx)(ht,{size:12}),` `,(0,R.jsx)(`span`,{className:`truncate`,children:_(`at {host}`,{host:S})})]}):C?(0,R.jsxs)(`div`,{className:`mt-1 flex items-center gap-1 text-[13px] text-muted`,children:[(0,R.jsx)(Tt,{size:12}),` `,(0,R.jsx)(`span`,{className:`truncate`,children:_(`to {recipient}`,{recipient:C})})]}):null,e.purpose&&(0,R.jsx)(`div`,{className:`mt-1.5 break-words text-[13px] leading-snug text-muted line-clamp-2`,children:e.purpose}),b&&(0,R.jsx)(`div`,{className:`mt-1.5`,children:(0,R.jsx)(lp,{risk:e.risk})})]})]}),e.warnings?.length>0&&(0,R.jsx)(`div`,{className:`space-y-1 px-4 pb-2`,children:e.warnings.map(e=>(0,R.jsxs)(`div`,{className:`flex items-start gap-1.5 text-[12.5px] text-rose-600 dark:text-rose-300`,children:[(0,R.jsx)(fn,{size:13,className:`mt-0.5 shrink-0`}),` `,(0,R.jsx)(`span`,{children:_(e)})]},e))}),(w||e.reasons?.length>0)&&(0,R.jsx)(`div`,{className:`px-4 pb-2`,children:(0,R.jsxs)(`div`,{className:`rounded-xl bg-surface-2 px-3 py-2.5`,children:[(0,R.jsx)(`div`,{className:`text-[11px] font-medium text-muted`,children:_(`Exactly what will run`)}),(0,R.jsxs)(`div`,{ref:g,className:i(`-mt-1`,!r&&`max-h-40 overflow-hidden`),children:[(0,R.jsx)(up,{args:e.args}),e.reasons?.length>0&&(0,R.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:_(`Why it asks: {reasons}`,{reasons:e.reasons.map(v).join(` · `)})})]}),(l||r)&&(0,R.jsxs)(`button`,{type:`button`,className:`mt-1 flex items-center gap-1 text-[12.5px] text-accent`,onClick:()=>a(e=>!e),children:[r?(0,R.jsx)(je,{size:13}):(0,R.jsx)(nt,{size:13}),` `,_(r?`Fewer options`:`Show all`)]})]})}),y?(0,R.jsxs)(`div`,{className:`flex flex-col gap-2 px-4 pb-4 pt-2`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>m(!0,`once`),disabled:f,className:`h-[46px] w-full rounded-full bg-accent text-[15px] font-semibold text-accent-fg transition active:scale-[0.98] disabled:opacity-60`,children:_(`Allow once`)}),x.filter(e=>o||e===`conversation`||e===`always`).map(t=>(0,R.jsx)(`button`,{type:`button`,onClick:()=>m(!0,t),disabled:f,className:`h-11 w-full truncate rounded-full bg-surface-2 px-4 text-[15px] font-medium transition active:scale-[0.98] disabled:opacity-60`,children:pp(t,e.tool,e.target,e.args)},t)),(0,R.jsx)(`button`,{type:`button`,onClick:()=>m(!1,`once`),disabled:f,className:`h-11 w-full rounded-full bg-surface-2 text-[15px] font-medium transition active:scale-[0.98] disabled:opacity-60`,children:_(`Deny`)}),x.length===0?(0,R.jsx)(`div`,{className:`text-center text-[12px] text-muted`,children:_(`This kind of action is approved one at a time.`)}):!o&&x.some(e=>e!==`conversation`&&e!==`always`)&&(0,R.jsx)(`button`,{type:`button`,className:`self-center text-[12.5px] text-muted`,onClick:()=>c(!0),children:_(`Allow {subject} for longer…`,{subject:fp(e.tool,e.target,e.args)})})]}):(0,R.jsxs)(`div`,{className:i(`flex items-center gap-1.5 border-t border-border px-4 py-2.5 text-[13px] font-medium`,e.status===`approved`?`text-emerald-600 dark:text-emerald-300`:`text-muted`),children:[e.status===`approved`?(0,R.jsx)(Oe,{size:15}):(0,R.jsx)(Pe,{size:15}),e.status===`approved`?`${_(`Approved`)}${e.scope&&e.scope!==`once`?` · ${pp(e.scope,e.tool,e.target).toLowerCase()}`:``}`:e.status===`denied`?_(`Denied`):_(`Expired without an answer`),(0,R.jsx)(`span`,{className:`ml-auto font-normal`,children:u(e.updated_ts??e.ts)})]})]})})}function hp({event:e,name:t}){let n=s();return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`div`,{className:`max-w-full rounded-[20px] rounded-bl-md border border-accent/30 bg-surface px-4 py-3`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-1.5 text-[12.5px] font-medium text-accent`,children:[(0,R.jsx)(Ot,{size:14}),` `,n(`{name} asks`,{name:t})]}),(0,R.jsx)(`div`,{className:`mt-1 text-[15px] leading-snug whitespace-pre-wrap break-words`,children:e.text}),e.status===`pending`&&(0,R.jsx)(`div`,{className:`mt-1.5 text-[12.5px] text-muted`,children:n(`Reply below to continue.`)}),e.status===`answered`&&e.answer&&(0,R.jsxs)(`div`,{className:`mt-2 text-[13px] text-muted border-t border-border pt-2`,children:[n(`You:`),` `,(0,R.jsx)(`span`,{className:`text-fg`,children:e.answer})]})]})})}function gp(e){return e.source===`reminder`||e.source===`memory`?t(e.text):_(e.text,e.vars)}function _p({event:e,onCloudRetry:t}){let n=s(),[r,a]=(0,L.useState)(!1),[o,c]=(0,L.useState)(!1),[l,u]=(0,L.useState)(null),d=e.detail&&e.detail!==e.text?e.detail:null;return e.code===`allowance`&&e.allowance?(0,R.jsxs)(`div`,{className:`rise flex flex-col items-center gap-2 px-6`,children:[(0,R.jsx)(`div`,{className:`max-w-full rounded-2xl bg-amber-500/12 px-3 py-1.5 text-center text-[12.5px] leading-snug text-amber-700 dark:text-amber-300`,children:n(e.text)}),(0,R.jsx)(`div`,{className:`w-full max-w-[520px]`,children:(0,R.jsx)(Pf,{info:e.allowance,exhausted:!0,compact:!0})})]}):(0,R.jsx)(`div`,{className:`rise flex justify-center px-6`,children:(0,R.jsxs)(`div`,{className:i(`max-w-full rounded-2xl px-3 py-1.5 text-center text-[12.5px] leading-snug`,e.level===`info`&&`bg-surface-2/70 text-muted`,e.level===`warn`&&`bg-amber-500/12 text-amber-700 dark:text-amber-300`,e.level===`error`&&`bg-rose-500/12 text-rose-700 dark:text-rose-300`),children:[gp(e),d&&(0,R.jsxs)(R.Fragment,{children:[` `,(0,R.jsx)(`button`,{type:`button`,onClick:()=>a(e=>!e),className:`underline decoration-dotted underline-offset-2 opacity-70 hover:opacity-100`,children:n(r?`Hide details`:`Details`)}),r&&(0,R.jsx)(`div`,{className:`mt-1.5 break-all text-left font-mono text-[11px] opacity-80`,children:d})]}),t&&(0,R.jsxs)(`div`,{className:`mt-1.5 flex flex-col items-center gap-1`,children:[(0,R.jsx)(`button`,{type:`button`,disabled:o,onClick:()=>{t&&(c(!0),u(null),t().catch(e=>u(n(e.message||`Could not send`))).finally(()=>c(!1)))},className:`rounded-full bg-accent/12 px-3 py-1 text-[12px] font-medium text-accent disabled:opacity-50`,children:n(`Use nanoMuse Cloud this time`)}),l&&(0,R.jsx)(`div`,{className:`text-[12px] text-rose-700 dark:text-rose-300`,children:l})]})]})})}function vp({event:e,onOpen:t}){let[r,i]=(0,L.useState)(!1),a=s(),o=e.status===`live`,c=e.url;try{c=new URL(e.url).host}catch{}return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(e.id),"aria-label":a(`Browser: {title}`,{title:e.title||c}),className:`w-full max-w-[340px] overflow-hidden rounded-3xl rounded-tl-lg border border-border bg-surface shadow-sm hover:bg-surface-2 transition text-left`,children:[(0,R.jsxs)(`div`,{className:`relative aspect-[16/10] bg-surface-2`,children:[r?(0,R.jsxs)(`div`,{className:`absolute inset-0 flex flex-col items-center justify-center gap-1 text-muted`,children:[(0,R.jsx)(ht,{size:22}),(0,R.jsx)(`span`,{className:`text-[12px]`,children:a(`Frame no longer available`)})]}):(0,R.jsx)(`img`,{src:n(e.thread,e.frame),alt:e.title||c,onError:()=>i(!0),className:`absolute inset-0 h-full w-full object-cover object-top`},e.frame),(0,R.jsx)(`div`,{className:`absolute left-2.5 top-2.5 flex items-center gap-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[10.5px] font-semibold text-white`,children:o?(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse`}),` `,a(`LIVE`)]}):(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(ht,{size:11}),` `,a(`BROWSER`)]})})]}),(0,R.jsxs)(`div`,{className:`flex items-center gap-2 px-3.5 py-2.5`,children:[(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.title||c}),(0,R.jsxs)(`div`,{className:`text-[12px] text-muted truncate`,children:[e.by_user&&e.action.startsWith(`You`)?a(`You`)+e.action.slice(3):e.action,` · `,c]})]}),(0,R.jsx)(nt,{size:16,className:`text-muted shrink-0`})]})]})})}function yp({event:e,name:t=`nanoMuse`}){let n=s(),{state:r,toast:a}=E(),[o,c]=(0,L.useState)(!1),[l,d]=(0,L.useState)(!1),f=e.status===`live`,p=e.last,m=[e.app,e.title].filter(Boolean).join(` · `),h=r.holds.find(t=>t.thread===e.thread&&t.tool===`computer`),g=async()=>{c(!0);try{await M.stopHands()}catch{}finally{c(!1)}},_=async()=>{d(!0);try{await M.openHold(e.thread,`computer`)}catch(e){a(e.message)}finally{d(!1)}};return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`div`,{className:i(`w-full max-w-[360px] overflow-hidden rounded-3xl rounded-tl-lg border bg-surface shadow-sm`,f?`border-accent/50`:`border-border/70`),children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-3.5 pb-2`,children:[(0,R.jsx)(`div`,{className:i(`mt-0.5 rounded-full p-2`,f?`bg-accent/12 text-accent`:`bg-surface-2 text-fg/80`),children:(0,R.jsx)(_t,{size:18})}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2 text-[12.5px] font-medium text-muted`,children:[(0,R.jsx)(`span`,{children:f?n(`{name} is using this computer`,{name:t}):e.status===`stopped`?n(`Hands stopped`):n(`Hands done`)}),f&&(0,R.jsxs)(`span`,{className:`flex items-center gap-1 rounded-full bg-accent/12 px-2 py-0.5 text-[10.5px] font-semibold text-accent`,children:[(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 animate-pulse rounded-full bg-accent`}),` `,n(`LIVE`)]})]}),e.text&&(0,R.jsx)(`div`,{className:`mt-0.5 break-words text-[15px] font-semibold leading-snug`,children:e.text}),p?.action&&(0,R.jsxs)(`div`,{className:`mt-1.5 flex items-center gap-1.5 text-[13px] text-fg/90`,children:[(0,R.jsx)(Rt,{size:13,className:`shrink-0 text-muted`}),(0,R.jsx)(`span`,{className:`truncate`,children:wp(p,n)})]}),m&&(0,R.jsx)(`div`,{className:`mt-1 truncate text-[12px] text-muted`,children:m}),e.notice&&(0,R.jsx)(`div`,{className:`mt-1.5 rounded-2xl bg-amber-500/12 px-3 py-1.5 text-[12.5px] text-amber-700 dark:text-amber-300`,children:e.notice}),f&&h&&(0,R.jsx)(bp,{hold:h})]})]}),(0,R.jsxs)(`div`,{className:`flex items-center gap-2 border-t border-border/70 px-4 py-2 text-[12px] text-muted`,children:[(0,R.jsx)(`span`,{children:n(`{n} steps`,{n:e.steps})}),(0,R.jsx)(`span`,{className:`ml-auto`,children:u(e.updated_ts??e.ts)}),f&&!h&&(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void _(),disabled:l,className:`ml-1 inline-flex items-center gap-1.5 rounded-full bg-accent/12 px-3 py-1 text-[12.5px] font-semibold text-accent transition active:scale-95 disabled:opacity-60`,children:[(0,R.jsx)(_t,{size:11}),` `,n(`Take over`)]}),f&&(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void g(),disabled:o,className:`ml-1 inline-flex items-center gap-1.5 rounded-full bg-fg px-3 py-1 text-[12.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:[(0,R.jsx)(on,{size:10,fill:`currentColor`}),` `,n(`Stop`)]})]})]})})}function bp({hold:e,compact:t}){let n=s(),[r,a]=(0,L.useState)(!1),o=async()=>{a(!0);try{await M.doneHold(e.id)}catch{}finally{a(!1)}};return(0,R.jsxs)(`div`,{className:i(`flex items-center gap-2 rounded-2xl bg-amber-500/12 px-3 text-amber-800 dark:text-amber-200`,t?`py-1.5 text-[12.5px]`:`mt-1.5 py-2 text-[13px]`),children:[(0,R.jsx)(_t,{size:14,className:`shrink-0`}),(0,R.jsx)(`span`,{className:`min-w-0 flex-1 leading-snug`,children:Sp(e,n)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void o(),disabled:r,className:`shrink-0 rounded-full bg-fg px-3 py-1 text-[12.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:r?(0,R.jsx)(A,{size:13,className:`animate-spin`}):n(`Done`)})]})}function xp(e,t){return t(e===`browser`?`the browser`:e===`phone`?`the phone`:`this computer`)}function Sp(e,t){return e.by===`agent`?e.reason?t(`Your turn: {reason}`,{reason:e.reason}):t(`Your turn`):t(`You took over {thing}`,{thing:xp(e.tool,t)})}function Cp({event:e,name:t=`nanoMuse`}){let n=s(),[r,a]=(0,L.useState)(!1),o=e.status===`on`,c=xp(e.tool,n),l=async()=>{a(!0);try{await M.doneHold(e.id)}catch{}finally{a(!1)}},d=e.timed_out?n(`{name} waited ten minutes and went on without you.`,{name:t}):e.stale?n(`Ended when the runtime restarted.`):e.by===`agent`?n(`You did it; {name} carried on.`,{name:t}):n(`Handed back; {name} carried on.`,{name:t});return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`div`,{className:i(`w-full max-w-[360px] overflow-hidden rounded-3xl rounded-tl-lg border bg-surface shadow-sm`,o?`border-amber-500/50`:`border-border/70 opacity-70`),children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-3.5 pb-3`,children:[(0,R.jsx)(`div`,{className:i(`mt-0.5 rounded-full p-2`,o?`bg-amber-500/15 text-amber-700 dark:text-amber-300`:`bg-surface-2 text-fg/70`),children:(0,R.jsx)(_t,{size:18})}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[12.5px] font-medium text-muted`,children:e.tool===`browser`?n(`The browser`):e.tool===`phone`?n(`The phone`):n(`This computer`)}),(0,R.jsx)(`div`,{className:`mt-0.5 break-words text-[15px] font-semibold leading-snug`,children:e.by===`agent`?n(`Your turn`):n(`You took over {thing}`,{thing:c})}),e.by===`agent`&&e.reason&&(0,R.jsx)(`div`,{className:`mt-1 break-words text-[13.5px] leading-snug text-fg/90`,children:e.reason}),(0,R.jsx)(`div`,{className:`mt-1.5 text-[12.5px] leading-snug text-muted`,children:o?e.by===`agent`?n(`Do this part yourself, then press Done. {name} looks again and carries on from there.`,{name:t}):n(`{name} waits. Press Done when you are finished and it looks again.`,{name:t}):d})]})]}),(0,R.jsxs)(`div`,{className:`flex items-center gap-2 border-t border-border/70 px-4 py-2 text-[12px] text-muted`,children:[(0,R.jsx)(`span`,{children:u(e.ts)}),o&&(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void l(),disabled:r,className:`ml-auto inline-flex items-center gap-1.5 rounded-full bg-fg px-3.5 py-1 text-[12.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:[r?(0,R.jsx)(A,{size:13,className:`animate-spin`}):(0,R.jsx)(Oe,{size:12}),` `,n(`Done`)]})]})]})})}function wp(e,t){let n=e.label?`“${e.label}”`:``;switch(e.action){case`click`:return n?t(`Clicked {label}`,{label:n}):t(`Clicked`);case`double_click`:return n?t(`Double-clicked {label}`,{label:n}):t(`Double-clicked`);case`right_click`:case`middle_click`:return n?t(`Right-clicked {label}`,{label:n}):t(`Right-clicked`);case`move`:return n?t(`Pointed at {label}`,{label:n}):t(`Moved the mouse`);case`type`:return t(`Typed {n} characters`,{n:(e.text??``).length});case`key`:return t(`Pressed {keys}`,{keys:(e.keys??[]).join(`+`)});case`scroll`:return t(`Scrolled`);case`drag`:return t(`Dragged`);case`open_app`:return n?t(`Opened {label}`,{label:n}):t(`Opened an application`);case`wait`:return t(`Waited`);default:return e.action??``}}function Tp({event:e,onOpen:t}){let n=s(),a=r(e.name),o=n(a===`html`?`Page`:a===`image`?`Image`:a===`data`?`Data`:a===`code`?`Code`:a===`event`?`Event`:`Document`),c=e.action===`update`?`${o} · ${n(`updated`)}`:o,l=a===`html`||a===`image`;return(0,R.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(e.path),className:i(`max-w-full overflow-hidden rounded-[20px] border border-border bg-surface text-left shadow-[0_4px_20px_-12px_rgba(0,0,0,0.2)] transition hover:bg-surface-2`,l?`w-[300px]`:``),children:[l&&(0,R.jsx)(Ep,{path:e.path,kind:a}),(0,R.jsxs)(`div`,{className:`flex items-center gap-3 px-4 py-3`,children:[!l&&(0,R.jsx)(`div`,{className:`rounded-2xl bg-accent/12 p-2.5 text-accent`,children:a===`code`?(0,R.jsx)(st,{size:20}):a===`event`?(0,R.jsx)(et,{size:20}):(0,R.jsx)(pt,{size:20})}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[11px] font-semibold uppercase tracking-wide text-muted`,children:c}),(0,R.jsx)(`div`,{className:`truncate text-[14.5px] font-medium`,children:e.name}),(0,R.jsx)(`div`,{className:`truncate text-[12px] text-muted`,children:e.path})]}),(0,R.jsx)(nt,{size:16,className:`ml-1 shrink-0 text-muted`})]})]})})}function Ep({path:e,kind:t}){return t===`image`?(0,R.jsx)(`img`,{src:h(e),alt:``,loading:`lazy`,className:`h-40 w-full object-cover`}):(0,R.jsx)(`div`,{className:`pointer-events-none relative h-40 w-full overflow-hidden bg-white`,children:(0,R.jsx)(`iframe`,{title:e,src:h(e),sandbox:``,tabIndex:-1,loading:`lazy`,scrolling:`no`,className:`absolute left-0 top-0 h-[480px] w-[900px] origin-top-left scale-[0.3333] border-0`})})}function Dp({open:e,onClose:t,title:n,children:r,footer:i}){let a=s(),o=(0,L.useRef)(null);return Dn(o,e),(0,L.useEffect)(()=>{if(!e)return;let n=e=>e.key===`Escape`&&t();return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),e?(0,R.jsxs)(`div`,{className:`fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center`,children:[(0,R.jsx)(`div`,{className:`absolute inset-0 bg-black/45 backdrop-blur-[2px]`,onClick:t}),(0,R.jsxs)(`div`,{ref:o,role:`dialog`,"aria-modal":`true`,className:`sheet-in relative w-full sm:max-w-lg max-h-[92dvh] flex flex-col bg-surface rounded-t-3xl sm:rounded-3xl shadow-2xl border border-border`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-3 px-5 pt-4 pb-2`,children:[(0,R.jsx)(`div`,{className:`sm:hidden absolute left-1/2 -translate-x-1/2 top-2 h-1 w-10 rounded-full bg-border`}),(0,R.jsx)(`div`,{className:`flex-1 font-semibold text-[17px] mt-1`,children:n}),(0,R.jsx)(`button`,{type:`button`,onClick:t,"aria-label":a(`Close`),className:`p-2 -mr-2 rounded-full text-muted hover:bg-surface-2`,children:(0,R.jsx)(Pe,{size:20})})]}),(0,R.jsx)(`div`,{className:`flex-1 overflow-y-auto px-5 pb-4`,children:r}),i&&(0,R.jsx)(`div`,{className:`px-5 pt-2 pb-4 safe-bottom border-t border-border`,children:i})]})]}):null}var Op=1e3;function kp({thread:e,eventId:t,onClose:r}){let{state:a,toast:o}=E(),c=s(),l=(a.events[e]??[]).find(e=>e.id===t),u=a.holds.find(t=>t.thread===e&&t.tool===`browser`),[d,f]=(0,L.useState)(null),[p,m]=(0,L.useState)(null),[h,g]=(0,L.useState)(``),[_,v]=(0,L.useState)(``),[y,b]=(0,L.useState)(!1),x=(0,L.useRef)(null),S=d?d===`on`:!!u;(0,L.useEffect)(()=>b(!1),[l?.frame]),(0,L.useEffect)(()=>f(null),[u?.id,u?.status]);let C=l?.backend===`device`&&ip();(0,L.useEffect)(()=>{if(!C)return;let t=t=>{let n=t.detail;n?.thread&&n.thread!==e||M.browserControl(e,{action:`handed_back`}).catch(e=>o(e.message))};return window.addEventListener(`nanomuse:browser-handed-back`,t),()=>window.removeEventListener(`nanomuse:browser-handed-back`,t)},[C,e,o]);let w=(0,L.useCallback)(async t=>{m(t.action);try{await M.browserControl(e,t)}catch(e){o(e.message)}finally{m(null)}},[e,o]),T=()=>{if(C){rp()?.takeOverBrowser?.(e),M.browserControl(e,{action:`take_over`}).catch(()=>void 0);return}f(`on`),w({action:`take_over`})},D=()=>{f(`off`),w({action:`handed_back`})};(0,L.useEffect)(()=>{if(!S||C||!l||l.status!==`live`)return;let t=window.setInterval(()=>{document.hidden||M.browserControl(e,{action:`look`}).catch(()=>void 0)},Op);return()=>window.clearInterval(t)},[S,C,e,l]);let O=e=>{if(!S||p)return;let t=e.currentTarget.getBoundingClientRect(),n=(e.clientX-t.left)/t.width,r=(e.clientY-t.top)/t.height;w({action:`click`,x:Math.min(Math.max(n,0),1),y:Math.min(Math.max(r,0),1)})},k=e=>{!S||p||Math.abs(e.deltaY)<8||w({action:`scroll`,dy:Math.max(-2e3,Math.min(2e3,Math.round(e.deltaY*2)))})};if(!l)return null;let ee=l.url;try{ee=new URL(l.url).host}catch{}let te=l.status===`live`,j=a.profile?.name??`nanoMuse`,ne=u?c(`{name} is waiting for you`,{name:j}):a.status.state!==`idle`&&a.status.thread===e?c(`{name} is working`,{name:j}):l.action;return(0,R.jsxs)(Dp,{open:!0,onClose:r,title:(0,R.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,R.jsx)(ht,{size:17,className:`text-accent shrink-0`}),(0,R.jsx)(`span`,{className:`truncate`,children:l.title||ee}),te&&(0,R.jsxs)(`span`,{className:`ml-1 flex items-center gap-1 rounded-full bg-rose-500/12 px-2 py-0.5 text-[10.5px] font-semibold text-rose-500`,children:[(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-rose-500 animate-pulse`}),` `,c(`LIVE`)]})]}),footer:S?(0,R.jsxs)(`div`,{className:`space-y-2`,children:[(0,R.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),h&&w({action:`type`,text:h}).then(()=>g(``))},children:[(0,R.jsx)(`input`,{value:h,onChange:e=>g(e.target.value),placeholder:c(`Type into the focused field…`),className:`flex-1 rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14px] outline-none`,autoComplete:`off`,autoCapitalize:`off`}),(0,R.jsx)(`button`,{type:`submit`,disabled:!h||!!p,"aria-label":c(`Type`),className:`rounded-full bg-accent p-2.5 text-white disabled:opacity-40`,children:p===`type`?(0,R.jsx)(A,{size:16,className:`animate-spin`}):(0,R.jsx)(Ke,{size:16})}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`key`,key:`Enter`}),disabled:!!p,"aria-label":c(`Press Enter`),className:`rounded-full bg-surface-2 p-2.5 text-fg disabled:opacity-40`,children:(0,R.jsx)(ut,{size:16})})]}),(0,R.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),_&&w({action:`navigate`,url:_}).then(()=>v(``))},children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`back`}),disabled:!!p,"aria-label":c(`Back`),className:`rounded-full bg-surface-2 p-2 text-fg disabled:opacity-40`,children:(0,R.jsx)(Ge,{size:15})}),(0,R.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),placeholder:c(`Open a URL…`),inputMode:`url`,autoCapitalize:`off`,className:`flex-1 min-w-0 rounded-2xl bg-surface-2 px-3.5 py-2 text-[13px] outline-none`}),(0,R.jsx)(`button`,{type:`submit`,disabled:!_||!!p,className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13px] font-medium disabled:opacity-40`,children:c(`Go`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`scroll`,dy:-600}),disabled:!!p,"aria-label":c(`Scroll up`),className:`rounded-full bg-surface-2 p-2 text-fg disabled:opacity-40`,children:(0,R.jsx)(qe,{size:15})}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`scroll`,dy:600}),disabled:!!p,"aria-label":c(`Scroll down`),className:`rounded-full bg-surface-2 p-2 text-fg disabled:opacity-40`,children:(0,R.jsx)(We,{size:15})})]}),(0,R.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,R.jsx)(`span`,{className:`min-w-0 truncate text-[12px] text-muted`,children:c(`Tap the page to click. If the browser was closed, open a URL first.`)}),(0,R.jsxs)(`button`,{type:`button`,onClick:D,disabled:p===`handed_back`,className:`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-fg px-3.5 py-1.5 text-[13px] font-semibold text-bg disabled:opacity-60`,children:[p===`handed_back`?(0,R.jsx)(A,{size:14,className:`animate-spin`}):(0,R.jsx)(Oe,{size:14}),` `,c(`Done`)]})]})]}):(0,R.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,R.jsxs)(`div`,{className:`min-w-0 text-[12.5px] text-muted truncate`,children:[ne,` · `,ee]}),(0,R.jsxs)(`div`,{className:`flex items-center gap-2 shrink-0`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`look`}),disabled:!!p,"aria-label":c(`Refresh`),className:`rounded-full bg-surface-2 p-2 text-muted disabled:opacity-40`,children:p===`look`?(0,R.jsx)(A,{size:15,className:`animate-spin`}):(0,R.jsx)(Le,{size:15})}),(0,R.jsxs)(`button`,{type:`button`,onClick:T,disabled:d===`on`,className:`flex items-center gap-1.5 rounded-full bg-accent px-3.5 py-2 text-[13px] font-semibold text-white disabled:opacity-60`,children:[(0,R.jsx)(_t,{size:14}),` `,c(`Take over`)]})]})]}),children:[u&&u.by===`agent`&&(0,R.jsx)(`div`,{className:`mb-2`,children:(0,R.jsx)(bp,{hold:u,compact:!0})}),(0,R.jsxs)(`div`,{className:i(`relative overflow-hidden rounded-2xl border border-border bg-surface-2`,S&&`ring-2 ring-accent`),children:[y?(0,R.jsxs)(`div`,{className:`flex aspect-[16/10] flex-col items-center justify-center gap-1 text-muted`,children:[(0,R.jsx)(ht,{size:24}),(0,R.jsx)(`span`,{className:`text-[12.5px]`,children:c(`This frame is no longer available.`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`look`}),className:`text-accent text-[13px] font-medium`,children:c(`Look again`)})]}):(0,R.jsx)(`img`,{ref:x,src:n(e,l.frame),alt:l.title||ee,onError:()=>b(!0),onClick:O,onWheel:k,draggable:!1,className:i(`block w-full select-none`,S?`cursor-crosshair`:``)},l.frame),p&&p!==`look`&&(0,R.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center bg-black/20`,children:(0,R.jsx)(A,{size:26,className:`animate-spin text-white`})})]}),(0,R.jsxs)(`div`,{className:`mt-2 flex items-center justify-between text-[12px] text-muted`,children:[(0,R.jsx)(`span`,{className:`truncate`,children:l.url}),(0,R.jsx)(`span`,{className:`shrink-0 ml-2`,children:l.frames===1?c(`1 frame`):c(`{n} frames`,{n:l.frames})})]}),S&&(0,R.jsxs)(`div`,{className:`mt-3 rounded-2xl bg-accent/8 px-3.5 py-2.5 text-[13px] leading-snug`,children:[(0,R.jsx)(`div`,{className:`font-medium`,children:u&&u.by===`agent`?Sp(u,c):c(`You have the page. Press Done when you are finished.`)}),(0,R.jsx)(`div`,{className:`mt-1 text-muted`,children:c(`{name} waits meanwhile, then looks at the page as you left it and carries on. Passwords you type here go to the website, never to the model.`,{name:j})})]})]})}function Ap(){if(typeof window>`u`)return null;let e=window;return e.SpeechRecognition??e.webkitSpeechRecognition??null}function jp(){return Ap()!==null}function Mp(e,t){let[n,r]=(0,L.useState)(!1),i=(0,L.useRef)(null),a=(0,L.useRef)(``),o=s(),c=(0,L.useCallback)(()=>{i.current?.stop()},[]),l=(0,L.useCallback)(()=>{let n=Ap();if(!n)return;let s=new n;s.lang=navigator.language||`en-US`,s.continuous=!0,s.interimResults=!0,a.current=``,s.onresult=t=>{let n=``;for(let e=t.resultIndex;e{let n=e.error||``;n!==`aborted`&&n!==`no-speech`&&t?.(n===`not-allowed`||n===`service-not-allowed`?o(`The microphone is not allowed. Allow it in the browser and try again.`):o(`Voice input stopped ({code}).`,{code:n}))},s.onend=()=>{i.current=null,r(!1)},i.current=s;try{s.start(),r(!0)}catch{i.current=null,r(!1)}},[e,t,o]),u=(0,L.useCallback)(()=>{i.current?c():l()},[l,c]);return(0,L.useEffect)(()=>()=>i.current?.abort(),[]),{supported:jp(),listening:n,toggle:u,stop:c}}function Np({listening:e,onClick:t,className:n}){let r=s();return(0,R.jsx)(`button`,{type:`button`,onClick:t,"aria-label":r(e?`Stop voice input`:`Voice input`),"aria-pressed":e,className:i(`flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition active:scale-95`,e?`bg-accent/15 text-accent animate-pulse`:`text-fg/70 hover:bg-surface-2`,n),children:e?(0,R.jsx)(Nt,{size:19}):(0,R.jsx)(Pt,{size:19})})}function Pp({lines:e}){return(0,R.jsx)(`div`,{className:`flex items-end gap-2 pr-10`,children:(0,R.jsx)(`div`,{className:`min-w-0 max-w-full`,children:e.map((e,t)=>(0,R.jsx)(`div`,{className:`my-1 max-w-[340px] rounded-[20px] bg-surface-2 px-3.5 py-2.5 text-[14.5px] leading-snug whitespace-pre-wrap break-words wide:max-w-[600px]`,children:e},t))})})}function Fp({chips:e,chosen:t,busy:n,onPick:r,onElse:a}){let o=s();return e.length===0&&!t?null:(0,R.jsxs)(`section`,{className:`rise mx-1 my-2 rounded-[22px] border border-border/70 bg-surface p-3.5`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2 text-[13px] font-medium text-fg`,children:[(0,R.jsx)(tn,{size:14,className:`text-accent`}),` `,o(`Pick a name for me, or type one`)]}),(0,R.jsxs)(`div`,{className:`mt-2.5 flex flex-wrap gap-2`,children:[e.map(e=>{let a=t===e;return(0,R.jsxs)(`button`,{type:`button`,disabled:n||!!t,onClick:()=>r(e),className:i(`inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[13.5px] font-medium transition-colors disabled:opacity-60`,a?`bg-accent text-accent-fg`:`bg-surface-2 hover:bg-border/60`),children:[a&&(0,R.jsx)(Oe,{size:13,strokeWidth:3}),e]},e)}),!t&&(0,R.jsx)(`button`,{type:`button`,disabled:n,onClick:a,className:`rounded-full border border-border px-3.5 py-1.5 text-[13.5px] text-muted hover:bg-surface-2 disabled:opacity-60`,children:o(`Something else`)})]})]})}var Ip=[{id:`pink`,background:`#F9D9E3`,accent:`#B5476A`,mood:`idle`},{id:`blue`,background:`#D6E6FA`,accent:`#2F5FA8`,mood:`working`},{id:`yellow`,background:`#FBEFC7`,accent:`#9A6B12`,mood:`waiting`},{id:`purple`,background:`#E4DDF7`,accent:`#5D44A6`,mood:`happy`},{id:`green`,background:`#D9F0DF`,accent:`#2E7D4F`,mood:`error`}],Lp=`github.com/zeeshanhaque21/nanoMuse`,Rp=540,zp=720;function Bp(e,t){return!e||w(e.avatar)?j(t):e.avatar?re(e.avatar,t):null}function Vp(e){return new Promise(t=>{let n=new Image;n.crossOrigin=`anonymous`,n.onload=()=>t(n),n.onerror=()=>t(null),n.src=e})}function Hp(e,t,n){let r=t.includes(` `)?t.split(` `):Array.from(t),i=t.includes(` `)?` `:``,a=[],o=``;for(let t of r){let r=o?o+i+t:t;e.measureText(r).width>n&&o?(a.push(o),o=t):o=r}return o&&a.push(o),a}function Up(e,t,n,r,i,a){e.beginPath(),e.moveTo(t+a,n),e.arcTo(t+r,n,t+r,n+i,a),e.arcTo(t+r,n+i,t,n+i,a),e.arcTo(t,n+i,t,n,a),e.arcTo(t,n,t+r,n,a),e.closePath()}async function Wp(e,t,n,r,i,a=2){let o=document.createElement(`canvas`);o.width=Rp*a,o.height=zp*a;let s=o.getContext(`2d`);if(!s)return o;s.scale(a,a),s.fillStyle=e.background,Up(s,0,0,Rp,zp,36),s.fill(),s.font=`600 22px system-ui, -apple-system, 'Segoe UI', sans-serif`;let c=Hp(s,r,400),l=Math.max(...c.map(e=>s.measureText(e).width))+48,u=c.length*30+36,d=(Rp-l)/2;s.fillStyle=`#ffffff`,Up(s,d,56,l,u,24),s.fill(),s.beginPath(),s.moveTo(Rp/2-14,56+u-1),s.lineTo(Rp/2,56+u+18),s.lineTo(284,56+u-1),s.closePath(),s.fill(),s.fillStyle=e.accent,s.textAlign=`center`,s.textBaseline=`top`,c.forEach((e,t)=>s.fillText(e,Rp/2,74+t*30+3));let f=56+u+54;s.fillStyle=`#ffffff`,s.beginPath(),s.arc(Rp/2,f+150,158,0,Math.PI*2),s.fill();let p=Bp(t,e.mood),m=p?await Vp(p):null;return s.save(),s.beginPath(),s.arc(Rp/2,f+150,150,0,Math.PI*2),s.clip(),m?(s.fillStyle=`#f1efeb`,s.fillRect(120,f,300,300),s.drawImage(m,120,f,300,300)):(s.fillStyle=t?.color??`#0064d4`,s.fillRect(120,f,300,300),s.font=`150px system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif`,s.textAlign=`center`,s.textBaseline=`middle`,s.fillStyle=`#ffffff`,s.fillText(t?.emoji??`✨`,Rp/2,f+150+6)),s.restore(),s.fillStyle=e.accent,s.textAlign=`center`,s.textBaseline=`top`,s.font=`700 30px system-ui, -apple-system, 'Segoe UI', sans-serif`,s.fillText(n,Rp/2,f+300+34),s.font=`500 15px system-ui, -apple-system, 'Segoe UI', sans-serif`,s.globalAlpha=.8,Hp(s,i,460).forEach((e,t)=>s.fillText(e,Rp/2,644+t*22)),s.globalAlpha=1,o}function Gp(e){return new Promise(t=>e.toBlob(t,`image/png`))}function Kp({open:e,onClose:t}){let{state:n,toast:r}=E(),a=s(),o=n.profile,c=o?.name??`nanoMuse`,[l,u]=(0,L.useState)(0),[d,f]=(0,L.useState)([]),[p,m]=(0,L.useState)(!1),h=(0,L.useRef)(!0),g=a(`Hi, I'm {name}, a personal AI agent. Meet nanoMuse: open source, runs on your phone.`,{name:c}),_=a(`Your personal AI agent, open source · {repo}`,{repo:Lp});(0,L.useEffect)(()=>{if(h.current=!0,e)return u(0),(async()=>{let e=[];for(let t of Ip){let n=await Wp(t,o,c,g,_,1);e.push(n.toDataURL(`image/png`))}h.current&&f(e)})(),()=>{h.current=!1}},[e,o?.avatar,o?.emoji,o?.color,c]);let v=async()=>{m(!0);try{let e=await Gp(await Wp(Ip[l]??Ip[0],o,c,g,_,2));if(!e)throw Error(a(`The card could not be drawn.`));let n=new File([e],`${c}-nanomuse.png`,{type:`image/png`}),i=`${g}\nhttps://${Lp}`,s=navigator;if(typeof s.share==`function`&&(!s.canShare||s.canShare({files:[n]})))try{await s.share({files:[n],text:i,title:c}),t();return}catch(e){if(e.name===`AbortError`)return}let u=document.createElement(`a`);u.href=URL.createObjectURL(e),u.download=n.name,u.click(),window.setTimeout(()=>URL.revokeObjectURL(u.href),2e3),r(a(`Saved the card as a picture.`))}catch(e){r(e.message)}finally{m(!1)}};return(0,R.jsxs)(Dp,{open:e,onClose:t,title:a(`Share my avatar`),children:[(0,R.jsx)(`p`,{className:`mb-3 text-[13px] text-muted`,children:a(`Pick a card. It goes out through the system share sheet, or saves as a picture where there is none.`)}),(0,R.jsx)(`div`,{className:`grid grid-cols-3 gap-2.5 sm:grid-cols-5`,children:Ip.map((e,t)=>(0,R.jsx)(`button`,{type:`button`,"aria-label":e.id,"aria-pressed":l===t,onClick:()=>u(t),className:i(`overflow-hidden rounded-2xl border-2 transition`,l===t?`border-accent`:`border-transparent`),style:{background:e.background,aspectRatio:`${Rp} / ${zp}`},children:d[t]?(0,R.jsx)(`img`,{src:d[t],alt:``,className:`h-full w-full object-cover`}):(0,R.jsx)(A,{size:18,className:`mx-auto my-8 animate-spin text-muted`})},e.id))}),d[l]&&(0,R.jsx)(`img`,{src:d[l],alt:``,className:`mx-auto mt-4 w-[260px] rounded-[20px] shadow-md`}),(0,R.jsxs)(`div`,{className:`mt-4 flex justify-end gap-2`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:t,className:Ae,children:a(`Later`)}),(0,R.jsxs)(`button`,{type:`button`,disabled:p||!d.length,onClick:()=>void v(),className:Me,children:[p?(0,R.jsx)(A,{size:15,className:`animate-spin`}):typeof navigator.share==`function`?(0,R.jsx)(Yt,{size:15}):(0,R.jsx)(dt,{size:15}),` `,typeof navigator.share==`function`?a(`Share`):a(`Save picture`)]})]})]})}function qp({message:e,onRetry:t}){let n=s();return(0,R.jsxs)(`div`,{className:`flex flex-col items-center gap-3 px-8 py-10 text-center`,children:[(0,R.jsx)(`p`,{className:`text-[14px] text-rose-600 dark:text-rose-300`,children:n(e)||n(`Could not load.`)}),(0,R.jsxs)(`button`,{type:`button`,onClick:t,className:`flex h-10 items-center gap-1.5 rounded-full bg-surface px-4 text-[13.5px] font-medium shadow-[0_1px_4px_rgba(0,0,0,0.14)] dark:border dark:border-border dark:shadow-none`,children:[(0,R.jsx)(Le,{size:14}),` `,n(`Try again`)]})]})}var Jp=[{id:`health`,label:`Health`,icon:e=>(0,R.jsx)(vt,{...e}),tone:`bg-rose-500/12 text-rose-600 dark:text-rose-300`},{id:`finance`,label:`Finance`,icon:e=>(0,R.jsx)(Ut,{...e}),tone:`bg-emerald-500/12 text-emerald-600 dark:text-emerald-300`},{id:`career`,label:`Career`,icon:e=>(0,R.jsx)(Qe,{...e}),tone:`bg-sky-500/12 text-sky-600 dark:text-sky-300`},{id:`learning`,label:`Learning`,icon:e=>(0,R.jsx)(gt,{...e}),tone:`bg-violet-500/12 text-violet-600 dark:text-violet-300`},{id:`relationships`,label:`Relationships`,icon:e=>(0,R.jsx)(oe,{...e}),tone:`bg-pink-500/12 text-pink-600 dark:text-pink-300`},{id:`family`,label:`Family`,icon:e=>(0,R.jsx)(mn,{...e}),tone:`bg-amber-500/12 text-amber-600 dark:text-amber-300`},{id:`home`,label:`Home`,icon:e=>(0,R.jsx)(yt,{...e}),tone:`bg-orange-500/12 text-orange-600 dark:text-orange-300`},{id:`travel`,label:`Travel`,icon:e=>(0,R.jsx)(ct,{...e}),tone:`bg-cyan-500/12 text-cyan-600 dark:text-cyan-300`},{id:`creative`,label:`Creative`,icon:e=>(0,R.jsx)(Bt,{...e}),tone:`bg-fuchsia-500/12 text-fuchsia-600 dark:text-fuchsia-300`},{id:`other`,label:`Other`,icon:e=>(0,R.jsx)(cn,{...e}),tone:`bg-surface-2 text-muted`}];function Yp(e){return Jp.find(t=>t.id===e)??null}var Xp=[{id:``,label:`No reminders`},{id:`daily`,label:`Every day`},{id:`weekdays`,label:`Weekdays`},{id:`weekly`,label:`Once a week`},{id:`monthly`,label:`Once a month`}],Zp=[`mon`,`tue`,`wed`,`thu`,`fri`,`sat`,`sun`];function Qp(e){let t=/^(daily|weekdays|weekly|monthly)(?:\s+(\S+?))?(?:\s+(\d{1,2}:\d{2}))?$/.exec(e.trim());if(!t)return{cadence:``,anchor:``,time:`09:00`};let n=t[2]??``,r=t[3]??`09:00`;return n&&/^\d{1,2}:\d{2}$/.test(n)&&(r=n,n=``),{cadence:t[1],anchor:n,time:r.padStart(5,`0`)}}function $p(e,t,n){return e?[e,t,n].filter(Boolean).join(` `):``}function em(e){let t=Zp.indexOf(e);return t<0?e:new Date(2024,0,1+t).toLocaleDateString(m(),{weekday:`long`})}function tm(e){let{cadence:t,anchor:n,time:r}=Qp(e);switch(t){case`daily`:return _(`Daily at {time}`,{time:r});case`weekdays`:return _(`Weekdays at {time}`,{time:r});case`weekly`:return n&&Zp.includes(n)?_(`{day}s at {time}`,{day:em(n),time:r}):_(`Weekly at {time}`,{time:r});case`monthly`:return _(`Monthly on the {day} at {time}`,{day:nm(Number(n||1)),time:r});default:return``}}function nm(e){if(c()!==`en`)return String(e);let t=[`th`,`st`,`nd`,`rd`],n=e%100;return`${e}${t[(n-20)%10]??t[n]??t[0]}`}function rm(e,t){if(!e)return``;let n=new Date(`${e}T00:00:00`),r=n.toLocaleDateString(m(),{month:`short`,day:`numeric`,year:n.getFullYear()===new Date().getFullYear()?void 0:`numeric`});if(t)return _(`Was due {date}`,{date:r});let i=Math.ceil((n.getTime()-Date.now())/864e5);return i<=0?_(`Due today`):i===1?_(`Due tomorrow`):i<=14?_(`Due in {n} days`,{n:i}):_(`By {date}`,{date:r})}function im({open:e,onClose:t}){let{state:n,setTab:r,draft:a,toast:o}=E(),[c,l]=(0,L.useState)(0),[u,d]=(0,L.useState)(!1),[f,p]=(0,L.useState)(!1),[m,h]=(0,L.useState)(!1),g=n.profile?.name??`nanoMuse`,_=n.pendingApprovals.length,v=s();(0,L.useEffect)(()=>{e&&(l(+(_>0)),d(!1),p(!1))},[e]);let y=(e,n)=>{t(),n&&history.replaceState(null,``,`${window.location.pathname}${window.location.search}#${n}`),r(e)},b=n.status.state===`working`,x=_>0?_>1?v(`{n} approvals waiting for you`,{n:_}):v(`1 approval waiting for you`):b?n.status.detail||v(`On it`):n.status.state===`waiting`?v(`Waiting for you`):v(`online`),S=async()=>{h(!0);try{(await M.stopThread(n.status.thread||`main`)).ok||o(v(`Nothing to stop`))}catch(e){o(e.message)}finally{h(!1)}},C=[{icon:(0,R.jsx)(rt,{size:22}),label:v(`Activity`)},{icon:(0,R.jsx)(Zt,{size:22}),label:v(`Approvals`),badge:_},{icon:(0,R.jsx)(it,{size:22}),label:v(`Daily`)},{icon:(0,R.jsx)(mt,{size:22}),label:v(`Soul & memory`)}];return(0,R.jsxs)(Dp,{open:e,onClose:t,children:[(0,R.jsxs)(`div`,{className:`relative flex flex-col items-center px-1 pb-3 text-center`,children:[(0,R.jsx)(`button`,{type:`button`,"aria-label":v(`Share avatar`),title:v(`Share avatar`),onClick:()=>p(!0),className:`absolute right-0 top-0 flex h-9 w-9 items-center justify-center rounded-full bg-surface-2/70 text-fg hover:bg-surface-2`,children:(0,R.jsx)(Yt,{size:16})}),(0,R.jsxs)(`div`,{className:`relative`,children:[(0,R.jsx)(D,{profile:n.profile,status:n.status,size:72}),(0,R.jsx)(`button`,{type:`button`,"aria-label":v(`Edit`),"aria-expanded":u,onClick:()=>d(e=>!e),className:`absolute -bottom-0.5 -right-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-surface text-fg shadow-[0_1px_4px_rgba(0,0,0,0.18)] hover:bg-surface-2 dark:border dark:border-border`,children:(0,R.jsx)(Vt,{size:13})}),u&&(0,R.jsxs)(`div`,{className:`absolute left-1/2 top-full z-20 mt-2 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-border/70 bg-surface py-1 text-left text-[14px] shadow-lg`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>{a(v(`Change your avatar to `)),t(),r(`chat`)},className:`block w-full px-4 py-2.5 text-left hover:bg-surface-2`,children:v(`Change avatar`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>y(`you`,`who`),className:`block w-full px-4 py-2.5 text-left hover:bg-surface-2`,children:v(`Edit name`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>y(`avatar`),className:`block w-full px-4 py-2.5 text-left text-muted hover:bg-surface-2`,children:v(`Avatar studio…`)})]})]}),(0,R.jsx)(`div`,{className:`mt-2.5 text-[21px] font-semibold leading-[26px]`,children:g}),(0,R.jsxs)(`div`,{className:i(`mt-0.5 flex items-center gap-1.5 text-[14px]`,b||_>0?`text-fg`:`text-muted`),children:[b&&(0,R.jsx)(A,{size:13,className:`animate-spin text-accent`}),(0,R.jsx)(`span`,{className:`line-clamp-2`,children:x})]}),(b||n.status.state===`waiting`)&&(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void S(),disabled:m,className:`mt-2.5 inline-flex items-center gap-1.5 rounded-full bg-fg px-4 py-1.5 text-[13.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:[(0,R.jsx)(on,{size:12,fill:`currentColor`}),` `,v(`Stop`)]})]}),(0,R.jsx)(`div`,{role:`tablist`,className:`mb-5 flex rounded-2xl bg-surface-2/60 p-1`,children:C.map((e,t)=>(0,R.jsxs)(`button`,{type:`button`,role:`tab`,"aria-selected":c===t,"aria-label":e.label,title:e.label,onClick:()=>l(t),className:i(`relative flex h-11 flex-1 items-center justify-center rounded-[13px] text-fg transition`,c===t&&`bg-surface shadow-sm`),children:[e.icon,e.badge?(0,R.jsx)(`span`,{className:`absolute right-1.5 top-1 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white`,children:e.badge}):null]},e.label))}),c===0&&(0,R.jsx)(vm,{open:e}),c===1&&(0,R.jsxs)(R.Fragment,{children:[_>0&&(0,R.jsx)(sm,{onDone:()=>void 0}),(0,R.jsx)(ym,{open:e})]}),c===2&&(0,R.jsx)(cm,{onSettings:()=>y(`you`,`proactivity`)}),c===3&&(0,R.jsx)(am,{name:g,onEdit:()=>y(`you`,`who`),onMemory:()=>y(`memory`)}),(0,R.jsx)(Kp,{open:f,onClose:()=>p(!1)})]})}function am({name:e,onEdit:t,onMemory:n}){let{state:r}=E(),i=s(),a=r.profile?.tagline?.trim()??``;return(0,R.jsxs)(`div`,{className:`pb-2`,children:[(0,R.jsx)(`div`,{className:`px-1 text-[15px]`,children:e}),(0,R.jsxs)(`button`,{type:`button`,onClick:t,className:`mt-2 flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl bg-surface-2/70 text-[15px] font-medium hover:bg-surface-2`,children:[(0,R.jsx)(Vt,{size:18}),` `,i(`Edit`)]}),(0,R.jsxs)(`div`,{className:`mt-4 grid grid-cols-2 gap-3`,children:[(0,R.jsx)(om,{title:`SOUL`,caption:i(`Handle with care`),foot:a,colors:`from-[#7d6bc7] to-[#574494]`,onClick:t}),(0,R.jsx)(om,{title:i(`Memory`),caption:i(`Handle with care`),foot:r.settings?.memory_enabled===!1?i(`Off`):``,colors:`from-[#b0413e] to-[#7a1f1f]`,onClick:n})]})]})}function om({title:e,caption:t,foot:n,colors:r,onClick:a}){return(0,R.jsxs)(`button`,{type:`button`,onClick:a,className:i(`flex h-[130px] flex-col items-start rounded-[18px] bg-gradient-to-b p-3.5 text-left text-white transition active:scale-[0.98]`,r),children:[(0,R.jsx)(`span`,{className:`text-[17px] font-semibold`,children:e}),(0,R.jsx)(`span`,{className:`mt-0.5 text-[11px] text-white/85`,children:t}),(0,R.jsx)(`span`,{className:`mt-auto text-[12px] text-white/90`,children:n})]})}function sm({onDone:e}){let{state:t,decide:n,openThread:r,toast:i}=E(),a=t.profile?.name??`nanoMuse`,o=s(),c=[...t.pendingApprovals].sort((e,t)=>e.ts.localeCompare(t.ts)),l=e=>{let n=t.threads.find(t=>t.id===e);return n?n.id===`main`?o(n.title):n.title:e};return(0,L.useEffect)(()=>{if(c.length===0){let t=window.setTimeout(e,600);return()=>window.clearTimeout(t)}},[c.length,e]),c.length===0?(0,R.jsxs)(`div`,{className:`py-10 text-center text-muted text-[14px]`,children:[(0,R.jsx)(Oe,{size:26,className:`mx-auto mb-2 text-emerald-500`}),o(`Nothing is waiting for you.`)]}):(0,R.jsxs)(`div`,{className:`space-y-4 pb-2`,children:[(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:o(`Actions {name} wants to take but cannot without you. Each one shows what will run and why; a permission you grant is bound to that exact tool and target.`,{name:a})}),c.map(e=>(0,R.jsxs)(`div`,{children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>r(e.thread),className:`mb-1 px-1 text-[12px] font-semibold uppercase tracking-wide text-muted hover:text-accent`,children:[l(e.thread),` · `,u(e.ts)]}),(0,R.jsx)(`div`,{className:`rounded-[22px] border border-border/70`,children:(0,R.jsx)(mp,{event:e,onDecide:(t,r)=>n(e.id,t,r).catch(e=>i(e.message))})})]},e.id))]})}function cm({onSettings:t}){let{state:n,refreshSettings:r,toast:a}=E(),[o,c]=(0,L.useState)(null),[l,d]=(0,L.useState)(null),[f,p]=(0,L.useState)(null),m=n.profile?.name??`nanoMuse`,h=s(),g=()=>M.upcoming().then(e=>{c(e),d(null)}).catch(e=>o===null?d(e.message):a(e.message));(0,L.useEffect)(()=>{g()},[n.goalsVersion,n.remindersVersion,n.profile?.proactive,n.profile?.goal_interval_minutes]);let _=async()=>{try{await M.updateSettings({profile:{proactive:!n.profile?.proactive}}),await r()}catch(e){a(e.message)}},v=async e=>{p(e);try{await M.advanceGoal(e),a(h(`{name} is working on it in the main chat`,{name:m}))}catch(e){a(e.message)}finally{p(null)}};return o?(0,R.jsxs)(`div`,{className:`space-y-4 pb-2`,children:[(0,R.jsxs)(`div`,{className:`rounded-2xl border border-border p-3.5`,children:[(0,R.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:`font-medium`,children:h(`Background work`)}),(0,R.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:o.proactive?`${h(o.proactivity[0].toUpperCase()+o.proactivity.slice(1))} · ${h(`every {n} min {name} picks one active goal and works on its next step.`,{n:o.effective_interval_minutes,name:m})}${o.quiet_until?` ${h(`Quiet hours until {time}.`,{time:u(o.quiet_until)})}`:o.next_pass_at?` ${h(`Next around {time}.`,{time:u(o.next_pass_at)})}`:``}`:h(`Off. {name} only works when you ask.`,{name:m})})]}),(0,R.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":o.proactive,onClick:()=>void _(),className:i(`relative h-7 w-12 shrink-0 rounded-full transition`,o.proactive?`bg-accent`:`bg-border`),children:(0,R.jsx)(`span`,{className:i(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,o.proactive?`left-[26px]`:`left-0.5`)})})]}),(0,R.jsx)(`button`,{type:`button`,onClick:t,className:`mt-2 text-[12.5px] text-accent font-medium`,children:h(`Level, interval and quiet hours in Settings`)})]}),(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:h(`In line`)}),o.queue.length===0?(0,R.jsx)(`div`,{className:`text-[13px] text-muted`,children:h(`No active goal has a next step. Add one in Goals and {name} will pick it up.`,{name:m})}):(0,R.jsx)(`ul`,{className:`space-y-1.5`,children:o.queue.map((e,t)=>(0,R.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,R.jsx)(`span`,{className:`w-5 text-center text-[12px] font-semibold text-muted`,children:t+1}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.title}),(0,R.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[e.next_step??`—`,` · `,h(`{done}/{total} done`,{done:e.progress.done,total:e.progress.total})]})]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void v(e.goal_id),disabled:f!==null||o.busy,"aria-label":h(`Work on {title} now`,{title:e.title}),className:`rounded-full bg-accent/12 p-2 text-accent disabled:opacity-50`,children:f===e.goal_id?(0,R.jsx)(A,{size:15,className:`animate-spin`}):(0,R.jsx)(Wt,{size:15})})]},e.goal_id))})]}),o.check_ins.length>0&&(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:h(`Check-ins`)}),(0,R.jsx)(`ul`,{className:`space-y-1.5`,children:o.check_ins.map(t=>(0,R.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,R.jsx)(Ye,{size:15,className:`text-muted shrink-0`}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:t.title}),(0,R.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[e(t.at),` · `,u(t.at),` · `,tm(t.cadence).toLowerCase()]})]})]},t.goal_id))}),(0,R.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:h(`Goal nudges you asked for; they arrive whatever the proactivity level, but wait out quiet hours.`)})]}),(0,R.jsx)(lm,{items:o.reminders,name:m,onChange:()=>void g()}),(0,R.jsx)(pm,{data:o.triggers,name:m,onChange:()=>void g()})]}):l?(0,R.jsx)(qp,{message:l,onRetry:()=>void g()}):(0,R.jsx)(`div`,{className:`py-10 flex justify-center text-muted`,children:(0,R.jsx)(A,{className:`animate-spin`,size:20})})}function lm({items:t,name:n,onChange:r}){let{toast:i}=E(),a=s(),[o,c]=(0,L.useState)(null),[l,d]=(0,L.useState)(!1),f=t.filter(e=>e.status===`active`),p=t.filter(e=>e.status!==`active`),m=async e=>{c(e.id);try{await M.cancelReminder(e.id),r()}catch(e){i(e.message)}finally{c(null)}},h=async e=>{c(e.id);try{await M.fireReminder(e.id),i(e.kind===`task`?a(`{name} is on it`,{name:n}):a(`{name} will say it now`,{name:n})),r()}catch(e){i(e.message)}finally{c(null)}};return(0,R.jsxs)(`div`,{children:[(0,R.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,R.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold`,children:a(`Reminders & routines`)}),!l&&(0,R.jsx)(`button`,{type:`button`,onClick:()=>d(!0),className:`text-[12.5px] text-accent font-medium`,children:a(`+ Add`)})]}),l&&(0,R.jsx)(um,{onDone:()=>{d(!1),r()},onCancel:()=>d(!1)}),f.length===0&&!l?(0,R.jsx)(`div`,{className:`text-[13px] text-muted`,children:a(`Nothing scheduled. Tell {name} “remind me at six to call mum” or “every weekday morning, summarise my unread email”.`,{name:n})}):(0,R.jsx)(`ul`,{className:`space-y-1.5`,children:f.map(t=>(0,R.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[t.kind===`task`?(0,R.jsx)(I,{size:15,className:`text-muted shrink-0`}):(0,R.jsx)(Ue,{size:15,className:`text-muted shrink-0`}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:t.text}),(0,R.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[t.next_at?`${e(t.next_at)} · ${u(t.next_at)}`:`—`,t.repeat&&` · ${tm(t.repeat).toLowerCase()}`,t.kind===`task`&&` · ${a(`does the work`)}`]})]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void h(t),disabled:o!==null,"aria-label":t.kind===`task`?a(`Do “{text}” now`,{text:t.text}):a(`Remind me now: {text}`,{text:t.text}),className:`rounded-full bg-accent/12 p-2 text-accent disabled:opacity-50`,children:o===t.id?(0,R.jsx)(A,{size:15,className:`animate-spin`}):(0,R.jsx)(Wt,{size:15})}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void m(t),disabled:o!==null,"aria-label":a(`Cancel: {text}`,{text:t.text}),className:`rounded-full p-2 text-muted hover:text-fg disabled:opacity-50`,children:(0,R.jsx)(Pe,{size:15})})]},t.id))}),p.length>0&&(0,R.jsxs)(`details`,{className:`mt-2`,children:[(0,R.jsx)(`summary`,{className:`text-[12px] text-muted cursor-pointer select-none`,children:a(`{n} finished recently`,{n:p.length})}),(0,R.jsx)(`ul`,{className:`mt-1.5 space-y-1`,children:p.map(t=>(0,R.jsxs)(`li`,{className:`flex items-center gap-2 px-3 text-[12.5px] text-muted`,children:[(0,R.jsx)(`span`,{className:`min-w-0 flex-1 truncate line-through decoration-border`,children:t.text}),(0,R.jsx)(`span`,{className:`shrink-0`,children:t.status===`done`?t.last_fired_at?e(t.last_fired_at):a(`done`):a(`cancelled`)})]},t.id))})]}),(0,R.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`A time you named is kept whatever the level or the quiet hours. Reminders just say it; routines do the work and report.`)})]})}function um({onDone:e,onCancel:t}){let{toast:n}=E(),r=s(),[a,o]=(0,L.useState)(``),[c,l]=(0,L.useState)(`remind`),[u,d]=(0,L.useState)(`once`),[f,p]=(0,L.useState)(()=>gm()),[h,g]=(0,L.useState)(`daily`),[_,v]=(0,L.useState)(`09:00`),[y,b]=(0,L.useState)(`mon`),[x,S]=(0,L.useState)(`1`),[C,w]=(0,L.useState)(!1),T=h===`weekly`?`weekly ${y} ${_}`:h===`monthly`?`monthly ${x} ${_}`:`${h} ${_}`,D=async()=>{if(a.trim()){w(!0);try{await M.createReminder(u===`once`?{text:a.trim(),kind:c,at:f.replace(`T`,` `)}:{text:a.trim(),kind:c,repeat:T}),e()}catch(e){n(e.message)}finally{w(!1)}}},O=`h-9 rounded-xl bg-surface border border-border px-3 text-[13px] outline-none focus:border-accent`;return(0,R.jsxs)(`div`,{className:`mb-2 rounded-2xl border border-border p-3 space-y-2`,children:[(0,R.jsx)(`input`,{autoFocus:!0,value:a,onChange:e=>o(e.target.value),placeholder:r(c===`task`?`What should be done…`:`What to remind you of…`),className:i(O,`w-full h-10 text-[14px]`)}),(0,R.jsxs)(`div`,{className:`flex gap-1.5 text-[12.5px]`,children:[(0,R.jsx)(hm,{options:[[`remind`,r(`Remind me`)],[`task`,r(`Do it for me`)]],value:c,onChange:e=>l(e)}),(0,R.jsx)(hm,{options:[[`once`,r(`Once`)],[`repeat`,r(`Repeat`)]],value:u,onChange:e=>d(e)})]}),u===`once`?(0,R.jsx)(`input`,{type:`datetime-local`,value:f,onChange:e=>p(e.target.value),className:i(O,`w-full`)}):(0,R.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[(0,R.jsxs)(`select`,{value:h,onChange:e=>g(e.target.value),className:O,children:[(0,R.jsx)(`option`,{value:`daily`,children:r(`Every day`)}),(0,R.jsx)(`option`,{value:`weekdays`,children:r(`Weekdays`)}),(0,R.jsx)(`option`,{value:`weekly`,children:r(`Weekly`)}),(0,R.jsx)(`option`,{value:`monthly`,children:r(`Monthly`)})]}),h===`weekly`&&(0,R.jsx)(`select`,{value:y,onChange:e=>b(e.target.value),className:O,children:[`mon`,`tue`,`wed`,`thu`,`fri`,`sat`,`sun`].map((e,t)=>(0,R.jsx)(`option`,{value:e,children:new Date(2024,0,1+t).toLocaleDateString(m(),{weekday:`long`})},e))}),h===`monthly`&&(0,R.jsx)(`select`,{value:x,onChange:e=>S(e.target.value),className:O,children:Array.from({length:28},(e,t)=>String(t+1)).map(e=>(0,R.jsx)(`option`,{value:e,children:r(`Day {d}`,{d:e})},e))}),(0,R.jsx)(`input`,{type:`time`,value:_,onChange:e=>v(e.target.value),className:O})]}),(0,R.jsxs)(`div`,{className:`flex justify-end gap-2 pt-0.5`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:t,className:`rounded-full px-3 py-1.5 text-[13px] text-muted`,children:r(`Cancel`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void D(),disabled:C||!a.trim(),className:`rounded-full bg-accent px-3.5 py-1.5 text-[13px] font-medium text-accent-fg disabled:opacity-50`,children:r(C?`Saving…`:u===`once`?`Set reminder`:`Set routine`)})]})]})}var dm={mail:Tt,event:$e,hook:gn};function fm(e,t){if(e.kind===`mail`)return e.match?t(`mail matching “{match}”`,{match:e.match}):t(`any new mail`);if(e.kind===`event`){let n=e.match?t(`events matching “{match}”`,{match:e.match}):t(`any event`);return t(`{n} min before {what}`,{n:e.lead_minutes,what:n})}return e.match?t(`webhook “{match}”`,{match:e.match}):t(`webhook`)}function pm({data:t,name:n,onChange:r}){let{toast:a}=E(),o=s(),[c,l]=(0,L.useState)(null),[u,d]=(0,L.useState)(!1),f=t.items.filter(e=>e.status===`active`),p=f.some(e=>e.kind===`mail`),m=async e=>{l(e.id);try{await M.cancelTrigger(e.id),r()}catch(e){a(e.message)}finally{l(null)}},h=async e=>{l(e.id);try{await M.fireTrigger(e.id),a(o(`{name} is on it`,{name:n})),r()}catch(e){a(e.message)}finally{l(null)}},g=async e=>{if(e.url)try{await navigator.clipboard.writeText(e.url),a(o(`Webhook URL copied`))}catch{a(e.url)}};return(0,R.jsxs)(`div`,{children:[(0,R.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,R.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold`,children:o(`When something happens`)}),!u&&(0,R.jsx)(`button`,{type:`button`,onClick:()=>d(!0),className:`text-[12.5px] text-accent font-medium`,children:o(`+ Add`)})]}),u&&(0,R.jsx)(mm,{available:t.available,onDone:()=>{d(!1),r()},onCancel:()=>d(!1)}),f.length===0&&!u?(0,R.jsx)(`div`,{className:`text-[13px] text-muted`,children:o(`Nothing yet. Tell {name} “when the landlord writes back, summarise it and draft a reply” or “half an hour before any review, brief me”.`,{name:n})}):(0,R.jsx)(`ul`,{className:`space-y-1.5`,children:f.map(t=>{let n=dm[t.kind];return(0,R.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,R.jsx)(n,{size:15,className:`text-muted shrink-0 mt-[3px]`}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium`,children:fm(t,o)}),(0,R.jsx)(`div`,{className:`text-[12px] leading-snug text-fg/80 line-clamp-2`,children:t.text}),(0,R.jsxs)(`div`,{className:`text-[11.5px] text-muted flex flex-wrap items-center gap-x-1.5`,children:[t.fired>0?(0,R.jsxs)(`span`,{children:[o(`fired {n}×`,{n:t.fired}),t.last_fired_at&&` · ${e(t.last_fired_at)}`]}):(0,R.jsx)(`span`,{children:o(`not fired yet`)}),t.kind===`hook`&&t.url&&(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void g(t),className:`inline-flex items-center gap-1 text-accent font-medium`,children:[(0,R.jsx)(lt,{size:11}),` `,o(`Copy URL`)]})]})]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void h(t),disabled:c!==null,"aria-label":o(`Run now: {text}`,{text:t.text}),className:`rounded-full bg-accent/12 p-2 text-accent disabled:opacity-50`,children:c===t.id?(0,R.jsx)(A,{size:15,className:`animate-spin`}):(0,R.jsx)(Wt,{size:15})}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void m(t),disabled:c!==null,"aria-label":o(`Cancel: {text}`,{text:t.text}),className:`rounded-full p-2 text-muted hover:text-fg disabled:opacity-50`,children:(0,R.jsx)(Pe,{size:15})})]},t.id)})}),p&&(0,R.jsx)(`div`,{className:i(`mt-1.5 text-[12px]`,t.mail_error?`text-rose-500`:`text-muted`),children:t.mail_error?o(`The inbox could not be read: {error}`,{error:t.mail_error}):t.mail_checked_at?o(`Inbox checked {when}; {name} looks every {n} min.`,{when:e(t.mail_checked_at),name:n,n:t.mail_poll_minutes}):o(`{name} will look at the inbox shortly, then every {n} min.`,{name:n,n:t.mail_poll_minutes})}),(0,R.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:o(`Each time it happens {name} does the work in the chat it was set from and reports in the Feed. A mail or a request is treated as data, never as instructions.`,{name:n})})]})}function mm({available:e,onDone:t,onCancel:n}){let{toast:r}=E(),a=s(),o=[`mail`,`event`,`hook`].find(t=>e[t])??`hook`,[c,l]=(0,L.useState)(o),[u,d]=(0,L.useState)(``),[f,p]=(0,L.useState)(`30`),[m,h]=(0,L.useState)(``),[g,_]=(0,L.useState)(!1),[v,y]=(0,L.useState)(null),b=async()=>{if(m.trim()){_(!0);try{let e=await M.createTrigger({kind:c,text:m.trim(),match:u.trim(),lead_minutes:Number(f)||30});e.kind===`hook`&&e.url?y(e):t()}catch(e){r(e.message)}finally{_(!1)}}},x=`h-9 rounded-xl bg-surface border border-border px-3 text-[13px] outline-none focus:border-accent`;if(v)return(0,R.jsxs)(`div`,{className:`mb-2 rounded-2xl border border-border p-3 space-y-2`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium`,children:a(`Webhook ready`)}),(0,R.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:a(`POST anything to this URL and the work starts. The key is in the URL; share it only with the program that will call it.`)}),(0,R.jsx)(`code`,{className:`block break-all rounded-xl bg-surface-2 px-3 py-2 text-[12px]`,children:v.url}),(0,R.jsxs)(`div`,{className:`flex justify-end gap-2 pt-0.5`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:()=>void navigator.clipboard.writeText(v.url??``).then(()=>r(a(`Webhook URL copied`)),()=>r(v.url??``)),className:`rounded-full px-3 py-1.5 text-[13px] text-accent font-medium`,children:a(`Copy`)}),(0,R.jsx)(`button`,{type:`button`,onClick:t,className:`rounded-full bg-accent px-3.5 py-1.5 text-[13px] font-medium text-accent-fg`,children:a(`Done`)})]})]});let S=[[`mail`,a(`New mail`)],[`event`,a(`Before an event`)],[`hook`,a(`Webhook`)]];return(0,R.jsxs)(`div`,{className:`mb-2 rounded-2xl border border-border p-3 space-y-2`,children:[(0,R.jsx)(hm,{options:S,value:c,onChange:e=>l(e)}),!e[c]&&(0,R.jsx)(`div`,{className:`text-[12.5px] text-rose-500`,children:a(c===`mail`?`Connect a mailbox under Connections first.`:`Add a calendar under Connections first.`)}),(0,R.jsxs)(`div`,{className:`flex gap-1.5`,children:[(0,R.jsx)(`input`,{value:u,onChange:e=>d(e.target.value),placeholder:a(c===`mail`?`Words in the sender or subject (empty: any mail)`:c===`event`?`Words in the title or place (empty: any event)`:`A name for this hook`),className:i(x,`min-w-0 flex-1`)}),c===`event`&&(0,R.jsxs)(`label`,{className:`flex items-center gap-1 text-[12.5px] text-muted`,children:[(0,R.jsx)(`input`,{type:`number`,min:0,max:1440,value:f,onChange:e=>p(e.target.value),className:i(x,`w-[64px] px-2`),"aria-label":a(`Minutes before`)}),a(`min before`)]})]}),(0,R.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),placeholder:a(`What should be done each time…`),className:i(x,`w-full h-10 text-[14px]`)}),(0,R.jsx)(`div`,{className:`text-[12px] text-muted`,children:a(`When {condition}`,{condition:fm({kind:c,match:u.trim(),lead_minutes:Number(f)||30},a)})}),(0,R.jsxs)(`div`,{className:`flex justify-end gap-2 pt-0.5`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:n,className:`rounded-full px-3 py-1.5 text-[13px] text-muted`,children:a(`Cancel`)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void b(),disabled:g||!m.trim()||!e[c],className:`rounded-full bg-accent px-3.5 py-1.5 text-[13px] font-medium text-accent-fg disabled:opacity-50`,children:a(g?`Saving…`:`Set trigger`)})]})]})}function hm({options:e,value:t,onChange:n}){return(0,R.jsx)(`div`,{className:`flex rounded-full bg-surface-2 p-0.5`,children:e.map(([e,r])=>(0,R.jsx)(`button`,{type:`button`,onClick:()=>n(e),className:i(`rounded-full px-2.5 py-1 transition`,t===e?`bg-surface shadow-sm font-medium`:`text-muted`),children:r},e))})}function gm(){let e=new Date(Date.now()+36e5);e.setMinutes(0,0,0);let t=e=>String(e).padStart(2,`0`);return`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}T${t(e.getHours())}:${t(e.getMinutes())}`}function _m(e){let[t,n]=(0,L.useState)(null),[r,i]=(0,L.useState)(null),a=async()=>{try{n(await M.activity(150)),i(null)}catch(e){i(e.message||`Could not load.`)}};return(0,L.useEffect)(()=>{if(!e)return;a();let t=window.setInterval(()=>void a(),4e3);return()=>window.clearInterval(t)},[e]),[t,a,r]}function vm({open:e}){let[t,n,r]=_m(e),{state:i}=E(),a=s(),o=i.profile?.name??`nanoMuse`,c=(t?.audit??[]).filter(e=>e.event===`tool_call`).reverse(),l=c.filter(e=>e.channel===`gui`).length;return(0,R.jsxs)(`div`,{className:`pb-2`,children:[(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted mb-2`,children:a(`Every tool call goes through the Sentinel and is written to the audit log, including the ones it refused.`)}),l>0&&(0,R.jsx)(`p`,{className:`text-[12.5px] text-muted mb-2`,children:a(`On the phone's screen: {gui} of {total} steps ({pct}%). The screen is the last resort; a skill, a fetch or the browser comes first.`,{gui:String(l),total:String(c.length),pct:String(Math.round(l/c.length*100))})}),!t&&r&&(0,R.jsx)(qp,{message:r,onRetry:()=>void n()}),!t&&!r&&(0,R.jsx)(`div`,{className:`py-10 flex justify-center text-muted`,children:(0,R.jsx)(A,{className:`animate-spin`,size:20})}),t&&c.length===0&&(0,R.jsx)(`div`,{className:`py-8 text-center text-muted text-[14px]`,children:a(`Nothing yet today. What you ask for and what {name} does about it shows up here.`,{name:o})}),(0,R.jsx)(`ul`,{className:`space-y-1.5`,children:c.map((e,t)=>(0,R.jsx)(wm,{entry:e},`${e.ts}-${t}`))})]})}function ym({open:e}){let{state:t,toast:n}=E(),r=s(),[i,a]=_m(e),o=async()=>{if(window.confirm(r(`Forget every permission you granted? {name} will ask again next time.`,{name:t.profile?.name??`nanoMuse`})))try{await M.resetApprovals(),await a(),n(r(`Permissions reset`))}catch(e){n(e.message)}},c=async e=>{try{await M.revokeGrant(e.key),await a(),n(r(`Revoked: {subject}`,{subject:fp(e.tool,e.target)}))}catch(e){n(e.message)}},l=t.settings?.sentinel.mode;return(0,R.jsxs)(`div`,{className:`space-y-4 pb-2`,children:[(0,R.jsxs)(`div`,{className:`rounded-2xl border border-border p-3.5`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2 font-medium`,children:[(0,R.jsx)(Zt,{size:18,className:`text-accent`}),` `,r(`Sentinel mode:`),` `,(0,R.jsx)(`span`,{className:`capitalize`,children:l===`ask`?r(`balanced`):l===`strict`?r(`cautious`):l===`auto`?r(`hands-off`):l})]}),(0,R.jsxs)(`p`,{className:`mt-1 text-[12.5px] text-muted`,children:[l===`ask`&&r(`Safe and moderate actions run freely; anything sensitive (email, shell, purchases) waits for you.`),l===`strict`&&r(`Moderate and sensitive actions both wait for your approval.`),l===`auto`&&r(`Everything is approved automatically except explicit deny rules. Use with care.`)]}),i?.tainted&&(0,R.jsxs)(`p`,{className:`mt-2 text-[12.5px] text-amber-600 dark:text-amber-300 flex items-center gap-1.5`,children:[(0,R.jsx)(Qt,{size:14}),` `,r(`Private data was read this session: network calls to new destinations need approval.`)]})]}),(0,R.jsx)(Sm,{grants:i?.grants??[],onRevoke:c}),(0,R.jsx)(Cm,{title:r(`Always ask (from config)`),tools:t.settings?.sentinel.always_ask_tools??[],muted:!0}),(i?.grants.length??0)>0&&(0,R.jsx)(`button`,{type:`button`,onClick:()=>void o(),className:`w-full rounded-2xl border border-border py-2.5 text-[14px] font-medium`,children:r(`Reset all granted permissions`)})]})}function bm(e,t){switch(e.scope){case`conversation`:return t(`for this conversation`);case`session`:return t(`until restart`);case`24h`:return e.expires_at?t(`until {time}`,{time:u(e.expires_at)}):t(`for 24 hours`);case`always`:return t(`always`);default:return e.scope}}var xm=[`sensitive`,`moderate`,`low`,`safe`];function Sm({grants:e,onRevoke:t}){let n=s(),{state:r}=E(),i=e=>r.settings?.tools.find(t=>t.name===e)?.risk??`moderate`,a=xm.map(t=>({risk:t,rows:e.filter(e=>i(e.tool)===t)})).filter(e=>e.rows.length>0);return(0,R.jsx)(`div`,{className:`space-y-3`,children:e.length===0?(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:n(`Permissions you granted`)}),(0,R.jsx)(`div`,{className:`text-[13px] text-muted`,children:n(`No standing approvals. When you answer “always allow” to a risky step, it is listed here and can be revoked; approvals you give once are not kept.`)})]}):a.map((e,r)=>(0,R.jsxs)(`div`,{children:[(0,R.jsxs)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:[r===0?`${n(`Permissions you granted`)} · `:``,n(e.risk)]}),(0,R.jsx)(`ul`,{className:`space-y-1.5`,children:e.rows.map(e=>(0,R.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,R.jsx)(`span`,{className:`text-accent`,children:op(e.tool,15)}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:fp(e.tool,e.target)}),(0,R.jsxs)(`div`,{className:`text-[11.5px] text-muted`,children:[e.tool,` · `,bm(e,n),` · `,n(`granted {time}`,{time:u(e.granted_at)})]})]}),(0,R.jsx)(`button`,{type:`button`,"aria-label":n(`Revoke {what}`,{what:pp(e.scope,e.tool,e.target)}),onClick:()=>t(e),className:`rounded-full p-1.5 text-muted hover:bg-surface active:scale-95`,children:(0,R.jsx)(Pe,{size:15})})]},e.key))})]},e.risk))})}function Cm({title:e,tools:t,muted:n}){let r=s();return(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:e}),t.length===0?(0,R.jsx)(`div`,{className:`text-[13px] text-muted`,children:r(`None`)}):(0,R.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:t.map(e=>(0,R.jsxs)(`span`,{className:i(`rounded-full px-2.5 py-1 text-[12.5px] flex items-center gap-1`,n?`bg-surface-2 text-muted`:`bg-accent/12 text-accent`),children:[op(e,12),` `,e]},e))})]})}function wm({entry:e}){let t=s(),n=e.decision===`deny`,r=e.ok===!1&&!n;return(0,R.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,R.jsx)(`div`,{className:i(`mt-0.5`,n?`text-rose-500`:r?`text-amber-500`:`text-muted`),children:n||r?(0,R.jsx)(Je,{size:15}):(0,R.jsx)(Oe,{size:15})}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px]`,children:[(0,R.jsx)(`span`,{className:`text-muted`,children:op(e.tool??``,13)}),(0,R.jsx)(`span`,{className:`font-medium truncate`,children:e.summary||e.tool})]}),(0,R.jsxs)(`div`,{className:`mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] text-muted`,children:[(0,R.jsx)(`span`,{children:u(e.ts)}),e.risk&&(0,R.jsx)(lp,{risk:e.risk}),e.approved===!0&&(0,R.jsxs)(`span`,{className:`text-emerald-600 dark:text-emerald-300`,children:[t(`approved by you`),e.approval_scope&&e.approval_scope!==`once`?` (${e.approval_scope})`:``]}),e.approved==null&&e.approval_scope&&e.approval_scope!==`once`&&(0,R.jsx)(`span`,{className:`text-emerald-600 dark:text-emerald-300`,children:t(`covered by your {scope} permission`,{scope:e.approval_scope})}),n&&(0,R.jsx)(`span`,{className:`text-rose-500`,children:t(`blocked`)}),r&&(0,R.jsx)(`span`,{className:`text-amber-600`,children:t(`failed`)}),typeof e.duration_ms==`number`&&(0,R.jsxs)(`span`,{children:[(e.duration_ms/1e3).toFixed(1),`s`]})]}),n&&e.reasons&&e.reasons.length>0&&(0,R.jsx)(`div`,{className:`mt-0.5 text-[12px] text-muted`,children:e.reasons.map(v).join(` · `)})]})]})}function Tm({title:e,actions:t=[],trailing:n,children:r}){let{state:i,setDrawer:a}=E(),o=s(),[c,l]=(0,L.useState)(!1),u=i.profile?.name||`nanoMuse`,d=i.pendingApprovals.length,f=d>0?d>1?o(`{n} approvals waiting for you`,{n:d}):o(`1 approval waiting for you`):i.status.state===`working`?i.status.detail||o(`On it`):i.status.state===`waiting`?o(`Waiting for you`):null;return(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(ne,{profile:i.profile,status:i.status,name:u,statusLine:f,statusTone:f?`accent`:`muted`,spinning:i.status.state===`working`&&d===0,badge:d,onAvatar:()=>l(!0),leading:(0,R.jsx)(te,{onClick:()=>a(!0),label:o(`Chats`),className:`wide:invisible`,children:(0,R.jsx)(Dt,{size:22})}),trailing:n??(0,R.jsx)(Em,{actions:t})}),(e||r)&&(0,R.jsxs)(`div`,{className:`flex shrink-0 items-center gap-3 pr-5`,children:[(0,R.jsx)(k,{className:`flex-1`,children:e}),r]}),(0,R.jsx)(im,{open:c,onClose:()=>l(!1)})]})}function Em({actions:e=[]}){let{setTab:t}=E(),n=s(),[r,i]=(0,L.useState)(!1),a=(0,L.useRef)(null);(0,L.useEffect)(()=>{if(!r)return;let e=e=>{a.current?.contains(e.target)||i(!1)},t=e=>{e.key===`Escape`&&i(!1)};return window.addEventListener(`pointerdown`,e),window.addEventListener(`keydown`,t),()=>{window.removeEventListener(`pointerdown`,e),window.removeEventListener(`keydown`,t)}},[r]);let o=e=>{i(!1),t(e)},c=[...e.map(e=>({label:e.label,onClick:()=>{i(!1),e.onClick()}})),{label:n(`Memory`),onClick:()=>o(`memory`)},{label:n(`Settings`),onClick:()=>o(`you`)}];return(0,R.jsxs)(`div`,{ref:a,className:`relative`,children:[(0,R.jsx)(te,{onClick:()=>i(e=>!e),label:n(`More`),expanded:r,children:(0,R.jsx)(ft,{size:22})}),r&&(0,R.jsx)(`ul`,{role:`menu`,className:`absolute right-0 top-12 z-30 min-w-[180px] overflow-hidden rounded-2xl border border-border bg-surface py-1 shadow-xl`,children:c.map(e=>(0,R.jsx)(`li`,{children:(0,R.jsx)(`button`,{type:`button`,role:`menuitem`,onClick:e.onClick,className:`w-full px-4 py-2.5 text-left text-[14.5px] hover:bg-surface-2`,children:e.label})},e.label))})]})}var Dm=`nanomuse-naming`;function Om(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function km(e,t,n=!1){let r="```"+t,i=e;if(i.includes(r)){let e=RegExp("```"+Om(t)+"[ \\t]*\\r?\\n[\\s\\S]*?```",`g`);i=i.replace(e,``);let n=i.lastIndexOf(r);n>=0&&(i=i.slice(0,n))}if(n){let e=i.lastIndexOf("```");e>=0&&r.startsWith(i.slice(e))&&(i=i.slice(0,e))}return i===e?e:i.replace(/\s+$/,``)}function Am(e,t=!1){return km(e,Dm,t)}function jm(e){return e.filter(e=>!e.this&&e.kind!==`web`&&e.name.trim()!==``)}function Mm(e){let t=/^@([^\s,:;,:;]*)$/.exec(e);return t?t[1]:null}function Nm(e,t,n=6){let r=Mm(e);if(r===null)return[];let i=r.toLowerCase();return jm(t).filter(e=>e.name.toLowerCase().startsWith(i)).sort((e,t)=>Number(t.online)-Number(e.online)||e.name.localeCompare(t.name)).slice(0,n)}function Pm(e,t){let n=e.trimStart();if(!n.startsWith(`@`))return null;let r=n.slice(1),i=r.toLowerCase(),a=jm(t),o=a.filter(e=>{let t=e.name.trim().toLowerCase();if(!i.startsWith(t))return!1;let n=r.charAt(t.length);return n===``||/[\s,:;,:;]/.test(n)}).sort((e,t)=>t.name.length-e.name.length);if(o[0])return o[0];let s=/^[^\s,:;,:;]+/.exec(r)?.[0]?.toLowerCase()??``;if(!s)return null;let c=a.filter(e=>e.name.trim().toLowerCase().startsWith(s));if(c.length===1)return c[0];let l=c.filter(e=>e.online);return l.length===1?l[0]:null}var Fm=`nm.show_steps`;function Im(){try{return localStorage.getItem(Fm)!==`0`}catch{return!0}}var Lm=Im(),Rm=new Set;function zm(){return Lm}function Bm(e){Lm=e;try{localStorage.setItem(Fm,e?`1`:`0`)}catch{}Rm.forEach(e=>e())}function Vm(e){return Rm.add(e),()=>Rm.delete(e)}function Hm(){return(0,L.useSyncExternalStore)(Vm,zm,zm)}function Um(){let{state:e,send:t,sendViaCloud:n,decide:r,loadEvents:i,openFile:a,toast:o,setDrawer:c,setTab:l}=E(),u=s(),{profile:d,status:f,activeThread:p,threads:m}=e,h=e.events[p],g=(0,L.useMemo)(()=>h??[],[h]),_=h!==void 0,v=e.streams[p],b=m.find(e=>e.id===p),[x,S]=(0,L.useState)(!1),[C,w]=(0,L.useState)(null),T=d?.name??`nanoMuse`,D=b?.device?e.hub?.devices.find(e=>e.id===b.device)?.online??null:null,O=th(e.working[p]),k=(0,L.useRef)(null),ee=(0,L.useRef)(null),A=(0,L.useRef)(!0),[j,re]=(0,L.useState)(!1),N=e.firstrun,P=!!N&&N.phase!==`none`&&N.session_id===p,ie=P&&N.running,ae=y()===`zh-CN`?`zh`:`en`,[oe,se]=(0,L.useState)(null);(0,L.useEffect)(()=>{if(!P||oe?.lang===ae)return;let e=!0;return M.firstrun(ae).then(t=>e&&t.intro&&se({lang:ae,lines:t.intro})).catch(()=>void 0),()=>{e=!1}},[P,ae,oe?.lang]);let ce=e=>{M.firstrunPick(e).then(()=>t(p,e)).catch(e=>o(e.message||u(`Could not send`)))},le=()=>window.dispatchEvent(new Event(`nanomuse:quick-chat`)),[ue,de]=(0,L.useState)(null),fe=e.settings?.onboarded===!0,pe=(0,L.useRef)(!1),me=(0,L.useRef)(!1);(0,L.useEffect)(()=>{if(b?.busy){pe.current||(me.current=ie),pe.current=!0;return}pe.current&&(pe.current=!1,fe&&!me.current&&!ie&&sh(g)&&de(xe()))},[b?.busy,g,fe,ie]);let[he,ge]=(0,L.useState)(null);(0,L.useEffect)(()=>{let{days:e,fresh:t}=ye();t&&ge(e)},[]);let _e=(0,L.useCallback)(()=>{let e=k.current;e&&(e.scrollTop=e.scrollHeight,re(!1))},[]),ve=(0,L.useCallback)(()=>{let e=k.current;if(!e)return;let t=e.scrollHeight-e.scrollTop-e.clientHeight;A.current=t<120,re(t>400)},[]),be=(0,L.useRef)(null);(0,L.useLayoutEffect)(()=>{be.current!==p&&(be.current=p,A.current=!0),A.current&&_e()},[g,v?.text,p,_e]),(0,L.useEffect)(()=>{let e=k.current,t=ee.current;if(!e||!t||typeof ResizeObserver>`u`)return;let n=new ResizeObserver(()=>{A.current?_e():ve()});return n.observe(e),n.observe(t),()=>n.disconnect()},[_e,ve]);let Se=()=>{let e=k.current;e&&e.scrollTo({top:e.scrollHeight,behavior:`smooth`})},we=e.pendingApprovals.length,Te=e.pendingApprovals.filter(e=>e.thread===p).length,Ee=(0,L.useMemo)(()=>{let e=!f.thread||f.thread===p;if(Te>0&&!(e&&f.state===`working`))return Te>1?u(`{n} approvals waiting for you`,{n:Te}):u(`1 approval waiting for you`);if(!e)return b?.busy?u(`On it`):void 0;if(f.state===`idle`&&!b?.busy)return;if(f.detail)return f.detail;if(f.state===`waiting`)return u(`Waiting for you`);let t=oh(g);return t?u(`On it: {request}`,{request:t}):u(`On it`)},[f,b,p,Te,g,u]),De=g.filter(e=>e.type===`approval`&&e.status===`pending`).length,Oe=Hm(),ke=(0,L.useMemo)(()=>Oe?g:g.filter(e=>e.type!==`tool`),[g,Oe]),Ae={signedIn:!!e.hub?.account.signed_in,chatOnCloud:!!e.settings?.llm.cloud,deviceChat:!!b?.device},Me=e=>{let t=$f(ke,e,Ae);return t?()=>n(p,t.text,t.files):void 0},Ne=(0,L.useMemo)(()=>Array.from(new Set(g.flatMap(e=>e.type===`artifact`?[e.path]:[]))),[g]);return(0,R.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,R.jsx)(ne,{profile:d,status:f,name:T,statusLine:Ee,statusTone:f.state===`idle`&&!b?.busy?`muted`:`accent`,spinning:(f.state===`working`&&f.thread===p||!!b?.busy)&&Te===0,badge:we,onAvatar:()=>S(!0),leading:(0,R.jsx)(te,{onClick:()=>c(!0),label:u(`Chats`),dot:m.length>1&&p!==`main`,className:`wide:invisible`,children:(0,R.jsx)(Dt,{size:22})}),trailing:(0,R.jsx)(Em,{actions:[{label:u(`Coding agents`),onClick:()=>l(`coding`)}]}),under:b&&b.id!==`main`?b.device?(0,R.jsxs)(`span`,{className:`flex max-w-full items-center gap-1 rounded-full bg-accent/12 px-2.5 py-0.5 text-[11.5px] font-medium text-accent`,children:[D===!1?(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-border`}):(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-emerald-500`}),(0,R.jsx)(`span`,{className:`truncate`,children:u(`on {device}`,{device:b.device_name||b.device})})]}):b.remote_from?(0,R.jsxs)(`span`,{className:`flex max-w-full items-center gap-1 rounded-full bg-surface-2 px-2.5 py-0.5 text-[11.5px] font-medium text-fg/80`,children:[(0,R.jsx)(Ft,{size:11}),` `,(0,R.jsx)(`span`,{className:`truncate`,children:u(`from {device}`,{device:b.remote_from.name})})]}):(0,R.jsx)(`span`,{className:`truncate rounded-full bg-surface-2 px-2.5 py-0.5 text-[11.5px] font-medium text-fg/80`,children:b.title}):void 0}),(0,R.jsxs)(`div`,{ref:k,onScroll:ve,className:`relative flex-1 overflow-y-auto px-3 py-3`,children:[(0,R.jsxs)(`div`,{ref:ee,className:`space-y-2.5`,children:[e.hasMore[p]&&g.length>0&&(0,R.jsx)(`div`,{className:`flex justify-center`,children:(0,R.jsx)(`button`,{type:`button`,className:`text-[12.5px] text-accent px-3 py-1 rounded-full bg-surface-2`,onClick:()=>void i(p,g[0]?.id),children:u(`Load earlier messages`)})}),P&&_&&!e.hasMore[p]&&oe&&(0,R.jsx)(Pp,{lines:oe.lines}),_&&g.length===0&&!v&&!P&&(0,R.jsx)(rh,{name:T,device:b?.device?b.device_name||b.device:void 0,onSend:e=>t(p,e).catch(e=>o(e.message||u(`Could not send`)))}),ke.map((e,t)=>(0,R.jsx)(Wm,{event:e,prev:ke[t-1],name:T,onDecide:(t,n)=>r(e.id,t,n).catch(e=>o(e.message||u(`Could not send decision`))),onOpenFile:a,onOpenBrowser:w,files:Ne,onCloudRetry:Me(t)},e.id)),v&&v.text&&(0,R.jsx)(Qm,{text:v.text,streaming:!0,files:Ne,onOpenFile:a}),(b?.busy||f.state!==`idle`)&&!v?.text&&f.state!==`waiting`&&(0,R.jsx)(eh,{label:b?.queued?u(`{n} queued`,{n:b.queued}):void 0}),P&&N.phase===`ask_agent_name`&&!b?.busy&&(0,R.jsx)(Fp,{chips:N.chips,chosen:N.chosen,onPick:ce,onElse:le}),O&&!b?.busy&&(0,R.jsx)(nh,{who:O}),!b?.busy&&f.state===`idle`&&ue!==null&&(0,R.jsx)(Ce,{moment:`tasks`,n:ue,className:`mx-1 my-2`}),!b?.busy&&f.state===`idle`&&he!==null&&(0,R.jsx)(Ce,{moment:`days_used`,n:he,className:`mx-1 my-2`})]}),j&&(0,R.jsxs)(`button`,{type:`button`,onClick:Se,className:`sticky bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-surface border border-border shadow px-3 py-1.5 text-[12.5px] flex items-center gap-1`,children:[(0,R.jsx)(je,{size:14}),` `,u(`Latest`),De?` · ${u(`{n} approval`,{n:De})}`:``]})]}),(0,R.jsx)(Lf,{}),(0,R.jsx)(ih,{name:T,busy:!!b?.busy&&De===0,waiting:g.some(e=>e.type===`question`&&e.status===`pending`),onSend:(e,n)=>t(p,e,n).catch(e=>{throw o(e.message||u(`Could not send`)),e})}),(0,R.jsx)(im,{open:x,onClose:()=>S(!1)}),C&&(0,R.jsx)(kp,{thread:p,eventId:C,onClose:()=>w(null)})]})}function Wm({event:e,prev:t,name:n,onDecide:r,onOpenFile:i,onOpenBrowser:a,files:o,onCloudRetry:s}){let c=Km(t,e)?(0,R.jsx)(qm,{ts:e.ts}):null,l=(()=>{switch(e.type){case`user`:return(0,R.jsx)(Jm,{event:e,onOpenFile:i});case`assistant`:return e.quiet?(0,R.jsx)($m,{text:e.text,about:e.about,ts:e.ts}):(0,R.jsx)(Qm,{text:e.text,reasoning:e.reasoning,continued:t?.type===`assistant`,files:o,onOpenFile:i,spoken:e.via===`call`,modelUsed:e.model_used});case`tool`:return(0,R.jsx)(dp,{event:e});case`approval`:return(0,R.jsx)(mp,{event:e,name:n,onDecide:r});case`question`:return(0,R.jsx)(hp,{event:e,name:n});case`notice`:return(0,R.jsx)(_p,{event:e,onCloudRetry:s});case`artifact`:return(0,R.jsx)(Tp,{event:e,onOpen:i});case`browser`:return(0,R.jsx)(vp,{event:e,onOpen:a});case`hands`:return(0,R.jsx)(yp,{event:e,name:n});case`hold`:return(0,R.jsx)(Cp,{event:e,name:n});case`avatar`:return(0,R.jsx)(tp,{event:e,name:n});default:return null}})();return l?(0,R.jsxs)(R.Fragment,{children:[c,l]}):null}var Gm=6e5;function Km(e,t){return t.ts?!e?.ts||new Date(t.ts).getTime()-new Date(e.ts).getTime()>=Gm:!1}function qm({ts:e}){let t=l(e);return t?(0,R.jsx)(`div`,{className:`pt-2 pb-0.5 text-center text-[11.5px] font-medium text-muted`,children:t}):null}function Jm({event:e,onOpenFile:t}){let n=s(),r=e.files??[],a=r.filter(e=>e.kind===`image`),o=r.filter(e=>e.kind!==`image`);return(0,R.jsxs)(`div`,{className:`rise flex flex-col items-end pl-12`,children:[e.via===`call`?(0,R.jsxs)(`span`,{className:`mb-1 flex items-center gap-1 text-[11.5px] font-medium text-muted`,children:[(0,R.jsx)(Ht,{size:11}),` `,n(`said on a call`)]}):e.via?(0,R.jsx)(`span`,{className:`mb-1 text-[11.5px] font-medium text-muted`,children:n(`asked from {device}`,{device:e.via})}):e.to_device_name||e.to_device?(0,R.jsxs)(`span`,{className:`mb-1 flex items-center gap-1 text-[11.5px] font-medium text-accent`,children:[(0,R.jsx)(Ft,{size:11}),` `,n(`to {device}`,{device:e.to_device_name||e.to_device||``})]}):(e.via_device_name||e.via_device)&&(0,R.jsx)(`span`,{className:`mb-1 text-[11.5px] font-medium text-muted`,children:n(`From {device}`,{device:e.via_device_name||e.via_device||``})}),a.length>0&&(0,R.jsx)(`div`,{className:`mb-1 flex max-w-full flex-wrap justify-end gap-1.5`,children:a.map(e=>(0,R.jsx)(`button`,{type:`button`,onClick:()=>t(e.path),"aria-label":e.name,className:i(`overflow-hidden rounded-2xl border border-border/60 bg-surface-2 shadow-sm active:scale-[0.98] transition`,a.length===1?`max-h-64 max-w-[240px]`:`h-28 w-28`),children:(0,R.jsx)(`img`,{src:h(e.path),alt:e.name,loading:`lazy`,className:i(`object-cover`,a.length===1?`max-h-64 w-auto max-w-full`:`h-full w-full`)})},e.path))}),o.map(e=>(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(e.path),className:`mb-1 flex max-w-full items-center gap-2.5 rounded-2xl border border-border bg-surface px-3 py-2 text-left shadow-sm hover:bg-surface-2 active:scale-[0.98] transition`,children:[(0,R.jsx)(`span`,{className:`rounded-xl bg-accent/12 p-2 text-accent`,children:e.kind===`data`?(0,R.jsx)(sn,{size:17}):(0,R.jsx)(pt,{size:17})}),(0,R.jsxs)(`span`,{className:`min-w-0`,children:[(0,R.jsx)(`span`,{className:`block truncate text-[14px] font-medium`,children:e.name}),(0,R.jsx)(`span`,{className:`block text-[11.5px] text-muted`,children:Ym(e.size)})]})]},e.path)),e.text&&(0,R.jsx)(`div`,{className:`bubble-user max-w-full rounded-[20px] bg-bubble-user px-4 py-2.5 text-bubble-user-fg`,children:(0,R.jsx)(`div`,{className:`md text-[15px] leading-[1.45] whitespace-pre-wrap break-words`,children:e.text})})]})}function Ym(e){return e<1024?`${e} B`:e<1048576?`${Math.round(e/1024)} KB`:`${(e/1048576).toFixed(1)} MB`}function Xm(e){let t=e.toLowerCase().split(`.`).pop()??``;return[`csv`,`tsv`,`json`,`xlsx`,`xls`].includes(t)?`data`:`other`}var Zm=`image/*,.pdf,.txt,.md,.csv,.tsv,.json,.log,.html,.xml,.yaml,.yml,.toml,.ics,.vcf,.xlsx,.xls,.docx`;function Qm({text:e,reasoning:t,streaming:n,continued:r,files:a,onOpenFile:o,spoken:c,modelUsed:l}){let[u,d]=(0,L.useState)(!1),f=s(),p=(0,L.useMemo)(()=>pf(Am(e,!!n)),[e,n]);return p.length===0&&!n&&!t?null:(0,R.jsx)(`div`,{className:i(`rise flex items-end gap-2 pr-10`,r&&`-mt-1`),children:(0,R.jsxs)(`div`,{className:`min-w-0 max-w-full`,children:[t&&(0,R.jsx)(`button`,{type:`button`,className:`mb-1 ml-1 text-[12px] text-muted`,onClick:()=>d(e=>!e),children:f(u?`Hide thinking`:`Show thinking`)}),t&&u&&(0,R.jsx)(`div`,{className:`mb-1.5 rounded-2xl bg-surface-2/70 px-3 py-2 text-[13px] text-muted whitespace-pre-wrap break-words`,children:t}),p.map((e,t)=>e.bare?(0,R.jsx)(`div`,{className:`my-1 max-w-full`,children:(0,R.jsx)(mf,{text:e.text,files:a,onOpenFile:o})},t):(0,R.jsxs)(`div`,{className:i(`my-1 max-w-[340px] rounded-[20px] bg-surface-2 px-3.5 py-2.5 wide:max-w-[600px]`,c&&`border border-accent/15`),children:[c&&t===0&&(0,R.jsxs)(`div`,{className:`mb-0.5 flex items-center gap-1 text-[11px] font-medium text-accent/80`,children:[(0,R.jsx)(Ht,{size:10}),` `,f(`said on a call`)]}),(0,R.jsx)(mf,{text:e.text,files:a,onOpenFile:o}),n&&t===p.length-1&&(0,R.jsx)(`span`,{className:`inline-block w-1.5 h-4 ml-0.5 align-middle bg-accent/70 animate-pulse rounded-sm`})]},t)),p.length===0&&n&&(0,R.jsx)(`div`,{className:`my-1 rounded-[20px] bg-surface-2 px-3.5 py-2.5`,children:(0,R.jsx)(`span`,{className:`inline-block w-1.5 h-4 align-middle bg-accent/70 animate-pulse rounded-sm`})}),l&&!n&&(0,R.jsx)(`div`,{className:`ml-1 text-[11px] text-muted`,children:f(`Answered by nanoMuse Cloud this time ({model})`,{model:l})})]})})}function $m({text:e,about:n,ts:r}){let[i,a]=(0,L.useState)(!1),o=s(),c=n?t(n.replace(/^Working on your goal: /,``)):o(`background check`);return(0,R.jsx)(`div`,{className:`rise flex justify-center px-6`,children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>a(e=>!e),className:`max-w-full rounded-2xl px-3 py-1.5 text-[12px] text-muted text-center leading-snug`,children:[(0,R.jsxs)(`span`,{className:`inline-flex items-center gap-1.5`,children:[(0,R.jsx)(Lt,{size:12}),` `,o(`Checked on {label}: nothing new`,{label:c}),r?` · ${u(r)}`:``]}),i&&(0,R.jsx)(`span`,{className:`block mt-1 text-left whitespace-pre-wrap text-[12.5px]`,children:e})]})})}function eh({label:e}){return(0,R.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,R.jsxs)(`div`,{className:`rounded-[20px] rounded-bl-md bg-surface-2 px-3.5 py-2.5 flex items-center gap-1`,children:[(0,R.jsx)(`span`,{className:`typing-dot h-2 w-2 rounded-full bg-muted`}),(0,R.jsx)(`span`,{className:`typing-dot h-2 w-2 rounded-full bg-muted`}),(0,R.jsx)(`span`,{className:`typing-dot h-2 w-2 rounded-full bg-muted`})]}),e&&(0,R.jsx)(`span`,{className:`text-[12px] text-muted`,children:e})]})}function th(e){let t=e?e.at*1e3+N:0,[n,r]=(0,L.useState)(0);return(0,L.useEffect)(()=>{if(!t)return;let e=t-Date.now();if(e<=0){r(t);return}let n=window.setTimeout(()=>r(t),e+50);return()=>window.clearTimeout(n)},[t]),!e||n===t?null:e}function nh({who:e}){let t=s();return(0,R.jsxs)(`div`,{className:`flex items-center gap-1.5 px-1 text-[12px] text-muted`,children:[(0,R.jsx)(A,{size:12,className:`animate-spin`}),(0,R.jsx)(`span`,{children:t(`{device} is working…`,{device:e.device_name||e.device})})]})}function rh({name:e,device:t,onSend:n}){let r=s(),i=t?[r(`What is on your screen right now?`),r(`Which folder are you in, and what is in it?`),r(`Check for updates and tell me what needs a restart`)]:[r(`What can you do for me?`),r(`Plan my week: ask me what's on my plate`),r(`Research and compare two options for me`),r(`Set up a long-term goal and track it`)];return(0,R.jsxs)(`div`,{className:`flex flex-col items-center text-center px-6 pt-10 pb-6 gap-3`,children:[(0,R.jsx)(`div`,{className:`text-[20px] font-semibold`,children:t?r(`This chat goes to {device}.`,{device:t}):r(`Hi, I'm {name}.`,{name:e})}),(0,R.jsx)(`p`,{className:`text-muted text-[14.5px] leading-snug max-w-sm`,children:t?r(`Whatever you ask here, the {name} on {device} does where it is: its shell, its files, its screen. Every step shows up here, and anything that needs an approval asks you here.`,{name:e,device:t}):r(`I don't just answer; I get things done: research, plans, files, code, email, long-running goals. Everything I do shows up here, and anything hard to undo waits for your approval.`)}),(0,R.jsx)(`div`,{className:`mt-2 flex flex-wrap justify-center gap-2`,children:i.map(e=>(0,R.jsx)(`button`,{type:`button`,onClick:()=>n(e),className:`rounded-full bg-surface-2 px-3.5 py-1.5 text-[13.5px] hover:bg-border/60`,children:e},e))})]})}function ih({name:e,busy:t,waiting:n,onSend:r}){let{state:a,draft:o,draftFiles:c,toast:l}=E(),[u,d]=(0,L.useState)(``),[f,p]=(0,L.useState)(!1),m=(0,L.useRef)(``),g=Mp((0,L.useCallback)((e,t)=>{let n=m.current,r=n&&!/\s$/.test(n)?` `:``;d(n+r+e+t)},[]),(0,L.useCallback)(e=>l(e),[l])),_=()=>{g.listening||(m.current=u),g.toggle()},[v,y]=(0,L.useState)([]),[b,x]=(0,L.useState)([]);(0,L.useEffect)(()=>{if(!a.draftFiles)return;let e=a.draftFiles.map(e=>({key:`shared-${e.path}`,file:new File([],e.name),preview:e.kind===`image`?h(e.path):null,info:e,error:null}));x(t=>[...t.filter(t=>!e.some(e=>e.key===t.key)),...e].slice(0,10)),c(null)},[a.draftFiles]);let S=(0,L.useRef)(null),C=(0,L.useRef)(null),w=s();(0,L.useEffect)(()=>()=>b.forEach(e=>e.preview?.startsWith(`blob:`)&&URL.revokeObjectURL(e.preview)),[b]),(0,L.useEffect)(()=>{let e=()=>S.current?.focus();return window.addEventListener(`nanomuse:quick-chat`,e),()=>window.removeEventListener(`nanomuse:quick-chat`,e)},[]);let T=e=>{let t=Array.from(e).slice(0,Math.max(0,10-b.length));if(t.length===0)return;let n=t.map(e=>({key:`${e.name}-${e.size}-${e.lastModified}-${Math.random().toString(36).slice(2,7)}`,file:e,preview:e.type.startsWith(`image/`)?URL.createObjectURL(e):null,info:null,error:null}));x(e=>[...e,...n]);for(let e of n)M.upload(e.file).then(t=>x(n=>n.map(n=>n.key===e.key?{...n,info:t}:n))).catch(t=>{let n=t.message?P(t.message):w(`Upload failed`);x(t=>t.map(t=>t.key===e.key?{...t,error:n}:t)),l(n)})},D=e=>x(t=>t.filter(t=>t.key!==e)),O=b.some(e=>!e.info&&!e.error),k=b.flatMap(e=>e.info?[e.info.path]:[]);(0,L.useEffect)(()=>{let e=S.current;e&&(e.style.height=`0px`,e.style.height=`${Math.min(e.scrollHeight,160)}px`)},[u]),(0,L.useEffect)(()=>{if(a.draft===null)return;d(a.draft),o(null);let e=S.current;e&&(e.focus(),e.setSelectionRange(e.value.length,e.value.length))},[a.draft]);let ee=a.settings?.skills?.enabled!==!1;(0,L.useEffect)(()=>{ee&&M.skills().then(e=>y(e.skills.filter(e=>e.enabled))).catch(()=>y([]))},[ee,a.skillsVersion]);let te=/^\/([a-z0-9-]*)$/i.exec(u),j=te?v.filter(e=>e.name.startsWith(te[1].toLowerCase())).slice(0,6):[],ne=e=>{d(`/${e.name} `),S.current?.focus()},re=a.hub?.devices??[],N=Nm(u,re),ie=N.length===0?Pm(u,re):null,ae=e=>{d(`@${e.name} `),S.current?.focus()},oe=e=>{e?.preventDefault();let t=u.trim();if(!t&&k.length===0||O||f)return;g.listening&&g.stop();let n=b;p(!0),d(``),x([]),S.current?.focus(),r(t,k).catch(()=>{d(e=>e||t),x(e=>e.length?e:n)}).finally(()=>p(!1))};return(0,R.jsxs)(`form`,{onSubmit:oe,className:`shrink-0 bg-bg px-3 pt-1.5 pb-1`,children:[t&&!n&&(0,R.jsx)(`div`,{className:`px-2 pb-1 text-[12px] text-muted`,children:w(`{name} is working; anything you send now is picked up right away.`,{name:e})}),j.length>0&&(0,R.jsx)(`ul`,{className:`mb-2 max-h-72 overflow-y-auto rounded-3xl border border-border/70 bg-surface shadow-lg divide-y divide-border/70`,role:`listbox`,"aria-label":w(`Skills`),children:j.map(e=>(0,R.jsx)(`li`,{children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>ne(e),className:`flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-surface-2/70 active:bg-surface-2`,children:[(0,R.jsx)(hn,{size:16,className:`shrink-0 text-accent`}),(0,R.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,R.jsxs)(`span`,{className:`block text-[14px] font-medium`,children:[`/`,e.name]}),(0,R.jsx)(`span`,{className:`block truncate text-[12.5px] text-muted`,children:e.description})]})]})},e.name))}),N.length>0&&(0,R.jsx)(`ul`,{className:`mb-2 max-h-72 overflow-y-auto rounded-3xl border border-border/70 bg-surface shadow-lg divide-y divide-border/70`,role:`listbox`,"aria-label":w(`Devices`),children:N.map(e=>(0,R.jsx)(`li`,{children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>ae(e),className:`flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-surface-2/70 active:bg-surface-2`,children:[(0,R.jsx)(`span`,{className:`shrink-0 text-accent`,children:e.kind===`computer`?(0,R.jsx)(It,{size:16}):(0,R.jsx)(en,{size:16})}),(0,R.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,R.jsxs)(`span`,{className:`block text-[14px] font-medium`,children:[`@`,e.name]}),(0,R.jsx)(`span`,{className:`block truncate text-[12.5px] text-muted`,children:e.online?w(`Runs this there and reports back`):w(`Offline`)})]})]})},e.id))}),ie&&(0,R.jsxs)(`div`,{className:`flex items-center gap-1.5 px-2 pb-1 text-[12px] text-muted`,children:[(0,R.jsx)(Ft,{size:12}),` `,w(`This goes to {device}.`,{device:ie.name})]}),b.length>0&&(0,R.jsx)(`div`,{className:`mb-1 flex gap-2.5 overflow-x-auto px-1 pt-2.5 pb-1 pr-3`,role:`list`,"aria-label":w(`Attachments`),children:b.map(e=>(0,R.jsxs)(`div`,{role:`listitem`,className:i(`relative shrink-0 rounded-2xl border bg-surface-2`,e.error?`border-danger/60`:`border-border/70`),children:[e.preview?(0,R.jsx)(`img`,{src:e.preview,alt:e.file.name,className:`h-16 w-16 rounded-2xl object-cover`}):(0,R.jsxs)(`div`,{className:`flex h-16 w-40 items-center gap-2 px-2.5`,children:[(0,R.jsx)(`span`,{className:`rounded-xl bg-accent/12 p-1.5 text-accent`,children:Xm(e.file.name)===`data`?(0,R.jsx)(sn,{size:16}):(0,R.jsx)(pt,{size:16})}),(0,R.jsxs)(`span`,{className:`min-w-0`,children:[(0,R.jsx)(`span`,{className:`block truncate text-[12.5px] font-medium`,children:e.file.name}),(0,R.jsx)(`span`,{className:`block text-[11px] text-muted`,children:e.error?w(`Upload failed`):Ym(e.info?.size??e.file.size)})]})]}),!e.info&&!e.error&&(0,R.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center rounded-2xl bg-bg/50`,children:(0,R.jsx)(A,{size:18,className:`animate-spin text-accent`})}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>D(e.key),"aria-label":w(`Remove`),className:`absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-surface text-muted shadow-sm`,children:(0,R.jsx)(Pe,{size:13})})]},e.key))}),(0,R.jsxs)(`div`,{className:`flex items-end gap-2 rounded-[26px] border border-border bg-surface px-1.5 py-1.5 shadow-[0_2px_12px_-6px_rgba(0,0,0,0.12)] focus-within:border-fg/20`,children:[(0,R.jsx)(`input`,{ref:C,type:`file`,multiple:!0,accept:Zm,className:`hidden`,onChange:e=>{e.target.files&&T(e.target.files),e.target.value=``}}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>C.current?.click(),"aria-label":w(`Attach a file`),className:`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg/70 transition hover:bg-surface-2 active:scale-95`,children:(0,R.jsx)(Gt,{size:22})}),(0,R.jsx)(`textarea`,{ref:S,value:u,onChange:e=>d(e.target.value),onPaste:e=>{let t=Array.from(e.clipboardData?.files??[]);t.length>0&&(e.preventDefault(),T(t))},onKeyDown:e=>{if(e.key===`Tab`&&j.length>0&&j[0]){e.preventDefault(),ne(j[0]);return}if(e.key===`Tab`&&N.length>0&&N[0]){e.preventDefault(),ae(N[0]);return}e.key===`Enter`&&!e.shiftKey&&!e.nativeEvent.isComposing&&(e.preventDefault(),oe())},rows:1,placeholder:n?w(`Answer {name}…`,{name:e}):b.length>0?w(`Say what to do with it…`):w(`Message`),className:`flex-1 resize-none bg-transparent px-1 py-2 text-[15px] leading-[1.4] outline-none placeholder:text-muted`}),g.supported&&(0,R.jsx)(Np,{listening:g.listening,onClick:_}),(0,R.jsx)(`button`,{type:`submit`,disabled:!u.trim()&&k.length===0||O,"aria-label":w(`Send`),className:i(`flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition active:scale-95`,u.trim()||k.length>0?`bg-accent text-accent-fg`:`bg-surface-2 text-muted`,`disabled:opacity-60`),children:(0,R.jsx)(qe,{size:19,strokeWidth:2.5})})]})]})}function ah({threads:e,active:t,onPick:n,onCleared:r,compact:a=!1,grouped:o=!1}){let{state:c,toast:l,dispatch:d}=E(),f=s(),[p,m]=(0,L.useState)(``),[h,g]=(0,L.useState)(!1),_=(0,L.useRef)(null),v=(c.hub?.devices??[]).filter(e=>!e.this&&e.kind!==`web`&&e.online),y=e=>c.hub?.devices.find(t=>t.id===e)?.kind??`phone`,b=async e=>{try{let t=await M.askDevice(e.id);d({type:`ws`,msg:{kind:`thread`,thread:t.thread}}),n(t.thread.id)}catch(e){l(e.message)}},x=async()=>{g(!0);try{let e=await M.createThread(p.trim()||f(`Side chat`));d({type:`ws`,msg:{kind:`thread`,thread:e}}),m(``),n(e.id)}catch(e){l(e.message)}finally{g(!1)}},S=async e=>{if(window.confirm(f(`Delete this side chat and its history?`)))try{await M.deleteThread(e),d({type:`ws`,msg:{kind:`thread_deleted`,thread:e}})}catch(e){l(e.message)}},C=async e=>{if(window.confirm(f(`Clear this conversation? Memory and goals are kept.`)))try{await M.clearThread(e),d({type:`ws`,msg:{kind:`thread_cleared`,thread:e}}),r?.()}catch(e){l(e.message)}},w=e=>(0,R.jsxs)(`li`,{className:i(`group flex items-center gap-2 px-3`,a?`py-2 rounded-xl hover:bg-surface-2/60`:`py-2.5`,e.id===t&&`bg-surface-2/60`),children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>n(e.id),className:`flex-1 text-left min-w-0`,children:[(0,R.jsxs)(`div`,{className:i(`font-medium truncate flex items-center gap-2`,a?`text-[13.5px]`:`text-[15px]`),children:[e.device?(0,R.jsx)(`span`,{className:`text-accent`,children:y(e.device)===`computer`?(0,R.jsx)(It,{size:14}):(0,R.jsx)(en,{size:14})}):e.remote_from?(0,R.jsx)(Ft,{size:14,className:`text-muted`}):null,(0,R.jsx)(`span`,{className:`truncate`,children:e.id===`main`?f(e.title):e.title}),e.busy&&(0,R.jsx)(`span`,{className:`h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400 animate-pulse`})]}),(0,R.jsxs)(`div`,{className:i(`text-muted`,a?`text-[11.5px]`:`text-[12px]`),children:[f(`{n} events`,{n:e.events}),e.queued?` · ${f(`{n} queued`,{n:e.queued})}`:``,` · `,u(e.updated_at)]})]}),(0,R.jsx)(`button`,{type:`button`,"aria-label":f(`Clear`),onClick:()=>void C(e.id),className:i(`p-2 text-muted hover:text-fg`,a&&`opacity-0 group-hover:opacity-100 focus:opacity-100`),children:(0,R.jsx)(Pe,{size:16})}),e.id!==`main`&&(0,R.jsx)(`button`,{type:`button`,"aria-label":f(`Delete`),onClick:()=>void S(e.id),className:i(`p-2 text-muted hover:text-rose-500`,a&&`opacity-0 group-hover:opacity-100 focus:opacity-100`),children:(0,R.jsx)(dn,{size:16})})]},e.id),T=i(`divide-y divide-border overflow-hidden`,a?`rounded-xl`:`rounded-2xl border border-border`),D=`px-3 pb-1 pt-3 text-[11.5px] font-semibold uppercase tracking-wide text-muted`;return(0,R.jsxs)(`div`,{children:[o?(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(`div`,{className:D,children:f(`Main chat`)}),(0,R.jsx)(`ul`,{className:T,children:e.filter(e=>e.id===`main`).map(w)}),(0,R.jsxs)(`div`,{className:i(D,`flex items-center justify-between pr-1`),children:[(0,R.jsx)(`span`,{children:f(`Side chats`)}),(0,R.jsx)(`button`,{type:`button`,"aria-label":f(`New side chat`),onClick:()=>_.current?.focus(),className:`flex h-6 w-6 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,R.jsx)(Gt,{size:15})})]}),(0,R.jsx)(`ul`,{className:T,children:e.filter(e=>e.id!==`main`).map(w)}),e.every(e=>e.id===`main`)&&(0,R.jsx)(`p`,{className:`px-3 py-2 text-[12px] text-muted`,children:f(`A side chat keeps one topic apart from the rest.`)})]}):(0,R.jsx)(`ul`,{className:T,children:e.map(w)}),v.length>0&&(0,R.jsxs)(`div`,{className:`mt-4`,children:[(0,R.jsx)(`div`,{className:`mb-1.5 text-[12px] font-medium text-muted`,children:f(`Ask one of your devices`)}),(0,R.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:v.map(e=>(0,R.jsxs)(`button`,{type:`button`,onClick:()=>void b(e),className:`flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-[13px] font-medium hover:bg-surface-2`,children:[e.kind===`phone`?(0,R.jsx)(en,{size:14,className:`text-accent`}):(0,R.jsx)(It,{size:14,className:`text-accent`}),e.name]},e.id))})]}),(0,R.jsxs)(`div`,{className:i(`flex gap-2`,a?`mt-3`:`mt-4`),children:[(0,R.jsx)(`input`,{ref:_,value:p,onChange:e=>m(e.target.value),onKeyDown:e=>{e.key===`Enter`&&!h&&x()},placeholder:f(a?`New side chat`:`New side chat, e.g. “Trip to Kyoto”`),className:i(`min-w-0 flex-1 bg-surface-2 outline-none focus:ring-2 focus:ring-accent/40`,a?`rounded-xl px-3 py-2 text-[13px]`:`rounded-2xl px-3.5 py-2.5 text-[14px]`)}),(0,R.jsxs)(`button`,{type:`button`,disabled:h,onClick:()=>void x(),"aria-label":f(`New`),className:i(`flex items-center gap-1.5 bg-accent text-accent-fg font-medium disabled:opacity-50`,a?`rounded-xl px-2.5`:`rounded-2xl px-3.5 py-2.5`),children:[(0,R.jsx)(At,{size:18}),` `,!a&&f(`New`)]})]})]})}function oh(e){let t=[...e].reverse().find(e=>e.type===`user`),n=(((t?.type===`user`?t.text:``)||``).split(/\r?\n/).map(e=>e.trim()).find(e=>e.length>0)??``).replace(/\s+/g,` `);if(n.length<=36)return n;let r=n.slice(0,36),i=r.lastIndexOf(` `);return(i>16?r.slice(0,i):r).trimEnd()+`…`}function sh(e){let t=!1;for(let n=e.length-1;n>=0;n--){let r=e[n];if(r.type===`assistant`){if(!r.text||r.quiet)return!1;t=!0;continue}if(r.type===`user`)return t&&!r.via;if(r.type===`notice`)return!1}return!1}function ch(e){let t=xn(),n=t?`Desktop app (${t.platform}, shell ${t.version})`:`Web app (${navigator.userAgent})`;return`https://github.com/zeeshanhaque21/nanoMuse/issues/new?${new URLSearchParams({template:`bug_report.yml`,version:e||``,os:n}).toString()}`}function lh(){let{state:e,setTab:t,openThread:n}=E(),r=s(),[a,o]=(0,L.useState)(!1),[c,l]=(0,L.useState)(!1),[u,d]=(0,L.useState)(``),f=(0,L.useRef)(null),p=Qf(),{profile:m,status:h}=e,g=m?.name??`nanoMuse`,_=e.pendingApprovals.length,v=e.goals.filter(e=>e.proposal&&e.status!==`cancelled`).length,y=e.pendingApprovals.filter(t=>t.ts>e.feedSeenAt).length,b=(e.hub?.devices??[]).filter(e=>!e.this&&e.kind!==`web`&&e.online).length,x=_>0?_>1?r(`{n} approvals waiting for you`,{n:_}):r(`1 approval waiting for you`):h.state===`idle`?r(`Idle`):h.detail||(h.state===`waiting`?r(`Waiting for you`):r(`Working…`));(0,L.useEffect)(()=>{if(!c)return;let e=e=>{f.current?.contains(e.target)||l(!1)},t=e=>{e.key===`Escape`&&l(!1)};return document.addEventListener(`mousedown`,e),document.addEventListener(`keydown`,t),()=>{document.removeEventListener(`mousedown`,e),document.removeEventListener(`keydown`,t)}},[c]);let S=[{id:`chat`,label:`Chat`,icon:(0,R.jsx)(kt,{size:21}),badge:_},{id:`feed`,label:`Feed`,icon:(0,R.jsx)(zt,{size:21}),badge:y},{id:`ideas`,label:`Ideas`,icon:(0,R.jsx)(St,{size:21})},{id:`goals`,label:`Goals`,icon:(0,R.jsx)(nn,{size:21}),badge:v},{id:`library`,label:`Library`,icon:(0,R.jsx)(xt,{size:21})},{id:`devices`,label:`Devices`,icon:(0,R.jsx)(Ft,{size:21}),dot:b>0},...p?[{id:`coding`,label:`Coding`,icon:(0,R.jsx)(an,{size:21})}]:[]],C=u.trim().toLowerCase(),w=C?e.threads.filter(e=>e.id===`main`||(e.id===`main`?r(e.title):e.title).toLowerCase().includes(C)||(e.device_name??``).toLowerCase().includes(C)):e.threads,T=e=>i(`relative flex w-[60px] flex-col items-center gap-[3px] rounded-xl py-1.5 text-[10.5px] font-medium leading-none transition`,e?`bg-surface-2 text-fg`:`text-fg/65 hover:bg-surface-2/60 hover:text-fg`);return(0,R.jsxs)(`div`,{className:`flex h-full shrink-0`,children:[(0,R.jsxs)(`aside`,{className:`relative flex h-full w-[76px] shrink-0 flex-col items-center border-r border-border bg-surface/70`,children:[(0,R.jsx)(`div`,{className:`titlebar-room`}),(0,R.jsxs)(`button`,{type:`button`,onClick:()=>o(!0),className:`relative mt-3 rounded-full transition hover:scale-[1.04]`,"aria-label":r(`Activity`),title:`${g} · ${x}`,children:[(0,R.jsx)(D,{profile:m,status:h,size:44}),_>0&&(0,R.jsx)(`span`,{className:`pointer-events-none absolute -right-1 -top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border-2 border-surface bg-rose-500 px-1 text-[10.5px] font-bold text-white`,children:_})]}),(0,R.jsx)(`span`,{className:`mt-1.5 max-w-[68px] truncate px-1 text-[11px] font-semibold leading-tight`,children:g}),(0,R.jsx)(`nav`,{className:`mt-3 flex w-full flex-1 flex-col items-center gap-0.5 overflow-y-auto overflow-x-hidden px-2`,children:S.map(n=>{let a=e.tab===n.id;return(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(n.id),"aria-current":a?`page`:void 0,className:T(a),children:[(0,R.jsx)(`span`,{className:i(a?`text-accent`:`text-fg/60`),children:n.icon}),(0,R.jsx)(`span`,{className:`max-w-full truncate`,children:r(n.label)}),n.badge?(0,R.jsx)(`span`,{className:`absolute right-1.5 top-1 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white`,children:n.badge}):n.dot?(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(`span`,{"aria-hidden":`true`,className:`absolute right-3 top-2 h-2 w-2 rounded-full border-2 border-surface bg-emerald-500`}),(0,R.jsx)(`span`,{className:`sr-only`,children:r(`{n} online`,{n:b})})]}):null]},n.id)})}),(0,R.jsxs)(`div`,{className:`flex w-full flex-col items-center gap-0.5 px-2 pb-3 pt-2`,children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(`account`),"aria-current":e.tab===`account`?`page`:void 0,className:T(e.tab===`account`),title:e.hub?.account.signed_in?e.hub.account.hint:r(`Account`),children:[(0,R.jsx)(pn,{size:21,className:e.tab===`account`?`text-accent`:`text-fg/60`}),(0,R.jsx)(`span`,{className:`max-w-full truncate`,children:r(`Account`)})]}),(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(`you`),"aria-current":e.tab===`you`?`page`:void 0,className:T(e.tab===`you`),children:[(0,R.jsx)($t,{size:21,className:e.tab===`you`?`text-accent`:`text-fg/60`}),(0,R.jsx)(`span`,{className:`max-w-full truncate`,children:r(`Settings`)})]}),(0,R.jsxs)(`div`,{ref:f,className:`relative w-full`,children:[(0,R.jsxs)(`button`,{type:`button`,onClick:()=>l(e=>!e),"aria-expanded":c,"aria-haspopup":`menu`,className:i(T(c),`mx-auto`),children:[(0,R.jsx)(Dt,{size:21,className:`text-fg/60`}),(0,R.jsx)(`span`,{children:r(`More`)})]}),c&&(0,R.jsxs)(`div`,{role:`menu`,className:`absolute bottom-0 left-[68px] z-30 w-[232px] rounded-2xl border border-border bg-surface p-1.5 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.3)]`,children:[(0,R.jsxs)(`a`,{role:`menuitem`,href:ch(e.version),target:`_blank`,rel:`noreferrer`,onClick:()=>l(!1),className:`flex items-center gap-2.5 rounded-xl px-2.5 py-2 text-[13px] font-medium hover:bg-surface-2`,children:[(0,R.jsx)(se,{size:16,className:`text-fg/60`}),` `,r(`Report a bug`)]}),(0,R.jsxs)(`button`,{type:`button`,role:`menuitem`,onClick:()=>{l(!1),t(`you`),window.location.hash=`developer`},className:`flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-[13px] font-medium hover:bg-surface-2`,children:[(0,R.jsx)(vn,{size:16,className:`text-fg/60`}),(0,R.jsx)(`span`,{className:`flex-1`,children:r(`Developer tools`)}),(0,R.jsx)(`span`,{className:`text-[11.5px] font-normal text-muted`,children:r(p?`On`:`Off`)})]}),e.version&&(0,R.jsxs)(`div`,{className:`px-2.5 pb-1 pt-1.5 text-[11.5px] text-muted`,children:[`nanoMuse `,e.version]})]})]})]}),(0,R.jsx)(im,{open:a,onClose:()=>o(!1)})]}),e.tab===`chat`&&(0,R.jsxs)(`section`,{className:`flex h-full w-[248px] shrink-0 flex-col border-r border-border bg-surface/40`,"aria-label":r(`Chats`),children:[(0,R.jsx)(`div`,{className:`titlebar-room`}),(0,R.jsxs)(`div`,{className:`px-3 pb-2 pt-4`,children:[(0,R.jsx)(`h2`,{className:`px-1 text-[15px] font-semibold tracking-tight`,children:r(`Chats`)}),(0,R.jsxs)(`label`,{className:`mt-2 flex items-center gap-2 rounded-xl bg-surface-2 px-2.5 py-1.5 text-muted focus-within:ring-2 focus-within:ring-accent/40`,children:[(0,R.jsx)(Kt,{size:14,className:`shrink-0`}),(0,R.jsx)(`input`,{value:u,onChange:e=>d(e.target.value),placeholder:r(`Search`),"aria-label":r(`Search chats`),className:`min-w-0 flex-1 bg-transparent text-[13px] text-fg outline-none placeholder:text-muted`}),u&&(0,R.jsx)(`button`,{type:`button`,onClick:()=>d(``),"aria-label":r(`Clear`),className:`shrink-0 hover:text-fg`,children:(0,R.jsx)(Pe,{size:14})})]})]}),(0,R.jsx)(`div`,{className:`min-h-0 flex-1 overflow-y-auto px-2 pb-3`,children:(0,R.jsx)(ah,{threads:w,active:e.activeThread,onPick:e=>n(e),compact:!0,grouped:!0})})]})]})}function uh(){let{state:e,send:t,openThread:n,openFile:r,markFeedSeen:i,setTab:a,toast:o}=E(),[c,l]=(0,L.useState)(null),[u,p]=(0,L.useState)(null),[h,g]=(0,L.useState)(null),[_,v]=(0,L.useState)(null),b=e.profile?.name??`nanoMuse`,x=s(),S=y();(0,L.useEffect)(()=>{let e=!0;return M.feed(80).then(t=>e&&l(t)).catch(e=>o(e.message)),M.upcoming().then(t=>e&&g(t)).catch(()=>void 0),M.feedPosts().then(t=>e&&p(t)).catch(()=>void 0),()=>{e=!1}},[e.feedVersion,e.remindersVersion]),(0,L.useEffect)(()=>{let e=!0;return M.calendar().then(t=>e&&v(t)).catch(()=>void 0),()=>{e=!1}},[e.calendarVersion,e.connectionsVersion]),(0,L.useEffect)(()=>{c&&c.length&&c[0].ts>e.feedSeenAt&&i(c[0].ts)},[c,e.feedSeenAt,i]);let C=(0,L.useMemo)(()=>Oh(c??[]),[c,S]),[w,T]=(0,L.useState)(!1),[D,O]=(0,L.useState)(null),[k,ee]=(0,L.useState)(()=>d(dh)===`1`),[j,ne]=(0,L.useState)(!1),re=()=>{f(dh,`1`),ee(!0)},N=async()=>{ne(!0);try{let e=await M.refreshFeedPosts();p(e),e.error&&o(x(`Could not write posts: {error}`,{error:e.error}))}catch(e){o(e.message)}finally{ne(!1)}},P=e=>{t(`main`,e.prompt||`${x(`About this post from my feed:`)}\n\n**${e.title}**\n\n${e.body.slice(0,1200)}`).catch(e=>o(e.message||x(`Could not send`))),n(`main`)},ie=(0,L.useMemo)(()=>ph(u?.posts??[]),[u]),ae=u!==null&&u.posts.length===0;return(0,R.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,R.jsx)(Tm,{title:``,trailing:(0,R.jsx)(te,{onClick:()=>T(!0),label:x(`Feed settings`),children:(0,R.jsx)($t,{size:22})})}),(0,R.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-3.5 pb-7`,children:[ie.length===0&&(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(hh,{text:mh(fh(),new Date)}),!k&&(0,R.jsx)(bh,{instructions:u?.instructions??``,onEdit:()=>T(!0),onAck:re}),ae&&(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(xh,{emoji:`🖼️`,title:x(`Nothing in the feed yet`),body:x(`As we get to know each other, new posts will show up here. Every day at {time} I read what I remember about you (your memory files, the last week of diary, your goals) and write a few short posts.`,{time:u?.time??`08:00`})}),(0,R.jsx)(xh,{emoji:`📝`,title:x(`Steer it with one sentence`),body:x(`Tap the sliders at the top right to tell me what you want more of, switch the daily routine off, or have me write the first day now.`)}),e.settings&&!e.settings.llm_ready&&(0,R.jsx)(gh,{children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>a(`you`),className:`flex w-full items-center gap-2 p-4 text-left text-[14px] text-muted`,children:[(0,R.jsx)(bt,{size:16,className:`shrink-0 text-accent`}),` `,x(`Add a model first; the feed is written by your agent.`)]})}),(0,R.jsx)(gh,{children:(0,R.jsx)(`div`,{className:`p-4`,children:j?(0,R.jsxs)(`div`,{className:`flex items-center justify-center gap-2.5 text-[15px]`,children:[(0,R.jsx)(A,{size:18,className:`animate-spin text-accent`}),` `,x(`Writing…`)]}):(0,R.jsx)(`button`,{type:`button`,onClick:()=>void N(),className:`h-[46px] w-full rounded-full bg-accent text-[15px] font-semibold text-accent-fg`,children:x(`Write it now`)})})})]})]}),ie.map(([e,t],n)=>(0,R.jsxs)(`section`,{children:[(0,R.jsx)(hh,{text:mh(e,new Date(t[0].ts))}),n===0&&!k&&(0,R.jsx)(bh,{instructions:u?.instructions??``,onEdit:()=>T(!0),onAck:re}),n===0&&j&&(0,R.jsx)(gh,{children:(0,R.jsxs)(`div`,{className:`flex items-center gap-2.5 p-4 text-[15px]`,children:[(0,R.jsx)(A,{size:18,className:`animate-spin text-accent`}),` `,x(`Writing…`)]})}),t.map(e=>(0,R.jsx)(yh,{post:e,onDiscuss:()=>P(e),onInfo:()=>O(e)},e.id))]},e)),(0,R.jsxs)(`div`,{className:`space-y-4 px-0.5 pt-3`,children:[h&&(0,R.jsx)(wh,{data:h,name:b,onSettings:()=>a(`you`),onGoals:()=>a(`goals`)}),_?.configured&&(0,R.jsx)(Th,{data:_,onAsk:()=>n(`main`)}),c&&c.length===0&&(!u||u.posts.length===0)&&(0,R.jsxs)(`div`,{className:`py-10 text-center text-muted text-[14px] px-6`,children:[(0,R.jsx)(Lt,{size:28,className:`mx-auto mb-2 opacity-60`}),x(`Nothing yet. Once {name} works on a goal in the background or needs your approval, it shows up here.`,{name:b})]}),c&&c.length>0&&(0,R.jsx)(`div`,{className:`px-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:x(`While you were away`)}),C.map(([t,i])=>(0,R.jsxs)(`section`,{children:[(0,R.jsx)(`div`,{className:`px-1 mb-1.5 text-[12px] uppercase tracking-wide text-muted font-semibold`,children:t}),(0,R.jsx)(`ul`,{className:`space-y-2`,children:i.map(t=>(0,R.jsx)(`li`,{children:(0,R.jsx)(Dh,{item:t,unseen:t.ts>e.feedSeenAt,onOpen:()=>{t.kind===`artifact`&&t.path?r(t.path):n(t.thread)}})},t.id))})]},t))]})]}),(0,R.jsx)(Ch,{open:w,onClose:()=>T(!1),data:u,writing:j,onChange:p,onWriteNow:()=>void N()}),(0,R.jsx)(Dp,{open:D!==null,onClose:()=>O(null),title:D?.title,children:D&&(0,R.jsxs)(`div`,{className:`space-y-3 px-1 pb-2`,children:[(0,R.jsx)(Sh,{label:x(`Post type`),value:vh(D.area)}),(0,R.jsx)(Sh,{label:x(`Written`),value:new Date(D.ts).toLocaleString(m(),{dateStyle:`medium`,timeStyle:`short`})}),(0,R.jsx)(`button`,{type:`button`,onClick:async()=>{let e=D.id;O(null);try{await M.deleteFeedPost(e),p(t=>t&&{...t,posts:t.posts.filter(t=>t.id!==e)})}catch(e){o(e.message)}},className:`w-full rounded-full bg-surface-2 py-2.5 text-[15px] font-medium text-rose-500`,children:x(`Delete this post`)})]})})]})}var dh=`nanomuse.feed.introAck`;function fh(){let e=new Date;return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}function ph(e){let t=new Map;for(let n of e){let e=new Date(n.ts),r=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`,i=t.get(r);i?i.push(n):t.set(r,[n])}return[...t.entries()]}function mh(e,t){let n=fh(),r=new Date;r.setDate(r.getDate()-1);let i=`${r.getFullYear()}-${String(r.getMonth()+1).padStart(2,`0`)}-${String(r.getDate()).padStart(2,`0`)}`,a=t.toLocaleDateString(m(),{weekday:`long`});if(e===n){let e=new Date().getHours(),t=_(e<12?`morning`:e<18?`afternoon`:`evening`);return _(`{weekday} {part}`,{weekday:a,part:t})}return e===i?_(`Yesterday`):`${a} · ${t.toLocaleDateString(m(),{month:`short`,day:`numeric`})}`}function hh({text:e}){return(0,R.jsx)(`h2`,{className:`px-1.5 pt-3.5 pb-2 text-[22px] font-bold leading-7`,children:e})}function gh({children:e,className:t}){return(0,R.jsx)(`div`,{className:i(`my-1.5 overflow-hidden rounded-[18px] bg-surface dark:border dark:border-border`,t),children:e})}var _h={planning:`🗓️`,research:`🔍`,goals:`🎯`,money:`💰`,health:`🌿`,home:`🏠`,learning:`📚`,people:`👥`,files:`📄`,fun:`🎈`};function vh(e){switch(e){case`planning`:return _(`Planning`);case`research`:return _(`Research`);case`goals`:return _(`Goals`);case`money`:return _(`Money`);case`health`:return _(`Health`);case`home`:return _(`Home`);case`learning`:return _(`Learning`);case`people`:return _(`People`);case`files`:return _(`Files`);case`fun`:return _(`Fun`);default:return _(`Note`)}}function yh({post:e,onDiscuss:t,onInfo:n}){let r=s();return(0,R.jsx)(gh,{children:(0,R.jsxs)(`article`,{className:`pl-3.5 pr-2.5 pt-3.5 pb-1.5`,children:[(0,R.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,R.jsx)(`div`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-[22px] leading-none`,children:_h[e.area]??`📝`}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`h3`,{className:`text-[16px] font-semibold leading-[22px]`,children:e.title}),(0,R.jsx)(`div`,{className:`md mt-1 text-[15px] leading-[22px] text-fg/85`,children:(0,R.jsx)(mf,{text:e.body})})]})]}),(0,R.jsxs)(`div`,{className:`mt-1.5 flex items-center`,children:[(0,R.jsxs)(`button`,{type:`button`,onClick:t,className:`flex items-center gap-1.5 rounded-full px-2 py-2 text-[14px] hover:bg-surface-2`,children:[(0,R.jsx)(kt,{size:22,strokeWidth:1.8}),` `,r(`Discuss`)]}),(0,R.jsx)(`button`,{type:`button`,onClick:n,"aria-label":r(`About this post`),className:`ml-auto rounded-full p-2 hover:bg-surface-2`,children:(0,R.jsx)(bt,{size:22,strokeWidth:1.8})})]})]})})}function bh({instructions:e,onEdit:t,onAck:n}){let r=s();return(0,R.jsxs)(gh,{children:[(0,R.jsxs)(`div`,{className:`px-4 py-3.5`,children:[(0,R.jsx)(`div`,{className:`text-[17px] font-semibold`,children:r(`About the feed`)}),(0,R.jsx)(`p`,{className:`mt-1 text-[13px] leading-[18px] text-muted`,children:r(`Short posts your agent writes for you from what it remembers: your memory files, the last week of diary, your goals. The sentence below steers every post from now on; edit it any time.`)})]}),(0,R.jsxs)(`div`,{className:`border-t border-border/70 px-4 py-3.5`,children:[(0,R.jsx)(`p`,{className:`text-[15px] leading-[22px]`,children:e||r(`Build me a feed about what I care about. Keep it short and direct, easy to skim, no clickbait.`)}),(0,R.jsxs)(`div`,{className:`mt-3.5 flex justify-end gap-2.5`,children:[(0,R.jsx)(`button`,{type:`button`,onClick:t,className:`rounded-full bg-surface-2 px-6 py-2.5 text-[15px] font-medium`,children:r(`Edit`)}),(0,R.jsx)(`button`,{type:`button`,onClick:n,className:`rounded-full bg-accent px-6 py-2.5 text-[15px] font-semibold text-accent-fg`,children:r(`Got it`)})]})]})]})}function xh({emoji:e,title:t,body:n}){return(0,R.jsx)(gh,{children:(0,R.jsxs)(`div`,{className:`flex items-start gap-3 p-3.5`,children:[(0,R.jsx)(`div`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-[22px] leading-none`,children:e}),(0,R.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`div`,{className:`text-[16px] font-semibold leading-[22px]`,children:t}),(0,R.jsx)(`p`,{className:`mt-1 text-[15px] leading-[22px] text-fg/85`,children:n})]})]})})}function Sh({label:e,value:t}){return(0,R.jsxs)(`div`,{className:`flex items-start gap-3 text-[14.5px]`,children:[(0,R.jsx)(`span`,{className:`w-14 shrink-0 text-muted`,children:e}),(0,R.jsx)(`span`,{className:`min-w-0 flex-1 break-words`,children:t})]})}function Ch({open:e,onClose:t,data:n,writing:r,onChange:i,onWriteNow:a}){let{toast:o,state:c}=E(),l=s(),[u,d]=(0,L.useState)(``),[f,p]=(0,L.useState)(!1);(0,L.useEffect)(()=>{e&&d(n?.instructions??``)},[e,n]);let m=async()=>{p(!0);try{i(await M.setFeedInstructions({instructions:u})),t()}catch(e){o(e.message)}finally{p(!1)}},h=async e=>{try{i(await M.setFeedInstructions(e))}catch(e){o(e.message)}},g=c.settings?.llm_ready!==!1;return(0,R.jsx)(Dp,{open:e,onClose:t,title:l(`Feed`),children:(0,R.jsxs)(`div`,{className:`space-y-3 px-1 pb-2`,children:[(0,R.jsx)(`p`,{className:`text-[13px] leading-[18px] text-muted`,children:l(`Your feed is driven by the instruction below. Any edit you make here applies to every post from now on.`)}),(0,R.jsx)(`textarea`,{value:u,onChange:e=>d(e.target.value),rows:4,maxLength:2e3,placeholder:l(`Build me a feed about what I care about. Keep it short and direct, easy to skim, no clickbait.`),className:`w-full resize-none rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[15px] leading-snug outline-none focus:ring-2 focus:ring-accent/40`}),(0,R.jsxs)(`div`,{className:`space-y-2 rounded-2xl bg-surface-2 px-3.5 py-2.5`,children:[(0,R.jsx)(Ne,{label:l(`Write it every day`),hint:n?.daily===!1?void 0:l(`Daily at {time}`,{time:n?.time??`08:00`}),checked:n?.daily!==!1,onChange:e=>void h({daily:e}),disabled:!n}),n?.daily!==!1&&(0,R.jsxs)(`label`,{className:`flex items-center justify-between gap-3 text-[13px] text-muted`,children:[(0,R.jsx)(`span`,{children:l(`Time of day`)}),(0,R.jsx)(`input`,{type:`time`,value:n?.time??`08:00`,onChange:e=>e.target.value&&void h({time:e.target.value}),className:`rounded-lg bg-surface px-2 py-1 text-[13px] text-fg outline-none focus:ring-2 focus:ring-accent/40`})]}),!g&&(0,R.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:l(`Add a model first; the feed is written by your agent.`)})]}),(0,R.jsxs)(`button`,{type:`button`,onClick:a,disabled:r||!n||!g,className:`flex w-full items-center justify-center gap-2 rounded-full bg-surface-2 py-2.5 text-[15px] font-medium disabled:opacity-60`,children:[r?(0,R.jsx)(A,{size:16,className:`animate-spin`}):(0,R.jsx)(tn,{size:16,className:`text-accent`}),l(r?`Writing…`:`Write it now`)]}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>void m(),disabled:f,className:`h-[46px] w-full rounded-full bg-accent text-[15px] font-semibold text-accent-fg disabled:opacity-60`,children:l(`Save`)})]})})}function wh({data:t,name:n,onSettings:r,onGoals:i}){let a=s(),o=t.queue[0],c=t.reminders.filter(e=>e.status===`active`&&!!e.next_at),l=c[0],d=c.length;return(0,R.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface shadow-sm px-4 py-3.5`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] uppercase tracking-wide text-muted font-semibold`,children:[(0,R.jsx)(Ye,{size:13}),` `,a(`Next up`)]}),t.proactive?o?(0,R.jsxs)(`div`,{className:`mt-1.5 text-[14px] leading-snug`,children:[t.busy?a(`Working now`):t.quiet_until?a(`Quiet hours after {time}`,{time:u(t.quiet_until)}):t.next_pass_at?a(`Around {time}`,{time:u(t.next_pass_at)}):a(`Soon`),`: `,(0,R.jsx)(`span`,{className:`font-medium`,children:o.title}),o.next_step&&(0,R.jsxs)(`span`,{className:`text-muted`,children:[` · `,o.next_step]}),o.overdue&&(0,R.jsxs)(`span`,{className:`text-rose-500 font-medium`,children:[` · `,a(`overdue`)]}),t.queue.length>1&&(0,R.jsxs)(`span`,{className:`text-muted`,children:[` · `,a(`{n} more in line`,{n:t.queue.length-1})]})]}):(0,R.jsxs)(`div`,{className:`mt-1.5 text-[14px] leading-snug`,children:[a(`No goal has a next step to work on.`),` `,(0,R.jsx)(`button`,{type:`button`,onClick:i,className:`text-accent font-medium`,children:a(`Add one`)})]}):(0,R.jsxs)(`div`,{className:`mt-1.5 text-[14px] leading-snug`,children:[a(`Background work is off. {name} only acts when you ask.`,{name:n}),` `,(0,R.jsx)(`button`,{type:`button`,onClick:r,className:`text-accent font-medium`,children:a(`Turn it on`)})]}),t.check_ins[0]&&(0,R.jsxs)(`div`,{className:`mt-1.5 text-[12.5px] text-muted`,children:[a(`Check-in on`),` `,(0,R.jsx)(`span`,{className:`font-medium text-fg`,children:t.check_ins[0].title}),` `,e(t.check_ins[0].at),t.check_ins.length>1&&` · ${a(`{n} more`,{n:t.check_ins.length-1})}`]}),l&&(0,R.jsxs)(`div`,{className:`mt-1.5 text-[12.5px] text-muted`,children:[l.kind===`task`?a(`Routine`):a(`Reminder`),` `,(0,R.jsx)(`span`,{className:`font-medium text-fg`,children:l.text}),` `,e(l.next_at),d>1&&` · ${a(`{n} more`,{n:d-1})}`]})]})}function Th({data:e,onAsk:t}){let n=s(),r=t=>t.start.slice(0,10)<=e.today,a=e.events.filter(r),o=e.events.filter(e=>!r(e)),c=new Date,l=e=>!e.all_day&&new Date(e.end)e.error),d=a.every(l)&&o.length>0,f=d?o:a;return(0,R.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface shadow-sm px-4 py-3.5`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] uppercase tracking-wide text-muted font-semibold`,children:[(0,R.jsx)(et,{size:13}),` `,n(d?`Tomorrow`:`Today`),f.length>0&&(0,R.jsx)(`span`,{className:`ml-auto normal-case tracking-normal font-normal`,children:n(`{n} scheduled`,{n:f.length})})]}),f.length===0?(0,R.jsx)(`div`,{className:`mt-1.5 text-[14px] leading-snug text-muted`,children:n(`Nothing on the calendar today.`)}):(0,R.jsx)(`ul`,{className:`mt-1.5 space-y-1`,children:f.map(e=>(0,R.jsxs)(`li`,{className:i(`flex items-baseline gap-2.5 text-[14px] leading-snug`,!d&&l(e)&&`opacity-50`),children:[(0,R.jsx)(`span`,{className:`w-[86px] shrink-0 tabular-nums text-[12.5px] text-muted`,children:e.all_day?n(`all day`):`${e.start.slice(11,16)}–${e.end.slice(11,16)}`}),(0,R.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,R.jsx)(`span`,{className:`font-medium`,children:e.summary}),e.location&&(0,R.jsxs)(`span`,{className:`text-muted text-[12.5px]`,children:[` `,(0,R.jsx)(Et,{size:11,className:`inline -mt-0.5`}),` `,e.location]})]})]},`${e.uid}-${e.start}`))}),u.length>0&&(0,R.jsx)(`div`,{className:`mt-1.5 text-[12px] text-rose-500`,children:n(`{name} could not be read`,{name:u.map(e=>e.name).join(`, `)})}),(0,R.jsx)(`button`,{type:`button`,onClick:t,className:`mt-2 text-[12.5px] text-accent font-medium`,children:n(`Ask about your week`)})]})}function Eh(e){return e.replace(/```[\s\S]*?```/g,` `).replace(/^#{1,6}\s+/gm,``).replace(/(\*\*|__|`)/g,``).replace(/^\s*[-*+]\s+/gm,`• `).replace(/\[([^\]]+)\]\([^)]*\)/g,`$1`).trim()}function Dh({item:n,unseen:r,onOpen:a}){let o=s(),c=n.kind===`approval`?(0,R.jsx)(Xt,{size:18}):n.kind===`question`?(0,R.jsx)(Ot,{size:18}):n.kind===`artifact`?(0,R.jsx)(pt,{size:18}):n.quiet?(0,R.jsx)(Lt,{size:18}):n.title.startsWith(`New mail: `)?(0,R.jsx)(Tt,{size:18}):n.title.startsWith(`Coming up: `)?(0,R.jsx)($e,{size:18}):n.title.startsWith(`Webhook: `)?(0,R.jsx)(gn,{size:18}):(0,R.jsx)(tn,{size:18}),l=n.kind===`approval`?`bg-amber-500/12 text-amber-600 dark:text-amber-300`:n.kind===`question`?`bg-accent/12 text-accent`:`bg-surface-2 text-muted`;return n.quiet?(0,R.jsxs)(`button`,{type:`button`,onClick:a,className:`w-full text-left px-2 py-1.5 flex items-center gap-2 text-[12.5px] text-muted`,children:[(0,R.jsx)(Lt,{size:13,className:`shrink-0`}),(0,R.jsxs)(`span`,{className:`truncate`,children:[o(`{label}: nothing new`,{label:t(n.title.replace(/^Working on your goal: /,``))}),n.text?`: ${n.text}`:``]}),(0,R.jsx)(`span`,{className:`ml-auto shrink-0`,children:e(n.ts)})]}):(0,R.jsx)(`button`,{type:`button`,onClick:a,className:i(`w-full text-left rounded-3xl border bg-surface shadow-sm px-4 py-3 active:scale-[0.99] transition`,r?`border-accent/40`:`border-border/70`),children:(0,R.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,R.jsx)(`div`,{className:i(`rounded-2xl p-2 mt-0.5`,l),children:c}),(0,R.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,R.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,R.jsx)(`div`,{className:`font-semibold text-[14.5px] leading-snug truncate`,children:t(n.title)}),r&&(0,R.jsx)(`span`,{className:`h-2 w-2 rounded-full bg-accent shrink-0`})]}),n.text&&(0,R.jsx)(`div`,{className:`mt-0.5 text-[13.5px] text-muted leading-snug line-clamp-3 whitespace-pre-wrap`,children:Eh(n.text)}),(0,R.jsxs)(`div`,{className:`mt-1.5 flex items-center gap-2 text-[12px] text-muted`,children:[(0,R.jsx)(`span`,{children:e(n.ts)}),(0,R.jsx)(`span`,{children:`·`}),(0,R.jsx)(`span`,{className:`truncate`,children:n.thread===`main`?o(n.thread_title):n.thread_title}),(0,R.jsxs)(`span`,{className:`ml-auto flex items-center gap-1 text-accent font-medium`,children:[n.kind===`artifact`?o(`Open`):n.kind===`approval`||n.kind===`question`?o(`Answer`):o(`Open chat`),(0,R.jsx)(Ke,{size:13})]})]})]})]})})}function Oh(e){let t=new Date,n=new Date(t);n.setDate(t.getDate()-1);let r=e=>{let r=new Date(e);return r.toDateString()===t.toDateString()?_(`Today`):r.toDateString()===n.toDateString()?_(`Yesterday`):r.toLocaleDateString(m(),{weekday:`long`,month:`short`,day:`numeric`})},i=new Map;for(let t of e){let e=r(t.ts);i.set(e,[...i.get(e)??[],t])}return[...i.entries()]}function kh({children:e,className:t}){return(0,R.jsx)(`div`,{className:i(`mx-4 overflow-hidden rounded-2xl bg-surface dark:border dark:border-border`,t),children:e})}function Ah({inset:e=52}){return(0,R.jsx)(`div`,{className:i(`border-t border-border/60`,e===52?`ml-[52px]`:`ml-4`)})}function jh({icon:e,label:t,value:n,onClick:r,external:a=!1,trailing:o,chevron:s=!0}){let c=(0,R.jsxs)(R.Fragment,{children:[e&&(0,R.jsx)(`span`,{className:`shrink-0 text-fg`,children:e}),(0,R.jsx)(`span`,{className:i(`min-w-0 truncate text-[16px] leading-[21px]`,n?`max-w-[60%] shrink-0`:`flex-1`),children:t}),n&&(0,R.jsx)(`span`,{className:`min-w-0 flex-1 truncate pl-3 text-right text-[14px] leading-[18px] text-muted`,children:n}),o??(s?a?(0,R.jsx)(ke,{size:16,className:`shrink-0 text-muted/70`}):(0,R.jsx)(nt,{size:18,className:`shrink-0 text-muted/70`}):null)]}),l=`flex min-h-[54px] w-full items-center gap-3.5 px-4 py-2 text-left`;return r?(0,R.jsx)(`button`,{type:`button`,onClick:r,className:i(l,`hover:bg-surface-2/60`),children:c}):(0,R.jsx)(`div`,{className:l,children:c})}function Mh({icon:e,label:t,value:n,checked:r,disabled:a=!1,onChange:o}){return(0,R.jsxs)(`button`,{type:`button`,role:`switch`,"aria-checked":r,disabled:a,onClick:()=>o(!r),className:i(`flex min-h-[54px] w-full items-center gap-3.5 px-4 py-2 text-left`,a?`opacity-60`:`hover:bg-surface-2/60`),children:[e&&(0,R.jsx)(`span`,{className:`shrink-0 text-fg`,children:e}),(0,R.jsx)(`span`,{className:i(`min-w-0 truncate text-[16px] leading-[21px]`,n?`max-w-[60%] shrink-0`:`flex-1`),children:t}),n&&(0,R.jsx)(`span`,{className:`min-w-0 flex-1 truncate pl-3 text-right text-[14px] leading-[18px] text-muted`,children:n}),(0,R.jsx)(Nh,{checked:r})]})}function Nh({checked:e}){return(0,R.jsx)(`span`,{"aria-hidden":!0,className:i(`relative h-7 w-12 shrink-0 rounded-full transition`,e?`bg-accent`:`bg-surface-2 border border-border`),children:(0,R.jsx)(`span`,{className:i(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,e?`left-[22px]`:`left-0.5`)})})}function Ph({children:e,className:t}){return(0,R.jsx)(`p`,{className:i(`px-8 py-2 text-[13px] leading-[18px] text-muted`,t),children:e})}function Fh({children:e}){return(0,R.jsx)(`p`,{className:`px-8 pb-2 pt-[18px] text-[13px] leading-[18px] text-muted`,children:e})}function Ih({me:e,onChanged:t,flush:n=!1}){let r=s(),{state:a,toast:o}=E(),c=!!a.hub?.account.signed_in,[l,u]=(0,L.useState)(null),[d,f]=(0,L.useState)(!1),p=e??l,m=async()=>{if(c&&!e)try{u(await M.cloudMe())}catch{}};(0,L.useEffect)(()=>{m()},[c,e]);let h=p?.contribute??{on:!1,samples:0},g=h.samples??0,_=h.privacy_url||``,v=async n=>{f(!0);try{await M.cloudContribute(n),o(r(n?`On. Turn it off here at any time.`:`Off. Nothing more is kept.`)),e?t?.():await m()}catch(e){o(e.message)}finally{f(!1)}},y=async()=>{if(window.confirm(r(`The kept conversations are removed from the server. This cannot be undone.`))){f(!0);try{let n=await M.cloudDeleteSamples();o(r(`{n} turns deleted`,{n:String(n.deleted)})),e?t?.():await m()}catch(e){o(e.message)}finally{f(!1)}}};return(0,R.jsxs)(`div`,{className:i(n&&`-mx-4`),children:[(0,R.jsx)(Lh,{signedIn:c}),(0,R.jsx)(kh,{children:(0,R.jsx)(Mh,{label:r(`Help improve nanoMuse's AI models`),checked:c&&h.on,disabled:d||!c||!p,onChange:e=>void v(e)})}),(0,R.jsxs)(Ph,{children:[r(`While this is on, the text of your chats with the nanoMuse Cloud models (what you wrote, what it answered and the tools it chose to call) is kept on the relay to train the community's own open model. Not your memory or SOUL (the system prompt), not what tools returned, not pictures, and never next to who you are. Your own API key never passes through the relay.`),` `,c?h.default_on!==void 0&&(h.default_on?r(`New accounts start with it on.`):r(`New accounts start with it off.`)):r(`Sign in to nanoMuse Cloud to use it.`),` `,c&&g>0&&r(`{n} turns kept so far.`,{n:String(g)}),` `,_?(0,R.jsx)(`a`,{href:_,target:`_blank`,rel:`noopener noreferrer`,className:`text-accent underline-offset-2 hover:underline`,children:r(`Privacy policy`)}):null]}),c&&g>0&&(0,R.jsxs)(kh,{className:`mt-3`,children:[(0,R.jsx)(jh,{icon:d?(0,R.jsx)(A,{size:22,className:`animate-spin`}):(0,R.jsx)(dn,{size:22}),label:r(`Delete the kept conversations`),value:r(`{n} turns`,{n:String(g)}),onClick:()=>void y(),chevron:!1}),(0,R.jsx)(Ah,{inset:16}),(0,R.jsx)(Ph,{className:`px-4 pb-2.5 pt-2`,children:r(`Removes every turn kept from this account, whether the switch is on or off now. Turning the switch off keeps what was kept until you delete it here.`)})]})]})}function Lh({signedIn:e}){let t=s(),{toast:n}=E(),[r,i]=(0,L.useState)(null),[a,o]=(0,L.useState)(!1),c=r?.relay?.counts??{conversations:0,messages:0},l=e&&(c.conversations>0||c.messages>0),u=async()=>{try{i(await M.syncState())}catch{}};(0,L.useEffect)(()=>{u()},[e]);let d=async(e,t)=>{o(!0);try{i(await e()),n(t)}catch(e){n(e.message),await u()}finally{o(!1)}},f=e=>{(e||!l||window.confirm(t(`Off deletes the synced conversations from nanoMuse Cloud. The chats on each device stay.`)))&&d(()=>M.syncSetState(e),t(e?`On. Your devices show the same conversations from now on.`:`Off. Nothing is kept on nanoMuse Cloud any more.`))},p=()=>{window.confirm(t(`The synced conversations are removed from nanoMuse Cloud. The chats on each device stay. This cannot be undone.`))&&d(()=>M.syncDelete(),t(`Synced conversations deleted.`))},m=e=>{d(()=>M.syncSetSideChats(e),t(e?`On. This device's side chats go to the account; the other devices' are on their way.`:`Off. Side chats stay on this device from now on.`))},h=e&&!!r?.enabled;return(0,R.jsxs)(`div`,{className:`mb-6`,children:[(0,R.jsx)(kh,{children:(0,R.jsx)(Mh,{label:t(`Sync conversations between my devices`),checked:h,disabled:a||!e||!r,onChange:f})}),(0,R.jsxs)(Ph,{children:[t(`The text of your chats is kept on nanoMuse Cloud so every device shows the same conversations. Files and images stay on the device they were made on.`),` `,e?r?.paused?t(`Paused: sign in again to continue.`):l&&t(`{c} chats, {m} messages kept so far.`,{c:String(c.conversations),m:String(c.messages)}):t(`Sign in to nanoMuse Cloud to use it.`)]}),r&&`side_chats`in r&&(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(kh,{className:`mt-3`,children:(0,R.jsx)(Mh,{label:t(`Also sync side chats`),checked:h&&!!r.side_chats,disabled:a||!h,onChange:m})}),(0,R.jsx)(Ph,{children:t(`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.`)})]}),l&&(0,R.jsxs)(kh,{className:`mt-3`,children:[(0,R.jsx)(jh,{icon:a?(0,R.jsx)(A,{size:22,className:`animate-spin`}):(0,R.jsx)(dn,{size:22}),label:t(`Delete synced conversations`),value:t(`{n} chats`,{n:String(c.conversations)}),onClick:p,chevron:!1}),(0,R.jsx)(Ah,{inset:16}),(0,R.jsx)(Ph,{className:`px-4 pb-2.5 pt-2`,children:t(`Removes what nanoMuse Cloud stores for this account; the chats on each device stay, and the switch stays on. Deleting a chat on one device deletes it on all of them.`)})]})]})}var Rh=`nm.invite`;function zh(){try{let e=new URLSearchParams(window.location.search).get(`invite`);if(e){let t=Bh(e);t&&localStorage.setItem(Rh,t)}return localStorage.getItem(Rh)??``}catch{return``}}function Bh(e){return e.toUpperCase().replace(/[^A-Z0-9]/g,``).slice(0,8)}function Vh(e){return/^\s*[+\d]/.test(e)?`tel`:`email`}function Hh({onSignedIn:e,useAsModel:t=!1,cta:n,autoFocus:r=!0}){let a=s(),o=(0,L.useId)(),c=Se(),[l,u]=(0,L.useState)(`code`),[d,f]=(0,L.useState)(``),[p,m]=(0,L.useState)(``),[h,g]=(0,L.useState)(``),[_,v]=(0,L.useState)(!1),[y,b]=(0,L.useState)(null),[x,S]=(0,L.useState)(null),[C,w]=(0,L.useState)(``),[T,E]=(0,L.useState)(!1);(0,L.useEffect)(()=>{let e=zh();e&&(w(e),E(!0))},[]);let D=async()=>{if(t){b(`model`);try{await M.cloudUseAsModel()}catch{}}await e()},O=async()=>{let e=d.trim();if(e){b(`code`),S(null);try{await M.cloudCode(e),v(!0)}catch(e){S(a(e.message))}finally{b(null)}}},k=async()=>{let e=d.trim();if(!(!e||p.trim().length<4)){b(`verify`),S(null);try{(await M.cloudVerify(e,p.trim(),C.trim())).created&&Bf(),m(``),v(!1);try{localStorage.removeItem(Rh)}catch{}await D()}catch(e){S(a(e.message))}finally{b(null)}}},ee=async()=>{let e=d.trim();if(e&&h){b(`login`),S(null);try{await M.cloudLogin(e,h),g(``),await D()}catch(e){S(a(e.message))}finally{b(null)}}},te=()=>void(l===`password`?ee():_?k():O()),j=n??a(t?`Sign in and use its model`:`Sign in`);return(0,R.jsxs)(`form`,{className:`space-y-3`,onSubmit:e=>{e.preventDefault(),te()},children:[(0,R.jsxs)(`div`,{className:`grid grid-cols-2 gap-1 rounded-2xl bg-surface-2/70 p-1 text-[13px] font-medium`,children:[(0,R.jsx)(Uh,{active:l===`code`,onClick:()=>u(`code`),icon:(0,R.jsx)(jt,{size:14}),label:a(`With a code`)}),(0,R.jsx)(Uh,{active:l===`password`,onClick:()=>u(`password`),icon:(0,R.jsx)(F,{size:14}),label:a(`With a password`)})]}),(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`label`,{htmlFor:`${o}-id`,className:`text-[12px] text-muted`,children:le()?a(`Mainland China phone number or e-mail`):a(`E-mail (or a mainland China phone number)`)}),(0,R.jsx)(`input`,{id:`${o}-id`,value:d,onChange:e=>{f(e.target.value),v(!1)},inputMode:Vh(d),autoComplete:`username`,autoFocus:r,placeholder:a(`138 0000 0000 or you@example.com`),className:i(Fe,`mt-1`)}),l===`code`&&!_&&(!le()||ae(d))&&(0,R.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`Text-message codes reach mainland-China numbers only. Use an e-mail address instead.`)})]}),l===`password`?(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`label`,{htmlFor:`${o}-password`,className:`text-[12px] text-muted`,children:a(`Password`)}),(0,R.jsx)(`input`,{id:`${o}-password`,type:`password`,value:h,onChange:e=>g(e.target.value),autoComplete:`current-password`,className:i(Fe,`mt-1`)}),(0,R.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`No password yet? Use a code first; set one under Account.`)})]}):(0,R.jsxs)(R.Fragment,{children:[_&&(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`label`,{htmlFor:`${o}-code`,className:`text-[12px] text-muted`,children:a(`The code you received`)}),(0,R.jsx)(`input`,{id:`${o}-code`,value:p,onChange:e=>m(e.target.value.replace(/\D/g,``).slice(0,8)),inputMode:`numeric`,autoComplete:`one-time-code`,placeholder:`123456`,autoFocus:!0,className:i(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`mt-1 tracking-[0.3em]`)})]}),T?(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`label`,{htmlFor:`${o}-invite`,className:`text-[12px] text-muted`,children:a(`Invite code (optional)`)}),(0,R.jsx)(`input`,{id:`${o}-invite`,value:C,onChange:e=>w(Bh(e.target.value)),autoComplete:`off`,placeholder:`ABCD2345`,className:i(Fe,`mt-1 tracking-[0.2em] uppercase`)}),(0,R.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`A friend's code counts for a new account: you both get ¥{invite} more allowance.`,{invite:(c.invite_bonus_cny??5).toFixed(0)})})]}):(0,R.jsx)(`button`,{type:`button`,onClick:()=>E(!0),className:`text-[12px] text-muted underline-offset-2 hover:underline`,children:a(`Have an invite code?`)})]}),x&&(0,R.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:x}),(0,R.jsx)(`div`,{className:`flex gap-2`,children:l===`code`&&_?(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(`button`,{type:`button`,disabled:y!==null,onClick:()=>void O(),className:Ae,children:a(`Send again`)}),(0,R.jsxs)(`button`,{type:`submit`,disabled:y!==null||p.trim().length<4,className:i(Me,`flex-1 py-3`),children:[y?(0,R.jsx)(A,{size:15,className:`animate-spin`}):null,` `,j]})]}):l===`code`?(0,R.jsxs)(`button`,{type:`submit`,disabled:y!==null||!d.trim(),className:i(Me,`flex-1 py-3`),children:[y===`code`?(0,R.jsx)(A,{size:15,className:`animate-spin`}):null,` `,a(`Send me a code`)]}):(0,R.jsxs)(`button`,{type:`submit`,disabled:y!==null||!d.trim()||!h,className:i(Me,`flex-1 py-3`),children:[y?(0,R.jsx)(A,{size:15,className:`animate-spin`}):null,` `,j]})})]})}function Uh({active:e,onClick:t,icon:n,label:r}){return(0,R.jsxs)(`button`,{type:`button`,onClick:t,"aria-pressed":e,className:i(`flex items-center justify-center gap-1.5 rounded-xl px-2 py-2 transition`,e?`bg-surface text-fg shadow-sm`:`text-muted hover:text-fg`),children:[n,` `,r]})}function Wh(){let{state:e,refreshSettings:t,refreshHub:n,toast:r,skipSignIn:i}=E(),a=s(),o=e.profile?.name??`nanoMuse`,c=(0,R.jsxs)(`ul`,{className:`grid grid-cols-2 gap-2 text-[12.5px] wide:grid-cols-1 wide:gap-1.5 wide:text-[13.5px]`,children:[(0,R.jsx)(Gh,{icon:(0,R.jsx)(Ft,{size:15}),text:a(`Every device of yours, one nanoMuse`)}),(0,R.jsx)(Gh,{icon:(0,R.jsx)(ot,{size:15}),text:a(`A model with a free allowance, or your own key`)}),(0,R.jsx)(Gh,{icon:(0,R.jsx)(_t,{size:15}),text:a(`It uses your phone and computers for you`)}),(0,R.jsx)(Gh,{icon:(0,R.jsx)(Zt,{size:15}),text:a(`Keys in your vault; nothing you say is kept on the relay`)})]});return(0,R.jsxs)(`div`,{className:`relative mx-auto flex h-[100dvh] w-full max-w-[760px] flex-col overflow-hidden bg-bg sm:border-x sm:border-border wide:max-w-none wide:border-0`,children:[(0,R.jsx)(`div`,{className:`pointer-events-none absolute inset-x-0 top-0 h-[46vh] bg-[radial-gradient(ellipse_at_top,rgba(0,100,212,0.14),transparent_65%)] dark:bg-[radial-gradient(ellipse_at_top,rgba(23,147,255,0.2),transparent_65%)] wide:inset-y-0 wide:h-auto wide:bg-[radial-gradient(ellipse_at_left,rgba(0,100,212,0.12),transparent_60%)] wide:dark:bg-[radial-gradient(ellipse_at_left,rgba(23,147,255,0.16),transparent_60%)]`}),(0,R.jsx)(`div`,{className:`titlebar-room relative`}),(0,R.jsx)(`div`,{className:`relative flex-1 overflow-y-auto px-6 pb-8 wide:flex wide:items-center wide:px-12`,children:(0,R.jsxs)(`div`,{className:`mx-auto w-full wide:grid wide:max-w-[980px] wide:grid-cols-[1.1fr_1fr] wide:items-center wide:gap-16`,children:[(0,R.jsxs)(`div`,{className:`safe-top flex flex-col items-center pt-14 text-center wide:items-start wide:pt-0 wide:text-left`,children:[(0,R.jsx)(bn,{size:88,className:`drop-shadow-[0_20px_40px_rgba(0,100,212,0.28)]`}),(0,R.jsx)(`h1`,{className:`mt-6 text-[28px] font-bold tracking-tight wide:text-[34px]`,children:a(`Sign in to {name}`,{name:o})}),(0,R.jsx)(`p`,{className:`mt-2 max-w-sm text-[14.5px] leading-relaxed text-muted wide:max-w-md wide:text-[15.5px]`,children:a(`One free account. It is what lets your phone and computers work as one and brings a model to start with.`)}),(0,R.jsx)(`div`,{className:`mt-7 hidden w-full wide:block`,children:c}),(0,R.jsx)(we,{compact:!0,className:`mt-6 hidden w-full wide:block`})]}),(0,R.jsxs)(`div`,{children:[(0,R.jsx)(`div`,{className:`mt-8 rounded-[28px] border border-border/70 bg-surface p-5 shadow-sm wide:mt-0 wide:p-7`,children:(0,R.jsx)(Hh,{onSignedIn:async()=>{r(a(`Signed in to nanoMuse Cloud.`)),await Promise.all([t(),n()])}})}),(0,R.jsx)(`button`,{type:`button`,onClick:i,className:`mt-4 w-full rounded-2xl border border-border/70 px-4 py-2.5 text-[13.5px] text-fg/80 hover:bg-surface-2`,"data-testid":`sign-in-skip`,children:a(`Use your own API key instead`)}),(0,R.jsx)(`p`,{className:`mt-2 text-center text-[11.5px] leading-relaxed text-muted`,children:a(`You can sign in later under Connections, for sync and your devices.`)}),(0,R.jsx)(`div`,{className:`mt-6 wide:hidden`,children:c}),(0,R.jsx)(we,{compact:!0,className:`mt-6 wide:hidden`}),(0,R.jsxs)(`p`,{className:`mt-4 text-center text-[11.5px] leading-relaxed text-muted wide:mt-5`,children:[a(`The relay keeps an account id, a masked identifier, usage counts and your agent's name and look; what else, and what is yours to switch off, is in the`),` `,a(`relay privacy policy (ask your relay operator)`),`.`]})]})]})})]})}function Gh({icon:e,text:t}){return(0,R.jsxs)(`li`,{className:`flex items-start gap-2 rounded-2xl bg-surface-2/60 px-3 py-2.5 text-fg/80 wide:bg-transparent wide:px-0 wide:py-1`,children:[(0,R.jsx)(`span`,{className:`mt-0.5 text-accent`,children:e}),(0,R.jsx)(`span`,{className:`leading-snug`,children:t})]})}function Kh(e,t,n){return!e||!e.account.required||e.account.signed_in||t?.llm_ready?!1:!n}var qh=`modulepreload`,Jh=function(e){return`/`+e},Yh={},Xh=function(e){return e.pathname.endsWith(`.css`)},Zh=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e,i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?new URL(import.meta.resolve(e)):new URL(e,import.meta.url)}r=o(t.map(t=>{t=Jh(t,n);let r=s(t);if(r.href in Yh)return;Yh[r.href]=!0;let i=Xh(r);if(e===void 0){e={all:new Set,styles:new Set};let t=document.getElementsByTagName(`link`);for(let n=t.length-1;n>=0;n--){let r=t[n];e.all.add(r.href),r.rel===`stylesheet`&&e.styles.add(r.href)}}if((i?e.styles:e.all).has(r.href))return;let o=document.createElement(`link`);if(o.rel=i?`stylesheet`:qh,i||(o.as=`script`),o.crossOrigin=``,o.href=r.href,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),i)return new Promise((e,t)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>t(Error(`Unable to preload CSS for ${r}`)))})}).filter(e=>e!==void 0))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Qh=(0,L.lazy)(()=>Zh(()=>import(`./AccountScreen-COuDQPDa.js`).then(e=>({default:e.AccountScreen})),__vite__mapDeps([0,1,2,3,4,5,6,7,8]))),$h=(0,L.lazy)(()=>Zh(()=>import(`./AvatarStudioScreen-B7jrVEOY.js`).then(e=>({default:e.AvatarStudioScreen})),__vite__mapDeps([9,1,2,3,4,10,8,11]))),eg=(0,L.lazy)(()=>Zh(()=>import(`./ChannelsScreen-BbqVSzaT.js`).then(e=>({default:e.ChannelsScreen})),__vite__mapDeps([12,1,3,4,10,8]))),tg=(0,L.lazy)(()=>Zh(()=>import(`./CodingScreen-BpFj4V_H.js`).then(e=>({default:e.CodingScreen})),__vite__mapDeps([13,1,4,10,8]))),ng=(0,L.lazy)(()=>Zh(()=>import(`./ConnectionsScreen-DjHkABEK.js`).then(e=>({default:e.ConnectionsScreen})),__vite__mapDeps([14,1,2,3,4,5,7,15,10,8,16]))),rg=(0,L.lazy)(()=>Zh(()=>import(`./DevicesScreen-IPKIub3e.js`).then(e=>({default:e.DevicesScreen})),__vite__mapDeps([17,1,3,4,10,8,16]))),ig=(0,L.lazy)(()=>Zh(()=>import(`./GoalsScreen-BsVUgvTc.js`).then(e=>({default:e.GoalsScreen})),__vite__mapDeps([18,1,2,3,4]))),ag=(0,L.lazy)(()=>Zh(()=>import(`./IdeasScreen-C-S1VdV7.js`).then(e=>({default:e.IdeasScreen})),__vite__mapDeps([19,1,10]))),og=(0,L.lazy)(()=>Zh(()=>import(`./LibraryScreen-Df3fNYTa.js`).then(e=>({default:e.LibraryScreen})),__vite__mapDeps([20,1]))),sg=(0,L.lazy)(()=>Zh(()=>import(`./MemoryScreen-BJU-TDeO.js`).then(e=>({default:e.MemoryScreen})),__vite__mapDeps([21,1,8]))),cg=(0,L.lazy)(()=>Zh(()=>import(`./Onboarding-CgT7DiUR.js`).then(e=>({default:e.Onboarding})),__vite__mapDeps([22,1,2,3,4,23,11,14,5,7,15,10,8,16]))),lg=(0,L.lazy)(()=>Zh(()=>import(`./SettingsScreen-qmwHF2En.js`).then(e=>({default:e.SettingsScreen})),__vite__mapDeps([24,1,2,3,4,5,6,15,23,11,8]))),ug=(0,L.lazy)(()=>Zh(()=>import(`./SkillsScreen-BBsZ25e9.js`).then(e=>({default:e.SkillsScreen})),__vite__mapDeps([25,1,8])));function dg(){return(0,R.jsx)(`div`,{className:`flex h-full min-h-[40vh] items-center justify-center text-muted`,children:(0,R.jsx)(A,{className:`animate-spin`,size:20})})}var fg=e=>({size:26,strokeWidth:e?2:1.7,fill:e?`currentColor`:`none`,fillOpacity:e?.18:0}),pg=[{id:`chat`,label:`Chat`,icon:e=>(0,R.jsx)(kt,{...fg(e)})},{id:`feed`,label:`Feed`,icon:e=>(0,R.jsx)(zt,{...fg(e)})},{id:`ideas`,label:`Ideas`,icon:e=>(0,R.jsx)(St,{...fg(e)})},{id:`goals`,label:`Goals`,icon:e=>(0,R.jsx)(nn,{...fg(e)})},{id:`library`,label:`Library`,icon:e=>(0,R.jsx)(xt,{...fg(e)})}];function mg(){let{state:e,setTab:t,openFile:n}=E(),r=Uf(),a=s();(0,L.useEffect)(()=>{let t=e.profile?.name?.trim();document.title=t&&t.toLowerCase()!==`nanomuse`?`${t} · nanoMuse`:`nanoMuse`},[e.profile?.name]),(0,L.useEffect)(()=>{let e=xn()?.onQuickChat?.(()=>{t(`chat`),requestAnimationFrame(()=>window.dispatchEvent(new Event(`nanomuse:quick-chat`)))});return()=>{typeof e==`function`&&e()}},[t]),(0,L.useEffect)(()=>{let e=()=>{let e=window.location.hash.slice(1);(e&&pg.some(t=>t.id===e)||e===`devices`||e===`you`||e===`memory`||e===`connections`||e===`channels`||e===`skills`||e===`account`||e===`coding`||e===`avatar`)&&t(e)};return e(),window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[t]);let o=r&&e.hub?.account.signed_in?(0,R.jsx)(Wf,{}):null,c=e.lite&&e.settings!==null&&!e.settings.onboarded;if((0,L.useEffect)(()=>{c&&M.onboarded(!0).catch(()=>void 0)},[c]),e.authError)return(0,R.jsx)(gg,{});if(!e.loaded)return(0,R.jsx)(hg,{error:e.error});if(Kh(e.hub,e.settings,e.signInSkipped))return(0,R.jsx)(Wh,{});if(!e.lite&&e.settings&&!e.settings.onboarded&&!e.onboardingDismissed&&!e.threads.some(e=>e.events>0))return(0,R.jsxs)(L.Suspense,{fallback:(0,R.jsx)(dg,{}),children:[(0,R.jsx)(cg,{}),o]});let l=e.pendingApprovals.length,u=e.goals.filter(e=>e.proposal&&e.status!==`cancelled`).length,d=e.pendingApprovals.filter(t=>t.ts>e.feedSeenAt).length;return(0,R.jsxs)(`div`,{className:i(`mx-auto flex h-[100dvh] max-w-[760px] flex-col bg-bg sm:border-x sm:border-border`,!e.lite&&`wide:max-w-none wide:flex-row wide:border-x-0`),children:[!e.lite&&(0,R.jsx)(`div`,{className:`hidden wide:block wide:h-full`,children:(0,R.jsx)(lh,{})}),(0,R.jsxs)(`div`,{className:`flex min-h-0 min-w-0 flex-1 flex-col`,children:[e.gone&&(0,R.jsxs)(`div`,{className:`flex items-center justify-center gap-2 bg-surface-2 text-muted text-[12.5px] py-1`,children:[(0,R.jsx)(_n,{size:14}),` `,a(e.lite?`This demo has ended.`:`This nanoMuse has stopped. Reload the page once it is running again.`)]}),!e.gone&&!e.connected&&e.loaded&&(0,R.jsxs)(`div`,{className:`flex items-center justify-center gap-2 bg-amber-500/15 text-amber-700 dark:text-amber-300 text-[12.5px] py-1`,children:[(0,R.jsx)(_n,{size:14}),` `,a(`Reconnecting to your nanoMuse…`)]}),!e.lite&&(0,R.jsx)(Tn,{}),(0,R.jsx)(`main`,{className:`mx-auto min-h-0 w-full flex-1 wide:max-w-[900px]`,children:(0,R.jsxs)(L.Suspense,{fallback:(0,R.jsx)(dg,{}),children:[e.tab===`chat`&&(0,R.jsx)(Um,{}),e.tab===`feed`&&(0,R.jsx)(uh,{}),e.tab===`ideas`&&(0,R.jsx)(ag,{}),e.tab===`goals`&&(0,R.jsx)(ig,{}),e.tab===`library`&&(0,R.jsx)(og,{}),e.tab===`memory`&&(0,R.jsx)(sg,{}),e.tab===`skills`&&(0,R.jsx)(ug,{}),e.tab===`connections`&&(0,R.jsx)(ng,{}),e.tab===`channels`&&(0,R.jsx)(eg,{}),e.tab===`devices`&&(0,R.jsx)(rg,{}),e.tab===`you`&&(0,R.jsx)(lg,{}),e.tab===`account`&&(0,R.jsx)(Qh,{}),e.tab===`coding`&&(0,R.jsx)(tg,{}),e.tab===`avatar`&&(0,R.jsx)($h,{})]})}),(0,R.jsx)(`nav`,{className:i(`safe-bottom shrink-0 bg-bg`,!e.lite&&`wide:hidden`),children:(0,R.jsx)(`ul`,{className:`flex h-14 items-center justify-evenly`,children:pg.map(n=>{let r=e.tab===n.id,o=n.id===`chat`?l:n.id===`feed`?d:n.id===`goals`?u:0;return(0,R.jsx)(`li`,{children:(0,R.jsxs)(`button`,{type:`button`,onClick:()=>t(n.id),"aria-label":a(n.label),"aria-current":r?`page`:void 0,className:i(`relative flex h-12 w-12 items-center justify-center rounded-full transition`,r?`text-fg`:`text-fg/70 hover:text-fg`),children:[n.icon(r),o>0&&(0,R.jsx)(`span`,{className:`absolute right-1 top-1 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white`,children:o})]})},n.id)})})})]}),(0,R.jsx)(On,{}),(0,R.jsx)(hf,{path:e.viewer,onClose:()=>n(null)}),o,e.toast&&(0,R.jsx)(`div`,{className:`pointer-events-none fixed inset-x-0 bottom-24 z-[70] flex justify-center px-4`,children:(0,R.jsx)(`div`,{className:`rise rounded-2xl bg-fg text-bg px-4 py-2 text-[13.5px] shadow-lg max-w-sm text-center`,children:e.toast})})]})}function hg({error:e}){let t=s();return(0,R.jsxs)(`div`,{className:`mx-auto flex h-[100dvh] max-w-sm flex-col items-center justify-center px-6 text-center`,children:[(0,R.jsx)(bn,{size:72}),e?(0,R.jsxs)(R.Fragment,{children:[(0,R.jsx)(`p`,{className:`mt-6 text-[15px] font-medium`,children:t(`Could not reach your nanoMuse.`)}),(0,R.jsx)(`p`,{className:`mt-1.5 text-[13px] text-muted`,children:t(e)}),(0,R.jsx)(`button`,{type:`button`,onClick:()=>window.location.reload(),className:`mt-5 rounded-full bg-fg px-5 py-2 text-[14px] font-medium text-bg`,children:t(`Try again`)})]}):(0,R.jsxs)(`p`,{className:`mt-6 flex items-center gap-2 text-[14px] text-muted`,children:[(0,R.jsx)(A,{className:`animate-spin`,size:16}),` `,t(`Connecting to your nanoMuse…`)]})]})}function gg(){let[e,t]=(0,L.useState)(``),n=s();return(0,R.jsxs)(`div`,{className:`mx-auto flex h-[100dvh] max-w-md flex-col items-center justify-center px-6 text-center`,children:[(0,R.jsx)(bn,{size:88}),(0,R.jsx)(`h1`,{className:`mt-4 text-[22px] font-bold`,children:n(`Connect to your nanoMuse`)}),(0,R.jsxs)(`p`,{className:`mt-2 text-[14px] text-muted`,children:[n(`This app talks to the nanoMuse server you run yourself. Scan the QR code printed by`),` `,(0,R.jsx)(`code`,{className:`rounded bg-surface-2 px-1`,children:`nanomuse serve`}),n(`, or paste the access token below.`)]}),(0,R.jsxs)(`form`,{className:`mt-5 w-full flex gap-2`,onSubmit:t=>{t.preventDefault(),e.trim()&&(g(e.trim()),window.location.reload())},children:[(0,R.jsx)(`input`,{value:e,onChange:e=>t(e.target.value),"aria-label":n(`Access token`),placeholder:n(`Access token`),className:`flex-1 rounded-2xl bg-surface-2 px-4 py-2.5 text-[15px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,R.jsx)(`button`,{type:`submit`,className:`rounded-2xl bg-accent text-accent-fg px-4 font-medium`,children:n(`Connect`)})]})]})}a(),ee(),Cn(),yn.createRoot(document.getElementById(`root`)).render((0,R.jsx)(L.StrictMode,{children:(0,R.jsx)(T,{children:(0,R.jsx)(mg,{})})}));export{Vt as $,Af as A,dn as B,Dp as C,Ye as Ct,Xf as D,np as E,Ge as Et,jn as F,Zt as G,an as H,xn as I,qt as J,Xt as K,Sn as L,Nf as M,kf as N,Qf as O,mf as P,Ht as Q,vn as R,qp as S,Xe as St,rp as T,Ke as Tt,tn as U,on as V,en as W,Gt as X,Kt as Y,Wt as Z,rm as _,ot as _t,Ah as a,kt as at,Qp as b,et as bt,Mh as c,Ct as ct,Tm as d,_t as dt,Bt as et,Xp as f,ht as ft,tm as g,st as gt,Yp as h,lt as ht,kh as i,Mt as it,Mf as j,Pf as k,Bm as l,F as lt,Zp as m,ft as mt,Ih as n,It as nt,jh as o,Tt as ot,Jp as p,pt,Yt as q,Ph as r,Ft as rt,Fh as s,wt as st,Hh as t,Lt as tt,Hm as u,bt as ut,$p as v,it as vt,ap as w,qe as wt,em as x,Ze as xt,nm as y,nt as yt,hn as z}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/index-D-ZnIXbP.css b/nanomuse/server/static/assets/index-D-ZnIXbP.css deleted file mode 100644 index 12015268..00000000 --- a/nanomuse/server/static/assets/index-D-ZnIXbP.css +++ /dev/null @@ -1,2 +0,0 @@ -/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:"Figtree", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-orange-300:oklch(83.7% .128 66.29);--color-orange-400:oklch(75% .183 55.934);--color-orange-500:oklch(70.5% .213 47.604);--color-orange-600:oklch(64.6% .222 41.116);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-teal-700:oklch(51.1% .096 186.391);--color-cyan-300:oklch(86.5% .127 207.078);--color-cyan-500:oklch(71.5% .143 215.221);--color-cyan-600:oklch(60.9% .126 221.723);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-400:oklch(74.6% .16 232.661);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-violet-300:oklch(81.1% .111 293.571);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-fuchsia-300:oklch(83.3% .145 321.434);--color-fuchsia-500:oklch(66.7% .295 322.15);--color-fuchsia-600:oklch(59.1% .293 322.896);--color-pink-300:oklch(82.3% .12 346.018);--color-pink-500:oklch(65.6% .241 354.308);--color-pink-600:oklch(59.2% .249 .584);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--leading-tight:1.25;--leading-snug:1.375;--leading-relaxed:1.625;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--radius-3xl:1.5rem;--drop-shadow-sm:0 1px 2px #00000026;--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-3xl:64px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-bg:var(--om-bg);--color-surface:var(--om-surface);--color-surface-2:var(--om-surface-2);--color-border:var(--om-border);--color-fg:var(--om-fg);--color-muted:var(--om-muted);--color-accent:var(--om-accent);--color-accent-fg:#fff;--color-bubble-user:var(--om-bubble-user);--color-bubble-user-fg:var(--om-bubble-user-fg)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.\@container{container-type:inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.inset-0{inset:0}.inset-x-0{inset-inline:0}.inset-y-0{inset-block:0}.-top-1{top:calc(var(--spacing) * -1)}.-top-1\.5{top:calc(var(--spacing) * -1.5)}.-top-20{top:calc(var(--spacing) * -20)}.top-0{top:0}.top-0\.5{top:calc(var(--spacing) * .5)}.top-1{top:var(--spacing)}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-1\/2{top:50%}.top-2{top:calc(var(--spacing) * 2)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-9{top:calc(var(--spacing) * 9)}.top-12{top:calc(var(--spacing) * 12)}.top-\[3px\]{top:3px}.top-full{top:100%}.-right-0\.5{right:calc(var(--spacing) * -.5)}.-right-1{right:calc(var(--spacing) * -1)}.-right-1\.5{right:calc(var(--spacing) * -1.5)}.-right-16{right:calc(var(--spacing) * -16)}.right-0{right:0}.right-1{right:var(--spacing)}.right-1\.5{right:calc(var(--spacing) * 1.5)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.right-\[3px\]{right:3px}.-bottom-0\.5{bottom:calc(var(--spacing) * -.5)}.bottom-0{bottom:0}.bottom-1{bottom:var(--spacing)}.bottom-2{bottom:calc(var(--spacing) * 2)}.bottom-24{bottom:calc(var(--spacing) * 24)}.left-0{left:0}.left-0\.5{left:calc(var(--spacing) * .5)}.left-1\.5{left:calc(var(--spacing) * 1.5)}.left-1\/2{left:50%}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-3{left:calc(var(--spacing) * 3)}.left-4{left:calc(var(--spacing) * 4)}.left-\[18px\]{left:18px}.left-\[22px\]{left:22px}.left-\[26px\]{left:26px}.left-\[68px\]{left:68px}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-50{z-index:50}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-1{margin-inline:var(--spacing)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-4{margin-inline:calc(var(--spacing) * 4)}.mx-auto{margin-inline:auto}.my-1{margin-block:var(--spacing)}.my-1\.5{margin-block:calc(var(--spacing) * 1.5)}.my-2{margin-block:calc(var(--spacing) * 2)}.my-8{margin-block:calc(var(--spacing) * 8)}.-mt-0\.5{margin-top:calc(var(--spacing) * -.5)}.-mt-1{margin-top:calc(var(--spacing) * -1)}.-mt-2\.5{margin-top:calc(var(--spacing) * -2.5)}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-2\.5{margin-top:calc(var(--spacing) * 2.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-3\.5{margin-top:calc(var(--spacing) * 3.5)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-7{margin-top:calc(var(--spacing) * 7)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-\[2px\]{margin-top:2px}.mt-\[3px\]{margin-top:3px}.mt-\[22px\]{margin-top:22px}.mt-auto{margin-top:auto}.-mr-1\.5{margin-right:calc(var(--spacing) * -1.5)}.-mr-2{margin-right:calc(var(--spacing) * -2)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-2\.5{margin-right:calc(var(--spacing) * 2.5)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-5{margin-right:calc(var(--spacing) * 5)}.-mb-2{margin-bottom:calc(var(--spacing) * -2)}.mb-0\.5{margin-bottom:calc(var(--spacing) * .5)}.mb-1{margin-bottom:var(--spacing)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-1{margin-left:calc(var(--spacing) * -1)}.ml-0\.5{margin-left:calc(var(--spacing) * .5)}.ml-1{margin-left:var(--spacing)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-2\.5{margin-left:calc(var(--spacing) * 2.5)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-\[52px\]{margin-left:52px}.ml-\[76px\]{margin-left:76px}.ml-auto{margin-left:auto}.line-clamp-2{-webkit-line-clamp:2;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-3{-webkit-line-clamp:3;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.line-clamp-4{-webkit-line-clamp:4;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.aspect-\[16\/10\]{aspect-ratio:16/10}.aspect-square{aspect-ratio:1}.h-1{height:var(--spacing)}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-16{height:calc(var(--spacing) * 16)}.h-28{height:calc(var(--spacing) * 28)}.h-40{height:calc(var(--spacing) * 40)}.h-44{height:calc(var(--spacing) * 44)}.h-48{height:calc(var(--spacing) * 48)}.h-\[9px\]{height:9px}.h-\[17px\]{height:17px}.h-\[18px\]{height:18px}.h-\[46px\]{height:46px}.h-\[46vh\]{height:46vh}.h-\[50px\]{height:50px}.h-\[72px\]{height:72px}.h-\[100dvh\]{height:100dvh}.h-\[130px\]{height:130px}.h-\[148px\]{height:148px}.h-\[480px\]{height:480px}.h-full{height:100%}.max-h-28{max-height:calc(var(--spacing) * 28)}.max-h-32{max-height:calc(var(--spacing) * 32)}.max-h-40{max-height:calc(var(--spacing) * 40)}.max-h-52{max-height:calc(var(--spacing) * 52)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-72{max-height:calc(var(--spacing) * 72)}.max-h-\[60vh\]{max-height:60vh}.max-h-\[92dvh\]{max-height:92dvh}.max-h-full{max-height:100%}.min-h-0{min-height:0}.min-h-\[40vh\]{min-height:40vh}.min-h-\[44px\]{min-height:44px}.min-h-\[54px\]{min-height:54px}.min-h-\[56px\]{min-height:56px}.\!w-auto{width:auto!important}.w-1{width:var(--spacing)}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-16{width:calc(var(--spacing) * 16)}.w-28{width:calc(var(--spacing) * 28)}.w-40{width:calc(var(--spacing) * 40)}.w-44{width:calc(var(--spacing) * 44)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-\[9px\]{width:9px}.w-\[46px\]{width:46px}.w-\[60px\]{width:60px}.w-\[64px\]{width:64px}.w-\[72px\]{width:72px}.w-\[74px\]{width:74px}.w-\[76px\]{width:76px}.w-\[82vw\]{width:82vw}.w-\[86px\]{width:86px}.w-\[148px\]{width:148px}.w-\[188px\]{width:188px}.w-\[232px\]{width:232px}.w-\[248px\]{width:248px}.w-\[260px\]{width:260px}.w-\[300px\]{width:300px}.w-\[900px\]{width:900px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.max-w-\[60\%\]{max-width:60%}.max-w-\[68px\]{max-width:68px}.max-w-\[86\%\]{max-width:86%}.max-w-\[240px\]{max-width:240px}.max-w-\[320px\]{max-width:320px}.max-w-\[340px\]{max-width:340px}.max-w-\[360px\]{max-width:360px}.max-w-\[520px\]{max-width:520px}.max-w-\[560px\]{max-width:560px}.max-w-\[600px\]{max-width:600px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.max-w-\[calc\(100\%-128px\)\]{max-width:calc(100% - 128px)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-sm{max-width:var(--container-sm)}.min-w-0{min-width:0}.min-w-\[17px\]{min-width:17px}.min-w-\[18px\]{min-width:18px}.min-w-\[180px\]{min-width:180px}.min-w-full{min-width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.border-separate{border-collapse:separate}.border-spacing-0{--tw-border-spacing-x:0px;--tw-border-spacing-y:0px;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y)}.origin-top-left{transform-origin:0 0}.-translate-x-1\/2{--tw-translate-x:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.scale-\[0\.3333\]{scale:.3333}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-crosshair{cursor:crosshair}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.scroll-mt-4{scroll-margin-top:calc(var(--spacing) * 4)}.list-decimal{list-style-type:decimal}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-8{grid-template-columns:repeat(8,minmax(0,1fr))}.grid-cols-\[1fr_1fr_auto\]{grid-template-columns:1fr 1fr auto}.grid-cols-\[1fr_84px\]{grid-template-columns:1fr 84px}.grid-cols-\[auto_1fr\]{grid-template-columns:auto 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.justify-evenly{justify-content:space-evenly}.justify-start{justify-content:flex-start}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-\[3px\]{gap:3px}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-1\.5{column-gap:calc(var(--spacing) * 1.5)}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-3{column-gap:calc(var(--spacing) * 3)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-1\.5{row-gap:calc(var(--spacing) * 1.5)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-border>:not(:last-child)),:where(.divide-border\/60>:not(:last-child)){border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-border\/60>:not(:last-child)){border-color:color-mix(in oklab, var(--color-border) 60%, transparent)}}:where(.divide-border\/70>:not(:last-child)){border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){:where(.divide-border\/70>:not(:last-child)){border-color:color-mix(in oklab, var(--color-border) 70%, transparent)}}.self-center{align-self:center}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-3xl{border-radius:var(--radius-3xl)}.rounded-\[10px\]{border-radius:10px}.rounded-\[13px\]{border-radius:13px}.rounded-\[14px\]{border-radius:14px}.rounded-\[18px\]{border-radius:18px}.rounded-\[20px\]{border-radius:20px}.rounded-\[22px\]{border-radius:22px}.rounded-\[26px\]{border-radius:26px}.rounded-\[28px\]{border-radius:28px}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-t-3xl{border-top-left-radius:var(--radius-3xl);border-top-right-radius:var(--radius-3xl)}.rounded-t-\[28px\]{border-top-left-radius:28px;border-top-right-radius:28px}.rounded-tl-lg{border-top-left-radius:var(--radius-lg)}.rounded-br-lg{border-bottom-right-radius:var(--radius-lg)}.rounded-bl-lg{border-bottom-left-radius:var(--radius-lg)}.rounded-bl-md{border-bottom-left-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-\[1\.5px\]{border-style:var(--tw-border-style);border-width:1.5px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-accent,.border-accent\/15{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/15{border-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.border-accent\/25{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/25{border-color:color-mix(in oklab, var(--color-accent) 25%, transparent)}}.border-accent\/30{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/30{border-color:color-mix(in oklab, var(--color-accent) 30%, transparent)}}.border-accent\/40{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/40{border-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}.border-accent\/50{border-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.border-accent\/50{border-color:color-mix(in oklab, var(--color-accent) 50%, transparent)}}.border-amber-400\/40{border-color:#fcbb0066}@supports (color:color-mix(in lab, red, red)){.border-amber-400\/40{border-color:color-mix(in oklab, var(--color-amber-400) 40%, transparent)}}.border-amber-500\/50{border-color:#f99c0080}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/50{border-color:color-mix(in oklab, var(--color-amber-500) 50%, transparent)}}.border-bg{border-color:var(--color-bg)}.border-border,.border-border\/60{border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.border-border\/60{border-color:color-mix(in oklab, var(--color-border) 60%, transparent)}}.border-border\/70{border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.border-border\/70{border-color:color-mix(in oklab, var(--color-border) 70%, transparent)}}.border-border\/80{border-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.border-border\/80{border-color:color-mix(in oklab, var(--color-border) 80%, transparent)}}.border-current{border-color:currentColor}.border-fg{border-color:var(--color-fg)}.border-rose-400{border-color:var(--color-rose-400)}.border-rose-400\/60{border-color:#ff667f99}@supports (color:color-mix(in lab, red, red)){.border-rose-400\/60{border-color:color-mix(in oklab, var(--color-rose-400) 60%, transparent)}}.border-rose-500\/25{border-color:#ff235740}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/25{border-color:color-mix(in oklab, var(--color-rose-500) 25%, transparent)}}.border-surface{border-color:var(--color-surface)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.bg-\[\#2fb344\]{background-color:#2fb344}.bg-\[\#f1efeb\]{background-color:#f1efeb}.bg-accent,.bg-accent\/5{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/5{background-color:color-mix(in oklab, var(--color-accent) 5%, transparent)}}.bg-accent\/8{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/8{background-color:color-mix(in oklab, var(--color-accent) 8%, transparent)}}.bg-accent\/10{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/10{background-color:color-mix(in oklab, var(--color-accent) 10%, transparent)}}.bg-accent\/12{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/12{background-color:color-mix(in oklab, var(--color-accent) 12%, transparent)}}.bg-accent\/15{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/15{background-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.bg-accent\/70{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/70{background-color:color-mix(in oklab, var(--color-accent) 70%, transparent)}}.bg-accent\/\[0\.06\]{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.bg-accent\/\[0\.06\]{background-color:color-mix(in oklab, var(--color-accent) 6%, transparent)}}.bg-amber-50\/70{background-color:#fffbebb3}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/70{background-color:color-mix(in oklab, var(--color-amber-50) 70%, transparent)}}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-400\/15{background-color:#fcbb0026}@supports (color:color-mix(in lab, red, red)){.bg-amber-400\/15{background-color:color-mix(in oklab, var(--color-amber-400) 15%, transparent)}}.bg-amber-500{background-color:var(--color-amber-500)}.bg-amber-500\/10{background-color:#f99c001a}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/10{background-color:color-mix(in oklab, var(--color-amber-500) 10%, transparent)}}.bg-amber-500\/12{background-color:#f99c001f}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/12{background-color:color-mix(in oklab, var(--color-amber-500) 12%, transparent)}}.bg-amber-500\/15{background-color:#f99c0026}@supports (color:color-mix(in lab, red, red)){.bg-amber-500\/15{background-color:color-mix(in oklab, var(--color-amber-500) 15%, transparent)}}.bg-bg,.bg-bg\/50{background-color:var(--color-bg)}@supports (color:color-mix(in lab, red, red)){.bg-bg\/50{background-color:color-mix(in oklab, var(--color-bg) 50%, transparent)}}.bg-bg\/60{background-color:var(--color-bg)}@supports (color:color-mix(in lab, red, red)){.bg-bg\/60{background-color:color-mix(in oklab, var(--color-bg) 60%, transparent)}}.bg-bg\/80{background-color:var(--color-bg)}@supports (color:color-mix(in lab, red, red)){.bg-bg\/80{background-color:color-mix(in oklab, var(--color-bg) 80%, transparent)}}.bg-black\/20{background-color:#0003}@supports (color:color-mix(in lab, red, red)){.bg-black\/20{background-color:color-mix(in oklab, var(--color-black) 20%, transparent)}}.bg-black\/40{background-color:#0006}@supports (color:color-mix(in lab, red, red)){.bg-black\/40{background-color:color-mix(in oklab, var(--color-black) 40%, transparent)}}.bg-black\/45{background-color:#00000073}@supports (color:color-mix(in lab, red, red)){.bg-black\/45{background-color:color-mix(in oklab, var(--color-black) 45%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-border{background-color:var(--color-border)}.bg-bubble-user{background-color:var(--color-bubble-user)}.bg-cyan-500\/12{background-color:#00b7d71f}@supports (color:color-mix(in lab, red, red)){.bg-cyan-500\/12{background-color:color-mix(in oklab, var(--color-cyan-500) 12%, transparent)}}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-500\/12{background-color:#00bb7f1f}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/12{background-color:color-mix(in oklab, var(--color-emerald-500) 12%, transparent)}}.bg-emerald-500\/15{background-color:#00bb7f26}@supports (color:color-mix(in lab, red, red)){.bg-emerald-500\/15{background-color:color-mix(in oklab, var(--color-emerald-500) 15%, transparent)}}.bg-fg,.bg-fg\/40{background-color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.bg-fg\/40{background-color:color-mix(in oklab, var(--color-fg) 40%, transparent)}}.bg-fuchsia-500\/12{background-color:#e12afb1f}@supports (color:color-mix(in lab, red, red)){.bg-fuchsia-500\/12{background-color:color-mix(in oklab, var(--color-fuchsia-500) 12%, transparent)}}.bg-muted,.bg-muted\/35{background-color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.bg-muted\/35{background-color:color-mix(in oklab, var(--color-muted) 35%, transparent)}}.bg-orange-500\/12{background-color:#fe6e001f}@supports (color:color-mix(in lab, red, red)){.bg-orange-500\/12{background-color:color-mix(in oklab, var(--color-orange-500) 12%, transparent)}}.bg-pink-500{background-color:var(--color-pink-500)}.bg-pink-500\/12{background-color:#f6339a1f}@supports (color:color-mix(in lab, red, red)){.bg-pink-500\/12{background-color:color-mix(in oklab, var(--color-pink-500) 12%, transparent)}}.bg-red-500{background-color:var(--color-red-500)}.bg-rose-400{background-color:var(--color-rose-400)}.bg-rose-500{background-color:var(--color-rose-500)}.bg-rose-500\/12{background-color:#ff23571f}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/12{background-color:color-mix(in oklab, var(--color-rose-500) 12%, transparent)}}.bg-rose-500\/15{background-color:#ff235726}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/15{background-color:color-mix(in oklab, var(--color-rose-500) 15%, transparent)}}.bg-rose-500\/\[0\.04\]{background-color:#ff23570a}@supports (color:color-mix(in lab, red, red)){.bg-rose-500\/\[0\.04\]{background-color:color-mix(in oklab, var(--color-rose-500) 4%, transparent)}}.bg-sky-500\/12{background-color:#00a5ef1f}@supports (color:color-mix(in lab, red, red)){.bg-sky-500\/12{background-color:color-mix(in oklab, var(--color-sky-500) 12%, transparent)}}.bg-surface{background-color:var(--color-surface)}.bg-surface-2,.bg-surface-2\/40{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/40{background-color:color-mix(in oklab, var(--color-surface-2) 40%, transparent)}}.bg-surface-2\/60{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/60{background-color:color-mix(in oklab, var(--color-surface-2) 60%, transparent)}}.bg-surface-2\/70{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/70{background-color:color-mix(in oklab, var(--color-surface-2) 70%, transparent)}}.bg-surface-2\/80{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.bg-surface-2\/80{background-color:color-mix(in oklab, var(--color-surface-2) 80%, transparent)}}.bg-surface\/40{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/40{background-color:color-mix(in oklab, var(--color-surface) 40%, transparent)}}.bg-surface\/70{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/70{background-color:color-mix(in oklab, var(--color-surface) 70%, transparent)}}.bg-surface\/90{background-color:var(--color-surface)}@supports (color:color-mix(in lab, red, red)){.bg-surface\/90{background-color:color-mix(in oklab, var(--color-surface) 90%, transparent)}}.bg-transparent{background-color:#0000}.bg-violet-500{background-color:var(--color-violet-500)}.bg-violet-500\/12{background-color:#8d54ff1f}@supports (color:color-mix(in lab, red, red)){.bg-violet-500\/12{background-color:color-mix(in oklab, var(--color-violet-500) 12%, transparent)}}.bg-white{background-color:var(--color-white)}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-gradient-to-br{--tw-gradient-position:to bottom right in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.bg-\[radial-gradient\(ellipse_at_top\,rgba\(0\,100\,212\,0\.14\)\,transparent_65\%\)\]{background-image:radial-gradient(at top,#0064d424,#0000 65%)}.from-\[\#7d6bc7\]{--tw-gradient-from:#7d6bc7;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-\[\#b0413e\]{--tw-gradient-from:#b0413e;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-accent{--tw-gradient-from:var(--color-accent);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-emerald-500{--tw-gradient-from:var(--color-emerald-500);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-orange-400{--tw-gradient-from:var(--color-orange-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-400{--tw-gradient-from:var(--color-slate-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.from-slate-700{--tw-gradient-from:var(--color-slate-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-\[\#7a1f1f\]{--tw-gradient-to:#7a1f1f;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-\[\#574494\]{--tw-gradient-to:#574494;--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-amber-600{--tw-gradient-to:var(--color-amber-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-sky-400{--tw-gradient-to:var(--color-sky-400);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-600{--tw-gradient-to:var(--color-slate-600);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-slate-900{--tw-gradient-to:var(--color-slate-900);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-teal-700{--tw-gradient-to:var(--color-teal-700);--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.object-contain{object-fit:contain}.object-cover{object-fit:cover}.object-top{object-position:top}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:var(--spacing)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-2\.5{padding:calc(var(--spacing) * 2.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-3\.5{padding:calc(var(--spacing) * 3.5)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.\!px-3\.5{padding-inline:calc(var(--spacing) * 3.5)!important}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:var(--spacing)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.px-16{padding-inline:calc(var(--spacing) * 16)}.\!py-1\.5{padding-block:calc(var(--spacing) * 1.5)!important}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.pt-0{padding-top:0}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:var(--spacing)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-2\.5{padding-top:calc(var(--spacing) * 2.5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pt-3\.5{padding-top:calc(var(--spacing) * 3.5)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-5{padding-top:calc(var(--spacing) * 5)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-9{padding-top:calc(var(--spacing) * 9)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-14{padding-top:calc(var(--spacing) * 14)}.pt-\[3px\]{padding-top:3px}.pt-\[18px\]{padding-top:18px}.pt-\[22px\]{padding-top:22px}.pr-1{padding-right:var(--spacing)}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-2\.5{padding-right:calc(var(--spacing) * 2.5)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-5{padding-right:calc(var(--spacing) * 5)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-10{padding-right:calc(var(--spacing) * 10)}.pb-0\.5{padding-bottom:calc(var(--spacing) * .5)}.pb-1{padding-bottom:var(--spacing)}.pb-1\.5{padding-bottom:calc(var(--spacing) * 1.5)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-2\.5{padding-bottom:calc(var(--spacing) * 2.5)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-3\.5{padding-bottom:calc(var(--spacing) * 3.5)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-7{padding-bottom:calc(var(--spacing) * 7)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-3\.5{padding-left:calc(var(--spacing) * 3.5)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-5{padding-left:calc(var(--spacing) * 5)}.pl-7{padding-left:calc(var(--spacing) * 7)}.pl-9{padding-left:calc(var(--spacing) * 9)}.pl-12{padding-left:calc(var(--spacing) * 12)}.pl-\[18px\]{padding-left:18px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13\.5px\]{font-size:13.5px}.text-\[13px\]{font-size:13px}.text-\[14\.5px\]{font-size:14.5px}.text-\[14px\]{font-size:14px}.text-\[15px\]{font-size:15px}.text-\[16px\]{font-size:16px}.text-\[17px\]{font-size:17px}.text-\[18px\]{font-size:18px}.text-\[20px\]{font-size:20px}.text-\[21px\]{font-size:21px}.text-\[22px\]{font-size:22px}.text-\[24px\]{font-size:24px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-\[30px\]{font-size:30px}.text-\[64px\]{font-size:64px}.leading-4{--tw-leading:calc(var(--spacing) * 4);line-height:calc(var(--spacing) * 4)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-7{--tw-leading:calc(var(--spacing) * 7);line-height:calc(var(--spacing) * 7)}.leading-8{--tw-leading:calc(var(--spacing) * 8);line-height:calc(var(--spacing) * 8)}.leading-\[1\.4\]{--tw-leading:1.4;line-height:1.4}.leading-\[1\.5\]{--tw-leading:1.5;line-height:1.5}.leading-\[1\.45\]{--tw-leading:1.45;line-height:1.45}.leading-\[14px\]{--tw-leading:14px;line-height:14px}.leading-\[17px\]{--tw-leading:17px;line-height:17px}.leading-\[18px\]{--tw-leading:18px;line-height:18px}.leading-\[19px\]{--tw-leading:19px;line-height:19px}.leading-\[20px\]{--tw-leading:20px;line-height:20px}.leading-\[21px\]{--tw-leading:21px;line-height:21px}.leading-\[22px\]{--tw-leading:22px;line-height:22px}.leading-\[26px\]{--tw-leading:26px;line-height:26px}.leading-\[34px\]{--tw-leading:34px;line-height:34px}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-\[0\.2em\]{--tw-tracking:.2em;letter-spacing:.2em}.tracking-\[0\.3em\]{--tw-tracking:.3em;letter-spacing:.3em}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-accent{color:var(--color-accent)}.text-accent-fg{color:var(--color-accent-fg)}.text-accent\/80{color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.text-accent\/80{color:color-mix(in oklab, var(--color-accent) 80%, transparent)}}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-bg{color:var(--color-bg)}.text-bubble-user-fg{color:var(--color-bubble-user-fg)}.text-cyan-600{color:var(--color-cyan-600)}.text-emerald-500{color:var(--color-emerald-500)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-fg,.text-fg\/45{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/45{color:color-mix(in oklab, var(--color-fg) 45%, transparent)}}.text-fg\/60{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/60{color:color-mix(in oklab, var(--color-fg) 60%, transparent)}}.text-fg\/65{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/65{color:color-mix(in oklab, var(--color-fg) 65%, transparent)}}.text-fg\/70{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/70{color:color-mix(in oklab, var(--color-fg) 70%, transparent)}}.text-fg\/75{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/75{color:color-mix(in oklab, var(--color-fg) 75%, transparent)}}.text-fg\/80{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/80{color:color-mix(in oklab, var(--color-fg) 80%, transparent)}}.text-fg\/85{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/85{color:color-mix(in oklab, var(--color-fg) 85%, transparent)}}.text-fg\/90{color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.text-fg\/90{color:color-mix(in oklab, var(--color-fg) 90%, transparent)}}.text-fuchsia-600{color:var(--color-fuchsia-600)}.text-muted,.text-muted\/70{color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.text-muted\/70{color:color-mix(in oklab, var(--color-muted) 70%, transparent)}}.text-muted\/80{color:var(--color-muted)}@supports (color:color-mix(in lab, red, red)){.text-muted\/80{color:color-mix(in oklab, var(--color-muted) 80%, transparent)}}.text-orange-600{color:var(--color-orange-600)}.text-pink-600{color:var(--color-pink-600)}.text-red-600{color:var(--color-red-600)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-rose-700{color:var(--color-rose-700)}.text-sky-600{color:var(--color-sky-600)}.text-violet-600{color:var(--color-violet-600)}.text-white{color:var(--color-white)}.text-white\/85{color:#ffffffd9}@supports (color:color-mix(in lab, red, red)){.text-white\/85{color:color-mix(in oklab, var(--color-white) 85%, transparent)}}.text-white\/90{color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.text-white\/90{color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.capitalize{text-transform:capitalize}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.ordinal{--tw-ordinal:ordinal;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.line-through{text-decoration-line:line-through}.underline{text-decoration-line:underline}.decoration-border{-webkit-text-decoration-color:var(--color-border);-webkit-text-decoration-color:var(--color-border);-webkit-text-decoration-color:var(--color-border);text-decoration-color:var(--color-border)}.decoration-dotted{text-decoration-style:dotted}.underline-offset-2{text-underline-offset:2px}.accent-\[var\(--om-accent\)\]{accent-color:var(--om-accent)}.accent-accent{accent-color:var(--color-accent)}.opacity-0{opacity:0}.opacity-45{opacity:.45}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(0\,0\,0\,0\.14\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#00000024);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(0\,0\,0\,0\.16\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#00000029);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_4px_rgba\(0\,0\,0\,0\.18\)\]{--tw-shadow:0 1px 4px var(--tw-shadow-color,#0000002e);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_12px_-6px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 12px -6px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_2px_12px_rgba\(0\,0\,0\,0\.12\)\]{--tw-shadow:0 2px 12px var(--tw-shadow-color,#0000001f);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_4px_20px_-12px_rgba\(0\,0\,0\,0\.2\)\]{--tw-shadow:0 4px 20px -12px var(--tw-shadow-color,#0003);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_12px_40px_-12px_rgba\(0\,0\,0\,0\.3\)\]{--tw-shadow:0 12px 40px -12px var(--tw-shadow-color,#0000004d);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-2{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-\[2\.5px\]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2.5px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-\[3px\]{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-accent,.ring-accent\/40{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.ring-accent\/40{--tw-ring-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}.ring-offset-2{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.ring-offset-bg{--tw-ring-offset-color:var(--color-bg)}.blur-3xl{--tw-blur:blur(var(--blur-3xl));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow-\[0_20px_40px_rgba\(0\,100\,212\,0\.28\)\]{--tw-drop-shadow-size:drop-shadow(0 20px 40px var(--tw-drop-shadow-color,#0064d447));--tw-drop-shadow:var(--tw-drop-shadow-size);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.drop-shadow-sm{--tw-drop-shadow-size:drop-shadow(0 1px 2px var(--tw-drop-shadow-color,#00000026));--tw-drop-shadow:drop-shadow(var(--drop-shadow-sm));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-\[2px\]{--tw-backdrop-blur:blur(2px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.will-change-transform{will-change:transform}.outline-none{--tw-outline-style:none;outline-style:none}.select-all{-webkit-user-select:all;user-select:all}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.placeholder\:text-muted::placeholder{color:var(--color-muted)}.odd\:bg-surface:nth-child(odd){background-color:var(--color-surface)}.focus-within\:border-fg\/20:focus-within{border-color:var(--color-fg)}@supports (color:color-mix(in lab, red, red)){.focus-within\:border-fg\/20:focus-within{border-color:color-mix(in oklab, var(--color-fg) 20%, transparent)}}.focus-within\:ring-2:focus-within{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-within\:ring-accent\/40:focus-within{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.focus-within\:ring-accent\/40:focus-within{--tw-ring-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}@media (hover:hover){.hover\:scale-\[1\.04\]:hover{scale:1.04}.hover\:bg-accent\/15:hover{background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-accent\/15:hover{background-color:color-mix(in oklab, var(--color-accent) 15%, transparent)}}.hover\:bg-border\/60:hover{background-color:var(--color-border)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-border\/60:hover{background-color:color-mix(in oklab, var(--color-border) 60%, transparent)}}.hover\:bg-rose-500\/10:hover{background-color:#ff23571a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-rose-500\/10:hover{background-color:color-mix(in oklab, var(--color-rose-500) 10%, transparent)}}.hover\:bg-surface:hover{background-color:var(--color-surface)}.hover\:bg-surface-2:hover,.hover\:bg-surface-2\/60:hover{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface-2\/60:hover{background-color:color-mix(in oklab, var(--color-surface-2) 60%, transparent)}}.hover\:bg-surface-2\/70:hover{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.hover\:bg-surface-2\/70:hover{background-color:color-mix(in oklab, var(--color-surface-2) 70%, transparent)}}.hover\:text-accent:hover{color:var(--color-accent)}.hover\:text-fg:hover{color:var(--color-fg)}.hover\:text-rose-500:hover{color:var(--color-rose-500)}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-90:hover{opacity:.9}.hover\:opacity-100:hover{opacity:1}.hover\:ring-2:hover{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:ring-accent:hover,.hover\:ring-accent\/60:hover{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.hover\:ring-accent\/60:hover{--tw-ring-color:color-mix(in oklab, var(--color-accent) 60%, transparent)}}}.focus\:border-accent:focus{border-color:var(--color-accent)}.focus\:bg-surface-2:focus{background-color:var(--color-surface-2)}.focus\:opacity-100:focus{opacity:1}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-accent\/40:focus{--tw-ring-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.focus\:ring-accent\/40:focus{--tw-ring-color:color-mix(in oklab, var(--color-accent) 40%, transparent)}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:scale-\[0\.98\]:active{scale:.98}.active\:scale-\[0\.99\]:active{scale:.99}.active\:bg-surface-2:active,.active\:bg-surface-2\/70:active{background-color:var(--color-surface-2)}@supports (color:color-mix(in lab, red, red)){.active\:bg-surface-2\/70:active{background-color:color-mix(in oklab, var(--color-surface-2) 70%, transparent)}}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.disabled\:opacity-60:disabled{opacity:.6}.disabled\:opacity-70:disabled{opacity:.7}@media (width>=40rem){.sm\:hidden{display:none}.sm\:max-w-lg{max-width:var(--container-lg)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:items-center{align-items:center}.sm\:justify-center{justify-content:center}.sm\:rounded-3xl{border-radius:var(--radius-3xl)}.sm\:rounded-\[28px\]{border-radius:28px}.sm\:border-x{border-inline-style:var(--tw-border-style);border-inline-width:1px}.sm\:border-border{border-color:var(--color-border)}}@container (width>=42rem){.\@2xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}.dark\:border:where([data-theme=dark],[data-theme=dark] *){border-style:var(--tw-border-style);border-width:1px}.dark\:border-border:where([data-theme=dark],[data-theme=dark] *){border-color:var(--color-border)}.dark\:bg-accent\/\[0\.10\]:where([data-theme=dark],[data-theme=dark] *){background-color:var(--color-accent)}@supports (color:color-mix(in lab, red, red)){.dark\:bg-accent\/\[0\.10\]:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-accent) 10%, transparent)}}.dark\:bg-amber-400\/\[0\.08\]:where([data-theme=dark],[data-theme=dark] *){background-color:#fcbb0014}@supports (color:color-mix(in lab, red, red)){.dark\:bg-amber-400\/\[0\.08\]:where([data-theme=dark],[data-theme=dark] *){background-color:color-mix(in oklab, var(--color-amber-400) 8%, transparent)}}.dark\:bg-\[radial-gradient\(ellipse_at_top\,rgba\(23\,147\,255\,0\.2\)\,transparent_65\%\)\]:where([data-theme=dark],[data-theme=dark] *){background-image:radial-gradient(at top,#1793ff33,#0000 65%)}.dark\:text-amber-200:where([data-theme=dark],[data-theme=dark] *){color:var(--color-amber-200)}.dark\:text-amber-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-amber-300)}.dark\:text-cyan-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-cyan-300)}.dark\:text-emerald-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-emerald-300)}.dark\:text-emerald-400:where([data-theme=dark],[data-theme=dark] *){color:var(--color-emerald-400)}.dark\:text-fuchsia-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-fuchsia-300)}.dark\:text-orange-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-orange-300)}.dark\:text-pink-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-pink-300)}.dark\:text-red-400:where([data-theme=dark],[data-theme=dark] *){color:var(--color-red-400)}.dark\:text-rose-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-rose-300)}.dark\:text-rose-400:where([data-theme=dark],[data-theme=dark] *){color:var(--color-rose-400)}.dark\:text-sky-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-sky-300)}.dark\:text-violet-300:where([data-theme=dark],[data-theme=dark] *){color:var(--color-violet-300)}.dark\:shadow-none:where([data-theme=dark],[data-theme=dark] *){--tw-shadow:0 0 #0000;box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (width>=64rem){.wide\:invisible{visibility:hidden}}:where(html.desktop) .wide\:invisible{visibility:hidden}@media (width>=64rem){.wide\:inset-y-0{inset-block:0}}:where(html.desktop) .wide\:inset-y-0{inset-block:0}@media (width>=64rem){.wide\:mt-0{margin-top:0}}:where(html.desktop) .wide\:mt-0{margin-top:0}@media (width>=64rem){.wide\:mt-5{margin-top:calc(var(--spacing) * 5)}}:where(html.desktop) .wide\:mt-5{margin-top:calc(var(--spacing) * 5)}@media (width>=64rem){.wide\:block{display:block}}:where(html.desktop) .wide\:block{display:block}@media (width>=64rem){.wide\:flex{display:flex}}:where(html.desktop) .wide\:flex{display:flex}@media (width>=64rem){.wide\:grid{display:grid}}:where(html.desktop) .wide\:grid{display:grid}@media (width>=64rem){.wide\:hidden{display:none}}:where(html.desktop) .wide\:hidden{display:none}@media (width>=64rem){.wide\:h-auto{height:auto}}:where(html.desktop) .wide\:h-auto{height:auto}@media (width>=64rem){.wide\:h-full{height:100%}}:where(html.desktop) .wide\:h-full{height:100%}@media (width>=64rem){.wide\:max-w-\[600px\]{max-width:600px}}:where(html.desktop) .wide\:max-w-\[600px\]{max-width:600px}@media (width>=64rem){.wide\:max-w-\[900px\]{max-width:900px}}:where(html.desktop) .wide\:max-w-\[900px\]{max-width:900px}@media (width>=64rem){.wide\:max-w-\[980px\]{max-width:980px}}:where(html.desktop) .wide\:max-w-\[980px\]{max-width:980px}@media (width>=64rem){.wide\:max-w-md{max-width:var(--container-md)}}:where(html.desktop) .wide\:max-w-md{max-width:var(--container-md)}@media (width>=64rem){.wide\:max-w-none{max-width:none}}:where(html.desktop) .wide\:max-w-none{max-width:none}@media (width>=64rem){.wide\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}}:where(html.desktop) .wide\:grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}@media (width>=64rem){.wide\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}:where(html.desktop) .wide\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}@media (width>=64rem){.wide\:grid-cols-\[1\.1fr_1fr\]{grid-template-columns:1.1fr 1fr}}:where(html.desktop) .wide\:grid-cols-\[1\.1fr_1fr\]{grid-template-columns:1.1fr 1fr}@media (width>=64rem){.wide\:flex-row{flex-direction:row}}:where(html.desktop) .wide\:flex-row{flex-direction:row}@media (width>=64rem){.wide\:items-center{align-items:center}}:where(html.desktop) .wide\:items-center{align-items:center}@media (width>=64rem){.wide\:items-start{align-items:flex-start}}:where(html.desktop) .wide\:items-start{align-items:flex-start}@media (width>=64rem){.wide\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}}:where(html.desktop) .wide\:gap-1\.5{gap:calc(var(--spacing) * 1.5)}@media (width>=64rem){.wide\:gap-16{gap:calc(var(--spacing) * 16)}}:where(html.desktop) .wide\:gap-16{gap:calc(var(--spacing) * 16)}@media (width>=64rem){.wide\:border-0{border-style:var(--tw-border-style);border-width:0}}:where(html.desktop) .wide\:border-0{border-style:var(--tw-border-style);border-width:0}@media (width>=64rem){.wide\:border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}}:where(html.desktop) .wide\:border-x-0{border-inline-style:var(--tw-border-style);border-inline-width:0}@media (width>=64rem){.wide\:bg-transparent{background-color:#0000}}:where(html.desktop) .wide\:bg-transparent{background-color:#0000}@media (width>=64rem){.wide\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(0\,100\,212\,0\.12\)\,transparent_60\%\)\]{background-image:radial-gradient(at 0,#0064d41f,#0000 60%)}}:where(html.desktop) .wide\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(0\,100\,212\,0\.12\)\,transparent_60\%\)\]{background-image:radial-gradient(at 0,#0064d41f,#0000 60%)}@media (width>=64rem){.wide\:p-7{padding:calc(var(--spacing) * 7)}}:where(html.desktop) .wide\:p-7{padding:calc(var(--spacing) * 7)}@media (width>=64rem){.wide\:px-0{padding-inline:0}}:where(html.desktop) .wide\:px-0{padding-inline:0}@media (width>=64rem){.wide\:px-12{padding-inline:calc(var(--spacing) * 12)}}:where(html.desktop) .wide\:px-12{padding-inline:calc(var(--spacing) * 12)}@media (width>=64rem){.wide\:py-1{padding-block:var(--spacing)}}:where(html.desktop) .wide\:py-1{padding-block:var(--spacing)}@media (width>=64rem){.wide\:pt-0{padding-top:0}}:where(html.desktop) .wide\:pt-0{padding-top:0}@media (width>=64rem){.wide\:text-left{text-align:left}}:where(html.desktop) .wide\:text-left{text-align:left}@media (width>=64rem){.wide\:text-\[13\.5px\]{font-size:13.5px}}:where(html.desktop) .wide\:text-\[13\.5px\]{font-size:13.5px}@media (width>=64rem){.wide\:text-\[15\.5px\]{font-size:15.5px}}:where(html.desktop) .wide\:text-\[15\.5px\]{font-size:15.5px}@media (width>=64rem){.wide\:text-\[34px\]{font-size:34px}}:where(html.desktop) .wide\:text-\[34px\]{font-size:34px}@media (width>=64rem){.wide\:dark\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(23\,147\,255\,0\.16\)\,transparent_60\%\)\]:where([data-theme=dark],[data-theme=dark] *){background-image:radial-gradient(at 0,#1793ff29,#0000 60%)}}:where(html.desktop) .wide\:dark\:bg-\[radial-gradient\(ellipse_at_left\,rgba\(23\,147\,255\,0\.16\)\,transparent_60\%\)\]:where([data-theme=dark],[data-theme=dark] *){background-image:radial-gradient(at 0,#1793ff29,#0000 60%)}.\[\&\+\&\]\:mt-1\.5+.\[\&\+\&\]\:mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}}@font-face{font-family:Figtree;font-style:normal;font-weight:300 900;font-display:swap;src:url(/fonts/figtree-latin.woff2)format("woff2");unicode-range:U+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Figtree;font-style:normal;font-weight:300 900;font-display:swap;src:url(/fonts/figtree-latin-ext.woff2)format("woff2");unicode-range:U+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}:root{--om-bg:#fcfcfc;--om-surface:#fff;--om-surface-2:#f2f2f4;--om-border:#e7e7ea;--om-fg:#111112;--om-muted:#6b6b71;--om-accent:#0064d4;--om-bubble-user:#d8e9ff;--om-bubble-user-fg:#111112;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light}html[data-theme=dark]{--om-bg:#181819;--om-surface:#202022;--om-surface-2:#2a2a2d;--om-border:#333337;--om-fg:#fff;--om-muted:#9d9da4;--om-accent:#1793ff;--om-bubble-user:#1c3d66;--om-bubble-user-fg:#fff;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}html,body,#root{height:100%}body{background:var(--om-bg);color:var(--om-fg);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent;overscroll-behavior-y:none;margin:0}.safe-top{padding-top:env(safe-area-inset-top)}.titlebar-room{display:none}html.desktop[data-platform=darwin] .titlebar-room{-webkit-app-region:drag;flex-shrink:0;height:28px;display:block}.safe-bottom{padding-bottom:env(safe-area-inset-bottom)}*{scrollbar-width:thin;scrollbar-color:var(--om-border) transparent}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-thumb{background:var(--om-border);border-radius:999px}::-webkit-scrollbar-track{background:0 0}@media (pointer:coarse),(width<=480px){*{scrollbar-width:none}::-webkit-scrollbar{display:none}}.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{scrollbar-width:none}.md>:first-child{margin-top:0}.md>:last-child{margin-bottom:0}.md p{margin:.35em 0}.md ul,.md ol{margin:.35em 0;padding-left:1.3em}.md ul{list-style:outside}.md ol{list-style:decimal}.md li{margin:.15em 0}.md h1,.md h2,.md h3,.md h4{margin:.7em 0 .3em;font-weight:650;line-height:1.3}.md h1{font-size:1.15em}.md h2{font-size:1.08em}.md h3,.md h4{font-size:1em}.md a{color:var(--om-accent);text-underline-offset:2px;word-break:break-all;text-decoration:underline}.bubble-user .md a{color:var(--om-accent)}.md code{background:#7f7fa029;border-radius:6px;padding:.1em .35em;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em}.md pre{color:#eaeaf5;background:#0f0f19e6;border-radius:12px;margin:.5em 0;padding:.7em .85em;font-size:.82em;line-height:1.45;overflow-x:auto}.md pre code{font-size:inherit;background:0 0;padding:0}.md .md-file{color:var(--om-accent);background:var(--om-accent);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.86em}@supports (color:color-mix(in lab, red, red)){.md .md-file{background:color-mix(in srgb, var(--om-accent) 12%, transparent)}}.md .md-file{text-underline-offset:2px;word-break:break-all;border-radius:6px;padding:.1em .4em;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.md blockquote{border-left:3px solid var(--om-border);color:var(--om-muted);margin:.5em 0;padding-left:.8em}.md table{border-collapse:collapse;max-width:100%;margin:.5em 0;font-size:.9em;display:block;overflow-x:auto}.md th,.md td{border:1px solid var(--om-border);text-align:left;padding:.3em .55em}.md hr{border:0;border-top:1px solid var(--om-border);margin:.7em 0}.md img{border-radius:10px;max-width:100%}@keyframes om-breathe{0%,to{transform:translateY(0)scale(1)}50%{transform:translateY(-1.5%)scale(1.025)}}@keyframes om-sway{0%,to{transform:rotate(-4deg)translateY(0)}25%{transform:rotate(0)translateY(-2%)}50%{transform:rotate(4deg)translateY(0)}75%{transform:rotate(0)translateY(-2%)}}@keyframes om-hop{0%,70%,to{transform:translateY(0)scale(1)}78%{transform:translateY(-9%)scale(1.04,.97)}86%{transform:translateY(0)scale(.98,1.03)}92%{transform:translateY(-3%)scale(1)}}@keyframes om-wiggle{0%,to{transform:rotate(0)}20%{transform:rotate(-10deg)scale(1.06)}45%{transform:rotate(9deg)scale(1.06)}70%{transform:rotate(-5deg)}}.avatar-idle{animation:4.5s ease-in-out infinite om-breathe}.avatar-working{animation:2.6s ease-in-out infinite om-sway}.avatar-waiting{animation:3.2s ease-in-out infinite om-hop}.avatar-wiggle{animation:.6s ease-in-out om-wiggle}@keyframes om-pop{0%{transform:scale(.6)}70%{transform:scale(1.06)}to{transform:scale(1)}}.avatar-pop{animation:.55s cubic-bezier(.34,1.56,.64,1) om-pop}@media (prefers-reduced-motion:reduce){.avatar-idle,.avatar-working,.avatar-waiting,.avatar-wiggle,.avatar-pop{animation:none}}@keyframes om-blink{50%{opacity:.25}}.typing-dot{animation:1.1s infinite om-blink}.typing-dot:nth-child(2){animation-delay:.18s}.typing-dot:nth-child(3){animation-delay:.36s}@keyframes om-rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}.rise{animation:.18s ease-out om-rise}@keyframes om-sheet{0%{transform:translateY(100%)}to{transform:none}}.sheet-in{animation:.22s cubic-bezier(.2,.8,.2,1) om-sheet}@keyframes om-drawer-in{0%{transform:translate(-100%)}to{transform:none}}.drawer-in{animation:.2s ease-out om-drawer-in}@property --tw-border-spacing-x{syntax:"";inherits:false;initial-value:0}@property --tw-border-spacing-y{syntax:"";inherits:false;initial-value:0}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} diff --git a/nanomuse/server/static/assets/index-tYqPtwPX.js b/nanomuse/server/static/assets/index-tYqPtwPX.js new file mode 100644 index 00000000..db4803d1 --- /dev/null +++ b/nanomuse/server/static/assets/index-tYqPtwPX.js @@ -0,0 +1,44 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/AccountScreen-Bws27b-C.js","assets/MuseHeader-RC--uR8o.js","assets/StarNudge-Df6LQyo9.js","assets/external-link-TvxP_dKX.js","assets/Form-H3s9oTHq.js","assets/clapperboard-CuoVHGrc.js","assets/database-zap-Bce5E3JJ.js","assets/image-nIygWCIp.js","assets/BackBar-JEuC8Q_e.js","assets/AvatarStudioScreen-Dgqo0TEO.js","assets/refresh-cw-CxZ3ZdI3.js","assets/AvatarPicker-BCihUURa.js","assets/ChannelsScreen-D_7Ynsew.js","assets/CodingScreen-BLVF-Er8.js","assets/ConnectionsScreen-CuIVN07d.js","assets/plug-DV_c5_Ny.js","assets/CloudCard-D5foJm1I.js","assets/DevicesScreen-BkPa89LS.js","assets/GoalsScreen-yUUdUKV2.js","assets/IdeasScreen-CmecfnoP.js","assets/LibraryScreen-BoofKOos.js","assets/MemoryScreen-DHNxjNpq.js","assets/Onboarding-DBBDNzyW.js","assets/IdentityForm-g-zvQ2IC.js","assets/SettingsScreen-B84xmoRg.js","assets/SkillsScreen-DsMpJe1f.js"])))=>i.map(i=>d[i]); +import{A as e,B as t,C as n,D as r,E as i,I as a,J as o,K as s,L as c,M as l,N as u,O as d,P as f,Q as p,R as m,S as h,T as g,U as _,V as v,W as y,X as b,Y as x,Z as S,b as C,c as w,d as T,f as E,i as D,j as O,n as k,p as ee,q as A,r as te,s as j,t as ne,u as re,x as M,y as ie,z as N}from"./MuseHeader-RC--uR8o.js";import{A as ae,E as oe,M as se,N as ce,S as le,T as ue,_ as de,a as fe,b as pe,c as me,i as he,j as ge,k as _e,l as ve,m as ye,n as be,o as xe,r as Se,s as Ce,t as we,u as Te,v as Ee,x as De,y as Oe}from"./StarNudge-Df6LQyo9.js";import{n as ke,t as Ae}from"./external-link-TvxP_dKX.js";import{a as je,c as Me,i as Ne,n as Pe,o as Fe,r as Ie,s as Le}from"./Form-H3s9oTHq.js";import{t as Re}from"./refresh-cw-CxZ3ZdI3.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var ze=b((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=typeof setTimeout==`function`?setTimeout:null,_=typeof clearTimeout==`function`?clearTimeout:null,v=typeof setImmediate<`u`?setImmediate:null;typeof navigator<`u`&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function y(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function b(e){if(h=!1,y(e),!m){if(n(c)!==null)m=!0,te(x);else{var t=n(l);t!==null&&j(b,t.startTime-e)}}}function x(t,i){m=!1,h&&(h=!1,_(w),w=-1),p=!0;var a=f;try{for(y(i),d=n(c);d!==null&&(!(d.expirationTime>i)||t&&!D());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=i);i=e.unstable_now(),typeof s==`function`?d.callback=s:d===n(c)&&r(c),y(i)}else r(c);d=n(c)}if(d!==null)var u=!0;else{var g=n(l);g!==null&&j(b,g.startTime-i),u=!1}return u}finally{d=null,f=a,p=!1}}var S=!1,C=null,w=-1,T=5,E=-1;function D(){return!(e.unstable_now()-Ee||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(_(w),w=-1):h=!0,j(b,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,te(x))),r},e.unstable_shouldYield=D,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),Be=b(((e,t)=>{t.exports=ze()})),Ve=b((e=>{var t=x(),n=Be();function r(e){for(var t=`https://reactjs.org/docs/error-decoder.html?invariant=`+e,n=1;nt}return!1}function g(e,t,n,r,i,a,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=i,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=a,this.removeEmptyString=o}var _={};`children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style`.split(` `).forEach(function(e){_[e]=new g(e,0,!1,e,null,!1,!1)}),[[`acceptCharset`,`accept-charset`],[`className`,`class`],[`htmlFor`,`for`],[`httpEquiv`,`http-equiv`]].forEach(function(e){var t=e[0];_[t]=new g(t,1,!1,e[1],null,!1,!1)}),[`contentEditable`,`draggable`,`spellCheck`,`value`].forEach(function(e){_[e]=new g(e,2,!1,e.toLowerCase(),null,!1,!1)}),[`autoReverse`,`externalResourcesRequired`,`focusable`,`preserveAlpha`].forEach(function(e){_[e]=new g(e,2,!1,e,null,!1,!1)}),`allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope`.split(` `).forEach(function(e){_[e]=new g(e,3,!1,e.toLowerCase(),null,!1,!1)}),[`checked`,`multiple`,`muted`,`selected`].forEach(function(e){_[e]=new g(e,3,!0,e,null,!1,!1)}),[`capture`,`download`].forEach(function(e){_[e]=new g(e,4,!1,e,null,!1,!1)}),[`cols`,`rows`,`size`,`span`].forEach(function(e){_[e]=new g(e,6,!1,e,null,!1,!1)}),[`rowSpan`,`start`].forEach(function(e){_[e]=new g(e,5,!1,e.toLowerCase(),null,!1,!1)});var v=/[\-:]([a-z])/g;function y(e){return e[1].toUpperCase()}`accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height`.split(` `).forEach(function(e){var t=e.replace(v,y);_[t]=new g(t,1,!1,e,null,!1,!1)}),`xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type`.split(` `).forEach(function(e){var t=e.replace(v,y);_[t]=new g(t,1,!1,e,`http://www.w3.org/1999/xlink`,!1,!1)}),[`xml:base`,`xml:lang`,`xml:space`].forEach(function(e){var t=e.replace(v,y);_[t]=new g(t,1,!1,e,`http://www.w3.org/XML/1998/namespace`,!1,!1)}),[`tabIndex`,`crossOrigin`].forEach(function(e){_[e]=new g(e,1,!1,e.toLowerCase(),null,!1,!1)}),_.xlinkHref=new g(`xlinkHref`,1,!1,`xlink:href`,`http://www.w3.org/1999/xlink`,!0,!1),[`src`,`href`,`action`,`formAction`].forEach(function(e){_[e]=new g(e,1,!1,e.toLowerCase(),null,!0,!0)});function b(e,t,n,r){var i=_.hasOwnProperty(t)?_[t]:null;(i===null?r||!(2s||i[o]!==a[s]){var c=` +`+i[o].replace(` at new `,` at `);return e.displayName&&c.includes(``)&&(c=c.replace(``,e.displayName)),c}while(1<=o&&0<=s);break}}}finally{se=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:``)?oe(e):``}function le(e){switch(e.tag){case 5:return oe(e.type);case 16:return oe(`Lazy`);case 13:return oe(`Suspense`);case 19:return oe(`SuspenseList`);case 0:case 2:case 15:return e=ce(e.type,!1),e;case 11:return e=ce(e.type.render,!1),e;case 1:return e=ce(e.type,!0),e;default:return``}}function ue(e){if(e==null)return null;if(typeof e==`function`)return e.displayName||e.name||null;if(typeof e==`string`)return e;switch(e){case T:return`Fragment`;case w:return`Portal`;case D:return`Profiler`;case E:return`StrictMode`;case A:return`Suspense`;case te:return`SuspenseList`}if(typeof e==`object`)switch(e.$$typeof){case k:return(e.displayName||`Context`)+`.Consumer`;case O:return(e._context.displayName||`Context`)+`.Provider`;case ee:var t=e.render;return e=e.displayName,e||=(e=t.displayName||t.name||``,e===``?`ForwardRef`:`ForwardRef(`+e+`)`),e;case j:return t=e.displayName||null,t===null?ue(e.type)||`Memo`:t;case ne:t=e._payload,e=e._init;try{return ue(e(t))}catch{}}return null}function de(e){var t=e.type;switch(e.tag){case 24:return`Cache`;case 9:return(t.displayName||`Context`)+`.Consumer`;case 10:return(t._context.displayName||`Context`)+`.Provider`;case 18:return`DehydratedFragment`;case 11:return e=t.render,e=e.displayName||e.name||``,t.displayName||(e===``?`ForwardRef`:`ForwardRef(`+e+`)`);case 7:return`Fragment`;case 5:return t;case 4:return`Portal`;case 3:return`Root`;case 6:return`Text`;case 16:return ue(t);case 8:return t===E?`StrictMode`:`Mode`;case 22:return`Offscreen`;case 12:return`Profiler`;case 21:return`Scope`;case 13:return`Suspense`;case 19:return`SuspenseList`;case 25:return`TracingMarker`;case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t==`function`)return t.displayName||t.name||null;if(typeof t==`string`)return t}return null}function fe(e){switch(typeof e){case`boolean`:case`number`:case`string`:case`undefined`:return e;case`object`:return e;default:return``}}function pe(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()===`input`&&(t===`checkbox`||t===`radio`)}function me(e){var t=pe(e)?`checked`:`value`,n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=``+e[t];if(!e.hasOwnProperty(t)&&n!==void 0&&typeof n.get==`function`&&typeof n.set==`function`){var i=n.get,a=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return i.call(this)},set:function(e){r=``+e,a.call(this,e)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(e){r=``+e},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function he(e){e._valueTracker||=me(e)}function ge(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r=``;return e&&(r=pe(e)?e.checked?`true`:`false`:e.value),e=r,e!==n&&(t.setValue(e),!0)}function _e(e){if(e||=typeof document<`u`?document:void 0,e===void 0)return null;try{return e.activeElement||e.body}catch{return e.body}}function ve(e,t){var n=t.checked;return N({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ye(e,t){var n=t.defaultValue==null?``:t.defaultValue,r=t.checked==null?t.defaultChecked:t.checked;n=fe(t.value==null?n:t.value),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type===`checkbox`||t.type===`radio`?t.checked!=null:t.value!=null}}function be(e,t){t=t.checked,t!=null&&b(e,`checked`,t,!1)}function xe(e,t){be(e,t);var n=fe(t.value),r=t.type;if(n!=null)r===`number`?(n===0&&e.value===``||e.value!=n)&&(e.value=``+n):e.value!==``+n&&(e.value=``+n);else if(r===`submit`||r===`reset`){e.removeAttribute(`value`);return}t.hasOwnProperty(`value`)?Ce(e,t.type,n):t.hasOwnProperty(`defaultValue`)&&Ce(e,t.type,fe(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Se(e,t,n){if(t.hasOwnProperty(`value`)||t.hasOwnProperty(`defaultValue`)){var r=t.type;if(!(r!==`submit`&&r!==`reset`||t.value!==void 0&&t.value!==null))return;t=``+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==``&&(e.name=``),e.defaultChecked=!!e._wrapperState.initialChecked,n!==``&&(e.name=n)}function Ce(e,t,n){(t!==`number`||_e(e.ownerDocument)!==e)&&(n==null?e.defaultValue=``+e._wrapperState.initialValue:e.defaultValue!==``+n&&(e.defaultValue=``+n))}var we=Array.isArray;function Te(e,t,n,r){if(e=e.options,t){t={};for(var i=0;i`+t.valueOf().toString()+``,t=Me.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Pe(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Fe={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Ie=[`Webkit`,`ms`,`Moz`,`O`];Object.keys(Fe).forEach(function(e){Ie.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Fe[t]=Fe[e]})});function Le(e,t,n){return t==null||typeof t==`boolean`||t===``?``:n||typeof t!=`number`||t===0||Fe.hasOwnProperty(e)&&Fe[e]?(``+t).trim():t+`px`}function Re(e,t){for(var n in e=e.style,t)if(t.hasOwnProperty(n)){var r=n.indexOf(`--`)===0,i=Le(n,t[n],r);n===`float`&&(n=`cssFloat`),r?e.setProperty(n,i):e[n]=i}}var ze=N({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function Ve(e,t){if(t){if(ze[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(r(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(r(60));if(typeof t.dangerouslySetInnerHTML!=`object`||!(`__html`in t.dangerouslySetInnerHTML))throw Error(r(61))}if(t.style!=null&&typeof t.style!=`object`)throw Error(r(62))}}function He(e,t){if(e.indexOf(`-`)===-1)return typeof t.is==`string`;switch(e){case`annotation-xml`:case`color-profile`:case`font-face`:case`font-face-src`:case`font-face-uri`:case`font-face-format`:case`font-face-name`:case`missing-glyph`:return!1;default:return!0}}var Ue=null;function We(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var Ge=null,Ke=null,qe=null;function Je(e){if(e=Li(e)){if(typeof Ge!=`function`)throw Error(r(280));var t=e.stateNode;t&&(t=zi(t),Ge(e.stateNode,e.type,t))}}function Ye(e){Ke?qe?qe.push(e):qe=[e]:Ke=e}function Xe(){if(Ke){var e=Ke,t=qe;if(qe=Ke=null,Je(e),t)for(e=0;e>>=0,e===0?32:31-(Mt(e)/Nt|0)|0}var Ft=64,It=4194304;function Lt(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Rt(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,i=e.suspendedLanes,a=e.pingedLanes,o=n&268435455;if(o!==0){var s=o&~i;s===0?(a&=o,a!==0&&(r=Lt(a))):r=Lt(s)}else o=n&~i,o===0?a!==0&&(r=Lt(a)):r=Lt(o);if(r===0)return 0;if(t!==0&&t!==r&&(t&i)===0&&(i=r&-r,a=t&-t,i>=a||i===16&&a&4194240))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Wt(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-jt(t),e[t]=n}function Gt(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=tr),ir=` `,ar=!1;function or(e,t){switch(e){case`keyup`:return $n.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function sr(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var cr=!1;function lr(e,t){switch(e){case`compositionend`:return sr(t);case`keypress`:return t.which===32?(ar=!0,ir):null;case`textInput`:return e=t.data,e===ir&&ar?null:e;default:return null}}function ur(e,t){if(cr)return e===`compositionend`||!er&&or(e,t)?(e=Dn(),En=Tn=wn=null,cr=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=Mr(n)}}function Pr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Pr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Fr(){for(var e=window,t=_e();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=_e(e.document)}return t}function Ir(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}function Lr(e){var t=Fr(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Pr(n.ownerDocument.documentElement,n)){if(r!==null&&Ir(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),`selectionStart`in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var i=n.textContent.length,a=Math.min(r.start,i);r=r.end===void 0?a:Math.min(r.end,i),!e.extend&&a>r&&(i=r,r=a,a=i),i=Nr(n,a);var o=Nr(n,r);i&&o&&(e.rangeCount!==1||e.anchorNode!==i.node||e.anchorOffset!==i.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(i.node,i.offset),e.removeAllRanges(),a>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus==`function`&&n.focus(),n=0;n=document.documentMode,zr=null,Br=null,Vr=null,Hr=!1;function Ur(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Hr||zr==null||zr!==_e(r)||(r=zr,`selectionStart`in r&&Ir(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Vr&&jr(Vr,r)||(Vr=r,r=mi(Br,`onSelect`),0Vi||(e.current=Bi[Vi],Bi[Vi]=null,Vi--)}function q(e,t){Vi++,Bi[Vi]=e.current,e.current=t}var Ui={},Wi=Hi(Ui),Gi=Hi(!1),Ki=Ui;function qi(e,t){var n=e.type.contextTypes;if(!n)return Ui;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var i={},a;for(a in n)i[a]=t[a];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=i),i}function Ji(e){return e=e.childContextTypes,e!=null}function Yi(){K(Gi),K(Wi)}function Xi(e,t,n){if(Wi.current!==Ui)throw Error(r(168));q(Wi,t),q(Gi,n)}function Zi(e,t,n){var i=e.stateNode;if(t=t.childContextTypes,typeof i.getChildContext!=`function`)return n;for(var a in i=i.getChildContext(),i)if(!(a in t))throw Error(r(108,de(e)||`Unknown`,a));return N({},n,i)}function Qi(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||Ui,Ki=Wi.current,q(Wi,e),q(Gi,Gi.current),!0}function $i(e,t,n){var i=e.stateNode;if(!i)throw Error(r(169));n?(e=Zi(e,t,Ki),i.__reactInternalMemoizedMergedChildContext=e,K(Gi),K(Wi),q(Wi,e)):K(Gi),q(Gi,n)}var ea=null,ta=!1,na=!1;function ra(e){ea===null?ea=[e]:ea.push(e)}function ia(e){ta=!0,ra(e)}function aa(){if(!na&&ea!==null){na=!0;var e=0,t=F;try{var n=ea;for(F=1;e>=o,i-=o,pa=1<<32-jt(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(r,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(r,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(r,d),J&&ha(r,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),J&&ha(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return J&&ha(a,g),u}for(h=i(a,h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),J&&ha(a,g),u}function _(e,r,i,o){if(typeof i==`object`&&i&&i.type===T&&i.key===null&&(i=i.props.children),typeof i==`object`&&i){switch(i.$$typeof){case C:a:{for(var c=i.key,l=r;l!==null;){if(l.key===c){if(c=i.type,c===T){if(l.tag===7){n(e,l.sibling),r=a(l,i.props.children),r.return=e,e=r;break a}}else if(l.elementType===c||typeof c==`object`&&c&&c.$$typeof===ne&&Pa(c)===l.type){n(e,l.sibling),r=a(l,i.props),r.ref=Ma(e,l,i),r.return=e,e=r;break a}n(e,l);break}t(e,l),l=l.sibling}i.type===T?(r=Zl(i.props.children,e.mode,o,i.key),r.return=e,e=r):(o=Xl(i.type,i.key,i.props,null,e.mode,o),o.ref=Ma(e,r,i),o.return=e,e=o)}return s(e);case w:a:{for(l=i.key;r!==null;){if(r.key===l){if(r.tag===4&&r.stateNode.containerInfo===i.containerInfo&&r.stateNode.implementation===i.implementation){n(e,r.sibling),r=a(r,i.children||[]),r.return=e,e=r;break a}n(e,r);break}t(e,r),r=r.sibling}r=eu(i,e.mode,o),r.return=e,e=r}return s(e);case ne:return l=i._init,_(e,r,l(i._payload),o)}if(we(i))return h(e,r,i,o);if(ie(i))return g(e,r,i,o);Na(e,i)}return typeof i==`string`&&i!==``||typeof i==`number`?(i=``+i,r!==null&&r.tag===6?(n(e,r.sibling),r=a(r,i),r.return=e,e=r):(n(e,r),r=$l(i,e.mode,o),r.return=e,e=r),s(e)):n(e,r)}return _}var Ia=Fa(!0),La=Fa(!1),Ra=Hi(null),za=null,Ba=null,Va=null;function Ha(){Va=Ba=za=null}function Ua(e){var t=Ra.current;K(Ra),e._currentValue=t}function Wa(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)===t?r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t):(e.childLanes|=t,r!==null&&(r.childLanes|=t)),e===n)break;e=e.return}}function Ga(e,t){za=e,Va=Ba=null,e=e.dependencies,e!==null&&e.firstContext!==null&&((e.lanes&t)!==0&&(Ns=!0),e.firstContext=null)}function Ka(e){var t=e._currentValue;if(Va!==e){if(e={context:e,memoizedValue:t,next:null},Ba===null){if(za===null)throw Error(r(308));Ba=e,za.dependencies={lanes:0,firstContext:e}}else Ba=Ba.next=e}return t}var qa=null;function Ja(e){qa===null?qa=[e]:qa.push(e)}function Ya(e,t,n,r){var i=t.interleaved;return i===null?(n.next=n,Ja(t)):(n.next=i.next,i.next=n),t.interleaved=n,Xa(e,r)}function Xa(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var Za=!1;function Qa(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function $a(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function eo(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function to(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Q&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,Xa(e,n)}return i=r.interleaved,i===null?(t.next=t,Ja(r)):(t.next=i.next,i.next=t),r.interleaved=t,Xa(e,n)}function no(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194240)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Kt(e,n)}}function ro(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function io(e,t,n,r){var i=e.updateQueue;Za=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane,p=s.eventTime;if((r&f)===f){u!==null&&(u=u.next={eventTime:p,lane:0,tag:s.tag,payload:s.payload,callback:s.callback,next:null});a:{var m=e,h=s;switch(f=t,p=n,h.tag){case 1:if(m=h.payload,typeof m==`function`){d=m.call(p,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=h.payload,f=typeof m==`function`?m.call(p,d,f):m,f==null)break a;d=N({},d,f);break a;case 2:Za=!0}}s.callback!==null&&s.lane!==0&&(e.flags|=64,f=i.effects,f===null?i.effects=[s]:f.push(s))}else p={eventTime:p,lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;f=s,s=f.next,f.next=null,i.lastBaseUpdate=f,i.shared.pending=null}}while(1);if(u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,t=i.shared.interleaved,t!==null){i=t;do o|=i.lane,i=i.next;while(i!==t)}else a===null&&(i.shared.lanes=0);Yc|=o,e.lanes=o,e.memoizedState=d}}function ao(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=bo.transition;bo.transition={};try{e(!1),t()}finally{F=n,bo.transition=r}}function os(){return No().memoizedState}function ss(e,t,n){var r=ml(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},ls(e))us(t,n);else if(n=Ya(e,t,n,r),n!==null){var i=pl();hl(n,e,r,i),ds(n,t,r)}}function cs(e,t,n){var r=ml(e),i={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(ls(e))us(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,Ar(s,o)){var c=t.interleaved;c===null?(i.next=i,Ja(t)):(i.next=c.next,c.next=i),t.interleaved=i;return}}catch{}n=Ya(e,t,i,r),n!==null&&(i=pl(),hl(n,e,r,i),ds(n,t,r))}}function ls(e){var t=e.alternate;return e===X||t!==null&&t===X}function us(e,t){To=wo=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function ds(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Kt(e,n)}}var fs={readContext:Ka,useCallback:Oo,useContext:Oo,useEffect:Oo,useImperativeHandle:Oo,useInsertionEffect:Oo,useLayoutEffect:Oo,useMemo:Oo,useReducer:Oo,useRef:Oo,useState:Oo,useDebugValue:Oo,useDeferredValue:Oo,useTransition:Oo,useMutableSource:Oo,useSyncExternalStore:Oo,useId:Oo,unstable_isNewReconciler:!1},ps={readContext:Ka,useCallback:function(e,t){return Mo().memoizedState=[e,t===void 0?null:t],e},useContext:Ka,useEffect:Yo,useImperativeHandle:function(e,t,n){return n=n==null?null:n.concat([e]),qo(4194308,4,$o.bind(null,t,e),n)},useLayoutEffect:function(e,t){return qo(4194308,4,e,t)},useInsertionEffect:function(e,t){return qo(4,2,e,t)},useMemo:function(e,t){var n=Mo();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Mo();return t=n===void 0?t:n(t),r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=ss.bind(null,X,e),[r.memoizedState,e]},useRef:function(e){var t=Mo();return e={current:e},t.memoizedState=e},useState:Wo,useDebugValue:ts,useDeferredValue:function(e){return Mo().memoizedState=e},useTransition:function(){var e=Wo(!1),t=e[0];return e=as.bind(null,e[1]),Mo().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var i=X,a=Mo();if(J){if(n===void 0)throw Error(r(407));n=n()}else{if(n=t(),Hc===null)throw Error(r(349));xo&30||zo(i,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Yo(Vo.bind(null,i,o,e),[e]),i.flags|=2048,Go(9,Bo.bind(null,i,o,n,t),void 0,null),n},useId:function(){var e=Mo(),t=Hc.identifierPrefix;if(J){var n=ma,r=pa;n=(r&~(1<<32-jt(r)-1)).toString(32)+n,t=`:`+t+`R`+n,n=Eo++,0<\/script>`,e=e.removeChild(e.firstChild)):typeof i.is==`string`?e=c.createElement(n,{is:i.is}):(e=c.createElement(n),n===`select`&&(c=e,i.multiple?c.multiple=!0:i.size&&(c.size=i.size))):e=c.createElementNS(e,n),e[ji]=t,e[Mi]=i,rc(e,t,!1,!1),t.stateNode=e;a:{switch(c=He(n,i),n){case`dialog`:V(`cancel`,e),V(`close`,e),o=i;break;case`iframe`:case`object`:case`embed`:V(`load`,e),o=i;break;case`video`:case`audio`:for(o=0;otl&&(t.flags|=128,i=!0,oc(s,!1),t.lanes=4194304)}}else{if(!i){if(e=go(c),e!==null){if(t.flags|=128,i=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),oc(s,!0),s.tail===null&&s.tailMode===`hidden`&&!c.alternate&&!J)return sc(t),null}else 2*P()-s.renderingStartTime>tl&&n!==1073741824&&(t.flags|=128,i=!0,oc(s,!1),t.lanes=4194304)}s.isBackwards?(c.sibling=t.child,t.child=c):(n=s.last,n===null?t.child=c:n.sibling=c,s.last=c)}return s.tail===null?(sc(t),null):(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=P(),t.sibling=null,n=Y.current,q(Y,i?n&1|2:n&1),t);case 22:case 23:return Tl(),i=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==i&&(t.flags|=8192),i&&t.mode&1?Gc&1073741824&&(sc(t),t.subtreeFlags&6&&(t.flags|=8192)):sc(t),null;case 24:return null;case 25:return null}throw Error(r(156,t.tag))}function lc(e,t){switch(va(t),t.tag){case 1:return Ji(t.type)&&Yi(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return po(),K(Gi),K(Wi),vo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return ho(t),null;case 13:if(K(Y),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(r(340));ka()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return K(Y),null;case 4:return po(),null;case 10:return Ua(t.type._context),null;case 22:case 23:return Tl(),null;case 24:return null;default:return null}}var uc=!1,dc=!1,fc=typeof WeakSet==`function`?WeakSet:Set,Z=null;function pc(e,t){var n=e.ref;if(n!==null){if(typeof n==`function`)try{n(null)}catch(n){$(e,t,n)}else n.current=null}}function mc(e,t,n){try{n()}catch(n){$(e,t,n)}}var hc=!1;function gc(e,t){if(Si=yn,e=Fr(),Ir(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var i=n.getSelection&&n.getSelection();if(i&&i.rangeCount!==0){n=i.anchorNode;var a=i.anchorOffset,o=i.focusNode;i=i.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||i!==0&&f.nodeType!==3||(l=s+i),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===i&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Ci={focusedElem:e,selectionRange:n},yn=!1,Z=t;Z!==null;)if(t=Z,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Z=e;else for(;Z!==null;){t=Z;try{var h=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(h!==null){var g=h.memoizedProps,_=h.memoizedState,v=t.stateNode;v.__reactInternalSnapshotBeforeUpdate=v.getSnapshotBeforeUpdate(t.elementType===t.type?g:gs(t.type,g),_)}break;case 3:var y=t.stateNode.containerInfo;y.nodeType===1?y.textContent=``:y.nodeType===9&&y.documentElement&&y.removeChild(y.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(r(163))}}catch(e){$(t,t.return,e)}if(e=t.sibling,e!==null){e.return=t.return,Z=e;break}Z=t.return}return h=hc,hc=!1,h}function _c(e,t,n){var r=t.updateQueue;if(r=r===null?null:r.lastEffect,r!==null){var i=r=r.next;do{if((i.tag&e)===e){var a=i.destroy;i.destroy=void 0,a!==void 0&&mc(t,n,a)}i=i.next}while(i!==r)}}function vc(e,t){if(t=t.updateQueue,t=t===null?null:t.lastEffect,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function yc(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t==`function`?t(e):t.current=e}}function bc(e){var t=e.alternate;t!==null&&(e.alternate=null,bc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[ji],delete t[Mi],delete t[G],delete t[Pi],delete t[Fi])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function xc(e){return e.tag===5||e.tag===3||e.tag===4}function Sc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||xc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Cc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=xi));else if(r!==4&&(e=e.child,e!==null))for(Cc(e,t,n),e=e.sibling;e!==null;)Cc(e,t,n),e=e.sibling}function wc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(wc(e,t,n),e=e.sibling;e!==null;)wc(e,t,n),e=e.sibling}var Tc=null,Ec=!1;function Dc(e,t,n){for(n=n.child;n!==null;)Oc(e,t,n),n=n.sibling}function Oc(e,t,n){if(kt&&typeof kt.onCommitFiberUnmount==`function`)try{kt.onCommitFiberUnmount(Ot,n)}catch{}switch(n.tag){case 5:dc||pc(n,t);case 6:var r=Tc,i=Ec;Tc=null,Dc(e,t,n),Tc=r,Ec=i,Tc!==null&&(Ec?(e=Tc,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):Tc.removeChild(n.stateNode));break;case 18:Tc!==null&&(Ec?(e=Tc,n=n.stateNode,e.nodeType===8?H(e.parentNode,n):e.nodeType===1&&H(e,n),_n(e)):H(Tc,n.stateNode));break;case 4:r=Tc,i=Ec,Tc=n.stateNode.containerInfo,Ec=!0,Dc(e,t,n),Tc=r,Ec=i;break;case 0:case 11:case 14:case 15:if(!dc&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){i=r=r.next;do{var a=i,o=a.destroy;a=a.tag,o!==void 0&&(a&2||a&4)&&mc(n,t,o),i=i.next}while(i!==r)}Dc(e,t,n);break;case 1:if(!dc&&(pc(n,t),r=n.stateNode,typeof r.componentWillUnmount==`function`))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(e){$(n,t,e)}Dc(e,t,n);break;case 21:Dc(e,t,n);break;case 22:n.mode&1?(dc=(r=dc)||n.memoizedState!==null,Dc(e,t,n),dc=r):Dc(e,t,n);break;default:Dc(e,t,n)}}function kc(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new fc),t.forEach(function(t){var r=Hl.bind(null,e,t);n.has(t)||(n.add(t),t.then(r,r))})}}function Ac(e,t){var n=t.deletions;if(n!==null)for(var i=0;ia&&(a=s),i&=~o}if(i=a,i=P()-i,i=(120>i?120:480>i?480:1080>i?1080:1920>i?1920:3e3>i?3e3:4320>i?4320:1960*Rc(i/1960))-i,10e?16:e,sl===null)var i=!1;else{if(e=sl,sl=null,cl=0,Q&6)throw Error(r(331));var a=Q;for(Q|=4,Z=e.current;Z!==null;){var o=Z,s=o.child;if(Z.flags&16){var c=o.deletions;if(c!==null){for(var l=0;lP()-el?El(e,0):Zc|=n),gl(e,t)}function Bl(e,t){t===0&&(e.mode&1?(t=It,It<<=1,!(It&130023424)&&(It=4194304)):t=1);var n=pl();e=Xa(e,t),e!==null&&(Wt(e,t,n),gl(e,n))}function Vl(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Bl(e,n)}function Hl(e,t){var n=0;switch(e.tag){case 13:var i=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:i=e.stateNode;break;default:throw Error(r(314))}i!==null&&i.delete(t),Bl(e,n)}var Ul=function(e,t,n){if(e!==null){if(e.memoizedProps!==t.pendingProps||Gi.current)Ns=!0;else{if((e.lanes&n)===0&&!(t.flags&128))return Ns=!1,nc(e,t,n);Ns=!!(e.flags&131072)}}else Ns=!1,J&&t.flags&1048576&&ga(t,la,t.index);switch(t.lanes=0,t.tag){case 2:var i=t.type;ec(e,t),e=t.pendingProps;var a=qi(t,Wi.current);Ga(t,n),a=Ao(null,t,i,e,a,n);var o=jo();return t.flags|=1,typeof a==`object`&&a&&typeof a.render==`function`&&a.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,Ji(i)?(o=!0,Qi(t)):o=!1,t.memoizedState=a.state!==null&&a.state!==void 0?a.state:null,Qa(t),a.updater=vs,t.stateNode=a,a._reactInternals=t,Ss(t,i,e,n),t=Hs(null,t,i,!0,o,n)):(t.tag=0,J&&o&&_a(t),Ps(null,t,a,n),t=t.child),t;case 16:i=t.elementType;a:{switch(ec(e,t),e=t.pendingProps,a=i._init,i=a(i._payload),t.type=i,a=t.tag=Jl(i),e=gs(i,e),a){case 0:t=Bs(null,t,i,e,n);break a;case 1:t=Vs(null,t,i,e,n);break a;case 11:t=Fs(null,t,i,e,n);break a;case 14:t=Is(null,t,i,gs(i.type,e),n);break a}throw Error(r(306,i,``))}return t;case 0:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:gs(i,a),Bs(e,t,i,a,n);case 1:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:gs(i,a),Vs(e,t,i,a,n);case 3:a:{if(Us(t),e===null)throw Error(r(387));i=t.pendingProps,o=t.memoizedState,a=o.element,$a(e,t),io(t,i,null,n);var s=t.memoizedState;if(i=s.element,o.isDehydrated){if(o={element:i,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=o,t.memoizedState=o,t.flags&256){a=Cs(Error(r(423)),t),t=Ws(e,t,i,n,a);break a}if(i!==a){a=Cs(Error(r(424)),t),t=Ws(e,t,i,n,a);break a}for(ba=U(t.stateNode.containerInfo.firstChild),ya=t,J=!0,xa=null,n=La(t,null,i,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling}else{if(ka(),i===a){t=tc(e,t,n);break a}Ps(e,t,i,n)}t=t.child}return t;case 5:return mo(t),e===null&&Ta(t),i=t.type,a=t.pendingProps,o=e===null?null:e.memoizedProps,s=a.children,wi(i,a)?s=null:o!==null&&wi(i,o)&&(t.flags|=32),zs(e,t),Ps(e,t,s,n),t.child;case 6:return e===null&&Ta(t),null;case 13:return qs(e,t,n);case 4:return fo(t,t.stateNode.containerInfo),i=t.pendingProps,e===null?t.child=Ia(t,null,i,n):Ps(e,t,i,n),t.child;case 11:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:gs(i,a),Fs(e,t,i,a,n);case 7:return Ps(e,t,t.pendingProps,n),t.child;case 8:return Ps(e,t,t.pendingProps.children,n),t.child;case 12:return Ps(e,t,t.pendingProps.children,n),t.child;case 10:a:{if(i=t.type._context,a=t.pendingProps,o=t.memoizedProps,s=a.value,q(Ra,i._currentValue),i._currentValue=s,o!==null){if(Ar(o.value,s)){if(o.children===a.children&&!Gi.current){t=tc(e,t,n);break a}}else for(o=t.child,o!==null&&(o.return=t);o!==null;){var c=o.dependencies;if(c!==null){s=o.child;for(var l=c.firstContext;l!==null;){if(l.context===i){if(o.tag===1){l=eo(-1,n&-n),l.tag=2;var u=o.updateQueue;if(u!==null){u=u.shared;var d=u.pending;d===null?l.next=l:(l.next=d.next,d.next=l),u.pending=l}}o.lanes|=n,l=o.alternate,l!==null&&(l.lanes|=n),Wa(o.return,n,t),c.lanes|=n;break}l=l.next}}else if(o.tag===10)s=o.type===t.type?null:o.child;else if(o.tag===18){if(s=o.return,s===null)throw Error(r(341));s.lanes|=n,c=s.alternate,c!==null&&(c.lanes|=n),Wa(s,n,t),s=o.sibling}else s=o.child;if(s!==null)s.return=o;else for(s=o;s!==null;){if(s===t){s=null;break}if(o=s.sibling,o!==null){o.return=s.return,s=o;break}s=s.return}o=s}}Ps(e,t,a.children,n),t=t.child}return t;case 9:return a=t.type,i=t.pendingProps.children,Ga(t,n),a=Ka(a),i=i(a),t.flags|=1,Ps(e,t,i,n),t.child;case 14:return i=t.type,a=gs(i,t.pendingProps),a=gs(i.type,a),Is(e,t,i,a,n);case 15:return Ls(e,t,t.type,t.pendingProps,n);case 17:return i=t.type,a=t.pendingProps,a=t.elementType===i?a:gs(i,a),ec(e,t),t.tag=1,Ji(i)?(e=!0,Qi(t)):e=!1,Ga(t,n),bs(t,i,a),Ss(t,i,a,n),Hs(null,t,i,!0,e,n);case 19:return $s(e,t,n);case 22:return Rs(e,t,n)}throw Error(r(156,t.tag))};function Wl(e,t){return vt(e,t)}function Gl(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Kl(e,t,n,r){return new Gl(e,t,n,r)}function ql(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Jl(e){if(typeof e==`function`)return+!!ql(e);if(e!=null){if(e=e.$$typeof,e===ee)return 11;if(e===j)return 14}return 2}function Yl(e,t){var n=e.alternate;return n===null?(n=Kl(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Xl(e,t,n,i,a,o){var s=2;if(i=e,typeof e==`function`)ql(e)&&(s=1);else if(typeof e==`string`)s=5;else a:switch(e){case T:return Zl(n.children,a,o,t);case E:s=8,a|=8;break;case D:return e=Kl(12,n,t,a|2),e.elementType=D,e.lanes=o,e;case A:return e=Kl(13,n,t,a),e.elementType=A,e.lanes=o,e;case te:return e=Kl(19,n,t,a),e.elementType=te,e.lanes=o,e;case re:return Ql(n,a,o,t);default:if(typeof e==`object`&&e)switch(e.$$typeof){case O:s=10;break a;case k:s=9;break a;case ee:s=11;break a;case j:s=14;break a;case ne:s=16,i=null;break a}throw Error(r(130,e==null?e:typeof e,``))}return t=Kl(s,n,t,a),t.elementType=e,t.type=i,t.lanes=o,t}function Zl(e,t,n,r){return e=Kl(7,e,r,t),e.lanes=n,e}function Ql(e,t,n,r){return e=Kl(22,e,r,t),e.elementType=re,e.lanes=n,e.stateNode={isHidden:!1},e}function $l(e,t,n){return e=Kl(6,e,null,t),e.lanes=n,e}function eu(e,t,n){return t=Kl(4,e.children===null?[]:e.children,e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function tu(e,t,n,r,i){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ut(0),this.expirationTimes=Ut(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ut(0),this.identifierPrefix=r,this.onRecoverableError=i,this.mutableSourceEagerHydrationData=null}function nu(e,t,n,r,i,a,o,s,c){return e=new tu(e,t,n,s,c),t===1?(t=1,!0===a&&(t|=8)):t=0,a=Kl(3,null,null,t),e.current=a,a.stateNode=e,a.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Qa(a),e}function ru(e,t,n){var r=3{function n(){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<`u`&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE==`function`)try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=Ve()})),Ue=b((e=>{var t=He();e.createRoot=t.createRoot,e.hydrateRoot=t.hydrateRoot})),We=o(`alarm-clock`,[[`circle`,{cx:`12`,cy:`13`,r:`8`,key:`3y4lt7`}],[`path`,{d:`M12 9v4l2 2`,key:`1c63tq`}],[`path`,{d:`M5 3 2 6`,key:`18tl5t`}],[`path`,{d:`m22 6-3-3`,key:`1opdir`}],[`path`,{d:`M6.38 18.7 4 21`,key:`17xu3x`}],[`path`,{d:`M17.64 18.67 20 21`,key:`kv2oe2`}]]),Ge=o(`arrow-down`,[[`path`,{d:`M12 5v14`,key:`s699le`}],[`path`,{d:`m19 12-7 7-7-7`,key:`1idqje`}]]),Ke=o(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]),qe=o(`arrow-right`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`m12 5 7 7-7 7`,key:`xquz4c`}]]),Je=o(`arrow-up`,[[`path`,{d:`m5 12 7-7 7 7`,key:`hav0vg`}],[`path`,{d:`M12 19V5`,key:`x0mq9r`}]]),Ye=o(`ban`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M4.929 4.929 19.07 19.071`,key:`196cmz`}]]),Xe=o(`bell`,[[`path`,{d:`M10.268 21a2 2 0 0 0 3.464 0`,key:`vwvbt9`}],[`path`,{d:`M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326`,key:`11g9vi`}]]),Ze=o(`bot`,[[`path`,{d:`M12 8V4H8`,key:`hb8ula`}],[`rect`,{width:`16`,height:`12`,x:`4`,y:`8`,rx:`2`,key:`enze0r`}],[`path`,{d:`M2 14h2`,key:`vft8re`}],[`path`,{d:`M20 14h2`,key:`4cs60a`}],[`path`,{d:`M15 13v2`,key:`1xurst`}],[`path`,{d:`M9 13v2`,key:`rq6x2g`}]]),Qe=o(`brain`,[[`path`,{d:`M12 18V5`,key:`adv99a`}],[`path`,{d:`M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4`,key:`1e3is1`}],[`path`,{d:`M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5`,key:`1gqd8o`}],[`path`,{d:`M17.997 5.125a4 4 0 0 1 2.526 5.77`,key:`iwvgf7`}],[`path`,{d:`M18 18a4 4 0 0 0 2-7.464`,key:`efp6ie`}],[`path`,{d:`M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517`,key:`1gq6am`}],[`path`,{d:`M6 18a4 4 0 0 1-2-7.464`,key:`k1g0md`}],[`path`,{d:`M6.003 5.125a4 4 0 0 0-2.526 5.77`,key:`q97ue3`}]]),$e=o(`briefcase`,[[`path`,{d:`M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16`,key:`jecpp`}],[`rect`,{width:`20`,height:`14`,x:`2`,y:`6`,rx:`2`,key:`i6l2r4`}]]),et=o(`calendar-clock`,[[`path`,{d:`M16 14v2.2l1.6 1`,key:`fo4ql5`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`path`,{d:`M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5`,key:`1osxxc`}],[`path`,{d:`M3 10h5`,key:`r794hk`}],[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`circle`,{cx:`16`,cy:`16`,r:`6`,key:`qoo3c4`}]]),tt=o(`calendar-days`,[[`path`,{d:`M8 2v4`,key:`1cmpym`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`4`,rx:`2`,key:`1hopcy`}],[`path`,{d:`M3 10h18`,key:`8toen8`}],[`path`,{d:`M8 14h.01`,key:`6423bh`}],[`path`,{d:`M12 14h.01`,key:`1etili`}],[`path`,{d:`M16 14h.01`,key:`1gbofw`}],[`path`,{d:`M8 18h.01`,key:`lrp35t`}],[`path`,{d:`M12 18h.01`,key:`mhygvu`}],[`path`,{d:`M16 18h.01`,key:`kzsmim`}]]),nt=o(`calendar-plus`,[[`path`,{d:`M16 19h6`,key:`xwg31i`}],[`path`,{d:`M16 2v4`,key:`4m81vk`}],[`path`,{d:`M19 16v6`,key:`tddt3s`}],[`path`,{d:`M21 12.598V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h8.5`,key:`1glfrc`}],[`path`,{d:`M3 10h18`,key:`8toen8`}],[`path`,{d:`M8 2v4`,key:`1cmpym`}]]),rt=o(`chevron-right`,[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]),it=o(`clipboard-list`,[[`rect`,{width:`8`,height:`4`,x:`8`,y:`2`,rx:`1`,ry:`1`,key:`tgr4d6`}],[`path`,{d:`M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2`,key:`116196`}],[`path`,{d:`M12 11h4`,key:`1jrz19`}],[`path`,{d:`M12 16h4`,key:`n85exb`}],[`path`,{d:`M8 11h.01`,key:`1dfujw`}],[`path`,{d:`M8 16h.01`,key:`18s6g9`}]]),at=o(`clock`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 6v6l4 2`,key:`mmk7yg`}]]),ot=o(`cloud-cog`,[[`path`,{d:`m10.852 19.772-.383.924`,key:`r7sl7d`}],[`path`,{d:`m13.148 14.228.383-.923`,key:`1d5zpm`}],[`path`,{d:`M13.148 19.772a3 3 0 1 0-2.296-5.544l-.383-.923`,key:`1ydik7`}],[`path`,{d:`m13.53 20.696-.382-.924a3 3 0 1 1-2.296-5.544`,key:`1m1vsf`}],[`path`,{d:`m14.772 15.852.923-.383`,key:`660p6e`}],[`path`,{d:`m14.772 18.148.923.383`,key:`hrcpis`}],[`path`,{d:`M4.2 15.1a7 7 0 1 1 9.93-9.858A7 7 0 0 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.2`,key:`j2q98n`}],[`path`,{d:`m9.228 15.852-.923-.383`,key:`1p9ong`}],[`path`,{d:`m9.228 18.148-.923.383`,key:`6558rz`}]]),st=o(`cloud`,[[`path`,{d:`M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`,key:`p7xjir`}]]),ct=o(`code-xml`,[[`path`,{d:`m18 16 4-4-4-4`,key:`1inbqp`}],[`path`,{d:`m6 8-4 4 4 4`,key:`15zrgr`}],[`path`,{d:`m14.5 4-5 16`,key:`e7oirm`}]]),lt=o(`compass`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z`,key:`9ktpf1`}]]),ut=o(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),dt=o(`corner-down-left`,[[`path`,{d:`M20 4v7a4 4 0 0 1-4 4H4`,key:`6o5b7l`}],[`path`,{d:`m9 10-5 5 5 5`,key:`1kshq7`}]]),ft=o(`download`,[[`path`,{d:`M12 15V3`,key:`m9g1x1`}],[`path`,{d:`M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4`,key:`ih7n3h`}],[`path`,{d:`m7 10 5 5 5-5`,key:`brsn70`}]]),pt=o(`ellipsis`,[[`circle`,{cx:`12`,cy:`12`,r:`1`,key:`41hilf`}],[`circle`,{cx:`19`,cy:`12`,r:`1`,key:`1wjl8i`}],[`circle`,{cx:`5`,cy:`12`,r:`1`,key:`1pcz8c`}]]),mt=o(`file-text`,[[`path`,{d:`M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z`,key:`1oefj6`}],[`path`,{d:`M14 2v5a1 1 0 0 0 1 1h5`,key:`wfsgrz`}],[`path`,{d:`M10 9H8`,key:`b1mrlr`}],[`path`,{d:`M16 13H8`,key:`t4e002`}],[`path`,{d:`M16 17H8`,key:`z1uh3a`}]]),ht=o(`fingerprint-pattern`,[[`path`,{d:`M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4`,key:`1nerag`}],[`path`,{d:`M14 13.12c0 2.38 0 6.38-1 8.88`,key:`o46ks0`}],[`path`,{d:`M17.29 21.02c.12-.6.43-2.3.5-3.02`,key:`ptglia`}],[`path`,{d:`M2 12a10 10 0 0 1 18-6`,key:`ydlgp0`}],[`path`,{d:`M2 16h.01`,key:`1gqxmh`}],[`path`,{d:`M21.8 16c.2-2 .131-5.354 0-6`,key:`drycrb`}],[`path`,{d:`M5 19.5C5.5 18 6 15 6 12a6 6 0 0 1 .34-2`,key:`1tidbn`}],[`path`,{d:`M8.65 22c.21-.66.45-1.32.57-2`,key:`13wd9y`}],[`path`,{d:`M9 6.8a6 6 0 0 1 9 5.2v2`,key:`1fr1j5`}]]),gt=o(`globe`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20`,key:`13o1zl`}],[`path`,{d:`M2 12h20`,key:`9i4pu4`}]]),_t=o(`graduation-cap`,[[`path`,{d:`M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.08a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z`,key:`j76jl0`}],[`path`,{d:`M22 10v6`,key:`1lu8f3`}],[`path`,{d:`M6 12.5V16a6 3 0 0 0 12 0v-3.5`,key:`1r8lef`}]]),vt=o(`hand`,[[`path`,{d:`M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2`,key:`1fvzgz`}],[`path`,{d:`M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2`,key:`1kc0my`}],[`path`,{d:`M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8`,key:`10h0bg`}],[`path`,{d:`M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15`,key:`1s1gnw`}]]),yt=o(`heart`,[[`path`,{d:`M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5`,key:`mvr1a0`}]]),bt=o(`house`,[[`path`,{d:`M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8`,key:`5wwlr5`}],[`path`,{d:`M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z`,key:`r6nss1`}]]),xt=o(`info`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 16v-4`,key:`1dtifu`}],[`path`,{d:`M12 8h.01`,key:`e9boi3`}]]),P=o(`key-round`,[[`path`,{d:`M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z`,key:`1s6t7t`}],[`circle`,{cx:`16.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`w0ekpg`}]]),St=o(`layout-grid`,[[`rect`,{width:`7`,height:`7`,x:`3`,y:`3`,rx:`1`,key:`1g98yp`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`3`,rx:`1`,key:`6d4xhi`}],[`rect`,{width:`7`,height:`7`,x:`14`,y:`14`,rx:`1`,key:`nxv5o0`}],[`rect`,{width:`7`,height:`7`,x:`3`,y:`14`,rx:`1`,key:`1bb6yr`}]]),Ct=o(`lightbulb`,[[`path`,{d:`M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5`,key:`1gvzjb`}],[`path`,{d:`M9 18h6`,key:`x1upvd`}],[`path`,{d:`M10 22h4`,key:`ceow96`}]]),wt=o(`log-in`,[[`path`,{d:`m10 17 5-5-5-5`,key:`1bsop3`}],[`path`,{d:`M15 12H3`,key:`6jk70r`}],[`path`,{d:`M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4`,key:`u53s6r`}]]),Tt=o(`log-out`,[[`path`,{d:`m16 17 5-5-5-5`,key:`1bji2h`}],[`path`,{d:`M21 12H9`,key:`dn1m92`}],[`path`,{d:`M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4`,key:`1uf3rs`}]]),Et=o(`mail`,[[`path`,{d:`m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7`,key:`132q7q`}],[`rect`,{x:`2`,y:`4`,width:`20`,height:`16`,rx:`2`,key:`izxlao`}]]),Dt=o(`map-pin`,[[`path`,{d:`M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0`,key:`1r0f0z`}],[`circle`,{cx:`12`,cy:`10`,r:`3`,key:`ilqhr7`}]]),Ot=o(`menu`,[[`path`,{d:`M4 5h16`,key:`1tepv9`}],[`path`,{d:`M4 12h16`,key:`1lakjw`}],[`path`,{d:`M4 19h16`,key:`1djgab`}]]),kt=o(`message-circle-question-mark`,[[`path`,{d:`M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719`,key:`1sd12s`}],[`path`,{d:`M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3`,key:`1u773s`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),At=o(`message-circle`,[[`path`,{d:`M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092 10 10 0 1 0-4.777-4.719`,key:`1sd12s`}]]),jt=o(`message-square-plus`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M12 8v6`,key:`1ib9pf`}],[`path`,{d:`M9 11h6`,key:`1fldmi`}]]),Mt=o(`message-square-text`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}],[`path`,{d:`M7 11h10`,key:`1twpyw`}],[`path`,{d:`M7 15h6`,key:`d9of3u`}],[`path`,{d:`M7 7h8`,key:`af5zfr`}]]),Nt=o(`message-square`,[[`path`,{d:`M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z`,key:`18887p`}]]),Pt=o(`mic-off`,[[`path`,{d:`M12 19v3`,key:`npa21l`}],[`path`,{d:`M15 9.34V5a3 3 0 0 0-5.68-1.33`,key:`1gzdoj`}],[`path`,{d:`M16.95 16.95A7 7 0 0 1 5 12v-2`,key:`cqa7eg`}],[`path`,{d:`M18.89 13.23A7 7 0 0 0 19 12v-2`,key:`16hl24`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M9 9v3a3 3 0 0 0 5.12 2.12`,key:`r2i35w`}]]),Ft=o(`mic`,[[`path`,{d:`M12 19v3`,key:`npa21l`}],[`path`,{d:`M19 10v2a7 7 0 0 1-14 0v-2`,key:`1vc78b`}],[`rect`,{x:`9`,y:`2`,width:`6`,height:`13`,rx:`3`,key:`s6n7sd`}]]),It=o(`monitor-smartphone`,[[`path`,{d:`M18 8V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h8`,key:`10dyio`}],[`path`,{d:`M10 19v-3.96 3.15`,key:`1irgej`}],[`path`,{d:`M7 19h5`,key:`qswx4l`}],[`rect`,{width:`6`,height:`10`,x:`16`,y:`12`,rx:`2`,key:`1egngj`}]]),Lt=o(`monitor`,[[`rect`,{width:`20`,height:`14`,x:`2`,y:`3`,rx:`2`,key:`48i651`}],[`line`,{x1:`8`,x2:`16`,y1:`21`,y2:`21`,key:`1svkeh`}],[`line`,{x1:`12`,x2:`12`,y1:`17`,y2:`21`,key:`vw1qmm`}]]),Rt=o(`moon`,[[`path`,{d:`M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401`,key:`kfwtm`}]]),zt=o(`mouse-pointer-click`,[[`path`,{d:`M14 4.1 12 6`,key:`ita8i4`}],[`path`,{d:`m5.1 8-2.9-.8`,key:`1go3kf`}],[`path`,{d:`m6 12-1.9 2`,key:`mnht97`}],[`path`,{d:`M7.2 2.2 8 5.1`,key:`1cfko1`}],[`path`,{d:`M9.037 9.69a.498.498 0 0 1 .653-.653l11 4.5a.5.5 0 0 1-.074.949l-4.349 1.041a1 1 0 0 0-.74.739l-1.04 4.35a.5.5 0 0 1-.95.074z`,key:`s0h3yz`}]]),Bt=o(`newspaper`,[[`path`,{d:`M15 18h-5`,key:`95g1m2`}],[`path`,{d:`M18 14h-8`,key:`sponae`}],[`path`,{d:`M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-4 0v-9a2 2 0 0 1 2-2h2`,key:`39pd36`}],[`rect`,{width:`8`,height:`4`,x:`10`,y:`6`,rx:`1`,key:`aywv1n`}]]),Vt=o(`palette`,[[`path`,{d:`M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z`,key:`e79jfc`}],[`circle`,{cx:`13.5`,cy:`6.5`,r:`.5`,fill:`currentColor`,key:`1okk4w`}],[`circle`,{cx:`17.5`,cy:`10.5`,r:`.5`,fill:`currentColor`,key:`f64h9f`}],[`circle`,{cx:`6.5`,cy:`12.5`,r:`.5`,fill:`currentColor`,key:`qy21gx`}],[`circle`,{cx:`8.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`fotxhn`}]]),Ht=o(`pencil`,[[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}],[`path`,{d:`m15 5 4 4`,key:`1mk7zo`}]]),Ut=o(`phone`,[[`path`,{d:`M13.832 16.568a1 1 0 0 0 1.213-.303l.355-.465A2 2 0 0 1 17 15h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2A18 18 0 0 1 2 4a2 2 0 0 1 2-2h3a2 2 0 0 1 2 2v3a2 2 0 0 1-.8 1.6l-.468.351a1 1 0 0 0-.292 1.233 14 14 0 0 0 6.392 6.384`,key:`9njp5v`}]]),Wt=o(`piggy-bank`,[[`path`,{d:`M11 17h3v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3a3.16 3.16 0 0 0 2-2h1a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-1a5 5 0 0 0-2-4V3a4 4 0 0 0-3.2 1.6l-.3.4H11a6 6 0 0 0-6 6v1a5 5 0 0 0 2 4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1z`,key:`1piglc`}],[`path`,{d:`M16 10h.01`,key:`1m94wz`}],[`path`,{d:`M2 8v1a2 2 0 0 0 2 2h1`,key:`1env43`}]]),Gt=o(`play`,[[`path`,{d:`M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z`,key:`10ikf1`}]]),Kt=o(`plus`,[[`path`,{d:`M5 12h14`,key:`1ays0h`}],[`path`,{d:`M12 5v14`,key:`s699le`}]]),F=o(`repeat`,[[`path`,{d:`m17 2 4 4-4 4`,key:`nntrym`}],[`path`,{d:`M3 11v-1a4 4 0 0 1 4-4h14`,key:`84bu3i`}],[`path`,{d:`m7 22-4-4 4-4`,key:`1wqhfi`}],[`path`,{d:`M21 13v1a4 4 0 0 1-4 4H3`,key:`1rx37r`}]]),qt=o(`search`,[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]),Jt=o(`send`,[[`path`,{d:`M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z`,key:`1ffxy3`}],[`path`,{d:`m21.854 2.147-10.94 10.939`,key:`12cjpa`}]]),Yt=o(`settings`,[[`path`,{d:`M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915`,key:`1i5ecw`}],[`circle`,{cx:`12`,cy:`12`,r:`3`,key:`1v7zrd`}]]),Xt=o(`share-2`,[[`circle`,{cx:`18`,cy:`5`,r:`3`,key:`gq8acd`}],[`circle`,{cx:`6`,cy:`12`,r:`3`,key:`w7nqdw`}],[`circle`,{cx:`18`,cy:`19`,r:`3`,key:`1xt0gg`}],[`line`,{x1:`8.59`,x2:`15.42`,y1:`13.51`,y2:`17.49`,key:`47mynk`}],[`line`,{x1:`15.41`,x2:`8.59`,y1:`6.51`,y2:`10.49`,key:`1n3mei`}]]),Zt=o(`shield-alert`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`M12 8v4`,key:`1got3b`}],[`path`,{d:`M12 16h.01`,key:`1drbdi`}]]),Qt=o(`shield-check`,[[`path`,{d:`M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z`,key:`oel41y`}],[`path`,{d:`m9 12 2 2 4-4`,key:`dzmm74`}]]),$t=o(`shield-off`,[[`path`,{d:`m2 2 20 20`,key:`1ooewy`}],[`path`,{d:`M5 5a1 1 0 0 0-1 1v7c0 5 3.5 7.5 7.67 8.94a1 1 0 0 0 .67.01c2.35-.82 4.48-1.97 5.9-3.71`,key:`1jlk70`}],[`path`,{d:`M9.309 3.652A12.252 12.252 0 0 0 11.24 2.28a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1v7a9.784 9.784 0 0 1-.08 1.264`,key:`18rp1v`}]]),en=o(`sliders-horizontal`,[[`path`,{d:`M10 5H3`,key:`1qgfaw`}],[`path`,{d:`M12 19H3`,key:`yhmn1j`}],[`path`,{d:`M14 3v4`,key:`1sua03`}],[`path`,{d:`M16 17v4`,key:`1q0r14`}],[`path`,{d:`M21 12h-9`,key:`1o4lsq`}],[`path`,{d:`M21 19h-5`,key:`1rlt1p`}],[`path`,{d:`M21 5h-7`,key:`1oszz2`}],[`path`,{d:`M8 10v4`,key:`tgpxqk`}],[`path`,{d:`M8 12H3`,key:`a7s4jb`}]]),tn=o(`smartphone`,[[`rect`,{width:`14`,height:`20`,x:`5`,y:`2`,rx:`2`,ry:`2`,key:`1yt0o3`}],[`path`,{d:`M12 18h.01`,key:`mhygvu`}]]),nn=o(`sparkles`,[[`path`,{d:`M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z`,key:`1s2grr`}],[`path`,{d:`M20 2v4`,key:`1rf3ol`}],[`path`,{d:`M22 4h-4`,key:`gwowj6`}],[`circle`,{cx:`4`,cy:`20`,r:`2`,key:`6kqj1y`}]]),rn=o(`square-check-big`,[[`path`,{d:`M21 10.656V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h12.344`,key:`2acyp4`}],[`path`,{d:`m9 11 3 3L22 4`,key:`1pflzl`}]]),an=o(`square-pen`,[[`path`,{d:`M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7`,key:`1m0v6g`}],[`path`,{d:`M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z`,key:`ohrbg2`}]]),on=o(`square-terminal`,[[`path`,{d:`m7 11 2-2-2-2`,key:`1lz0vl`}],[`path`,{d:`M11 13h4`,key:`1p7l4v`}],[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,ry:`2`,key:`1m3agn`}]]),sn=o(`square`,[[`rect`,{width:`18`,height:`18`,x:`3`,y:`3`,rx:`2`,key:`afitv7`}]]),cn=o(`table-2`,[[`path`,{d:`M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18`,key:`gugj83`}]]),ln=o(`tag`,[[`path`,{d:`M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z`,key:`vktsd0`}],[`circle`,{cx:`7.5`,cy:`7.5`,r:`.5`,fill:`currentColor`,key:`kqv944`}]]),un=o(`target`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`circle`,{cx:`12`,cy:`12`,r:`6`,key:`1vlfrh`}],[`circle`,{cx:`12`,cy:`12`,r:`2`,key:`1c9p78`}]]),dn=o(`terminal`,[[`path`,{d:`M12 19h8`,key:`baeox8`}],[`path`,{d:`m4 17 6-6-6-6`,key:`1yngyt`}]]),fn=o(`trash-2`,[[`path`,{d:`M10 11v6`,key:`nco0om`}],[`path`,{d:`M14 11v6`,key:`outv1u`}],[`path`,{d:`M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6`,key:`miytrc`}],[`path`,{d:`M3 6h18`,key:`d0wm0j`}],[`path`,{d:`M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2`,key:`e791ji`}]]),pn=o(`triangle-alert`,[[`path`,{d:`m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3`,key:`wmoenq`}],[`path`,{d:`M12 9v4`,key:`juzpu7`}],[`path`,{d:`M12 17h.01`,key:`p32p05`}]]),mn=o(`user-round`,[[`circle`,{cx:`12`,cy:`8`,r:`5`,key:`1hypcn`}],[`path`,{d:`M20 21a8 8 0 0 0-16 0`,key:`rfgkzh`}]]),hn=o(`users`,[[`path`,{d:`M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2`,key:`1yyitq`}],[`path`,{d:`M16 3.128a4 4 0 0 1 0 7.744`,key:`16gr8j`}],[`path`,{d:`M22 21v-2a4 4 0 0 0-3-3.87`,key:`kshegd`}],[`circle`,{cx:`9`,cy:`7`,r:`4`,key:`nufk8`}]]),gn=o(`wand-sparkles`,[[`path`,{d:`m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72`,key:`ul74o6`}],[`path`,{d:`m14 7 3 3`,key:`1r5n42`}],[`path`,{d:`M5 6v4`,key:`ilb8ba`}],[`path`,{d:`M19 14v4`,key:`blhpug`}],[`path`,{d:`M10 2v2`,key:`7u0qdc`}],[`path`,{d:`M7 8H3`,key:`zfb6yr`}],[`path`,{d:`M21 16h-4`,key:`1cnmox`}],[`path`,{d:`M11 3H9`,key:`1obp7u`}]]),_n=o(`webhook`,[[`path`,{d:`M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2`,key:`q3hayz`}],[`path`,{d:`m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06`,key:`1go1hn`}],[`path`,{d:`m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8`,key:`qlwsc0`}]]),vn=o(`wifi-off`,[[`path`,{d:`M12 20h.01`,key:`zekei9`}],[`path`,{d:`M8.5 16.429a5 5 0 0 1 7 0`,key:`1bycff`}],[`path`,{d:`M5 12.859a10 10 0 0 1 5.17-2.69`,key:`1dl1wf`}],[`path`,{d:`M19 12.859a10 10 0 0 0-2.007-1.523`,key:`4k23kn`}],[`path`,{d:`M2 8.82a15 15 0 0 1 4.177-2.643`,key:`1grhjp`}],[`path`,{d:`M22 8.82a15 15 0 0 0-11.288-3.764`,key:`z3jwby`}],[`path`,{d:`m2 2 20 20`,key:`1ooewy`}]]),yn=o(`wrench`,[[`path`,{d:`M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z`,key:`1ngwbx`}]]),I=p(x(),1),bn=p(Ue(),1),L=C();function xn({size:e=72,className:t}){return(0,L.jsx)(`img`,{src:`/icon.svg`,alt:`nanoMuse`,width:e,height:e,draggable:!1,className:i(`block shrink-0 select-none`,t),style:{width:e,height:e}})}function Sn(){return window.nanomuseDesktop??null}function Cn(){return!!window.nanomuseDesktop}function wn(){let e=Sn();e&&(document.documentElement.classList.add(`desktop`),document.documentElement.dataset.platform=e.platform)}var Tn=`nm.desktop.remote_hint`;function En(){let{state:e,setTab:t}=E(),n=s(),[r,i]=(0,I.useState)(()=>!Cn()||d(Tn)===`1`),a=e.hub,o=!r&&!!a&&a.state===`connected`&&a.remote_control&&a.account.signed_in;if((0,I.useEffect)(()=>{o&&f(Tn,`1`)},[o]),!o)return null;let c=()=>i(!0);return(0,L.jsxs)(`div`,{className:`flex items-center gap-2.5 border-b border-border bg-accent/10 px-4 py-2 text-[12.5px] text-fg`,children:[(0,L.jsx)(It,{size:16,className:`shrink-0 text-accent`}),(0,L.jsx)(`span`,{className:`min-w-0 flex-1`,children:n(`Your phone can now operate this computer: run commands, fetch files, hand over whole tasks. Each risky step still asks here first.`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>{c(),t(`devices`)},className:`shrink-0 rounded-full border border-border bg-surface px-2.5 py-1 text-[12px] font-medium hover:bg-surface-2`,children:n(`Devices`)}),(0,L.jsx)(`button`,{type:`button`,onClick:c,"aria-label":n(`Dismiss`),className:`shrink-0 rounded-full p-1 text-muted hover:text-fg`,children:(0,L.jsx)(Fe,{size:15})})]})}var Dn=`a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])`;function On(e,t){(0,I.useEffect)(()=>{if(!t)return;let n=e.current;if(!n)return;let r=document.activeElement,i=()=>Array.from(n.querySelectorAll(Dn)).filter(e=>e.offsetParent!==null||e===document.activeElement);if(!n.contains(document.activeElement)){let e=i()[0];e?e.focus():(n.tabIndex=-1,n.focus())}let a=e=>{if(e.key!==`Tab`)return;let t=i();if(t.length===0){e.preventDefault();return}let r=t[0],a=t[t.length-1],o=document.activeElement;e.shiftKey&&(o===r||!n.contains(o))?(e.preventDefault(),a.focus()):!e.shiftKey&&(o===a||!n.contains(o))&&(e.preventDefault(),r.focus())};return document.addEventListener(`keydown`,a,!0),()=>{document.removeEventListener(`keydown`,a,!0),r&&r.isConnected&&typeof r.focus==`function`&&r.focus()}},[e,t])}function kn(){let{state:e,setDrawer:t,setTab:n,openThread:r,toast:i,dispatch:a}=E(),o=s(),c=e.drawer,[l,u]=(0,I.useState)(``),[d,f]=(0,I.useState)(null),p=(0,I.useRef)(null);On(p,c),(0,I.useEffect)(()=>{if(!c)return;let e=e=>e.key===`Escape`&&t(!1);return window.addEventListener(`keydown`,e),()=>window.removeEventListener(`keydown`,e)},[c,t]),(0,I.useEffect)(()=>{c||(u(``),f(null))},[c]);let m=e.profile?.name||`nanoMuse`,h=(0,I.useMemo)(()=>{let t=l.trim().toLowerCase();return e.threads.filter(e=>e.id!==`main`).filter(e=>!t||(e.title||``).toLowerCase().includes(t)).sort((e,t)=>(t.updated_at||``).localeCompare(e.updated_at||``))},[e.threads,l]),g=(e.hub?.devices??[]).filter(e=>!e.this&&e.kind!==`web`),_=g.filter(e=>e.online).length,v=e.hub?.state===`connected`,y=g.some(e=>e.online&&e.kind===`computer`),b=e.tab===`chat`,x=e=>{r(e),n(`chat`),t(!1)},S=e=>{n(e),t(!1)},C=async()=>{try{let e=await M.createThread(o(`New chat`));a({type:`ws`,msg:{kind:`thread`,thread:e}}),x(e.id)}catch(e){i(e.message)}},w=async t=>{try{await M.deleteThread(t),a({type:`ws`,msg:{kind:`thread_deleted`,thread:t}}),e.activeThread===t&&r(`main`)}catch(e){i(e.message)}finally{f(null)}},T=t=>e.hub?.devices.find(e=>e.id===t)?.kind??`phone`,D=v?_===0?o(`Only this one`):o(`{n} online`,{n:_}):o(`Off`);return c?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50`,children:[(0,L.jsx)(`div`,{className:`rise absolute inset-0 bg-black/40`,onClick:()=>t(!1)}),(0,L.jsxs)(`nav`,{ref:p,role:`dialog`,"aria-modal":`true`,"aria-label":o(`Chats`),className:`drawer-in safe-top safe-bottom absolute inset-y-0 left-0 flex w-[82vw] max-w-[320px] flex-col bg-bg shadow-2xl`,children:[(0,L.jsx)(`div`,{className:`px-5 pt-5 pb-2 text-[24px] font-bold leading-7 tracking-tight`,children:m}),(0,L.jsx)(An,{icon:(0,L.jsx)(bt,{size:20}),label:o(`Main chat`),selected:b&&e.activeThread===`main`,onClick:()=>x(`main`)}),(0,L.jsx)(An,{icon:(0,L.jsx)(It,{size:20}),label:o(`Devices`),detail:D,selected:e.tab===`devices`,onClick:()=>S(`devices`)}),y&&(0,L.jsx)(An,{icon:(0,L.jsx)(dn,{size:20}),label:o(`Coding agents`),selected:e.tab===`coding`,onClick:()=>S(`coding`)}),(0,L.jsx)(`div`,{className:`mt-3 px-5 pb-1`,children:(0,L.jsx)(`span`,{className:`text-[14px] font-medium text-muted`,children:o(`Side chats`)})}),h.length===0&&!l?(0,L.jsxs)(`div`,{className:`flex flex-1 flex-col items-center justify-center px-8 text-center`,children:[(0,L.jsx)(Nt,{size:28,className:`mb-3 text-muted/70`}),(0,L.jsx)(`div`,{className:`text-[17px] font-semibold`,children:o(`Start a side chat`)}),(0,L.jsx)(`div`,{className:`mt-1 text-[13px] leading-snug text-muted`,children:o(`Side chats are an optional way to keep conversations organised by topic.`)})]}):(0,L.jsxs)(`ul`,{className:`min-h-0 flex-1 overflow-y-auto py-1`,children:[h.map(t=>(0,L.jsx)(jn,{thread:t,kind:t.device?T(t.device):void 0,selected:b&&e.activeThread===t.id,confirming:d===t.id,onPick:()=>x(t.id),onAskDelete:()=>f(t.id),onDelete:()=>void w(t.id),onKeep:()=>f(null)},t.id)),h.length===0&&(0,L.jsx)(`li`,{className:`px-5 py-3 text-[13px] text-muted`,children:o(`No chats match.`)})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2 border-t border-border px-3 pt-2 pb-2`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>S(`you`),"aria-label":o(`Settings`),className:`flex h-10 w-10 items-center justify-center rounded-full text-fg hover:bg-surface-2`,children:(0,L.jsx)(Yt,{size:20})}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>S(`memory`),"aria-label":o(`Memory`),className:`-ml-1 flex h-10 w-10 items-center justify-center rounded-full text-fg hover:bg-surface-2`,children:(0,L.jsx)(mt,{size:20})}),(0,L.jsxs)(`label`,{className:`flex min-w-0 flex-1 items-center gap-2 rounded-full bg-surface-2 px-3 py-2`,children:[(0,L.jsx)(qt,{size:16,className:`shrink-0 text-muted`}),(0,L.jsx)(`input`,{value:l,onChange:e=>u(e.target.value),placeholder:o(`Search`),className:`min-w-0 flex-1 bg-transparent text-[15px] outline-none placeholder:text-muted`}),l&&(0,L.jsx)(`button`,{type:`button`,onClick:()=>u(``),"aria-label":o(`Clear`),className:`text-muted`,children:(0,L.jsx)(Fe,{size:14})})]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void C(),"aria-label":o(`New side chat`),className:`flex h-10 w-10 items-center justify-center rounded-full text-fg hover:bg-surface-2`,children:(0,L.jsx)(an,{size:20})})]})]})]}):null}function An({icon:e,label:t,detail:n,selected:r,onClick:a}){return(0,L.jsxs)(`button`,{type:`button`,onClick:a,className:i(`mx-2 flex items-center gap-3 rounded-2xl px-3 py-2.5 text-left`,r?`bg-surface-2`:`hover:bg-surface-2/60`),children:[(0,L.jsx)(`span`,{className:`text-fg`,children:e}),(0,L.jsx)(`span`,{className:`min-w-0 flex-1 truncate text-[16px]`,children:t}),n&&(0,L.jsx)(`span`,{className:`shrink-0 text-[13px] text-muted`,children:n})]})}function jn({thread:e,kind:t,selected:n,confirming:r,onPick:a,onAskDelete:o,onDelete:c,onKeep:l}){let u=s();return r?(0,L.jsxs)(`li`,{className:`mx-2 flex items-center gap-2 rounded-2xl bg-surface-2 px-3 py-2`,children:[(0,L.jsx)(`span`,{className:`min-w-0 flex-1 truncate text-[13.5px]`,children:u(`Delete this side chat and its history?`)}),(0,L.jsx)(`button`,{type:`button`,onClick:c,className:`rounded-full bg-rose-500 px-3 py-1 text-[12.5px] font-semibold text-white`,children:u(`Delete`)}),(0,L.jsx)(`button`,{type:`button`,onClick:l,className:`rounded-full px-2 py-1 text-[12.5px] text-muted`,children:u(`Keep`)})]}):(0,L.jsxs)(`li`,{className:i(`group mx-2 flex items-center gap-2 rounded-2xl px-3`,n?`bg-surface-2`:`hover:bg-surface-2/60`),children:[(0,L.jsxs)(`button`,{type:`button`,onClick:a,className:`flex min-w-0 flex-1 items-center gap-2 py-2.5 text-left`,children:[e.device?(0,L.jsx)(`span`,{className:`shrink-0 text-accent`,children:t===`computer`?(0,L.jsx)(Lt,{size:15}):(0,L.jsx)(tn,{size:15})}):e.remote_from?(0,L.jsx)(It,{size:15,className:`shrink-0 text-muted`}):null,(0,L.jsx)(`span`,{className:`min-w-0 flex-1 truncate text-[15px]`,children:e.title||u(`New chat`)}),e.busy&&(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-amber-400`}),(0,L.jsx)(`span`,{className:`shrink-0 text-[12px] text-muted`,children:Mn(e.updated_at)})]}),(0,L.jsx)(`button`,{type:`button`,"aria-label":u(`Delete`),onClick:o,className:`p-1.5 text-muted/70 opacity-0 hover:text-rose-500 focus:opacity-100 group-hover:opacity-100`,children:(0,L.jsx)(fn,{size:15})})]})}function Mn(e,t=new Date){if(!e)return``;let n=new Date(e);if(Number.isNaN(n.getTime()))return``;let r=t.getTime()-n.getTime();return n.toDateString()===t.toDateString()?n.toLocaleTimeString(m(),{hour:`2-digit`,minute:`2-digit`}):r<6048e5?n.toLocaleDateString(m(),{weekday:`short`}):n.toLocaleDateString(m(),{month:`short`,day:`numeric`})}function Nn(e,t){let n=t||{};return(e[e.length-1]===``?[...e,``]:e).join((n.padRight?` `:``)+`,`+(n.padLeft===!1?``:` `)).trim()}var Pn=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Fn=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,In={};function Ln(e,t){return((t||In).jsx?Fn:Pn).test(e)}var Rn=/[ \t\n\f\r]/g;function zn(e){return typeof e==`object`?e.type===`text`&&Bn(e.value):Bn(e)}function Bn(e){return e.replace(Rn,``)===``}var Vn=class{constructor(e,t,n){this.normal=t,this.property=e,n&&(this.space=n)}};Vn.prototype.normal={},Vn.prototype.property={},Vn.prototype.space=void 0;function Hn(e,t){let n={},r={};for(let t of e)Object.assign(n,t.property),Object.assign(r,t.normal);return new Vn(n,r,t)}function Un(e){return e.toLowerCase()}var Wn=class{constructor(e,t){this.attribute=t,this.property=e}};Wn.prototype.attribute=``,Wn.prototype.booleanish=!1,Wn.prototype.boolean=!1,Wn.prototype.commaOrSpaceSeparated=!1,Wn.prototype.commaSeparated=!1,Wn.prototype.defined=!1,Wn.prototype.mustUseProperty=!1,Wn.prototype.number=!1,Wn.prototype.overloadedBoolean=!1,Wn.prototype.property=``,Wn.prototype.spaceSeparated=!1,Wn.prototype.space=void 0;var Gn=S({boolean:()=>R,booleanish:()=>qn,commaOrSpaceSeparated:()=>Xn,commaSeparated:()=>Yn,number:()=>z,overloadedBoolean:()=>Jn,spaceSeparated:()=>B}),Kn=0,R=Zn(),qn=Zn(),Jn=Zn(),z=Zn(),B=Zn(),Yn=Zn(),Xn=Zn();function Zn(){return 2**++Kn}var Qn=Object.keys(Gn),$n=class extends Wn{constructor(e,t,n,r){let i=-1;if(super(e,t),er(this,`space`,r),typeof n==`number`)for(;++i4&&n.slice(0,4)===`data`&&pr.test(t)){if(t.charAt(4)===`-`){let e=t.slice(5).replace(fr,gr);r=`data`+e.charAt(0).toUpperCase()+e.slice(1)}else{let e=t.slice(4);if(!fr.test(e)){let n=e.replace(dr,hr);n.charAt(0)!==`-`&&(n=`-`+n),t=`data`+n}}i=$n}return new i(r,t)}function hr(e){return`-`+e.toLowerCase()}function gr(e){return e.charAt(1).toUpperCase()}var _r=Hn([nr,ar,sr,cr,lr],`html`),vr=Hn([nr,or,sr,cr,lr],`svg`);function yr(e){return e.join(` `).trim()}var br=b(((e,t)=>{var n=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,r=/\n/g,i=/^\s*/,a=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,o=/^:\s*/,s=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,c=/^[;\s]*/,l=/^\s+|\s+$/g;function u(e,t){if(typeof e!=`string`)throw TypeError(`First argument must be a string`);if(!e)return[];t||={};var l=1,u=1;function f(e){var t=e.match(r);t&&(l+=t.length);var n=e.lastIndexOf(` +`);u=~n?e.length-n:u+e.length}function p(){var e={line:l,column:u};return function(t){return t.position=new m(e),_(),t}}function m(e){this.start=e,this.end={line:l,column:u},this.source=t.source}m.prototype.content=e;function h(n){var r=Error(t.source+`:`+l+`:`+u+`: `+n);if(r.reason=n,r.filename=t.source,r.line=l,r.column=u,r.source=e,!t.silent)throw r}function g(t){var n=t.exec(e);if(n){var r=n[0];return f(r),e=e.slice(r.length),n}}function _(){g(i)}function v(e){var t;for(e||=[];t=y();)t!==!1&&e.push(t);return e}function y(){var t=p();if(e.charAt(0)==`/`&&e.charAt(1)==`*`){for(var n=2;e.charAt(n)!=``&&(e.charAt(n)!=`*`||e.charAt(n+1)!=`/`);)++n;if(n+=2,e.charAt(n-1)===``)return h(`End of comment missing`);var r=e.slice(2,n-2);return u+=2,f(r),e=e.slice(n),u+=2,t({type:`comment`,comment:r})}}function b(){var e=p(),t=g(a);if(t){if(y(),!g(o))return h(`property missing ':'`);var r=g(s),i=e({type:`declaration`,property:d(t[0].replace(n,``)),value:r?d(r[0].replace(n,``)):``});return g(c),i}}function x(){var e=[];v(e);for(var t;t=b();)t!==!1&&(e.push(t),v(e));return e}return _(),x()}function d(e){return e?e.replace(l,``):``}t.exports=u})),xr=b((e=>{var t=e&&e.__importDefault||function(e){return e&&e.__esModule?e:{default:e}};Object.defineProperty(e,"__esModule",{value:!0}),e.default=r;var n=t(br());function r(e,t){let r=null;if(!e||typeof e!=`string`)return r;let i=(0,n.default)(e),a=typeof t==`function`;return i.forEach(e=>{if(e.type!==`declaration`)return;let{property:n,value:i}=e;a?t(n,i,e):i&&(r||={},r[n]=i)}),r}})),Sr=b((e=>{Object.defineProperty(e,"__esModule",{value:!0}),e.camelCase=void 0;var t=/^--[a-zA-Z0-9_-]+$/,n=/-([a-z])/g,r=/^[^-]+$/,i=/^-(webkit|moz|ms|o|khtml)-/,a=/^-(ms)-/,o=function(e){return!e||r.test(e)||t.test(e)},s=function(e,t){return t.toUpperCase()},c=function(e,t){return`${t}-`};e.camelCase=function(e,t){return t===void 0&&(t={}),o(e)?e:(e=e.toLowerCase(),e=t.reactCompat?e.replace(a,c):e.replace(i,c),e.replace(n,s))}})),Cr=b(((e,t)=>{var n=(e&&e.__importDefault||function(e){return e&&e.__esModule?e:{default:e}})(xr()),r=Sr();function i(e,t){var i={};return!e||typeof e!=`string`||(0,n.default)(e,function(e,n){e&&n&&(i[(0,r.camelCase)(e,t)]=n)}),i}i.default=i,t.exports=i})),wr=Er(`end`),Tr=Er(`start`);function Er(e){return t;function t(t){let n=t&&t.position&&t.position[e]||{};if(typeof n.line==`number`&&n.line>0&&typeof n.column==`number`&&n.column>0)return{line:n.line,column:n.column,offset:typeof n.offset==`number`&&n.offset>-1?n.offset:void 0}}}function Dr(e){let t=Tr(e),n=wr(e);if(t&&n)return{start:t,end:n}}function Or(e){return!e||typeof e!=`object`?``:`position`in e||`type`in e?Ar(e.position):`start`in e||`end`in e?Ar(e):`line`in e||`column`in e?kr(e):``}function kr(e){return jr(e&&e.line)+`:`+jr(e&&e.column)}function Ar(e){return kr(e&&e.start)+`-`+kr(e&&e.end)}function jr(e){return e&&typeof e==`number`?e:1}var Mr=class extends Error{constructor(e,t,n){super(),typeof t==`string`&&(n=t,t=void 0);let r=``,i={},a=!1;if(t&&(i=`line`in t&&`column`in t||`start`in t&&`end`in t?{place:t}:`type`in t?{ancestors:[t],place:t.position}:{...t}),typeof e==`string`?r=e:!i.cause&&e&&(a=!0,r=e.message,i.cause=e),!i.ruleId&&!i.source&&typeof n==`string`){let e=n.indexOf(`:`);e===-1?i.ruleId=n:(i.source=n.slice(0,e),i.ruleId=n.slice(e+1))}if(!i.place&&i.ancestors&&i.ancestors){let e=i.ancestors[i.ancestors.length-1];e&&(i.place=e.position)}let o=i.place&&`start`in i.place?i.place.start:i.place;this.ancestors=i.ancestors||void 0,this.cause=i.cause||void 0,this.column=o?o.column:void 0,this.fatal=void 0,this.file=``,this.message=r,this.line=o?o.line:void 0,this.name=Or(i.place)||`1:1`,this.place=i.place||void 0,this.reason=this.message,this.ruleId=i.ruleId||void 0,this.source=i.source||void 0,this.stack=a&&i.cause&&typeof i.cause.stack==`string`?i.cause.stack:``,this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}};Mr.prototype.file=``,Mr.prototype.name=``,Mr.prototype.reason=``,Mr.prototype.message=``,Mr.prototype.stack=``,Mr.prototype.column=void 0,Mr.prototype.line=void 0,Mr.prototype.ancestors=void 0,Mr.prototype.cause=void 0,Mr.prototype.fatal=void 0,Mr.prototype.place=void 0,Mr.prototype.ruleId=void 0,Mr.prototype.source=void 0;var Nr=p(Cr(),1),Pr={}.hasOwnProperty,Fr=new Map,Ir=/[A-Z]/g,Lr=new Set([`table`,`tbody`,`thead`,`tfoot`,`tr`]),Rr=new Set([`td`,`th`]),zr=`https://github.com/syntax-tree/hast-util-to-jsx-runtime`;function Br(e,t){if(!t||t.Fragment===void 0)throw TypeError("Expected `Fragment` in options");let n=t.filePath||void 0,r;if(t.development){if(typeof t.jsxDEV!=`function`)throw TypeError("Expected `jsxDEV` in options when `development: true`");r=Zr(n,t.jsxDEV)}else{if(typeof t.jsx!=`function`)throw TypeError("Expected `jsx` in production options");if(typeof t.jsxs!=`function`)throw TypeError("Expected `jsxs` in production options");r=Xr(n,t.jsx,t.jsxs)}let i={Fragment:t.Fragment,ancestors:[],components:t.components||{},create:r,elementAttributeNameCase:t.elementAttributeNameCase||`react`,evaluater:t.createEvaluater?t.createEvaluater():void 0,filePath:n,ignoreInvalidStyle:t.ignoreInvalidStyle||!1,passKeys:t.passKeys!==!1,passNode:t.passNode||!1,schema:t.space===`svg`?vr:_r,stylePropertyNameCase:t.stylePropertyNameCase||`dom`,tableCellAlignToStyle:t.tableCellAlignToStyle!==!1},a=Vr(i,e,void 0);return a&&typeof a!=`string`?a:i.create(e,i.Fragment,{children:a||void 0},void 0)}function Vr(e,t,n){if(t.type===`element`)return Hr(e,t,n);if(t.type===`mdxFlowExpression`||t.type===`mdxTextExpression`)return Ur(e,t);if(t.type===`mdxJsxFlowElement`||t.type===`mdxJsxTextElement`)return Gr(e,t,n);if(t.type===`mdxjsEsm`)return Wr(e,t);if(t.type===`root`)return Kr(e,t,n);if(t.type===`text`)return qr(e,t)}function Hr(e,t,n){let r=e.schema,i=r;t.tagName.toLowerCase()===`svg`&&r.space===`html`&&(i=vr,e.schema=i),e.ancestors.push(t);let a=ri(e,t.tagName,!1),o=Qr(e,t),s=ei(e,t);return Lr.has(t.tagName)&&(s=s.filter(function(e){return typeof e!=`string`||!zn(e)})),Jr(e,o,a,t),Yr(o,s),e.ancestors.pop(),e.schema=r,e.create(t,a,o,n)}function Ur(e,t){if(t.data&&t.data.estree&&e.evaluater){let n=t.data.estree.body[0];return n.type,e.evaluater.evaluateExpression(n.expression)}ii(e,t.position)}function Wr(e,t){if(t.data&&t.data.estree&&e.evaluater)return e.evaluater.evaluateProgram(t.data.estree);ii(e,t.position)}function Gr(e,t,n){let r=e.schema,i=r;t.name===`svg`&&r.space===`html`&&(i=vr,e.schema=i),e.ancestors.push(t);let a=t.name===null?e.Fragment:ri(e,t.name,!0),o=$r(e,t),s=ei(e,t);return Jr(e,o,a,t),Yr(o,s),e.ancestors.pop(),e.schema=r,e.create(t,a,o,n)}function Kr(e,t,n){let r={};return Yr(r,ei(e,t)),e.create(t,e.Fragment,r,n)}function qr(e,t){return t.value}function Jr(e,t,n,r){typeof n!=`string`&&n!==e.Fragment&&e.passNode&&(t.node=r)}function Yr(e,t){if(t.length>0){let n=t.length>1?t:t[0];n&&(e.children=n)}}function Xr(e,t,n){return r;function r(e,r,i,a){let o=Array.isArray(i.children)?n:t;return a?o(r,i,a):o(r,i)}}function Zr(e,t){return n;function n(n,r,i,a){let o=Array.isArray(i.children),s=Tr(n);return t(r,i,a,o,{columnNumber:s?s.column-1:void 0,fileName:e,lineNumber:s?s.line:void 0},void 0)}}function Qr(e,t){let n={},r,i;for(i in t.properties)if(i!==`children`&&Pr.call(t.properties,i)){let a=ti(e,i,t.properties[i]);if(a){let[i,o]=a;e.tableCellAlignToStyle&&i===`align`&&typeof o==`string`&&Rr.has(t.tagName)?r=o:n[i]=o}}if(r){let t=n.style||={};t[e.stylePropertyNameCase===`css`?`text-align`:`textAlign`]=r}return n}function $r(e,t){let n={};for(let r of t.attributes)if(r.type===`mdxJsxExpressionAttribute`){if(r.data&&r.data.estree&&e.evaluater){let t=r.data.estree.body[0];t.type;let i=t.expression;i.type;let a=i.properties[0];a.type,Object.assign(n,e.evaluater.evaluateExpression(a.argument))}else ii(e,t.position)}else{let i=r.name,a;if(r.value&&typeof r.value==`object`){if(r.value.data&&r.value.data.estree&&e.evaluater){let t=r.value.data.estree.body[0];t.type,a=e.evaluater.evaluateExpression(t.expression)}else ii(e,t.position)}else a=r.value===null||r.value;n[i]=a}return n}function ei(e,t){let n=[],r=-1,i=e.passKeys?new Map:Fr;for(;++ri?0:i+t:t>i?i:t,n=n>0?n:0,r.length<1e4)o=Array.from(r),o.unshift(t,n),e.splice(...o);else for(n&&e.splice(t,n);a0?(hi(e,e.length,0,t),e):t}var _i={}.hasOwnProperty;function vi(e){let t={},n=-1;for(;++n13&&n<32||n>126&&n<160||n>55295&&n<57344||n>64975&&n<65008||(n&65535)==65535||(n&65535)==65534||n>1114111?`�`:String.fromCodePoint(n)}function Si(e){return e.replace(/[\t\n\r ]+/g,` `).replace(/^ | $/g,``).toLowerCase().toUpperCase()}var Ci=Mi(/[A-Za-z]/),wi=Mi(/[\dA-Za-z]/),Ti=Mi(/[#-'*+\--9=?A-Z^-~]/);function Ei(e){return e!==null&&(e<32||e===127)}var Di=Mi(/\d/),Oi=Mi(/[\dA-Fa-f]/),ki=Mi(/[!-/:-@[-`{-~]/);function H(e){return e!==null&&e<-2}function U(e){return e!==null&&(e<0||e===32)}function W(e){return e===-2||e===-1||e===32}var Ai=Mi(/\p{P}|\p{S}/u),ji=Mi(/\s/);function Mi(e){return t;function t(t){return t!==null&&t>-1&&e.test(String.fromCharCode(t))}}function Ni(e){let t=[],n=-1,r=0,i=0;for(;++n55295&&a<57344){let t=e.charCodeAt(n+1);a<56320&&t>56319&&t<57344?(o=String.fromCharCode(a,t),i=1):o=`�`}else o=String.fromCharCode(a);o&&=(t.push(e.slice(r,n),encodeURIComponent(o)),r=n+i+1,``),i&&=(n+=i,0)}return t.join(``)+e.slice(r)}function G(e,t,n,r){let i=r?r-1:1/0,a=0;return o;function o(r){return W(r)?(e.enter(n),s(r)):t(r)}function s(r){return W(r)&&a++o))return;let n=t.events.length,a=n,s,c;for(;a--;)if(t.events[a][0]===`exit`&&t.events[a][1].type===`chunkFlow`){if(s){c=t.events[a][1].end;break}s=!0}for(_(r),e=n;er;){let r=n[i];t.containerState=r[1],r[0].exit.call(t,e)}n.length=r}function v(){i.write([null]),a=void 0,i=void 0,t.containerState._closeFlow=void 0}}function zi(e,t,n){return G(e,e.attempt(this.parser.constructs.document,t,n),`linePrefix`,this.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)}function Bi(e){if(e===null||U(e)||ji(e))return 1;if(Ai(e))return 2}function Vi(e,t,n){let r=[],i=-1;for(;++i1&&e[n][1].end.offset-e[n][1].start.offset>1?2:1;let d={...e[r][1].end},f={...e[n][1].start};Ui(d,-c),Ui(f,c),o={type:c>1?`strongSequence`:`emphasisSequence`,start:d,end:{...e[r][1].end}},s={type:c>1?`strongSequence`:`emphasisSequence`,start:{...e[n][1].start},end:f},a={type:c>1?`strongText`:`emphasisText`,start:{...e[r][1].end},end:{...e[n][1].start}},i={type:c>1?`strong`:`emphasis`,start:{...o.start},end:{...s.end}},e[r][1].end={...o.start},e[n][1].start={...s.end},l=[],e[r][1].end.offset-e[r][1].start.offset&&(l=gi(l,[[`enter`,e[r][1],t],[`exit`,e[r][1],t]])),l=gi(l,[[`enter`,i,t],[`enter`,o,t],[`exit`,o,t],[`enter`,a,t]]),l=gi(l,Vi(t.parser.constructs.insideSpan.null,e.slice(r+1,n),t)),l=gi(l,[[`exit`,a,t],[`enter`,s,t],[`exit`,s,t],[`exit`,i,t]]),e[n][1].end.offset-e[n][1].start.offset?(u=2,l=gi(l,[[`enter`,e[n][1],t],[`exit`,e[n][1],t]])):u=0,hi(e,r-1,n-r+3,l),n=r+l.length-u-2;break}}for(n=-1;++n0&&W(t)?G(e,v,`linePrefix`,a+1)(t):v(t)}function v(t){return t===null||H(t)?e.check(na,h,b)(t):(e.enter(`codeFlowValue`),y(t))}function y(t){return t===null||H(t)?(e.exit(`codeFlowValue`),v(t)):(e.consume(t),y)}function b(n){return e.exit(`codeFenced`),t(n)}function x(e,t,n){let i=0;return a;function a(t){return e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),c}function c(t){return e.enter(`codeFencedFence`),W(t)?G(e,l,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):l(t)}function l(t){return t===s?(e.enter(`codeFencedFenceSequence`),u(t)):n(t)}function u(t){return t===s?(i++,e.consume(t),u):i>=o?(e.exit(`codeFencedFenceSequence`),W(t)?G(e,d,`whitespace`)(t):d(t)):n(t)}function d(r){return r===null||H(r)?(e.exit(`codeFencedFence`),t(r)):n(r)}}}function aa(e,t,n){let r=this;return i;function i(t){return t===null?n(t):(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),a)}function a(e){return r.parser.lazy[r.now().line]?n(e):t(e)}}var oa={name:`codeIndented`,tokenize:ca},sa={partial:!0,tokenize:la};function ca(e,t,n){let r=this;return i;function i(t){return e.enter(`codeIndented`),G(e,a,`linePrefix`,5)(t)}function a(e){let t=r.events[r.events.length-1];return t&&t[1].type===`linePrefix`&&t[2].sliceSerialize(t[1],!0).length>=4?o(e):n(e)}function o(t){return t===null?c(t):H(t)?e.attempt(sa,o,c)(t):(e.enter(`codeFlowValue`),s(t))}function s(t){return t===null||H(t)?(e.exit(`codeFlowValue`),o(t)):(e.consume(t),s)}function c(n){return e.exit(`codeIndented`),t(n)}}function la(e,t,n){let r=this;return i;function i(t){return r.parser.lazy[r.now().line]?n(t):H(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),i):G(e,a,`linePrefix`,5)(t)}function a(e){let a=r.events[r.events.length-1];return a&&a[1].type===`linePrefix`&&a[2].sliceSerialize(a[1],!0).length>=4?t(e):H(e)?i(e):n(e)}}var ua={name:`codeText`,previous:fa,resolve:da,tokenize:pa};function da(e){let t=e.length-4,n=3,r,i;if((e[n][1].type===`lineEnding`||e[n][1].type===`space`)&&(e[t][1].type===`lineEnding`||e[t][1].type===`space`)){for(r=n;++r=this.left.length+this.right.length)throw RangeError("Cannot access index `"+e+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return ethis.left.length?this.right.slice(this.right.length-n+this.left.length,this.right.length-e+this.left.length).reverse():this.left.slice(e).concat(this.right.slice(this.right.length-n+this.left.length).reverse())}splice(e,t,n){let r=t||0;this.setCursor(Math.trunc(e));let i=this.right.splice(this.right.length-r,1/0);return n&&ha(this.left,n),i.reverse()}pop(){return this.setCursor(1/0),this.left.pop()}push(e){this.setCursor(1/0),this.left.push(e)}pushMany(e){this.setCursor(1/0),ha(this.left,e)}unshift(e){this.setCursor(0),this.right.push(e)}unshiftMany(e){this.setCursor(0),ha(this.right,e.reverse())}setCursor(e){if(!(e===this.left.length||e>this.left.length&&this.right.length===0||e<0&&this.left.length===0)){if(e=4?t(i):e.interrupt(r.parser.constructs.flow,n,t)(i)}}function Sa(e,t,n,r,i,a,o,s,c){let l=c||1/0,u=0;return d;function d(t){return t===60?(e.enter(r),e.enter(i),e.enter(a),e.consume(t),e.exit(a),f):t===null||t===32||t===41||Ei(t)?n(t):(e.enter(r),e.enter(o),e.enter(s),e.enter(`chunkString`,{contentType:`string`}),h(t))}function f(n){return n===62?(e.enter(a),e.consume(n),e.exit(a),e.exit(i),e.exit(r),t):(e.enter(s),e.enter(`chunkString`,{contentType:`string`}),p(n))}function p(t){return t===62?(e.exit(`chunkString`),e.exit(s),f(t)):t===null||t===60||H(t)?n(t):(e.consume(t),t===92?m:p)}function m(t){return t===60||t===62||t===92?(e.consume(t),p):p(t)}function h(i){return!u&&(i===null||i===41||U(i))?(e.exit(`chunkString`),e.exit(s),e.exit(o),e.exit(r),t(i)):u999||l===null||l===91||l===93&&!c||l===94&&!s&&`_hiddenFootnoteSupport`in o.parser.constructs?n(l):l===93?(e.exit(a),e.enter(i),e.consume(l),e.exit(i),e.exit(r),t):H(l)?(e.enter(`lineEnding`),e.consume(l),e.exit(`lineEnding`),u):(e.enter(`chunkString`,{contentType:`string`}),d(l))}function d(t){return t===null||t===91||t===93||H(t)||s++>999?(e.exit(`chunkString`),u(t)):(e.consume(t),c||=!W(t),t===92?f:d)}function f(t){return t===91||t===92||t===93?(e.consume(t),s++,d):d(t)}}function wa(e,t,n,r,i,a){let o;return s;function s(t){return t===34||t===39||t===40?(e.enter(r),e.enter(i),e.consume(t),e.exit(i),o=t===40?41:t,c):n(t)}function c(n){return n===o?(e.enter(i),e.consume(n),e.exit(i),e.exit(r),t):(e.enter(a),l(n))}function l(t){return t===o?(e.exit(a),c(o)):t===null?n(t):H(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),G(e,l,`linePrefix`)):(e.enter(`chunkString`,{contentType:`string`}),u(t))}function u(t){return t===o||t===null||H(t)?(e.exit(`chunkString`),l(t)):(e.consume(t),t===92?d:u)}function d(t){return t===o||t===92?(e.consume(t),u):u(t)}}function Ta(e,t){let n;return r;function r(i){return H(i)?(e.enter(`lineEnding`),e.consume(i),e.exit(`lineEnding`),n=!0,r):W(i)?G(e,r,n?`linePrefix`:`lineSuffix`)(i):t(i)}}var Ea={name:`definition`,tokenize:Oa},Da={partial:!0,tokenize:ka};function Oa(e,t,n){let r=this,i;return a;function a(t){return e.enter(`definition`),o(t)}function o(t){return Ca.call(r,e,s,n,`definitionLabel`,`definitionLabelMarker`,`definitionLabelString`)(t)}function s(t){return i=Si(r.sliceSerialize(r.events[r.events.length-1][1]).slice(1,-1)),t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),c):n(t)}function c(t){return U(t)?Ta(e,l)(t):l(t)}function l(t){return Sa(e,u,n,`definitionDestination`,`definitionDestinationLiteral`,`definitionDestinationLiteralMarker`,`definitionDestinationRaw`,`definitionDestinationString`)(t)}function u(t){return e.attempt(Da,d,d)(t)}function d(t){return W(t)?G(e,f,`whitespace`)(t):f(t)}function f(a){return a===null||H(a)?(e.exit(`definition`),r.parser.defined.push(i),t(a)):n(a)}}function ka(e,t,n){return r;function r(t){return U(t)?Ta(e,i)(t):n(t)}function i(t){return wa(e,a,n,`definitionTitle`,`definitionTitleMarker`,`definitionTitleString`)(t)}function a(t){return W(t)?G(e,o,`whitespace`)(t):o(t)}function o(e){return e===null||H(e)?t(e):n(e)}}var Aa={name:`hardBreakEscape`,tokenize:ja};function ja(e,t,n){return r;function r(t){return e.enter(`hardBreakEscape`),e.consume(t),i}function i(r){return H(r)?(e.exit(`hardBreakEscape`),t(r)):n(r)}}var Ma={name:`headingAtx`,resolve:Na,tokenize:Pa};function Na(e,t){let n=e.length-2,r=3,i,a;return e[r][1].type===`whitespace`&&(r+=2),n-2>r&&e[n][1].type===`whitespace`&&(n-=2),e[n][1].type===`atxHeadingSequence`&&(r===n-1||n-4>r&&e[n-2][1].type===`whitespace`)&&(n-=r+1===n?2:4),n>r&&(i={type:`atxHeadingText`,start:e[r][1].start,end:e[n][1].end},a={type:`chunkText`,start:e[r][1].start,end:e[n][1].end,contentType:`text`},hi(e,r,n-r+1,[[`enter`,i,t],[`enter`,a,t],[`exit`,a,t],[`exit`,i,t]])),e}function Pa(e,t,n){let r=0;return i;function i(t){return e.enter(`atxHeading`),a(t)}function a(t){return e.enter(`atxHeadingSequence`),o(t)}function o(t){return t===35&&r++<6?(e.consume(t),o):t===null||U(t)?(e.exit(`atxHeadingSequence`),s(t)):n(t)}function s(n){return n===35?(e.enter(`atxHeadingSequence`),c(n)):n===null||H(n)?(e.exit(`atxHeading`),t(n)):W(n)?G(e,s,`whitespace`)(n):(e.enter(`atxHeadingText`),l(n))}function c(t){return t===35?(e.consume(t),c):(e.exit(`atxHeadingSequence`),s(t))}function l(t){return t===null||t===35||U(t)?(e.exit(`atxHeadingText`),s(t)):(e.consume(t),l)}}var Fa=`address.article.aside.base.basefont.blockquote.body.caption.center.col.colgroup.dd.details.dialog.dir.div.dl.dt.fieldset.figcaption.figure.footer.form.frame.frameset.h1.h2.h3.h4.h5.h6.head.header.hr.html.iframe.legend.li.link.main.menu.menuitem.nav.noframes.ol.optgroup.option.p.param.search.section.summary.table.tbody.td.tfoot.th.thead.title.tr.track.ul`.split(`.`),Ia=[`pre`,`script`,`style`,`textarea`],La={concrete:!0,name:`htmlFlow`,resolveTo:Ba,tokenize:Va},Ra={partial:!0,tokenize:Ua},za={partial:!0,tokenize:Ha};function Ba(e){let t=e.length;for(;t--&&(e[t][0]!==`enter`||e[t][1].type!==`htmlFlow`););return t>1&&e[t-2][1].type===`linePrefix`&&(e[t][1].start=e[t-2][1].start,e[t+1][1].start=e[t-2][1].start,e.splice(t-2,2)),e}function Va(e,t,n){let r=this,i,a,o,s,c;return l;function l(e){return u(e)}function u(t){return e.enter(`htmlFlow`),e.enter(`htmlFlowData`),e.consume(t),d}function d(s){return s===33?(e.consume(s),f):s===47?(e.consume(s),a=!0,h):s===63?(e.consume(s),i=3,r.interrupt?t:M):Ci(s)?(e.consume(s),o=String.fromCharCode(s),g):n(s)}function f(a){return a===45?(e.consume(a),i=2,p):a===91?(e.consume(a),i=5,s=0,m):Ci(a)?(e.consume(a),i=4,r.interrupt?t:M):n(a)}function p(i){return i===45?(e.consume(i),r.interrupt?t:M):n(i)}function m(i){return i===`CDATA[`.charCodeAt(s++)?(e.consume(i),s===6?r.interrupt?t:O:m):n(i)}function h(t){return Ci(t)?(e.consume(t),o=String.fromCharCode(t),g):n(t)}function g(s){if(s===null||s===47||s===62||U(s)){let c=s===47,l=o.toLowerCase();return!c&&!a&&Ia.includes(l)?(i=1,r.interrupt?t(s):O(s)):Fa.includes(o.toLowerCase())?(i=6,c?(e.consume(s),_):r.interrupt?t(s):O(s)):(i=7,r.interrupt&&!r.parser.lazy[r.now().line]?n(s):a?v(s):y(s))}return s===45||wi(s)?(e.consume(s),o+=String.fromCharCode(s),g):n(s)}function _(i){return i===62?(e.consume(i),r.interrupt?t:O):n(i)}function v(t){return W(t)?(e.consume(t),v):E(t)}function y(t){return t===47?(e.consume(t),E):t===58||t===95||Ci(t)?(e.consume(t),b):W(t)?(e.consume(t),y):E(t)}function b(t){return t===45||t===46||t===58||t===95||wi(t)?(e.consume(t),b):x(t)}function x(t){return t===61?(e.consume(t),S):W(t)?(e.consume(t),x):y(t)}function S(t){return t===null||t===60||t===61||t===62||t===96?n(t):t===34||t===39?(e.consume(t),c=t,C):W(t)?(e.consume(t),S):w(t)}function C(t){return t===c?(e.consume(t),c=null,T):t===null||H(t)?n(t):(e.consume(t),C)}function w(t){return t===null||t===34||t===39||t===47||t===60||t===61||t===62||t===96||U(t)?x(t):(e.consume(t),w)}function T(e){return e===47||e===62||W(e)?y(e):n(e)}function E(t){return t===62?(e.consume(t),D):n(t)}function D(t){return t===null||H(t)?O(t):W(t)?(e.consume(t),D):n(t)}function O(t){return t===45&&i===2?(e.consume(t),te):t===60&&i===1?(e.consume(t),j):t===62&&i===4?(e.consume(t),ie):t===63&&i===3?(e.consume(t),M):t===93&&i===5?(e.consume(t),re):H(t)&&(i===6||i===7)?(e.exit(`htmlFlowData`),e.check(Ra,N,k)(t)):t===null||H(t)?(e.exit(`htmlFlowData`),k(t)):(e.consume(t),O)}function k(t){return e.check(za,ee,N)(t)}function ee(t){return e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),A}function A(t){return t===null||H(t)?k(t):(e.enter(`htmlFlowData`),O(t))}function te(t){return t===45?(e.consume(t),M):O(t)}function j(t){return t===47?(e.consume(t),o=``,ne):O(t)}function ne(t){if(t===62){let n=o.toLowerCase();return Ia.includes(n)?(e.consume(t),ie):O(t)}return Ci(t)&&o.length<8?(e.consume(t),o+=String.fromCharCode(t),ne):O(t)}function re(t){return t===93?(e.consume(t),M):O(t)}function M(t){return t===62?(e.consume(t),ie):t===45&&i===2?(e.consume(t),M):O(t)}function ie(t){return t===null||H(t)?(e.exit(`htmlFlowData`),N(t)):(e.consume(t),ie)}function N(n){return e.exit(`htmlFlow`),t(n)}}function Ha(e,t,n){let r=this;return i;function i(t){return H(t)?(e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),a):n(t)}function a(e){return r.parser.lazy[r.now().line]?n(e):t(e)}}function Ua(e,t,n){return r;function r(r){return e.enter(`lineEnding`),e.consume(r),e.exit(`lineEnding`),e.attempt(Ki,t,n)}}var Wa={name:`htmlText`,tokenize:Ga};function Ga(e,t,n){let r=this,i,a,o;return s;function s(t){return e.enter(`htmlText`),e.enter(`htmlTextData`),e.consume(t),c}function c(t){return t===33?(e.consume(t),l):t===47?(e.consume(t),x):t===63?(e.consume(t),y):Ci(t)?(e.consume(t),w):n(t)}function l(t){return t===45?(e.consume(t),u):t===91?(e.consume(t),a=0,m):Ci(t)?(e.consume(t),v):n(t)}function u(t){return t===45?(e.consume(t),p):n(t)}function d(t){return t===null?n(t):t===45?(e.consume(t),f):H(t)?(o=d,j(t)):(e.consume(t),d)}function f(t){return t===45?(e.consume(t),p):d(t)}function p(e){return e===62?te(e):e===45?f(e):d(e)}function m(t){return t===`CDATA[`.charCodeAt(a++)?(e.consume(t),a===6?h:m):n(t)}function h(t){return t===null?n(t):t===93?(e.consume(t),g):H(t)?(o=h,j(t)):(e.consume(t),h)}function g(t){return t===93?(e.consume(t),_):h(t)}function _(t){return t===62?te(t):t===93?(e.consume(t),_):h(t)}function v(t){return t===null||t===62?te(t):H(t)?(o=v,j(t)):(e.consume(t),v)}function y(t){return t===null?n(t):t===63?(e.consume(t),b):H(t)?(o=y,j(t)):(e.consume(t),y)}function b(e){return e===62?te(e):y(e)}function x(t){return Ci(t)?(e.consume(t),S):n(t)}function S(t){return t===45||wi(t)?(e.consume(t),S):C(t)}function C(t){return H(t)?(o=C,j(t)):W(t)?(e.consume(t),C):te(t)}function w(t){return t===45||wi(t)?(e.consume(t),w):t===47||t===62||U(t)?T(t):n(t)}function T(t){return t===47?(e.consume(t),te):t===58||t===95||Ci(t)?(e.consume(t),E):H(t)?(o=T,j(t)):W(t)?(e.consume(t),T):te(t)}function E(t){return t===45||t===46||t===58||t===95||wi(t)?(e.consume(t),E):D(t)}function D(t){return t===61?(e.consume(t),O):H(t)?(o=D,j(t)):W(t)?(e.consume(t),D):T(t)}function O(t){return t===null||t===60||t===61||t===62||t===96?n(t):t===34||t===39?(e.consume(t),i=t,k):H(t)?(o=O,j(t)):W(t)?(e.consume(t),O):(e.consume(t),ee)}function k(t){return t===i?(e.consume(t),i=void 0,A):t===null?n(t):H(t)?(o=k,j(t)):(e.consume(t),k)}function ee(t){return t===null||t===34||t===39||t===60||t===61||t===96?n(t):t===47||t===62||U(t)?T(t):(e.consume(t),ee)}function A(e){return e===47||e===62||U(e)?T(e):n(e)}function te(r){return r===62?(e.consume(r),e.exit(`htmlTextData`),e.exit(`htmlText`),t):n(r)}function j(t){return e.exit(`htmlTextData`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),ne}function ne(t){return W(t)?G(e,re,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):re(t)}function re(t){return e.enter(`htmlTextData`),o(t)}}var Ka={name:`labelEnd`,resolveAll:Xa,resolveTo:Za,tokenize:Qa},qa={tokenize:$a},Ja={tokenize:eo},Ya={tokenize:to};function Xa(e){let t=-1,n=[];for(;++t=3&&(a===null||H(a))?(e.exit(`thematicBreak`),t(a)):n(a)}function c(t){return t===i?(e.consume(t),r++,c):(e.exit(`thematicBreakSequence`),W(t)?G(e,s,`whitespace`)(t):s(t))}}var uo={continuation:{tokenize:ho},exit:go,name:`list`,tokenize:mo},fo={partial:!0,tokenize:_o},po={partial:!0,tokenize:Y};function mo(e,t,n){let r=this,i=r.events[r.events.length-1],a=i&&i[1].type===`linePrefix`?i[2].sliceSerialize(i[1],!0).length:0,o=0;return s;function s(t){let i=r.containerState.type||(t===42||t===43||t===45?`listUnordered`:`listOrdered`);if(i===`listUnordered`?!r.containerState.marker||t===r.containerState.marker:Di(t)){if(r.containerState.type||(r.containerState.type=i,e.enter(i,{_container:!0})),i===`listUnordered`)return e.enter(`listItemPrefix`),t===42||t===45?e.check(co,n,l)(t):l(t);if(!r.interrupt||t===49)return e.enter(`listItemPrefix`),e.enter(`listItemValue`),c(t)}return n(t)}function c(t){return Di(t)&&++o<10?(e.consume(t),c):(!r.interrupt||o<2)&&(r.containerState.marker?t===r.containerState.marker:t===41||t===46)?(e.exit(`listItemValue`),l(t)):n(t)}function l(t){return e.enter(`listItemMarker`),e.consume(t),e.exit(`listItemMarker`),r.containerState.marker=r.containerState.marker||t,e.check(Ki,r.interrupt?n:u,e.attempt(fo,f,d))}function u(e){return r.containerState.initialBlankLine=!0,a++,f(e)}function d(t){return W(t)?(e.enter(`listItemPrefixWhitespace`),e.consume(t),e.exit(`listItemPrefixWhitespace`),f):n(t)}function f(n){return r.containerState.size=a+r.sliceSerialize(e.exit(`listItemPrefix`),!0).length,t(n)}}function ho(e,t,n){let r=this;return r.containerState._closeFlow=void 0,e.check(Ki,i,a);function i(n){return r.containerState.furtherBlankLines=r.containerState.furtherBlankLines||r.containerState.initialBlankLine,G(e,t,`listItemIndent`,r.containerState.size+1)(n)}function a(n){return r.containerState.furtherBlankLines||!W(n)?(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,o(n)):(r.containerState.furtherBlankLines=void 0,r.containerState.initialBlankLine=void 0,e.attempt(po,t,o)(n))}function o(i){return r.containerState._closeFlow=!0,r.interrupt=void 0,G(e,e.attempt(uo,t,n),`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(i)}}function Y(e,t,n){let r=this;return G(e,i,`listItemIndent`,r.containerState.size+1);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`listItemIndent`&&i[2].sliceSerialize(i[1],!0).length===r.containerState.size?t(e):n(e)}}function go(e){e.exit(this.containerState.type)}function _o(e,t,n){let r=this;return G(e,i,`listItemPrefixWhitespace`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:5);function i(e){let i=r.events[r.events.length-1];return!W(e)&&i&&i[1].type===`listItemPrefixWhitespace`?t(e):n(e)}}var vo={name:`setextUnderline`,resolveTo:yo,tokenize:bo};function yo(e,t){let n=e.length,r,i,a;for(;n--;)if(e[n][0]===`enter`){if(e[n][1].type===`content`){r=n;break}e[n][1].type===`paragraph`&&(i=n)}else e[n][1].type===`content`&&e.splice(n,1),!a&&e[n][1].type===`definition`&&(a=n);let o={type:`setextHeading`,start:{...e[r][1].start},end:{...e[e.length-1][1].end}};return e[i][1].type=`setextHeadingText`,a?(e.splice(i,0,[`enter`,o,t]),e.splice(a+1,0,[`exit`,e[r][1],t]),e[r][1].end={...e[a][1].end}):e[r][1]=o,e.push([`exit`,o,t]),e}function bo(e,t,n){let r=this,i;return a;function a(t){let a=r.events.length,s;for(;a--;)if(r.events[a][1].type!==`lineEnding`&&r.events[a][1].type!==`linePrefix`&&r.events[a][1].type!==`content`){s=r.events[a][1].type===`paragraph`;break}return!r.parser.lazy[r.now().line]&&(r.interrupt||s)?(e.enter(`setextHeadingLine`),i=t,o(t)):n(t)}function o(t){return e.enter(`setextHeadingLineSequence`),s(t)}function s(t){return t===i?(e.consume(t),s):(e.exit(`setextHeadingLineSequence`),W(t)?G(e,c,`lineSuffix`)(t):c(t))}function c(r){return r===null||H(r)?(e.exit(`setextHeadingLine`),t(r)):n(r)}}var xo={tokenize:X};function X(e){let t=this,n=e.attempt(Ki,r,e.attempt(this.parser.constructs.flowInitial,i,G(e,e.attempt(this.parser.constructs.flow,i,e.attempt(va,i)),`linePrefix`)));return n;function r(r){if(r===null){e.consume(r);return}return e.enter(`lineEndingBlank`),e.consume(r),e.exit(`lineEndingBlank`),t.currentConstruct=void 0,n}function i(r){if(r===null){e.consume(r);return}return e.enter(`lineEnding`),e.consume(r),e.exit(`lineEnding`),t.currentConstruct=void 0,n}}var So={resolveAll:Eo()},Co=To(`string`),wo=To(`text`);function To(e){return{resolveAll:Eo(e===`text`?Do:void 0),tokenize:t};function t(t){let n=this,r=this.parser.constructs[e],i=t.attempt(r,a,o);return a;function a(e){return c(e)?i(e):o(e)}function o(e){if(e===null){t.consume(e);return}return t.enter(`data`),t.consume(e),s}function s(e){return c(e)?(t.exit(`data`),i(e)):(t.consume(e),s)}function c(e){if(e===null)return!0;let t=r[e],i=-1;if(t)for(;++iIo,contentInitial:()=>Ao,disable:()=>Lo,document:()=>ko,flow:()=>Mo,flowInitial:()=>jo,insideSpan:()=>Fo,string:()=>No,text:()=>Po}),ko={42:uo,43:uo,45:uo,48:uo,49:uo,50:uo,51:uo,52:uo,53:uo,54:uo,55:uo,56:uo,57:uo,62:Ji},Ao={91:Ea},jo={[-2]:oa,[-1]:oa,32:oa},Mo={35:Ma,42:co,45:[vo,co],60:La,61:vo,95:co,96:ra,126:ra},No={38:ea,92:Qi},Po={[-5]:oo,[-4]:oo,[-3]:oo,33:no,38:ea,42:Hi,60:[Wi,Wa],91:io,92:[Aa,Qi],93:Ka,95:Hi,96:ua},Fo={null:[Hi,So]},Io={null:[42,95]},Lo={null:[]};function Ro(e,t,n){let r={_bufferIndex:-1,_index:0,line:n&&n.line||1,column:n&&n.column||1,offset:n&&n.offset||0},i={},a=[],o=[],s=[],c={attempt:C(x),check:C(S),consume:v,enter:y,exit:b,interrupt:C(S,{interrupt:!0})},l={code:null,containerState:{},defineSkip:h,events:[],now:m,parser:e,previous:null,sliceSerialize:f,sliceStream:p,write:d},u=t.tokenize.call(l,c);return t.resolveAll&&a.push(t),l;function d(e){return o=gi(o,e),g(),o[o.length-1]===null?(w(t,0),l.events=Vi(a,l.events,l),l.events):[]}function f(e,t){return Bo(p(e),t)}function p(e){return zo(o,e)}function m(){let{_bufferIndex:e,_index:t,line:n,column:i,offset:a}=r;return{_bufferIndex:e,_index:t,line:n,column:i,offset:a}}function h(e){i[e.line]=e.column,E()}function g(){let e;for(;r._index-1){let e=o[0];typeof e==`string`?o[0]=e.slice(r):o.shift()}a>0&&o.push(e[i].slice(0,a))}return o}function Bo(e,t){let n=-1,r=[],i;for(;++n0){let e=a.tokenStack[a.tokenStack.length-1];(e[1]||es).call(a,void 0,e[0])}for(r.position={start:Zo(e.length>0?e[0][1].start:{line:1,column:1,offset:0}),end:Zo(e.length>0?e[e.length-2][1].end:{line:1,column:1,offset:0})},d=-1;++d0&&(r.className=[`language-`+i[0]]);let a={type:`element`,tagName:`code`,properties:r,children:[{type:`text`,value:n}]};return t.meta&&(a.data={meta:t.meta}),e.patch(t,a),a=e.applyData(t,a),a={type:`element`,tagName:`pre`,properties:{},children:[a]},e.patch(t,a),a}function as(e,t){let n={type:`element`,tagName:`del`,properties:{},children:e.all(t)};return e.patch(t,n),e.applyData(t,n)}function os(e,t){let n={type:`element`,tagName:`em`,properties:{},children:e.all(t)};return e.patch(t,n),e.applyData(t,n)}function ss(e,t){let n=typeof e.options.clobberPrefix==`string`?e.options.clobberPrefix:`user-content-`,r=String(t.identifier).toUpperCase(),i=Ni(r.toLowerCase()),a=e.footnoteOrder.indexOf(r),o,s=e.footnoteCounts.get(r);s===void 0?(s=0,e.footnoteOrder.push(r),o=e.footnoteOrder.length):o=a+1,s+=1,e.footnoteCounts.set(r,s);let c={type:`element`,tagName:`a`,properties:{href:`#`+n+`fn-`+i,id:n+`fnref-`+i+(s>1?`-`+s:``),dataFootnoteRef:!0,ariaDescribedBy:[`footnote-label`]},children:[{type:`text`,value:String(o)}]};e.patch(t,c);let l={type:`element`,tagName:`sup`,properties:{},children:[c]};return e.patch(t,l),e.applyData(t,l)}function cs(e,t){let n={type:`element`,tagName:`h`+t.depth,properties:{},children:e.all(t)};return e.patch(t,n),e.applyData(t,n)}function ls(e,t){if(e.options.allowDangerousHtml){let n={type:`raw`,value:t.value};return e.patch(t,n),e.applyData(t,n)}}function us(e,t){let n=t.referenceType,r=`]`;if(n===`collapsed`?r+=`[]`:n===`full`&&(r+=`[`+(t.label||t.identifier)+`]`),t.type===`imageReference`)return[{type:`text`,value:`![`+t.alt+r}];let i=e.all(t),a=i[0];a&&a.type===`text`?a.value=`[`+a.value:i.unshift({type:`text`,value:`[`});let o=i[i.length-1];return o&&o.type===`text`?o.value+=r:i.push({type:`text`,value:r}),i}function ds(e,t){let n=String(t.identifier).toUpperCase(),r=e.definitionById.get(n);if(!r)return us(e,t);let i={src:Ni(r.url||``),alt:t.alt};r.title!==null&&r.title!==void 0&&(i.title=r.title);let a={type:`element`,tagName:`img`,properties:i,children:[]};return e.patch(t,a),e.applyData(t,a)}function fs(e,t){let n={src:Ni(t.url)};t.alt!==null&&t.alt!==void 0&&(n.alt=t.alt),t.title!==null&&t.title!==void 0&&(n.title=t.title);let r={type:`element`,tagName:`img`,properties:n,children:[]};return e.patch(t,r),e.applyData(t,r)}function ps(e,t){let n={type:`text`,value:t.value.replace(/\r?\n|\r/g,` `)};e.patch(t,n);let r={type:`element`,tagName:`code`,properties:{},children:[n]};return e.patch(t,r),e.applyData(t,r)}function ms(e,t){let n=String(t.identifier).toUpperCase(),r=e.definitionById.get(n);if(!r)return us(e,t);let i={href:Ni(r.url||``)};r.title!==null&&r.title!==void 0&&(i.title=r.title);let a={type:`element`,tagName:`a`,properties:i,children:e.all(t)};return e.patch(t,a),e.applyData(t,a)}function hs(e,t){let n={href:Ni(t.url)};t.title!==null&&t.title!==void 0&&(n.title=t.title);let r={type:`element`,tagName:`a`,properties:n,children:e.all(t)};return e.patch(t,r),e.applyData(t,r)}function gs(e,t,n){let r=e.all(t),i=n?_s(n):vs(t),a={},o=[];if(typeof t.checked==`boolean`){let e=r[0],n;e&&e.type===`element`&&e.tagName===`p`?n=e:(n={type:`element`,tagName:`p`,properties:{},children:[]},r.unshift(n)),n.children.length>0&&n.children.unshift({type:`text`,value:` `}),n.children.unshift({type:`element`,tagName:`input`,properties:{type:`checkbox`,checked:t.checked,disabled:!0},children:[]}),a.className=[`task-list-item`]}let s=-1;for(;++s1}function ys(e,t){let n={},r=e.all(t),i=-1;for(typeof t.start==`number`&&t.start!==1&&(n.start=t.start);++i0){let r={type:`element`,tagName:`tbody`,properties:{},children:e.wrap(n,!0)},a=Tr(t.children[1]),o=wr(t.children[t.children.length-1]);a&&o&&(r.position={start:a,end:o}),i.push(r)}let a={type:`element`,tagName:`table`,properties:{},children:e.wrap(i,!0)};return e.patch(t,a),e.applyData(t,a)}function ws(e,t,n){let r=n?n.children:void 0,i=(r?r.indexOf(t):1)===0?`th`:`td`,a=n&&n.type===`table`?n.align:void 0,o=a?a.length:t.children.length,s=-1,c=[];for(;++s0,!0),r[0]),i=r.index+r[0].length,r=n.exec(t);return a.push(ks(t.slice(i),i>0,!1)),a.join(``)}function ks(e,t,n){let r=0,i=e.length;if(t){let t=e.codePointAt(r);for(;t===Es||t===Ds;)r++,t=e.codePointAt(r)}if(n){let t=e.codePointAt(i-1);for(;t===Es||t===Ds;)i--,t=e.codePointAt(i-1)}return i>r?e.slice(r,i):``}function As(e,t){let n={type:`text`,value:Os(String(t.value))};return e.patch(t,n),e.applyData(t,n)}function js(e,t){let n={type:`element`,tagName:`hr`,properties:{},children:[]};return e.patch(t,n),e.applyData(t,n)}var Ms={blockquote:ns,break:rs,code:is,delete:as,emphasis:os,footnoteReference:ss,heading:cs,html:ls,imageReference:ds,image:fs,inlineCode:ps,linkReference:ms,link:hs,listItem:gs,list:ys,paragraph:bs,root:xs,strong:Ss,table:Cs,tableCell:Ts,tableRow:ws,text:As,thematicBreak:js,toml:Ns,yaml:Ns,definition:Ns,footnoteDefinition:Ns};function Ns(){}var{defineProperty:Ps}=Object,Fs=typeof self==`object`?self:globalThis,Is=(e,t)=>{switch(e){case`Function`:case`SharedWorker`:case`Worker`:case`eval`:case`setInterval`:case`setTimeout`:throw TypeError(`unable to deserialize `+e)}return new Fs[e](t)},Ls=(e,t)=>{let n=(t,n)=>(e.set(n,t),t),r=i=>{if(e.has(i))return e.get(i);let[a,o]=t[i];switch(a){case 0:case-1:return n(o,i);case 1:{let e=n([],i);for(let t of o)e.push(r(t));return e}case 2:{let e=n({},i);for(let[t,n]of o){let i=r(t),a=r(n);i===`__proto__`?Ps(e,i,{value:a,configurable:!0,enumerable:!0,writable:!0}):e[i]=a}return e}case 3:return n(new Date(o),i);case 4:{let{source:e,flags:t}=o;return n(new RegExp(e,t),i)}case 5:{let e=n(new Map,i);for(let[t,n]of o)e.set(r(t),r(n));return e}case 6:{let e=n(new Set,i);for(let t of o)e.add(r(t));return e}case 7:{let{name:e,message:t}=o;return n(typeof Fs[e]==`function`?Is(e,t):Error(t),i)}case 8:return n(BigInt(o),i);case`BigInt`:return n(Object(BigInt(o)),i);case`ArrayBuffer`:return n(new Uint8Array(o).buffer,o);case`DataView`:{let{buffer:e}=new Uint8Array(o);return n(new DataView(e),o)}case`-0`:return-0}return n(Is(a,o),i)};return r},Rs=e=>Ls(new Map,e)(0),zs=``,{toString:Bs}={},{keys:Vs,is:Hs}=Object,Us=e=>{let t=typeof e;if(t!==`object`||!e)return[0,t];let n=Bs.call(e).slice(8,-1);switch(n){case`Array`:return[1,zs];case`Object`:return[2,zs];case`Date`:return[3,zs];case`RegExp`:return[4,zs];case`Map`:return[5,zs];case`Set`:return[6,zs];case`DataView`:return[1,n]}return n.includes(`Array`)?[1,n]:e instanceof Error?[7,e.name||`Error`]:[2,n]},Ws=([e,t])=>e===0&&(t===`function`||t===`symbol`),Gs=(e,t,n,r)=>{let i=(e,t)=>{let i=r.push(e)-1;return n.set(t,i),i},a=o=>{if(n.has(o))return n.get(o);let[s,c]=Us(o);switch(s){case 0:{let t=o;switch(c){case`bigint`:s=8,t=o.toString();break;case`number`:if(!o&&Hs(o,-0))return r.push([`-0`])-1;break;case`function`:case`symbol`:if(e)throw TypeError(`unable to serialize `+c);t=null;break;case`undefined`:return i([-1],o)}return i([s,t],o)}case 1:{if(c){let e=o;return c===`DataView`?e=new Uint8Array(o.buffer):c===`ArrayBuffer`&&(e=new Uint8Array(o)),i([c,[...e]],o)}let e=[],t=i([s,e],o);for(let t of o)e.push(a(t));return t}case 2:{if(c)switch(c){case`BigInt`:return i([c,o.toString()],o);case`Boolean`:case`Number`:case`String`:return i([c,o.valueOf()],o)}if(t&&`toJSON`in o)return a(o.toJSON());let n=[],r=i([s,n],o);for(let t of Vs(o))(e||!Ws(Us(o[t])))&&n.push([a(t),a(o[t])]);return r}case 3:return i([s,isNaN(o.getTime())?zs:o.toISOString()],o);case 4:{let{source:e,flags:t}=o;return i([s,{source:e,flags:t}],o)}case 5:{let t=[],n=i([s,t],o);for(let[n,r]of o)(e||!(Ws(Us(n))||Ws(Us(r))))&&t.push([a(n),a(r)]);return n}case 6:{let t=[],n=i([s,t],o);for(let n of o)(e||!Ws(Us(n)))&&t.push(a(n));return n}}let{message:l}=o;return i([s,{name:c,message:l}],o)};return a},Ks=(e,{json:t,lossy:n}={})=>{let r=[];return Gs(!(t||n),!!t,new Map,r)(e),r},qs=typeof structuredClone==`function`?(e,t)=>t&&(`json`in t||`lossy`in t)?Rs(Ks(e,t)):structuredClone(e):(e,t)=>Rs(Ks(e,t));function Js(e,t){let n=[{type:`text`,value:`↩`}];return t>1&&n.push({type:`element`,tagName:`sup`,properties:{},children:[{type:`text`,value:String(t)}]}),n}function Ys(e,t){return`Back to reference `+(e+1)+(t>1?`-`+t:``)}function Xs(e){let t=typeof e.options.clobberPrefix==`string`?e.options.clobberPrefix:`user-content-`,n=e.options.footnoteBackContent||Js,r=e.options.footnoteBackLabel||Ys,i=e.options.footnoteLabel||`Footnotes`,a=e.options.footnoteLabelTagName||`h2`,o=e.options.footnoteLabelProperties||{className:[`sr-only`]},s=[],c=-1;for(;++c0&&d.push({type:`text`,value:` `});let e=typeof n==`string`?n:n(c,u);typeof e==`string`&&(e={type:`text`,value:e}),d.push({type:`element`,tagName:`a`,properties:{href:`#`+t+`fnref-`+l+(u>1?`-`+u:``),dataFootnoteBackref:``,ariaLabel:typeof r==`string`?r:r(c,u),className:[`data-footnote-backref`]},children:Array.isArray(e)?e:[e]})}let p=a[a.length-1];if(p&&p.type===`element`&&p.tagName===`p`){let e=p.children[p.children.length-1];e&&e.type===`text`?e.value+=` `:p.children.push({type:`text`,value:` `}),p.children.push(...d)}else a.push(...d);let m={type:`element`,tagName:`li`,properties:{id:t+`fn-`+l},children:e.wrap(a,!0)};e.patch(i,m),s.push(m)}if(s.length!==0)return{type:`element`,tagName:`section`,properties:{dataFootnotes:!0,className:[`footnotes`]},children:[{type:`element`,tagName:a,properties:{...qs(o),id:`footnote-label`},children:[{type:`text`,value:i}]},{type:`text`,value:` +`},{type:`element`,tagName:`ol`,properties:{},children:e.wrap(s,!0)},{type:`text`,value:` +`}]}}var Zs=(function(e){if(e==null)return nc;if(typeof e==`function`)return tc(e);if(typeof e==`object`)return Array.isArray(e)?Qs(e):$s(e);if(typeof e==`string`)return ec(e);throw Error(`Expected function, string, or object as test`)});function Qs(e){let t=[],n=-1;for(;++n`:``))+`)`})}return u;function u(){let l=ac,u,d,f;if((!t||a(e,i,c[c.length-1]||void 0))&&(l=sc(n(e,c)),l[0]===!1))return l;if(`children`in e&&e.children){let t=e;if(t.children&&l[0]!==`skip`)for(d=(r?t.children.length:-1)+o,f=c.concat(t);d>-1&&d0&&n.push({type:`text`,value:` +`}),n}function hc(e){let t=0,n=e.charCodeAt(t);for(;n===9||n===32;)t++,n=e.charCodeAt(t);return e.slice(t)}function gc(e,t){let n=dc(e,t),r=n.one(e,void 0),i=Xs(n),a=Array.isArray(r)?{type:`root`,children:r}:r||{type:`root`,children:[]};return i&&(`children`in a,a.children.push({type:`text`,value:` +`},i)),a}function _c(e,t){return e&&`run`in e?async function(n,r){let i=gc(n,{file:r,...t});await e.run(i,r)}:function(n,r){return gc(n,{file:r,...e||t})}}function vc(e){if(e)throw e}var yc=b(((e,t)=>{var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=function(e){return typeof Array.isArray==`function`?Array.isArray(e):r.call(e)===`[object Array]`},s=function(e){if(!e||r.call(e)!==`[object Object]`)return!1;var t=n.call(e,`constructor`),i=e.constructor&&e.constructor.prototype&&n.call(e.constructor.prototype,`isPrototypeOf`);if(e.constructor&&!t&&!i)return!1;for(var a in e);return a===void 0||n.call(e,a)},c=function(e,t){i&&t.name===`__proto__`?i(e,t.name,{enumerable:!0,configurable:!0,value:t.newValue,writable:!0}):e[t.name]=t.newValue},l=function(e,t){if(t===`__proto__`){if(!n.call(e,t))return;if(a)return a(e,t).value}return e[t]};t.exports=function e(){var t,n,r,i,a,u,d=arguments[0],f=1,p=arguments.length,m=!1;for(typeof d==`boolean`&&(m=d,d=arguments[1]||{},f=2),(d==null||typeof d!=`object`&&typeof d!=`function`)&&(d={});ft.length,o;r&&t.push(i);try{o=e.apply(this,t)}catch(e){let t=e;if(r&&n)throw t;return i(t)}r||(o&&o.then&&typeof o.then==`function`?o.then(a,i):o instanceof Error?i(o):a(o))}function i(e,...r){n||(n=!0,t(e,...r))}function a(e){i(null,e)}}var Cc={basename:wc,dirname:Tc,extname:Ec,join:Dc,sep:`/`};function wc(e,t){if(t!==void 0&&typeof t!=`string`)throw TypeError(`"ext" argument must be a string`);Ac(e);let n=0,r=-1,i=e.length,a;if(t===void 0||t.length===0||t.length>e.length){for(;i--;)if(e.codePointAt(i)===47){if(a){n=i+1;break}}else r<0&&(a=!0,r=i+1);return r<0?``:e.slice(n,r)}if(t===e)return``;let o=-1,s=t.length-1;for(;i--;)if(e.codePointAt(i)===47){if(a){n=i+1;break}}else o<0&&(a=!0,o=i+1),s>-1&&(e.codePointAt(i)===t.codePointAt(s--)?s<0&&(r=i):(s=-1,r=o));return n===r?r=o:r<0&&(r=e.length),e.slice(n,r)}function Tc(e){if(Ac(e),e.length===0)return`.`;let t=-1,n=e.length,r;for(;--n;)if(e.codePointAt(n)===47){if(r){t=n;break}}else r||=!0;return t<0?e.codePointAt(0)===47?`/`:`.`:t===1&&e.codePointAt(0)===47?`//`:e.slice(0,t)}function Ec(e){Ac(e);let t=e.length,n=-1,r=0,i=-1,a=0,o;for(;t--;){let s=e.codePointAt(t);if(s===47){if(o){r=t+1;break}continue}n<0&&(o=!0,n=t+1),s===46?i<0?i=t:a!==1&&(a=1):i>-1&&(a=-1)}return i<0||n<0||a===0||a===1&&i===n-1&&i===r+1?``:e.slice(i,n)}function Dc(...e){let t=-1,n;for(;++t0&&e.codePointAt(e.length-1)===47&&(n+=`/`),t?`/`+n:n}function kc(e,t){let n=``,r=0,i=-1,a=0,o=-1,s,c;for(;++o<=e.length;){if(o2){if(c=n.lastIndexOf(`/`),c!==n.length-1){c<0?(n=``,r=0):(n=n.slice(0,c),r=n.length-1-n.lastIndexOf(`/`)),i=o,a=0;continue}}else if(n.length>0){n=``,r=0,i=o,a=0;continue}}t&&(n=n.length>0?n+`/..`:`..`,r=2)}else n.length>0?n+=`/`+e.slice(i+1,o):n=e.slice(i+1,o),r=o-i-1}i=o,a=0}else s===46&&a>-1?a++:a=-1}return n}function Ac(e){if(typeof e!=`string`)throw TypeError(`Path must be a string. Received `+JSON.stringify(e))}var jc={cwd:Mc};function Mc(){return`/`}function Nc(e){return!!(typeof e==`object`&&e&&`href`in e&&e.href&&`protocol`in e&&e.protocol&&e.auth===void 0)}function Pc(e){if(typeof e==`string`)e=new URL(e);else if(!Nc(e)){let t=TypeError('The "path" argument must be of type string or an instance of URL. Received `'+e+"`");throw t.code=`ERR_INVALID_ARG_TYPE`,t}if(e.protocol!==`file:`){let e=TypeError(`The URL must be of scheme file`);throw e.code=`ERR_INVALID_URL_SCHEME`,e}return Fc(e)}function Fc(e){if(e.hostname!==``){let e=TypeError(`File URL host must be "localhost" or empty on darwin`);throw e.code=`ERR_INVALID_FILE_URL_HOST`,e}let t=e.pathname,n=-1;for(;++n0){let[r,...a]=t,o=n[i][1];bc(o)&&bc(r)&&(r=(0,Hc.default)(!0,o,r)),n[i]=[e,r,...a]}}}}().freeze();function Gc(e,t){if(typeof t!=`function`)throw TypeError("Cannot `"+e+"` without `parser`")}function Kc(e,t){if(typeof t!=`function`)throw TypeError("Cannot `"+e+"` without `compiler`")}function qc(e,t){if(t)throw Error("Cannot call `"+e+"` on a frozen processor.\nCreate a new processor first, by calling it: use `processor()` instead of `processor`.")}function Jc(e){if(!bc(e)||typeof e.type!=`string`)throw TypeError("Expected node, got `"+e+"`")}function Yc(e,t,n){if(!n)throw Error("`"+e+"` finished async. Use `"+t+"` instead")}function Xc(e){return Zc(e)?e:new Lc(e)}function Zc(e){return!!(e&&typeof e==`object`&&`message`in e&&`messages`in e)}function Qc(e){return typeof e==`string`||$c(e)}function $c(e){return!!(e&&typeof e==`object`&&`byteLength`in e&&`byteOffset`in e)}var el=[],tl={allowDangerousHtml:!0},nl=/^(https?|ircs?|mailto|xmpp)$/i,rl=[{from:`astPlugins`,id:`remove-buggy-html-in-markdown-parser`},{from:`allowDangerousHtml`,id:`remove-buggy-html-in-markdown-parser`},{from:`allowNode`,id:`replace-allownode-allowedtypes-and-disallowedtypes`,to:`allowElement`},{from:`allowedTypes`,id:`replace-allownode-allowedtypes-and-disallowedtypes`,to:`allowedElements`},{from:`disallowedTypes`,id:`replace-allownode-allowedtypes-and-disallowedtypes`,to:`disallowedElements`},{from:`escapeHtml`,id:`remove-buggy-html-in-markdown-parser`},{from:`includeElementIndex`,id:`#remove-includeelementindex`},{from:`includeNodeIndex`,id:`change-includenodeindex-to-includeelementindex`},{from:`linkTarget`,id:`remove-linktarget`},{from:`plugins`,id:`change-plugins-to-remarkplugins`,to:`remarkPlugins`},{from:`rawSourcePos`,id:`#remove-rawsourcepos`},{from:`renderers`,id:`change-renderers-to-components`,to:`components`},{from:`source`,id:`change-source-to-children`,to:`children`},{from:`sourcePos`,id:`#remove-sourcepos`},{from:`transformImageUri`,id:`#add-urltransform`,to:`urlTransform`},{from:`transformLinkUri`,id:`#add-urltransform`,to:`urlTransform`}];function il(e){let t=al(e),n=ol(e);return sl(t.runSync(t.parse(n),n),e)}function al(e){let t=e.rehypePlugins||el,n=e.remarkPlugins||el,r=e.remarkRehypeOptions?{...e.remarkRehypeOptions,...tl}:tl;return Wc().use(ts).use(n).use(_c,r).use(t)}function ol(e){let t=e.children||``,n=new Lc;return typeof t==`string`?n.value=t:``+t,n}function sl(e,t){let n=t.allowedElements,r=t.allowElement,i=t.components,a=t.disallowedElements,o=t.skipHtml,s=t.unwrapDisallowed,c=t.urlTransform||cl;for(let e of rl)Object.hasOwn(t,e.from)&&``+e.from+(e.to?"use `"+e.to+"` instead":`remove it`)+e.id;return t.className&&(e={type:`element`,tagName:`div`,properties:{className:t.className},children:e.type===`root`?e.children:[e]}),cc(e,l),Br(e,{Fragment:L.Fragment,components:i,ignoreInvalidStyle:!0,jsx:L.jsx,jsxs:L.jsxs,passKeys:!0,passNode:!0});function l(e,t,i){if(e.type===`raw`&&i&&typeof t==`number`)return o?i.children.splice(t,1):i.children[t]={type:`text`,value:e.value},t;if(e.type===`element`){let t;for(t in V)if(Object.hasOwn(V,t)&&Object.hasOwn(e.properties,t)){let n=e.properties[t],r=V[t];(r===null||r.includes(e.tagName))&&(e.properties[t]=c(String(n||``),t,e))}}if(e.type===`element`){let o=n?!n.includes(e.tagName):a?a.includes(e.tagName):!1;if(!o&&r&&typeof t==`number`&&(o=!r(e,t,i)),o&&i&&typeof t==`number`)return s&&e.children?i.children.splice(t,1,...e.children):i.children.splice(t,1),t}}}function cl(e){let t=e.indexOf(`:`),n=e.indexOf(`?`),r=e.indexOf(`#`),i=e.indexOf(`/`);return t===-1||i!==-1&&t>i||n!==-1&&t>n||r!==-1&&t>r||nl.test(e.slice(0,t))?e:``}function ll(e,t){let n=String(e);if(typeof t!=`string`)throw TypeError(`Expected character`);let r=0,i=n.indexOf(t);for(;i!==-1;)r++,i=n.indexOf(t,i+t.length);return r}function ul(e){if(typeof e!=`string`)throw TypeError(`Expected a string`);return e.replace(/[|\\{}()[\]^$+*?.]/g,`\\$&`).replace(/-/g,`\\x2d`)}function dl(e,t,n){let r=Zs((n||{}).ignore||[]),i=fl(t),a=-1;for(;++a0?{type:`text`,value:a}:void 0),a===!1?r.lastIndex=n+1:(s!==n&&u.push({type:`text`,value:e.value.slice(s,n)}),Array.isArray(a)?u.push(...a):a&&u.push(a),s=n+d[0].length,l=!0),!r.global)break;d=r.exec(e.value)}return l?(s?\]}]+$/.exec(e);if(!t)return[e,void 0];e=e.slice(0,t.index);let n=t[0],r=n.indexOf(`)`),i=ll(e,`(`),a=ll(e,`)`);for(;r!==-1&&i>a;)e+=n.slice(0,r+1),n=n.slice(r+1),r=n.indexOf(`)`),a++;return[e,n]}function Al(e,t){let n=e.input.charCodeAt(e.index-1);return(e.index===0||ji(n)||Ai(n))&&(!t||n!==47)}zl.peek=$;function jl(){this.buffer()}function Ml(e){this.enter({type:`footnoteReference`,identifier:``,label:``},e)}function Nl(){this.buffer()}function Pl(e){this.enter({type:`footnoteDefinition`,identifier:``,label:``,children:[]},e)}function Fl(e){let t=this.resume(),n=this.stack[this.stack.length-1];n.type,n.identifier=Si(this.sliceSerialize(e)).toLowerCase(),n.label=t}function Il(e){this.exit(e)}function Ll(e){let t=this.resume(),n=this.stack[this.stack.length-1];n.type,n.identifier=Si(this.sliceSerialize(e)).toLowerCase(),n.label=t}function Rl(e){this.exit(e)}function $(){return`[`}function zl(e,t,n,r){let i=n.createTracker(r),a=i.move(`[^`),o=n.enter(`footnoteReference`),s=n.enter(`reference`);return a+=i.move(n.safe(n.associationId(e),{after:`]`,before:a})),s(),o(),a+=i.move(`]`),a}function Bl(){return{enter:{gfmFootnoteCallString:jl,gfmFootnoteCall:Ml,gfmFootnoteDefinitionLabelString:Nl,gfmFootnoteDefinition:Pl},exit:{gfmFootnoteCallString:Fl,gfmFootnoteCall:Il,gfmFootnoteDefinitionLabelString:Ll,gfmFootnoteDefinition:Rl}}}function Vl(e){let t=!1;return e&&e.firstLineBlank&&(t=!0),{handlers:{footnoteDefinition:n,footnoteReference:zl},unsafe:[{character:`[`,inConstruct:[`label`,`phrasing`,`reference`]}]};function n(e,n,r,i){let a=r.createTracker(i),o=a.move(`[^`),s=r.enter(`footnoteDefinition`),c=r.enter(`label`);return o+=a.move(r.safe(r.associationId(e),{before:o,after:`]`})),c(),o+=a.move(`]:`),e.children&&e.children.length>0&&(a.shift(4),o+=a.move((t?` +`:` `)+r.indentLines(r.containerFlow(e,a.current()),t?Ul:Hl))),s(),o}}function Hl(e,t,n){return t===0?e:Ul(e,t,n)}function Ul(e,t,n){return(n?``:` `)+e}var Wl=[`autolink`,`destinationLiteral`,`destinationRaw`,`reference`,`titleQuote`,`titleApostrophe`];Yl.peek=Xl;function Gl(){return{canContainEols:[`delete`],enter:{strikethrough:ql},exit:{strikethrough:Jl}}}function Kl(){return{unsafe:[{character:`~`,inConstruct:`phrasing`,notInConstruct:Wl}],handlers:{delete:Yl}}}function ql(e){this.enter({type:`delete`,children:[]},e)}function Jl(e){this.exit(e)}function Yl(e,t,n,r){let i=n.createTracker(r),a=n.enter(`strikethrough`),o=i.move(`~~`);return o+=n.containerPhrasing(e,{...i.current(),before:o,after:`~`}),o+=i.move(`~~`),a(),o}function Xl(){return`~`}function Zl(e){return e.length}function Ql(e,t){let n=t||{},r=(n.align||[]).concat(),i=n.stringLength||Zl,a=[],o=[],s=[],c=[],l=0,u=-1;for(;++ul&&(l=e[u].length);++ac[a])&&(c[a]=e)}t.push(o)}o[u]=t,s[u]=r}let d=-1;if(typeof r==`object`&&`length`in r)for(;++dc[d]&&(c[d]=i),p[d]=i),f[d]=o}o.splice(1,0,f),s.splice(1,0,p),u=-1;let m=[];for(;++u `),a.shift(2);let o=n.indentLines(n.containerFlow(e,a.current()),nu);return i(),o}function nu(e,t,n){return`>`+(n?``:` `)+e}function ru(e,t){return iu(e,t.inConstruct,!0)&&!iu(e,t.notInConstruct,!1)}function iu(e,t,n){if(typeof t==`string`&&(t=[t]),!t||t.length===0)return n;let r=-1;for(;++ro&&(o=a):a=1,i=r+t.length,r=n.indexOf(t,i);return o}function su(e,t){return!(t.options.fences!==!1||!e.value||e.lang||!/[^ \r\n]/.test(e.value)||/^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$/.test(e.value))}function cu(e){let t=e.options.fence||"`";if(t!=="`"&&t!==`~`)throw Error("Cannot serialize code with `"+t+"` for `options.fence`, expected `` ` `` or `~`");return t}function lu(e,t,n,r){let i=cu(n),a=e.value||``,o=i==="`"?`GraveAccent`:`Tilde`;if(su(e,n)){let e=n.enter(`codeIndented`),t=n.indentLines(a,uu);return e(),t}let s=n.createTracker(r),c=i.repeat(Math.max(ou(a,i)+1,3)),l=n.enter(`codeFenced`),u=s.move(c);if(e.lang){let t=n.enter(`codeFencedLang${o}`);u+=s.move(n.safe(e.lang,{before:u,after:` `,encode:["`"],...s.current()})),t()}if(e.lang&&e.meta){let t=n.enter(`codeFencedMeta${o}`);u+=s.move(` `),u+=s.move(n.safe(e.meta,{before:u,after:` +`,encode:["`"],...s.current()})),t()}return u+=s.move(` +`),a&&(u+=s.move(a+` +`)),u+=s.move(c),l(),u}function uu(e,t,n){return(n?``:` `)+e}function du(e){let t=e.options.quote||`"`;if(t!==`"`&&t!==`'`)throw Error("Cannot serialize title with `"+t+"` for `options.quote`, expected `\"`, or `'`");return t}function fu(e,t,n,r){let i=du(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.enter(`definition`),s=n.enter(`label`),c=n.createTracker(r),l=c.move(`[`);return l+=c.move(n.safe(n.associationId(e),{before:l,after:`]`,...c.current()})),l+=c.move(`]: `),s(),!e.url||/[\0- \u007F]/.test(e.url)?(s=n.enter(`destinationLiteral`),l+=c.move(`<`),l+=c.move(n.safe(e.url,{before:l,after:`>`,...c.current()})),l+=c.move(`>`)):(s=n.enter(`destinationRaw`),l+=c.move(n.safe(e.url,{before:l,after:e.title?` `:` +`,...c.current()}))),s(),e.title&&(s=n.enter(`title${a}`),l+=c.move(` `+i),l+=c.move(n.safe(e.title,{before:l,after:i,...c.current()})),l+=c.move(i),s()),o(),l}function pu(e){let t=e.options.emphasis||`*`;if(t!==`*`&&t!==`_`)throw Error("Cannot serialize emphasis with `"+t+"` for `options.emphasis`, expected `*`, or `_`");return t}function mu(e){return`&#x`+e.toString(16).toUpperCase()+`;`}function hu(e,t,n){let r=Bi(e),i=Bi(t);return r===void 0?i===void 0?n===`_`?{inside:!0,outside:!0}:{inside:!1,outside:!1}:i===1?{inside:!0,outside:!0}:{inside:!1,outside:!0}:r===1?i===void 0?{inside:!1,outside:!1}:i===1?{inside:!0,outside:!0}:{inside:!1,outside:!1}:i===void 0?{inside:!1,outside:!1}:i===1?{inside:!0,outside:!1}:{inside:!1,outside:!1}}gu.peek=_u;function gu(e,t,n,r){let i=pu(n),a=n.enter(`emphasis`),o=n.createTracker(r),s=o.move(i),c=o.move(n.containerPhrasing(e,{after:i,before:s,...o.current()})),l=c.charCodeAt(0),u=hu(r.before.charCodeAt(r.before.length-1),l,i);u.inside&&(c=mu(l)+c.slice(1));let d=c.charCodeAt(c.length-1),f=hu(r.after.charCodeAt(0),d,i);f.inside&&(c=c.slice(0,-1)+mu(d));let p=o.move(i);return a(),n.attentionEncodeSurroundingInfo={after:f.outside,before:u.outside},s+c+p}function _u(e,t,n){return n.options.emphasis||`*`}function vu(e,t){let n=!1;return cc(e,function(e){if(`value`in e&&/\r?\n|\r/.test(e.value)||e.type===`break`)return n=!0,!1}),!!((!e.depth||e.depth<3)&&li(e)&&(t.options.setext||n))}function yu(e,t,n,r){let i=Math.max(Math.min(6,e.depth||1),1),a=n.createTracker(r);if(vu(e,n)){let t=n.enter(`headingSetext`),r=n.enter(`phrasing`),o=n.containerPhrasing(e,{...a.current(),before:` +`,after:` +`});return r(),t(),o+` +`+(i===1?`=`:`-`).repeat(o.length-(Math.max(o.lastIndexOf(`\r`),o.lastIndexOf(` +`))+1))}let o=`#`.repeat(i),s=n.enter(`headingAtx`),c=n.enter(`phrasing`);a.move(o+` `);let l=n.containerPhrasing(e,{before:`# `,after:` +`,...a.current()});return/^[\t ]/.test(l)&&(l=mu(l.charCodeAt(0))+l.slice(1)),l=l?o+` `+l:o,n.options.closeAtx&&(l+=` `+o),c(),s(),l}bu.peek=xu;function bu(e){return e.value||``}function xu(){return`<`}Su.peek=Cu;function Su(e,t,n,r){let i=du(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.enter(`image`),s=n.enter(`label`),c=n.createTracker(r),l=c.move(`![`);return l+=c.move(n.safe(e.alt,{before:l,after:`]`,...c.current()})),l+=c.move(`](`),s(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(s=n.enter(`destinationLiteral`),l+=c.move(`<`),l+=c.move(n.safe(e.url,{before:l,after:`>`,...c.current()})),l+=c.move(`>`)):(s=n.enter(`destinationRaw`),l+=c.move(n.safe(e.url,{before:l,after:e.title?` `:`)`,...c.current()}))),s(),e.title&&(s=n.enter(`title${a}`),l+=c.move(` `+i),l+=c.move(n.safe(e.title,{before:l,after:i,...c.current()})),l+=c.move(i),s()),l+=c.move(`)`),o(),l}function Cu(){return`!`}wu.peek=Tu;function wu(e,t,n,r){let i=e.referenceType,a=n.enter(`imageReference`),o=n.enter(`label`),s=n.createTracker(r),c=s.move(`![`),l=n.safe(e.alt,{before:c,after:`]`,...s.current()});c+=s.move(l+`][`),o();let u=n.stack;n.stack=[],o=n.enter(`reference`);let d=n.safe(n.associationId(e),{before:c,after:`]`,...s.current()});return o(),n.stack=u,a(),i===`full`||!l||l!==d?c+=s.move(d+`]`):i===`shortcut`?c=c.slice(0,-1):c+=s.move(`]`),c}function Tu(){return`!`}Eu.peek=Du;function Eu(e,t,n){let r=e.value||``,i="`",a=-1;for(;RegExp("(^|[^`])"+i+"([^`]|$)").test(r);)i+="`";for(/[^ \r\n]/.test(r)&&(/^[ \r\n]/.test(r)&&/[ \r\n]$/.test(r)||/^`|`$/.test(r))&&(r=` `+r+` `);++a\u007F]/.test(e.url))}ku.peek=Au;function ku(e,t,n,r){let i=du(n),a=i===`"`?`Quote`:`Apostrophe`,o=n.createTracker(r),s,c;if(Ou(e,n)){let t=n.stack;n.stack=[],s=n.enter(`autolink`);let r=o.move(`<`);return r+=o.move(n.containerPhrasing(e,{before:r,after:`>`,...o.current()})),r+=o.move(`>`),s(),n.stack=t,r}s=n.enter(`link`),c=n.enter(`label`);let l=o.move(`[`);return l+=o.move(n.containerPhrasing(e,{before:l,after:`](`,...o.current()})),l+=o.move(`](`),c(),!e.url&&e.title||/[\0- \u007F]/.test(e.url)?(c=n.enter(`destinationLiteral`),l+=o.move(`<`),l+=o.move(n.safe(e.url,{before:l,after:`>`,...o.current()})),l+=o.move(`>`)):(c=n.enter(`destinationRaw`),l+=o.move(n.safe(e.url,{before:l,after:e.title?` `:`)`,...o.current()}))),c(),e.title&&(c=n.enter(`title${a}`),l+=o.move(` `+i),l+=o.move(n.safe(e.title,{before:l,after:i,...o.current()})),l+=o.move(i),c()),l+=o.move(`)`),s(),l}function Au(e,t,n){return Ou(e,n)?`<`:`[`}ju.peek=Mu;function ju(e,t,n,r){let i=e.referenceType,a=n.enter(`linkReference`),o=n.enter(`label`),s=n.createTracker(r),c=s.move(`[`),l=n.containerPhrasing(e,{before:c,after:`]`,...s.current()});c+=s.move(l+`][`),o();let u=n.stack;n.stack=[],o=n.enter(`reference`);let d=n.safe(n.associationId(e),{before:c,after:`]`,...s.current()});return o(),n.stack=u,a(),i===`full`||!l||l!==d?c+=s.move(d+`]`):i===`shortcut`?c=c.slice(0,-1):c+=s.move(`]`),c}function Mu(){return`[`}function Nu(e){let t=e.options.bullet||`*`;if(t!==`*`&&t!==`+`&&t!==`-`)throw Error("Cannot serialize items with `"+t+"` for `options.bullet`, expected `*`, `+`, or `-`");return t}function Pu(e){let t=Nu(e),n=e.options.bulletOther;if(!n)return t===`*`?`-`:`*`;if(n!==`*`&&n!==`+`&&n!==`-`)throw Error("Cannot serialize items with `"+n+"` for `options.bulletOther`, expected `*`, `+`, or `-`");if(n===t)throw Error("Expected `bullet` (`"+t+"`) and `bulletOther` (`"+n+"`) to be different");return n}function Fu(e){let t=e.options.bulletOrdered||`.`;if(t!==`.`&&t!==`)`)throw Error("Cannot serialize items with `"+t+"` for `options.bulletOrdered`, expected `.` or `)`");return t}function Iu(e){let t=e.options.rule||`*`;if(t!==`*`&&t!==`-`&&t!==`_`)throw Error("Cannot serialize rules with `"+t+"` for `options.rule`, expected `*`, `-`, or `_`");return t}function Lu(e,t,n,r){let i=n.enter(`list`),a=n.bulletCurrent,o=e.ordered?Fu(n):Nu(n),s=e.ordered?o===`.`?`)`:`.`:Pu(n),c=t&&n.bulletLastUsed?o===n.bulletLastUsed:!1;if(!e.ordered){let t=e.children?e.children[0]:void 0;if((o===`*`||o===`-`)&&t&&(!t.children||!t.children[0])&&n.stack[n.stack.length-1]===`list`&&n.stack[n.stack.length-2]===`listItem`&&n.stack[n.stack.length-3]===`list`&&n.stack[n.stack.length-4]===`listItem`&&n.indexStack[n.indexStack.length-1]===0&&n.indexStack[n.indexStack.length-2]===0&&n.indexStack[n.indexStack.length-3]===0&&(c=!0),Iu(n)===o&&t){let t=-1;for(;++t-1?t.start:1)+(n.options.incrementListMarker===!1?0:t.children.indexOf(e))+a);let o=a.length+1;(i===`tab`||i===`mixed`&&(t&&t.type===`list`&&t.spread||e.spread))&&(o=Math.ceil(o/4)*4);let s=n.createTracker(r);s.move(a+` `.repeat(o-a.length)),s.shift(o);let c=n.enter(`listItem`),l=n.indentLines(n.containerFlow(e,s.current()),u);return c(),l;function u(e,t,n){return t?(n?``:` `.repeat(o))+e:(n?a:a+` `.repeat(o-a.length))+e}}function Bu(e,t,n,r){let i=n.enter(`paragraph`),a=n.enter(`phrasing`),o=n.containerPhrasing(e,r);return a(),i(),o}var Vu=Zs([`break`,`delete`,`emphasis`,`footnote`,`footnoteReference`,`image`,`imageReference`,`inlineCode`,`inlineMath`,`link`,`linkReference`,`mdxJsxTextElement`,`mdxTextExpression`,`strong`,`text`,`textDirective`]);function Hu(e,t,n,r){return(e.children.some(function(e){return Vu(e)})?n.containerPhrasing:n.containerFlow).call(n,e,r)}function Uu(e){let t=e.options.strong||`*`;if(t!==`*`&&t!==`_`)throw Error("Cannot serialize strong with `"+t+"` for `options.strong`, expected `*`, or `_`");return t}Wu.peek=Gu;function Wu(e,t,n,r){let i=Uu(n),a=n.enter(`strong`),o=n.createTracker(r),s=o.move(i+i),c=o.move(n.containerPhrasing(e,{after:i,before:s,...o.current()})),l=c.charCodeAt(0),u=hu(r.before.charCodeAt(r.before.length-1),l,i);u.inside&&(c=mu(l)+c.slice(1));let d=c.charCodeAt(c.length-1),f=hu(r.after.charCodeAt(0),d,i);f.inside&&(c=c.slice(0,-1)+mu(d));let p=o.move(i+i);return a(),n.attentionEncodeSurroundingInfo={after:f.outside,before:u.outside},s+c+p}function Gu(e,t,n){return n.options.strong||`*`}function Ku(e,t,n,r){return n.safe(e.value,r)}function qu(e){let t=e.options.ruleRepetition||3;if(t<3)throw Error("Cannot serialize rules with repetition `"+t+"` for `options.ruleRepetition`, expected `3` or more");return t}function Ju(e,t,n){let r=(Iu(n)+(n.options.ruleSpaces?` `:``)).repeat(qu(n));return n.options.ruleSpaces?r.slice(0,-1):r}var Yu={blockquote:tu,break:au,code:lu,definition:fu,emphasis:gu,hardBreak:au,heading:yu,html:bu,image:Su,imageReference:wu,inlineCode:Eu,link:ku,linkReference:ju,list:Lu,listItem:zu,paragraph:Bu,root:Hu,strong:Wu,text:Ku,thematicBreak:Ju};function Xu(){return{enter:{table:Zu,tableData:td,tableHeader:td,tableRow:$u},exit:{codeText:nd,table:Qu,tableData:ed,tableHeader:ed,tableRow:ed}}}function Zu(e){let t=e._align;this.enter({type:`table`,align:t.map(function(e){return e===`none`?null:e}),children:[]},e),this.data.inTable=!0}function Qu(e){this.exit(e),this.data.inTable=void 0}function $u(e){this.enter({type:`tableRow`,children:[]},e)}function ed(e){this.exit(e)}function td(e){this.enter({type:`tableCell`,children:[]},e)}function nd(e){let t=this.resume();this.data.inTable&&(t=t.replace(/\\([\\|])/g,rd));let n=this.stack[this.stack.length-1];n.type,n.value=t,this.exit(e)}function rd(e,t){return t===`|`?t:e}function id(e){let t=e||{},n=t.tableCellPadding,r=t.tablePipeAlign,i=t.stringLength,a=n?` `:`|`;return{unsafe:[{character:`\r`,inConstruct:`tableCell`},{character:` +`,inConstruct:`tableCell`},{atBreak:!0,character:`|`,after:`[ :-]`},{character:`|`,inConstruct:`tableCell`},{atBreak:!0,character:`:`,after:`-`},{atBreak:!0,character:`-`,after:`[:|-]`}],handlers:{inlineCode:f,table:o,tableCell:c,tableRow:s}};function o(e,t,n,r){return l(u(e,n,r),e.align)}function s(e,t,n,r){let i=l([d(e,n,r)]);return i.slice(0,i.indexOf(` +`))}function c(e,t,n,r){let i=n.enter(`tableCell`),o=n.enter(`phrasing`),s=n.containerPhrasing(e,{...r,before:a,after:a});return o(),i(),s}function l(e,t){return Ql(e,{align:t,alignDelimiters:r,padding:n,stringLength:i})}function u(e,t,n){let r=e.children,i=-1,a=[],o=t.enter(`table`);for(;++i0&&!n&&(e[e.length-1][1]._gfmAutolinkLiteralWalkedInto=!0),n}var Id={tokenize:Wd,partial:!0};function Ld(){return{document:{91:{name:`gfmFootnoteDefinition`,tokenize:Vd,continuation:{tokenize:Hd},exit:Ud}},text:{91:{name:`gfmFootnoteCall`,tokenize:Bd},93:{name:`gfmPotentialFootnoteCall`,add:`after`,tokenize:Rd,resolveTo:zd}}}}function Rd(e,t,n){let r=this,i=r.events.length,a=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),o;for(;i--;){let e=r.events[i][1];if(e.type===`labelImage`){o=e;break}if(e.type===`gfmFootnoteCall`||e.type===`labelLink`||e.type===`label`||e.type===`image`||e.type===`link`)break}return s;function s(i){if(!o||!o._balanced)return n(i);let s=Si(r.sliceSerialize({start:o.end,end:r.now()}));return s.codePointAt(0)!==94||!a.includes(s.slice(1))?n(i):(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(i),e.exit(`gfmFootnoteCallLabelMarker`),t(i))}}function zd(e,t){let n=e.length;for(;n--;)if(e[n][1].type===`labelImage`&&e[n][0]===`enter`){e[n][1];break}e[n+1][1].type=`data`,e[n+3][1].type=`gfmFootnoteCallLabelMarker`;let r={type:`gfmFootnoteCall`,start:Object.assign({},e[n+3][1].start),end:Object.assign({},e[e.length-1][1].end)},i={type:`gfmFootnoteCallMarker`,start:Object.assign({},e[n+3][1].end),end:Object.assign({},e[n+3][1].end)};i.end.column++,i.end.offset++,i.end._bufferIndex++;let a={type:`gfmFootnoteCallString`,start:Object.assign({},i.end),end:Object.assign({},e[e.length-1][1].start)},o={type:`chunkString`,contentType:`string`,start:Object.assign({},a.start),end:Object.assign({},a.end)},s=[e[n+1],e[n+2],[`enter`,r,t],e[n+3],e[n+4],[`enter`,i,t],[`exit`,i,t],[`enter`,a,t],[`enter`,o,t],[`exit`,o,t],[`exit`,a,t],e[e.length-2],e[e.length-1],[`exit`,r,t]];return e.splice(n,e.length-n+1,...s),e}function Bd(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a=0,o;return s;function s(t){return e.enter(`gfmFootnoteCall`),e.enter(`gfmFootnoteCallLabelMarker`),e.consume(t),e.exit(`gfmFootnoteCallLabelMarker`),c}function c(t){return t===94?(e.enter(`gfmFootnoteCallMarker`),e.consume(t),e.exit(`gfmFootnoteCallMarker`),e.enter(`gfmFootnoteCallString`),e.enter(`chunkString`).contentType=`string`,l):n(t)}function l(s){if(a>999||s===93&&!o||s===null||s===91||U(s))return n(s);if(s===93){e.exit(`chunkString`);let a=e.exit(`gfmFootnoteCallString`);return i.includes(Si(r.sliceSerialize(a)))?(e.enter(`gfmFootnoteCallLabelMarker`),e.consume(s),e.exit(`gfmFootnoteCallLabelMarker`),e.exit(`gfmFootnoteCall`),t):n(s)}return U(s)||(o=!0),a++,e.consume(s),s===92?u:l}function u(t){return t===91||t===92||t===93?(e.consume(t),a++,l):l(t)}}function Vd(e,t,n){let r=this,i=r.parser.gfmFootnotes||(r.parser.gfmFootnotes=[]),a,o=0,s;return c;function c(t){return e.enter(`gfmFootnoteDefinition`)._container=!0,e.enter(`gfmFootnoteDefinitionLabel`),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),l}function l(t){return t===94?(e.enter(`gfmFootnoteDefinitionMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionMarker`),e.enter(`gfmFootnoteDefinitionLabelString`),e.enter(`chunkString`).contentType=`string`,u):n(t)}function u(t){if(o>999||t===93&&!s||t===null||t===91||U(t))return n(t);if(t===93){e.exit(`chunkString`);let n=e.exit(`gfmFootnoteDefinitionLabelString`);return a=Si(r.sliceSerialize(n)),e.enter(`gfmFootnoteDefinitionLabelMarker`),e.consume(t),e.exit(`gfmFootnoteDefinitionLabelMarker`),e.exit(`gfmFootnoteDefinitionLabel`),f}return U(t)||(s=!0),o++,e.consume(t),t===92?d:u}function d(t){return t===91||t===92||t===93?(e.consume(t),o++,u):u(t)}function f(t){return t===58?(e.enter(`definitionMarker`),e.consume(t),e.exit(`definitionMarker`),i.includes(a)||i.push(a),G(e,p,`gfmFootnoteDefinitionWhitespace`)):n(t)}function p(e){return t(e)}}function Hd(e,t,n){return e.check(Ki,t,e.attempt(Id,t,n))}function Ud(e){e.exit(`gfmFootnoteDefinition`)}function Wd(e,t,n){let r=this;return G(e,i,`gfmFootnoteDefinitionIndent`,5);function i(e){let i=r.events[r.events.length-1];return i&&i[1].type===`gfmFootnoteDefinitionIndent`&&i[2].sliceSerialize(i[1],!0).length===4?t(e):n(e)}}function Gd(e){let t=(e||{}).singleTilde,n={name:`strikethrough`,tokenize:i,resolveAll:r};return t??=!0,{text:{126:n},insideSpan:{null:[n]},attentionMarkers:{null:[126]}};function r(e,t){let n=-1;for(;++n1?r(a):(e.consume(a),o++,c);if(o<2&&!t)return r(a);let l=e.exit(`strikethroughSequenceTemporary`),u=Bi(a);return l._open=!u||u===2&&!!s,l._close=!s||s===2&&!!u,n(a)}}}var Kd=class{constructor(){this.map=[],this.index=new Map}add(e,t,n){qd(this,e,t,n)}consume(e){if(this.map.sort(function(e,t){return e[0]-t[0]}),this.map.length===0)return;let t=this.map.length,n=[];for(;t>0;)--t,n.push(e.slice(this.map[t][0]+this.map[t][1]),this.map[t][2]),e.length=this.map[t][0];n.push(e.slice()),e.length=0;let r=n.pop();for(;r;){for(let t of r)e.push(t);r=n.pop()}this.map.length=0,this.index.clear()}};function qd(e,t,n,r){if(n===0&&r.length===0)return;let i=e.index.get(t);if(i){i[1]+=n,i[2].push(...r);return}let a=[t,n,r];e.map.push(a),e.index.set(t,a)}function Jd(e,t){let n=!1,r=[];for(;t-1;){let{type:e}=r.events[t][1];if(e===`lineEnding`||e===`linePrefix`)t--;else break}let i=t>-1?r.events[t][1].type:null,a=i===`tableHead`||i===`tableRow`?S:c;return a===S&&r.parser.lazy[r.now().line]?n(e):a(e)}function c(t){return e.enter(`tableHead`),e.enter(`tableRow`),l(t)}function l(e){return e===124?u(e):(o=!0,a+=1,u(e))}function u(t){return t===null?n(t):H(t)?a>1?(a=0,r.interrupt=!0,e.exit(`tableRow`),e.enter(`lineEnding`),e.consume(t),e.exit(`lineEnding`),p):n(t):W(t)?G(e,u,`whitespace`)(t):(a+=1,o&&(o=!1,i+=1),t===124?(e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),o=!0,u):(e.enter(`data`),d(t)))}function d(t){return t===null||t===124||U(t)?(e.exit(`data`),u(t)):(e.consume(t),t===92?f:d)}function f(t){return t===92||t===124?(e.consume(t),d):d(t)}function p(t){return r.interrupt=!1,r.parser.lazy[r.now().line]?n(t):(e.enter(`tableDelimiterRow`),o=!1,W(t)?G(e,m,`linePrefix`,r.parser.constructs.disable.null.includes(`codeIndented`)?void 0:4)(t):m(t))}function m(t){return t===45||t===58?g(t):t===124?(o=!0,e.enter(`tableCellDivider`),e.consume(t),e.exit(`tableCellDivider`),h):x(t)}function h(t){return W(t)?G(e,g,`whitespace`)(t):g(t)}function g(t){return t===58?(a+=1,o=!0,e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),_):t===45?(a+=1,_(t)):t===null||H(t)?b(t):x(t)}function _(t){return t===45?(e.enter(`tableDelimiterFiller`),v(t)):x(t)}function v(t){return t===45?(e.consume(t),v):t===58?(o=!0,e.exit(`tableDelimiterFiller`),e.enter(`tableDelimiterMarker`),e.consume(t),e.exit(`tableDelimiterMarker`),y):(e.exit(`tableDelimiterFiller`),y(t))}function y(t){return W(t)?G(e,b,`whitespace`)(t):b(t)}function b(n){return n===124?m(n):n===null||H(n)?!o||i!==a?x(n):(e.exit(`tableDelimiterRow`),e.exit(`tableHead`),t(n)):x(n)}function x(e){return n(e)}function S(t){return e.enter(`tableRow`),C(t)}function C(n){return n===124?(e.enter(`tableCellDivider`),e.consume(n),e.exit(`tableCellDivider`),C):n===null||H(n)?(e.exit(`tableRow`),t(n)):W(n)?G(e,C,`whitespace`)(n):(e.enter(`data`),w(n))}function w(t){return t===null||t===124||U(t)?(e.exit(`data`),C(t)):(e.consume(t),t===92?T:w)}function T(t){return t===92||t===124?(e.consume(t),w):w(t)}}function Zd(e,t){let n=-1,r=!0,i=0,a=[0,0,0,0],o=[0,0,0,0],s=!1,c=0,l,u,d,f=new Kd;for(;++nn[2]+1){let t=n[2]+1,r=n[3]-n[2]-1;e.add(t,r,[])}}e.add(n[3]+1,0,[[`exit`,o,t]])}return i!==void 0&&(a.end=Object.assign({},ef(t.events,i)),e.add(i,0,[[`exit`,a,t]]),a=void 0),a}function $d(e,t,n,r,i){let a=[],o=ef(t.events,n);i&&(i.end=Object.assign({},o),a.push([`exit`,i,t])),r.end=Object.assign({},o),a.push([`exit`,r,t]),e.add(n+1,0,a)}function ef(e,t){let n=e[t],r=n[0]===`enter`?`start`:`end`;return n[1][r]}var tf={name:`tasklistCheck`,tokenize:rf};function nf(){return{text:{91:tf}}}function rf(e,t,n){let r=this;return i;function i(t){return r.previous!==null||!r._gfmTasklistFirstContentOfListItem?n(t):(e.enter(`taskListCheck`),e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),a)}function a(t){return U(t)?(e.enter(`taskListCheckValueUnchecked`),e.consume(t),e.exit(`taskListCheckValueUnchecked`),o):t===88||t===120?(e.enter(`taskListCheckValueChecked`),e.consume(t),e.exit(`taskListCheckValueChecked`),o):n(t)}function o(t){return t===93?(e.enter(`taskListCheckMarker`),e.consume(t),e.exit(`taskListCheckMarker`),e.exit(`taskListCheck`),s):n(t)}function s(r){return H(r)?t(r):W(r)?e.check({tokenize:af},t,n)(r):n(r)}}function af(e,t,n){return G(e,r,`whitespace`);function r(e){return e===null?n(e):t(e)}}function of(e){return vi([xd(),Ld(),Gd(e),Yd(),nf()])}var sf={};function cf(e){let t=this,n=e||sf,r=t.data(),i=r.micromarkExtensions||=[],a=r.fromMarkdownExtensions||=[],o=r.toMarkdownExtensions||=[];i.push(of(n)),a.push(ud()),o.push(dd(n))}function lf(e,t){let n=e.trim().replace(/^\.?\//,``);if(!n||t.length===0)return null;if(t.includes(n))return n;let r=t.filter(e=>e.endsWith(`/${n}`));return r.length===1?r[0]:null}var uf=e=>e.replace(/[.*+?^${}()|[\]\\/-]/g,`\\$&`);function df(e,t){if(!e||t.length===0)return e;let n=new Set(t);for(let e of t){let r=e.slice(e.lastIndexOf(`/`)+1);r.includes(`.`)&&lf(r,t)===e&&n.add(r)}let r=[...n].sort((e,t)=>t.length-e.length).map(uf),i=RegExp(`(?]*>)/).map((e,t)=>t%2==1?e:e.replace(i,"`$1`")).join(``)}function ff(e){let t=typeof e.children==`string`?e.children:``;return!String(e.className??``).includes(`language-`)&&!t.includes(` +`)}function pf(e){let t=[],n=[],r=null,i=()=>{let e=n.join(` +`).trim();if(n=[],!e)return;let r=e.trimStart(),i=r.startsWith("```")||r.startsWith(`~~~`)||r.startsWith(`|`)||r.startsWith(`<`),a=t[t.length-1];if(a&&!a.bare&&/^#{1,6}\s/.test(a.text)&&!a.text.includes(` +`)&&!i){a.text=`${a.text}\n\n${e}`;return}t.push({text:e,bare:i})};for(let t of e.split(` +`)){let e=/^\s*(```|~~~)/.exec(t);if(r){n.push(t),e&&e[1]===r&&(r=null);continue}if(e){n.length&&i(),r=e[1],n.push(t);continue}if(t.trim()===``){i();continue}n.push(t)}return i(),t}function mf({text:e,files:t=[],onOpenFile:n}){let r=e=>n&&e&&t.length>0?lf(e,t):null,i=e=>n?.(e),a=n&&t.length>0?df(e,t):e;return(0,L.jsx)(`div`,{className:`md text-[15px] leading-[1.5] break-words`,children:(0,L.jsx)(il,{remarkPlugins:[cf],components:{a:({href:e,children:t,...n})=>{let a=e&&!/^[a-z]+:/i.test(e)?r(O(e)):null;return a?(0,L.jsx)(`button`,{type:`button`,className:`md-file`,onClick:()=>i(a),children:t}):(0,L.jsx)(`a`,{href:e,...n,target:`_blank`,rel:`noreferrer noopener`,children:t})},code:({children:e,...t})=>{let n=ff({children:e,...t})&&typeof e==`string`?r(e):null;return n?(0,L.jsx)(`button`,{type:`button`,className:`md-file`,onClick:()=>i(n),title:n,children:e}):(0,L.jsx)(`code`,{...t,children:e})}},children:a})})}function hf({path:e,onClose:t}){let n=s(),i=(0,I.useRef)(null);if(On(i,!!e),(0,I.useEffect)(()=>{if(!e)return;let n=e=>e.key===`Escape`&&t();return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),!e)return null;let a=e.split(`/`).pop()??e,o=r(a);return(0,L.jsxs)(`div`,{ref:i,role:`dialog`,"aria-modal":`true`,"aria-label":a,className:`fixed inset-0 z-[60] flex flex-col bg-bg`,children:[(0,L.jsxs)(`header`,{className:`safe-top shrink-0 flex items-center gap-2 border-b border-border bg-surface/90 px-3 py-2 backdrop-blur`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:t,"aria-label":n(`Close`),className:`p-2 rounded-full text-muted hover:bg-surface-2`,children:(0,L.jsx)(Fe,{size:20})}),(0,L.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,L.jsx)(`div`,{className:`font-semibold text-[15px] truncate`,children:a}),(0,L.jsx)(`div`,{className:`text-[12px] text-muted truncate`,children:e})]}),o!==`html`&&(0,L.jsx)(`a`,{href:h(e),target:`_blank`,rel:`noreferrer noopener`,"aria-label":n(`Open in a new tab`),className:`p-2 rounded-full text-muted hover:bg-surface-2`,children:(0,L.jsx)(Ae,{size:19})}),(0,L.jsx)(`a`,{href:h(e,!0),"aria-label":n(`Download`),className:`p-2 rounded-full text-muted hover:bg-surface-2`,children:(0,L.jsx)(ft,{size:19})})]}),(0,L.jsx)(`div`,{className:`min-h-0 flex-1 overflow-auto`,children:(0,L.jsx)(gf,{path:e,kind:o})})]})}function gf({path:e,kind:t}){let n=s();return t===`image`?(0,L.jsx)(`div`,{className:`flex h-full items-center justify-center p-3`,children:(0,L.jsx)(`img`,{src:h(e),alt:e,className:`max-h-full max-w-full rounded-2xl object-contain`})}):t===`pdf`?(0,L.jsx)(`iframe`,{title:e,src:h(e),className:`h-full w-full bg-white`}):t===`html`?(0,L.jsx)(vf,{path:e}):t===`event`?(0,L.jsx)(yf,{path:e}):t===`text`||t===`code`||t===`data`?(0,L.jsx)(Sf,{path:e,kind:t}):(0,L.jsxs)(`div`,{className:`p-8 text-center text-muted text-[14px]`,children:[n(`No preview for this file type.`),` `,(0,L.jsx)(`a`,{href:h(e,!0),className:`text-accent font-medium`,children:n(`Download it`)})]})}function _f(e){let[t,n]=(0,I.useState)(null),[r,i]=(0,I.useState)(null);return(0,I.useEffect)(()=>{let t=!0;return n(null),i(null),M.fileText(e).then(e=>t&&n(e)).catch(e=>t&&i(e.message)),()=>{t=!1}},[e]),{text:t,error:r}}function vf({path:e}){let{text:t,error:n}=_f(e);return n?(0,L.jsx)(Tf,{error:n}):t===null?(0,L.jsx)(wf,{}):(0,L.jsx)(`iframe`,{title:e,srcDoc:t,sandbox:`allow-scripts allow-popups allow-forms allow-modals`,referrerPolicy:`no-referrer`,className:`h-full w-full bg-white`})}function yf({path:e}){let t=s(),{text:n,error:r}=_f(e);if(r)return(0,L.jsx)(Tf,{error:r});if(n===null)return(0,L.jsx)(wf,{});let i=bf(n);return(0,L.jsxs)(`div`,{className:`mx-auto max-w-[560px] px-5 py-6 space-y-4`,children:[i.length===0&&(0,L.jsx)(`div`,{className:`text-center text-muted text-[14px]`,children:t(`No event in this file.`)}),i.map((e,n)=>(0,L.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface shadow-sm p-5`,children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,L.jsx)(`div`,{className:`rounded-2xl bg-accent/12 text-accent p-2.5`,children:(0,L.jsx)(tt,{size:22})}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[18px] font-semibold leading-snug`,children:e.summary||t(`Untitled event`)}),(0,L.jsx)(`div`,{className:`mt-1 text-[14px] text-muted`,children:xf(e)}),e.location&&(0,L.jsxs)(`div`,{className:`mt-1 flex items-center gap-1 text-[14px] text-muted`,children:[(0,L.jsx)(Dt,{size:14}),` `,e.location]})]})]}),e.description&&(0,L.jsx)(`div`,{className:`mt-4 whitespace-pre-wrap text-[14px] leading-relaxed`,children:e.description})]},n)),(0,L.jsxs)(`a`,{href:h(e,!0),download:!0,className:`flex items-center justify-center gap-2 rounded-2xl bg-accent text-accent-fg px-4 py-3 text-[15px] font-medium`,children:[(0,L.jsx)(nt,{size:18}),` `,t(`Add to calendar`)]}),(0,L.jsx)(`p`,{className:`text-center text-[12.5px] text-muted`,children:t(`Opens in your calendar app, which asks before it saves anything.`)})]})}function bf(e){let t=e.replace(/\r\n/g,` +`).replace(/\n[ \t]/g,``).split(` +`),n=[],r=null,i=e=>e.replace(/\\n/gi,` +`).replace(/\\([,;\\])/g,`$1`),a=(e,t)=>{if(/VALUE=DATE(?![-])/i.test(t)||/^\d{8}$/.test(e)){let t=/^(\d{4})(\d{2})(\d{2})/.exec(e);return{at:t?new Date(+t[1],t[2]-1,+t[3]):null,allDay:!0}}let n=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z?)$/.exec(e);if(!n)return{at:null,allDay:!1};let r=[+n[1],n[2]-1,+n[3],+n[4],+n[5],+(n[6]??0)];return{at:n[7]?new Date(Date.UTC(...r)):new Date(...r),allDay:!1}};for(let e of t)if(e===`BEGIN:VEVENT`)r={summary:``,location:``,description:``,start:null,end:null,allDay:!1};else if(e===`END:VEVENT`&&r)n.push(r),r=null;else if(r){let t=e.indexOf(`:`);if(t<0)continue;let[n,...o]=e.slice(0,t).split(`;`),s=e.slice(t+1),c=o.join(`;`);if(n===`SUMMARY`)r.summary=i(s);else if(n===`LOCATION`)r.location=i(s);else if(n===`DESCRIPTION`)r.description=i(s);else if(n===`DTSTART`){let e=a(s,c);r.start=e.at,r.allDay=e.allDay}else n===`DTEND`&&(r.end=a(s,c).at)}return n}function xf(e){if(!e.start)return``;let t=m(),n=e=>e.toLocaleDateString(t,{weekday:`short`,year:`numeric`,month:`short`,day:`numeric`}),r=e=>e.toLocaleTimeString(t,{hour:`numeric`,minute:`2-digit`});if(e.allDay){let t=e.end?new Date(e.end.getTime()-864e5):e.start;return t>e.start?`${n(e.start)} – ${n(t)}`:`${n(e.start)} · ${_(`all day`)}`}return e.end?e.start.toDateString()===e.end.toDateString()?`${n(e.start)} ${r(e.start)}–${r(e.end)}`:`${n(e.start)} ${r(e.start)} – ${n(e.end)} ${r(e.end)}`:`${n(e.start)} ${r(e.start)}`}function Sf({path:e,kind:t}){let{text:n,error:r}=_f(e);return r?(0,L.jsx)(Tf,{error:r}):n===null?(0,L.jsx)(wf,{}):t===`text`&&/\.md$/i.test(e)?(0,L.jsx)(`div`,{className:`mx-auto max-w-[720px] px-5 py-4`,children:(0,L.jsx)(mf,{text:n})}):t===`data`&&/\.csv$/i.test(e)?(0,L.jsx)(Cf,{text:n}):(0,L.jsx)(`pre`,{className:`whitespace-pre-wrap break-words px-4 py-3 font-mono text-[12.5px] leading-relaxed`,children:n})}function Cf({text:e}){let t=s(),n=e.split(/\r?\n/).filter(e=>e.trim()).slice(0,500).map(e=>e.split(`,`));if(!n.length)return(0,L.jsx)(`div`,{className:`p-6 text-muted text-center`,children:t(`Empty file.`)});let[r,...i]=n;return(0,L.jsx)(`div`,{className:`overflow-auto p-3`,children:(0,L.jsxs)(`table`,{className:`min-w-full text-[13px] border-separate border-spacing-0`,children:[(0,L.jsx)(`thead`,{children:(0,L.jsx)(`tr`,{children:r.map((e,t)=>(0,L.jsx)(`th`,{className:`sticky top-0 bg-surface-2 text-left font-semibold px-3 py-2 border-b border-border whitespace-nowrap`,children:e},t))})}),(0,L.jsx)(`tbody`,{children:i.map((e,t)=>(0,L.jsx)(`tr`,{className:`odd:bg-surface`,children:e.map((e,t)=>(0,L.jsx)(`td`,{className:`px-3 py-1.5 border-b border-border/60 whitespace-nowrap`,children:e},t))},t))})]})})}function wf(){return(0,L.jsx)(`div`,{className:`flex h-full items-center justify-center text-muted`,children:(0,L.jsx)(A,{className:`animate-spin`,size:22})})}function Tf({error:e}){let t=s();return(0,L.jsx)(`div`,{className:`p-8 text-center text-rose-600 dark:text-rose-300 text-[14px]`,children:t(`Could not load the file: {error}`,{error:e})})}var Ef=`OpenAI's terms cover using a ChatGPT plan inside OpenAI's own Codex; other apps have had this access cut off before (OpenCode, January 2026). If it stops working, an API key does.`,Df=2e3,Of=6e5;function kf({compact:e,onChanged:t}){let n=s(),{toast:r}=E(),[a,o]=(0,I.useState)(null),[c,l]=(0,I.useState)(`unknown`),[u,d]=(0,I.useState)(null),[f,p]=(0,I.useState)(null),[m,h]=(0,I.useState)(!0),[g,_]=(0,I.useState)(``),[v,y]=(0,I.useState)(null),b=(0,I.useRef)(0),x=(0,I.useCallback)(async()=>{try{let e=await M.chatgptStatus();return o(e),l(`yes`),e}catch(e){return/not found/i.test(e.message)?l(`no`):y(e.message),null}},[]);(0,I.useEffect)(()=>{x().then(e=>{e?.pending&&e.url&&(b.current=Date.now(),h(e.port_bound!==!1),p(e.url))})},[x]),(0,I.useEffect)(()=>{if(!f)return;let e=setInterval(()=>{x().then(e=>{e?.signed_in?(p(null),de(),r(n(`Signed in with {label}.`,{label:e.label||`ChatGPT`})),t?.()):e&&e.pending===!1&&e.error?(p(null),y(n(e.error))):Date.now()-b.current>Of&&(p(null),y(n(`The sign-in did not finish in ten minutes. Try again.`)))})},Df);return()=>clearInterval(e)},[f,x,t,r,n]);let S=async()=>{d(`login`),y(null);try{let e=await M.chatgptLogin();b.current=Date.now(),h(e.port_bound!==!1),_(``),p(e.url),window.open(e.url,`_blank`,`noopener`)}catch(e){/not found/i.test(e.message)?l(`no`):y(e.message)}finally{d(null)}},C=async()=>{let e=g.trim();if(e){d(`paste`),y(null);try{await M.chatgptCallback(e),_(``)}catch(e){let t=e.message;/state/i.test(t)?y(n(`That address belongs to another sign-in attempt. Open the page again and paste the address it ends on.`)):/no sign-in|409/i.test(t)?(p(null),y(n(`The sign-in did not finish in ten minutes. Try again.`))):y(t)}finally{d(null)}}},w=async()=>{d(`use`),y(null);try{await M.setLLM({provider:`chatgpt`,model:a?.models?.[0]??``,base_url:``,api_key:``}),de(),r(n(`ChatGPT answers the chat now.`)),t?.()}catch(e){y(e.message)}finally{d(null)}},T=async()=>{d(`logout`),y(null);try{await M.chatgptLogout(),de(),await x(),t?.()}catch(e){y(e.message)}finally{d(null)}},D=n(`Covers chat and the hands. Pictures and clips are not part of it; those need a provider with image or video models.`);return(0,L.jsxs)(`div`,{className:i(`space-y-2`,!e&&`rounded-2xl bg-surface-2/60 p-3`),children:[!e&&(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[13px] font-medium`,children:[(0,L.jsx)(Mt,{size:15,className:`text-accent`}),` `,n(`Sign in with ChatGPT`)]}),c===`no`?(0,L.jsxs)(`p`,{className:`text-[12.5px] text-muted`,children:[n(`This nanoMuse has no sign-in route yet. In a terminal on this computer, run`),` `,(0,L.jsx)(`code`,{className:`rounded bg-surface-2 px-1 py-0.5 text-[12px]`,children:`nanomuse chatgpt login`}),` `,n(`and reload this page.`)]}):a?.signed_in?(0,L.jsxs)(`div`,{className:`space-y-2`,children:[(0,L.jsxs)(`p`,{className:`text-[12.5px]`,children:[(0,L.jsx)(`span`,{className:`font-medium`,children:n(`Signed in with {label}.`,{label:a.label||`ChatGPT`})}),` `,(0,L.jsx)(`span`,{className:`text-muted`,children:D})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,L.jsxs)(`button`,{type:`button`,disabled:u!==null,onClick:()=>void w(),className:i(Ne,`inline-flex items-center gap-1.5 py-2`),children:[u===`use`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):(0,L.jsx)(Mt,{size:14}),` `,n(`Use it for the chat`)]}),(0,L.jsxs)(`button`,{type:`button`,disabled:u!==null,onClick:()=>void T(),className:i(je,`inline-flex items-center gap-1.5`),children:[u===`logout`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):(0,L.jsx)(Tt,{size:14}),` `,n(`Sign out of ChatGPT`)]})]})]}):f?(0,L.jsxs)(`div`,{className:`space-y-2`,children:[(0,L.jsxs)(`p`,{className:`flex items-start gap-2 text-[12.5px] text-muted`,children:[(0,L.jsx)(A,{size:14,className:`mt-0.5 shrink-0 animate-spin`}),(0,L.jsx)(`span`,{children:n(m?`A page from OpenAI opened in a new tab. Sign in there; this card follows on its own.`:`A page from OpenAI opened in a new tab. Sign in there; when the browser ends on an address that will not load, paste that address below.`)})]}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,L.jsxs)(`a`,{href:f,target:`_blank`,rel:`noopener noreferrer`,className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Ae,{size:14}),` `,n(`Open the page again`)]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>p(null),className:je,children:n(`Cancel`)})]}),(0,L.jsxs)(`form`,{className:`flex flex-wrap items-center gap-2`,onSubmit:e=>{e.preventDefault(),C()},children:[(0,L.jsx)(`input`,{type:`url`,value:g,onChange:e=>_(e.target.value),placeholder:`http://localhost:1455/auth/callback?code=…`,"aria-label":n(`The address the browser ended on`),className:`min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-[12.5px]`}),(0,L.jsxs)(`button`,{type:`submit`,disabled:u!==null||!g.trim(),className:i(je,`inline-flex items-center gap-1.5`),children:[u===`paste`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):null,` `,n(`Use this address`)]})]}),!m&&(0,L.jsx)(`p`,{className:`text-[11.5px] text-muted`,children:n(`This nanoMuse runs where the browser cannot reach port 1455, so it cannot pick the sign-in up by itself.`)})]}):(0,L.jsxs)(`div`,{className:`space-y-2`,children:[(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:D}),(0,L.jsxs)(`button`,{type:`button`,disabled:u!==null||c===`unknown`,onClick:()=>void S(),className:i(Ne,`inline-flex items-center gap-1.5 py-2`),children:[u===`login`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):(0,L.jsx)(wt,{size:14}),` `,n(`Sign in with ChatGPT`)]})]}),(0,L.jsx)(`p`,{className:`text-[11.5px] leading-snug text-muted`,children:n(Ef)}),v&&(0,L.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:v})]})}var Af=``,jf=`nm.connections.preset`;function Mf(e,t=ae().preset){try{sessionStorage.setItem(jf,t)}catch{}e(`connections`)}function Nf(){try{let e=sessionStorage.getItem(jf);return e&&sessionStorage.removeItem(jf),e}catch{return null}}function Pf({info:e,exhausted:t,compact:n}){let r=s(),{toast:a,setTab:o,state:l}=E(),u=l.hub?.account,d=ae(u),f=le(u),p=c(),m=e.invite_bonus_cny??5,h=e.own_key_docs||e.guidance?.docs||Af,g=e.invite_url||``,[_,v]=(0,I.useState)(!1),[y,b]=(0,I.useState)(!1),x=(e.guidance?.providers??De(f)).filter(e=>e.id!==d.id).map(e=>({id:e.id,name:pe(e,p),keyUrl:e.key_url,covers:`covers`in e?e.covers:e.capabilities})),S=Ee().map(e=>e.name),C=async e=>{try{await navigator.clipboard.writeText(e),a(r(`Copied`))}catch{a(e)}},w=async()=>{let e=r(`Try nanoMuse with me, a fully open-source personal agent, free to use. Sign up with my link: {link}`,{link:g}),t=navigator;if(t.share)try{await t.share({text:e});return}catch{}await C(e)},T=Ce(),[D,O]=me(`exhausted`,t),k=ve(xe(r,`exhausted`)),ee=t?r(`The free allowance is used up.`):r(`Nearly used up: ¥{left} of ¥{grant} left.`,{left:(e.left??0).toFixed(2),grant:(e.grant??0).toFixed(0)});return(0,L.jsxs)(`div`,{className:i(`space-y-2.5`,!n&&`pt-1`),children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:i(`font-semibold`,n?`text-[13.5px]`:`text-[15px]`),children:ee}),(0,L.jsx)(`div`,{className:`mt-0.5 text-[12.5px] text-muted`,children:r(`Three ways on; your sign-in and your devices keep working whichever you pick.`)})]}),(0,L.jsxs)(Ff,{icon:(0,L.jsx)(P,{size:16}),tone:`bg-accent/12 text-accent`,title:r(`Use your own model key`),children:[(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:_e(r,u)}),(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>Mf(o,d.preset),className:i(Ne,`inline-flex items-center gap-1.5 py-2`),children:[(0,L.jsx)(P,{size:14}),` `,r(`Set it up`)]}),(0,L.jsxs)(`a`,{href:d.keyUrl,target:`_blank`,rel:`noopener noreferrer`,className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Ae,{size:14}),` `,r(`Get a key from {vendor}`,{vendor:d.label})]}),h?(0,L.jsxs)(`a`,{href:h,target:`_blank`,rel:`noopener noreferrer`,className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Ae,{size:14}),` `,r(`Step-by-step guide`)]}):null]}),x.length>0&&(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>v(!_),className:`inline-flex items-center gap-1 text-[12px] text-muted`,children:[_?(0,L.jsx)(Le,{size:13}):(0,L.jsx)(Me,{size:13}),` `,r(`Other providers and what each key covers`)]}),_&&(0,L.jsx)(`ul`,{className:`mt-1.5 space-y-1 text-[12.5px]`,children:x.map(e=>(0,L.jsxs)(`li`,{className:`flex flex-wrap items-baseline justify-between gap-x-3`,children:[(0,L.jsxs)(`span`,{children:[(0,L.jsx)(`span`,{className:`font-medium`,children:e.name}),` `,(0,L.jsxs)(`span`,{className:`text-muted`,children:[`· `,ye(r,e.covers)]})]}),(0,L.jsxs)(`span`,{className:`flex gap-2`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>Mf(o,Oe(e.id)),className:`text-accent`,children:r(`Set it up`)}),(0,L.jsxs)(`a`,{href:e.keyUrl,target:`_blank`,rel:`noopener noreferrer`,className:`inline-flex items-center gap-0.5 text-accent`,children:[r(`Get a key`),` `,(0,L.jsx)(Ae,{size:11})]})]})]},e.id))})]})]}),(0,L.jsxs)(Ff,{icon:(0,L.jsx)(wt,{size:16}),tone:`bg-emerald-500/12 text-emerald-600 dark:text-emerald-300`,title:r(`Sign in with a plan you already pay for`),children:[(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:r(`{plans}: the plan's models answer here, no key to make. ChatGPT works on every device, the others on the phones. A ChatGPT sign-in covers chat and the hands; pictures and clips still want a key.`,{plans:S.join(r(`, `))})}),y?(0,L.jsx)(kf,{compact:!0,onChanged:()=>b(!1)}):(0,L.jsxs)(`button`,{type:`button`,onClick:()=>b(!0),className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(wt,{size:14}),` `,r(`Sign in with ChatGPT`)]})]}),(0,L.jsx)(Ff,{icon:(0,L.jsx)(hn,{size:16}),tone:`bg-violet-500/12 text-violet-600 dark:text-violet-300`,title:r(`Invite a friend: +¥{bonus} for you and +¥{bonus} for them, for each new person who signs up with your link.`,{bonus:m.toFixed(0)}),children:g?(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-2`,children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void w(),className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Xt,{size:14}),` `,r(`Share the link`)]}),(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void C(g),className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(ut,{size:14}),` `,r(`Copy the link`)]})]}):(0,L.jsxs)(`button`,{type:`button`,onClick:()=>o(`account`),className:i(je,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Xt,{size:14}),` `,r(`Your invite link is under Account`)]})}),t&&D&&(0,L.jsxs)(Ff,{icon:(0,L.jsx)(ge,{size:16}),tone:`bg-amber-400/15 text-amber-600 dark:text-amber-300`,title:k,children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>fe(T.repo_url),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(ge,{size:14}),` `,r(`Star on GitHub`)]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>{he(),O(!1)},className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:r(`I already starred`)})]})]})}function Ff({icon:e,tone:t,title:n,children:r}){return(0,L.jsxs)(`div`,{className:`flex gap-3 rounded-2xl bg-surface-2/60 p-3`,children:[(0,L.jsx)(`span`,{className:i(`flex h-8 w-8 shrink-0 items-center justify-center rounded-xl`,t),children:e}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1 space-y-2`,children:[(0,L.jsx)(`div`,{className:`text-[13px] font-medium leading-snug`,children:n}),r]})]})}var If=`nm.cloud.warned_grant`;function Lf(){let e=s(),{state:t,setTab:n}=E(),[r,i]=(0,I.useState)(null),a=(0,I.useRef)(0),o=t.hub?.account,c=!!o?.signed_in&&!!o.is_model,l=t.finishedAt;if((0,I.useEffect)(()=>{if(!c||!l||r||Date.now()-a.current<6e4)return;a.current=Date.now();let e=!1;return M.cloudMe().then(t=>{if(e)return;let n=t.spend,r=n.grant??n.allowance_cny??0,a=n.left??null;if(n.unlimited||!n.warn||a===null||a<=0)return;let o=null;try{o=localStorage.getItem(If)}catch{}o!==String(r)&&i({left:a,grant:r,invite_url:t.invite?.url,invite_bonus_cny:n.invite_bonus_cny,invitee_bonus_cny:n.invitee_bonus_cny,own_key_docs:n.own_key_docs,guidance:n.guidance})}).catch(()=>{}),()=>{e=!0}},[c,l,r]),!r)return null;let u=()=>{try{localStorage.setItem(If,String(r.grant??0))}catch{}i(null)};return(0,L.jsxs)(`div`,{className:`rise mx-3 mb-1.5 flex items-start gap-2 rounded-2xl bg-amber-500/12 px-3 py-2 text-[12.5px] leading-snug text-amber-800 dark:text-amber-200`,children:[(0,L.jsx)(nn,{size:14,className:`mt-0.5 shrink-0`}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[e(`Nearly used up: ¥{left} of ¥{grant} left.`,{left:(r.left??0).toFixed(2),grant:(r.grant??0).toFixed(0)}),` `,e(`Invite a friend (+¥{invite} for each of you) or bring your own key; your sign-in keeps working either way.`,{invite:(r.invite_bonus_cny??5).toFixed(0)}),` `,(0,L.jsx)(`button`,{type:`button`,onClick:()=>{u(),n(`account`)},className:`font-semibold underline decoration-dotted underline-offset-2`,children:e(`See the ways`)})]}),(0,L.jsx)(`button`,{type:`button`,onClick:u,"aria-label":e(`Dismiss`),className:`shrink-0 rounded-full p-0.5 opacity-70 hover:opacity-100`,children:(0,L.jsx)(Fe,{size:14})})]})}var Rf=`nm.cloud.first_sign_in`,zf=`nm:first-sign-in`;function Bf(){try{sessionStorage.setItem(Rf,`1`)}catch{}window.dispatchEvent(new Event(zf))}function Vf(){try{sessionStorage.removeItem(Rf)}catch{}window.dispatchEvent(new Event(zf))}function Hf(){try{return sessionStorage.getItem(Rf)===`1`}catch{return!1}}function Uf(){let[e,t]=(0,I.useState)(Hf);return(0,I.useEffect)(()=>{let e=()=>t(Hf());return window.addEventListener(zf,e),()=>window.removeEventListener(zf,e)},[]),e}function Wf(){let e=s(),{toast:t,refreshHub:n,setTab:r,state:a}=E(),o=ae(a.hub?.account),[c,l]=(0,I.useState)(`password`),[u,d]=(0,I.useState)(``),[f,p]=(0,I.useState)(``),[m,h]=(0,I.useState)(!1),[g,_]=(0,I.useState)(null),v=async()=>{Vf(),await n().catch(()=>void 0)},y=async()=>{if(!(u.length<8||u!==f)){h(!0),_(null);try{await M.cloudPassword(u),t(e(`Password set.`)),d(``),p(``),l(`source`)}catch(t){_(e(t.message))}finally{h(!1)}}},b=async()=>{await v(),Mf(r,o.preset)},x=(0,I.useRef)(null);On(x,!0);let S=f.length>0&&f!==u;return(0,L.jsx)(`div`,{className:`fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-[2px] sm:items-center`,role:`dialog`,"aria-modal":`true`,children:(0,L.jsxs)(`div`,{ref:x,className:`safe-bottom w-full max-w-[520px] rounded-t-[28px] bg-bg p-6 shadow-2xl sm:rounded-[28px]`,children:[(0,L.jsxs)(`div`,{className:`mb-4 flex gap-1`,children:[(0,L.jsx)(`span`,{className:i(`h-1.5 w-5 rounded-full bg-accent`)}),(0,L.jsx)(`span`,{className:i(`h-1.5 rounded-full transition-all`,c===`source`?`w-5 bg-accent`:`w-1.5 bg-border`)})]}),c===`password`?(0,L.jsxs)(`form`,{className:`space-y-4`,onSubmit:e=>{e.preventDefault(),y()},children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,L.jsx)(`span`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-accent/12 text-accent`,children:(0,L.jsx)(P,{size:20})}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h2`,{className:`text-[20px] font-bold tracking-tight`,children:e(`Set a password`)}),(0,L.jsx)(`p`,{className:`mt-1 text-[13.5px] leading-relaxed text-muted`,children:e(`Your account is in. With a password, your phone and your other computers sign in at once, without waiting for a code. Optional; you can set one later under Account.`)})]})]}),(0,L.jsxs)(`div`,{className:`space-y-2`,children:[(0,L.jsx)(`input`,{type:`password`,value:u,onChange:e=>d(e.target.value),autoComplete:`new-password`,autoFocus:!0,placeholder:e(`At least 8 characters`),className:Ie}),(0,L.jsx)(`input`,{type:`password`,value:f,onChange:e=>p(e.target.value),autoComplete:`new-password`,placeholder:e(`Once more`),className:i(Ie,S&&`border-rose-400`)}),S&&(0,L.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:e(`The two do not match.`)})]}),g&&(0,L.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:g}),(0,L.jsxs)(`div`,{className:`flex gap-2`,children:[(0,L.jsx)(`button`,{type:`button`,disabled:m,onClick:()=>l(`source`),className:je,children:e(`Skip`)}),(0,L.jsxs)(`button`,{type:`submit`,disabled:m||u.length<8||u!==f,className:i(Ne,`flex-1 py-3`),children:[m?(0,L.jsx)(A,{size:15,className:`animate-spin`}):null,` `,e(`Set the password`),` `,(0,L.jsx)(qe,{size:16})]})]})]}):(0,L.jsxs)(`div`,{className:`space-y-4`,children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,L.jsx)(`span`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-accent/12 text-accent`,children:(0,L.jsx)(ot,{size:20})}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`h2`,{className:`text-[20px] font-bold tracking-tight`,children:e(`Which model answers?`)}),(0,L.jsx)(`p`,{className:`mt-1 text-[13.5px] leading-relaxed text-muted`,children:e(`Your account already brings one. You can add your own API key as well and switch at any time.`)})]})]}),(0,L.jsxs)(`ul`,{className:`space-y-2 text-[13px] leading-snug`,children:[(0,L.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2 px-3 py-2.5`,children:[(0,L.jsx)(ot,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,L.jsxs)(`span`,{children:[(0,L.jsx)(`span`,{className:`font-medium`,children:e(`Use the nanoMuse Cloud model`)}),(0,L.jsx)(`span`,{className:`block text-muted`,children:e(`DeepSeek for the chat and Qwen for the hands, with a free allowance per account paid by the developer. Nothing to configure.`)})]})]}),(0,L.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2 px-3 py-2.5`,children:[(0,L.jsx)(P,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,L.jsxs)(`span`,{children:[(0,L.jsx)(`span`,{className:`font-medium`,children:e(`I have my own API key`)}),(0,L.jsx)(`span`,{className:`block text-muted`,children:o.preset===`qwen`?e(`Alibaba Cloud Bailian first (one key covers chat, the hands, pictures and clips), then DeepSeek, Kimi, Zhipu, OpenAI, OpenRouter and any OpenAI-compatible endpoint. The key stays on this device.`):e(`OpenRouter first (one account, one key, pay as you go), then OpenAI, Anthropic, Gemini, DeepSeek and any OpenAI-compatible endpoint. The key stays on this device.`)})]})]}),(0,L.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2 px-3 py-2.5`,children:[(0,L.jsx)(wt,{size:16,className:`mt-0.5 shrink-0 text-accent`}),(0,L.jsxs)(`span`,{children:[(0,L.jsx)(`span`,{className:`font-medium`,children:e(`Sign in with a plan you already pay for`)}),(0,L.jsx)(`span`,{className:`block text-muted`,children:e(`A ChatGPT plan signs in from Connections and answers the chat and the hands. Pictures and clips still want a key.`)})]})]})]}),(0,L.jsxs)(`div`,{className:`flex gap-2`,children:[(0,L.jsx)(`button`,{type:`button`,disabled:m,onClick:()=>void b(),className:je,children:e(`I have my own API key`)}),(0,L.jsxs)(`button`,{type:`button`,disabled:m,onClick:()=>void v(),className:i(Ne,`flex-1 py-3`),children:[e(`Use the nanoMuse Cloud model`),` `,(0,L.jsx)(qe,{size:16})]})]})]})]})})}var Gf=`nanomuse_devtools`;function Kf(){try{return localStorage.getItem(Gf)===`1`}catch{return!1}}var qf=Kf(),Jf=new Set;function Yf(){return qf}function Xf(e){qf=e;try{localStorage.setItem(Gf,e?`1`:`0`)}catch{}Jf.forEach(e=>e())}function Zf(e){return Jf.add(e),()=>Jf.delete(e)}function Qf(){return(0,I.useSyncExternalStore)(Zf,Yf,Yf)}function $f(e,t,n){if(!n.signedIn||n.chatOnCloud||n.deviceChat)return;let r=e[t];if(r?.type===`notice`&&r.level===`error`&&r.code&&r.code!==`allowance`&&!r.model_used)for(let n=t-1;n>=0;n--){let t=e[n];if(t?.type===`user`){let e=(t.files??[]).map(e=>e.path);return t.text||e.length>0?{text:t.text,files:e}:void 0}}}var ep=[`idle`,`working`,`waiting`,`happy`,`error`];function tp({event:e,name:t=`nanoMuse`}){let n=s(),{toast:r}=E(),[a,o]=(0,I.useState)(null),c=e.cost??{},l=e.candidates??[null,null,null,null],u=e.moods??{},d=e.clips??{},f=async(e,t)=>{o(e);try{await t()}catch(e){r(e.message)}finally{o(null)}},p=e.stage===`estimate`||e.stage===`drawing`||e.stage===`choose`||e.stage===`posing`||e.stage===`animating`,m=c.clips?n(`{n} pictures and {c} clips`,{n:c.pictures??8,c:c.clips}):n(`{n} pictures`,{n:c.pictures??8}),g=(()=>{if(c.cloud===!1)return n(`{what}, made with your own key at your provider's prices.`,{what:m});if(c.error)return n(`The cost could not be checked: {error}`,{error:c.error});if(typeof c.cny!=`number`)return n(`Checking the cost…`);if(c.unlimited)return n(`About ¥{cny} for {what}; your account has no limit.`,{cny:c.cny.toFixed(2),what:m});let e=typeof c.left_cny==`number`?c.left_cny.toFixed(2):`?`;return c.affordable===!1?n(`About ¥{cny} for {what}, more than the ¥{left} left in your allowance.`,{cny:c.cny.toFixed(2),what:m,left:e}):n(`About ¥{cny} for {what}; ¥{left} left in your allowance.`,{cny:c.cny.toFixed(2),what:m,left:e})})();return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`div`,{className:i(`w-full max-w-[360px] overflow-hidden rounded-3xl rounded-tl-lg border bg-surface shadow-sm`,p?`border-accent/50`:`border-border/70`),children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-3.5 pb-2`,children:[(0,L.jsx)(`div`,{className:i(`mt-0.5 rounded-full p-2`,p?`bg-accent/12 text-accent`:`bg-surface-2 text-fg/80`),children:(0,L.jsx)(nn,{size:18})}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsxs)(`div`,{className:`text-[12.5px] font-medium text-muted`,children:[e.stage===`estimate`&&n(`A new look for {name}`,{name:t}),e.stage===`drawing`&&n(`Drawing four to choose from…`),e.stage===`choose`&&n(`Pick one: tap it, or say which`),e.stage===`posing`&&n(`Drawing the poses…`),e.stage===`animating`&&n(`The new look is on; making the clips…`),e.stage===`done`&&n(`The new look is on`),e.stage===`cancelled`&&n(`Not this time`),e.stage===`failed`&&n(`That did not work`)]}),(0,L.jsx)(`div`,{className:`mt-0.5 text-[14px] leading-snug`,children:e.description}),e.reference&&(0,L.jsxs)(`div`,{className:`mt-1.5 flex items-center gap-2 text-[12px] text-muted`,children:[(0,L.jsx)(`img`,{src:h(e.reference),alt:``,draggable:!1,className:`h-9 w-9 rounded-lg object-cover`}),n(`drawn from the picture you attached`)]})]})]}),e.stage===`estimate`&&(0,L.jsxs)(`div`,{className:`px-4 pb-3.5 space-y-2.5`,children:[(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:g}),(0,L.jsxs)(`div`,{className:`flex gap-2`,children:[(0,L.jsxs)(`button`,{type:`button`,disabled:a!==null||c.cloud!==!1&&typeof c.cny!=`number`&&!c.error,onClick:()=>f(`start`,()=>M.avatarStart(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl bg-accent text-accent-fg px-4 py-2.5 text-[14px] font-medium disabled:opacity-40`,`inline-flex items-center gap-1.5 py-2`),children:[a===`start`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):(0,L.jsx)(nn,{size:14}),` `,n(`Draw`)]}),(0,L.jsxs)(`button`,{type:`button`,disabled:a!==null,onClick:()=>f(`cancel`,()=>M.avatarCancel(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Fe,{size:14}),` `,n(`Not now`)]})]})]}),(e.stage===`drawing`||e.stage===`choose`)&&(0,L.jsxs)(`div`,{className:`px-4 pb-3.5 space-y-2.5`,children:[(0,L.jsx)(`div`,{className:`grid grid-cols-2 gap-2`,children:l.map((t,r)=>(0,L.jsxs)(`button`,{type:`button`,disabled:!t||e.stage!==`choose`||a!==null,onClick:()=>f(`pick${r}`,()=>M.avatarChoose(e.session,r)),"aria-label":n(`Candidate {n}`,{n:r+1}),className:i(`relative aspect-square overflow-hidden rounded-2xl bg-[#f1efeb] transition`,t&&e.stage===`choose`?`hover:ring-2 hover:ring-accent`:``),children:[t?(0,L.jsx)(`img`,{src:h(t),alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,L.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center text-muted`,children:e.stage===`drawing`?(0,L.jsx)(A,{size:20,className:`animate-spin`}):(0,L.jsx)(Fe,{size:18,className:`opacity-50`})}),a===`pick${r}`&&(0,L.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center bg-bg/60`,children:(0,L.jsx)(A,{size:20,className:`animate-spin`})}),(0,L.jsx)(`span`,{className:`absolute left-1.5 top-1.5 rounded-full bg-bg/80 px-1.5 text-[10.5px] font-semibold text-fg/80`,children:r+1})]},r))}),e.errors&&e.errors.length>0&&(0,L.jsx)(`p`,{className:`text-[12px] text-rose-600 dark:text-rose-300`,children:n(e.errors[0])}),e.stage===`choose`&&(0,L.jsxs)(`div`,{className:`flex gap-2`,children:[(0,L.jsxs)(`button`,{type:`button`,disabled:a!==null,onClick:()=>f(`redraw`,()=>M.avatarStart(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[a===`redraw`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):(0,L.jsx)(Re,{size:14}),` `,n(`Redraw`)]}),(0,L.jsxs)(`button`,{type:`button`,disabled:a!==null,onClick:()=>f(`cancel`,()=>M.avatarCancel(e.session)),className:i(`flex items-center justify-center gap-1.5 rounded-2xl border border-border px-4 py-2.5 text-[14px] font-medium text-muted disabled:opacity-40`,`inline-flex items-center gap-1.5`),children:[(0,L.jsx)(Fe,{size:14}),` `,n(`Not now`)]})]})]}),(e.stage===`posing`||e.stage===`animating`||e.stage===`done`)&&(0,L.jsxs)(`div`,{className:`px-4 pb-3.5 space-y-2`,children:[(0,L.jsx)(`div`,{className:`flex gap-1.5`,children:ep.map(t=>(0,L.jsxs)(`div`,{className:`relative aspect-square flex-1 overflow-hidden rounded-xl bg-[#f1efeb]`,children:[d[t]?(0,L.jsx)(`video`,{src:h(d[t]),poster:u[t]?h(u[t]):void 0,autoPlay:!0,loop:!0,muted:!0,playsInline:!0,disablePictureInPicture:!0,className:`h-full w-full object-cover`},d[t]):u[t]?(0,L.jsx)(`img`,{src:h(u[t]),alt:``,draggable:!1,className:`h-full w-full object-cover`}):(0,L.jsx)(`span`,{className:`absolute inset-0 flex items-center justify-center text-muted`,children:(0,L.jsx)(A,{size:14,className:`animate-spin`})}),e.stage===`animating`&&u[t]&&!d[t]&&t!==`error`&&(0,L.jsx)(`span`,{className:`absolute bottom-1 right-1 rounded-full bg-white/80 p-0.5 text-muted`,children:(0,L.jsx)(A,{size:10,className:`animate-spin`})})]},t))}),e.stage===`done`?(0,L.jsxs)(`p`,{className:`flex items-center gap-1.5 text-[12.5px] text-muted`,children:[(0,L.jsx)(ke,{size:14,className:`text-emerald-600`}),` `,n(`Idle, working, waiting, happy, sorry: the face follows what {name} is doing.`,{name:t})]}):e.stage===`animating`?(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:n(`The face is already on; four short clips are being made from the poses, a few minutes.`)}):(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:n(`Four more pictures from the one you picked; this takes a minute or two.`)}),e.errors&&e.errors.length>0&&e.stage===`done`&&(0,L.jsxs)(`p`,{className:`text-[12px] text-muted`,children:[e.errors.some(e=>!e.includes(` clip:`))&&n(`Some poses could not be drawn and show the idle picture instead.`),` `,e.errors.some(e=>e.includes(` clip:`))&&n(`Some clips could not be made; those moods stay still.`)]})]}),(e.stage===`failed`||e.stage===`cancelled`)&&e.message&&(0,L.jsx)(`p`,{className:`px-4 pb-3.5 text-[12.5px] text-muted`,children:n(e.message)})]})})}function np(){let e=rp();if(!e||typeof e.keepRunning!=`function`)return null;try{return JSON.parse(e.keepRunning())}catch{return null}}function rp(){return window.NanoMuseAndroid??null}function ip(){let e=rp();return!!e&&typeof e.takeOverBrowser==`function`&&(typeof e.hasBrowser!=`function`||e.hasBrowser())}function ap(){let e=rp();if(!e||typeof e.accessibilityState!=`function`)return null;let t=e.accessibilityState();return t===`on`||t===`off`||t===`unsupported`?t:null}function op(e,t=15){switch(e){case`web_search`:return(0,L.jsx)(qt,{size:t});case`web_fetch`:case`browser`:return(0,L.jsx)(gt,{size:t});case`files`:return(0,L.jsx)(mt,{size:t});case`shell`:return(0,L.jsx)(dn,{size:t});case`python_execute`:return(0,L.jsx)(ct,{size:t});case`read_emails`:case`send_email`:return(0,L.jsx)(Et,{size:t});case`goals`:return(0,L.jsx)(un,{size:t});case`remember`:case`recall`:case`forget`:return(0,L.jsx)(Qe,{size:t});case`ask_user`:return(0,L.jsx)(kt,{size:t});case`devices`:return(0,L.jsx)(It,{size:t});case`device_shell`:return(0,L.jsx)(dn,{size:t});case`device_files`:case`device_get`:case`device_put`:return(0,L.jsx)(mt,{size:t});case`device_open`:return(0,L.jsx)(Ae,{size:t});case`device_screen`:return(0,L.jsx)(Lt,{size:t});case`device_notify`:return(0,L.jsx)(Xe,{size:t});case`delegate`:return(0,L.jsx)(Jt,{size:t});case`computer_screen`:return(0,L.jsx)(Lt,{size:t});case`computer_act`:return(0,L.jsx)(zt,{size:t});case`computer_task`:case`computer_app`:return(0,L.jsx)(vt,{size:t});case`phone_screen`:case`phone_act`:case`phone_task`:return(0,L.jsx)(tn,{size:t});default:return(0,L.jsx)(Ze,{size:t})}}function sp({device:e,className:t}){let n=s();return(0,L.jsxs)(`span`,{className:i(`inline-flex max-w-full items-center gap-1 rounded-full bg-accent/12 px-2 py-0.5 text-[11px] font-medium text-accent`,t),children:[(0,L.jsx)(It,{size:11,className:`shrink-0`}),` `,(0,L.jsx)(`span`,{className:`truncate`,children:n(`on {device}`,{device:e})})]})}var cp={safe:`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300`,moderate:`bg-amber-500/15 text-amber-700 dark:text-amber-300`,sensitive:`bg-rose-500/15 text-rose-700 dark:text-rose-300`};function lp({risk:e}){return(0,L.jsx)(`span`,{className:i(`px-2 py-0.5 rounded-full text-[11px] font-semibold uppercase tracking-wide`,cp[e]),children:_(e)})}function up({args:e}){let t=Object.entries(e??{}).filter(([,e])=>e!=null&&e!==``);return t.length?(0,L.jsx)(`dl`,{className:`mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[13px]`,children:t.map(([e,t])=>(0,L.jsxs)(`div`,{className:`contents`,children:[(0,L.jsx)(`dt`,{className:`text-muted`,children:e}),(0,L.jsx)(`dd`,{className:`break-words whitespace-pre-wrap font-mono text-[12.5px] leading-snug`,children:String(t)})]},e))}):null}function dp({event:e}){let[t,n]=(0,I.useState)(!1),r=e.status===`running`?(0,L.jsx)(A,{size:14,className:`animate-spin text-accent`}):e.status===`ok`?(0,L.jsx)(ke,{size:14,className:`text-emerald-500`}):e.status===`blocked`?(0,L.jsx)(Ye,{size:14,className:`text-rose-500`}):(0,L.jsx)(Fe,{size:14,className:`text-rose-500`});return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>n(e=>!e),className:`max-w-full text-left rounded-2xl bg-surface-2/80 border border-border/60 px-3 py-1.5 text-[13px] text-muted hover:text-fg transition`,children:[(0,L.jsxs)(`span`,{className:`flex items-center gap-2`,children:[(0,L.jsx)(`span`,{className:`text-muted`,children:op(e.tool)}),(0,L.jsx)(`span`,{className:`truncate flex-1`,children:e.title||e.summary||e.tool}),e.device&&(0,L.jsx)(sp,{device:e.device}),r,e.output?t?(0,L.jsx)(Me,{size:13}):(0,L.jsx)(rt,{size:13}):null]}),t&&(0,L.jsxs)(`div`,{className:`mt-2 text-fg`,children:[(0,L.jsx)(up,{args:e.args}),e.output&&(0,L.jsx)(`pre`,{className:`mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-bg p-2 text-[12px] leading-snug`,children:e.output})]})]})})}function fp(e,t,n){if(!t)return e.replace(/_/g,` `);switch(e){case`shell`:return _(`{what} commands`,{what:t.split(`,`).join(`, `)});case`send_email`:return _(`email to {who}`,{who:t.split(`,`).join(`, `)});case`remote_control`:return _(`the device {name}`,{name:typeof n?.device==`string`&&n.device?n.device:t});case`computer_app`:return _(`the hands in {app}`,{app:typeof n?.name==`string`&&n.name?n.name:t});default:return t}}function pp(e,t,n,r){switch(e){case`once`:return _(`Once`);case`conversation`:return _(`For this conversation`);case`session`:return _(`Until restart`);case`24h`:return _(`For 24 hours`);case`always`:return _(`Always for {subject}`,{subject:fp(t,n,r)});default:return e}}function mp({event:e,name:t=`nanoMuse`,onDecide:n}){let[r,a]=(0,I.useState)(!1),[o,c]=(0,I.useState)(!1),[l,d]=(0,I.useState)(!1),[f,p]=(0,I.useState)(!1),m=(e,t)=>{f||(p(!0),Promise.resolve(n(e,t)).finally(()=>p(!1)))},h=(0,I.useRef)(null),g=(0,I.useRef)(null),_=s(),y=e.status===`pending`,b=e.risk===`sensitive`,x=(e.grant_options??[`once`]).filter(e=>e!==`once`),S=e.egress_target||([`web_fetch`,`browser`,`web_search`].includes(e.tool)?e.target:null),C=e.tool===`send_email`?e.target:null,w=Object.values(e.args??{}).some(e=>e!=null&&e!==``);return(0,I.useEffect)(()=>{let e=g.current;e&&!r&&d(e.scrollHeight>e.clientHeight+2)},[e.args,r]),(0,I.useEffect)(()=>{(r||o)&&h.current?.scrollIntoView({block:`end`,behavior:`smooth`})},[r,o]),(0,L.jsx)(`div`,{className:`rise flex justify-start`,ref:h,children:(0,L.jsxs)(`div`,{className:i(`w-full max-w-[600px] overflow-hidden rounded-[22px] bg-surface shadow-[0_2px_12px_rgba(0,0,0,0.12)] dark:border dark:border-border dark:shadow-none`,y&&b&&`border border-rose-400/60 dark:border-rose-400/60`),children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-4 pb-2`,children:[(0,L.jsx)(`div`,{className:i(`flex h-10 w-10 shrink-0 items-center justify-center rounded-full`,b?`bg-rose-500/12 text-rose-500`:`bg-surface-2 text-fg`),children:b?(0,L.jsx)(Zt,{size:22}):op(e.tool,22)}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsxs)(`div`,{className:`flex flex-wrap items-center gap-1.5 text-[12.5px] font-medium text-muted`,children:[(0,L.jsx)(`span`,{children:e.remote?_(`{name} on {device} wants to`,{name:t,device:e.remote.name}):_(`{name} wants to`,{name:t})}),e.remote&&(0,L.jsx)(sp,{device:e.remote.name})]}),(0,L.jsx)(`div`,{className:`mt-0.5 break-words text-[17px] font-semibold leading-[22px]`,children:e.summary}),S?(0,L.jsxs)(`div`,{className:`mt-1 flex items-center gap-1 text-[13px] text-muted`,children:[(0,L.jsx)(gt,{size:12}),` `,(0,L.jsx)(`span`,{className:`truncate`,children:_(`at {host}`,{host:S})})]}):C?(0,L.jsxs)(`div`,{className:`mt-1 flex items-center gap-1 text-[13px] text-muted`,children:[(0,L.jsx)(Et,{size:12}),` `,(0,L.jsx)(`span`,{className:`truncate`,children:_(`to {recipient}`,{recipient:C})})]}):null,e.purpose&&(0,L.jsx)(`div`,{className:`mt-1.5 break-words text-[13px] leading-snug text-muted line-clamp-2`,children:e.purpose}),b&&(0,L.jsx)(`div`,{className:`mt-1.5`,children:(0,L.jsx)(lp,{risk:e.risk})})]})]}),e.warnings?.length>0&&(0,L.jsx)(`div`,{className:`space-y-1 px-4 pb-2`,children:e.warnings.map(e=>(0,L.jsxs)(`div`,{className:`flex items-start gap-1.5 text-[12.5px] text-rose-600 dark:text-rose-300`,children:[(0,L.jsx)(pn,{size:13,className:`mt-0.5 shrink-0`}),` `,(0,L.jsx)(`span`,{children:_(e)})]},e))}),(w||e.reasons?.length>0)&&(0,L.jsx)(`div`,{className:`px-4 pb-2`,children:(0,L.jsxs)(`div`,{className:`rounded-xl bg-surface-2 px-3 py-2.5`,children:[(0,L.jsx)(`div`,{className:`text-[11px] font-medium text-muted`,children:_(`Exactly what will run`)}),(0,L.jsxs)(`div`,{ref:g,className:i(`-mt-1`,!r&&`max-h-40 overflow-hidden`),children:[(0,L.jsx)(up,{args:e.args}),e.reasons?.length>0&&(0,L.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:_(`Why it asks: {reasons}`,{reasons:e.reasons.map(v).join(` · `)})})]}),(l||r)&&(0,L.jsxs)(`button`,{type:`button`,className:`mt-1 flex items-center gap-1 text-[12.5px] text-accent`,onClick:()=>a(e=>!e),children:[r?(0,L.jsx)(Me,{size:13}):(0,L.jsx)(rt,{size:13}),` `,_(r?`Fewer options`:`Show all`)]})]})}),y?(0,L.jsxs)(`div`,{className:`flex flex-col gap-2 px-4 pb-4 pt-2`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>m(!0,`once`),disabled:f,className:`h-[46px] w-full rounded-full bg-accent text-[15px] font-semibold text-accent-fg transition active:scale-[0.98] disabled:opacity-60`,children:_(`Allow once`)}),x.filter(e=>o||e===`conversation`||e===`always`).map(t=>(0,L.jsx)(`button`,{type:`button`,onClick:()=>m(!0,t),disabled:f,className:`h-11 w-full truncate rounded-full bg-surface-2 px-4 text-[15px] font-medium transition active:scale-[0.98] disabled:opacity-60`,children:pp(t,e.tool,e.target,e.args)},t)),(0,L.jsx)(`button`,{type:`button`,onClick:()=>m(!1,`once`),disabled:f,className:`h-11 w-full rounded-full bg-surface-2 text-[15px] font-medium transition active:scale-[0.98] disabled:opacity-60`,children:_(`Deny`)}),x.length===0?(0,L.jsx)(`div`,{className:`text-center text-[12px] text-muted`,children:_(`This kind of action is approved one at a time.`)}):!o&&x.some(e=>e!==`conversation`&&e!==`always`)&&(0,L.jsx)(`button`,{type:`button`,className:`self-center text-[12.5px] text-muted`,onClick:()=>c(!0),children:_(`Allow {subject} for longer…`,{subject:fp(e.tool,e.target,e.args)})})]}):(0,L.jsxs)(`div`,{className:i(`flex items-center gap-1.5 border-t border-border px-4 py-2.5 text-[13px] font-medium`,e.status===`approved`?`text-emerald-600 dark:text-emerald-300`:`text-muted`),children:[e.status===`approved`?(0,L.jsx)(ke,{size:15}):(0,L.jsx)(Fe,{size:15}),e.status===`approved`?`${_(`Approved`)}${e.scope&&e.scope!==`once`?` · ${pp(e.scope,e.tool,e.target).toLowerCase()}`:``}`:e.status===`denied`?_(`Denied`):_(`Expired without an answer`),(0,L.jsx)(`span`,{className:`ml-auto font-normal`,children:u(e.updated_ts??e.ts)})]})]})})}function hp({event:e,name:t}){let n=s();return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`div`,{className:`max-w-full rounded-[20px] rounded-bl-md border border-accent/30 bg-surface px-4 py-3`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-1.5 text-[12.5px] font-medium text-accent`,children:[(0,L.jsx)(kt,{size:14}),` `,n(`{name} asks`,{name:t})]}),(0,L.jsx)(`div`,{className:`mt-1 text-[15px] leading-snug whitespace-pre-wrap break-words`,children:e.text}),e.status===`pending`&&(0,L.jsx)(`div`,{className:`mt-1.5 text-[12.5px] text-muted`,children:n(`Reply below to continue.`)}),e.status===`answered`&&e.answer&&(0,L.jsxs)(`div`,{className:`mt-2 text-[13px] text-muted border-t border-border pt-2`,children:[n(`You:`),` `,(0,L.jsx)(`span`,{className:`text-fg`,children:e.answer})]})]})})}function gp(e){return e.source===`reminder`||e.source===`memory`?t(e.text):_(e.text,e.vars)}function _p({event:e,onCloudRetry:t}){let n=s(),[r,a]=(0,I.useState)(!1),[o,c]=(0,I.useState)(!1),[l,u]=(0,I.useState)(null),d=e.detail&&e.detail!==e.text?e.detail:null;return e.code===`allowance`&&e.allowance?(0,L.jsxs)(`div`,{className:`rise flex flex-col items-center gap-2 px-6`,children:[(0,L.jsx)(`div`,{className:`max-w-full rounded-2xl bg-amber-500/12 px-3 py-1.5 text-center text-[12.5px] leading-snug text-amber-700 dark:text-amber-300`,children:n(e.text)}),(0,L.jsx)(`div`,{className:`w-full max-w-[520px]`,children:(0,L.jsx)(Pf,{info:e.allowance,exhausted:!0,compact:!0})})]}):(0,L.jsx)(`div`,{className:`rise flex justify-center px-6`,children:(0,L.jsxs)(`div`,{className:i(`max-w-full rounded-2xl px-3 py-1.5 text-center text-[12.5px] leading-snug`,e.level===`info`&&`bg-surface-2/70 text-muted`,e.level===`warn`&&`bg-amber-500/12 text-amber-700 dark:text-amber-300`,e.level===`error`&&`bg-rose-500/12 text-rose-700 dark:text-rose-300`),children:[gp(e),d&&(0,L.jsxs)(L.Fragment,{children:[` `,(0,L.jsx)(`button`,{type:`button`,onClick:()=>a(e=>!e),className:`underline decoration-dotted underline-offset-2 opacity-70 hover:opacity-100`,children:n(r?`Hide details`:`Details`)}),r&&(0,L.jsx)(`div`,{className:`mt-1.5 break-all text-left font-mono text-[11px] opacity-80`,children:d})]}),t&&(0,L.jsxs)(`div`,{className:`mt-1.5 flex flex-col items-center gap-1`,children:[(0,L.jsx)(`button`,{type:`button`,disabled:o,onClick:()=>{t&&(c(!0),u(null),t().catch(e=>u(n(e.message||`Could not send`))).finally(()=>c(!1)))},className:`rounded-full bg-accent/12 px-3 py-1 text-[12px] font-medium text-accent disabled:opacity-50`,children:n(`Use nanoMuse Cloud this time`)}),l&&(0,L.jsx)(`div`,{className:`text-[12px] text-rose-700 dark:text-rose-300`,children:l})]})]})})}function vp({event:e,onOpen:t}){let[r,i]=(0,I.useState)(!1),a=s(),o=e.status===`live`,c=e.url;try{c=new URL(e.url).host}catch{}return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>t(e.id),"aria-label":a(`Browser: {title}`,{title:e.title||c}),className:`w-full max-w-[340px] overflow-hidden rounded-3xl rounded-tl-lg border border-border bg-surface shadow-sm hover:bg-surface-2 transition text-left`,children:[(0,L.jsxs)(`div`,{className:`relative aspect-[16/10] bg-surface-2`,children:[r?(0,L.jsxs)(`div`,{className:`absolute inset-0 flex flex-col items-center justify-center gap-1 text-muted`,children:[(0,L.jsx)(gt,{size:22}),(0,L.jsx)(`span`,{className:`text-[12px]`,children:a(`Frame no longer available`)})]}):(0,L.jsx)(`img`,{src:n(e.thread,e.frame),alt:e.title||c,onError:()=>i(!0),className:`absolute inset-0 h-full w-full object-cover object-top`},e.frame),(0,L.jsx)(`div`,{className:`absolute left-2.5 top-2.5 flex items-center gap-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[10.5px] font-semibold text-white`,children:o?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-rose-400 animate-pulse`}),` `,a(`LIVE`)]}):(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(gt,{size:11}),` `,a(`BROWSER`)]})})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2 px-3.5 py-2.5`,children:[(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.title||c}),(0,L.jsxs)(`div`,{className:`text-[12px] text-muted truncate`,children:[e.by_user&&e.action.startsWith(`You`)?a(`You`)+e.action.slice(3):e.action,` · `,c]})]}),(0,L.jsx)(rt,{size:16,className:`text-muted shrink-0`})]})]})})}function yp({event:e,name:t=`nanoMuse`}){let n=s(),{state:r,toast:a}=E(),[o,c]=(0,I.useState)(!1),[l,d]=(0,I.useState)(!1),f=e.status===`live`,p=e.last,m=[e.app,e.title].filter(Boolean).join(` · `),h=r.holds.find(t=>t.thread===e.thread&&t.tool===`computer`),g=async()=>{c(!0);try{await M.stopHands()}catch{}finally{c(!1)}},_=async()=>{d(!0);try{await M.openHold(e.thread,`computer`)}catch(e){a(e.message)}finally{d(!1)}};return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`div`,{className:i(`w-full max-w-[360px] overflow-hidden rounded-3xl rounded-tl-lg border bg-surface shadow-sm`,f?`border-accent/50`:`border-border/70`),children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-3.5 pb-2`,children:[(0,L.jsx)(`div`,{className:i(`mt-0.5 rounded-full p-2`,f?`bg-accent/12 text-accent`:`bg-surface-2 text-fg/80`),children:(0,L.jsx)(vt,{size:18})}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12.5px] font-medium text-muted`,children:[(0,L.jsx)(`span`,{children:f?n(`{name} is using this computer`,{name:t}):e.status===`stopped`?n(`Hands stopped`):n(`Hands done`)}),f&&(0,L.jsxs)(`span`,{className:`flex items-center gap-1 rounded-full bg-accent/12 px-2 py-0.5 text-[10.5px] font-semibold text-accent`,children:[(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 animate-pulse rounded-full bg-accent`}),` `,n(`LIVE`)]})]}),e.text&&(0,L.jsx)(`div`,{className:`mt-0.5 break-words text-[15px] font-semibold leading-snug`,children:e.text}),p?.action&&(0,L.jsxs)(`div`,{className:`mt-1.5 flex items-center gap-1.5 text-[13px] text-fg/90`,children:[(0,L.jsx)(zt,{size:13,className:`shrink-0 text-muted`}),(0,L.jsx)(`span`,{className:`truncate`,children:wp(p,n)})]}),m&&(0,L.jsx)(`div`,{className:`mt-1 truncate text-[12px] text-muted`,children:m}),e.notice&&(0,L.jsx)(`div`,{className:`mt-1.5 rounded-2xl bg-amber-500/12 px-3 py-1.5 text-[12.5px] text-amber-700 dark:text-amber-300`,children:e.notice}),f&&h&&(0,L.jsx)(bp,{hold:h})]})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2 border-t border-border/70 px-4 py-2 text-[12px] text-muted`,children:[(0,L.jsx)(`span`,{children:n(`{n} steps`,{n:e.steps})}),(0,L.jsx)(`span`,{className:`ml-auto`,children:u(e.updated_ts??e.ts)}),f&&!h&&(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void _(),disabled:l,className:`ml-1 inline-flex items-center gap-1.5 rounded-full bg-accent/12 px-3 py-1 text-[12.5px] font-semibold text-accent transition active:scale-95 disabled:opacity-60`,children:[(0,L.jsx)(vt,{size:11}),` `,n(`Take over`)]}),f&&(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void g(),disabled:o,className:`ml-1 inline-flex items-center gap-1.5 rounded-full bg-fg px-3 py-1 text-[12.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:[(0,L.jsx)(sn,{size:10,fill:`currentColor`}),` `,n(`Stop`)]})]})]})})}function bp({hold:e,compact:t}){let n=s(),[r,a]=(0,I.useState)(!1),o=async()=>{a(!0);try{await M.doneHold(e.id)}catch{}finally{a(!1)}};return(0,L.jsxs)(`div`,{className:i(`flex items-center gap-2 rounded-2xl bg-amber-500/12 px-3 text-amber-800 dark:text-amber-200`,t?`py-1.5 text-[12.5px]`:`mt-1.5 py-2 text-[13px]`),children:[(0,L.jsx)(vt,{size:14,className:`shrink-0`}),(0,L.jsx)(`span`,{className:`min-w-0 flex-1 leading-snug`,children:Sp(e,n)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void o(),disabled:r,className:`shrink-0 rounded-full bg-fg px-3 py-1 text-[12.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:r?(0,L.jsx)(A,{size:13,className:`animate-spin`}):n(`Done`)})]})}function xp(e,t){return t(e===`browser`?`the browser`:e===`phone`?`the phone`:`this computer`)}function Sp(e,t){return e.by===`agent`?e.reason?t(`Your turn: {reason}`,{reason:e.reason}):t(`Your turn`):t(`You took over {thing}`,{thing:xp(e.tool,t)})}function Cp({event:e,name:t=`nanoMuse`}){let n=s(),[r,a]=(0,I.useState)(!1),o=e.status===`on`,c=xp(e.tool,n),l=async()=>{a(!0);try{await M.doneHold(e.id)}catch{}finally{a(!1)}},d=e.timed_out?n(`{name} waited ten minutes and went on without you.`,{name:t}):e.stale?n(`Ended when the runtime restarted.`):e.by===`agent`?n(`You did it; {name} carried on.`,{name:t}):n(`Handed back; {name} carried on.`,{name:t});return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`div`,{className:i(`w-full max-w-[360px] overflow-hidden rounded-3xl rounded-tl-lg border bg-surface shadow-sm`,o?`border-amber-500/50`:`border-border/70 opacity-70`),children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3 px-4 pt-3.5 pb-3`,children:[(0,L.jsx)(`div`,{className:i(`mt-0.5 rounded-full p-2`,o?`bg-amber-500/15 text-amber-700 dark:text-amber-300`:`bg-surface-2 text-fg/70`),children:(0,L.jsx)(vt,{size:18})}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[12.5px] font-medium text-muted`,children:e.tool===`browser`?n(`The browser`):e.tool===`phone`?n(`The phone`):n(`This computer`)}),(0,L.jsx)(`div`,{className:`mt-0.5 break-words text-[15px] font-semibold leading-snug`,children:e.by===`agent`?n(`Your turn`):n(`You took over {thing}`,{thing:c})}),e.by===`agent`&&e.reason&&(0,L.jsx)(`div`,{className:`mt-1 break-words text-[13.5px] leading-snug text-fg/90`,children:e.reason}),(0,L.jsx)(`div`,{className:`mt-1.5 text-[12.5px] leading-snug text-muted`,children:o?e.by===`agent`?n(`Do this part yourself, then press Done. {name} looks again and carries on from there.`,{name:t}):n(`{name} waits. Press Done when you are finished and it looks again.`,{name:t}):d})]})]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2 border-t border-border/70 px-4 py-2 text-[12px] text-muted`,children:[(0,L.jsx)(`span`,{children:u(e.ts)}),o&&(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void l(),disabled:r,className:`ml-auto inline-flex items-center gap-1.5 rounded-full bg-fg px-3.5 py-1 text-[12.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:[r?(0,L.jsx)(A,{size:13,className:`animate-spin`}):(0,L.jsx)(ke,{size:12}),` `,n(`Done`)]})]})]})})}function wp(e,t){let n=e.label?`“${e.label}”`:``;switch(e.action){case`click`:return n?t(`Clicked {label}`,{label:n}):t(`Clicked`);case`double_click`:return n?t(`Double-clicked {label}`,{label:n}):t(`Double-clicked`);case`right_click`:case`middle_click`:return n?t(`Right-clicked {label}`,{label:n}):t(`Right-clicked`);case`move`:return n?t(`Pointed at {label}`,{label:n}):t(`Moved the mouse`);case`type`:return t(`Typed {n} characters`,{n:(e.text??``).length});case`key`:return t(`Pressed {keys}`,{keys:(e.keys??[]).join(`+`)});case`scroll`:return t(`Scrolled`);case`drag`:return t(`Dragged`);case`open_app`:return n?t(`Opened {label}`,{label:n}):t(`Opened an application`);case`wait`:return t(`Waited`);default:return e.action??``}}function Tp({event:e,onOpen:t}){let n=s(),a=r(e.name),o=n(a===`html`?`Page`:a===`image`?`Image`:a===`data`?`Data`:a===`code`?`Code`:a===`event`?`Event`:`Document`),c=e.action===`update`?`${o} · ${n(`updated`)}`:o,l=a===`html`||a===`image`;return(0,L.jsx)(`div`,{className:`rise flex justify-start pr-8`,children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>t(e.path),className:i(`max-w-full overflow-hidden rounded-[20px] border border-border bg-surface text-left shadow-[0_4px_20px_-12px_rgba(0,0,0,0.2)] transition hover:bg-surface-2`,l?`w-[300px]`:``),children:[l&&(0,L.jsx)(Ep,{path:e.path,kind:a}),(0,L.jsxs)(`div`,{className:`flex items-center gap-3 px-4 py-3`,children:[!l&&(0,L.jsx)(`div`,{className:`rounded-2xl bg-accent/12 p-2.5 text-accent`,children:a===`code`?(0,L.jsx)(ct,{size:20}):a===`event`?(0,L.jsx)(tt,{size:20}):(0,L.jsx)(mt,{size:20})}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[11px] font-semibold uppercase tracking-wide text-muted`,children:c}),(0,L.jsx)(`div`,{className:`truncate text-[14.5px] font-medium`,children:e.name}),(0,L.jsx)(`div`,{className:`truncate text-[12px] text-muted`,children:e.path})]}),(0,L.jsx)(rt,{size:16,className:`ml-1 shrink-0 text-muted`})]})]})})}function Ep({path:e,kind:t}){return t===`image`?(0,L.jsx)(`img`,{src:h(e),alt:``,loading:`lazy`,className:`h-40 w-full object-cover`}):(0,L.jsx)(`div`,{className:`pointer-events-none relative h-40 w-full overflow-hidden bg-white`,children:(0,L.jsx)(`iframe`,{title:e,src:h(e),sandbox:``,tabIndex:-1,loading:`lazy`,scrolling:`no`,className:`absolute left-0 top-0 h-[480px] w-[900px] origin-top-left scale-[0.3333] border-0`})})}function Dp({open:e,onClose:t,title:n,children:r,footer:i}){let a=s(),o=(0,I.useRef)(null),c=(0,I.useId)();return On(o,e),(0,I.useEffect)(()=>{if(!e)return;let n=e=>e.key===`Escape`&&t();return window.addEventListener(`keydown`,n),()=>window.removeEventListener(`keydown`,n)},[e,t]),e?(0,L.jsxs)(`div`,{className:`fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center`,children:[(0,L.jsx)(`div`,{className:`absolute inset-0 bg-black/45 backdrop-blur-[2px]`,onClick:t}),(0,L.jsxs)(`div`,{ref:o,role:`dialog`,"aria-modal":`true`,"aria-labelledby":n?c:void 0,className:`sheet-in relative w-full sm:max-w-lg max-h-[92dvh] flex flex-col bg-surface rounded-t-3xl sm:rounded-3xl shadow-2xl border border-border`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-3 px-5 pt-4 pb-2`,children:[(0,L.jsx)(`div`,{className:`sm:hidden absolute left-1/2 -translate-x-1/2 top-2 h-1 w-10 rounded-full bg-border`}),(0,L.jsx)(`div`,{id:c,className:`flex-1 font-semibold text-[17px] mt-1`,children:n}),(0,L.jsx)(`button`,{type:`button`,onClick:t,"aria-label":a(`Close`),className:`p-2 -mr-2 rounded-full text-muted hover:bg-surface-2`,children:(0,L.jsx)(Fe,{size:20})})]}),(0,L.jsx)(`div`,{className:`flex-1 overflow-y-auto px-5 pb-4`,children:r}),i&&(0,L.jsx)(`div`,{className:`px-5 pt-2 pb-4 safe-bottom border-t border-border`,children:i})]})]}):null}var Op=1e3;function kp({thread:e,eventId:t,onClose:r}){let{state:a,toast:o}=E(),c=s(),l=(a.events[e]??[]).find(e=>e.id===t),u=a.holds.find(t=>t.thread===e&&t.tool===`browser`),[d,f]=(0,I.useState)(null),[p,m]=(0,I.useState)(null),[h,g]=(0,I.useState)(``),[_,v]=(0,I.useState)(``),[y,b]=(0,I.useState)(!1),x=(0,I.useRef)(null),S=d?d===`on`:!!u;(0,I.useEffect)(()=>b(!1),[l?.frame]),(0,I.useEffect)(()=>f(null),[u?.id,u?.status]);let C=l?.backend===`device`&&ip();(0,I.useEffect)(()=>{if(!C)return;let t=t=>{let n=t.detail;n?.thread&&n.thread!==e||M.browserControl(e,{action:`handed_back`}).catch(e=>o(e.message))};return window.addEventListener(`nanomuse:browser-handed-back`,t),()=>window.removeEventListener(`nanomuse:browser-handed-back`,t)},[C,e,o]);let w=(0,I.useCallback)(async t=>{m(t.action);try{await M.browserControl(e,t)}catch(e){o(e.message)}finally{m(null)}},[e,o]),T=()=>{if(C){rp()?.takeOverBrowser?.(e),M.browserControl(e,{action:`take_over`}).catch(()=>void 0);return}f(`on`),w({action:`take_over`})},D=()=>{f(`off`),w({action:`handed_back`})};(0,I.useEffect)(()=>{if(!S||C||!l||l.status!==`live`)return;let t=window.setInterval(()=>{document.hidden||M.browserControl(e,{action:`look`}).catch(()=>void 0)},Op);return()=>window.clearInterval(t)},[S,C,e,l]);let O=e=>{if(!S||p)return;let t=e.currentTarget.getBoundingClientRect(),n=(e.clientX-t.left)/t.width,r=(e.clientY-t.top)/t.height;w({action:`click`,x:Math.min(Math.max(n,0),1),y:Math.min(Math.max(r,0),1)})},k=e=>{!S||p||Math.abs(e.deltaY)<8||w({action:`scroll`,dy:Math.max(-2e3,Math.min(2e3,Math.round(e.deltaY*2)))})};if(!l)return null;let ee=l.url;try{ee=new URL(l.url).host}catch{}let te=l.status===`live`,j=a.profile?.name??`nanoMuse`,ne=u?c(`{name} is waiting for you`,{name:j}):a.status.state!==`idle`&&a.status.thread===e?c(`{name} is working`,{name:j}):l.action;return(0,L.jsxs)(Dp,{open:!0,onClose:r,title:(0,L.jsxs)(`div`,{className:`flex items-center gap-2 min-w-0`,children:[(0,L.jsx)(gt,{size:17,className:`text-accent shrink-0`}),(0,L.jsx)(`span`,{className:`truncate`,children:l.title||ee}),te&&(0,L.jsxs)(`span`,{className:`ml-1 flex items-center gap-1 rounded-full bg-rose-500/12 px-2 py-0.5 text-[10.5px] font-semibold text-rose-500`,children:[(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-rose-500 animate-pulse`}),` `,c(`LIVE`)]})]}),footer:S?(0,L.jsxs)(`div`,{className:`space-y-2`,children:[(0,L.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),h&&w({action:`type`,text:h}).then(()=>g(``))},children:[(0,L.jsx)(`input`,{value:h,onChange:e=>g(e.target.value),placeholder:c(`Type into the focused field…`),"aria-label":c(`Type into the focused field…`),className:`flex-1 rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14px] outline-none`,autoComplete:`off`,autoCapitalize:`off`}),(0,L.jsx)(`button`,{type:`submit`,disabled:!h||!!p,"aria-label":c(`Type`),className:`rounded-full bg-accent p-2.5 text-white disabled:opacity-40`,children:p===`type`?(0,L.jsx)(A,{size:16,className:`animate-spin`}):(0,L.jsx)(qe,{size:16})}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`key`,key:`Enter`}),disabled:!!p,"aria-label":c(`Press Enter`),className:`rounded-full bg-surface-2 p-2.5 text-fg disabled:opacity-40`,children:(0,L.jsx)(dt,{size:16})})]}),(0,L.jsxs)(`form`,{className:`flex items-center gap-2`,onSubmit:e=>{e.preventDefault(),_&&w({action:`navigate`,url:_}).then(()=>v(``))},children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`back`}),disabled:!!p,"aria-label":c(`Back`),className:`rounded-full bg-surface-2 p-2 text-fg disabled:opacity-40`,children:(0,L.jsx)(Ke,{size:15})}),(0,L.jsx)(`input`,{value:_,onChange:e=>v(e.target.value),placeholder:c(`Open a URL…`),"aria-label":c(`Open a URL…`),inputMode:`url`,autoCapitalize:`off`,className:`flex-1 min-w-0 rounded-2xl bg-surface-2 px-3.5 py-2 text-[13px] outline-none`}),(0,L.jsx)(`button`,{type:`submit`,disabled:!_||!!p,className:`rounded-2xl bg-surface-2 px-3 py-2 text-[13px] font-medium disabled:opacity-40`,children:c(`Go`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`scroll`,dy:-600}),disabled:!!p,"aria-label":c(`Scroll up`),className:`rounded-full bg-surface-2 p-2 text-fg disabled:opacity-40`,children:(0,L.jsx)(Je,{size:15})}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`scroll`,dy:600}),disabled:!!p,"aria-label":c(`Scroll down`),className:`rounded-full bg-surface-2 p-2 text-fg disabled:opacity-40`,children:(0,L.jsx)(Ge,{size:15})})]}),(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,L.jsx)(`span`,{className:`min-w-0 truncate text-[12px] text-muted`,children:c(`Tap the page to click. If the browser was closed, open a URL first.`)}),(0,L.jsxs)(`button`,{type:`button`,onClick:D,disabled:p===`handed_back`,className:`flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full bg-fg px-3.5 py-1.5 text-[13px] font-semibold text-bg disabled:opacity-60`,children:[p===`handed_back`?(0,L.jsx)(A,{size:14,className:`animate-spin`}):(0,L.jsx)(ke,{size:14}),` `,c(`Done`)]})]})]}):(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-2`,children:[(0,L.jsxs)(`div`,{className:`min-w-0 text-[12.5px] text-muted truncate`,children:[ne,` · `,ee]}),(0,L.jsxs)(`div`,{className:`flex items-center gap-2 shrink-0`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`look`}),disabled:!!p,"aria-label":c(`Refresh`),className:`rounded-full bg-surface-2 p-2 text-muted disabled:opacity-40`,children:p===`look`?(0,L.jsx)(A,{size:15,className:`animate-spin`}):(0,L.jsx)(Re,{size:15})}),(0,L.jsxs)(`button`,{type:`button`,onClick:T,disabled:d===`on`,className:`flex items-center gap-1.5 rounded-full bg-accent px-3.5 py-2 text-[13px] font-semibold text-white disabled:opacity-60`,children:[(0,L.jsx)(vt,{size:14}),` `,c(`Take over`)]})]})]}),children:[u&&u.by===`agent`&&(0,L.jsx)(`div`,{className:`mb-2`,children:(0,L.jsx)(bp,{hold:u,compact:!0})}),(0,L.jsxs)(`div`,{className:i(`relative overflow-hidden rounded-2xl border border-border bg-surface-2`,S&&`ring-2 ring-accent`),children:[y?(0,L.jsxs)(`div`,{className:`flex aspect-[16/10] flex-col items-center justify-center gap-1 text-muted`,children:[(0,L.jsx)(gt,{size:24}),(0,L.jsx)(`span`,{className:`text-[12.5px]`,children:c(`This frame is no longer available.`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void w({action:`look`}),className:`text-accent text-[13px] font-medium`,children:c(`Look again`)})]}):(0,L.jsx)(`img`,{ref:x,src:n(e,l.frame),alt:l.title||ee,onError:()=>b(!0),onClick:O,onWheel:k,draggable:!1,className:i(`block w-full select-none`,S?`cursor-crosshair`:``)},l.frame),p&&p!==`look`&&(0,L.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center bg-black/20`,children:(0,L.jsx)(A,{size:26,className:`animate-spin text-white`})})]}),(0,L.jsxs)(`div`,{className:`mt-2 flex items-center justify-between text-[12px] text-muted`,children:[(0,L.jsx)(`span`,{className:`truncate`,children:l.url}),(0,L.jsx)(`span`,{className:`shrink-0 ml-2`,children:l.frames===1?c(`1 frame`):c(`{n} frames`,{n:l.frames})})]}),S&&(0,L.jsxs)(`div`,{className:`mt-3 rounded-2xl bg-accent/8 px-3.5 py-2.5 text-[13px] leading-snug`,children:[(0,L.jsx)(`div`,{className:`font-medium`,children:u&&u.by===`agent`?Sp(u,c):c(`You have the page. Press Done when you are finished.`)}),(0,L.jsx)(`div`,{className:`mt-1 text-muted`,children:c(`{name} waits meanwhile, then looks at the page as you left it and carries on. Passwords you type here go to the website, never to the model.`,{name:j})})]})]})}function Ap(){if(typeof window>`u`)return null;let e=window;return e.SpeechRecognition??e.webkitSpeechRecognition??null}function jp(){return Ap()!==null}function Mp(e,t){let[n,r]=(0,I.useState)(!1),i=(0,I.useRef)(null),a=(0,I.useRef)(``),o=s(),c=(0,I.useCallback)(()=>{i.current?.stop()},[]),l=(0,I.useCallback)(()=>{let n=Ap();if(!n)return;let s=new n;s.lang=navigator.language||`en-US`,s.continuous=!0,s.interimResults=!0,a.current=``,s.onresult=t=>{let n=``;for(let e=t.resultIndex;e{let n=e.error||``;n!==`aborted`&&n!==`no-speech`&&t?.(n===`not-allowed`||n===`service-not-allowed`?o(`The microphone is not allowed. Allow it in the browser and try again.`):o(`Voice input stopped ({code}).`,{code:n}))},s.onend=()=>{i.current=null,r(!1)},i.current=s;try{s.start(),r(!0)}catch{i.current=null,r(!1)}},[e,t,o]),u=(0,I.useCallback)(()=>{i.current?c():l()},[l,c]);return(0,I.useEffect)(()=>()=>i.current?.abort(),[]),{supported:jp(),listening:n,toggle:u,stop:c}}function Np({listening:e,onClick:t,className:n}){let r=s();return(0,L.jsx)(`button`,{type:`button`,onClick:t,"aria-label":r(e?`Stop voice input`:`Voice input`),"aria-pressed":e,className:i(`flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition active:scale-95`,e?`bg-accent/15 text-accent animate-pulse`:`text-fg/70 hover:bg-surface-2`,n),children:e?(0,L.jsx)(Pt,{size:19}):(0,L.jsx)(Ft,{size:19})})}function Pp({lines:e}){return(0,L.jsx)(`div`,{className:`flex items-end gap-2 pr-10`,children:(0,L.jsx)(`div`,{className:`min-w-0 max-w-full`,children:e.map((e,t)=>(0,L.jsx)(`div`,{className:`my-1 max-w-[340px] rounded-[20px] bg-surface-2 px-3.5 py-2.5 text-[14.5px] leading-snug whitespace-pre-wrap break-words wide:max-w-[600px]`,children:e},t))})})}function Fp({chips:e,chosen:t,busy:n,onPick:r,onElse:a}){let o=s();return e.length===0&&!t?null:(0,L.jsxs)(`section`,{className:`rise mx-1 my-2 rounded-[22px] border border-border/70 bg-surface p-3.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[13px] font-medium text-fg`,children:[(0,L.jsx)(nn,{size:14,className:`text-accent`}),` `,o(`Pick a name for me, or type one`)]}),(0,L.jsxs)(`div`,{className:`mt-2.5 flex flex-wrap gap-2`,children:[e.map(e=>{let a=t===e;return(0,L.jsxs)(`button`,{type:`button`,disabled:n||!!t,onClick:()=>r(e),className:i(`inline-flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-[13.5px] font-medium transition-colors disabled:opacity-60`,a?`bg-accent text-accent-fg`:`bg-surface-2 hover:bg-border/60`),children:[a&&(0,L.jsx)(ke,{size:13,strokeWidth:3}),e]},e)}),!t&&(0,L.jsx)(`button`,{type:`button`,disabled:n,onClick:a,className:`rounded-full border border-border px-3.5 py-1.5 text-[13.5px] text-muted hover:bg-surface-2 disabled:opacity-60`,children:o(`Something else`)})]})]})}var Ip=[{id:`pink`,background:`#F9D9E3`,accent:`#B5476A`,mood:`idle`},{id:`blue`,background:`#D6E6FA`,accent:`#2F5FA8`,mood:`working`},{id:`yellow`,background:`#FBEFC7`,accent:`#9A6B12`,mood:`waiting`},{id:`purple`,background:`#E4DDF7`,accent:`#5D44A6`,mood:`happy`},{id:`green`,background:`#D9F0DF`,accent:`#2E7D4F`,mood:`error`}],Lp=`github.com/zeeshanhaque21/nanoMuse`,Rp=540,zp=720;function Bp(e,t){return!e||w(e.avatar)?j(t):e.avatar?re(e.avatar,t):null}function Vp(e){return new Promise(t=>{let n=new Image;n.crossOrigin=`anonymous`,n.onload=()=>t(n),n.onerror=()=>t(null),n.src=e})}function Hp(e,t,n){let r=t.includes(` `)?t.split(` `):Array.from(t),i=t.includes(` `)?` `:``,a=[],o=``;for(let t of r){let r=o?o+i+t:t;e.measureText(r).width>n&&o?(a.push(o),o=t):o=r}return o&&a.push(o),a}function Up(e,t,n,r,i,a){e.beginPath(),e.moveTo(t+a,n),e.arcTo(t+r,n,t+r,n+i,a),e.arcTo(t+r,n+i,t,n+i,a),e.arcTo(t,n+i,t,n,a),e.arcTo(t,n,t+r,n,a),e.closePath()}async function Wp(e,t,n,r,i,a=2){let o=document.createElement(`canvas`);o.width=Rp*a,o.height=zp*a;let s=o.getContext(`2d`);if(!s)return o;s.scale(a,a),s.fillStyle=e.background,Up(s,0,0,Rp,zp,36),s.fill(),s.font=`600 22px system-ui, -apple-system, 'Segoe UI', sans-serif`;let c=Hp(s,r,400),l=Math.max(...c.map(e=>s.measureText(e).width))+48,u=c.length*30+36,d=(Rp-l)/2;s.fillStyle=`#ffffff`,Up(s,d,56,l,u,24),s.fill(),s.beginPath(),s.moveTo(Rp/2-14,56+u-1),s.lineTo(Rp/2,56+u+18),s.lineTo(284,56+u-1),s.closePath(),s.fill(),s.fillStyle=e.accent,s.textAlign=`center`,s.textBaseline=`top`,c.forEach((e,t)=>s.fillText(e,Rp/2,74+t*30+3));let f=56+u+54;s.fillStyle=`#ffffff`,s.beginPath(),s.arc(Rp/2,f+150,158,0,Math.PI*2),s.fill();let p=Bp(t,e.mood),m=p?await Vp(p):null;return s.save(),s.beginPath(),s.arc(Rp/2,f+150,150,0,Math.PI*2),s.clip(),m?(s.fillStyle=`#f1efeb`,s.fillRect(120,f,300,300),s.drawImage(m,120,f,300,300)):(s.fillStyle=t?.color??`#0064d4`,s.fillRect(120,f,300,300),s.font=`150px system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif`,s.textAlign=`center`,s.textBaseline=`middle`,s.fillStyle=`#ffffff`,s.fillText(t?.emoji??`✨`,Rp/2,f+150+6)),s.restore(),s.fillStyle=e.accent,s.textAlign=`center`,s.textBaseline=`top`,s.font=`700 30px system-ui, -apple-system, 'Segoe UI', sans-serif`,s.fillText(n,Rp/2,f+300+34),s.font=`500 15px system-ui, -apple-system, 'Segoe UI', sans-serif`,s.globalAlpha=.8,Hp(s,i,460).forEach((e,t)=>s.fillText(e,Rp/2,644+t*22)),s.globalAlpha=1,o}function Gp(e){return new Promise(t=>e.toBlob(t,`image/png`))}function Kp({open:e,onClose:t}){let{state:n,toast:r}=E(),a=s(),o=n.profile,c=o?.name??`nanoMuse`,[l,u]=(0,I.useState)(0),[d,f]=(0,I.useState)([]),[p,m]=(0,I.useState)(!1),h=(0,I.useRef)(!0),g=a(`Hi, I'm {name}, a personal AI agent. Meet nanoMuse: open source, runs on your phone.`,{name:c}),_=a(`Your personal AI agent, open source · {repo}`,{repo:Lp});(0,I.useEffect)(()=>{if(h.current=!0,e)return u(0),(async()=>{let e=[];for(let t of Ip){let n=await Wp(t,o,c,g,_,1);e.push(n.toDataURL(`image/png`))}h.current&&f(e)})(),()=>{h.current=!1}},[e,o?.avatar,o?.emoji,o?.color,c]);let v=async()=>{m(!0);try{let e=await Gp(await Wp(Ip[l]??Ip[0],o,c,g,_,2));if(!e)throw Error(a(`The card could not be drawn.`));let n=new File([e],`${c}-nanomuse.png`,{type:`image/png`}),i=`${g}\nhttps://${Lp}`,s=navigator;if(typeof s.share==`function`&&(!s.canShare||s.canShare({files:[n]})))try{await s.share({files:[n],text:i,title:c}),t();return}catch(e){if(e.name===`AbortError`)return}let u=document.createElement(`a`);u.href=URL.createObjectURL(e),u.download=n.name,u.click(),window.setTimeout(()=>URL.revokeObjectURL(u.href),2e3),r(a(`Saved the card as a picture.`))}catch(e){r(e.message)}finally{m(!1)}};return(0,L.jsxs)(Dp,{open:e,onClose:t,title:a(`Share my avatar`),children:[(0,L.jsx)(`p`,{className:`mb-3 text-[13px] text-muted`,children:a(`Pick a card. It goes out through the system share sheet, or saves as a picture where there is none.`)}),(0,L.jsx)(`div`,{className:`grid grid-cols-3 gap-2.5 sm:grid-cols-5`,children:Ip.map((e,t)=>(0,L.jsx)(`button`,{type:`button`,"aria-label":e.id,"aria-pressed":l===t,onClick:()=>u(t),className:i(`overflow-hidden rounded-2xl border-2 transition`,l===t?`border-accent`:`border-transparent`),style:{background:e.background,aspectRatio:`${Rp} / ${zp}`},children:d[t]?(0,L.jsx)(`img`,{src:d[t],alt:``,className:`h-full w-full object-cover`}):(0,L.jsx)(A,{size:18,className:`mx-auto my-8 animate-spin text-muted`})},e.id))}),d[l]&&(0,L.jsx)(`img`,{src:d[l],alt:``,className:`mx-auto mt-4 w-[260px] rounded-[20px] shadow-md`}),(0,L.jsxs)(`div`,{className:`mt-4 flex justify-end gap-2`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:t,className:je,children:a(`Later`)}),(0,L.jsxs)(`button`,{type:`button`,disabled:p||!d.length,onClick:()=>void v(),className:Ne,children:[p?(0,L.jsx)(A,{size:15,className:`animate-spin`}):typeof navigator.share==`function`?(0,L.jsx)(Xt,{size:15}):(0,L.jsx)(ft,{size:15}),` `,typeof navigator.share==`function`?a(`Share`):a(`Save picture`)]})]})]})}function qp({message:e,onRetry:t}){let n=s();return(0,L.jsxs)(`div`,{className:`flex flex-col items-center gap-3 px-8 py-10 text-center`,children:[(0,L.jsx)(`p`,{className:`text-[14px] text-rose-600 dark:text-rose-300`,children:n(e)||n(`Could not load.`)}),(0,L.jsxs)(`button`,{type:`button`,onClick:t,className:`flex h-10 items-center gap-1.5 rounded-full bg-surface px-4 text-[13.5px] font-medium shadow-[0_1px_4px_rgba(0,0,0,0.14)] dark:border dark:border-border dark:shadow-none`,children:[(0,L.jsx)(Re,{size:14}),` `,n(`Try again`)]})]})}var Jp=[{id:`health`,label:`Health`,icon:e=>(0,L.jsx)(yt,{...e}),tone:`bg-rose-500/12 text-rose-600 dark:text-rose-300`},{id:`finance`,label:`Finance`,icon:e=>(0,L.jsx)(Wt,{...e}),tone:`bg-emerald-500/12 text-emerald-600 dark:text-emerald-300`},{id:`career`,label:`Career`,icon:e=>(0,L.jsx)($e,{...e}),tone:`bg-sky-500/12 text-sky-600 dark:text-sky-300`},{id:`learning`,label:`Learning`,icon:e=>(0,L.jsx)(_t,{...e}),tone:`bg-violet-500/12 text-violet-600 dark:text-violet-300`},{id:`relationships`,label:`Relationships`,icon:e=>(0,L.jsx)(se,{...e}),tone:`bg-pink-500/12 text-pink-600 dark:text-pink-300`},{id:`family`,label:`Family`,icon:e=>(0,L.jsx)(hn,{...e}),tone:`bg-amber-500/12 text-amber-600 dark:text-amber-300`},{id:`home`,label:`Home`,icon:e=>(0,L.jsx)(bt,{...e}),tone:`bg-orange-500/12 text-orange-600 dark:text-orange-300`},{id:`travel`,label:`Travel`,icon:e=>(0,L.jsx)(lt,{...e}),tone:`bg-cyan-500/12 text-cyan-600 dark:text-cyan-300`},{id:`creative`,label:`Creative`,icon:e=>(0,L.jsx)(Vt,{...e}),tone:`bg-fuchsia-500/12 text-fuchsia-600 dark:text-fuchsia-300`},{id:`other`,label:`Other`,icon:e=>(0,L.jsx)(ln,{...e}),tone:`bg-surface-2 text-muted`}];function Yp(e){return Jp.find(t=>t.id===e)??null}var Xp=[{id:``,label:`No reminders`},{id:`daily`,label:`Every day`},{id:`weekdays`,label:`Weekdays`},{id:`weekly`,label:`Once a week`},{id:`monthly`,label:`Once a month`}],Zp=[`mon`,`tue`,`wed`,`thu`,`fri`,`sat`,`sun`];function Qp(e){let t=/^(daily|weekdays|weekly|monthly)(?:\s+(\S+?))?(?:\s+(\d{1,2}:\d{2}))?$/.exec(e.trim());if(!t)return{cadence:``,anchor:``,time:`09:00`};let n=t[2]??``,r=t[3]??`09:00`;return n&&/^\d{1,2}:\d{2}$/.test(n)&&(r=n,n=``),{cadence:t[1],anchor:n,time:r.padStart(5,`0`)}}function $p(e,t,n){return e?[e,t,n].filter(Boolean).join(` `):``}function em(e){let t=Zp.indexOf(e);return t<0?e:new Date(2024,0,1+t).toLocaleDateString(m(),{weekday:`long`})}function tm(e){let{cadence:t,anchor:n,time:r}=Qp(e);switch(t){case`daily`:return _(`Daily at {time}`,{time:r});case`weekdays`:return _(`Weekdays at {time}`,{time:r});case`weekly`:return n&&Zp.includes(n)?_(`{day}s at {time}`,{day:em(n),time:r}):_(`Weekly at {time}`,{time:r});case`monthly`:return _(`Monthly on the {day} at {time}`,{day:nm(Number(n||1)),time:r});default:return``}}function nm(e){if(c()!==`en`)return String(e);let t=[`th`,`st`,`nd`,`rd`],n=e%100;return`${e}${t[(n-20)%10]??t[n]??t[0]}`}function rm(e,t){if(!e)return``;let n=new Date(`${e}T00:00:00`),r=n.toLocaleDateString(m(),{month:`short`,day:`numeric`,year:n.getFullYear()===new Date().getFullYear()?void 0:`numeric`});if(t)return _(`Was due {date}`,{date:r});let i=Math.ceil((n.getTime()-Date.now())/864e5);return i<=0?_(`Due today`):i===1?_(`Due tomorrow`):i<=14?_(`Due in {n} days`,{n:i}):_(`By {date}`,{date:r})}function im({open:e,onClose:t}){let{state:n,setTab:r,draft:a,toast:o}=E(),[c,l]=(0,I.useState)(0),[u,d]=(0,I.useState)(!1),[f,p]=(0,I.useState)(!1),[m,h]=(0,I.useState)(!1),g=n.profile?.name??`nanoMuse`,_=n.pendingApprovals.length,v=s();(0,I.useEffect)(()=>{e&&(l(+(_>0)),d(!1),p(!1))},[e]);let y=(e,n)=>{t(),n&&history.replaceState(null,``,`${window.location.pathname}${window.location.search}#${n}`),r(e)},b=n.status.state===`working`,x=_>0?_>1?v(`{n} approvals waiting for you`,{n:_}):v(`1 approval waiting for you`):b?n.status.detail||v(`On it`):n.status.state===`waiting`?v(`Waiting for you`):v(`online`),S=async()=>{h(!0);try{(await M.stopThread(n.status.thread||`main`)).ok||o(v(`Nothing to stop`))}catch(e){o(e.message)}finally{h(!1)}},C=[{icon:(0,L.jsx)(it,{size:22}),label:v(`Activity`)},{icon:(0,L.jsx)(Qt,{size:22}),label:v(`Approvals`),badge:_},{icon:(0,L.jsx)(at,{size:22}),label:v(`Daily`)},{icon:(0,L.jsx)(ht,{size:22}),label:v(`Soul & memory`)}];return(0,L.jsxs)(Dp,{open:e,onClose:t,children:[(0,L.jsxs)(`div`,{className:`relative flex flex-col items-center px-1 pb-3 text-center`,children:[(0,L.jsx)(`button`,{type:`button`,"aria-label":v(`Share avatar`),title:v(`Share avatar`),onClick:()=>p(!0),className:`absolute right-0 top-0 flex h-9 w-9 items-center justify-center rounded-full bg-surface-2/70 text-fg hover:bg-surface-2`,children:(0,L.jsx)(Xt,{size:16})}),(0,L.jsxs)(`div`,{className:`relative`,children:[(0,L.jsx)(D,{profile:n.profile,status:n.status,size:72}),(0,L.jsx)(`button`,{type:`button`,"aria-label":v(`Edit`),"aria-expanded":u,onClick:()=>d(e=>!e),className:`absolute -bottom-0.5 -right-0.5 flex h-6 w-6 items-center justify-center rounded-full bg-surface text-fg shadow-[0_1px_4px_rgba(0,0,0,0.18)] hover:bg-surface-2 dark:border dark:border-border`,children:(0,L.jsx)(Ht,{size:13})}),u&&(0,L.jsxs)(`div`,{className:`absolute left-1/2 top-full z-20 mt-2 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-border/70 bg-surface py-1 text-left text-[14px] shadow-lg`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>{a(v(`Change your avatar to `)),t(),r(`chat`)},className:`block w-full px-4 py-2.5 text-left hover:bg-surface-2`,children:v(`Change avatar`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>y(`you`,`who`),className:`block w-full px-4 py-2.5 text-left hover:bg-surface-2`,children:v(`Edit name`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>y(`avatar`),className:`block w-full px-4 py-2.5 text-left text-muted hover:bg-surface-2`,children:v(`Avatar studio…`)})]})]}),(0,L.jsx)(`div`,{className:`mt-2.5 text-[21px] font-semibold leading-[26px]`,children:g}),(0,L.jsxs)(`div`,{className:i(`mt-0.5 flex items-center gap-1.5 text-[14px]`,b||_>0?`text-fg`:`text-muted`),children:[b&&(0,L.jsx)(A,{size:13,className:`animate-spin text-accent`}),(0,L.jsx)(`span`,{className:`line-clamp-2`,children:x})]}),(b||n.status.state===`waiting`)&&(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void S(),disabled:m,className:`mt-2.5 inline-flex items-center gap-1.5 rounded-full bg-fg px-4 py-1.5 text-[13.5px] font-semibold text-bg transition active:scale-95 disabled:opacity-60`,children:[(0,L.jsx)(sn,{size:12,fill:`currentColor`}),` `,v(`Stop`)]})]}),(0,L.jsx)(`div`,{role:`tablist`,className:`mb-5 flex rounded-2xl bg-surface-2/60 p-1`,children:C.map((e,t)=>(0,L.jsxs)(`button`,{type:`button`,role:`tab`,"aria-selected":c===t,"aria-label":e.label,title:e.label,onClick:()=>l(t),className:i(`relative flex h-11 flex-1 items-center justify-center rounded-[13px] text-fg transition`,c===t&&`bg-surface shadow-sm`),children:[e.icon,e.badge?(0,L.jsx)(`span`,{className:`absolute right-1.5 top-1 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white`,children:e.badge}):null]},e.label))}),c===0&&(0,L.jsx)(vm,{open:e}),c===1&&(0,L.jsxs)(L.Fragment,{children:[_>0&&(0,L.jsx)(sm,{onDone:()=>void 0}),(0,L.jsx)(ym,{open:e})]}),c===2&&(0,L.jsx)(cm,{onSettings:()=>y(`you`,`proactivity`)}),c===3&&(0,L.jsx)(am,{name:g,onEdit:()=>y(`you`,`who`),onMemory:()=>y(`memory`)}),(0,L.jsx)(Kp,{open:f,onClose:()=>p(!1)})]})}function am({name:e,onEdit:t,onMemory:n}){let{state:r}=E(),i=s(),a=r.profile?.tagline?.trim()??``;return(0,L.jsxs)(`div`,{className:`pb-2`,children:[(0,L.jsx)(`div`,{className:`px-1 text-[15px]`,children:e}),(0,L.jsxs)(`button`,{type:`button`,onClick:t,className:`mt-2 flex h-[50px] w-full items-center justify-center gap-2 rounded-2xl bg-surface-2/70 text-[15px] font-medium hover:bg-surface-2`,children:[(0,L.jsx)(Ht,{size:18}),` `,i(`Edit`)]}),(0,L.jsxs)(`div`,{className:`mt-4 grid grid-cols-2 gap-3`,children:[(0,L.jsx)(om,{title:`SOUL`,caption:i(`Handle with care`),foot:a,colors:`from-[#7d6bc7] to-[#574494]`,onClick:t}),(0,L.jsx)(om,{title:i(`Memory`),caption:i(`Handle with care`),foot:r.settings?.memory_enabled===!1?i(`Off`):``,colors:`from-[#b0413e] to-[#7a1f1f]`,onClick:n})]})]})}function om({title:e,caption:t,foot:n,colors:r,onClick:a}){return(0,L.jsxs)(`button`,{type:`button`,onClick:a,className:i(`flex h-[130px] flex-col items-start rounded-[18px] bg-gradient-to-b p-3.5 text-left text-white transition active:scale-[0.98]`,r),children:[(0,L.jsx)(`span`,{className:`text-[17px] font-semibold`,children:e}),(0,L.jsx)(`span`,{className:`mt-0.5 text-[11px] text-white/85`,children:t}),(0,L.jsx)(`span`,{className:`mt-auto text-[12px] text-white/90`,children:n})]})}function sm({onDone:e}){let{state:t,decide:n,openThread:r,toast:i}=E(),a=t.profile?.name??`nanoMuse`,o=s(),c=[...t.pendingApprovals].sort((e,t)=>e.ts.localeCompare(t.ts)),l=e=>{let n=t.threads.find(t=>t.id===e);return n?n.id===`main`?o(n.title):n.title:e};return(0,I.useEffect)(()=>{if(c.length===0){let t=window.setTimeout(e,600);return()=>window.clearTimeout(t)}},[c.length,e]),c.length===0?(0,L.jsxs)(`div`,{className:`py-10 text-center text-muted text-[14px]`,children:[(0,L.jsx)(ke,{size:26,className:`mx-auto mb-2 text-emerald-500`}),o(`Nothing is waiting for you.`)]}):(0,L.jsxs)(`div`,{className:`space-y-4 pb-2`,children:[(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted`,children:o(`Actions {name} wants to take but cannot without you. Each one shows what will run and why; a permission you grant is bound to that exact tool and target.`,{name:a})}),c.map(e=>(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>r(e.thread),className:`mb-1 px-1 text-[12px] font-semibold uppercase tracking-wide text-muted hover:text-accent`,children:[l(e.thread),` · `,u(e.ts)]}),(0,L.jsx)(`div`,{className:`rounded-[22px] border border-border/70`,children:(0,L.jsx)(mp,{event:e,onDecide:(t,r)=>n(e.id,t,r).catch(e=>i(e.message))})})]},e.id))]})}function cm({onSettings:t}){let{state:n,refreshSettings:r,toast:a}=E(),[o,c]=(0,I.useState)(null),[l,d]=(0,I.useState)(null),[f,p]=(0,I.useState)(null),m=n.profile?.name??`nanoMuse`,h=s(),g=()=>M.upcoming().then(e=>{c(e),d(null)}).catch(e=>o===null?d(e.message):a(e.message));(0,I.useEffect)(()=>{g()},[n.goalsVersion,n.remindersVersion,n.profile?.proactive,n.profile?.goal_interval_minutes]);let _=async()=>{try{await M.updateSettings({profile:{proactive:!n.profile?.proactive}}),await r()}catch(e){a(e.message)}},v=async e=>{p(e);try{await M.advanceGoal(e),a(h(`{name} is working on it in the main chat`,{name:m}))}catch(e){a(e.message)}finally{p(null)}};return o?(0,L.jsxs)(`div`,{className:`space-y-4 pb-2`,children:[(0,L.jsxs)(`div`,{className:`rounded-2xl border border-border p-3.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between gap-3`,children:[(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`font-medium`,children:h(`Background work`)}),(0,L.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:o.proactive?`${h(o.proactivity[0].toUpperCase()+o.proactivity.slice(1))} · ${h(`every {n} min {name} picks one active goal and works on its next step.`,{n:o.effective_interval_minutes,name:m})}${o.quiet_until?` ${h(`Quiet hours until {time}.`,{time:u(o.quiet_until)})}`:o.next_pass_at?` ${h(`Next around {time}.`,{time:u(o.next_pass_at)})}`:``}`:h(`Off. {name} only works when you ask.`,{name:m})})]}),(0,L.jsx)(`button`,{type:`button`,role:`switch`,"aria-checked":o.proactive,onClick:()=>void _(),className:i(`relative h-7 w-12 shrink-0 rounded-full transition`,o.proactive?`bg-accent`:`bg-border`),children:(0,L.jsx)(`span`,{className:i(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,o.proactive?`left-[26px]`:`left-0.5`)})})]}),(0,L.jsx)(`button`,{type:`button`,onClick:t,className:`mt-2 text-[12.5px] text-accent font-medium`,children:h(`Level, interval and quiet hours in Settings`)})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:h(`In line`)}),o.queue.length===0?(0,L.jsx)(`div`,{className:`text-[13px] text-muted`,children:h(`No active goal has a next step. Add one in Goals and {name} will pick it up.`,{name:m})}):(0,L.jsx)(`ul`,{className:`space-y-1.5`,children:o.queue.map((e,t)=>(0,L.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,L.jsx)(`span`,{className:`w-5 text-center text-[12px] font-semibold text-muted`,children:t+1}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:e.title}),(0,L.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[e.next_step??h(`No next step`),` · `,h(`{done}/{total} done`,{done:e.progress.done,total:e.progress.total})]})]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void v(e.goal_id),disabled:f!==null||o.busy,"aria-label":h(`Work on {title} now`,{title:e.title}),className:`rounded-full bg-accent/12 p-2 text-accent disabled:opacity-50`,children:f===e.goal_id?(0,L.jsx)(A,{size:15,className:`animate-spin`}):(0,L.jsx)(Gt,{size:15})})]},e.goal_id))})]}),o.check_ins.length>0&&(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:h(`Check-ins`)}),(0,L.jsx)(`ul`,{className:`space-y-1.5`,children:o.check_ins.map(t=>(0,L.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,L.jsx)(Xe,{size:15,className:`text-muted shrink-0`}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:t.title}),(0,L.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[e(t.at),` · `,u(t.at),` · `,tm(t.cadence).toLowerCase()]})]})]},t.goal_id))}),(0,L.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:h(`Goal nudges you asked for; they arrive whatever the proactivity level, but wait out quiet hours.`)})]}),(0,L.jsx)(lm,{items:o.reminders,name:m,onChange:()=>void g()}),(0,L.jsx)(pm,{data:o.triggers,name:m,onChange:()=>void g()})]}):l?(0,L.jsx)(qp,{message:l,onRetry:()=>void g()}):(0,L.jsx)(`div`,{className:`py-10 flex justify-center text-muted`,children:(0,L.jsx)(A,{className:`animate-spin`,size:20})})}function lm({items:t,name:n,onChange:r}){let{toast:i}=E(),a=s(),[o,c]=(0,I.useState)(null),[l,d]=(0,I.useState)(!1),f=t.filter(e=>e.status===`active`),p=t.filter(e=>e.status!==`active`),m=async e=>{c(e.id);try{await M.cancelReminder(e.id),r()}catch(e){i(e.message)}finally{c(null)}},h=async e=>{c(e.id);try{await M.fireReminder(e.id),i(e.kind===`task`?a(`{name} is on it`,{name:n}):a(`{name} will say it now`,{name:n})),r()}catch(e){i(e.message)}finally{c(null)}};return(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,L.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold`,children:a(`Reminders & routines`)}),!l&&(0,L.jsx)(`button`,{type:`button`,onClick:()=>d(!0),className:`text-[12.5px] text-accent font-medium`,children:a(`+ Add`)})]}),l&&(0,L.jsx)(um,{onDone:()=>{d(!1),r()},onCancel:()=>d(!1)}),f.length===0&&!l?(0,L.jsx)(`div`,{className:`text-[13px] text-muted`,children:a(`Nothing scheduled. Tell {name} “remind me at six to call mum” or “every weekday morning, summarise my unread email”.`,{name:n})}):(0,L.jsx)(`ul`,{className:`space-y-1.5`,children:f.map(t=>(0,L.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[t.kind===`task`?(0,L.jsx)(F,{size:15,className:`text-muted shrink-0`}):(0,L.jsx)(We,{size:15,className:`text-muted shrink-0`}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:t.text}),(0,L.jsxs)(`div`,{className:`text-[11.5px] text-muted truncate`,children:[t.next_at?`${e(t.next_at)} · ${u(t.next_at)}`:a(`Not scheduled`),t.repeat&&` · ${tm(t.repeat).toLowerCase()}`,t.kind===`task`&&` · ${a(`does the work`)}`]})]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void h(t),disabled:o!==null,"aria-label":t.kind===`task`?a(`Do “{text}” now`,{text:t.text}):a(`Remind me now: {text}`,{text:t.text}),className:`rounded-full bg-accent/12 p-2 text-accent disabled:opacity-50`,children:o===t.id?(0,L.jsx)(A,{size:15,className:`animate-spin`}):(0,L.jsx)(Gt,{size:15})}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void m(t),disabled:o!==null,"aria-label":a(`Cancel: {text}`,{text:t.text}),className:`rounded-full p-2 text-muted hover:text-fg disabled:opacity-50`,children:(0,L.jsx)(Fe,{size:15})})]},t.id))}),p.length>0&&(0,L.jsxs)(`details`,{className:`mt-2`,children:[(0,L.jsx)(`summary`,{className:`text-[12px] text-muted cursor-pointer select-none`,children:a(`{n} finished recently`,{n:p.length})}),(0,L.jsx)(`ul`,{className:`mt-1.5 space-y-1`,children:p.map(t=>(0,L.jsxs)(`li`,{className:`flex items-center gap-2 px-3 text-[12.5px] text-muted`,children:[(0,L.jsx)(`span`,{className:`min-w-0 flex-1 truncate line-through decoration-border`,children:t.text}),(0,L.jsx)(`span`,{className:`shrink-0`,children:t.status===`done`?t.last_fired_at?e(t.last_fired_at):a(`done`):a(`cancelled`)})]},t.id))})]}),(0,L.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`A time you named is kept whatever the level or the quiet hours. Reminders just say it; routines do the work and report.`)})]})}function um({onDone:e,onCancel:t}){let{toast:n}=E(),r=s(),[a,o]=(0,I.useState)(``),[c,l]=(0,I.useState)(`remind`),[u,d]=(0,I.useState)(`once`),[f,p]=(0,I.useState)(()=>gm()),[h,g]=(0,I.useState)(`daily`),[_,v]=(0,I.useState)(`09:00`),[y,b]=(0,I.useState)(`mon`),[x,S]=(0,I.useState)(`1`),[C,w]=(0,I.useState)(!1),T=h===`weekly`?`weekly ${y} ${_}`:h===`monthly`?`monthly ${x} ${_}`:`${h} ${_}`,D=async()=>{if(a.trim()){w(!0);try{await M.createReminder(u===`once`?{text:a.trim(),kind:c,at:f.replace(`T`,` `)}:{text:a.trim(),kind:c,repeat:T}),e()}catch(e){n(e.message)}finally{w(!1)}}},O=`h-9 rounded-xl bg-surface border border-border px-3 text-[13px] outline-none focus:border-accent`;return(0,L.jsxs)(`div`,{className:`mb-2 rounded-2xl border border-border p-3 space-y-2`,children:[(0,L.jsx)(`input`,{autoFocus:!0,value:a,onChange:e=>o(e.target.value),placeholder:r(c===`task`?`What should be done…`:`What to remind you of…`),className:i(O,`w-full h-10 text-[14px]`)}),(0,L.jsxs)(`div`,{className:`flex gap-1.5 text-[12.5px]`,children:[(0,L.jsx)(hm,{options:[[`remind`,r(`Remind me`)],[`task`,r(`Do it for me`)]],value:c,onChange:e=>l(e)}),(0,L.jsx)(hm,{options:[[`once`,r(`Once`)],[`repeat`,r(`Repeat`)]],value:u,onChange:e=>d(e)})]}),u===`once`?(0,L.jsx)(`input`,{type:`datetime-local`,value:f,onChange:e=>p(e.target.value),className:i(O,`w-full`)}):(0,L.jsxs)(`div`,{className:`flex flex-wrap gap-1.5`,children:[(0,L.jsxs)(`select`,{value:h,onChange:e=>g(e.target.value),className:O,children:[(0,L.jsx)(`option`,{value:`daily`,children:r(`Every day`)}),(0,L.jsx)(`option`,{value:`weekdays`,children:r(`Weekdays`)}),(0,L.jsx)(`option`,{value:`weekly`,children:r(`Weekly`)}),(0,L.jsx)(`option`,{value:`monthly`,children:r(`Monthly`)})]}),h===`weekly`&&(0,L.jsx)(`select`,{value:y,onChange:e=>b(e.target.value),className:O,children:[`mon`,`tue`,`wed`,`thu`,`fri`,`sat`,`sun`].map((e,t)=>(0,L.jsx)(`option`,{value:e,children:new Date(2024,0,1+t).toLocaleDateString(m(),{weekday:`long`})},e))}),h===`monthly`&&(0,L.jsx)(`select`,{value:x,onChange:e=>S(e.target.value),className:O,children:Array.from({length:28},(e,t)=>String(t+1)).map(e=>(0,L.jsx)(`option`,{value:e,children:r(`Day {d}`,{d:e})},e))}),(0,L.jsx)(`input`,{type:`time`,value:_,onChange:e=>v(e.target.value),className:O})]}),(0,L.jsxs)(`div`,{className:`flex justify-end gap-2 pt-0.5`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:t,className:`rounded-full px-3 py-1.5 text-[13px] text-muted`,children:r(`Cancel`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void D(),disabled:C||!a.trim(),className:`rounded-full bg-accent px-3.5 py-1.5 text-[13px] font-medium text-accent-fg disabled:opacity-50`,children:r(C?`Saving…`:u===`once`?`Set reminder`:`Set routine`)})]})]})}var dm={mail:Et,event:et,hook:_n};function fm(e,t){if(e.kind===`mail`)return e.match?t(`mail matching “{match}”`,{match:e.match}):t(`any new mail`);if(e.kind===`event`){let n=e.match?t(`events matching “{match}”`,{match:e.match}):t(`any event`);return t(`{n} min before {what}`,{n:e.lead_minutes,what:n})}return e.match?t(`webhook “{match}”`,{match:e.match}):t(`webhook`)}function pm({data:t,name:n,onChange:r}){let{toast:a}=E(),o=s(),[c,l]=(0,I.useState)(null),[u,d]=(0,I.useState)(!1),f=t.items.filter(e=>e.status===`active`),p=f.some(e=>e.kind===`mail`),m=async e=>{l(e.id);try{await M.cancelTrigger(e.id),r()}catch(e){a(e.message)}finally{l(null)}},h=async e=>{l(e.id);try{await M.fireTrigger(e.id),a(o(`{name} is on it`,{name:n})),r()}catch(e){a(e.message)}finally{l(null)}},g=async e=>{if(e.url)try{await navigator.clipboard.writeText(e.url),a(o(`Webhook URL copied`))}catch{a(e.url)}};return(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`div`,{className:`flex items-center justify-between mb-1.5`,children:[(0,L.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold`,children:o(`When something happens`)}),!u&&(0,L.jsx)(`button`,{type:`button`,onClick:()=>d(!0),className:`text-[12.5px] text-accent font-medium`,children:o(`+ Add`)})]}),u&&(0,L.jsx)(mm,{available:t.available,onDone:()=>{d(!1),r()},onCancel:()=>d(!1)}),f.length===0&&!u?(0,L.jsx)(`div`,{className:`text-[13px] text-muted`,children:o(`Nothing yet. Tell {name} “when the landlord writes back, summarise it and draft a reply” or “half an hour before any review, brief me”.`,{name:n})}):(0,L.jsx)(`ul`,{className:`space-y-1.5`,children:f.map(t=>{let n=dm[t.kind];return(0,L.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,L.jsx)(n,{size:15,className:`text-muted shrink-0 mt-[3px]`}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium`,children:fm(t,o)}),(0,L.jsx)(`div`,{className:`text-[12px] leading-snug text-fg/80 line-clamp-2`,children:t.text}),(0,L.jsxs)(`div`,{className:`text-[11.5px] text-muted flex flex-wrap items-center gap-x-1.5`,children:[t.fired>0?(0,L.jsxs)(`span`,{children:[o(`fired {n}×`,{n:t.fired}),t.last_fired_at&&` · ${e(t.last_fired_at)}`]}):(0,L.jsx)(`span`,{children:o(`not fired yet`)}),t.kind===`hook`&&t.url&&(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void g(t),className:`inline-flex items-center gap-1 text-accent font-medium`,children:[(0,L.jsx)(ut,{size:11}),` `,o(`Copy URL`)]})]})]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void h(t),disabled:c!==null,"aria-label":o(`Run now: {text}`,{text:t.text}),className:`rounded-full bg-accent/12 p-2 text-accent disabled:opacity-50`,children:c===t.id?(0,L.jsx)(A,{size:15,className:`animate-spin`}):(0,L.jsx)(Gt,{size:15})}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void m(t),disabled:c!==null,"aria-label":o(`Cancel: {text}`,{text:t.text}),className:`rounded-full p-2 text-muted hover:text-fg disabled:opacity-50`,children:(0,L.jsx)(Fe,{size:15})})]},t.id)})}),p&&(0,L.jsx)(`div`,{className:i(`mt-1.5 text-[12px]`,t.mail_error?`text-rose-500`:`text-muted`),children:t.mail_error?o(`The inbox could not be read: {error}`,{error:t.mail_error}):t.mail_checked_at?o(`Inbox checked {when}; {name} looks every {n} min.`,{when:e(t.mail_checked_at),name:n,n:t.mail_poll_minutes}):o(`{name} will look at the inbox shortly, then every {n} min.`,{name:n,n:t.mail_poll_minutes})}),(0,L.jsx)(`div`,{className:`mt-1.5 text-[12px] text-muted`,children:o(`Each time it happens {name} does the work in the chat it was set from and reports in the Feed. A mail or a request is treated as data, never as instructions.`,{name:n})})]})}function mm({available:e,onDone:t,onCancel:n}){let{toast:r}=E(),a=s(),o=[`mail`,`event`,`hook`].find(t=>e[t])??`hook`,[c,l]=(0,I.useState)(o),[u,d]=(0,I.useState)(``),[f,p]=(0,I.useState)(`30`),[m,h]=(0,I.useState)(``),[g,_]=(0,I.useState)(!1),[v,y]=(0,I.useState)(null),b=async()=>{if(m.trim()){_(!0);try{let e=await M.createTrigger({kind:c,text:m.trim(),match:u.trim(),lead_minutes:Number(f)||30});e.kind===`hook`&&e.url?y(e):t()}catch(e){r(e.message)}finally{_(!1)}}},x=`h-9 rounded-xl bg-surface border border-border px-3 text-[13px] outline-none focus:border-accent`;if(v)return(0,L.jsxs)(`div`,{className:`mb-2 rounded-2xl border border-border p-3 space-y-2`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium`,children:a(`Webhook ready`)}),(0,L.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:a(`POST anything to this URL and the work starts. The key is in the URL; share it only with the program that will call it.`)}),(0,L.jsx)(`code`,{className:`block break-all rounded-xl bg-surface-2 px-3 py-2 text-[12px]`,children:v.url}),(0,L.jsxs)(`div`,{className:`flex justify-end gap-2 pt-0.5`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:()=>void navigator.clipboard.writeText(v.url??``).then(()=>r(a(`Webhook URL copied`)),()=>r(v.url??``)),className:`rounded-full px-3 py-1.5 text-[13px] text-accent font-medium`,children:a(`Copy`)}),(0,L.jsx)(`button`,{type:`button`,onClick:t,className:`rounded-full bg-accent px-3.5 py-1.5 text-[13px] font-medium text-accent-fg`,children:a(`Done`)})]})]});let S=[[`mail`,a(`New mail`)],[`event`,a(`Before an event`)],[`hook`,a(`Webhook`)]];return(0,L.jsxs)(`div`,{className:`mb-2 rounded-2xl border border-border p-3 space-y-2`,children:[(0,L.jsx)(hm,{options:S,value:c,onChange:e=>l(e)}),!e[c]&&(0,L.jsx)(`div`,{className:`text-[12.5px] text-rose-500`,children:a(c===`mail`?`Connect a mailbox under Connections first.`:`Add a calendar under Connections first.`)}),(0,L.jsxs)(`div`,{className:`flex gap-1.5`,children:[(0,L.jsx)(`input`,{value:u,onChange:e=>d(e.target.value),placeholder:a(c===`mail`?`Words in the sender or subject (empty: any mail)`:c===`event`?`Words in the title or place (empty: any event)`:`A name for this hook`),className:i(x,`min-w-0 flex-1`)}),c===`event`&&(0,L.jsxs)(`label`,{className:`flex items-center gap-1 text-[12.5px] text-muted`,children:[(0,L.jsx)(`input`,{type:`number`,min:0,max:1440,value:f,onChange:e=>p(e.target.value),className:i(x,`w-[64px] px-2`),"aria-label":a(`Minutes before`)}),a(`min before`)]})]}),(0,L.jsx)(`input`,{value:m,onChange:e=>h(e.target.value),placeholder:a(`What should be done each time…`),className:i(x,`w-full h-10 text-[14px]`)}),(0,L.jsx)(`div`,{className:`text-[12px] text-muted`,children:a(`When {condition}`,{condition:fm({kind:c,match:u.trim(),lead_minutes:Number(f)||30},a)})}),(0,L.jsxs)(`div`,{className:`flex justify-end gap-2 pt-0.5`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:n,className:`rounded-full px-3 py-1.5 text-[13px] text-muted`,children:a(`Cancel`)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void b(),disabled:g||!m.trim()||!e[c],className:`rounded-full bg-accent px-3.5 py-1.5 text-[13px] font-medium text-accent-fg disabled:opacity-50`,children:a(g?`Saving…`:`Set trigger`)})]})]})}function hm({options:e,value:t,onChange:n}){return(0,L.jsx)(`div`,{className:`flex rounded-full bg-surface-2 p-0.5`,children:e.map(([e,r])=>(0,L.jsx)(`button`,{type:`button`,onClick:()=>n(e),className:i(`rounded-full px-2.5 py-1 transition`,t===e?`bg-surface shadow-sm font-medium`:`text-muted`),children:r},e))})}function gm(){let e=new Date(Date.now()+36e5);e.setMinutes(0,0,0);let t=e=>String(e).padStart(2,`0`);return`${e.getFullYear()}-${t(e.getMonth()+1)}-${t(e.getDate())}T${t(e.getHours())}:${t(e.getMinutes())}`}function _m(e){let[t,n]=(0,I.useState)(null),[r,i]=(0,I.useState)(null),a=async()=>{try{n(await M.activity(150)),i(null)}catch(e){i(e.message||`Could not load.`)}};return(0,I.useEffect)(()=>{if(!e)return;a();let t=window.setInterval(()=>void a(),4e3);return()=>window.clearInterval(t)},[e]),[t,a,r]}function vm({open:e}){let[t,n,r]=_m(e),{state:i}=E(),a=s(),o=i.profile?.name??`nanoMuse`,c=(t?.audit??[]).filter(e=>e.event===`tool_call`).reverse(),l=c.filter(e=>e.channel===`gui`).length;return(0,L.jsxs)(`div`,{className:`pb-2`,children:[(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted mb-2`,children:a(`Every tool call goes through the Sentinel and is written to the audit log, including the ones it refused.`)}),l>0&&(0,L.jsx)(`p`,{className:`text-[12.5px] text-muted mb-2`,children:a(`On the phone's screen: {gui} of {total} steps ({pct}%). The screen is the last resort; a skill, a fetch or the browser comes first.`,{gui:String(l),total:String(c.length),pct:String(Math.round(l/c.length*100))})}),!t&&r&&(0,L.jsx)(qp,{message:r,onRetry:()=>void n()}),!t&&!r&&(0,L.jsx)(`div`,{className:`py-10 flex justify-center text-muted`,children:(0,L.jsx)(A,{className:`animate-spin`,size:20})}),t&&c.length===0&&(0,L.jsx)(`div`,{className:`py-8 text-center text-muted text-[14px]`,children:a(`Nothing yet today. What you ask for and what {name} does about it shows up here.`,{name:o})}),(0,L.jsx)(`ul`,{className:`space-y-1.5`,children:c.map((e,t)=>(0,L.jsx)(wm,{entry:e},`${e.ts}-${t}`))})]})}function ym({open:e}){let{state:t,toast:n}=E(),r=s(),[i,a]=_m(e),o=async()=>{if(window.confirm(r(`Forget every permission you granted? {name} will ask again next time.`,{name:t.profile?.name??`nanoMuse`})))try{await M.resetApprovals(),await a(),n(r(`Permissions reset`))}catch(e){n(e.message)}},c=async e=>{try{await M.revokeGrant(e.key),await a(),n(r(`Revoked: {subject}`,{subject:fp(e.tool,e.target)}))}catch(e){n(e.message)}},l=t.settings?.sentinel.mode;return(0,L.jsxs)(`div`,{className:`space-y-4 pb-2`,children:[(0,L.jsxs)(`div`,{className:`rounded-2xl border border-border p-3.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 font-medium`,children:[(0,L.jsx)(Qt,{size:18,className:`text-accent`}),` `,r(`Sentinel mode:`),` `,(0,L.jsx)(`span`,{className:`capitalize`,children:l===`ask`?r(`balanced`):l===`strict`?r(`cautious`):l===`auto`?r(`hands-off`):l})]}),(0,L.jsxs)(`p`,{className:`mt-1 text-[12.5px] text-muted`,children:[l===`ask`&&r(`Safe and moderate actions run freely; anything sensitive (email, shell, purchases) waits for you.`),l===`strict`&&r(`Moderate and sensitive actions both wait for your approval.`),l===`auto`&&r(`Everything is approved automatically except explicit deny rules. Use with care.`)]}),i?.tainted&&(0,L.jsxs)(`p`,{className:`mt-2 text-[12.5px] text-amber-600 dark:text-amber-300 flex items-center gap-1.5`,children:[(0,L.jsx)($t,{size:14}),` `,r(`Private data was read this session: network calls to new destinations need approval.`)]})]}),(0,L.jsx)(Sm,{grants:i?.grants??[],onRevoke:c}),(0,L.jsx)(Cm,{title:r(`Always ask (from config)`),tools:t.settings?.sentinel.always_ask_tools??[],muted:!0}),(i?.grants.length??0)>0&&(0,L.jsx)(`button`,{type:`button`,onClick:()=>void o(),className:`w-full rounded-2xl border border-border py-2.5 text-[14px] font-medium`,children:r(`Reset all granted permissions`)})]})}function bm(e,t){switch(e.scope){case`conversation`:return t(`for this conversation`);case`session`:return t(`until restart`);case`24h`:return e.expires_at?t(`until {time}`,{time:u(e.expires_at)}):t(`for 24 hours`);case`always`:return t(`always`);default:return e.scope}}var xm=[`sensitive`,`moderate`,`low`,`safe`];function Sm({grants:e,onRevoke:t}){let n=s(),{state:r}=E(),i=e=>r.settings?.tools.find(t=>t.name===e)?.risk??`moderate`,a=xm.map(t=>({risk:t,rows:e.filter(e=>i(e.tool)===t)})).filter(e=>e.rows.length>0);return(0,L.jsx)(`div`,{className:`space-y-3`,children:e.length===0?(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:n(`Permissions you granted`)}),(0,L.jsx)(`div`,{className:`text-[13px] text-muted`,children:n(`No standing approvals. When you answer “always allow” to a risky step, it is listed here and can be revoked; approvals you give once are not kept.`)})]}):a.map((e,r)=>(0,L.jsxs)(`div`,{children:[(0,L.jsxs)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:[r===0?`${n(`Permissions you granted`)} · `:``,n(e.risk)]}),(0,L.jsx)(`ul`,{className:`space-y-1.5`,children:e.rows.map(e=>(0,L.jsxs)(`li`,{className:`flex items-center gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,L.jsx)(`span`,{className:`text-accent`,children:op(e.tool,15)}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[13.5px] font-medium truncate`,children:fp(e.tool,e.target)}),(0,L.jsxs)(`div`,{className:`text-[11.5px] text-muted`,children:[e.tool,` · `,bm(e,n),` · `,n(`granted {time}`,{time:u(e.granted_at)})]})]}),(0,L.jsx)(`button`,{type:`button`,"aria-label":n(`Revoke {what}`,{what:pp(e.scope,e.tool,e.target)}),onClick:()=>t(e),className:`rounded-full p-1.5 text-muted hover:bg-surface active:scale-95`,children:(0,L.jsx)(Fe,{size:15})})]},e.key))})]},e.risk))})}function Cm({title:e,tools:t,muted:n}){let r=s();return(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`text-[12px] uppercase tracking-wide text-muted font-semibold mb-1.5`,children:e}),t.length===0?(0,L.jsx)(`div`,{className:`text-[13px] text-muted`,children:r(`None`)}):(0,L.jsx)(`div`,{className:`flex flex-wrap gap-1.5`,children:t.map(e=>(0,L.jsxs)(`span`,{className:i(`rounded-full px-2.5 py-1 text-[12.5px] flex items-center gap-1`,n?`bg-surface-2 text-muted`:`bg-accent/12 text-accent`),children:[op(e,12),` `,e]},e))})]})}function wm({entry:e}){let t=s(),n=e.decision===`deny`,r=e.ok===!1&&!n;return(0,L.jsxs)(`li`,{className:`flex items-start gap-2.5 rounded-2xl bg-surface-2/60 px-3 py-2`,children:[(0,L.jsx)(`div`,{className:i(`mt-0.5`,n?`text-rose-500`:r?`text-amber-500`:`text-muted`),children:n||r?(0,L.jsx)(Ye,{size:15}):(0,L.jsx)(ke,{size:15})}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[13.5px]`,children:[(0,L.jsx)(`span`,{className:`text-muted`,children:op(e.tool??``,13)}),(0,L.jsx)(`span`,{className:`font-medium truncate`,children:e.summary||e.tool})]}),(0,L.jsxs)(`div`,{className:`mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] text-muted`,children:[(0,L.jsx)(`span`,{children:u(e.ts)}),e.risk&&(0,L.jsx)(lp,{risk:e.risk}),e.approved===!0&&(0,L.jsxs)(`span`,{className:`text-emerald-600 dark:text-emerald-300`,children:[t(`approved by you`),e.approval_scope&&e.approval_scope!==`once`?` (${e.approval_scope})`:``]}),e.approved==null&&e.approval_scope&&e.approval_scope!==`once`&&(0,L.jsx)(`span`,{className:`text-emerald-600 dark:text-emerald-300`,children:t(`covered by your {scope} permission`,{scope:e.approval_scope})}),n&&(0,L.jsx)(`span`,{className:`text-rose-500`,children:t(`blocked`)}),r&&(0,L.jsx)(`span`,{className:`text-amber-600`,children:t(`failed`)}),typeof e.duration_ms==`number`&&(0,L.jsxs)(`span`,{children:[(e.duration_ms/1e3).toFixed(1),`s`]})]}),n&&e.reasons&&e.reasons.length>0&&(0,L.jsx)(`div`,{className:`mt-0.5 text-[12px] text-muted`,children:e.reasons.map(v).join(` · `)})]})]})}function Tm({title:e,actions:t=[],trailing:n,children:r}){let{state:i,setDrawer:a}=E(),o=s(),[c,l]=(0,I.useState)(!1),u=i.profile?.name||`nanoMuse`,d=i.pendingApprovals.length,f=d>0?d>1?o(`{n} approvals waiting for you`,{n:d}):o(`1 approval waiting for you`):i.status.state===`working`?i.status.detail||o(`On it`):i.status.state===`waiting`?o(`Waiting for you`):null;return(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(ne,{profile:i.profile,status:i.status,name:u,statusLine:f,statusTone:f?`accent`:`muted`,spinning:i.status.state===`working`&&d===0,badge:d,onAvatar:()=>l(!0),leading:(0,L.jsx)(te,{onClick:()=>a(!0),label:o(`Chats`),className:`wide:invisible`,children:(0,L.jsx)(Ot,{size:22})}),trailing:n??(0,L.jsx)(Em,{actions:t})}),(e||r)&&(0,L.jsxs)(`div`,{className:`flex shrink-0 items-center gap-3 pr-5`,children:[(0,L.jsx)(k,{className:`flex-1`,children:e}),r]}),(0,L.jsx)(im,{open:c,onClose:()=>l(!1)})]})}function Em({actions:e=[]}){let{setTab:t}=E(),n=s(),[r,i]=(0,I.useState)(!1),a=(0,I.useRef)(null);(0,I.useEffect)(()=>{if(!r)return;let e=e=>{a.current?.contains(e.target)||i(!1)},t=e=>{e.key===`Escape`&&i(!1)};return window.addEventListener(`pointerdown`,e),window.addEventListener(`keydown`,t),()=>{window.removeEventListener(`pointerdown`,e),window.removeEventListener(`keydown`,t)}},[r]);let o=e=>{i(!1),t(e)},c=[...e.map(e=>({label:e.label,onClick:()=>{i(!1),e.onClick()}})),{label:n(`Memory`),onClick:()=>o(`memory`)},{label:n(`Settings`),onClick:()=>o(`you`)}];return(0,L.jsxs)(`div`,{ref:a,className:`relative`,children:[(0,L.jsx)(te,{onClick:()=>i(e=>!e),label:n(`More`),expanded:r,children:(0,L.jsx)(pt,{size:22})}),r&&(0,L.jsx)(`ul`,{role:`menu`,className:`absolute right-0 top-12 z-30 min-w-[180px] overflow-hidden rounded-2xl border border-border bg-surface py-1 shadow-xl`,children:c.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsx)(`button`,{type:`button`,role:`menuitem`,onClick:e.onClick,className:`w-full px-4 py-2.5 text-left text-[14.5px] hover:bg-surface-2`,children:e.label})},e.label))})]})}var Dm=`nanomuse-naming`;function Om(e){return e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`)}function km(e,t,n=!1){let r="```"+t,i=e;if(i.includes(r)){let e=RegExp("```"+Om(t)+"[ \\t]*\\r?\\n[\\s\\S]*?```",`g`);i=i.replace(e,``);let n=i.lastIndexOf(r);n>=0&&(i=i.slice(0,n))}if(n){let e=i.lastIndexOf("```");e>=0&&r.startsWith(i.slice(e))&&(i=i.slice(0,e))}return i===e?e:i.replace(/\s+$/,``)}function Am(e,t=!1){return km(e,Dm,t)}function jm(e){return e.filter(e=>!e.this&&e.kind!==`web`&&e.name.trim()!==``)}function Mm(e){let t=/^@([^\s,:;,:;]*)$/.exec(e);return t?t[1]:null}function Nm(e,t,n=6){let r=Mm(e);if(r===null)return[];let i=r.toLowerCase();return jm(t).filter(e=>e.name.toLowerCase().startsWith(i)).sort((e,t)=>Number(t.online)-Number(e.online)||e.name.localeCompare(t.name)).slice(0,n)}function Pm(e,t){let n=e.trimStart();if(!n.startsWith(`@`))return null;let r=n.slice(1),i=r.toLowerCase(),a=jm(t),o=a.filter(e=>{let t=e.name.trim().toLowerCase();if(!i.startsWith(t))return!1;let n=r.charAt(t.length);return n===``||/[\s,:;,:;]/.test(n)}).sort((e,t)=>t.name.length-e.name.length);if(o[0])return o[0];let s=/^[^\s,:;,:;]+/.exec(r)?.[0]?.toLowerCase()??``;if(!s)return null;let c=a.filter(e=>e.name.trim().toLowerCase().startsWith(s));if(c.length===1)return c[0];let l=c.filter(e=>e.online);return l.length===1?l[0]:null}var Fm=`nm.show_steps`;function Im(){try{return localStorage.getItem(Fm)!==`0`}catch{return!0}}var Lm=Im(),Rm=new Set;function zm(){return Lm}function Bm(e){Lm=e;try{localStorage.setItem(Fm,e?`1`:`0`)}catch{}Rm.forEach(e=>e())}function Vm(e){return Rm.add(e),()=>Rm.delete(e)}function Hm(){return(0,I.useSyncExternalStore)(Vm,zm,zm)}function Um(){let{state:e,send:t,sendViaCloud:n,decide:r,loadEvents:i,openFile:a,toast:o,setDrawer:c,setTab:l}=E(),u=s(),{profile:d,status:f,activeThread:p,threads:m}=e,h=e.events[p],g=(0,I.useMemo)(()=>h??[],[h]),_=h!==void 0,v=e.streams[p],b=m.find(e=>e.id===p),[x,S]=(0,I.useState)(!1),[C,w]=(0,I.useState)(null),T=d?.name??`nanoMuse`,D=b?.device?e.hub?.devices.find(e=>e.id===b.device)?.online??null:null,O=th(e.working[p]),k=(0,I.useRef)(null),ee=(0,I.useRef)(null),A=(0,I.useRef)(!0),[j,re]=(0,I.useState)(!1),[ie,N]=(0,I.useState)(!1),ae=(0,I.useRef)(null),[oe,se]=(0,I.useState)(0);(0,I.useLayoutEffect)(()=>{let e=ae.current;if(!e||(se(e.offsetHeight),typeof ResizeObserver>`u`))return;let t=new ResizeObserver(()=>se(e.offsetHeight));return t.observe(e),()=>t.disconnect()},[]);let ce=e.firstrun,le=!!ce&&ce.phase!==`none`&&ce.session_id===p,ue=le&&ce.running,de=y()===`zh-CN`?`zh`:`en`,[fe,pe]=(0,I.useState)(null);(0,I.useEffect)(()=>{if(!le||fe?.lang===de)return;let e=!0;return M.firstrun(de).then(t=>e&&t.intro&&pe({lang:de,lines:t.intro})).catch(()=>void 0),()=>{e=!1}},[le,de,fe?.lang]);let me=e=>{M.firstrunPick(e).then(()=>t(p,e)).catch(e=>o(e.message||u(`Could not send`)))},he=()=>window.dispatchEvent(new Event(`nanomuse:quick-chat`)),[ge,_e]=(0,I.useState)(null),ve=e.settings?.onboarded===!0,ye=(0,I.useRef)(!1),xe=(0,I.useRef)(!1);(0,I.useEffect)(()=>{if(b?.busy){ye.current||(xe.current=ue),ye.current=!0;return}ye.current&&(ye.current=!1,ve&&!xe.current&&!ue&&sh(g)&&_e(Se()))},[b?.busy,g,ve,ue]);let[Ce,Te]=(0,I.useState)(null);(0,I.useEffect)(()=>{let{days:e,fresh:t}=be();t&&Te(e)},[]);let Ee=(0,I.useCallback)(()=>{let e=k.current;e&&(e.scrollTop=e.scrollHeight,re(!1),N(!1))},[]),De=(0,I.useCallback)(()=>{let e=k.current;if(!e)return;let t=e.scrollHeight-e.scrollTop-e.clientHeight;A.current=t<120,re(t>400),N(t>8)},[]),Oe=(0,I.useRef)(null);(0,I.useLayoutEffect)(()=>{Oe.current!==p&&(Oe.current=p,A.current=!0),A.current&&Ee()},[g,v?.text,p,Ee]),(0,I.useEffect)(()=>{let e=k.current,t=ee.current;if(!e||!t||typeof ResizeObserver>`u`)return;let n=new ResizeObserver(()=>{A.current?Ee():De()});return n.observe(e),n.observe(t),()=>n.disconnect()},[Ee,De]);let ke=()=>{let e=k.current;e&&e.scrollTo({top:e.scrollHeight,behavior:`smooth`})},Ae=e.pendingApprovals.length,je=e.pendingApprovals.filter(e=>e.thread===p).length,Ne=(0,I.useMemo)(()=>{let e=!f.thread||f.thread===p;if(je>0&&!(e&&f.state===`working`))return je>1?u(`{n} approvals waiting for you`,{n:je}):u(`1 approval waiting for you`);if(!e)return b?.busy?u(`On it`):void 0;if(f.state===`idle`&&!b?.busy)return;if(f.detail)return f.detail;if(f.state===`waiting`)return u(`Waiting for you`);let t=oh(g);return t?u(`On it: {request}`,{request:t}):u(`On it`)},[f,b,p,je,g,u]),Pe=g.filter(e=>e.type===`approval`&&e.status===`pending`).length,Fe=Hm(),Ie=(0,I.useMemo)(()=>Fe?g:g.filter(e=>e.type!==`tool`),[g,Fe]),Le={signedIn:!!e.hub?.account.signed_in,chatOnCloud:!!e.settings?.llm.cloud,deviceChat:!!b?.device},Re=e=>{let t=$f(Ie,e,Le);return t?()=>n(p,t.text,t.files):void 0},ze=(0,I.useMemo)(()=>Array.from(new Set(g.flatMap(e=>e.type===`artifact`?[e.path]:[]))),[g]);return(0,L.jsxs)(`div`,{className:`relative flex h-full flex-col`,children:[(0,L.jsx)(`div`,{ref:ae,className:`pointer-events-none absolute inset-x-0 top-0 z-10`,children:(0,L.jsx)(ne,{scrim:!0,profile:d,status:f,name:T,statusLine:Ne,statusTone:f.state===`idle`&&!b?.busy?`muted`:`accent`,spinning:(f.state===`working`&&f.thread===p||!!b?.busy)&&je===0,badge:Ae,onAvatar:()=>S(!0),leading:(0,L.jsx)(te,{onClick:()=>c(!0),label:u(`Chats`),dot:m.length>1&&p!==`main`,className:`wide:invisible`,children:(0,L.jsx)(Ot,{size:22})}),trailing:(0,L.jsx)(Em,{actions:[{label:u(`Coding agents`),onClick:()=>l(`coding`)}]}),under:b&&b.id!==`main`?b.device?(0,L.jsxs)(`span`,{className:`flex max-w-full items-center gap-1 rounded-full bg-accent/12 px-2.5 py-0.5 text-[11.5px] font-medium text-accent`,children:[D===!1?(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-border`}):(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 rounded-full bg-emerald-500`}),(0,L.jsx)(`span`,{className:`truncate`,children:u(`on {device}`,{device:b.device_name||b.device})})]}):b.remote_from?(0,L.jsxs)(`span`,{className:`flex max-w-full items-center gap-1 rounded-full bg-surface-2 px-2.5 py-0.5 text-[11.5px] font-medium text-fg/80`,children:[(0,L.jsx)(It,{size:11}),` `,(0,L.jsx)(`span`,{className:`truncate`,children:u(`from {device}`,{device:b.remote_from.name})})]}):(0,L.jsx)(`span`,{className:`truncate rounded-full bg-surface-2 px-2.5 py-0.5 text-[11.5px] font-medium text-fg/80`,children:b.title}):void 0})}),(0,L.jsx)(`div`,{"aria-hidden":!0,className:`list-fade-top pointer-events-none absolute inset-x-0 top-0 z-[5] h-20`,style:{opacity:+!!ie}}),(0,L.jsxs)(`div`,{ref:k,onScroll:De,className:`relative flex-1 overflow-y-auto px-3 pb-3`,children:[(0,L.jsxs)(`div`,{ref:ee,className:`space-y-2.5`,children:[(0,L.jsx)(`div`,{"aria-hidden":!0,style:{height:oe}}),e.hasMore[p]&&g.length>0&&(0,L.jsx)(`div`,{className:`flex justify-center`,children:(0,L.jsx)(`button`,{type:`button`,className:`text-[12.5px] text-accent px-3 py-1 rounded-full bg-surface-2`,onClick:()=>void i(p,g[0]?.id),children:u(`Load earlier messages`)})}),le&&_&&!e.hasMore[p]&&fe&&(0,L.jsx)(Pp,{lines:fe.lines}),_&&g.length===0&&!v&&!le&&(0,L.jsx)(rh,{name:T,device:b?.device?b.device_name||b.device:void 0,onSend:e=>t(p,e).catch(e=>o(e.message||u(`Could not send`)))}),Ie.map((e,t)=>(0,L.jsx)(Wm,{event:e,prev:Ie[t-1],name:T,onDecide:(t,n)=>r(e.id,t,n).catch(e=>o(e.message||u(`Could not send decision`))),onOpenFile:a,onOpenBrowser:w,files:ze,onCloudRetry:Re(t)},e.id)),v&&v.text&&(0,L.jsx)(Qm,{text:v.text,streaming:!0,files:ze,onOpenFile:a}),(b?.busy||f.state!==`idle`)&&!v?.text&&f.state!==`waiting`&&(0,L.jsx)(eh,{label:b?.queued?u(`{n} queued`,{n:b.queued}):void 0}),le&&ce.phase===`ask_agent_name`&&!b?.busy&&(0,L.jsx)(Fp,{chips:ce.chips,chosen:ce.chosen,onPick:me,onElse:he}),O&&!b?.busy&&(0,L.jsx)(nh,{who:O}),!b?.busy&&f.state===`idle`&&ge!==null&&(0,L.jsx)(we,{moment:`tasks`,n:ge,className:`mx-1 my-2`}),!b?.busy&&f.state===`idle`&&Ce!==null&&(0,L.jsx)(we,{moment:`days_used`,n:Ce,className:`mx-1 my-2`})]}),j&&(0,L.jsxs)(`button`,{type:`button`,onClick:ke,className:`sticky bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-ink text-ink-fg shadow px-3 py-1.5 text-[12.5px] font-medium flex items-center gap-1`,children:[(0,L.jsx)(Me,{size:14}),` `,u(`Latest`),Pe?` · ${u(`{n} approval`,{n:Pe})}`:``]})]}),(0,L.jsx)(Lf,{}),(0,L.jsx)(ih,{name:T,busy:!!b?.busy&&Pe===0,waiting:g.some(e=>e.type===`question`&&e.status===`pending`),onSend:(e,n)=>t(p,e,n).catch(e=>{throw o(e.message||u(`Could not send`)),e})}),(0,L.jsx)(im,{open:x,onClose:()=>S(!1)}),C&&(0,L.jsx)(kp,{thread:p,eventId:C,onClose:()=>w(null)})]})}function Wm({event:e,prev:t,name:n,onDecide:r,onOpenFile:i,onOpenBrowser:a,files:o,onCloudRetry:s}){let c=Km(t,e)?(0,L.jsx)(qm,{ts:e.ts}):null,l=(()=>{switch(e.type){case`user`:return(0,L.jsx)(Jm,{event:e,onOpenFile:i});case`assistant`:return e.quiet?(0,L.jsx)($m,{text:e.text,about:e.about,ts:e.ts}):(0,L.jsx)(Qm,{text:e.text,reasoning:e.reasoning,continued:t?.type===`assistant`,files:o,onOpenFile:i,spoken:e.via===`call`,modelUsed:e.model_used});case`tool`:return(0,L.jsx)(dp,{event:e});case`approval`:return(0,L.jsx)(mp,{event:e,name:n,onDecide:r});case`question`:return(0,L.jsx)(hp,{event:e,name:n});case`notice`:return(0,L.jsx)(_p,{event:e,onCloudRetry:s});case`artifact`:return(0,L.jsx)(Tp,{event:e,onOpen:i});case`browser`:return(0,L.jsx)(vp,{event:e,onOpen:a});case`hands`:return(0,L.jsx)(yp,{event:e,name:n});case`hold`:return(0,L.jsx)(Cp,{event:e,name:n});case`avatar`:return(0,L.jsx)(tp,{event:e,name:n});default:return null}})();return l?(0,L.jsxs)(L.Fragment,{children:[c,l]}):null}var Gm=6e5;function Km(e,t){return t.ts?!e?.ts||new Date(t.ts).getTime()-new Date(e.ts).getTime()>=Gm:!1}function qm({ts:e}){let t=l(e);return t?(0,L.jsx)(`div`,{className:`pt-2 pb-0.5 text-center text-[11.5px] font-medium text-muted`,children:t}):null}function Jm({event:e,onOpenFile:t}){let n=s(),r=e.files??[],a=r.filter(e=>e.kind===`image`),o=r.filter(e=>e.kind!==`image`);return(0,L.jsxs)(`div`,{className:`rise flex flex-col items-end pl-12`,children:[e.via===`call`?(0,L.jsxs)(`span`,{className:`mb-1 flex items-center gap-1 text-[11.5px] font-medium text-muted`,children:[(0,L.jsx)(Ut,{size:11}),` `,n(`said on a call`)]}):e.via?(0,L.jsx)(`span`,{className:`mb-1 text-[11.5px] font-medium text-muted`,children:n(`asked from {device}`,{device:e.via})}):e.to_device_name||e.to_device?(0,L.jsxs)(`span`,{className:`mb-1 flex items-center gap-1 text-[11.5px] font-medium text-accent`,children:[(0,L.jsx)(It,{size:11}),` `,n(`to {device}`,{device:e.to_device_name||e.to_device||``})]}):(e.via_device_name||e.via_device)&&(0,L.jsx)(`span`,{className:`mb-1 text-[11.5px] font-medium text-muted`,children:n(`From {device}`,{device:e.via_device_name||e.via_device||``})}),a.length>0&&(0,L.jsx)(`div`,{className:`mb-1 flex max-w-full flex-wrap justify-end gap-1.5`,children:a.map(e=>(0,L.jsx)(`button`,{type:`button`,onClick:()=>t(e.path),"aria-label":e.name,className:i(`overflow-hidden rounded-2xl border border-border/60 bg-surface-2 shadow-sm active:scale-[0.98] transition`,a.length===1?`max-h-64 max-w-[240px]`:`h-28 w-28`),children:(0,L.jsx)(`img`,{src:h(e.path),alt:e.name,loading:`lazy`,className:i(`object-cover`,a.length===1?`max-h-64 w-auto max-w-full`:`h-full w-full`)})},e.path))}),o.map(e=>(0,L.jsxs)(`button`,{type:`button`,onClick:()=>t(e.path),className:`mb-1 flex max-w-full items-center gap-2.5 rounded-2xl border border-border bg-surface px-3 py-2 text-left shadow-sm hover:bg-surface-2 active:scale-[0.98] transition`,children:[(0,L.jsx)(`span`,{className:`rounded-xl bg-accent/12 p-2 text-accent`,children:e.kind===`data`?(0,L.jsx)(cn,{size:17}):(0,L.jsx)(mt,{size:17})}),(0,L.jsxs)(`span`,{className:`min-w-0`,children:[(0,L.jsx)(`span`,{className:`block truncate text-[14px] font-medium`,children:e.name}),(0,L.jsx)(`span`,{className:`block text-[11.5px] text-muted`,children:Ym(e.size)})]})]},e.path)),e.text&&(0,L.jsx)(`div`,{className:`bubble-user max-w-full rounded-[20px] bg-bubble-user px-4 py-2.5 text-bubble-user-fg`,children:(0,L.jsx)(`div`,{className:`md text-[15px] leading-[1.45] whitespace-pre-wrap break-words`,children:e.text})})]})}function Ym(e){return e<1024?`${e} B`:e<1048576?`${Math.round(e/1024)} KB`:`${(e/1048576).toFixed(1)} MB`}function Xm(e){let t=e.toLowerCase().split(`.`).pop()??``;return[`csv`,`tsv`,`json`,`xlsx`,`xls`].includes(t)?`data`:`other`}var Zm=`image/*,.pdf,.txt,.md,.csv,.tsv,.json,.log,.html,.xml,.yaml,.yml,.toml,.ics,.vcf,.xlsx,.xls,.docx`;function Qm({text:e,reasoning:t,streaming:n,continued:r,files:a,onOpenFile:o,spoken:c,modelUsed:l}){let[u,d]=(0,I.useState)(!1),f=s(),p=(0,I.useMemo)(()=>pf(Am(e,!!n)),[e,n]);return p.length===0&&!n&&!t?null:(0,L.jsx)(`div`,{className:i(`rise flex items-end gap-2 pr-10`,r&&`-mt-1`),children:(0,L.jsxs)(`div`,{className:`min-w-0 max-w-full`,children:[t&&(0,L.jsx)(`button`,{type:`button`,className:`mb-1 ml-1 text-[12px] text-muted`,onClick:()=>d(e=>!e),children:f(u?`Hide thinking`:`Show thinking`)}),t&&u&&(0,L.jsx)(`div`,{className:`mb-1.5 rounded-2xl bg-surface-2/70 px-3 py-2 text-[13px] text-muted whitespace-pre-wrap break-words`,children:t}),p.map((e,t)=>e.bare?(0,L.jsx)(`div`,{className:`my-1 max-w-full`,children:(0,L.jsx)(mf,{text:e.text,files:a,onOpenFile:o})},t):(0,L.jsxs)(`div`,{className:i(`my-1 max-w-[340px] rounded-[20px] bg-bubble-agent px-3.5 py-2.5 wide:max-w-[600px]`,c&&`border border-accent/15`),children:[c&&t===0&&(0,L.jsxs)(`div`,{className:`mb-0.5 flex items-center gap-1 text-[11px] font-medium text-accent/80`,children:[(0,L.jsx)(Ut,{size:10}),` `,f(`said on a call`)]}),(0,L.jsx)(mf,{text:e.text,files:a,onOpenFile:o}),n&&t===p.length-1&&(0,L.jsx)(`span`,{className:`inline-block w-1.5 h-4 ml-0.5 align-middle bg-accent/70 animate-pulse rounded-sm`})]},t)),p.length===0&&n&&(0,L.jsx)(`div`,{className:`my-1 rounded-[20px] bg-bubble-agent px-3.5 py-2.5`,children:(0,L.jsx)(`span`,{className:`inline-block w-1.5 h-4 align-middle bg-accent/70 animate-pulse rounded-sm`})}),l&&!n&&(0,L.jsx)(`div`,{className:`ml-1 text-[11px] text-muted`,children:f(`Answered by nanoMuse Cloud this time ({model})`,{model:l})})]})})}function $m({text:e,about:n,ts:r}){let[i,a]=(0,I.useState)(!1),o=s(),c=n?t(n.replace(/^Working on your goal: /,``)):o(`background check`);return(0,L.jsx)(`div`,{className:`rise flex justify-center px-6`,children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>a(e=>!e),className:`max-w-full rounded-2xl px-3 py-1.5 text-[12px] text-muted text-center leading-snug`,children:[(0,L.jsxs)(`span`,{className:`inline-flex items-center gap-1.5`,children:[(0,L.jsx)(Rt,{size:12}),` `,o(`Checked on {label}: nothing new`,{label:c}),r?` · ${u(r)}`:``]}),i&&(0,L.jsx)(`span`,{className:`block mt-1 text-left whitespace-pre-wrap text-[12.5px]`,children:e})]})})}function eh({label:e}){return(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsxs)(`div`,{className:`rounded-[20px] rounded-bl-md bg-bubble-agent px-3.5 py-2.5 flex items-center gap-1`,children:[(0,L.jsx)(`span`,{className:`typing-dot h-2 w-2 rounded-full bg-muted`}),(0,L.jsx)(`span`,{className:`typing-dot h-2 w-2 rounded-full bg-muted`}),(0,L.jsx)(`span`,{className:`typing-dot h-2 w-2 rounded-full bg-muted`})]}),e&&(0,L.jsx)(`span`,{className:`text-[12px] text-muted`,children:e})]})}function th(e){let t=e?e.at*1e3+ie:0,[n,r]=(0,I.useState)(0);return(0,I.useEffect)(()=>{if(!t)return;let e=t-Date.now();if(e<=0){r(t);return}let n=window.setTimeout(()=>r(t),e+50);return()=>window.clearTimeout(n)},[t]),!e||n===t?null:e}function nh({who:e}){let t=s();return(0,L.jsxs)(`div`,{className:`flex items-center gap-1.5 px-1 text-[12px] text-muted`,children:[(0,L.jsx)(A,{size:12,className:`animate-spin`}),(0,L.jsx)(`span`,{children:t(`{device} is working…`,{device:e.device_name||e.device})})]})}function rh({name:e,device:t,onSend:n}){let r=s(),i=t?[r(`What is on your screen right now?`),r(`Which folder are you in, and what is in it?`),r(`Check for updates and tell me what needs a restart`)]:[r(`What can you do for me?`),r(`Plan my week: ask me what's on my plate`),r(`Research and compare two options for me`),r(`Set up a long-term goal and track it`)];return(0,L.jsxs)(`div`,{className:`flex flex-col items-center text-center px-6 pt-10 pb-6 gap-3`,children:[(0,L.jsx)(`div`,{className:`text-[20px] font-semibold`,children:t?r(`This chat goes to {device}.`,{device:t}):r(`Hi, I'm {name}.`,{name:e})}),(0,L.jsx)(`p`,{className:`text-muted text-[14.5px] leading-snug max-w-sm`,children:t?r(`Whatever you ask here, the {name} on {device} does where it is: its shell, its files, its screen. Every step shows up here, and anything that needs an approval asks you here.`,{name:e,device:t}):r(`I don't just answer; I get things done: research, plans, files, code, email, long-running goals. Everything I do shows up here, and anything hard to undo waits for your approval.`)}),(0,L.jsx)(`div`,{className:`mt-2 flex flex-wrap justify-center gap-2`,children:i.map(e=>(0,L.jsx)(`button`,{type:`button`,onClick:()=>n(e),className:`rounded-full bg-surface-2 px-3.5 py-1.5 text-[13.5px] hover:bg-border/60`,children:e},e))})]})}function ih({name:e,busy:t,waiting:n,onSend:r}){let{state:a,draft:o,draftFiles:c,toast:l}=E(),[u,d]=(0,I.useState)(``),[f,p]=(0,I.useState)(!1),m=(0,I.useRef)(``),g=Mp((0,I.useCallback)((e,t)=>{let n=m.current,r=n&&!/\s$/.test(n)?` `:``;d(n+r+e+t)},[]),(0,I.useCallback)(e=>l(e),[l])),_=()=>{g.listening||(m.current=u),g.toggle()},[v,y]=(0,I.useState)([]),[b,x]=(0,I.useState)([]);(0,I.useEffect)(()=>{if(!a.draftFiles)return;let e=a.draftFiles.map(e=>({key:`shared-${e.path}`,file:new File([],e.name),preview:e.kind===`image`?h(e.path):null,info:e,error:null}));x(t=>[...t.filter(t=>!e.some(e=>e.key===t.key)),...e].slice(0,10)),c(null)},[a.draftFiles]);let S=(0,I.useRef)(null),C=(0,I.useRef)(null),w=s();(0,I.useEffect)(()=>()=>b.forEach(e=>e.preview?.startsWith(`blob:`)&&URL.revokeObjectURL(e.preview)),[b]),(0,I.useEffect)(()=>{let e=()=>S.current?.focus();return window.addEventListener(`nanomuse:quick-chat`,e),()=>window.removeEventListener(`nanomuse:quick-chat`,e)},[]);let T=e=>{let t=Array.from(e).slice(0,Math.max(0,10-b.length));if(t.length===0)return;let n=t.map(e=>({key:`${e.name}-${e.size}-${e.lastModified}-${Math.random().toString(36).slice(2,7)}`,file:e,preview:e.type.startsWith(`image/`)?URL.createObjectURL(e):null,info:null,error:null}));x(e=>[...e,...n]);for(let e of n)M.upload(e.file).then(t=>x(n=>n.map(n=>n.key===e.key?{...n,info:t}:n))).catch(t=>{let n=t.message?N(t.message):w(`Upload failed`);x(t=>t.map(t=>t.key===e.key?{...t,error:n}:t)),l(n)})},D=e=>x(t=>t.filter(t=>t.key!==e)),O=b.some(e=>!e.info&&!e.error),k=b.flatMap(e=>e.info?[e.info.path]:[]);(0,I.useEffect)(()=>{let e=S.current;e&&(e.style.height=`0px`,e.style.height=`${Math.min(e.scrollHeight,160)}px`)},[u]),(0,I.useEffect)(()=>{if(a.draft===null)return;d(a.draft),o(null);let e=S.current;e&&(e.focus(),e.setSelectionRange(e.value.length,e.value.length))},[a.draft]);let ee=a.settings?.skills?.enabled!==!1;(0,I.useEffect)(()=>{ee&&M.skills().then(e=>y(e.skills.filter(e=>e.enabled))).catch(()=>y([]))},[ee,a.skillsVersion]);let te=/^\/([a-z0-9-]*)$/i.exec(u),j=te?v.filter(e=>e.name.startsWith(te[1].toLowerCase())).slice(0,6):[],ne=e=>{d(`/${e.name} `),S.current?.focus()},re=a.hub?.devices??[],ie=Nm(u,re),ae=ie.length===0?Pm(u,re):null,oe=e=>{d(`@${e.name} `),S.current?.focus()},se=e=>{e?.preventDefault();let t=u.trim();if(!t&&k.length===0||O||f)return;g.listening&&g.stop();let n=b;p(!0),d(``),x([]),S.current?.focus(),r(t,k).catch(()=>{d(e=>e||t),x(e=>e.length?e:n)}).finally(()=>p(!1))};return(0,L.jsxs)(`form`,{onSubmit:se,className:`shrink-0 bg-bg px-3 pt-1.5 pb-1`,children:[t&&!n&&(0,L.jsx)(`div`,{className:`px-2 pb-1 text-[12px] text-muted`,children:w(`{name} is working; anything you send now is picked up right away.`,{name:e})}),j.length>0&&(0,L.jsx)(`ul`,{className:`mb-2 max-h-72 overflow-y-auto rounded-3xl border border-border/70 bg-surface shadow-lg divide-y divide-border/70`,role:`listbox`,"aria-label":w(`Skills`),children:j.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>ne(e),className:`flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-surface-2/70 active:bg-surface-2`,children:[(0,L.jsx)(gn,{size:16,className:`shrink-0 text-accent`}),(0,L.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,L.jsxs)(`span`,{className:`block text-[14px] font-medium`,children:[`/`,e.name]}),(0,L.jsx)(`span`,{className:`block truncate text-[12.5px] text-muted`,children:e.description})]})]})},e.name))}),ie.length>0&&(0,L.jsx)(`ul`,{className:`mb-2 max-h-72 overflow-y-auto rounded-3xl border border-border/70 bg-surface shadow-lg divide-y divide-border/70`,role:`listbox`,"aria-label":w(`Devices`),children:ie.map(e=>(0,L.jsx)(`li`,{children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>oe(e),className:`flex w-full items-center gap-3 px-4 py-2.5 text-left hover:bg-surface-2/70 active:bg-surface-2`,children:[(0,L.jsx)(`span`,{className:`shrink-0 text-accent`,children:e.kind===`computer`?(0,L.jsx)(Lt,{size:16}):(0,L.jsx)(tn,{size:16})}),(0,L.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,L.jsxs)(`span`,{className:`block text-[14px] font-medium`,children:[`@`,e.name]}),(0,L.jsx)(`span`,{className:`block truncate text-[12.5px] text-muted`,children:e.online?w(`Runs this there and reports back`):w(`Offline`)})]})]})},e.id))}),ae&&(0,L.jsxs)(`div`,{className:`flex items-center gap-1.5 px-2 pb-1 text-[12px] text-muted`,children:[(0,L.jsx)(It,{size:12}),` `,w(`This goes to {device}.`,{device:ae.name})]}),b.length>0&&(0,L.jsx)(`div`,{className:`mb-1 flex gap-2.5 overflow-x-auto px-1 pt-2.5 pb-1 pr-3`,role:`list`,"aria-label":w(`Attachments`),children:b.map(e=>(0,L.jsxs)(`div`,{role:`listitem`,className:i(`relative shrink-0 rounded-2xl border bg-surface-2`,e.error?`border-danger/60`:`border-border/70`),children:[e.preview?(0,L.jsx)(`img`,{src:e.preview,alt:e.file.name,className:`h-16 w-16 rounded-2xl object-cover`}):(0,L.jsxs)(`div`,{className:`flex h-16 w-40 items-center gap-2 px-2.5`,children:[(0,L.jsx)(`span`,{className:`rounded-xl bg-accent/12 p-1.5 text-accent`,children:Xm(e.file.name)===`data`?(0,L.jsx)(cn,{size:16}):(0,L.jsx)(mt,{size:16})}),(0,L.jsxs)(`span`,{className:`min-w-0`,children:[(0,L.jsx)(`span`,{className:`block truncate text-[12.5px] font-medium`,children:e.file.name}),(0,L.jsx)(`span`,{className:`block text-[11px] text-muted`,children:e.error?w(`Upload failed`):Ym(e.info?.size??e.file.size)})]})]}),!e.info&&!e.error&&(0,L.jsx)(`div`,{className:`absolute inset-0 flex items-center justify-center rounded-2xl bg-bg/50`,children:(0,L.jsx)(A,{size:18,className:`animate-spin text-accent`})}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>D(e.key),"aria-label":w(`Remove`),className:`absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full border border-border bg-surface text-muted shadow-sm`,children:(0,L.jsx)(Fe,{size:13})})]},e.key))}),(0,L.jsxs)(`div`,{className:`flex items-end gap-2 rounded-[26px] border border-border bg-surface px-1.5 py-1.5 shadow-[0_2px_12px_-6px_rgba(0,0,0,0.12)] focus-within:border-fg/20`,children:[(0,L.jsx)(`input`,{ref:C,type:`file`,multiple:!0,accept:Zm,className:`hidden`,onChange:e=>{e.target.files&&T(e.target.files),e.target.value=``}}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>C.current?.click(),"aria-label":w(`Attach a file`),className:`flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-fg/70 transition hover:bg-surface-2 active:scale-95`,children:(0,L.jsx)(Kt,{size:22})}),(0,L.jsx)(`textarea`,{ref:S,value:u,onChange:e=>d(e.target.value),onPaste:e=>{let t=Array.from(e.clipboardData?.files??[]);t.length>0&&(e.preventDefault(),T(t))},onKeyDown:e=>{if(e.key===`Tab`&&j.length>0&&j[0]){e.preventDefault(),ne(j[0]);return}if(e.key===`Tab`&&ie.length>0&&ie[0]){e.preventDefault(),oe(ie[0]);return}e.key===`Enter`&&!e.shiftKey&&!e.nativeEvent.isComposing&&(e.preventDefault(),se())},rows:1,placeholder:n?w(`Answer {name}…`,{name:e}):b.length>0?w(`Say what to do with it…`):w(`Message`),"aria-label":w(`Message`),className:`flex-1 resize-none bg-transparent px-1 py-2 text-[15px] leading-[1.4] outline-none placeholder:text-muted`}),g.supported&&(0,L.jsx)(Np,{listening:g.listening,onClick:_}),(0,L.jsx)(`button`,{type:`submit`,disabled:!u.trim()&&k.length===0||O,"aria-label":w(`Send`),className:i(`flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition active:scale-95`,u.trim()||k.length>0?`bg-ink text-ink-fg`:`bg-surface-2 text-muted`,`disabled:opacity-60`),children:(0,L.jsx)(Je,{size:19,strokeWidth:2.5})})]})]})}function ah({threads:e,active:t,onPick:n,onCleared:r,compact:a=!1,grouped:o=!1}){let{state:c,toast:l,dispatch:d}=E(),f=s(),[p,m]=(0,I.useState)(``),[h,g]=(0,I.useState)(!1),_=(0,I.useRef)(null),v=(c.hub?.devices??[]).filter(e=>!e.this&&e.kind!==`web`&&e.online),y=e=>c.hub?.devices.find(t=>t.id===e)?.kind??`phone`,b=async e=>{try{let t=await M.askDevice(e.id);d({type:`ws`,msg:{kind:`thread`,thread:t.thread}}),n(t.thread.id)}catch(e){l(e.message)}},x=async()=>{g(!0);try{let e=await M.createThread(p.trim()||f(`Side chat`));d({type:`ws`,msg:{kind:`thread`,thread:e}}),m(``),n(e.id)}catch(e){l(e.message)}finally{g(!1)}},S=async e=>{if(window.confirm(f(`Delete this side chat and its history?`)))try{await M.deleteThread(e),d({type:`ws`,msg:{kind:`thread_deleted`,thread:e}})}catch(e){l(e.message)}},C=async e=>{if(window.confirm(f(`Clear this conversation? Memory and goals are kept.`)))try{await M.clearThread(e),d({type:`ws`,msg:{kind:`thread_cleared`,thread:e}}),r?.()}catch(e){l(e.message)}},w=e=>(0,L.jsxs)(`li`,{className:i(`group flex items-center gap-2 px-3`,a?`py-2 rounded-xl hover:bg-surface-2/60`:`py-2.5`,e.id===t&&`bg-surface-2/60`),children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>n(e.id),className:`flex-1 text-left min-w-0`,children:[(0,L.jsxs)(`div`,{className:i(`font-medium truncate flex items-center gap-2`,a?`text-[13.5px]`:`text-[15px]`),children:[e.device?(0,L.jsx)(`span`,{className:`text-accent`,children:y(e.device)===`computer`?(0,L.jsx)(Lt,{size:14}):(0,L.jsx)(tn,{size:14})}):e.remote_from?(0,L.jsx)(It,{size:14,className:`text-muted`}):null,(0,L.jsx)(`span`,{className:`truncate`,children:e.id===`main`?f(e.title):e.title}),e.busy&&(0,L.jsx)(`span`,{className:`h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400 animate-pulse`})]}),(0,L.jsxs)(`div`,{className:i(`text-muted`,a?`text-[11.5px]`:`text-[12px]`),children:[f(`{n} events`,{n:e.events}),e.queued?` · ${f(`{n} queued`,{n:e.queued})}`:``,` · `,u(e.updated_at)]})]}),(0,L.jsx)(`button`,{type:`button`,"aria-label":f(`Clear`),onClick:()=>void C(e.id),className:i(`p-2 text-muted hover:text-fg`,a&&`opacity-0 group-hover:opacity-100 focus:opacity-100`),children:(0,L.jsx)(Fe,{size:16})}),e.id!==`main`&&(0,L.jsx)(`button`,{type:`button`,"aria-label":f(`Delete`),onClick:()=>void S(e.id),className:i(`p-2 text-muted hover:text-rose-500`,a&&`opacity-0 group-hover:opacity-100 focus:opacity-100`),children:(0,L.jsx)(fn,{size:16})})]},e.id),T=i(`divide-y divide-border overflow-hidden`,a?`rounded-xl`:`rounded-2xl border border-border`),D=`px-3 pb-1 pt-3 text-[11.5px] font-semibold uppercase tracking-wide text-muted`;return(0,L.jsxs)(`div`,{children:[o?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`div`,{className:D,children:f(`Main chat`)}),(0,L.jsx)(`ul`,{className:T,children:e.filter(e=>e.id===`main`).map(w)}),(0,L.jsxs)(`div`,{className:i(D,`flex items-center justify-between pr-1`),children:[(0,L.jsx)(`span`,{children:f(`Side chats`)}),(0,L.jsx)(`button`,{type:`button`,"aria-label":f(`New side chat`),onClick:()=>_.current?.focus(),className:`flex h-6 w-6 items-center justify-center rounded-full text-muted hover:bg-surface-2 hover:text-fg`,children:(0,L.jsx)(Kt,{size:15})})]}),(0,L.jsx)(`ul`,{className:T,children:e.filter(e=>e.id!==`main`).map(w)}),e.every(e=>e.id===`main`)&&(0,L.jsx)(`p`,{className:`px-3 py-2 text-[12px] text-muted`,children:f(`A side chat keeps one topic apart from the rest.`)})]}):(0,L.jsx)(`ul`,{className:T,children:e.map(w)}),v.length>0&&(0,L.jsxs)(`div`,{className:`mt-4`,children:[(0,L.jsx)(`div`,{className:`mb-1.5 text-[12px] font-medium text-muted`,children:f(`Ask one of your devices`)}),(0,L.jsx)(`div`,{className:`flex flex-wrap gap-2`,children:v.map(e=>(0,L.jsxs)(`button`,{type:`button`,onClick:()=>void b(e),className:`flex items-center gap-1.5 rounded-full border border-border bg-surface px-3 py-1.5 text-[13px] font-medium hover:bg-surface-2`,children:[e.kind===`phone`?(0,L.jsx)(tn,{size:14,className:`text-accent`}):(0,L.jsx)(Lt,{size:14,className:`text-accent`}),e.name]},e.id))})]}),(0,L.jsxs)(`div`,{className:i(`flex gap-2`,a?`mt-3`:`mt-4`),children:[(0,L.jsx)(`input`,{ref:_,value:p,onChange:e=>m(e.target.value),onKeyDown:e=>{e.key===`Enter`&&!h&&x()},placeholder:f(a?`New side chat`:`New side chat, e.g. “Trip to Kyoto”`),className:i(`min-w-0 flex-1 bg-surface-2 outline-none focus:ring-2 focus:ring-accent/40`,a?`rounded-xl px-3 py-2 text-[13px]`:`rounded-2xl px-3.5 py-2.5 text-[14px]`)}),(0,L.jsxs)(`button`,{type:`button`,disabled:h,onClick:()=>void x(),"aria-label":f(`New`),className:i(`flex items-center gap-1.5 bg-accent text-accent-fg font-medium disabled:opacity-50`,a?`rounded-xl px-2.5`:`rounded-2xl px-3.5 py-2.5`),children:[(0,L.jsx)(jt,{size:18}),` `,!a&&f(`New`)]})]})]})}function oh(e){let t=[...e].reverse().find(e=>e.type===`user`),n=(((t?.type===`user`?t.text:``)||``).split(/\r?\n/).map(e=>e.trim()).find(e=>e.length>0)??``).replace(/\s+/g,` `);if(n.length<=36)return n;let r=n.slice(0,36),i=r.lastIndexOf(` `);return(i>16?r.slice(0,i):r).trimEnd()+`…`}function sh(e){let t=!1;for(let n=e.length-1;n>=0;n--){let r=e[n];if(r.type===`assistant`){if(!r.text||r.quiet)return!1;t=!0;continue}if(r.type===`user`)return t&&!r.via;if(r.type===`notice`)return!1}return!1}function ch(e){let t=Sn(),n=t?`Desktop app (${t.platform}, shell ${t.version})`:`Web app (${navigator.userAgent})`;return`https://github.com/zeeshanhaque21/nanoMuse/issues/new?${new URLSearchParams({template:`bug_report.yml`,version:e||``,os:n}).toString()}`}function lh(){let{state:e,setTab:t,openThread:n}=E(),r=s(),[a,o]=(0,I.useState)(!1),[c,l]=(0,I.useState)(!1),[u,d]=(0,I.useState)(``),f=(0,I.useRef)(null),p=Qf(),{profile:m,status:h}=e,g=m?.name??`nanoMuse`,_=e.pendingApprovals.length,v=e.goals.filter(e=>e.proposal&&e.status!==`cancelled`).length,y=e.pendingApprovals.filter(t=>t.ts>e.feedSeenAt).length,b=(e.hub?.devices??[]).filter(e=>!e.this&&e.kind!==`web`&&e.online).length,x=_>0?_>1?r(`{n} approvals waiting for you`,{n:_}):r(`1 approval waiting for you`):h.state===`idle`?r(`Idle`):h.detail||(h.state===`waiting`?r(`Waiting for you`):r(`Working…`));(0,I.useEffect)(()=>{if(!c)return;let e=e=>{f.current?.contains(e.target)||l(!1)},t=e=>{e.key===`Escape`&&l(!1)};return document.addEventListener(`mousedown`,e),document.addEventListener(`keydown`,t),()=>{document.removeEventListener(`mousedown`,e),document.removeEventListener(`keydown`,t)}},[c]);let S=[{id:`chat`,label:`Chat`,icon:(0,L.jsx)(At,{size:21}),badge:_},{id:`feed`,label:`Feed`,icon:(0,L.jsx)(Bt,{size:21}),badge:y},{id:`ideas`,label:`Ideas`,icon:(0,L.jsx)(Ct,{size:21})},{id:`goals`,label:`Goals`,icon:(0,L.jsx)(rn,{size:21}),badge:v},{id:`library`,label:`Library`,icon:(0,L.jsx)(St,{size:21})},{id:`devices`,label:`Devices`,icon:(0,L.jsx)(It,{size:21}),dot:b>0},...p?[{id:`coding`,label:`Coding`,icon:(0,L.jsx)(on,{size:21})}]:[]],C=u.trim().toLowerCase(),w=C?e.threads.filter(e=>e.id===`main`||(e.id===`main`?r(e.title):e.title).toLowerCase().includes(C)||(e.device_name??``).toLowerCase().includes(C)):e.threads,T=e=>i(`relative flex w-[60px] flex-col items-center gap-[3px] rounded-xl py-1.5 text-[10.5px] font-medium leading-none transition`,e?`bg-surface-2 text-fg`:`text-fg/65 hover:bg-surface-2/60 hover:text-fg`);return(0,L.jsxs)(`div`,{className:`flex h-full shrink-0`,children:[(0,L.jsxs)(`aside`,{className:`relative flex h-full w-[76px] shrink-0 flex-col items-center border-r border-border bg-surface/70`,children:[(0,L.jsx)(`div`,{className:`titlebar-room`}),(0,L.jsxs)(`button`,{type:`button`,onClick:()=>o(!0),className:`relative mt-3 rounded-full transition hover:scale-[1.04]`,"aria-label":r(`Activity`),title:`${g} · ${x}`,children:[(0,L.jsx)(D,{profile:m,status:h,size:44}),_>0&&(0,L.jsx)(`span`,{className:`pointer-events-none absolute -right-1 -top-1 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border-2 border-surface bg-rose-500 px-1 text-[10.5px] font-bold text-white`,children:_})]}),(0,L.jsx)(`span`,{className:`mt-1.5 max-w-[68px] truncate px-1 text-[11px] font-semibold leading-tight`,children:g}),(0,L.jsx)(`nav`,{className:`mt-3 flex w-full flex-1 flex-col items-center gap-0.5 overflow-y-auto overflow-x-hidden px-2`,children:S.map(n=>{let a=e.tab===n.id;return(0,L.jsxs)(`button`,{type:`button`,onClick:()=>t(n.id),"aria-current":a?`page`:void 0,className:T(a),children:[(0,L.jsx)(`span`,{className:i(a?`text-accent`:`text-fg/60`),children:n.icon}),(0,L.jsx)(`span`,{className:`max-w-full truncate`,children:r(n.label)}),n.badge?(0,L.jsx)(`span`,{className:`absolute right-1.5 top-1 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white`,children:n.badge}):n.dot?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`span`,{"aria-hidden":`true`,className:`absolute right-3 top-2 h-2 w-2 rounded-full border-2 border-surface bg-emerald-500`}),(0,L.jsx)(`span`,{className:`sr-only`,children:r(`{n} online`,{n:b})})]}):null]},n.id)})}),(0,L.jsxs)(`div`,{className:`flex w-full flex-col items-center gap-0.5 px-2 pb-3 pt-2`,children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>t(`account`),"aria-current":e.tab===`account`?`page`:void 0,className:T(e.tab===`account`),title:e.hub?.account.signed_in?e.hub.account.hint:r(`Account`),children:[(0,L.jsx)(mn,{size:21,className:e.tab===`account`?`text-accent`:`text-fg/60`}),(0,L.jsx)(`span`,{className:`max-w-full truncate`,children:r(`Account`)})]}),(0,L.jsxs)(`button`,{type:`button`,onClick:()=>t(`you`),"aria-current":e.tab===`you`?`page`:void 0,className:T(e.tab===`you`),children:[(0,L.jsx)(en,{size:21,className:e.tab===`you`?`text-accent`:`text-fg/60`}),(0,L.jsx)(`span`,{className:`max-w-full truncate`,children:r(`Settings`)})]}),(0,L.jsxs)(`div`,{ref:f,className:`relative w-full`,children:[(0,L.jsxs)(`button`,{type:`button`,onClick:()=>l(e=>!e),"aria-expanded":c,"aria-haspopup":`menu`,className:i(T(c),`mx-auto`),children:[(0,L.jsx)(Ot,{size:21,className:`text-fg/60`}),(0,L.jsx)(`span`,{children:r(`More`)})]}),c&&(0,L.jsxs)(`div`,{role:`menu`,className:`absolute bottom-0 left-[68px] z-30 w-[232px] rounded-2xl border border-border bg-surface p-1.5 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.3)]`,children:[(0,L.jsxs)(`a`,{role:`menuitem`,href:ch(e.version),target:`_blank`,rel:`noreferrer`,onClick:()=>l(!1),className:`flex items-center gap-2.5 rounded-xl px-2.5 py-2 text-[13px] font-medium hover:bg-surface-2`,children:[(0,L.jsx)(ce,{size:16,className:`text-fg/60`}),` `,r(`Report a bug`)]}),(0,L.jsxs)(`button`,{type:`button`,role:`menuitem`,onClick:()=>{l(!1),t(`you`),window.location.hash=`developer`},className:`flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-[13px] font-medium hover:bg-surface-2`,children:[(0,L.jsx)(yn,{size:16,className:`text-fg/60`}),(0,L.jsx)(`span`,{className:`flex-1`,children:r(`Developer tools`)}),(0,L.jsx)(`span`,{className:`text-[11.5px] font-normal text-muted`,children:r(p?`On`:`Off`)})]}),e.version&&(0,L.jsxs)(`div`,{className:`px-2.5 pb-1 pt-1.5 text-[11.5px] text-muted`,children:[`nanoMuse `,e.version]})]})]})]}),(0,L.jsx)(im,{open:a,onClose:()=>o(!1)})]}),e.tab===`chat`&&(0,L.jsxs)(`section`,{className:`flex h-full w-[248px] shrink-0 flex-col border-r border-border bg-surface/40`,"aria-label":r(`Chats`),children:[(0,L.jsx)(`div`,{className:`titlebar-room`}),(0,L.jsxs)(`div`,{className:`px-3 pb-2 pt-4`,children:[(0,L.jsx)(`h2`,{className:`px-1 text-[15px] font-semibold tracking-tight`,children:r(`Chats`)}),(0,L.jsxs)(`label`,{className:`mt-2 flex items-center gap-2 rounded-xl bg-surface-2 px-2.5 py-1.5 text-muted focus-within:ring-2 focus-within:ring-accent/40`,children:[(0,L.jsx)(qt,{size:14,className:`shrink-0`}),(0,L.jsx)(`input`,{value:u,onChange:e=>d(e.target.value),placeholder:r(`Search`),"aria-label":r(`Search chats`),className:`min-w-0 flex-1 bg-transparent text-[13px] text-fg outline-none placeholder:text-muted`}),u&&(0,L.jsx)(`button`,{type:`button`,onClick:()=>d(``),"aria-label":r(`Clear`),className:`shrink-0 hover:text-fg`,children:(0,L.jsx)(Fe,{size:14})})]})]}),(0,L.jsx)(`div`,{className:`min-h-0 flex-1 overflow-y-auto px-2 pb-3`,children:(0,L.jsx)(ah,{threads:w,active:e.activeThread,onPick:e=>n(e),compact:!0,grouped:!0})})]})]})}function uh(){let{state:e,send:t,openThread:n,openFile:r,markFeedSeen:i,setTab:a,toast:o}=E(),[c,l]=(0,I.useState)(null),[u,p]=(0,I.useState)(null),[h,g]=(0,I.useState)(null),[_,v]=(0,I.useState)(null),b=e.profile?.name??`nanoMuse`,x=s(),S=y();(0,I.useEffect)(()=>{let e=!0;return M.feed(80).then(t=>e&&l(t)).catch(e=>o(e.message)),M.upcoming().then(t=>e&&g(t)).catch(()=>void 0),M.feedPosts().then(t=>e&&p(t)).catch(()=>void 0),()=>{e=!1}},[e.feedVersion,e.remindersVersion]),(0,I.useEffect)(()=>{let e=!0;return M.calendar().then(t=>e&&v(t)).catch(()=>void 0),()=>{e=!1}},[e.calendarVersion,e.connectionsVersion]),(0,I.useEffect)(()=>{c&&c.length&&c[0].ts>e.feedSeenAt&&i(c[0].ts)},[c,e.feedSeenAt,i]);let C=(0,I.useMemo)(()=>Oh(c??[]),[c,S]),[w,T]=(0,I.useState)(!1),[D,O]=(0,I.useState)(null),[k,ee]=(0,I.useState)(()=>d(dh)===`1`),[j,ne]=(0,I.useState)(!1),re=()=>{f(dh,`1`),ee(!0)},ie=async()=>{ne(!0);try{let e=await M.refreshFeedPosts();p(e),e.error&&o(x(`Could not write posts: {error}`,{error:e.error}))}catch(e){o(e.message)}finally{ne(!1)}},N=e=>{t(`main`,e.prompt||`${x(`About this post from my feed:`)}\n\n**${e.title}**\n\n${e.body.slice(0,1200)}`).catch(e=>o(e.message||x(`Could not send`))),n(`main`)},ae=(0,I.useMemo)(()=>ph(u?.posts??[]),[u]),oe=u!==null&&u.posts.length===0;return(0,L.jsxs)(`div`,{className:`flex h-full flex-col`,children:[(0,L.jsx)(Tm,{title:``,trailing:(0,L.jsx)(te,{onClick:()=>T(!0),label:x(`Feed settings`),children:(0,L.jsx)(en,{size:22})})}),(0,L.jsxs)(`div`,{className:`flex-1 overflow-y-auto px-3.5 pb-7`,children:[ae.length===0&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(hh,{text:mh(fh(),new Date)}),!k&&(0,L.jsx)(bh,{instructions:u?.instructions??``,onEdit:()=>T(!0),onAck:re}),oe&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(xh,{emoji:`🖼️`,title:x(`Nothing in the feed yet`),body:x(`As we get to know each other, new posts will show up here. Every day at {time} I read what I remember about you (your memory files, the last week of diary, your goals) and write a few short posts.`,{time:u?.time??`08:00`})}),(0,L.jsx)(xh,{emoji:`📝`,title:x(`Steer it with one sentence`),body:x(`Tap the sliders at the top right to tell me what you want more of, switch the daily routine off, or have me write the first day now.`)}),e.settings&&!e.settings.llm_ready&&(0,L.jsx)(gh,{children:(0,L.jsxs)(`button`,{type:`button`,onClick:()=>a(`you`),className:`flex w-full items-center gap-2 p-4 text-left text-[14px] text-muted`,children:[(0,L.jsx)(xt,{size:16,className:`shrink-0 text-accent`}),` `,x(`Add a model first; the feed is written by your agent.`)]})}),(0,L.jsx)(gh,{children:(0,L.jsx)(`div`,{className:`p-4`,children:j?(0,L.jsxs)(`div`,{className:`flex items-center justify-center gap-2.5 text-[15px]`,children:[(0,L.jsx)(A,{size:18,className:`animate-spin text-accent`}),` `,x(`Writing…`)]}):(0,L.jsx)(`button`,{type:`button`,onClick:()=>void ie(),className:`h-[46px] w-full rounded-full bg-accent text-[15px] font-semibold text-accent-fg`,children:x(`Write it now`)})})})]})]}),ae.map(([e,t],n)=>(0,L.jsxs)(`section`,{children:[(0,L.jsx)(hh,{text:mh(e,new Date(t[0].ts))}),n===0&&!k&&(0,L.jsx)(bh,{instructions:u?.instructions??``,onEdit:()=>T(!0),onAck:re}),n===0&&j&&(0,L.jsx)(gh,{children:(0,L.jsxs)(`div`,{className:`flex items-center gap-2.5 p-4 text-[15px]`,children:[(0,L.jsx)(A,{size:18,className:`animate-spin text-accent`}),` `,x(`Writing…`)]})}),t.map(e=>(0,L.jsx)(yh,{post:e,onDiscuss:()=>N(e),onInfo:()=>O(e)},e.id))]},e)),(0,L.jsxs)(`div`,{className:`space-y-4 px-0.5 pt-3`,children:[h&&(0,L.jsx)(wh,{data:h,name:b,onSettings:()=>a(`you`),onGoals:()=>a(`goals`)}),_?.configured&&(0,L.jsx)(Th,{data:_,onAsk:()=>n(`main`)}),c&&c.length===0&&(!u||u.posts.length===0)&&(0,L.jsxs)(`div`,{className:`py-10 text-center text-muted text-[14px] px-6`,children:[(0,L.jsx)(Rt,{size:28,className:`mx-auto mb-2 opacity-60`}),x(`Nothing yet. Once {name} works on a goal in the background or needs your approval, it shows up here.`,{name:b})]}),c&&c.length>0&&(0,L.jsx)(`div`,{className:`px-1 pt-1 text-[12px] font-semibold uppercase tracking-wide text-muted`,children:x(`While you were away`)}),C.map(([t,i])=>(0,L.jsxs)(`section`,{children:[(0,L.jsx)(`div`,{className:`px-1 mb-1.5 text-[12px] uppercase tracking-wide text-muted font-semibold`,children:t}),(0,L.jsx)(`ul`,{className:`space-y-2`,children:i.map(t=>(0,L.jsx)(`li`,{children:(0,L.jsx)(Dh,{item:t,unseen:t.ts>e.feedSeenAt,onOpen:()=>{t.kind===`artifact`&&t.path?r(t.path):n(t.thread)}})},t.id))})]},t))]})]}),(0,L.jsx)(Ch,{open:w,onClose:()=>T(!1),data:u,writing:j,onChange:p,onWriteNow:()=>void ie()}),(0,L.jsx)(Dp,{open:D!==null,onClose:()=>O(null),title:D?.title,children:D&&(0,L.jsxs)(`div`,{className:`space-y-3 px-1 pb-2`,children:[(0,L.jsx)(Sh,{label:x(`Post type`),value:vh(D.area)}),(0,L.jsx)(Sh,{label:x(`Written`),value:new Date(D.ts).toLocaleString(m(),{dateStyle:`medium`,timeStyle:`short`})}),(0,L.jsx)(`button`,{type:`button`,onClick:async()=>{let e=D.id;O(null);try{await M.deleteFeedPost(e),p(t=>t&&{...t,posts:t.posts.filter(t=>t.id!==e)})}catch(e){o(e.message)}},className:`w-full rounded-full bg-surface-2 py-2.5 text-[15px] font-medium text-rose-500`,children:x(`Delete this post`)})]})})]})}var dh=`nanomuse.feed.introAck`;function fh(){let e=new Date;return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`}function ph(e){let t=new Map;for(let n of e){let e=new Date(n.ts),r=`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,`0`)}-${String(e.getDate()).padStart(2,`0`)}`,i=t.get(r);i?i.push(n):t.set(r,[n])}return[...t.entries()]}function mh(e,t){let n=fh(),r=new Date;r.setDate(r.getDate()-1);let i=`${r.getFullYear()}-${String(r.getMonth()+1).padStart(2,`0`)}-${String(r.getDate()).padStart(2,`0`)}`,a=t.toLocaleDateString(m(),{weekday:`long`});if(e===n){let e=new Date().getHours(),t=_(e<12?`morning`:e<18?`afternoon`:`evening`);return _(`{weekday} {part}`,{weekday:a,part:t})}return e===i?_(`Yesterday`):`${a} · ${t.toLocaleDateString(m(),{month:`short`,day:`numeric`})}`}function hh({text:e}){return(0,L.jsx)(`h2`,{className:`px-1.5 pt-3.5 pb-2 text-[22px] font-bold leading-7`,children:e})}function gh({children:e,className:t}){return(0,L.jsx)(`div`,{className:i(`my-1.5 overflow-hidden rounded-[18px] bg-surface dark:border dark:border-border`,t),children:e})}var _h={planning:`🗓️`,research:`🔍`,goals:`🎯`,money:`💰`,health:`🌿`,home:`🏠`,learning:`📚`,people:`👥`,files:`📄`,fun:`🎈`};function vh(e){switch(e){case`planning`:return _(`Planning`);case`research`:return _(`Research`);case`goals`:return _(`Goals`);case`money`:return _(`Money`);case`health`:return _(`Health`);case`home`:return _(`Home`);case`learning`:return _(`Learning`);case`people`:return _(`People`);case`files`:return _(`Files`);case`fun`:return _(`Fun`);default:return _(`Note`)}}function yh({post:e,onDiscuss:t,onInfo:n}){let r=s();return(0,L.jsx)(gh,{children:(0,L.jsxs)(`article`,{className:`pl-3.5 pr-2.5 pt-3.5 pb-1.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,L.jsx)(`div`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-[22px] leading-none`,children:_h[e.area]??`📝`}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`h3`,{className:`text-[16px] font-semibold leading-[22px]`,children:e.title}),(0,L.jsx)(`div`,{className:`md mt-1 text-[15px] leading-[22px] text-fg/85`,children:(0,L.jsx)(mf,{text:e.body})})]})]}),(0,L.jsxs)(`div`,{className:`mt-1.5 flex items-center`,children:[(0,L.jsxs)(`button`,{type:`button`,onClick:t,className:`flex items-center gap-1.5 rounded-full px-2 py-2 text-[14px] hover:bg-surface-2`,children:[(0,L.jsx)(At,{size:22,strokeWidth:1.8}),` `,r(`Discuss`)]}),(0,L.jsx)(`button`,{type:`button`,onClick:n,"aria-label":r(`About this post`),className:`ml-auto rounded-full p-2 hover:bg-surface-2`,children:(0,L.jsx)(xt,{size:22,strokeWidth:1.8})})]})]})})}function bh({instructions:e,onEdit:t,onAck:n}){let r=s();return(0,L.jsxs)(gh,{children:[(0,L.jsxs)(`div`,{className:`px-4 py-3.5`,children:[(0,L.jsx)(`div`,{className:`text-[17px] font-semibold`,children:r(`About the feed`)}),(0,L.jsx)(`p`,{className:`mt-1 text-[13px] leading-[18px] text-muted`,children:r(`Short posts your agent writes for you from what it remembers: your memory files, the last week of diary, your goals. The sentence below steers every post from now on; edit it any time.`)})]}),(0,L.jsxs)(`div`,{className:`border-t border-border/70 px-4 py-3.5`,children:[(0,L.jsx)(`p`,{className:`text-[15px] leading-[22px]`,children:e||r(`Build me a feed about what I care about. Keep it short and direct, easy to skim, no clickbait.`)}),(0,L.jsxs)(`div`,{className:`mt-3.5 flex justify-end gap-2.5`,children:[(0,L.jsx)(`button`,{type:`button`,onClick:t,className:`rounded-full bg-surface-2 px-6 py-2.5 text-[15px] font-medium`,children:r(`Edit`)}),(0,L.jsx)(`button`,{type:`button`,onClick:n,className:`rounded-full bg-accent px-6 py-2.5 text-[15px] font-semibold text-accent-fg`,children:r(`Got it`)})]})]})]})}function xh({emoji:e,title:t,body:n}){return(0,L.jsx)(gh,{children:(0,L.jsxs)(`div`,{className:`flex items-start gap-3 p-3.5`,children:[(0,L.jsx)(`div`,{className:`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-[22px] leading-none`,children:e}),(0,L.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`div`,{className:`text-[16px] font-semibold leading-[22px]`,children:t}),(0,L.jsx)(`p`,{className:`mt-1 text-[15px] leading-[22px] text-fg/85`,children:n})]})]})})}function Sh({label:e,value:t}){return(0,L.jsxs)(`div`,{className:`flex items-start gap-3 text-[14.5px]`,children:[(0,L.jsx)(`span`,{className:`w-14 shrink-0 text-muted`,children:e}),(0,L.jsx)(`span`,{className:`min-w-0 flex-1 break-words`,children:t})]})}function Ch({open:e,onClose:t,data:n,writing:r,onChange:i,onWriteNow:a}){let{toast:o,state:c}=E(),l=s(),[u,d]=(0,I.useState)(``),[f,p]=(0,I.useState)(!1);(0,I.useEffect)(()=>{e&&d(n?.instructions??``)},[e,n]);let m=async()=>{p(!0);try{i(await M.setFeedInstructions({instructions:u})),t()}catch(e){o(e.message)}finally{p(!1)}},h=async e=>{try{i(await M.setFeedInstructions(e))}catch(e){o(e.message)}},g=c.settings?.llm_ready!==!1;return(0,L.jsx)(Dp,{open:e,onClose:t,title:l(`Feed`),children:(0,L.jsxs)(`div`,{className:`space-y-3 px-1 pb-2`,children:[(0,L.jsx)(`p`,{className:`text-[13px] leading-[18px] text-muted`,children:l(`Your feed is driven by the instruction below. Any edit you make here applies to every post from now on.`)}),(0,L.jsx)(`textarea`,{value:u,onChange:e=>d(e.target.value),rows:4,maxLength:2e3,placeholder:l(`Build me a feed about what I care about. Keep it short and direct, easy to skim, no clickbait.`),className:`w-full resize-none rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[15px] leading-snug outline-none focus:ring-2 focus:ring-accent/40`}),(0,L.jsxs)(`div`,{className:`space-y-2 rounded-2xl bg-surface-2 px-3.5 py-2.5`,children:[(0,L.jsx)(Pe,{label:l(`Write it every day`),hint:n?.daily===!1?void 0:l(`Daily at {time}`,{time:n?.time??`08:00`}),checked:n?.daily!==!1,onChange:e=>void h({daily:e}),disabled:!n}),n?.daily!==!1&&(0,L.jsxs)(`label`,{className:`flex items-center justify-between gap-3 text-[13px] text-muted`,children:[(0,L.jsx)(`span`,{children:l(`Time of day`)}),(0,L.jsx)(`input`,{type:`time`,value:n?.time??`08:00`,onChange:e=>e.target.value&&void h({time:e.target.value}),className:`rounded-lg bg-surface px-2 py-1 text-[13px] text-fg outline-none focus:ring-2 focus:ring-accent/40`})]}),!g&&(0,L.jsx)(`div`,{className:`text-[12.5px] text-muted`,children:l(`Add a model first; the feed is written by your agent.`)})]}),(0,L.jsxs)(`button`,{type:`button`,onClick:a,disabled:r||!n||!g,className:`flex w-full items-center justify-center gap-2 rounded-full bg-surface-2 py-2.5 text-[15px] font-medium disabled:opacity-60`,children:[r?(0,L.jsx)(A,{size:16,className:`animate-spin`}):(0,L.jsx)(nn,{size:16,className:`text-accent`}),l(r?`Writing…`:`Write it now`)]}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>void m(),disabled:f,className:`h-[46px] w-full rounded-full bg-accent text-[15px] font-semibold text-accent-fg disabled:opacity-60`,children:l(`Save`)})]})})}function wh({data:t,name:n,onSettings:r,onGoals:i}){let a=s(),o=t.queue[0],c=t.reminders.filter(e=>e.status===`active`&&!!e.next_at),l=c[0],d=c.length;return(0,L.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface shadow-sm px-4 py-3.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] uppercase tracking-wide text-muted font-semibold`,children:[(0,L.jsx)(Xe,{size:13}),` `,a(`Next up`)]}),t.proactive?o?(0,L.jsxs)(`div`,{className:`mt-1.5 text-[14px] leading-snug`,children:[t.busy?a(`Working now`):t.quiet_until?a(`Quiet hours after {time}`,{time:u(t.quiet_until)}):t.next_pass_at?a(`Around {time}`,{time:u(t.next_pass_at)}):a(`Soon`),`: `,(0,L.jsx)(`span`,{className:`font-medium`,children:o.title}),o.next_step&&(0,L.jsxs)(`span`,{className:`text-muted`,children:[` · `,o.next_step]}),o.overdue&&(0,L.jsxs)(`span`,{className:`text-rose-500 font-medium`,children:[` · `,a(`overdue`)]}),t.queue.length>1&&(0,L.jsxs)(`span`,{className:`text-muted`,children:[` · `,a(`{n} more in line`,{n:t.queue.length-1})]})]}):(0,L.jsxs)(`div`,{className:`mt-1.5 text-[14px] leading-snug`,children:[a(`No goal has a next step to work on.`),` `,(0,L.jsx)(`button`,{type:`button`,onClick:i,className:`text-accent font-medium`,children:a(`Add one`)})]}):(0,L.jsxs)(`div`,{className:`mt-1.5 text-[14px] leading-snug`,children:[a(`Background work is off. {name} only acts when you ask.`,{name:n}),` `,(0,L.jsx)(`button`,{type:`button`,onClick:r,className:`text-accent font-medium`,children:a(`Turn it on`)})]}),t.check_ins[0]&&(0,L.jsxs)(`div`,{className:`mt-1.5 text-[12.5px] text-muted`,children:[a(`Check-in on`),` `,(0,L.jsx)(`span`,{className:`font-medium text-fg`,children:t.check_ins[0].title}),` `,e(t.check_ins[0].at),t.check_ins.length>1&&` · ${a(`{n} more`,{n:t.check_ins.length-1})}`]}),l&&(0,L.jsxs)(`div`,{className:`mt-1.5 text-[12.5px] text-muted`,children:[l.kind===`task`?a(`Routine`):a(`Reminder`),` `,(0,L.jsx)(`span`,{className:`font-medium text-fg`,children:l.text}),` `,e(l.next_at),d>1&&` · ${a(`{n} more`,{n:d-1})}`]})]})}function Th({data:e,onAsk:t}){let n=s(),r=t=>t.start.slice(0,10)<=e.today,a=e.events.filter(r),o=e.events.filter(e=>!r(e)),c=new Date,l=e=>!e.all_day&&new Date(e.end)e.error),d=a.every(l)&&o.length>0,f=d?o:a;return(0,L.jsxs)(`div`,{className:`rounded-3xl border border-border/70 bg-surface shadow-sm px-4 py-3.5`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2 text-[12px] uppercase tracking-wide text-muted font-semibold`,children:[(0,L.jsx)(tt,{size:13}),` `,n(d?`Tomorrow`:`Today`),f.length>0&&(0,L.jsx)(`span`,{className:`ml-auto normal-case tracking-normal font-normal`,children:n(`{n} scheduled`,{n:f.length})})]}),f.length===0?(0,L.jsx)(`div`,{className:`mt-1.5 text-[14px] leading-snug text-muted`,children:n(`Nothing on the calendar today.`)}):(0,L.jsx)(`ul`,{className:`mt-1.5 space-y-1`,children:f.map(e=>(0,L.jsxs)(`li`,{className:i(`flex items-baseline gap-2.5 text-[14px] leading-snug`,!d&&l(e)&&`opacity-50`),children:[(0,L.jsx)(`span`,{className:`w-[86px] shrink-0 tabular-nums text-[12.5px] text-muted`,children:e.all_day?n(`all day`):`${e.start.slice(11,16)}–${e.end.slice(11,16)}`}),(0,L.jsxs)(`span`,{className:`min-w-0 flex-1`,children:[(0,L.jsx)(`span`,{className:`font-medium`,children:e.summary}),e.location&&(0,L.jsxs)(`span`,{className:`text-muted text-[12.5px]`,children:[` `,(0,L.jsx)(Dt,{size:11,className:`inline -mt-0.5`}),` `,e.location]})]})]},`${e.uid}-${e.start}`))}),u.length>0&&(0,L.jsx)(`div`,{className:`mt-1.5 text-[12px] text-rose-500`,children:n(`{name} could not be read`,{name:u.map(e=>e.name).join(`, `)})}),(0,L.jsx)(`button`,{type:`button`,onClick:t,className:`mt-2 text-[12.5px] text-accent font-medium`,children:n(`Ask about your week`)})]})}function Eh(e){return e.replace(/```[\s\S]*?```/g,` `).replace(/^#{1,6}\s+/gm,``).replace(/(\*\*|__|`)/g,``).replace(/^\s*[-*+]\s+/gm,`• `).replace(/\[([^\]]+)\]\([^)]*\)/g,`$1`).trim()}function Dh({item:n,unseen:r,onOpen:a}){let o=s(),c=n.kind===`approval`?(0,L.jsx)(Zt,{size:18}):n.kind===`question`?(0,L.jsx)(kt,{size:18}):n.kind===`artifact`?(0,L.jsx)(mt,{size:18}):n.quiet?(0,L.jsx)(Rt,{size:18}):n.title.startsWith(`New mail: `)?(0,L.jsx)(Et,{size:18}):n.title.startsWith(`Coming up: `)?(0,L.jsx)(et,{size:18}):n.title.startsWith(`Webhook: `)?(0,L.jsx)(_n,{size:18}):(0,L.jsx)(nn,{size:18}),l=n.kind===`approval`?`bg-amber-500/12 text-amber-600 dark:text-amber-300`:n.kind===`question`?`bg-accent/12 text-accent`:`bg-surface-2 text-muted`;return n.quiet?(0,L.jsxs)(`button`,{type:`button`,onClick:a,className:`w-full text-left px-2 py-1.5 flex items-center gap-2 text-[12.5px] text-muted`,children:[(0,L.jsx)(Rt,{size:13,className:`shrink-0`}),(0,L.jsxs)(`span`,{className:`truncate`,children:[o(`{label}: nothing new`,{label:t(n.title.replace(/^Working on your goal: /,``))}),n.text?`: ${n.text}`:``]}),(0,L.jsx)(`span`,{className:`ml-auto shrink-0`,children:e(n.ts)})]}):(0,L.jsx)(`button`,{type:`button`,onClick:a,className:i(`w-full text-left rounded-3xl border bg-surface shadow-sm px-4 py-3 active:scale-[0.99] transition`,r?`border-accent/40`:`border-border/70`),children:(0,L.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,L.jsx)(`div`,{className:i(`rounded-2xl p-2 mt-0.5`,l),children:c}),(0,L.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,L.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,L.jsx)(`div`,{className:`font-semibold text-[14.5px] leading-snug truncate`,children:t(n.title)}),r&&(0,L.jsx)(`span`,{className:`h-2 w-2 rounded-full bg-accent shrink-0`})]}),n.text&&(0,L.jsx)(`div`,{className:`mt-0.5 text-[13.5px] text-muted leading-snug line-clamp-3 whitespace-pre-wrap`,children:Eh(n.text)}),(0,L.jsxs)(`div`,{className:`mt-1.5 flex items-center gap-2 text-[12px] text-muted`,children:[(0,L.jsx)(`span`,{children:e(n.ts)}),(0,L.jsx)(`span`,{children:`·`}),(0,L.jsx)(`span`,{className:`truncate`,children:n.thread===`main`?o(n.thread_title):n.thread_title}),(0,L.jsxs)(`span`,{className:`ml-auto flex items-center gap-1 text-accent font-medium`,children:[n.kind===`artifact`?o(`Open`):n.kind===`approval`||n.kind===`question`?o(`Answer`):o(`Open chat`),(0,L.jsx)(qe,{size:13})]})]})]})]})})}function Oh(e){let t=new Date,n=new Date(t);n.setDate(t.getDate()-1);let r=e=>{let r=new Date(e);return r.toDateString()===t.toDateString()?_(`Today`):r.toDateString()===n.toDateString()?_(`Yesterday`):r.toLocaleDateString(m(),{weekday:`long`,month:`short`,day:`numeric`})},i=new Map;for(let t of e){let e=r(t.ts);i.set(e,[...i.get(e)??[],t])}return[...i.entries()]}function kh({children:e,className:t}){return(0,L.jsx)(`div`,{className:i(`mx-4 overflow-hidden rounded-2xl bg-surface dark:border dark:border-border`,t),children:e})}function Ah({inset:e=52}){return(0,L.jsx)(`div`,{className:i(`border-t border-border/60`,e===52?`ml-[52px]`:`ml-4`)})}function jh({icon:e,label:t,value:n,onClick:r,external:a=!1,trailing:o,chevron:s=!0}){let c=(0,L.jsxs)(L.Fragment,{children:[e&&(0,L.jsx)(`span`,{className:`shrink-0 text-fg`,children:e}),(0,L.jsx)(`span`,{className:i(`min-w-0 truncate text-[16px] leading-[21px]`,n?`max-w-[60%] shrink-0`:`flex-1`),children:t}),n&&(0,L.jsx)(`span`,{className:`min-w-0 flex-1 truncate pl-3 text-right text-[14px] leading-[18px] text-muted`,children:n}),o??(s?a?(0,L.jsx)(Ae,{size:16,className:`shrink-0 text-muted/70`}):(0,L.jsx)(rt,{size:18,className:`shrink-0 text-muted/70`}):null)]}),l=`flex min-h-[54px] w-full items-center gap-3.5 px-4 py-2 text-left`;return r?(0,L.jsx)(`button`,{type:`button`,onClick:r,className:i(l,`hover:bg-surface-2/60`),children:c}):(0,L.jsx)(`div`,{className:l,children:c})}function Mh({icon:e,label:t,value:n,checked:r,disabled:a=!1,onChange:o}){return(0,L.jsxs)(`button`,{type:`button`,role:`switch`,"aria-checked":r,disabled:a,onClick:()=>o(!r),className:i(`flex min-h-[54px] w-full items-center gap-3.5 px-4 py-2 text-left`,a?`opacity-60`:`hover:bg-surface-2/60`),children:[e&&(0,L.jsx)(`span`,{className:`shrink-0 text-fg`,children:e}),(0,L.jsx)(`span`,{className:i(`min-w-0 truncate text-[16px] leading-[21px]`,n?`max-w-[60%] shrink-0`:`flex-1`),children:t}),n&&(0,L.jsx)(`span`,{className:`min-w-0 flex-1 truncate pl-3 text-right text-[14px] leading-[18px] text-muted`,children:n}),(0,L.jsx)(Nh,{checked:r})]})}function Nh({checked:e}){return(0,L.jsx)(`span`,{"aria-hidden":!0,className:i(`relative h-7 w-12 shrink-0 rounded-full transition`,e?`bg-accent`:`bg-surface-2 border border-border`),children:(0,L.jsx)(`span`,{className:i(`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition`,e?`left-[22px]`:`left-0.5`)})})}function Ph({children:e,className:t}){return(0,L.jsx)(`p`,{className:i(`px-8 py-2 text-[13px] leading-[18px] text-muted`,t),children:e})}function Fh({children:e}){return(0,L.jsx)(`p`,{className:`px-8 pb-2 pt-[18px] text-[13px] leading-[18px] text-muted`,children:e})}function Ih({me:e,onChanged:t,flush:n=!1}){let r=s(),{state:a,toast:o}=E(),c=!!a.hub?.account.signed_in,[l,u]=(0,I.useState)(null),[d,f]=(0,I.useState)(!1),p=e??l,m=async()=>{if(c&&!e)try{u(await M.cloudMe())}catch{}};(0,I.useEffect)(()=>{m()},[c,e]);let h=p?.contribute??{on:!1,samples:0},g=h.samples??0,_=h.privacy_url||``,v=async n=>{f(!0);try{await M.cloudContribute(n),o(r(n?`On. Turn it off here at any time.`:`Off. Nothing more is kept.`)),e?t?.():await m()}catch(e){o(e.message)}finally{f(!1)}},y=async()=>{if(window.confirm(r(`The kept conversations are removed from the server. This cannot be undone.`))){f(!0);try{let n=await M.cloudDeleteSamples();o(r(`{n} turns deleted`,{n:String(n.deleted)})),e?t?.():await m()}catch(e){o(e.message)}finally{f(!1)}}};return(0,L.jsxs)(`div`,{className:i(n&&`-mx-4`),children:[(0,L.jsx)(Lh,{signedIn:c}),(0,L.jsx)(kh,{children:(0,L.jsx)(Mh,{label:r(`Help improve nanoMuse's AI models`),checked:c&&h.on,disabled:d||!c||!p,onChange:e=>void v(e)})}),(0,L.jsxs)(Ph,{children:[r(`While this is on, the text of your chats with the nanoMuse Cloud models (what you wrote, what it answered and the tools it chose to call) is kept on the relay to train the community's own open model. Not your memory or SOUL (the system prompt), not what tools returned, not pictures, and never next to who you are. Your own API key never passes through the relay.`),` `,c?h.default_on!==void 0&&(h.default_on?r(`New accounts start with it on.`):r(`New accounts start with it off.`)):r(`Sign in to nanoMuse Cloud to use it.`),` `,c&&g>0&&r(`{n} turns kept so far.`,{n:String(g)}),` `,_?(0,L.jsx)(`a`,{href:_,target:`_blank`,rel:`noopener noreferrer`,className:`text-accent underline-offset-2 hover:underline`,children:r(`Privacy policy`)}):null]}),c&&g>0&&(0,L.jsxs)(kh,{className:`mt-3`,children:[(0,L.jsx)(jh,{icon:d?(0,L.jsx)(A,{size:22,className:`animate-spin`}):(0,L.jsx)(fn,{size:22}),label:r(`Delete the kept conversations`),value:r(`{n} turns`,{n:String(g)}),onClick:()=>void y(),chevron:!1}),(0,L.jsx)(Ah,{inset:16}),(0,L.jsx)(Ph,{className:`px-4 pb-2.5 pt-2`,children:r(`Removes every turn kept from this account, whether the switch is on or off now. Turning the switch off keeps what was kept until you delete it here.`)})]})]})}function Lh({signedIn:e}){let t=s(),{toast:n}=E(),[r,i]=(0,I.useState)(null),[a,o]=(0,I.useState)(!1),c=r?.relay?.counts??{conversations:0,messages:0},l=e&&(c.conversations>0||c.messages>0),u=async()=>{try{i(await M.syncState())}catch{}};(0,I.useEffect)(()=>{u()},[e]);let d=async(e,t)=>{o(!0);try{i(await e()),n(t)}catch(e){n(e.message),await u()}finally{o(!1)}},f=e=>{(e||!l||window.confirm(t(`Off deletes the synced conversations from nanoMuse Cloud. The chats on each device stay.`)))&&d(()=>M.syncSetState(e),t(e?`On. Your devices show the same conversations from now on.`:`Off. Nothing is kept on nanoMuse Cloud any more.`))},p=()=>{window.confirm(t(`The synced conversations are removed from nanoMuse Cloud. The chats on each device stay. This cannot be undone.`))&&d(()=>M.syncDelete(),t(`Synced conversations deleted.`))},m=e=>{d(()=>M.syncSetSideChats(e),t(e?`On. This device's side chats go to the account; the other devices' are on their way.`:`Off. Side chats stay on this device from now on.`))},h=e&&!!r?.enabled;return(0,L.jsxs)(`div`,{className:`mb-6`,children:[(0,L.jsx)(kh,{children:(0,L.jsx)(Mh,{label:t(`Sync conversations between my devices`),checked:h,disabled:a||!e||!r,onChange:f})}),(0,L.jsxs)(Ph,{children:[t(`The text of your chats is kept on nanoMuse Cloud so every device shows the same conversations. Files and images stay on the device they were made on.`),` `,e?r?.paused?t(`Paused: sign in again to continue.`):l&&t(`{c} chats, {m} messages kept so far.`,{c:String(c.conversations),m:String(c.messages)}):t(`Sign in to nanoMuse Cloud to use it.`)]}),r&&`side_chats`in r&&(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(kh,{className:`mt-3`,children:(0,L.jsx)(Mh,{label:t(`Also sync side chats`),checked:h&&!!r.side_chats,disabled:a||!h,onChange:m})}),(0,L.jsx)(Ph,{children:t(`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.`)})]}),l&&(0,L.jsxs)(kh,{className:`mt-3`,children:[(0,L.jsx)(jh,{icon:a?(0,L.jsx)(A,{size:22,className:`animate-spin`}):(0,L.jsx)(fn,{size:22}),label:t(`Delete synced conversations`),value:t(`{n} chats`,{n:String(c.conversations)}),onClick:p,chevron:!1}),(0,L.jsx)(Ah,{inset:16}),(0,L.jsx)(Ph,{className:`px-4 pb-2.5 pt-2`,children:t(`Removes what nanoMuse Cloud stores for this account; the chats on each device stay, and the switch stays on. Deleting a chat on one device deletes it on all of them.`)})]})]})}var Rh=`nm.invite`;function zh(){try{let e=new URLSearchParams(window.location.search).get(`invite`);if(e){let t=Bh(e);t&&localStorage.setItem(Rh,t)}return localStorage.getItem(Rh)??``}catch{return``}}function Bh(e){return e.toUpperCase().replace(/[^A-Z0-9]/g,``).slice(0,8)}function Vh(e){return/^\s*[+\d]/.test(e)?`tel`:`email`}function Hh({onSignedIn:e,useAsModel:t=!1,cta:n,autoFocus:r=!0}){let a=s(),o=(0,I.useId)(),c=Ce(),[l,u]=(0,I.useState)(`code`),[d,f]=(0,I.useState)(``),[p,m]=(0,I.useState)(``),[h,g]=(0,I.useState)(``),[_,v]=(0,I.useState)(!1),[y,b]=(0,I.useState)(null),[x,S]=(0,I.useState)(null),[C,w]=(0,I.useState)(``),[T,E]=(0,I.useState)(!1);(0,I.useEffect)(()=>{let e=zh();e&&(w(e),E(!0))},[]);let D=async()=>{if(t){b(`model`);try{await M.cloudUseAsModel()}catch{}}await e()},O=async()=>{let e=d.trim();if(e){b(`code`),S(null);try{await M.cloudCode(e),v(!0)}catch(e){S(a(e.message))}finally{b(null)}}},k=async()=>{let e=d.trim();if(!(!e||p.trim().length<4)){b(`verify`),S(null);try{(await M.cloudVerify(e,p.trim(),C.trim())).created&&Bf(),m(``),v(!1);try{localStorage.removeItem(Rh)}catch{}await D()}catch(e){S(a(e.message))}finally{b(null)}}},ee=async()=>{let e=d.trim();if(e&&h){b(`login`),S(null);try{await M.cloudLogin(e,h),g(``),await D()}catch(e){S(a(e.message))}finally{b(null)}}},te=()=>void(l===`password`?ee():_?k():O()),j=n??a(t?`Sign in and use its model`:`Sign in`);return(0,L.jsxs)(`form`,{className:`space-y-3`,onSubmit:e=>{e.preventDefault(),te()},children:[(0,L.jsxs)(`div`,{className:`grid grid-cols-2 gap-1 rounded-2xl bg-surface-2/70 p-1 text-[13px] font-medium`,children:[(0,L.jsx)(Uh,{active:l===`code`,onClick:()=>u(`code`),icon:(0,L.jsx)(Mt,{size:14}),label:a(`With a code`)}),(0,L.jsx)(Uh,{active:l===`password`,onClick:()=>u(`password`),icon:(0,L.jsx)(P,{size:14}),label:a(`With a password`)})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`label`,{htmlFor:`${o}-id`,className:`text-[12px] text-muted`,children:ue()?a(`Mainland China phone number or e-mail`):a(`E-mail (or a mainland China phone number)`)}),(0,L.jsx)(`input`,{id:`${o}-id`,value:d,onChange:e=>{f(e.target.value),v(!1)},inputMode:Vh(d),autoComplete:`username`,autoFocus:r,placeholder:a(`138 0000 0000 or you@example.com`),className:i(Ie,`mt-1`)}),l===`code`&&!_&&(!ue()||oe(d))&&(0,L.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`Text-message codes reach mainland-China numbers only. Use an e-mail address instead.`)})]}),l===`password`?(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`label`,{htmlFor:`${o}-password`,className:`text-[12px] text-muted`,children:a(`Password`)}),(0,L.jsx)(`input`,{id:`${o}-password`,type:`password`,value:h,onChange:e=>g(e.target.value),autoComplete:`current-password`,className:i(Ie,`mt-1`)}),(0,L.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`No password yet? Use a code first; set one under Account.`)})]}):(0,L.jsxs)(L.Fragment,{children:[_&&(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`label`,{htmlFor:`${o}-code`,className:`text-[12px] text-muted`,children:a(`The code you received`)}),(0,L.jsx)(`input`,{id:`${o}-code`,value:p,onChange:e=>m(e.target.value.replace(/\D/g,``).slice(0,8)),inputMode:`numeric`,autoComplete:`one-time-code`,placeholder:`123456`,autoFocus:!0,className:i(`w-full rounded-2xl bg-surface-2 px-3.5 py-2.5 text-[14.5px] outline-none focus:ring-2 focus:ring-accent/40`,`mt-1 tracking-[0.3em]`)})]}),T?(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`label`,{htmlFor:`${o}-invite`,className:`text-[12px] text-muted`,children:a(`Invite code (optional)`)}),(0,L.jsx)(`input`,{id:`${o}-invite`,value:C,onChange:e=>w(Bh(e.target.value)),autoComplete:`off`,placeholder:`ABCD2345`,className:i(Ie,`mt-1 tracking-[0.2em] uppercase`)}),(0,L.jsx)(`p`,{className:`mt-1.5 text-[12px] text-muted`,children:a(`A friend's code counts for a new account: you both get ¥{invite} more allowance.`,{invite:(c.invite_bonus_cny??5).toFixed(0)})})]}):(0,L.jsx)(`button`,{type:`button`,onClick:()=>E(!0),className:`text-[12px] text-muted underline-offset-2 hover:underline`,children:a(`Have an invite code?`)})]}),x&&(0,L.jsx)(`div`,{className:`rounded-2xl bg-rose-500/12 px-3 py-2 text-[12.5px] text-rose-700 dark:text-rose-300`,children:x}),(0,L.jsx)(`div`,{className:`flex gap-2`,children:l===`code`&&_?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`button`,{type:`button`,disabled:y!==null,onClick:()=>void O(),className:je,children:a(`Send again`)}),(0,L.jsxs)(`button`,{type:`submit`,disabled:y!==null||p.trim().length<4,className:i(Ne,`flex-1 py-3`),children:[y?(0,L.jsx)(A,{size:15,className:`animate-spin`}):null,` `,j]})]}):l===`code`?(0,L.jsxs)(`button`,{type:`submit`,disabled:y!==null||!d.trim(),className:i(Ne,`flex-1 py-3`),children:[y===`code`?(0,L.jsx)(A,{size:15,className:`animate-spin`}):null,` `,a(`Send me a code`)]}):(0,L.jsxs)(`button`,{type:`submit`,disabled:y!==null||!d.trim()||!h,className:i(Ne,`flex-1 py-3`),children:[y?(0,L.jsx)(A,{size:15,className:`animate-spin`}):null,` `,j]})})]})}function Uh({active:e,onClick:t,icon:n,label:r}){return(0,L.jsxs)(`button`,{type:`button`,onClick:t,"aria-pressed":e,className:i(`flex items-center justify-center gap-1.5 rounded-xl px-2 py-2 transition`,e?`bg-surface text-fg shadow-sm`:`text-muted hover:text-fg`),children:[n,` `,r]})}function Wh(){let{state:e,refreshSettings:t,refreshHub:n,toast:r,skipSignIn:i}=E(),a=s(),o=e.profile?.name??`nanoMuse`,c=(0,L.jsxs)(`ul`,{className:`grid grid-cols-2 gap-2 text-[12.5px] wide:grid-cols-1 wide:gap-1.5 wide:text-[13.5px]`,children:[(0,L.jsx)(Gh,{icon:(0,L.jsx)(It,{size:15}),text:a(`Every device of yours, one nanoMuse`)}),(0,L.jsx)(Gh,{icon:(0,L.jsx)(st,{size:15}),text:a(`A model with a free allowance, or your own key`)}),(0,L.jsx)(Gh,{icon:(0,L.jsx)(vt,{size:15}),text:a(`It uses your phone and computers for you`)}),(0,L.jsx)(Gh,{icon:(0,L.jsx)(Qt,{size:15}),text:a(`Keys in your vault; nothing you say is kept on the relay`)})]});return(0,L.jsxs)(`div`,{className:`relative mx-auto flex h-[100dvh] w-full max-w-[760px] flex-col overflow-hidden bg-bg sm:border-x sm:border-border wide:max-w-none wide:border-0`,children:[(0,L.jsx)(`div`,{className:`pointer-events-none absolute inset-x-0 top-0 h-[46vh] bg-[radial-gradient(ellipse_at_top,rgba(0,100,212,0.14),transparent_65%)] dark:bg-[radial-gradient(ellipse_at_top,rgba(23,147,255,0.2),transparent_65%)] wide:inset-y-0 wide:h-auto wide:bg-[radial-gradient(ellipse_at_left,rgba(0,100,212,0.12),transparent_60%)] wide:dark:bg-[radial-gradient(ellipse_at_left,rgba(23,147,255,0.16),transparent_60%)]`}),(0,L.jsx)(`div`,{className:`titlebar-room relative`}),(0,L.jsx)(`div`,{className:`relative flex-1 overflow-y-auto px-6 pb-8 wide:flex wide:items-center wide:px-12`,children:(0,L.jsxs)(`div`,{className:`mx-auto w-full wide:grid wide:max-w-[980px] wide:grid-cols-[1.1fr_1fr] wide:items-center wide:gap-16`,children:[(0,L.jsxs)(`div`,{className:`safe-top flex flex-col items-center pt-14 text-center wide:items-start wide:pt-0 wide:text-left`,children:[(0,L.jsx)(xn,{size:88,className:`drop-shadow-[0_20px_40px_rgba(0,100,212,0.28)]`}),(0,L.jsx)(`h1`,{className:`mt-6 text-[28px] font-bold tracking-tight wide:text-[34px]`,children:a(`Sign in to {name}`,{name:o})}),(0,L.jsx)(`p`,{className:`mt-2 max-w-sm text-[14.5px] leading-relaxed text-muted wide:max-w-md wide:text-[15.5px]`,children:a(`One free account. It is what lets your phone and computers work as one and brings a model to start with.`)}),(0,L.jsx)(`div`,{className:`mt-7 hidden w-full wide:block`,children:c}),(0,L.jsx)(Te,{compact:!0,className:`mt-6 hidden w-full wide:block`})]}),(0,L.jsxs)(`div`,{children:[(0,L.jsx)(`div`,{className:`mt-8 rounded-[28px] border border-border/70 bg-surface p-5 shadow-sm wide:mt-0 wide:p-7`,children:(0,L.jsx)(Hh,{onSignedIn:async()=>{r(a(`Signed in to nanoMuse Cloud.`)),await Promise.all([t(),n()])}})}),(0,L.jsx)(`button`,{type:`button`,onClick:i,className:`mt-4 w-full rounded-2xl border border-border/70 px-4 py-2.5 text-[13.5px] text-fg/80 hover:bg-surface-2`,"data-testid":`sign-in-skip`,children:a(`Use your own API key instead`)}),(0,L.jsx)(`p`,{className:`mt-2 text-center text-[11.5px] leading-relaxed text-muted`,children:a(`You can sign in later under Connections, for sync and your devices.`)}),(0,L.jsx)(`div`,{className:`mt-6 wide:hidden`,children:c}),(0,L.jsx)(Te,{compact:!0,className:`mt-6 wide:hidden`}),(0,L.jsxs)(`p`,{className:`mt-4 text-center text-[11.5px] leading-relaxed text-muted wide:mt-5`,children:[a(`The relay keeps an account id, a masked identifier, usage counts and your agent's name and look; what else, and what is yours to switch off, is in the`),` `,a(`relay privacy policy (ask your relay operator)`),`.`]})]})]})})]})}function Gh({icon:e,text:t}){return(0,L.jsxs)(`li`,{className:`flex items-start gap-2 rounded-2xl bg-surface-2/60 px-3 py-2.5 text-fg/80 wide:bg-transparent wide:px-0 wide:py-1`,children:[(0,L.jsx)(`span`,{className:`mt-0.5 text-accent`,children:e}),(0,L.jsx)(`span`,{className:`leading-snug`,children:t})]})}function Kh(e,t,n){return!e||!e.account.required||e.account.signed_in||t?.llm_ready?!1:!n}var qh=`modulepreload`,Jh=function(e){return`/`+e},Yh={},Xh=function(e){return e.pathname.endsWith(`.css`)},Zh=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e,i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function s(e){return import.meta.resolve?new URL(import.meta.resolve(e)):new URL(e,import.meta.url)}r=o(t.map(t=>{t=Jh(t,n);let r=s(t);if(r.href in Yh)return;Yh[r.href]=!0;let i=Xh(r);if(e===void 0){e={all:new Set,styles:new Set};let t=document.getElementsByTagName(`link`);for(let n=t.length-1;n>=0;n--){let r=t[n];e.all.add(r.href),r.rel===`stylesheet`&&e.styles.add(r.href)}}if((i?e.styles:e.all).has(r.href))return;let o=document.createElement(`link`);if(o.rel=i?`stylesheet`:qh,i||(o.as=`script`),o.crossOrigin=``,o.href=r.href,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),i)return new Promise((e,t)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>t(Error(`Unable to preload CSS for ${r}`)))})}).filter(e=>e!==void 0))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},Qh=(0,I.lazy)(()=>Zh(()=>import(`./AccountScreen-Bws27b-C.js`).then(e=>({default:e.AccountScreen})),__vite__mapDeps([0,1,2,3,4,5,6,7,8]))),$h=(0,I.lazy)(()=>Zh(()=>import(`./AvatarStudioScreen-Dgqo0TEO.js`).then(e=>({default:e.AvatarStudioScreen})),__vite__mapDeps([9,1,2,3,4,10,8,11]))),eg=(0,I.lazy)(()=>Zh(()=>import(`./ChannelsScreen-D_7Ynsew.js`).then(e=>({default:e.ChannelsScreen})),__vite__mapDeps([12,1,3,4,10,8]))),tg=(0,I.lazy)(()=>Zh(()=>import(`./CodingScreen-BLVF-Er8.js`).then(e=>({default:e.CodingScreen})),__vite__mapDeps([13,1,4,10,8]))),ng=(0,I.lazy)(()=>Zh(()=>import(`./ConnectionsScreen-CuIVN07d.js`).then(e=>({default:e.ConnectionsScreen})),__vite__mapDeps([14,1,2,3,4,5,7,15,10,8,16]))),rg=(0,I.lazy)(()=>Zh(()=>import(`./DevicesScreen-BkPa89LS.js`).then(e=>({default:e.DevicesScreen})),__vite__mapDeps([17,1,3,4,10,8,16]))),ig=(0,I.lazy)(()=>Zh(()=>import(`./GoalsScreen-yUUdUKV2.js`).then(e=>({default:e.GoalsScreen})),__vite__mapDeps([18,1,2,3,4]))),ag=(0,I.lazy)(()=>Zh(()=>import(`./IdeasScreen-CmecfnoP.js`).then(e=>({default:e.IdeasScreen})),__vite__mapDeps([19,1,10]))),og=(0,I.lazy)(()=>Zh(()=>import(`./LibraryScreen-BoofKOos.js`).then(e=>({default:e.LibraryScreen})),__vite__mapDeps([20,1]))),sg=(0,I.lazy)(()=>Zh(()=>import(`./MemoryScreen-DHNxjNpq.js`).then(e=>({default:e.MemoryScreen})),__vite__mapDeps([21,1,8]))),cg=(0,I.lazy)(()=>Zh(()=>import(`./Onboarding-DBBDNzyW.js`).then(e=>({default:e.Onboarding})),__vite__mapDeps([22,1,2,3,4,23,11,14,5,7,15,10,8,16]))),lg=(0,I.lazy)(()=>Zh(()=>import(`./SettingsScreen-B84xmoRg.js`).then(e=>({default:e.SettingsScreen})),__vite__mapDeps([24,1,2,3,4,5,6,15,23,11,8]))),ug=(0,I.lazy)(()=>Zh(()=>import(`./SkillsScreen-DsMpJe1f.js`).then(e=>({default:e.SkillsScreen})),__vite__mapDeps([25,1,8])));function dg(){return(0,L.jsx)(`div`,{className:`flex h-full min-h-[40vh] items-center justify-center text-muted`,children:(0,L.jsx)(A,{className:`animate-spin`,size:20})})}var fg=e=>({size:26,strokeWidth:e?2:1.7,fill:e?`currentColor`:`none`,fillOpacity:e?.18:0}),pg=[{id:`chat`,label:`Chat`,icon:e=>(0,L.jsx)(At,{...fg(e)})},{id:`feed`,label:`Feed`,icon:e=>(0,L.jsx)(Bt,{...fg(e)})},{id:`ideas`,label:`Ideas`,icon:e=>(0,L.jsx)(Ct,{...fg(e)})},{id:`goals`,label:`Goals`,icon:e=>(0,L.jsx)(rn,{...fg(e)})},{id:`library`,label:`Library`,icon:e=>(0,L.jsx)(St,{...fg(e)})}];function mg(){let{state:e,setTab:t,openFile:n}=E(),r=Uf(),a=s();(0,I.useEffect)(()=>{let t=e.profile?.name?.trim();document.title=t&&t.toLowerCase()!==`nanomuse`?`${t} · nanoMuse`:`nanoMuse`},[e.profile?.name]),(0,I.useEffect)(()=>{let e=Sn()?.onQuickChat?.(()=>{t(`chat`),requestAnimationFrame(()=>window.dispatchEvent(new Event(`nanomuse:quick-chat`)))});return()=>{typeof e==`function`&&e()}},[t]),(0,I.useEffect)(()=>{let e=()=>{let e=window.location.hash.slice(1);(e&&pg.some(t=>t.id===e)||e===`devices`||e===`you`||e===`memory`||e===`connections`||e===`channels`||e===`skills`||e===`account`||e===`coding`||e===`avatar`)&&t(e)};return e(),window.addEventListener(`hashchange`,e),()=>window.removeEventListener(`hashchange`,e)},[t]);let o=r&&e.hub?.account.signed_in?(0,L.jsx)(Wf,{}):null,c=e.lite&&e.settings!==null&&!e.settings.onboarded;if((0,I.useEffect)(()=>{c&&M.onboarded(!0).catch(()=>void 0)},[c]),e.authError)return(0,L.jsx)(gg,{});if(!e.loaded)return(0,L.jsx)(hg,{error:e.error});if(Kh(e.hub,e.settings,e.signInSkipped))return(0,L.jsx)(Wh,{});if(!e.lite&&e.settings&&!e.settings.onboarded&&!e.onboardingDismissed&&!e.threads.some(e=>e.events>0))return(0,L.jsxs)(I.Suspense,{fallback:(0,L.jsx)(dg,{}),children:[(0,L.jsx)(cg,{}),o]});let l=e.pendingApprovals.length,u=e.goals.filter(e=>e.proposal&&e.status!==`cancelled`).length,d=e.pendingApprovals.filter(t=>t.ts>e.feedSeenAt).length;return(0,L.jsxs)(`div`,{className:i(`mx-auto flex h-[100dvh] max-w-[760px] flex-col bg-bg sm:border-x sm:border-border`,!e.lite&&`wide:max-w-none wide:flex-row wide:border-x-0`),children:[!e.lite&&(0,L.jsx)(`div`,{className:`hidden wide:block wide:h-full`,children:(0,L.jsx)(lh,{})}),(0,L.jsxs)(`div`,{className:`relative flex min-h-0 min-w-0 flex-1 flex-col`,children:[e.gone&&(0,L.jsxs)(`div`,{className:`flex items-center justify-center gap-2 bg-surface-2 text-muted text-[12.5px] py-1`,children:[(0,L.jsx)(vn,{size:14}),` `,a(e.lite?`This demo has ended.`:`This nanoMuse has stopped. Reload the page once it is running again.`)]}),!e.gone&&!e.connected&&e.loaded&&(0,L.jsxs)(`div`,{className:`flex items-center justify-center gap-2 bg-amber-500/15 text-amber-700 dark:text-amber-300 text-[12.5px] py-1`,children:[(0,L.jsx)(vn,{size:14}),` `,a(`Reconnecting to your nanoMuse…`)]}),!e.lite&&(0,L.jsx)(En,{}),(0,L.jsx)(`main`,{className:i(`tabbar-room mx-auto min-h-0 w-full flex-1 wide:max-w-[900px]`,!e.lite&&`wide:pb-0`),children:(0,L.jsxs)(I.Suspense,{fallback:(0,L.jsx)(dg,{}),children:[e.tab===`chat`&&(0,L.jsx)(Um,{}),e.tab===`feed`&&(0,L.jsx)(uh,{}),e.tab===`ideas`&&(0,L.jsx)(ag,{}),e.tab===`goals`&&(0,L.jsx)(ig,{}),e.tab===`library`&&(0,L.jsx)(og,{}),e.tab===`memory`&&(0,L.jsx)(sg,{}),e.tab===`skills`&&(0,L.jsx)(ug,{}),e.tab===`connections`&&(0,L.jsx)(ng,{}),e.tab===`channels`&&(0,L.jsx)(eg,{}),e.tab===`devices`&&(0,L.jsx)(rg,{}),e.tab===`you`&&(0,L.jsx)(lg,{}),e.tab===`account`&&(0,L.jsx)(Qh,{}),e.tab===`coding`&&(0,L.jsx)(tg,{}),e.tab===`avatar`&&(0,L.jsx)($h,{})]})}),(0,L.jsx)(`nav`,{className:i(`tabbar-float pointer-events-none absolute inset-x-0 z-20 flex justify-center px-3`,!e.lite&&`wide:hidden`),children:(0,L.jsx)(`ul`,{className:`pointer-events-auto flex w-full max-w-[420px] items-center gap-0.5 rounded-full bg-surface-2/55 px-3.5 py-1.5 ring-1 ring-border/50 backdrop-blur-2xl`,children:pg.map(n=>{let r=e.tab===n.id,o=n.id===`chat`?l:n.id===`feed`?d:n.id===`goals`?u:0;return(0,L.jsx)(`li`,{className:`min-w-0 flex-1`,children:(0,L.jsx)(`button`,{type:`button`,onClick:()=>t(n.id),"aria-label":a(n.label),"aria-current":r?`page`:void 0,className:i(`flex h-[46px] w-full items-center justify-center`,r?`text-ink-fg`:`text-muted hover:text-fg`),children:(0,L.jsxs)(`span`,{className:i(`relative flex h-[46px] w-full max-w-[60px] items-center justify-center rounded-full transition-colors duration-300`,r&&`bg-ink`),children:[n.icon(r),o>0&&(0,L.jsx)(`span`,{className:`absolute right-2.5 top-1 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold text-white`,children:o})]})})},n.id)})})})]}),(0,L.jsx)(kn,{}),(0,L.jsx)(hf,{path:e.viewer,onClose:()=>n(null)}),o,e.toast&&(0,L.jsx)(`div`,{className:`pointer-events-none fixed inset-x-0 bottom-[calc(6rem+env(safe-area-inset-bottom))] z-[70] flex justify-center px-4`,children:(0,L.jsx)(`div`,{role:`status`,className:`rise rounded-2xl bg-fg text-bg px-4 py-2 text-[13.5px] shadow-lg max-w-sm text-center`,children:e.toast})})]})}function hg({error:e}){let t=s();return(0,L.jsxs)(`div`,{className:`mx-auto flex h-[100dvh] max-w-sm flex-col items-center justify-center px-6 text-center`,children:[(0,L.jsx)(xn,{size:72}),e?(0,L.jsxs)(L.Fragment,{children:[(0,L.jsx)(`p`,{className:`mt-6 text-[15px] font-medium`,children:t(`Could not reach your nanoMuse.`)}),(0,L.jsx)(`p`,{className:`mt-1.5 text-[13px] text-muted`,children:t(e)}),(0,L.jsx)(`button`,{type:`button`,onClick:()=>window.location.reload(),className:`mt-5 rounded-full bg-fg px-5 py-2 text-[14px] font-medium text-bg`,children:t(`Try again`)})]}):(0,L.jsxs)(`p`,{className:`mt-6 flex items-center gap-2 text-[14px] text-muted`,children:[(0,L.jsx)(A,{className:`animate-spin`,size:16}),` `,t(`Connecting to your nanoMuse…`)]})]})}function gg(){let[e,t]=(0,I.useState)(``),n=s();return(0,L.jsxs)(`div`,{className:`mx-auto flex h-[100dvh] max-w-md flex-col items-center justify-center px-6 text-center`,children:[(0,L.jsx)(xn,{size:88}),(0,L.jsx)(`h1`,{className:`mt-4 text-[22px] font-bold`,children:n(`Connect to your nanoMuse`)}),(0,L.jsxs)(`p`,{className:`mt-2 text-[14px] text-muted`,children:[n(`This app talks to the nanoMuse server you run yourself. Scan the QR code printed by`),` `,(0,L.jsx)(`code`,{className:`rounded bg-surface-2 px-1`,children:`nanomuse serve`}),n(`, or paste the access token below.`)]}),(0,L.jsxs)(`form`,{className:`mt-5 w-full flex gap-2`,onSubmit:t=>{t.preventDefault(),e.trim()&&(g(e.trim()),window.location.reload())},children:[(0,L.jsx)(`input`,{value:e,onChange:e=>t(e.target.value),"aria-label":n(`Access token`),placeholder:n(`Access token`),className:`flex-1 rounded-2xl bg-surface-2 px-4 py-2.5 text-[15px] outline-none focus:ring-2 focus:ring-accent/40`}),(0,L.jsx)(`button`,{type:`submit`,className:`rounded-2xl bg-accent text-accent-fg px-4 font-medium`,children:n(`Connect`)})]})]})}a(),ee(),wn(),bn.createRoot(document.getElementById(`root`)).render((0,L.jsx)(I.StrictMode,{children:(0,L.jsx)(T,{children:(0,L.jsx)(mg,{})})}));export{Ht as $,Af as A,fn as B,Dp as C,Xe as Ct,Xf as D,np as E,Ke as Et,Mn as F,Qt as G,on as H,Sn as I,Jt as J,Zt as K,Cn as L,Nf as M,kf as N,Qf as O,mf as P,Ut as Q,yn as R,qp as S,Ze as St,rp as T,qe as Tt,nn as U,sn as V,tn as W,Kt as X,qt as Y,Gt as Z,rm as _,st as _t,Ah as a,At as at,Qp as b,tt as bt,Mh as c,wt as ct,Tm as d,vt as dt,Vt as et,Xp as f,gt as ft,tm as g,ct as gt,Yp as h,ut as ht,kh as i,Nt as it,Mf as j,Pf as k,Bm as l,P as lt,Zp as m,pt as mt,Ih as n,Lt as nt,jh as o,Et as ot,Jp as p,mt as pt,Xt as q,Ph as r,It as rt,Fh as s,Tt as st,Hh as t,Rt as tt,Hm as u,xt as ut,$p as v,at as vt,ap as w,Je as wt,em as x,Qe as xt,nm as y,rt as yt,gn as z}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/plug-DQfRcVG2.js b/nanomuse/server/static/assets/plug-DV_c5_Ny.js similarity index 77% rename from nanomuse/server/static/assets/plug-DQfRcVG2.js rename to nanomuse/server/static/assets/plug-DV_c5_Ny.js index 30146834..83b4ef1f 100644 --- a/nanomuse/server/static/assets/plug-DQfRcVG2.js +++ b/nanomuse/server/static/assets/plug-DV_c5_Ny.js @@ -1 +1 @@ -import{J as e}from"./MuseHeader-DHtaUVEa.js";var t=e(`plug`,[[`path`,{d:`M12 22v-5`,key:`1ega77`}],[`path`,{d:`M15 8V2`,key:`18g5xt`}],[`path`,{d:`M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z`,key:`1xoxul`}],[`path`,{d:`M9 8V2`,key:`14iosj`}]]);export{t}; \ No newline at end of file +import{J as e}from"./MuseHeader-RC--uR8o.js";var t=e(`plug`,[[`path`,{d:`M12 22v-5`,key:`1ega77`}],[`path`,{d:`M15 8V2`,key:`18g5xt`}],[`path`,{d:`M17 8a1 1 0 0 1 1 1v4a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1z`,key:`1xoxul`}],[`path`,{d:`M9 8V2`,key:`14iosj`}]]);export{t}; \ No newline at end of file diff --git a/nanomuse/server/static/assets/refresh-cw-DQelymc2.js b/nanomuse/server/static/assets/refresh-cw-CxZ3ZdI3.js similarity index 79% rename from nanomuse/server/static/assets/refresh-cw-DQelymc2.js rename to nanomuse/server/static/assets/refresh-cw-CxZ3ZdI3.js index c603992a..b384a640 100644 --- a/nanomuse/server/static/assets/refresh-cw-DQelymc2.js +++ b/nanomuse/server/static/assets/refresh-cw-CxZ3ZdI3.js @@ -1 +1 @@ -import{J as e}from"./MuseHeader-DHtaUVEa.js";var t=e(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]);export{t}; \ No newline at end of file +import{J as e}from"./MuseHeader-RC--uR8o.js";var t=e(`refresh-cw`,[[`path`,{d:`M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8`,key:`v9h5vc`}],[`path`,{d:`M21 3v5h-5`,key:`1q7to0`}],[`path`,{d:`M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16`,key:`3uifl3`}],[`path`,{d:`M8 16H3v5`,key:`1cv678`}]]);export{t}; \ No newline at end of file diff --git a/nanomuse/server/static/index.html b/nanomuse/server/static/index.html index d9ccaffb..af240a70 100644 --- a/nanomuse/server/static/index.html +++ b/nanomuse/server/static/index.html @@ -6,7 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" /> - + - - - - - - + + + + + + +
diff --git a/nanomuse/server/static/manifest.webmanifest b/nanomuse/server/static/manifest.webmanifest index d74f40d8..ac656d54 100644 --- a/nanomuse/server/static/manifest.webmanifest +++ b/nanomuse/server/static/manifest.webmanifest @@ -6,7 +6,7 @@ "scope": "/", "display": "standalone", "orientation": "portrait", - "background_color": "#fcfcfc", + "background_color": "#ffffff", "theme_color": "#0a66e4", "icons": [ { diff --git a/nanomuse/tools/files.py b/nanomuse/tools/files.py index 0da23ee0..459a83f4 100644 --- a/nanomuse/tools/files.py +++ b/nanomuse/tools/files.py @@ -208,7 +208,7 @@ async def execute( ) break return ToolResult(output=("\n".join(matches) or "(no matches)") + note) - return ToolResult.fail(f"unknown action '{action}' – use read/write/append/list/search") + return ToolResult.fail(f"unknown action '{action}'; use read/write/append/list/search") except (PermissionError, FileNotFoundError, IsADirectoryError, OSError) as exc: return ToolResult.fail(str(exc)) diff --git a/nanomuse/tools/goal_tools.py b/nanomuse/tools/goal_tools.py index 56ddab8c..83edb197 100644 --- a/nanomuse/tools/goal_tools.py +++ b/nanomuse/tools/goal_tools.py @@ -74,6 +74,9 @@ def assess(self, args: dict[str, Any]) -> CallAssessment: action = str(args.get("action") or "?") gid = args.get("goal_id") or "" if action == "create": + # a goal is work the model takes up again at every check-in: creating one + # changes state (strict mode asks); the bookkeeping actions stay safe + a.risk = RiskLevel.MODERATE detail = str(args.get("title") or "")[:80] elif action == "update_step": detail = f"{gid} step {args.get('step_index')} → {args.get('status') or 'note'}" diff --git a/nanomuse/tools/reminder_tools.py b/nanomuse/tools/reminder_tools.py index cf9a6365..67311343 100644 --- a/nanomuse/tools/reminder_tools.py +++ b/nanomuse/tools/reminder_tools.py @@ -59,6 +59,9 @@ def assess(self, args: dict[str, Any]) -> CallAssessment: a = super().assess(args) action = str(args.get("action") or "?") if action == "create": + # a reminder, a routine above all, is work the model does later on its own: + # creating one changes state (strict mode asks); listing and cancelling stay safe + a.risk = RiskLevel.MODERATE when = args.get("repeat") or args.get("at") or "?" detail = f"{when}: {str(args.get('text') or '')[:60]}" elif action == "cancel": diff --git a/nanomuse/tools/trigger_tools.py b/nanomuse/tools/trigger_tools.py index 5eedea37..0a683ca4 100644 --- a/nanomuse/tools/trigger_tools.py +++ b/nanomuse/tools/trigger_tools.py @@ -63,6 +63,9 @@ def assess(self, args: dict[str, Any]) -> CallAssessment: a = super().assess(args) action = str(args.get("action") or "?") if action == "create": + # a trigger is standing work: the model acts on it later, in a thread nobody + # may be watching, so creating one changes state (strict mode asks) + a.risk = RiskLevel.MODERATE detail = f"{args.get('kind') or '?'} “{args.get('match') or '*'}”: {str(args.get('text') or '')[:60]}" elif action == "cancel": detail = str(args.get("trigger_id") or "") diff --git a/nanomuse/tools/web.py b/nanomuse/tools/web.py index 88ae0934..43f2f708 100644 --- a/nanomuse/tools/web.py +++ b/nanomuse/tools/web.py @@ -34,7 +34,11 @@ def host_of(url: str) -> str | None: def _is_private_host(host: str) -> bool: - """SSRF guard: refuse loopback / link-local / private ranges.""" + """SSRF guard: refuse every address that is not globally routable: loopback, link-local + (the cloud metadata address among them), the private ranges, the carrier-grade NAT + block 100.64.0.0/10 (a Tailscale or an ISP network), multicast, reserved and + unspecified addresses, in IPv4 and IPv6 alike (IPv4-mapped addresses included). A + name that does not resolve is left to the request, which reports the DNS error.""" if host in ("localhost",) or host.endswith((".local", ".internal", ".localhost")): return True try: @@ -44,7 +48,8 @@ def _is_private_host(host: str) -> bool: for info in infos: ip = ipaddress.ip_address(info[4][0]) if ( - ip.is_private + not ip.is_global + or ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved diff --git a/nanomuse/vault/vault.py b/nanomuse/vault/vault.py index f729e8fc..aca391b2 100644 --- a/nanomuse/vault/vault.py +++ b/nanomuse/vault/vault.py @@ -69,7 +69,7 @@ def _load(self) -> dict[str, str]: raw = self.fernet.decrypt(self.path.read_bytes()) except InvalidToken as exc: raise VaultError( - f"cannot decrypt {self.path} – wrong key? (key file: {self.key_path})" + f"cannot decrypt {self.path}: wrong key? (key file: {self.key_path})" ) from exc data = json.loads(raw.decode("utf-8")) self._cache = {str(k): str(v) for k, v in data.items()} diff --git a/pyproject.toml b/pyproject.toml index f828da58..d543b5f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "nanomuse" -version = "1.0.2" +version = "1.0.3" description = "nanoMuse: an open-source personal agent for every device you own. This is its runtime: the agent, the Sentinel that decides what may run, the hands that work a phone or a computer, and the web app. Bring your own model." readme = "README.md" license = { text = "GPL-3.0-or-later" } @@ -12,7 +12,7 @@ requires-python = ">=3.11" authors = [{ name = "nanoMuse Contributors" }] keywords = ["agent", "ai", "llm", "personal-assistant", "personal-agent", "muse", "meta-muse", "gui-agent", "mcp", "deepseek", "feishu", "amap"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "Programming Language :: Python :: 3", diff --git a/scripts/rebrand.py b/scripts/rebrand.py index 3729c046..3350414b 100755 --- a/scripts/rebrand.py +++ b/scripts/rebrand.py @@ -45,8 +45,8 @@ APP_ID = "io.github.nanomuse.app" NAME = "nanoMuse" -VERSION_NAME = "1.0.2" -VERSION_CODE = 45 +VERSION_NAME = "1.0.3" +VERSION_CODE = 46 REPO = "zeeshanhaque21/nanoMuse" REPO_URL = f"https://github.com/{REPO}" PRIVACY_URL = f"{REPO_URL}/blob/main/docs/privacy.md" @@ -192,16 +192,16 @@ def schemes() -> None: ("0xFF899390", "0xFF8E8E93"), # dark outline ] -# Applied to ChatColors.kt only; light palette first, then dark. +# Applied to ChatColors.kt only; light palette first, then dark. The user's own +# message is a black pill in the light theme and a white one in the dark theme +# (docs/design.md); the send button keeps upstream's black / white, so no entry. CHAT_MAP = [ - ("userBubble = Color(0x1E787880)", "userBubble = Color(0xFFE5F0FF)"), - ("sendButton = Color(0xFF000000)", "sendButton = Color(0xFF015CFB)"), + ("userBubble = Color(0x1E787880)", "userBubble = Color(0xFF000000)"), ("codeBlockText = Color(0xFF34C759)", "codeBlockText = Color(0xFFE5E5EA)"), ("inlineCodeText = Color(0xFFFF9500)", "inlineCodeText = Color(0xFF015CFB)"), ("blockquoteBar = Color(0x80FF9500)", "blockquoteBar = Color(0x80015CFB)"), ("fabAccent = Color(0xFFB7AF96)", "fabAccent = Color(0xFF015CFB)"), - ("userBubble = Color(0xFF2F3A5C)", "userBubble = Color(0xFF1A2B4A)"), - ("sendButton = Color(0xFFFFFFFF)", "sendButton = Color(0xFF58A6FF)"), + ("userBubble = Color(0xFF2F3A5C)", "userBubble = Color(0xFFFFFFFF)"), ("codeBlockText = Color(0xFF8CF38C)", "codeBlockText = Color(0xFFE5E5EA)"), ("inlineCodeText = Color(0xFFFF9F0A)", "inlineCodeText = Color(0xFF58A6FF)"), ("blockquoteBar = Color(0x80FF9F0A)", "blockquoteBar = Color(0x8058A6FF)"), diff --git a/scripts/release-apk.sh b/scripts/release-apk.sh index 7d88de00..e3a77640 100755 --- a/scripts/release-apk.sh +++ b/scripts/release-apk.sh @@ -45,8 +45,8 @@ command -v sha256sum >/dev/null 2>&1 || sha256sum() { shasum -a 256 "$@"; } out="$NM_ROOT/dist/nanoMuse-$version-arm64.apk" # Every part of the release carries the same version: the runtime, the desktop app, the web -# app (the APK is checked by badging below). Packages are named after -# these, so one left behind shows up as a wrong file name on the release. +# app, the iPhone app's MARKETING_VERSION (the APK is checked by badging below). Packages are +# named after these, so one left behind shows up as a wrong file name on the release. echo "== versions" versions_ok=1 check_version() { # file, what grep should find in it @@ -58,6 +58,7 @@ check_version web/package.json "\"version\": \"$version\"" check_version CITATION.cff "^version: $version$" check_version harness/dsh-nanomuse/package.json "\"version\": \"$version\"" check_version harness/desktop/package.json "\"version\": \"$version\"" +check_version android/src/ios/Minis.xcodeproj/project.pbxproj "MARKETING_VERSION = $version;" [ $versions_ok = 1 ] || { echo "the versions above disagree — bump them first" >&2; exit 1; } # Signature + badging of one APK; exits on the debug key unless allowed. diff --git a/scripts/release-docs.py b/scripts/release-docs.py index 2972bed4..dbe859e3 100755 --- a/scripts/release-docs.py +++ b/scripts/release-docs.py @@ -2,6 +2,7 @@ """The release-day edits to the pages that name the current version. scripts/release-docs.py [--dry-run] + [--roadmap "" --roadmap-zh "<一句话>"] scripts/release-docs.py 0.1.40 0.1.41 Clear Still 2026-10-20 - ``CHANGELOG.md``: a ``## [] - · `` heading goes right under @@ -17,8 +18,10 @@ - ``docs/release-notes-template.md``: the new codename joins the list. - ``.github/ISSUE_TEMPLATE/*.yml``: the example versions (``1.0.0 (43)``, ``desktop 1.0.0``) move to the new version and the ``VERSION_CODE`` that ``scripts/rebrand.py`` carries. -- ``docs/roadmap.md`` (the past-releases table) and ``docs/parity.md`` (cells that said "next - release") are edited by hand. +- ``docs/roadmap.md`` and ``docs/zh/roadmap.md``: with ``--roadmap`` and ``--roadmap-zh`` (both + or neither) a row ``| | | |`` goes at the end of the past-releases + table of each; without them the row is written by hand, like the ``docs/parity.md`` cells + that said "next release". ``--dry-run`` writes nothing and prints, for every page with a News list, its first three News lines (unchanged by this script) so the reviewer sees what a new milestone line would go above. @@ -98,6 +101,28 @@ def edit_issue_form(text: str, old: str, new: str, code: str) -> tuple[str, int] return out, n1 + n2 +def add_roadmap_row(text: str, version: str, codename: str, line: str) -> str: + """The roadmap page with ``| version | codename | line |`` as the last row of its + past-releases table (the table whose separator is ``|---|---|---|``). + + Raises ``ValueError`` when the page has no such table or already has a row for the + version; the one line is taken as written, with ``|`` not allowed in it.""" + if "|" in line: + raise ValueError("the roadmap line cannot contain |") + lines = text.split("\n") + try: + start = next(i for i, text_line in enumerate(lines) if text_line.strip() == "|---|---|---|") + except StopIteration: + raise ValueError("no past-releases table (|---|---|---|)") from None + end = start + 1 + while end < len(lines) and lines[end].startswith("|"): + end += 1 + if any(text_line.startswith(f"| {version} |") for text_line in lines[start:end]): + raise ValueError(f"a row for {version} is already there") + lines.insert(end, f"| {version} | {codename} | {line.strip()} |") + return "\n".join(lines) + + def version_code() -> str: """The Android ``VERSION_CODE`` as ``scripts/rebrand.py`` carries it after the bump.""" m = re.search(r"^VERSION_CODE = (\d+)$", Path("scripts/rebrand.py").read_text("utf-8"), re.M) @@ -109,6 +134,18 @@ def version_code() -> str: def main(argv: list[str]) -> int: dry_run = "--dry-run" in argv argv = [a for a in argv if a != "--dry-run"] + roadmap: dict[str, str] = {} + for flag, page in (("--roadmap", "docs/roadmap.md"), ("--roadmap-zh", "docs/zh/roadmap.md")): + if flag in argv: + at = argv.index(flag) + if at + 1 >= len(argv): + print(f"{flag} needs the one line of the row", file=sys.stderr) + return 2 + roadmap[page] = argv[at + 1] + del argv[at : at + 2] + if len(roadmap) == 1: + print("--roadmap and --roadmap-zh go together (one row per language)", file=sys.stderr) + return 2 if len(argv) != 5: print(__doc__.strip().splitlines()[2].strip(), file=sys.stderr) return 2 @@ -116,6 +153,17 @@ def main(argv: list[str]) -> int: if not Path("pyproject.toml").is_file(): print("run from the repository root", file=sys.stderr) return 2 + # The roadmap rows are worked out before anything is written, so a page without the table + # or with the row already there refuses the run while every file is still as it was. + roadmap_texts: dict[str, str] = {} + for name, line in roadmap.items(): + try: + roadmap_texts[name] = add_roadmap_row( + Path(name).read_text(encoding="utf-8"), new, newname, line + ) + except ValueError as exc: + print(f"{name}: {exc}", file=sys.stderr) + return 1 changelog = Path("CHANGELOG.md") s = changelog.read_text(encoding="utf-8") @@ -154,6 +202,11 @@ def main(argv: list[str]) -> int: if moved: print(f"{name}: {moved} example version(s) now {new} ({code})") + for name, text in roadmap_texts.items(): + if not dry_run: + Path(name).write_text(text, encoding="utf-8") + print(f"{name}: a row for {new} {newname} at the end of the past-releases table") + template = Path("docs/release-notes-template.md") s = template.read_text(encoding="utf-8") if f", {oldname} (CHANGELOG" not in s: @@ -173,12 +226,19 @@ def main(argv: list[str]) -> int: if is_major(new) else "no News line (the News lists are milestones; this is not a major version), " ) + roadmap_note = ( + "" + if roadmap + else "docs/roadmap.md's and docs/zh/roadmap.md's past-releases rows (or run again with " + "--roadmap and --roadmap-zh), " + ) print( ("dry run, nothing written. " if dry_run else "") + "by hand now: " + news - + "the homepage's version line and download links, docs/roadmap.md's past-releases row, " - "docs/parity.md, the empty Unreleased headings in CHANGELOG.md" + + "the homepage's version line and download links, " + + roadmap_note + + "docs/parity.md, the empty Unreleased headings in CHANGELOG.md" ) return 0 diff --git a/scripts/self-host.sh b/scripts/self-host.sh index 214cbb76..e1d5cebb 100755 --- a/scripts/self-host.sh +++ b/scripts/self-host.sh @@ -29,7 +29,7 @@ while [ $# -gt 0 ]; do --bind) BIND="$2"; shift ;; --port) PORT="$2"; shift ;; --no-start) START=0 ;; - -h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + -h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; *) echo "unknown option: $1 (try --help)" >&2; exit 2 ;; esac shift @@ -199,6 +199,11 @@ if [ "$LOCAL" = 1 ]; then fi say say "Starting the relay$([ "$LOCAL" = 0 ] && echo " and Caddy")…" +# The relay runs as uid 10001 in its container; the bind-mounted data directory must be its. +# Docker creates a missing ./data as root, so the chown runs inside the image, as root, and +# needs no sudo on the host. Idempotent. +"${COMPOSE[@]}" "${FILES[@]}" build relay +"${COMPOSE[@]}" "${FILES[@]}" run --rm --no-deps --user 0 relay chown -R 10001:10001 /srv/nanomuse-cloud/data "${COMPOSE[@]}" "${FILES[@]}" up -d --build health="$BASE/healthz" diff --git a/tests/conftest.py b/tests/conftest.py index d7293ad9..cf358840 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import socket import sqlite3 from collections.abc import Iterator from pathlib import Path @@ -10,6 +11,39 @@ from nanomuse.config import Settings +# The address every ``.example`` host resolves to in the tests. It has to count as public for +# the SSRF guard, and Python's ``ipaddress`` files the documentation ranges (TEST-NET-1 to 3, +# ``2001:db8::/32``) under ``is_private``, so this is the address ``example.com`` answered for +# years. Nothing is ever sent to it: every fetch in the tests goes through a mocked transport. +EXAMPLE_HOST_ADDRESS = "93.184.216.34" + + +def _is_example_host(host: bytes | str | None) -> bool: + return isinstance(host, str) and (host == "example" or host.endswith(".example")) + + +@pytest.fixture(autouse=True) +def _example_hosts_resolve_offline(monkeypatch: pytest.MonkeyPatch) -> None: + """``.example`` hosts answer a fixed public address without asking the resolver. + + The web tools run ``_is_private_host`` (``socket.getaddrinfo``) on a host before the + mocked transport sees the request. The reserved ``.example`` TLD is NXDOMAIN on most + resolvers, which the guard treats as "not private", but a resolver that sinkholes + unknown names to ``127.0.0.1`` or ``0.0.0.0`` made the guard refuse ``shop.example`` + and three tests failed on that machine only (#339). Every other host, numeric + literals included, still goes to the real function. + """ + real_getaddrinfo = socket.getaddrinfo + + def getaddrinfo( + host: bytes | str | None, port: bytes | str | int | None, *args: Any, **kwargs: Any + ) -> Any: + if _is_example_host(host): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (EXAMPLE_HOST_ADDRESS, port or 0))] + return real_getaddrinfo(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", getaddrinfo) + @pytest.fixture(autouse=True) def _own_environment(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_calendar.py b/tests/test_calendar.py index 0c0bb24b..af1b5f09 100644 --- a/tests/test_calendar.py +++ b/tests/test_calendar.py @@ -215,7 +215,8 @@ def client(**kwargs: object) -> httpx.AsyncClient: status = await feeds.refresh() good, bad = status["feeds"] assert good["events"] == 6 and not good["error"] - assert bad["events"] == 0 and "404" in bad["error"] + assert bad["events"] == 0 and bad["error"] == "HTTP 404" + assert "missing.ics" not in bad["error"], "a private link never shows in the status" assert len(feeds.agenda(date(2026, 9, 21))) == 1, "the good feed still answers" assert seen[0] == "https://cal.example/good.ics", "a webcal:// link is fetched over https" diff --git a/tests/test_cli.py b/tests/test_cli.py index ade256b0..5b13434a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -247,3 +247,14 @@ def test_config_show_masks_every_credential(tmp_path, monkeypatch): "a": {"token": "***"}, "b": [{"client_secret": "xxxx…xx"}], } + # an MCP server's env, a custom header and a proxy with a password hold secrets too + masked = mask_secrets( + { + "env": {"AMAP_KEY": "amap-0123456789", "HOME": "/home/me"}, + "extra_headers": {"Authorization": "Bearer abcdefghijkl"}, + "proxy": "http://user:hunter2@proxy.example:3128", + } + ) + assert masked["env"] == {"AMAP_KEY": "amap…89", "HOME": "/home/me"} + assert masked["extra_headers"] == {"Authorization": "Bear…kl"} + assert "hunter2" not in masked["proxy"] and "proxy.example:3128" in masked["proxy"] diff --git a/tests/test_docs_markup.py b/tests/test_docs_markup.py new file mode 100644 index 00000000..47a26e99 --- /dev/null +++ b/tests/test_docs_markup.py @@ -0,0 +1,43 @@ +"""Every page under docs/ renders without a literal emphasis marker. + +CommonMark's flanking rules: a ``**…**`` or ``*…*`` run that closes right after a punctuation +mark (a full-width colon, a closing bracket) and is followed by a letter has no closer, so the +asterisks show on the site; the same when a run opens on punctuation after a letter. Chinese +prose runs into this where English, with its spaces, does not. markdown-it-py follows the +same rules as the markdown-it VitePress renders with, so a hit here is a hit on the site. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from markdown_it import MarkdownIt + +ROOT = Path(__file__).resolve().parents[1] +DOCS = ROOT / "docs" +SKIP = {"releases", "archive", "tasks", "traces", "briefs"} +CJK = "\u3000-\u303f\u4e00-\u9fff\uff00-\uffef" +# A doubled asterisk anywhere, or a single one touching a Chinese character or punctuation. +STRAY = re.compile(rf"\*\*|(?<=[{CJK}])\*|\*(?=[\u4e00-\u9fff])") + + +def pages() -> list[Path]: + under_docs = (p for p in DOCS.rglob("*.md") if not set(p.relative_to(DOCS).parts[:-1]) & SKIP) + return sorted([ROOT / "README.md", *under_docs]) + + +def rendered_text(source: str) -> str: + """The page's text as the site shows it: HTML rendered, code left out, tags stripped.""" + source = re.sub(r"\A---\n[\s\S]*?\n---\n", "", source) + html = MarkdownIt("commonmark").enable("table").enable("strikethrough").render(source) + html = re.sub(r"|", "", html) + return re.sub(r"<[^>]+>", "", html) + + +@pytest.mark.parametrize("page", pages(), ids=lambda p: str(p.relative_to(ROOT))) +def test_no_literal_emphasis_marker(page: Path) -> None: + text = rendered_text(page.read_text(encoding="utf-8")) + hits = [text[max(0, m.start() - 20) : m.end() + 20] for m in STRAY.finditer(text)] + assert not hits, hits diff --git a/tests/test_endpoints_api.py b/tests/test_endpoints_api.py new file mode 100644 index 00000000..ffab5159 --- /dev/null +++ b/tests/test_endpoints_api.py @@ -0,0 +1,402 @@ +"""Custom OpenAI-compatible endpoints, several at once (#294): ``/api/connections/endpoints``. + +The list behind the web console's *Add endpoint*: each entry has a stable id, a name, an +address, a key in the vault and a model list; a slot is set from one by id. The endpoint's +``/models`` is a stub on ``fetch_models``; nothing reaches the network. +""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from nanomuse.config import Settings, apply_app_settings, load_app_settings +from nanomuse.llm import MockLLM +from nanomuse.server import connections as conn_module +from nanomuse.server import create_app +from nanomuse.server.endpoints import host_label, key_name, typed_model_ids +from nanomuse.server.service import MuseService +from nanomuse.vault.vault import CredentialVault + +GATEWAY = "https://gateway.example/v1" +SECOND = "https://second.example/v1" + + +def _make(settings: Settings) -> tuple[TestClient, MuseService]: + settings.server.token = "secret-token" + service = MuseService(settings, llm=MockLLM([])) + app = create_app(settings, service) + client = TestClient(app) + client.headers["Authorization"] = "Bearer secret-token" + return client, service + + +@pytest.fixture() +def server(settings: Settings) -> Iterator[tuple[TestClient, MuseService]]: + client, service = _make(settings) + with client: + yield client, service + + +@pytest.fixture() +def listed(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[str]]: + """What each address lists on ``GET /models``, and the key each call carried.""" + answers: dict[str, list[str]] = {GATEWAY: ["b-model", "a-model", "qwen-image-3.0"]} + keys: list[str] = [] + + async def fake(base_url: str, key: str = "") -> tuple[list[str], str]: + keys.append(key) + ids = answers.get(base_url, []) + return (sorted(ids), "") if ids else ([], "404") + + monkeypatch.setattr(conn_module, "fetch_models", fake) + answers["__keys__"] = keys + return answers + + +def _endpoints(client: TestClient) -> list[dict]: + return client.get("/api/connections").json()["endpoints"] + + +def test_helpers(): + assert key_name("ep-7f3a9c21") == "ENDPOINT_EP_7F3A9C21_API_KEY" + assert host_label("https://gateway.example/v1") == "gateway.example" + assert host_label("http://127.0.0.1:8000/v1") == "127.0.0.1:8000" + assert typed_model_ids("glm-4.7, glm-4.7, , kimi-k2.5\n") == ["glm-4.7", "kimi-k2.5"] + assert typed_model_ids(["a", "b", "a"]) == ["a", "b"] + assert typed_model_ids(None) == [] + assert len(typed_model_ids(",".join(f"m{i}" for i in range(60)))) == 40 + + +def test_add_lists_the_models_and_keeps_the_key_in_the_vault(server, listed, settings): + client, service = server + assert _endpoints(client) == [] + assert client.get("/api/connections").json()["llm"]["endpoint"] == "" + # the key is refused with a space; the relay is the account, not an endpoint; no scheme is added + assert ( + client.post( + "/api/connections/endpoints", json={"base_url": GATEWAY, "api_key": "sk x"} + ).status_code + == 400 + ) + # Fork: no relay is configured by default (`cloud.base_url == ""`), so `/v1` + # is the relative address `/v1` and is refused as malformed before the account check. + assert settings.cloud.base_url == "" + assert ( + client.post( + "/api/connections/endpoints", json={"base_url": settings.cloud.base_url + "/v1"} + ).status_code + == 400 + ) + # With a relay configured the upstream contract holds: it is the account, not an endpoint. + settings.cloud.base_url = "https://relay.example.net" + relay = client.post( + "/api/connections/endpoints", json={"base_url": settings.cloud.base_url + "/v1"} + ) + assert relay.status_code == 400 and "nanoMuse Cloud" in relay.json()["detail"] + settings.cloud.base_url = "" + added = client.post( + "/api/connections/endpoints", + json={"base_url": "gateway.example/v1", "api_key": "sk-gateway-1", "models": "my-typed"}, + ) + assert added.status_code == 200, added.text + entry = added.json() + assert entry["id"].startswith("ep-") and entry["label"] == "gateway.example" + assert entry["base_url"] == GATEWAY and entry["key_source"] == "vault" + # typed ids first, the endpoint's own after them; nothing uses it yet + assert entry["models"] == ["my-typed", "a-model", "b-model", "qwen-image-3.0"] + assert entry["slots"] == [] + assert listed["__keys__"][-1] == "sk-gateway-1" + # the key is in the vault under the endpoint's own name and nowhere else + name = key_name(entry["id"]) + assert service.app.vault.get(name) == "sk-gateway-1" + assert "sk-gateway" not in json.dumps(entry) + saved = load_app_settings(settings.data_dir) + assert "sk-gateway" not in json.dumps(saved) + assert saved["endpoints"][0]["api_key"] == "{{vault:" + name + "}}" + # an endpoint that lists nothing and had nothing typed is refused before anything is written + silent = client.post("/api/connections/endpoints", json={"base_url": SECOND, "api_key": "sk-2"}) + assert silent.status_code == 400 and "type the ids" in silent.json()["detail"] + assert len(_endpoints(client)) == 1 + assert [n for n in service.app.vault.names() if n.startswith("ENDPOINT_")] == [name] + # typed ids make it; a server without a key is allowed + second = client.post( + "/api/connections/endpoints", + json={"base_url": SECOND, "label": "Second", "models": ["s-1"]}, + ) + assert second.status_code == 200 + assert second.json()["key_source"] == "none" and second.json()["models"] == ["s-1"] + + +def test_slots_are_set_from_an_endpoint_by_id(server, listed, settings): + client, service = server + first = client.post( + "/api/connections/endpoints", json={"base_url": GATEWAY, "api_key": "sk-1"} + ).json() + second = client.post( + "/api/connections/endpoints", json={"base_url": SECOND, "models": "s-1, s-vl"} + ).json() + # the chat model: the slot takes the address and the key by reference, never the key itself + r = client.put("/api/connections/llm", json={"endpoint": first["id"], "model": "a-model"}) + assert r.status_code == 200, r.text + assert r.json()["endpoint"] == first["id"] and r.json()["key_source"] == "vault" + assert settings.llm.base_url == GATEWAY and settings.llm.model == "a-model" + assert settings.llm.api_key == "{{vault:" + key_name(first["id"]) + "}}" + assert "sk-1" not in json.dumps(load_app_settings(settings.data_dir)) + # the hands and the pictures from the other endpoint, which has no key + hands = client.put("/api/connections/gui", json={"endpoint": second["id"], "model": "s-vl"}) + assert hands.status_code == 200, hands.text + assert hands.json()["endpoint"] == second["id"] and settings.gui.base_url == SECOND + assert settings.gui.model == "s-vl" and settings.gui.api_key == "" + image = client.put("/api/connections/image", json={"endpoint": second["id"], "model": "s-1"}) + assert image.status_code == 200, image.text + assert image.json()["endpoint"] == second["id"] and settings.image.base_url == SECOND + view = {e["id"]: e for e in _endpoints(client)} + assert view[first["id"]]["slots"] == ["chat"] + assert view[second["id"]]["slots"] == ["hands", "image"] + # an id nobody has is a plain 400 + assert ( + client.put("/api/connections/llm", json={"endpoint": "ep-nobody", "model": "x"}).status_code + == 400 + ) + # the model list for a slot's picker: the endpoint's own key is used, the saved list stands in + models = client.post("/api/llm/models", json={"endpoint": first["id"]}).json() + assert models["source"] == "live" and listed["__keys__"][-1] == "sk-1" + kept = client.post("/api/llm/models", json={"endpoint": second["id"]}).json() + assert kept == { + "models": ["s-1", "s-vl"], + "image_models": [], + "video_models": [], + "source": "catalogue", + "error": "404", + } + assert service.app.vault.get(key_name(second["id"])) is None + + +def test_rename_rekey_and_move_follow_through_to_the_slots(server, listed, settings): + client, service = server + first = client.post( + "/api/connections/endpoints", json={"base_url": GATEWAY, "api_key": "sk-1"} + ).json() + client.put("/api/connections/llm", json={"endpoint": first["id"], "model": "a-model"}) + client.put("/api/connections/gui", json={"endpoint": first["id"], "model": "b-model"}) + path = f"/api/connections/endpoints/{first['id']}" + # rename: the label only + renamed = client.put(path, json={"label": " Work gateway "}).json() + assert renamed["label"] == "Work gateway" and renamed["slots"] == ["chat", "hands"] + # a new key: the vault entry changes under the same name, so the slots need no change + rekeyed = client.put(path, json={"api_key": "sk-1-new"}) + assert rekeyed.status_code == 200 + assert service.app.vault.get(key_name(first["id"])) == "sk-1-new" + assert settings.llm.api_key == "{{vault:" + key_name(first["id"]) + "}}" + assert listed["__keys__"][-1] == "sk-1-new", ( + "the model list is asked for again with the new key" + ) + # a new address: the slots that used the endpoint move with it; the models stay when it lists none + listed[SECOND] = [] + moved = client.put(path, json={"base_url": SECOND}).json() + assert moved["base_url"] == SECOND + assert moved["models"] == ["a-model", "b-model", "qwen-image-3.0"] + assert settings.llm.base_url == SECOND and settings.gui.base_url == SECOND + assert settings.llm.model == "a-model" and settings.gui.model == "b-model" + assert client.get("/api/connections").json()["llm"]["endpoint"] == first["id"] + # typed models replace the list, the endpoint's own after them + listed[SECOND] = ["listed-z"] + retyped = client.put(path, json={"models": "typed-one"}).json() + assert retyped["models"] == ["typed-one", "listed-z"] + # asking again keeps typed ids out: the live list is the truth + again = client.post(path + "/models").json() + assert again["source"] == "live" and again["models"] == ["listed-z"] + # an unknown id is 404 for both + assert ( + client.put("/api/connections/endpoints/ep-nobody", json={"label": "x"}).status_code == 404 + ) + assert client.post("/api/connections/endpoints/ep-nobody/models").status_code == 404 + + +def test_remove_refuses_the_chat_endpoint_and_clears_the_dependent_slots(server, listed, settings): + client, service = server + first = client.post( + "/api/connections/endpoints", json={"base_url": GATEWAY, "api_key": "sk-1"} + ).json() + second = client.post( + "/api/connections/endpoints", json={"base_url": SECOND, "api_key": "sk-2", "models": "s-1"} + ).json() + client.put("/api/connections/llm", json={"endpoint": first["id"], "model": "a-model"}) + client.put("/api/connections/gui", json={"endpoint": first["id"], "model": "b-model"}) + client.put("/api/connections/video", json={"endpoint": first["id"], "model": "wan-x"}) + # the chat model answers through it: refused with a sentence, nothing changes + refused = client.delete(f"/api/connections/endpoints/{first['id']}") + assert refused.status_code == 409 and "chat model" in refused.json()["detail"] + assert len(_endpoints(client)) == 2 and service.app.vault.get(key_name(first["id"])) == "sk-1" + # the chat moves to the other endpoint; now the first can go, and the hands and clips go back to automatic + client.put("/api/connections/llm", json={"endpoint": second["id"], "model": "s-1"}) + assert settings.llm.api_key == "{{vault:" + key_name(second["id"]) + "}}" + gone = client.delete(f"/api/connections/endpoints/{first['id']}") + assert gone.status_code == 200 and gone.json() == {"ok": True} + assert [e["id"] for e in _endpoints(client)] == [second["id"]] + assert service.app.vault.get(key_name(first["id"])) is None + assert settings.gui.model == "" and settings.gui.base_url is None + assert settings.video.configured is False + data = client.get("/api/connections").json() + assert data["gui"]["endpoint"] == "" and data["video"]["endpoint"] == "" + assert data["llm"]["endpoint"] == second["id"] + assert client.delete(f"/api/connections/endpoints/{first['id']}").status_code == 404 + + +def _app_settings(settings: Settings, llm: dict) -> None: + """A chat slot set from the app on an earlier run, layered as ``load_settings`` does.""" + settings.data_dir.mkdir(parents=True, exist_ok=True) + (settings.data_dir / "app-settings.json").write_text( + json.dumps({"version": 1, "llm": llm}), "utf-8" + ) + apply_app_settings(settings, load_app_settings(settings.data_dir)) + + +def test_the_single_custom_slot_is_migrated_once(settings: Settings, listed): + """An existing user with *Other OpenAI-compatible endpoint* set from the app finds it as + endpoint number one: same address, the key copied to the endpoint's own vault entry, + the chat slot pointed at that entry, nothing asked.""" + vault = CredentialVault(settings.vault_file, settings.vault_key_file) + vault.set("LLM_API_KEY", "sk-old-gateway") + _app_settings( + settings, + { + "provider": "openai", + "model": "my-model", + "base_url": GATEWAY, + "api_key": "{{vault:LLM_API_KEY}}", + }, + ) + client, service = _make(settings) + with client: + rows = _endpoints(client) + assert len(rows) == 1 + one = rows[0] + assert one["label"] == "gateway.example" and one["base_url"] == GATEWAY + assert one["models"] == ["my-model"] and one["key_source"] == "vault" + assert one["slots"] == ["chat"] + name = key_name(one["id"]) + assert service.app.vault.get(name) == "sk-old-gateway" + assert service.app.vault.get("LLM_API_KEY") == "sk-old-gateway", ( + "the old entry is left alone" + ) + # the chat slot works as before, through the endpoint's own entry now + llm = client.get("/api/connections").json()["llm"] + assert ( + llm["endpoint"] == one["id"] + and llm["key_source"] == "vault" + and llm["model"] == "my-model" + ) + assert settings.llm.api_key == "{{vault:" + name + "}}" and settings.llm.base_url == GATEWAY + saved = load_app_settings(settings.data_dir) + assert saved["llm"]["api_key"] == "{{vault:" + name + "}}" + assert "sk-old" not in json.dumps(saved) + # a second start finds the list and adds nothing + client2, _ = _make(settings) + with client2: + assert [e["id"] for e in _endpoints(client2)] == [one["id"]] + + +def test_migration_keeps_a_dangling_key_reference_and_takes_a_slot_without_a_key( + settings: Settings, listed +): + """Two edges of the first start: a chat slot whose key names a vault entry nothing + answers (the vault was lost, or the key never set) becomes an endpoint that says + ``missing`` just as the slot does, and a key set on the endpoint later mends both; a slot + with no key at all becomes an endpoint with none.""" + _app_settings( + settings, + { + "provider": "openai", + "model": "my-model", + "base_url": GATEWAY, + "api_key": "{{vault:LLM_API_KEY}}", + }, + ) + client, service = _make(settings) + with client: + (one,) = _endpoints(client) + assert one["key_source"] == "missing" and one["slots"] == ["chat"] + assert client.get("/api/connections").json()["llm"]["key_source"] == "missing" + assert service.app.vault.get(key_name(one["id"])) is None + saved = load_app_settings(settings.data_dir) + assert saved["endpoints"][0]["api_key"] == "{{vault:LLM_API_KEY}}" + assert saved["llm"]["api_key"] == "{{vault:LLM_API_KEY}}" + # a key typed on the endpoint goes to its own entry and the chat slot follows + mended = client.put( + f"/api/connections/endpoints/{one['id']}", json={"api_key": "sk-found-again"} + ) + assert mended.status_code == 200 and mended.json()["key_source"] == "vault" + assert service.app.vault.get(key_name(one["id"])) == "sk-found-again" + assert settings.llm.api_key == "{{vault:" + key_name(one["id"]) + "}}" + assert client.get("/api/connections").json()["llm"]["key_source"] == "vault" + # taking the key away again: the endpoint and the slot both have none + cleared = client.put(f"/api/connections/endpoints/{one['id']}", json={"api_key": ""}) + assert cleared.status_code == 200 and cleared.json()["key_source"] == "none" + assert service.app.vault.get(key_name(one["id"])) is None + assert settings.llm.api_key == "" + assert client.get("/api/connections").json()["llm"]["key_source"] == "none" + + other = Settings() + other.data_dir = settings.data_dir.parent / "no-key" + other.agent.workspace = settings.data_dir.parent / "no-key-ws" + other.llm.stream = False + other.ensure_dirs() + _app_settings(other, {"provider": "openai", "model": "local-model", "base_url": SECOND}) + client2, service2 = _make(other) + with client2: + (row,) = _endpoints(client2) + assert row["base_url"] == SECOND and row["key_source"] == "none" + assert row["models"] == ["local-model"] and row["slots"] == ["chat"] + assert [n for n in service2.app.vault.names() if n.startswith("ENDPOINT_")] == [] + + +def test_app_settings_are_written_whole_and_private(settings: Settings, listed): + """``app-settings.json`` is written whole or not at all: after a save the file parses, + no temporary file is left next to it, and it is readable by its owner only (where the + platform keeps modes).""" + client, _ = _make(settings) + with client: + added = client.post( + "/api/connections/endpoints", + json={"base_url": GATEWAY, "api_key": "sk-gateway-1"}, + ) + assert added.status_code == 200 + path = settings.data_dir / "app-settings.json" + assert json.loads(path.read_text("utf-8"))["endpoints"][0]["base_url"] == GATEWAY + assert not path.with_suffix(".tmp").exists() + if sys.platform != "win32": + assert path.stat().st_mode & 0o777 == 0o600 + + +def test_a_catalogue_slot_or_a_config_slot_is_not_migrated(settings: Settings, tmp_path: Path): + _app_settings( + settings, + { + "provider": "openai", + "model": "deepseek-chat", + "base_url": "https://api.deepseek.com", + "api_key": "{{vault:LLM_API_KEY}}", + }, + ) + client, _ = _make(settings) + with client: + assert _endpoints(client) == [] + assert load_app_settings(settings.data_dir)["endpoints"] == [] + # a slot from config.toml alone (nothing set from the app) stays the file's business + other = Settings() + other.data_dir = tmp_path / "other" + other.agent.workspace = tmp_path / "other-ws" + other.llm.base_url = GATEWAY + other.llm.api_key = "plain" + other.llm.stream = False + other.ensure_dirs() + client2, _ = _make(other) + with client2: + assert _endpoints(client2) == [] diff --git a/tests/test_fork_docs_sanitization.py b/tests/test_fork_docs_sanitization.py index 9717853a..a88c883a 100644 --- a/tests/test_fork_docs_sanitization.py +++ b/tests/test_fork_docs_sanitization.py @@ -76,3 +76,24 @@ def test_the_scan_sees_the_active_docs() -> None: "website/.vitepress/config.mts", "site/legacy.html", } <= names + + +# Removing upstream's download-mirror clause from the ten READMEs swallowed the +# joining ";" and the sentence's own "." along with the clause, leaving +# ".../releases/latest) Open the app" -- a scar review found on 2026-10-11. +# The signature is a release link followed by whitespace and a word, which only +# the Latin-script readmes can produce: the ja/ko ones continue with a particle +# straight after ")" and the zh/zh-TW ones have no space there at all. +RELEASE_LINK_GLUED = re.compile(r"releases/latest\)\s+[^\s.。!?!?]") + + +def test_no_readme_glues_a_sentence_onto_the_release_link() -> None: + hits = [ + f"{path.relative_to(ROOT).as_posix()}:{n}" + for pattern in ("README.md", "docs/readme/*.md") + for path in ROOT.glob(pattern) + if path.is_file() + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if RELEASE_LINK_GLUED.search(line) + ] + assert not hits, "missing sentence terminator after the release link: " + ", ".join(hits) diff --git a/tests/test_memory_goals.py b/tests/test_memory_goals.py index 12ecec84..87b06f5e 100644 --- a/tests/test_memory_goals.py +++ b/tests/test_memory_goals.py @@ -165,6 +165,23 @@ def test_goals_lifecycle(tmp_path: Path): assert store.delete(goal.id) and store.get(goal.id) is None +def test_goals_tool_rates_creating_a_goal_as_a_change(tmp_path: Path): + """A goal is work the model takes up again at every check-in, so creating one is + ``moderate`` (strict mode asks); reading and bookkeeping stay ``safe``.""" + from nanomuse.schema import RiskLevel + from nanomuse.tools import Goals + + tool = Goals(store=GoalStore(tmp_path / "g.db")) + assert tool.assess({"action": "create", "title": "Sell my old car"}).risk == RiskLevel.MODERATE + for args in ( + {"action": "list"}, + {"action": "get", "goal_id": "g_1"}, + {"action": "update_step", "goal_id": "g_1", "step_index": 1, "status": "done"}, + {"action": "note", "goal_id": "g_1", "note": "buyer asked"}, + ): + assert tool.assess(args).risk == RiskLevel.SAFE, args + + def test_goal_categories_due_dates_and_check_ins(tmp_path: Path): from datetime import UTC, datetime, timedelta diff --git a/tests/test_readme_contributors.py b/tests/test_readme_contributors.py index 9ec0619e..41d2872c 100644 --- a/tests/test_readme_contributors.py +++ b/tests/test_readme_contributors.py @@ -108,5 +108,8 @@ def test_the_ten_readmes_carry_the_block_and_the_maintainer_card(script: ModuleT assert text.index(script.START) < text.index(script.END), path.name assert 'href="https://github.com/lgy0404"' in text, path.name # The same badge URL twice (badge row and footer): one camo URL, one view per visit. + # Fork: the badge is the fork's own counter. Upstream's hits.sh URL embeds + # `github.com/nano-muse/nanoMuse`, which this fork's sanitization contract forbids + # (tests/test_fork_docs_sanitization.py), so the ten READMEs use komarev instead. assert text.count("komarev.com/ghpvc/?username=nano-muse-nanomuse") == 2, path.name assert "lgy0404" not in text[text.index(script.START) : text.index(script.END)], path.name diff --git a/tests/test_release_docs.py b/tests/test_release_docs.py index 511d005b..858aa514 100644 --- a/tests/test_release_docs.py +++ b/tests/test_release_docs.py @@ -188,3 +188,83 @@ def test_main_moves_the_issue_forms_but_not_on_a_dry_run( assert release_docs.main(["1.0.0", "1.0.1", "Keel", "Still", "2026-10-20"]) == 0 assert "e.g. 1.0.1 (44)" in form.read_text(encoding="utf-8") + + +ROADMAP = """## Past releases + +| Version | Codename | In one line | +|---|---|---| +| 0.1.41 | Choice | Settings › Models with four rows | +| 1.0.0 | Keel | The first stable version | +""" + + +def test_a_roadmap_row_goes_at_the_end_of_the_past_releases_table( + release_docs: ModuleType, +) -> None: + text = release_docs.add_roadmap_row(ROADMAP, "1.0.1", "Ballast", "The phone app has a scheme") + assert text.endswith( + "| 1.0.0 | Keel | The first stable version |\n" + "| 1.0.1 | Ballast | The phone app has a scheme |\n" + ) + with pytest.raises(ValueError, match="already there"): + release_docs.add_roadmap_row(text, "1.0.1", "Ballast", "again") + with pytest.raises(ValueError, match="no past-releases table"): + release_docs.add_roadmap_row("# Roadmap\n", "1.0.1", "Ballast", "x") + with pytest.raises(ValueError, match="cannot contain"): + release_docs.add_roadmap_row(ROADMAP, "1.0.1", "Ballast", "a | b") + + +def test_main_writes_both_roadmap_rows_or_asks_for_them( + release_docs: ModuleType, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.chdir(tmp_path) + _tree(tmp_path) + (tmp_path / "docs" / "roadmap.md").write_text(ROADMAP, encoding="utf-8") + (tmp_path / "docs" / "zh" / "roadmap.md").write_text( + ROADMAP.replace("Version | Codename | In one line", "版本 | 代号 | 一句话"), + encoding="utf-8", + ) + args = ["1.0.0", "1.0.1", "Keel", "Still", "2026-10-20"] + + # one flag without the other is refused before anything is written + assert release_docs.main([*args, "--roadmap", "x"]) == 2 + assert "go together" in capsys.readouterr().err + assert "## [1.0.1]" not in (tmp_path / "CHANGELOG.md").read_text(encoding="utf-8") + + assert release_docs.main([*args, "--roadmap", "One line", "--roadmap-zh", "一句话"]) == 0 + out = capsys.readouterr().out + assert "docs/roadmap.md: a row for 1.0.1 Still" in out + assert "docs/roadmap.md's and docs/zh/roadmap.md's past-releases rows" not in out + assert ( + (tmp_path / "docs" / "roadmap.md") + .read_text(encoding="utf-8") + .endswith("| 1.0.1 | Still | One line |\n") + ) + assert ( + (tmp_path / "docs" / "zh" / "roadmap.md") + .read_text(encoding="utf-8") + .endswith("| 1.0.1 | Still | 一句话 |\n") + ) + + # a page without the table refuses the run before anything is written + bare = tmp_path / "bare" + bare.mkdir() + monkeypatch.chdir(bare) + _tree(bare) + (bare / "docs" / "roadmap.md").write_text("# Roadmap\n", encoding="utf-8") + (bare / "docs" / "zh" / "roadmap.md").write_text("# 路线图\n", encoding="utf-8") + assert release_docs.main([*args, "--roadmap", "One line", "--roadmap-zh", "一句话"]) == 1 + assert "no past-releases table" in capsys.readouterr().err + assert "## [1.0.1]" not in (bare / "CHANGELOG.md").read_text(encoding="utf-8") + + # without the flags the rows are left to hand work, and the message says so + again = tmp_path / "again" + again.mkdir() + monkeypatch.chdir(again) + _tree(again) + assert release_docs.main(["1.0.0", "1.0.2", "Keel", "Trim", "2026-10-21"]) == 0 + assert "run again with --roadmap and --roadmap-zh" in capsys.readouterr().out diff --git a/tests/test_reminders.py b/tests/test_reminders.py index 9d95261d..fd4c37aa 100644 --- a/tests/test_reminders.py +++ b/tests/test_reminders.py @@ -7,6 +7,7 @@ import pytest from nanomuse.reminders import ReminderStore, parse_when +from nanomuse.schema import RiskLevel from nanomuse.tools import Reminders @@ -99,7 +100,10 @@ def test_reminders_tool(tmp_path: Path): assert r.ok and r.output.startswith("Cancelled.") and len(changes) == 3 r = run(tool.execute(action="cancel", reminder_id=item.id)) assert not r.ok - assert tool.assess({"action": "create", "text": "call mum", "at": soon}).summary.startswith( - f"reminders create: {soon}" - ) + create = tool.assess({"action": "create", "text": "call mum", "at": soon}) + assert create.summary.startswith(f"reminders create: {soon}") + # creating standing work is a change of state; listing and cancelling are not + assert create.risk == RiskLevel.MODERATE + assert tool.assess({"action": "list"}).risk == RiskLevel.SAFE + assert tool.assess({"action": "cancel", "reminder_id": "r_x"}).risk == RiskLevel.SAFE store.close() diff --git a/tests/test_server.py b/tests/test_server.py index 16409214..e870113b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1321,6 +1321,24 @@ def test_app_settings_are_layered_on_config(server, settings: Settings): assert fresh.connectors.email.imap_host == "imap.x" and fresh.browser.enabled is True +def test_a_broken_app_settings_file_is_reported_not_silently_a_first_start(tmp_path: Path): + from loguru import logger + + from nanomuse.config import load_app_settings + + data_dir = tmp_path / "data" + data_dir.mkdir() + (data_dir / "app-settings.json").write_text('{"version": 1, "llm": {"model": "m2"', "utf-8") + lines: list[str] = [] + sink = logger.add(lambda m: lines.append(str(m)), level="WARNING") + try: + assert load_app_settings(data_dir) == {} + assert load_app_settings(tmp_path / "nowhere") == {}, "no file is a quiet first start" + finally: + logger.remove(sink) + assert len(lines) == 1 and "app-settings.json" in lines[0], lines + + def test_files_are_scoped_to_workspace(server, settings: Settings): client, _, _ = server (settings.agent.workspace / "notes").mkdir() diff --git a/tests/test_tools.py b/tests/test_tools.py index ee362636..6f510865 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -1,5 +1,7 @@ from __future__ import annotations +import contextlib +import socket import sys from pathlib import Path @@ -318,6 +320,57 @@ def fine(request: httpx.Request) -> httpx.Response: assert resp.status_code == 200 and resp.text == "moved here" and str(resp.url).endswith("/new") +async def test_web_fetch_refuses_local_names_without_dns(monkeypatch: pytest.MonkeyPatch): + """``localhost`` and ``.internal`` names are refused by name; the resolver is never asked, + so the refusal does not depend on what the machine's DNS would answer (#339).""" + + def no_dns(*args: object, **kwargs: object) -> object: + raise AssertionError(f"the resolver was asked: {args!r}") + + monkeypatch.setattr(socket, "getaddrinfo", no_dns) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="should never be reached") + + transport = httpx.MockTransport(handler) + for url in ("http://localhost/admin", "http://metadata.internal/latest"): + with pytest.raises(PermissionError, match="private/internal host"): + await fetch_public(url, timeout=5, transport=transport) + + +def test_private_host_guard_refuses_every_non_global_address(): + """Numeric literals go to the resolver as they are, so the guard's answer depends on the + address alone: every range that is not globally routable is refused, the carrier-grade + NAT block (a Tailscale network) and IPv4-mapped IPv6 addresses among them; decimal and + IPv6 spellings of loopback count as loopback.""" + from nanomuse.tools.web import _is_private_host + + refused = [ + "127.0.0.1", + "10.1.2.3", + "172.16.0.9", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + "0.0.0.0", + "224.0.0.1", + "240.0.0.1", + "::1", + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "fe80::1", + "fd00::1", + ] + for host in refused: + assert _is_private_host(host), host + # 127.0.0.1 written as one number: loopback where the platform's resolver reads it so + with contextlib.suppress(socket.gaierror): + socket.getaddrinfo("2130706433", None) + assert _is_private_host("2130706433") + assert not _is_private_host("93.184.216.34") + assert not _is_private_host("2606:2800:220:1:248:1893:25c8:1946") + + async def test_web_fetch_stops_reading_at_the_body_cap(monkeypatch: pytest.MonkeyPatch): from nanomuse.tools import web as web_mod diff --git a/tests/test_triggers.py b/tests/test_triggers.py index f47e63e9..98570d52 100644 --- a/tests/test_triggers.py +++ b/tests/test_triggers.py @@ -8,6 +8,7 @@ import pytest +from nanomuse.schema import RiskLevel from nanomuse.tools import Triggers from nanomuse.triggers import TriggerStore, matches @@ -113,10 +114,14 @@ def test_triggers_tool(tmp_path: Path): assert store.active("hook") == [] and changes == [1, 1, 1] assert run(tool.execute(action="rename")).error - summary = tool.assess( + create = tool.assess( {"action": "create", "kind": "mail", "match": "landlord", "text": "draft a reply"} - ).summary - assert summary == "triggers create: mail “landlord”: draft a reply" + ) + assert create.summary == "triggers create: mail “landlord”: draft a reply" + # creating standing work is a change of state; listing and cancelling are not + assert create.risk == RiskLevel.MODERATE + assert tool.assess({"action": "list"}).risk == RiskLevel.SAFE + assert tool.assess({"action": "cancel", "trigger_id": "t_x"}).risk == RiskLevel.SAFE store.close() diff --git a/web/index.html b/web/index.html index 4e57bbdd..0aee4c01 100644 --- a/web/index.html +++ b/web/index.html @@ -6,7 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" /> - +