mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
1 Commits
v0.2.14
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6a5ef0aa8 |
59
.env.example
59
.env.example
@@ -4,23 +4,8 @@ POSTGRES_USER=multica
|
||||
POSTGRES_PASSWORD=multica
|
||||
POSTGRES_PORT=5432
|
||||
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
|
||||
# You can also set pool_max_conns / pool_min_conns as query params on
|
||||
# DATABASE_URL; env vars below take precedence over URL params.
|
||||
# DATABASE_MAX_CONNS=25
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
@@ -36,28 +21,17 @@ MULTICA_CODEX_MODEL=
|
||||
MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Self-host image channel
|
||||
# Default stable release channel. Pin to an exact release like v0.2.4 if you
|
||||
# want to stay on a specific version. If the selected tag has not been
|
||||
# published to GHCR yet, use make selfhost-build / the build override instead.
|
||||
MULTICA_IMAGE_TAG=latest
|
||||
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
|
||||
# Google OAuth
|
||||
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
|
||||
# changing it only requires restarting the backend / compose stack. No web
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
|
||||
|
||||
# S3 / CloudFront
|
||||
S3_BUCKET=
|
||||
@@ -66,13 +40,6 @@ CLOUDFRONT_KEY_PAIR_ID=
|
||||
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
|
||||
CLOUDFRONT_PRIVATE_KEY=
|
||||
CLOUDFRONT_DOMAIN=
|
||||
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
|
||||
# Leave empty for single-host deployments (localhost, LAN IP, or a single
|
||||
# hostname) — session cookies become host-only, which is what the browser
|
||||
# wants. Only set it when the frontend and backend sit on different
|
||||
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
|
||||
# to an IP address: RFC 6265 forbids IP literals in the cookie Domain
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
@@ -96,25 +63,3 @@ NEXT_PUBLIC_WS_URL=
|
||||
# Remote API (optional) — set to proxy local frontend to a remote backend
|
||||
# Leave empty to use local backend (localhost:8080)
|
||||
# REMOTE_API_URL=https://multica-api.copilothub.ai
|
||||
|
||||
# ==================== Self-hosting: Control Signups (fixes #930) ====================
|
||||
# Set to "false" to completely disable new user signups (recommended for private instances)
|
||||
ALLOW_SIGNUP=true
|
||||
# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this
|
||||
# only requires restarting the backend / compose stack — not rebuilding web.
|
||||
# It is not hot-reloaded.
|
||||
|
||||
# Optional: Only allow emails from these domains (comma-separated)
|
||||
ALLOWED_EMAIL_DOMAINS=
|
||||
|
||||
# Optional: Only allow these exact email addresses (comma-separated)
|
||||
ALLOWED_EMAILS=
|
||||
|
||||
# ==================== Analytics (PostHog) ====================
|
||||
# Product analytics events feed the acquisition → activation → expansion funnel.
|
||||
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
|
||||
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
59
.github/workflows/desktop-smoke.yml
vendored
59
.github/workflows/desktop-smoke.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Desktop Smoke Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
desktop:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish never
|
||||
|
||||
- name: Upload Desktop artifacts (${{ matrix.target }})
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-${{ matrix.target }}
|
||||
path: apps/desktop/dist
|
||||
if-no-files-found: error
|
||||
181
.github/workflows/release.yml
vendored
181
.github/workflows/release.yml
vendored
@@ -3,21 +3,15 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
# GitHub Actions uses glob patterns here, not regex. Match versioned
|
||||
# tags broadly at the trigger layer, then enforce strict semver below.
|
||||
- "v*.*.*"
|
||||
- "!v*-dirty*"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.release_meta.outputs.tag_name }}
|
||||
is_stable: ${{ steps.release_meta.outputs.is_stable }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -25,25 +19,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag name
|
||||
id: release_meta
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
echo "Triggered by tag: $tag"
|
||||
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$tag" == *-dirty* ]]; then
|
||||
echo "::error::Refusing to release from dirty tag '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$tag" == *-* ]]; then
|
||||
echo "is_stable=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_stable=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -54,21 +36,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -77,145 +44,3 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
docker-images:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-docker-images-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute backend image tags
|
||||
id: meta_backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-backend
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
|
||||
type=raw,value=${{ needs.verify.outputs.tag_name }}
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Backend
|
||||
org.opencontainers.image.description=Multica self-hosted backend
|
||||
|
||||
- name: Build and push backend image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
pull: true
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.meta_backend.outputs.labels }}
|
||||
tags: ${{ steps.meta_backend.outputs.tags }}
|
||||
cache-from: type=gha,scope=release-backend
|
||||
cache-to: type=gha,mode=max,scope=release-backend
|
||||
build-args: |
|
||||
VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
COMMIT=${{ github.sha }}
|
||||
|
||||
- name: Compute web image tags
|
||||
id: meta_web
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-web
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
|
||||
type=raw,value=${{ needs.verify.outputs.tag_name }}
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Web
|
||||
org.opencontainers.image.description=Multica self-hosted web frontend
|
||||
|
||||
- name: Build and push web image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
pull: true
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
labels: ${{ steps.meta_web.outputs.labels }}
|
||||
tags: ${{ steps.meta_web.outputs.tags }}
|
||||
cache-from: type=gha,scope=release-web
|
||||
cache-to: type=gha,mode=max,scope=release-web
|
||||
build-args: |
|
||||
REMOTE_API_URL=http://backend:8080
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
|
||||
# Build the Desktop installers for Linux and Windows and upload them to
|
||||
# the GitHub Release that the `release` job above just published. macOS
|
||||
# Desktop continues to ship via the manual `release-desktop` skill so it
|
||||
# can be signed + notarized with Apple Developer credentials that are
|
||||
# not (yet) wired into CI.
|
||||
desktop:
|
||||
needs: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux
|
||||
- os: windows-latest
|
||||
target: win
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install rpmbuild (Linux)
|
||||
if: matrix.target == 'linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Package Desktop installers (${{ matrix.target }})
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# electron-builder's GitHub publisher reads this:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable code signing on Linux/Windows for now — the public
|
||||
# release is unsigned for these platforms, the CLI carries the
|
||||
# trust boundary. Set CSC_LINK in repo secrets to enable
|
||||
# Windows signing later.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,4 +57,3 @@ _features/
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
@@ -21,12 +21,12 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
# Legacy archive name kept so already-released CLIs (whose `multica update`
|
||||
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
|
||||
# once those versions are no longer in use.
|
||||
- id: legacy
|
||||
- id: default
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
@@ -34,16 +34,6 @@ archives:
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
# Versioned archive name used by current CLI / install scripts /
|
||||
# desktop bootstrap going forward.
|
||||
- id: versioned
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
@@ -58,8 +48,6 @@ changelog:
|
||||
|
||||
brews:
|
||||
- name: multica
|
||||
ids:
|
||||
- versioned
|
||||
repository:
|
||||
owner: multica-ai
|
||||
name: homebrew-tap
|
||||
|
||||
30
CLAUDE.md
30
CLAUDE.md
@@ -106,7 +106,6 @@ pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
@@ -220,35 +219,26 @@ Cross-workspace `push(path)` is detected by the navigation adapter (`platform/na
|
||||
|
||||
### Drag region (macOS window-move)
|
||||
|
||||
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
|
||||
Every full-window desktop view (login, overlay, any page that covers the native title bar) needs a top drag strip so users can move the window. On macOS the traffic lights are hidden via `useImmersiveMode` in overlay-style contexts, so the drag strip also gives back that corner for pointer-drag.
|
||||
|
||||
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
|
||||
**Pattern**: flex child at top, not absolute overlay.
|
||||
|
||||
```tsx
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col px-6 pb-12">
|
||||
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
|
||||
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
|
||||
</div>
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
<div className="h-12 shrink-0" style={{ WebkitAppRegion: "drag" }} />
|
||||
<div className="flex-1 overflow-auto" style={{ WebkitAppRegion: "no-drag" }}>
|
||||
{/* page content — interactive elements need their own "no-drag" */}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
```
|
||||
|
||||
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
|
||||
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Height matches `MainTopBar` (48px / `h-12`) for consistency.
|
||||
|
||||
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
|
||||
|
||||
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
|
||||
|
||||
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
|
||||
Canonical examples: `components/window-overlay.tsx`, `pages/login.tsx`.
|
||||
|
||||
### UX vs platform chrome
|
||||
|
||||
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
|
||||
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (drag strip, `useImmersiveMode`, tab system interaction, traffic-light accommodation) lives in desktop-only code. Violating this split always produces platform divergence — if a button exists on desktop but not on web for the same flow, it's a signal the UX escaped into platform code.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
|
||||
@@ -76,8 +76,7 @@ fi
|
||||
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
|
||||
|
||||
# Download and extract
|
||||
VERSION="${LATEST#v}"
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
|
||||
@@ -592,19 +592,6 @@ If you want to stop PostgreSQL and keep your local databases:
|
||||
make db-down
|
||||
```
|
||||
|
||||
If you want a fresh database for the current checkout only (drops the
|
||||
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
|
||||
|
||||
```bash
|
||||
make stop # stop backend/frontend first
|
||||
make db-reset
|
||||
make start
|
||||
```
|
||||
|
||||
- only affects the current env's database; other worktree databases are untouched
|
||||
- refuses to run if `DATABASE_URL` points at a remote host
|
||||
- pass `ENV_FILE=.env.worktree` to target a specific worktree
|
||||
|
||||
If you want to wipe all local PostgreSQL data for this repo:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -36,11 +36,11 @@ RUN pnpm install --frozen-lockfile --offline
|
||||
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_WS_URL
|
||||
ARG NEXT_PUBLIC_APP_VERSION=dev
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
ENV STANDALONE=true
|
||||
|
||||
# Build the web app (standalone output for minimal runtime)
|
||||
|
||||
164
Makefile
164
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,23 +36,10 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# Default target changed from selfhost to help: bare `make` now prints this help
|
||||
# instead of launching a full Docker Compose build, which is safer for onboarding.
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
##@ Help
|
||||
|
||||
help: ## Show available make targets and common local workflows
|
||||
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
|
||||
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
|
||||
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
makehelp: help ## Alias for `make help`
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
##@ Self-hosting
|
||||
|
||||
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
@@ -64,16 +51,8 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Pulling official Multica images..."
|
||||
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
|
||||
echo ""; \
|
||||
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
|
||||
echo "If this is before the first GHCR release, build from the current checkout:"; \
|
||||
echo " make selfhost-build"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
@@ -87,11 +66,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
@@ -102,57 +77,16 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Building Multica from the current checkout..."
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done
|
||||
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
echo ""; \
|
||||
echo "✓ Multica is running!"; \
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo ""; \
|
||||
echo "Built images locally via docker-compose.selfhost.build.yml."; \
|
||||
echo "Local tags: multica-backend:dev and multica-web:dev."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
echo " multica setup self-host"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
selfhost-stop: ## Stop the self-hosted Docker Compose stack
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
##@ One-click
|
||||
|
||||
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
setup:
|
||||
$(REQUIRE_ENV)
|
||||
@echo "==> Using env file: $(ENV_FILE)"
|
||||
@echo "==> Installing dependencies..."
|
||||
@@ -163,7 +97,8 @@ setup: ## Prepare the current checkout from its env file: install deps, ensure D
|
||||
@echo ""
|
||||
@echo "✓ Setup complete! Run 'make start' to launch the app."
|
||||
|
||||
start: ## Start backend and frontend for the current checkout and run migrations first
|
||||
# Start all services (backend + frontend)
|
||||
start:
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Using env file: $(ENV_FILE)"
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@@ -177,7 +112,8 @@ start: ## Start backend and frontend for the current checkout and run migrations
|
||||
pnpm dev:web & \
|
||||
wait
|
||||
|
||||
stop: ## Stop backend and frontend processes for the current checkout
|
||||
# Stop all services
|
||||
stop:
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@@ -189,52 +125,33 @@ stop: ## Stop backend and frontend processes for the current checkout
|
||||
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
|
||||
esac
|
||||
|
||||
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
$(REQUIRE_ENV)
|
||||
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
|
||||
|
||||
db-up: ## Start the shared PostgreSQL container used by main and worktrees
|
||||
db-up:
|
||||
@$(COMPOSE) up -d postgres
|
||||
|
||||
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
|
||||
db-down:
|
||||
@$(COMPOSE) down
|
||||
|
||||
# Drop + recreate the current env's database, then run all migrations.
|
||||
# Use for a clean slate in local dev. Only affects the DB named in
|
||||
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
|
||||
# worktree DBs are untouched. Refuses to run against a remote host.
|
||||
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
|
||||
$(REQUIRE_ENV)
|
||||
@case "$(DATABASE_URL)" in \
|
||||
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
|
||||
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
|
||||
esac
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
|
||||
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
|
||||
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
|
||||
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
|
||||
@echo "==> Running migrations..."
|
||||
cd server && go run ./cmd/migrate up
|
||||
@echo ""
|
||||
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
|
||||
|
||||
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
|
||||
worktree-env:
|
||||
@bash scripts/init-worktree-env.sh .env.worktree
|
||||
|
||||
setup-main: ## Prepare the main checkout using .env
|
||||
setup-main:
|
||||
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
start-main: ## Start the main checkout using .env
|
||||
start-main:
|
||||
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
stop-main: ## Stop the main checkout processes defined by .env
|
||||
stop-main:
|
||||
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
check-main: ## Run the full verification pipeline for the main checkout
|
||||
check-main:
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
|
||||
setup-worktree:
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
@@ -243,68 +160,65 @@ setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree: ## Start this worktree using .env.worktree
|
||||
start-worktree:
|
||||
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
stop-worktree: ## Stop this worktree's backend and frontend processes
|
||||
stop-worktree:
|
||||
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
check-worktree: ## Run the full verification pipeline for this worktree
|
||||
check-worktree:
|
||||
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
# ---------- Individual commands ----------
|
||||
##@ Individual commands
|
||||
|
||||
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
|
||||
# One-command dev: auto-setup env/deps/db/migrations, then start all services
|
||||
dev:
|
||||
@bash scripts/dev.sh
|
||||
|
||||
server: ## Run only the Go server for the current checkout
|
||||
# Go server only
|
||||
server:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
|
||||
daemon:
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
|
||||
|
||||
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
|
||||
cli:
|
||||
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
|
||||
|
||||
multica: ## Run the multica CLI entrypoint directly from the Go source tree
|
||||
multica:
|
||||
cd server && go run ./cmd/multica $(MULTICA_ARGS)
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
|
||||
test:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
##@ Database
|
||||
|
||||
migrate-up: ## Create the target DB if needed, then apply database migrations
|
||||
migrate-up:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
|
||||
migrate-down: ## Create the target DB if needed, then roll back database migrations
|
||||
migrate-down:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate down
|
||||
|
||||
sqlc: ## Regenerate sqlc code
|
||||
sqlc:
|
||||
cd server && sqlc generate
|
||||
|
||||
# Cleanup
|
||||
##@ Cleanup
|
||||
|
||||
clean: ## Remove generated server binaries and temp files
|
||||
clean:
|
||||
rm -rf server/bin server/tmp
|
||||
|
||||
@@ -85,8 +85,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
@@ -54,10 +54,6 @@ make selfhost
|
||||
|
||||
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
@@ -73,8 +69,6 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
@@ -162,15 +156,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
|
||||
|
||||
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
|
||||
## Upgrading
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,7 +186,6 @@ JWT_SECRET=$(openssl rand -hex 32)
|
||||
Then start everything:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
|
||||
@@ -14,15 +14,6 @@ All configuration is done via environment variables. Copy `.env.example` as a st
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Database Pool Tuning (Optional)
|
||||
|
||||
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
|
||||
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
@@ -42,18 +33,6 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### Signup Controls (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
@@ -65,14 +44,7 @@ For file uploads and attachments, configure S3 and CloudFront:
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
### Cookies
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
|
||||
|
||||
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
@@ -246,7 +218,7 @@ When using separate domains for frontend and backend, set these environment vari
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
|
||||
# Frontend (set before building the frontend image)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
@@ -262,15 +234,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then restart the stack:
|
||||
Then rebuild:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
|
||||
|
||||
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
|
||||
|
||||
## Health Check
|
||||
|
||||
@@ -286,9 +258,8 @@ Use this for load balancer health checks or monitoring.
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
|
||||
@@ -21,26 +21,23 @@ mac:
|
||||
- zip
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
|
||||
# so the filename alone surfaces kind, version, platform, and CPU arch.
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
|
||||
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
|
||||
# unaffected because `pnpm package` already requires the Developer ID
|
||||
# signing cert — notarization is a strict superset.
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
|
||||
@@ -2,31 +2,17 @@
|
||||
"name": "@multica/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
"homepage": "https://multica.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/multica-ai/multica.git",
|
||||
"directory": "apps/desktop"
|
||||
},
|
||||
"author": {
|
||||
"name": "Multica",
|
||||
"email": "support@multica.ai"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "node scripts/package.mjs",
|
||||
"package:all": "node scripts/package.mjs --all-platforms --publish never",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
@@ -39,7 +25,6 @@
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource-variable/inter": "^5.2.5",
|
||||
"@fontsource-variable/source-serif-4": "^5.2.9",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -23,54 +23,8 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const PLATFORM_TO_GOOS = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
|
||||
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
|
||||
|
||||
function runtimePlatformFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-platform");
|
||||
if (flagIndex === -1) return process.platform;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function runtimeArchFromArgs(argv) {
|
||||
const flagIndex = argv.indexOf("--target-arch");
|
||||
if (flagIndex === -1) return process.arch;
|
||||
return argv[flagIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
function normalizeRuntimePlatform(platform) {
|
||||
if (platform in PLATFORM_TO_GOOS) return platform;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target platform: ${platform}. ` +
|
||||
"Use darwin, linux, or win32.",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeArch(arch) {
|
||||
if (SUPPORTED_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[bundle-cli] unsupported target architecture: ${arch}. ` +
|
||||
"Use x64 or arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
function binaryNameForPlatform(platform) {
|
||||
return platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
const targetPlatform = normalizeRuntimePlatform(
|
||||
runtimePlatformFromArgs(process.argv.slice(2)),
|
||||
);
|
||||
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
|
||||
const goos = PLATFORM_TO_GOOS[targetPlatform];
|
||||
const goarch = targetArch === "x64" ? "amd64" : targetArch;
|
||||
const binName = binaryNameForPlatform(targetPlatform);
|
||||
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
@@ -107,9 +61,8 @@ if (hasGo()) {
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
);
|
||||
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
@@ -117,19 +70,10 @@ if (hasGo()) {
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
srcBinary,
|
||||
join("bin", binName),
|
||||
"./cmd/multica",
|
||||
],
|
||||
{
|
||||
cwd: serverDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
},
|
||||
},
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -144,11 +88,9 @@ if (!(await exists(srcBinary))) {
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await rm(destDir, { recursive: true, force: true });
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
|
||||
// produces matching CLI and Desktop versions.
|
||||
//
|
||||
// Builds the Electron bundles once, then for each requested target
|
||||
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
|
||||
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
|
||||
// the override applies at build time without mutating the tracked
|
||||
// package.json.
|
||||
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
|
||||
// into resources/bin/), then `electron-vite build` to produce the
|
||||
// main/preload/renderer bundles under out/, then invokes electron-builder
|
||||
// with `-c.extraMetadata.version=<derived>` so the override applies at
|
||||
// build time without mutating the tracked package.json.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
@@ -25,50 +25,11 @@
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { delimiter, dirname, resolve } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(here, "..");
|
||||
const bundleCliScript = resolve(here, "bundle-cli.mjs");
|
||||
|
||||
const PLATFORM_CONFIG = {
|
||||
mac: {
|
||||
aliases: new Set(["--mac", "--macos", "-m"]),
|
||||
builderFlag: "--mac",
|
||||
runtimePlatform: "darwin",
|
||||
label: "macOS",
|
||||
},
|
||||
win: {
|
||||
aliases: new Set(["--win", "--windows", "-w"]),
|
||||
builderFlag: "--win",
|
||||
runtimePlatform: "win32",
|
||||
label: "Windows",
|
||||
},
|
||||
linux: {
|
||||
aliases: new Set(["--linux", "-l"]),
|
||||
builderFlag: "--linux",
|
||||
runtimePlatform: "linux",
|
||||
label: "Linux",
|
||||
},
|
||||
};
|
||||
|
||||
const ARCH_FLAGS = new Map([
|
||||
["--x64", "x64"],
|
||||
["--arm64", "arm64"],
|
||||
["--ia32", "ia32"],
|
||||
["--armv7l", "armv7l"],
|
||||
["--universal", "universal"],
|
||||
]);
|
||||
|
||||
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
|
||||
const MAC_ALL_PLATFORM_TARGETS = [
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
];
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
@@ -116,231 +77,20 @@ function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
export function envWithLocalBins(env = process.env, root = desktopRoot) {
|
||||
const pathKey =
|
||||
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
|
||||
const existingPath = env[pathKey] ?? "";
|
||||
const localBins = uniqueOrdered([
|
||||
resolve(root, "node_modules", ".bin"),
|
||||
resolve(root, "..", "..", "node_modules", ".bin"),
|
||||
]);
|
||||
const mergedPath = uniqueOrdered([
|
||||
...localBins,
|
||||
...String(existingPath)
|
||||
.split(delimiter)
|
||||
.filter(Boolean),
|
||||
]).join(delimiter);
|
||||
return { ...env, [pathKey]: mergedPath };
|
||||
}
|
||||
|
||||
function hostPlatformKey(platform = process.platform) {
|
||||
if (platform === "darwin") return "mac";
|
||||
if (platform === "win32") return "win";
|
||||
if (platform === "linux") return "linux";
|
||||
throw new Error(`[package] unsupported host platform: ${platform}`);
|
||||
}
|
||||
|
||||
function hostArchKey(arch = process.arch) {
|
||||
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
|
||||
throw new Error(
|
||||
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
|
||||
);
|
||||
}
|
||||
|
||||
function expandPlatformShorthand(token) {
|
||||
if (!/^-[mwl]{2,}$/.test(token)) return null;
|
||||
const expanded = [];
|
||||
for (const char of token.slice(1)) {
|
||||
if (char === "m") expanded.push("mac");
|
||||
if (char === "w") expanded.push("win");
|
||||
if (char === "l") expanded.push("linux");
|
||||
}
|
||||
return uniqueOrdered(expanded);
|
||||
}
|
||||
|
||||
function platformKeyForToken(token) {
|
||||
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
|
||||
if (config.aliases.has(token)) return platform;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function platformTargetsTemplate() {
|
||||
return { mac: [], win: [], linux: [] };
|
||||
}
|
||||
|
||||
export function parsePackageArgs(argv) {
|
||||
const sharedArgs = [];
|
||||
const platformTargets = platformTargetsTemplate();
|
||||
const requestedPlatforms = [];
|
||||
const requestedArchs = [];
|
||||
let allPlatforms = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--all-platforms") {
|
||||
allPlatforms = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expandedPlatforms = expandPlatformShorthand(token);
|
||||
if (expandedPlatforms) {
|
||||
requestedPlatforms.push(...expandedPlatforms);
|
||||
continue;
|
||||
}
|
||||
|
||||
const platform = platformKeyForToken(token);
|
||||
if (platform) {
|
||||
requestedPlatforms.push(platform);
|
||||
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
||||
platformTargets[platform].push(argv[i + 1]);
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const arch = ARCH_FLAGS.get(token);
|
||||
if (arch) {
|
||||
requestedArchs.push(arch);
|
||||
continue;
|
||||
}
|
||||
|
||||
sharedArgs.push(token);
|
||||
}
|
||||
|
||||
return {
|
||||
allPlatforms,
|
||||
sharedArgs,
|
||||
platformTargets,
|
||||
requestedPlatforms: uniqueOrdered(requestedPlatforms),
|
||||
requestedArchs: uniqueOrdered(requestedArchs),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
|
||||
if (parsed.allPlatforms) {
|
||||
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
|
||||
throw new Error(
|
||||
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
|
||||
);
|
||||
}
|
||||
if (platform !== "darwin") {
|
||||
throw new Error(
|
||||
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
|
||||
);
|
||||
}
|
||||
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
|
||||
}
|
||||
|
||||
const platforms =
|
||||
parsed.requestedPlatforms.length > 0
|
||||
? parsed.requestedPlatforms
|
||||
: [hostPlatformKey(platform)];
|
||||
const archs =
|
||||
parsed.requestedArchs.length > 0
|
||||
? parsed.requestedArchs
|
||||
: [hostArchKey(arch)];
|
||||
|
||||
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
|
||||
"Use --x64 or --arm64.",
|
||||
);
|
||||
}
|
||||
|
||||
return platforms.flatMap((targetPlatform) =>
|
||||
archs.map((targetArch) => ({
|
||||
platform: targetPlatform,
|
||||
arch: targetArch,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTarget(target) {
|
||||
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
|
||||
}
|
||||
|
||||
export function builderArgsForTarget(
|
||||
target,
|
||||
parsed,
|
||||
version,
|
||||
{
|
||||
disableMacNotarize = false,
|
||||
hostPlatform = process.platform,
|
||||
useScopedOutputDir = false,
|
||||
} = {},
|
||||
) {
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
|
||||
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
|
||||
const requestedTargets = parsed.platformTargets[target.platform];
|
||||
if (
|
||||
target.platform === "linux" &&
|
||||
hostPlatform !== "linux" &&
|
||||
requestedTargets.length === 0
|
||||
) {
|
||||
// electron-builder only guarantees AppImage/Snap when cross-building
|
||||
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
|
||||
// to AppImage unless the caller explicitly requests Linux targets.
|
||||
builderArgs.push("AppImage");
|
||||
} else {
|
||||
builderArgs.push(...requestedTargets);
|
||||
}
|
||||
builderArgs.push(`--${target.arch}`);
|
||||
builderArgs.push(...parsed.sharedArgs);
|
||||
if (useScopedOutputDir) {
|
||||
builderArgs.push(
|
||||
`-c.directories.output=dist/${target.platform}-${target.arch}`,
|
||||
);
|
||||
}
|
||||
// electron-builder's update metadata file is `latest.yml` for Windows
|
||||
// regardless of arch (only Linux gets an arch suffix automatically — see
|
||||
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
|
||||
// channel override, building Windows x64 and arm64 in two invocations
|
||||
// makes both publish `latest.yml` to the same GitHub Release, so the
|
||||
// second upload overwrites the first and one of the two architectures
|
||||
// ends up with no auto-update metadata. Route Windows arm64 to its own
|
||||
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
|
||||
// the renderer-side updater pins the matching channel per arch.
|
||||
if (target.platform === "win" && target.arch === "arm64") {
|
||||
builderArgs.push("-c.publish.channel=latest-arm64");
|
||||
}
|
||||
return builderArgs;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const parsed = parsePackageArgs(passthrough);
|
||||
const buildMatrix = resolveBuildMatrix(parsed);
|
||||
console.log(
|
||||
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
|
||||
);
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
// Step 1: build the Electron main/preload/renderer bundles. Without
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
// this step electron-builder silently packages whatever is already in
|
||||
// out/, which on a fresh checkout (or after a partial build) ships an
|
||||
// app that white-screens because the renderer bundle is missing.
|
||||
//
|
||||
// CI invokes this script via `node scripts/package.mjs`, so we cannot
|
||||
// rely on pnpm/npm to inject package-local binaries into PATH.
|
||||
//
|
||||
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
|
||||
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
|
||||
// PATHEXT when spawning a bare command without a shell — it would fail
|
||||
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
|
||||
// through the shell is harmless. See
|
||||
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
|
||||
const viteResult = spawnSync("electron-vite", ["build"], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
env: envWithLocalBins(),
|
||||
shell: true,
|
||||
});
|
||||
if (viteResult.error) {
|
||||
console.error(
|
||||
@@ -353,7 +103,7 @@ function main() {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -363,62 +113,43 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
|
||||
if (disableMacNotarize) {
|
||||
// Step 4: assemble electron-builder args.
|
||||
const passthrough = stripLeadingSeparator(process.argv.slice(2));
|
||||
const builderArgs = [];
|
||||
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
|
||||
|
||||
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
|
||||
// sets `notarize: true` so real releases notarize in-build (keeping the
|
||||
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
|
||||
// who just wants to smoke-test a local package doesn't have Apple
|
||||
// credentials, and would otherwise hit a hard failure at the notarize
|
||||
// step. Detect the missing env and flip notarize off for this run only.
|
||||
if (!process.env.APPLE_TEAM_ID) {
|
||||
console.warn(
|
||||
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
|
||||
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
|
||||
);
|
||||
builderArgs.push("-c.mac.notarize=false");
|
||||
}
|
||||
|
||||
const useScopedOutputDir = buildMatrix.length > 1;
|
||||
builderArgs.push(...passthrough);
|
||||
|
||||
// Step 3: for each requested target, build the matching CLI into
|
||||
// resources/bin/ and package that target in isolation.
|
||||
for (const target of buildMatrix) {
|
||||
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
|
||||
execFileSync(
|
||||
"node",
|
||||
[
|
||||
bundleCliScript,
|
||||
"--target-platform",
|
||||
PLATFORM_CONFIG[target.platform].runtimePlatform,
|
||||
"--target-arch",
|
||||
target.arch,
|
||||
],
|
||||
{
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
},
|
||||
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
|
||||
// for the script run, so spawnSync finds the binary without needing a
|
||||
// shell wrapper (avoids any risk of argv interpolation).
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
|
||||
const builderArgs = builderArgsForTarget(target, parsed, version, {
|
||||
disableMacNotarize,
|
||||
hostPlatform: process.platform,
|
||||
useScopedOutputDir,
|
||||
});
|
||||
|
||||
// Step 4: invoke electron-builder for the current target only.
|
||||
// `shell: true` for the same Windows `.cmd` shim reason as the
|
||||
// electron-vite invocation above.
|
||||
const result = spawnSync("electron-builder", builderArgs, {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
env: envWithLocalBins(),
|
||||
shell: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
"[package] failed to spawn electron-builder:",
|
||||
result.error.message,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { delimiter, resolve } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
builderArgsForTarget,
|
||||
envWithLocalBins,
|
||||
normalizeGitVersion,
|
||||
parsePackageArgs,
|
||||
resolveBuildMatrix,
|
||||
stripLeadingSeparator,
|
||||
} from "./package.mjs";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
@@ -67,207 +59,3 @@ describe("stripLeadingSeparator", () => {
|
||||
expect(stripLeadingSeparator([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePackageArgs", () => {
|
||||
it("collects per-platform targets and shared args", () => {
|
||||
expect(
|
||||
parsePackageArgs([
|
||||
"--win", "nsis",
|
||||
"--mac", "dmg", "zip",
|
||||
"--arm64",
|
||||
"--publish", "never",
|
||||
]),
|
||||
).toEqual({
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: {
|
||||
mac: ["dmg", "zip"],
|
||||
win: ["nsis"],
|
||||
linux: [],
|
||||
},
|
||||
requestedPlatforms: ["win", "mac"],
|
||||
requestedArchs: ["arm64"],
|
||||
});
|
||||
});
|
||||
|
||||
it("expands combined short flags", () => {
|
||||
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
|
||||
"mac",
|
||||
"win",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks the all-platforms shortcut", () => {
|
||||
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBuildMatrix", () => {
|
||||
it("defaults to the current host platform and arch", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([{ platform: "mac", arch: "arm64" }]);
|
||||
});
|
||||
|
||||
it("expands all-platforms on macOS", () => {
|
||||
expect(
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: true,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: [],
|
||||
requestedArchs: [],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toEqual([
|
||||
{ platform: "mac", arch: "arm64" },
|
||||
{ platform: "win", arch: "x64" },
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{ platform: "linux", arch: "arm64" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported architectures", () => {
|
||||
expect(() =>
|
||||
resolveBuildMatrix(
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: [],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["universal"],
|
||||
},
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/unsupported Desktop CLI architecture/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("builderArgsForTarget", () => {
|
||||
it("adds scoped output directories for multi-target builds", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "arm64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["arm64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{
|
||||
disableMacNotarize: true,
|
||||
hostPlatform: "darwin",
|
||||
useScopedOutputDir: true,
|
||||
},
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"-c.mac.notarize=false",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--arm64",
|
||||
"--publish",
|
||||
"never",
|
||||
"-c.directories.output=dist/win-arm64",
|
||||
"-c.publish.channel=latest-arm64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "win", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "always"],
|
||||
platformTargets: { mac: [], win: ["nsis"], linux: [] },
|
||||
requestedPlatforms: ["win"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "win32", useScopedOutputDir: true },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--win",
|
||||
"nsis",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"always",
|
||||
"-c.directories.output=dist/win-x64",
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
|
||||
expect(
|
||||
builderArgsForTarget(
|
||||
{ platform: "linux", arch: "x64" },
|
||||
{
|
||||
allPlatforms: false,
|
||||
sharedArgs: ["--publish", "never"],
|
||||
platformTargets: { mac: [], win: [], linux: [] },
|
||||
requestedPlatforms: ["linux"],
|
||||
requestedArchs: ["x64"],
|
||||
},
|
||||
"1.2.3",
|
||||
{ hostPlatform: "darwin" },
|
||||
),
|
||||
).toEqual([
|
||||
"-c.extraMetadata.version=1.2.3",
|
||||
"--linux",
|
||||
"AppImage",
|
||||
"--x64",
|
||||
"--publish",
|
||||
"never",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envWithLocalBins", () => {
|
||||
it("prepends desktop-local binary directories to PATH", () => {
|
||||
const desktopRoot = "/repo/apps/desktop";
|
||||
const result = envWithLocalBins(
|
||||
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
|
||||
desktopRoot,
|
||||
);
|
||||
expect(result.PATH.split(delimiter)).toEqual([
|
||||
resolve(desktopRoot, "node_modules", ".bin"),
|
||||
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves an existing Path key and avoids duplicate entries", () => {
|
||||
const desktopRoot = "/repo/apps/desktop";
|
||||
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
|
||||
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
|
||||
const result = envWithLocalBins(
|
||||
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
|
||||
desktopRoot,
|
||||
);
|
||||
expect(result).not.toHaveProperty("PATH");
|
||||
expect(result.Path.split(delimiter)).toEqual([
|
||||
desktopBin,
|
||||
workspaceBin,
|
||||
"runner-bin",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,35 @@ import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
// Desktop prefers the bundled `multica` CLI shipped inside the app for
|
||||
// same-repo builds, but it can also repair or bootstrap a managed copy in
|
||||
// userData on first launch when the bundled binary is missing or unusable.
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
@@ -72,8 +92,14 @@ async function sha256OfFile(path: string): Promise<string> {
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
expected: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
@@ -92,14 +118,7 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const checksums = await fetchChecksums();
|
||||
const assetName = selectPlatformReleaseAssetName(checksums.keys());
|
||||
const expectedChecksum = checksums.get(assetName);
|
||||
if (!expectedChecksum) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const assetName = platformAssetName();
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
@@ -111,7 +130,7 @@ async function installFresh(): Promise<string> {
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName, expectedChecksum);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
@@ -124,7 +143,6 @@ async function installFresh(): Promise<string> {
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rm(target, { force: true }).catch(() => {});
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
@@ -148,10 +166,8 @@ async function installFresh(): Promise<string> {
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(
|
||||
options: { forceInstall?: boolean } = {},
|
||||
): Promise<string> {
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target) && !options.forceInstall) return target;
|
||||
if (existsSync(target)) return target;
|
||||
return installFresh();
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
|
||||
|
||||
describe("selectPlatformReleaseAssetName", () => {
|
||||
it("prefers the versioned archive name when both exist", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the legacy archive name when only legacy is present", () => {
|
||||
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica_darwin_amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed darwin archive from release assets", () => {
|
||||
const assetNames = [
|
||||
"checksums.txt",
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
"multica-cli-1.2.3-darwin-arm64.tar.gz",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
|
||||
"multica-cli-1.2.3-darwin-amd64.tar.gz",
|
||||
);
|
||||
});
|
||||
|
||||
it("matches the renamed windows zip archive", () => {
|
||||
const assetNames = [
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
"multica-cli-1.2.3-linux-amd64.tar.gz",
|
||||
];
|
||||
|
||||
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
|
||||
"multica-cli-1.2.3-windows-amd64.zip",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when the current platform asset is missing", () => {
|
||||
expect(() =>
|
||||
selectPlatformReleaseAssetName(
|
||||
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
|
||||
"darwin",
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/no release asset found/);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
|
||||
|
||||
function platformArchiveDescriptor(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): { os: string; arch: string; ext: string } {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[platform];
|
||||
const mappedArch = archMap[arch];
|
||||
if (!os || !mappedArch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
|
||||
);
|
||||
}
|
||||
const ext = platform === "win32" ? "zip" : "tar.gz";
|
||||
return { os, arch: mappedArch, ext };
|
||||
}
|
||||
|
||||
export function selectPlatformReleaseAssetName(
|
||||
assetNames: Iterable<string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string {
|
||||
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
|
||||
platform,
|
||||
arch,
|
||||
);
|
||||
const names = [...assetNames];
|
||||
|
||||
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
|
||||
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
|
||||
// only ship the legacy archive keep working.
|
||||
const suffix = `-${os}-${mappedArch}.${ext}`;
|
||||
const matches = names.filter(
|
||||
(name) =>
|
||||
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
|
||||
);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
|
||||
if (names.includes(legacyName)) {
|
||||
return legacyName;
|
||||
}
|
||||
|
||||
throw new Error(`no release asset found for current platform: ${suffix}`);
|
||||
}
|
||||
@@ -316,36 +316,6 @@ function bundledCliPath(): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function probeCliBinary(
|
||||
bin: string,
|
||||
source: "bundled" | "managed" | "path",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
if (typeof parsed.version === "string" && parsed.version.length > 0) {
|
||||
return parsed.version;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
|
||||
);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
@@ -369,55 +339,27 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
const version = await probeCliBinary(bundled, "bundled");
|
||||
if (version) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
cachedCliBinaryVersion = version;
|
||||
return bundled;
|
||||
}
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
const version = await probeCliBinary(managed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = managed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return managed;
|
||||
}
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli({
|
||||
forceInstall: existsSync(managed),
|
||||
});
|
||||
const version = await probeCliBinary(installed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = installed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return installed;
|
||||
}
|
||||
console.warn(
|
||||
`[daemon] managed CLI at ${installed} failed validation after install`,
|
||||
);
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
|
||||
const onPath = findCliOnPath();
|
||||
if (onPath) {
|
||||
const version = await probeCliBinary(onPath, "path");
|
||||
if (version) {
|
||||
cachedCliBinary = onPath;
|
||||
cachedCliBinaryVersion = version;
|
||||
return onPath;
|
||||
}
|
||||
}
|
||||
|
||||
cachedCliBinary = null;
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -428,10 +370,11 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the version of the currently resolved CLI binary. Cached for the
|
||||
* process lifetime — the bundled binary doesn't change after bundle time.
|
||||
* Reads the version of the currently resolved CLI binary by invoking
|
||||
* `multica version --output json`. Cached for the process lifetime — the
|
||||
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* wrong-arch bundled binary, etc.) so callers can fail open.
|
||||
* etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
@@ -440,7 +383,24 @@ async function getCliBinaryVersion(): Promise<string | null> {
|
||||
cachedCliBinaryVersion = null;
|
||||
return null;
|
||||
}
|
||||
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
|
||||
try {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
bin,
|
||||
["version", "--output", "json"],
|
||||
{ timeout: 5_000 },
|
||||
(err, out) => {
|
||||
if (err) reject(err);
|
||||
else resolve(out);
|
||||
},
|
||||
);
|
||||
});
|
||||
const parsed = JSON.parse(stdout) as { version?: string };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
|
||||
@@ -193,16 +193,6 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
|
||||
// preload can attach the values to `desktopAPI.appInfo` before any renderer
|
||||
// code reads them, ensuring the very first HTTP request from the renderer
|
||||
// already carries X-Client-Version and X-Client-OS.
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -1,31 +1,9 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
|
||||
// arches would otherwise collide on the same file in the GitHub Release.
|
||||
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
|
||||
// of this pact. Pin the channel here so arm64 clients fetch
|
||||
// `latest-arm64.yml` instead of the x64 metadata.
|
||||
if (process.platform === "win32" && process.arch === "arm64") {
|
||||
autoUpdater.channel = "latest-arm64";
|
||||
}
|
||||
|
||||
const STARTUP_CHECK_DELAY_MS = 5_000;
|
||||
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export type ManualUpdateCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
available: boolean;
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
const win = getMainWindow();
|
||||
@@ -59,42 +37,10 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
// staged rollouts, downgrades, and minimum-system-version gates — in
|
||||
// those cases updateInfo.version differs from app.getVersion() but no
|
||||
// `update-available` event fires, so showing "available" here would
|
||||
// promise a download prompt that never appears.
|
||||
return {
|
||||
ok: true,
|
||||
currentVersion,
|
||||
latestVersion: result?.updateInfo.version ?? currentVersion,
|
||||
available: result?.isUpdateAvailable ?? false,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
9
apps/desktop/src/preload/index.d.ts
vendored
9
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,11 +1,6 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
appInfo: {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -58,10 +53,6 @@ interface UpdaterAPI {
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
// (async ipc.invoke) would race the ApiClient construction in initCore and
|
||||
// the first few HTTP requests would go out without X-Client-Version/OS.
|
||||
function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" | "unknown" } {
|
||||
try {
|
||||
const info = ipcRenderer.sendSync("app:get-info") as
|
||||
| { version: string; os: "macos" | "windows" | "linux" | "unknown" }
|
||||
| undefined;
|
||||
if (info && typeof info.version === "string" && typeof info.os === "string") return info;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Fallback: derive OS from process.platform; version unknown.
|
||||
const p = process.platform;
|
||||
const os: "macos" | "windows" | "linux" | "unknown" =
|
||||
p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -121,10 +96,6 @@ const updaterAPI = {
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
checkForUpdates: (): Promise<
|
||||
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
|
||||
| { ok: false; error: string }
|
||||
> => ipcRenderer.invoke("updater:check"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
@@ -92,28 +90,11 @@ function AppContent() {
|
||||
// account switches (user A logout → user B login) should not trigger a
|
||||
// daemon restart here — daemon-manager already restarts on user change
|
||||
// via syncToken.
|
||||
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
|
||||
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return;
|
||||
if (!hasOnboarded) {
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
if (wsCount === 0) {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
const wsCount = workspaces?.length ?? 0;
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
@@ -133,6 +114,22 @@ function AppContent() {
|
||||
}
|
||||
}, [workspaces]);
|
||||
|
||||
// Bidirectional new-workspace overlay: visible when there are no
|
||||
// workspaces to enter, hidden as soon as one exists. Gated on
|
||||
// `workspaceListFetched` so the initial render doesn't flash the
|
||||
// overlay before the list arrives. The overlay's own `invite` type is
|
||||
// not touched here — that's an in-flight task owned by the user.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!workspaceListFetched) return;
|
||||
const { overlay, open, close } = useWindowOverlayStore.getState();
|
||||
const isEmpty = wsCount === 0;
|
||||
if (isEmpty) {
|
||||
if (!overlay) open({ type: "new-workspace" });
|
||||
} else if (overlay?.type === "new-workspace") {
|
||||
close();
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount]);
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
// false = session started with >=1 workspace, OR we've already restarted; skip
|
||||
@@ -161,15 +158,8 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Pageview tracker sits at the app root so it covers every visible
|
||||
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
|
||||
// would miss the logged-out and overlay states.
|
||||
return (
|
||||
<>
|
||||
<PageviewTracker />
|
||||
{user ? <DesktopShell /> : <DesktopLoginPage />}
|
||||
</>
|
||||
);
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
@@ -197,20 +187,12 @@ async function handleDaemonLogout() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "desktop", version, os }),
|
||||
[version, os],
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
@@ -135,7 +134,6 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
|
||||
* `daemonAPI` (main-process daemon state) into the page so its empty
|
||||
* state can distinguish "no runtime registered" from "runtime is on its
|
||||
* way" — without the bundled daemon's status, the page shows a
|
||||
* misleading "Run multica daemon start" hint during the few seconds
|
||||
* between page load and the daemon's first registration.
|
||||
*
|
||||
* `bootstrapping` is true while the daemon is installing, starting, or
|
||||
* already running but hasn't surfaced as a server-side runtime yet.
|
||||
* RuntimeList only shows the spinner when the runtime list is also
|
||||
* empty, so once the daemon registers (and the list fills) the flag
|
||||
* has no visible effect.
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
status.state === "installing_cli" ||
|
||||
status.state === "starting" ||
|
||||
status.state === "running";
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
* 1. Logged-out state → `/login`. No workspace context, no tabs.
|
||||
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
|
||||
* that match the equivalent web routes. Overlays are NOT tab routes on
|
||||
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
|
||||
* tab path alone would either miss them or mislabel them as "/".
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
* implementation in `apps/web/components/pageview-tracker.tsx`.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
capturePageview(path);
|
||||
}, [path]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
return "/workspaces/new";
|
||||
case "onboarding":
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
}
|
||||
}
|
||||
@@ -110,25 +110,12 @@ export function UpdateNotification() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
const result = await window.updater.checkForUpdates();
|
||||
if (!result.ok) {
|
||||
setState({ status: "error", message: result.error });
|
||||
return;
|
||||
}
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="size-3.5" />
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheck}
|
||||
disabled={state.status === "checking"}
|
||||
>
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
"Check now"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useImmersiveMode } from "@multica/views/platform";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
@@ -9,21 +9,18 @@ import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: renders above the tab system when the
|
||||
* user is in a pre-workspace flow (onboarding, create workspace, accept
|
||||
* invite).
|
||||
* user is in a pre-workspace flow (create workspace, accept invite).
|
||||
*
|
||||
* This component is intentionally thin — just a fixed positioning shell
|
||||
* that covers the tab system. It does NOT hide traffic lights or provide
|
||||
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
|
||||
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
|
||||
* native macOS traffic lights stay visible and the page content can fill
|
||||
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
|
||||
* pre-dashboard flows and keeps platform chrome consistent across every
|
||||
* "not-in-dashboard" surface.
|
||||
* This component is a thin **platform shell**:
|
||||
* - Hands the window-drag strip and macOS traffic-light hiding
|
||||
* (`useImmersiveMode`) — both are platform-specific, web has neither
|
||||
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
|
||||
* doesn't leak through
|
||||
*
|
||||
* All UX affordances (Back button, Log out button, welcome copy, invite
|
||||
* card) live inside the shared view components under `packages/views/`,
|
||||
* so web and desktop render identical content.
|
||||
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
|
||||
* components under `packages/views/`, so web and desktop render identical
|
||||
* content. The platform split is: UX in shared code, chrome here.
|
||||
*/
|
||||
export function WindowOverlay() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
@@ -37,6 +34,8 @@ function WindowOverlayInner() {
|
||||
const { push } = useNavigation();
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
useImmersiveMode();
|
||||
|
||||
if (!overlay) return null;
|
||||
|
||||
// Back is only meaningful when there's somewhere to go — i.e. the user
|
||||
@@ -45,35 +44,42 @@ function WindowOverlayInner() {
|
||||
const onBack = wsList.length > 0 ? close : undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col overflow-auto bg-background">
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
{/* Window-drag strip. Rendered as a flex *child* (not absolute
|
||||
overlay) so it owns its own 48px of real layout space — the
|
||||
prior absolute-positioned approach relied on z-index stacking
|
||||
to beat the content wrapper's no-drag, which in practice didn't
|
||||
hit-test reliably for `-webkit-app-region` on the welcome
|
||||
screen. A real flex row with nothing else in it has no such
|
||||
ambiguity: any pixel at top-48 is drag, full stop.
|
||||
|
||||
Height matches `MainTopBar` (48px) so the drag-to-grab area
|
||||
feels consistent with the rest of the app. The strip is
|
||||
invisible; macOS traffic lights would normally sit here but
|
||||
`useImmersiveMode` has hidden them for the overlay's lifetime. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-12 shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@
|
||||
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
|
||||
"Apple Garamond", Baskerville, "Times New Roman", serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,6 @@ import App from "./App";
|
||||
// Geist Mono kept as-is for code blocks; CJK is handled by system font fallback
|
||||
// (see globals.css --font-sans chain). Keep font stack in sync with apps/web/app/layout.tsx.
|
||||
import "@fontsource-variable/inter";
|
||||
// Editorial serif — matches web's next/font Source_Serif_4. Loaded app-wide so
|
||||
// onboarding headings and any future editorial surface can use `font-serif`
|
||||
// (see tokens.css @theme inline). Variable font = one file covers all weights.
|
||||
import "@fontsource-variable/source-serif-4";
|
||||
import "@fontsource-variable/source-serif-4/wght-italic.css";
|
||||
import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
@@ -15,7 +14,11 @@ export function DesktopLoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<DragStrip />
|
||||
{/* Traffic light inset */}
|
||||
<div
|
||||
className="h-[38px] shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -15,11 +15,10 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -54,13 +53,6 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/onboarding") {
|
||||
overlay.open({ type: "onboarding" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
|
||||
@@ -13,14 +13,14 @@ import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
@@ -113,7 +113,7 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <DesktopRuntimesPage />,
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
@@ -130,12 +130,6 @@ export const appRoutes: RouteObject[] = [
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -14,8 +14,7 @@ import { create } from "zustand";
|
||||
*/
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "onboarding" };
|
||||
| { type: "invite"; invitationId: string };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
overlay: WindowOverlay | null;
|
||||
|
||||
@@ -169,16 +169,6 @@ Stop PostgreSQL and keep local databases:
|
||||
make db-down
|
||||
```
|
||||
|
||||
Reset only the current checkout's database (drops `POSTGRES_DB`, recreates it, re-runs all migrations). Other worktree databases are untouched.
|
||||
|
||||
```bash
|
||||
make stop
|
||||
make db-reset
|
||||
make start
|
||||
```
|
||||
|
||||
> `make db-reset` refuses to run if `DATABASE_URL` points at a remote host.
|
||||
|
||||
Wipe all local PostgreSQL data:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -53,17 +53,13 @@ make selfhost
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
@@ -74,8 +70,6 @@ Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=prod
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
<Callout>
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
</Callout>
|
||||
@@ -157,15 +151,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
|
||||
Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
</Callout>
|
||||
|
||||
## Upgrading
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
@@ -198,18 +191,6 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### Signup Controls (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
@@ -221,14 +202,7 @@ For file uploads and attachments, configure S3 and CloudFront:
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
### Cookies
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
|
||||
|
||||
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
|
||||
@@ -11,32 +11,16 @@ function createWrapper() {
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
mockSendCode,
|
||||
mockVerifyCode,
|
||||
mockIssueCliToken,
|
||||
searchParamsState,
|
||||
authStateRef,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockSendCode, mockVerifyCode } = vi.hoisted(() => ({
|
||||
mockSendCode: vi.fn(),
|
||||
mockVerifyCode: vi.fn(),
|
||||
mockIssueCliToken: vi.fn(),
|
||||
searchParamsState: { params: new URLSearchParams() },
|
||||
authStateRef: {
|
||||
state: {
|
||||
sendCode: vi.fn(),
|
||||
verifyCode: vi.fn(),
|
||||
user: null as null | { id: string; email: string },
|
||||
isLoading: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/login",
|
||||
useSearchParams: () => searchParamsState.params,
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
|
||||
@@ -48,12 +32,15 @@ vi.mock("@multica/core/auth", async () => {
|
||||
await vi.importActual<typeof import("@multica/core/auth")>(
|
||||
"@multica/core/auth",
|
||||
);
|
||||
authStateRef.state.sendCode = mockSendCode;
|
||||
authStateRef.state.verifyCode = mockVerifyCode;
|
||||
const authState = {
|
||||
sendCode: mockSendCode,
|
||||
verifyCode: mockVerifyCode,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
};
|
||||
const useAuthStore = Object.assign(
|
||||
(selector: (s: typeof authStateRef.state) => unknown) =>
|
||||
selector(authStateRef.state),
|
||||
{ getState: () => authStateRef.state },
|
||||
(selector: (s: typeof authState) => unknown) => selector(authState),
|
||||
{ getState: () => authState },
|
||||
);
|
||||
return { ...actual, useAuthStore };
|
||||
});
|
||||
@@ -70,7 +57,6 @@ vi.mock("@multica/core/api", () => ({
|
||||
verifyCode: vi.fn(),
|
||||
setToken: vi.fn(),
|
||||
getMe: vi.fn(),
|
||||
issueCliToken: mockIssueCliToken,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,9 +65,6 @@ import LoginPage from "./page";
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsState.params = new URLSearchParams();
|
||||
authStateRef.state.user = null;
|
||||
authStateRef.state.isLoading = false;
|
||||
});
|
||||
|
||||
it("renders login form with email input and continue button", () => {
|
||||
@@ -154,44 +137,4 @@ describe("LoginPage", () => {
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: MUL-1080 — if the user is already authenticated on the web
|
||||
// and the Desktop app redirects them to /login?platform=desktop, the web
|
||||
// must exchange the cookie session for a bearer token and hand it off via
|
||||
// the multica:// deep link, not silently redirect to the workspace page.
|
||||
it("mints a token and deep-links to Desktop when already logged in with platform=desktop", async () => {
|
||||
searchParamsState.params = new URLSearchParams({ platform: "desktop" });
|
||||
authStateRef.state.user = { id: "u1", email: "test@multica.ai" };
|
||||
mockIssueCliToken.mockImplementation(() =>
|
||||
Promise.resolve({ token: "handoff-jwt" }),
|
||||
);
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockIssueCliToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"multica://auth/callback?token=handoff-jwt",
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("button", { name: "Open Multica Desktop" }),
|
||||
).toBeInTheDocument();
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
paths,
|
||||
resolvePostAuthDestination,
|
||||
useHasOnboarded,
|
||||
} from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import Link from "next/link";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const googleClientId = useConfigStore((state) => state.googleClientId);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -38,7 +22,6 @@ function LoginPageContent() {
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const platform = searchParams.get("platform");
|
||||
const isDesktopHandoff = platform === "desktop" && !cliCallbackRaw;
|
||||
// `next` carries a protected URL the user was originally headed to
|
||||
// (e.g. /invite/{id}). With URL-driven workspaces there is no legacy
|
||||
// "/issues" default — if `next` is absent we decide after login based on
|
||||
@@ -46,59 +29,34 @@ function LoginPageContent() {
|
||||
// cannot bounce the user off-origin after a successful login.
|
||||
const nextUrl = sanitizeNextUrl(searchParams.get("next"));
|
||||
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
const [desktopError, setDesktopError] = useState("");
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Already authenticated — honor ?next= or fall back to first workspace
|
||||
// (or /onboarding if the user has none). Skip this entire path when
|
||||
// (or /workspaces/new if the user has none). Skip this entire path when
|
||||
// the user arrived to authorize the CLI.
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || cliCallbackRaw) return;
|
||||
if (isDesktopHandoff) {
|
||||
// Desktop opened the browser for login but the web session is already
|
||||
// authenticated — mint a bearer token from the cookie session and hand
|
||||
// it off via deep link instead of silently redirecting to the workspace.
|
||||
api
|
||||
.issueCliToken()
|
||||
.then(({ token }) => {
|
||||
setDesktopToken(token);
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
|
||||
})
|
||||
.catch((err) => {
|
||||
setDesktopError(
|
||||
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hasOnboarded) {
|
||||
router.replace(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.replace(nextUrl);
|
||||
return;
|
||||
}
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, isDesktopHandoff, hasOnboarded, qc]);
|
||||
const [first] = list;
|
||||
router.replace(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
// Read the latest user snapshot directly — the closure's `hasOnboarded`
|
||||
// was captured before login completed and would be stale here.
|
||||
const currentUser = useAuthStore.getState().user;
|
||||
const onboarded = currentUser?.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
if (nextUrl) {
|
||||
router.push(nextUrl);
|
||||
return;
|
||||
}
|
||||
// The LoginPage view populates the workspace list cache before calling
|
||||
// onSuccess, so it's safe to read here.
|
||||
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
|
||||
router.push(resolvePostAuthDestination(list, onboarded));
|
||||
const [first] = list;
|
||||
router.push(
|
||||
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
|
||||
);
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
@@ -110,52 +68,6 @@ function LoginPageContent() {
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
// While the desktop handoff is in progress (or has produced a token/error),
|
||||
// render a dedicated screen instead of flashing the login form or redirecting
|
||||
// away to a workspace page.
|
||||
if (isDesktopHandoff && user) {
|
||||
if (desktopError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
|
||||
<CardDescription>{desktopError}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Opening Multica</CardTitle>
|
||||
<CardDescription>
|
||||
{desktopToken
|
||||
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
|
||||
: "Preparing Desktop sign-in..."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
{desktopToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
|
||||
}}
|
||||
>
|
||||
Open Multica Desktop
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginPage
|
||||
onSuccess={handleSuccess}
|
||||
@@ -174,22 +86,6 @@ function LoginPageContent() {
|
||||
: undefined
|
||||
}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
extra={
|
||||
// Web-only nudge toward the desktop app. Copy is hardcoded EN
|
||||
// for now because the login route sits outside the landing
|
||||
// group's LocaleProvider — if this page ever becomes
|
||||
// locale-aware, the strings live in positioning doc §3.3.
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Prefer the desktop app?{" "}
|
||||
<Link
|
||||
href="/download"
|
||||
onClick={() => captureDownloadIntent("login")}
|
||||
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
|
||||
>
|
||||
Download
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
paths,
|
||||
resolvePostAuthDestination,
|
||||
useHasOnboarded,
|
||||
} from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboarding";
|
||||
|
||||
/**
|
||||
* Web shell for the onboarding flow. The route is the platform chrome on
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
* shows are hardcoded — nothing environmental to thread through.
|
||||
*/
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
const { data: workspaces = [], isFetched: workspacesFetched } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user && hasOnboarded,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
return;
|
||||
}
|
||||
if (hasOnboarded && workspacesFetched) {
|
||||
router.replace(resolvePostAuthDestination(workspaces, hasOnboarded));
|
||||
}
|
||||
}, [isLoading, user, hasOnboarded, workspacesFetched, workspaces, router]);
|
||||
|
||||
if (isLoading || !user || hasOnboarded) return null;
|
||||
|
||||
// Layout: page owns its own scroll (root layout sets `body {
|
||||
// overflow: hidden }` for the app-shell convention). OnboardingFlow
|
||||
// owns the per-step width constraint internally — Welcome renders a
|
||||
// wide two-column hero, all other steps wrap themselves at max-w-xl.
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
}
|
||||
}}
|
||||
runtimeInstructions={<CliInstallInstructions />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { LandingHeader } from "@/features/landing/components/landing-header";
|
||||
import { LandingFooter } from "@/features/landing/components/landing-footer";
|
||||
import { DownloadHero } from "@/features/landing/components/download/hero";
|
||||
import { AllPlatforms } from "@/features/landing/components/download/all-platforms";
|
||||
import { CliSection } from "@/features/landing/components/download/cli-section";
|
||||
import { CloudSection } from "@/features/landing/components/download/cloud-section";
|
||||
import { useLocale } from "@/features/landing/i18n";
|
||||
import {
|
||||
detectOS,
|
||||
type DetectResult,
|
||||
} from "@/features/landing/utils/os-detect";
|
||||
import type { LatestRelease } from "@/features/landing/utils/github-release";
|
||||
import { captureDownloadPageViewed } from "@multica/core/analytics";
|
||||
|
||||
const ALL_RELEASES_URL =
|
||||
"https://github.com/multica-ai/multica/releases";
|
||||
|
||||
export function DownloadClient({ release }: { release: LatestRelease }) {
|
||||
const [detected, setDetected] = useState<DetectResult | null>(null);
|
||||
const versionUnavailable = release.version === null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
detectOS().then((result) => {
|
||||
if (cancelled) return;
|
||||
setDetected(result);
|
||||
// Fires once per page mount after detect resolves. Carries the
|
||||
// detect outcome + version-unavailable flag so PostHog can split
|
||||
// Safari-mac-arm64 fallback rate, Intel-Mac dead-end rate, and
|
||||
// rate-limit degraded sessions. `first_detected_os/arch` is
|
||||
// $set_once'd on the person so every downstream event gains a
|
||||
// platform dimension (useful for "Android visitors who later
|
||||
// downloaded Windows" style cross-device queries once we land
|
||||
// the desktop install closure).
|
||||
captureDownloadPageViewed({
|
||||
detected_os: result.os,
|
||||
detected_arch: result.arch,
|
||||
detect_confident: result.archConfident,
|
||||
version_available: !versionUnavailable,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [versionUnavailable]);
|
||||
|
||||
const releaseHtmlUrl = release.htmlUrl ?? ALL_RELEASES_URL;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Positioning context for the dark-variant LandingHeader —
|
||||
mirrors multica-landing.tsx. The header is `absolute top-0
|
||||
inset-x-0`, so it anchors to this `relative` wrapper and
|
||||
scrolls off together with the dark hero below. Without the
|
||||
wrapper, `absolute` would escape to the initial containing
|
||||
block and read as fixed. */}
|
||||
<div className="relative">
|
||||
<LandingHeader variant="dark" />
|
||||
<DownloadHero
|
||||
detected={detected}
|
||||
assets={release.assets}
|
||||
versionUnavailable={versionUnavailable}
|
||||
version={release.version}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AllPlatforms
|
||||
assets={release.assets}
|
||||
fallbackHref={ALL_RELEASES_URL}
|
||||
version={release.version}
|
||||
detected={detected}
|
||||
/>
|
||||
<CliSection />
|
||||
<CloudSection />
|
||||
<VersionInfoFooter
|
||||
version={release.version}
|
||||
releaseHtmlUrl={releaseHtmlUrl}
|
||||
/>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionInfoFooter({
|
||||
version,
|
||||
releaseHtmlUrl,
|
||||
}: {
|
||||
version: string | null;
|
||||
releaseHtmlUrl: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.footer;
|
||||
|
||||
return (
|
||||
<section className="bg-white pb-16 text-[#0a0d12] sm:pb-20">
|
||||
<div className="mx-auto flex max-w-[920px] flex-wrap items-center gap-x-6 gap-y-2 border-t border-[#0a0d12]/8 px-4 pt-8 text-[13px] text-[#0a0d12]/60 sm:px-6 lg:px-8">
|
||||
{version ? (
|
||||
<>
|
||||
<span>
|
||||
{d.currentVersion.replace("{version}", version)}
|
||||
</span>
|
||||
<span aria-hidden className="text-[#0a0d12]/25">
|
||||
·
|
||||
</span>
|
||||
<Link
|
||||
href={releaseHtmlUrl}
|
||||
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.releaseNotes.replace("{version}", version)}
|
||||
</Link>
|
||||
<span aria-hidden className="text-[#0a0d12]/25">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{d.versionUnavailable}</span>
|
||||
<span aria-hidden className="text-[#0a0d12]/25">
|
||||
·
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
href={ALL_RELEASES_URL}
|
||||
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{d.allReleases}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { fetchLatestRelease } from "@/features/landing/utils/github-release";
|
||||
import { DownloadClient } from "./download-client";
|
||||
|
||||
// Vercel ISR: the server fetch inside fetchLatestRelease carries
|
||||
// `next: { revalidate: 300 }`, which makes GitHub API cost at most
|
||||
// one request per region per 5 minutes. Page-level revalidate mirrors
|
||||
// that window so the first paint also refreshes every 5 minutes.
|
||||
export const revalidate = 300;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Download Multica",
|
||||
description:
|
||||
"Download Multica for macOS, Windows, or Linux — or install the CLI for servers and remote dev boxes.",
|
||||
openGraph: {
|
||||
title: "Download Multica",
|
||||
description:
|
||||
"Get the Multica desktop app with a bundled daemon, or install the CLI for servers and remote dev boxes.",
|
||||
url: "/download",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/download",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function DownloadPage() {
|
||||
const release = await fetchLatestRelease();
|
||||
return <DownloadClient release={release} />;
|
||||
}
|
||||
@@ -4,21 +4,13 @@ import { DashboardLayout } from "@multica/views/layout";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={
|
||||
<>
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
<StarterContentPrompt />
|
||||
</>
|
||||
}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -10,18 +10,6 @@ const { mockPush, mockSearchParams, mockLoginWithGoogle, mockListWorkspaces } =
|
||||
mockListWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const makeUser = (overrides: Partial<{ onboarded_at: string | null }> = {}) => ({
|
||||
id: "user-1",
|
||||
name: "Test",
|
||||
email: "test@multica.ai",
|
||||
avatar_url: null,
|
||||
onboarded_at: null,
|
||||
onboarding_questionnaire: {},
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
@@ -63,44 +51,30 @@ describe("CallbackPage", () => {
|
||||
vi.clearAllMocks();
|
||||
mockSearchParams.forEach((_v, k) => mockSearchParams.delete(k));
|
||||
mockSearchParams.set("code", "test-code");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockLoginWithGoogle.mockResolvedValue(undefined);
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("unonboarded user lands on /onboarding regardless of next=", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
it("falls back to paths.newWorkspace() when no next= is present and the user has no workspace", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("/invite/abc123");
|
||||
});
|
||||
|
||||
it("unonboarded user with no next= also lands on /onboarding", async () => {
|
||||
render(<CallbackPage />);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
});
|
||||
});
|
||||
|
||||
it("onboarded user ignores unsafe next= targets and lands on the default destination", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
it("ignores unsafe next= targets from the OAuth state and still lands on the default destination", async () => {
|
||||
mockSearchParams.set("state", "next:https://evil.example");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.newWorkspace());
|
||||
});
|
||||
expect(mockPush).not.toHaveBeenCalledWith("https://evil.example");
|
||||
});
|
||||
|
||||
it("onboarded user honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
mockLoginWithGoogle.mockResolvedValue(
|
||||
makeUser({ onboarded_at: "2026-01-01T00:00:00Z" }),
|
||||
);
|
||||
it("honors a safe next= target (e.g. /invite/{id})", async () => {
|
||||
mockSearchParams.set("state", "next:/invite/abc123");
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
@@ -62,17 +62,18 @@ function CallbackContent() {
|
||||
} else {
|
||||
// Normal web flow
|
||||
loginWithGoogle(code, redirectUri)
|
||||
.then(async (loggedInUser) => {
|
||||
.then(async () => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const onboarded = loggedInUser.onboarded_at != null;
|
||||
if (!onboarded) {
|
||||
router.push(paths.onboarding());
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
nextUrl || resolvePostAuthDestination(wsList, onboarded),
|
||||
);
|
||||
// URL is now the source of truth for the current workspace — the
|
||||
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
|
||||
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
|
||||
// in the first workspace's issues, or /workspaces/new for zero-workspace users.
|
||||
const [first] = wsList;
|
||||
const defaultDest = first
|
||||
? paths.workspace(first.slug).issues()
|
||||
: paths.newWorkspace();
|
||||
router.push(nextUrl || defaultDest);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { Inter, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -39,23 +39,6 @@ const geistMono = Geist_Mono({
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
// Editorial serif used for onboarding headlines. Italic support for h1 em
|
||||
// accents (e.g. "...on one shared board."). Only loaded on routes that
|
||||
// render the font; layout-shift-prevention handled by next/font's synthetic
|
||||
// fallback metrics, same as Inter.
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
style: ["normal", "italic"],
|
||||
variable: "--font-serif",
|
||||
fallback: [
|
||||
"ui-serif",
|
||||
"Iowan Old Style",
|
||||
"Apple Garamond",
|
||||
"Baskerville",
|
||||
"Times New Roman",
|
||||
"serif",
|
||||
],
|
||||
});
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@@ -106,7 +89,7 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the Next.js App Router path or query
|
||||
* string changes. Mounted once at the root so every route transition is
|
||||
* covered, including transitions into workspace-scoped subtrees.
|
||||
*
|
||||
* PostHog's own `capture_pageview: true` auto-capture is deliberately
|
||||
* disabled in `initAnalytics` so we own the event shape — this component
|
||||
* is what actually fires the event. Before this existed the acquisition
|
||||
* funnel's `/ → signup` step was empty.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
const qs = searchParams?.toString();
|
||||
const url = qs ? `${pathname}?${qs}` : pathname;
|
||||
capturePageview(url);
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import packageJson from "../package.json";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
setLoggedInCookie,
|
||||
clearLoggedInCookie,
|
||||
} from "@/features/auth/auth-cookie";
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
// Legacy token in localStorage → keep this session in token mode so users who
|
||||
// logged in before the cookie-auth migration stay authed. They migrate to
|
||||
@@ -35,20 +32,8 @@ function deriveWsUrl(): string | undefined {
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
// Build-time version preferred (CI sets NEXT_PUBLIC_APP_VERSION to a git tag
|
||||
// or sha so different deploys are distinguishable in server logs); fall back
|
||||
// to the package.json version so local dev still reports something useful.
|
||||
const WEB_VERSION =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version || "dev";
|
||||
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
const cookieAuth = !hasLegacyToken();
|
||||
// Stable identity reference so downstream effects keyed on it don't see a
|
||||
// new object on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "web", version: WEB_VERSION }),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
@@ -56,13 +41,7 @@ export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
cookieAuth={cookieAuth}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
identity={identity}
|
||||
>
|
||||
{/* Suspense boundary is required by Next.js for useSearchParams in
|
||||
a client component mounted this high in the tree. */}
|
||||
<Suspense fallback={null}>
|
||||
<PageviewTracker />
|
||||
</Suspense>
|
||||
<WebNavigationProvider>{children}</WebNavigationProvider>
|
||||
</CoreProvider>
|
||||
);
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import {
|
||||
captureDownloadInitiated,
|
||||
type DownloadInitiatedPayload,
|
||||
} from "@multica/core/analytics";
|
||||
import { useLocale } from "../../i18n";
|
||||
import type { DetectResult } from "../../utils/os-detect";
|
||||
import type { DownloadAssets } from "../../utils/parse-release-assets";
|
||||
import { AppleIcon, LinuxIcon, WindowsIcon } from "./os-icons";
|
||||
|
||||
type Platform = DownloadInitiatedPayload["platform"];
|
||||
type Arch = DownloadInitiatedPayload["arch"];
|
||||
type Format = DownloadInitiatedPayload["format"];
|
||||
|
||||
interface Props {
|
||||
assets: DownloadAssets;
|
||||
/** Link to GitHub releases page, used when individual asset URLs
|
||||
* couldn't be resolved (API down / parse failure). */
|
||||
fallbackHref: string;
|
||||
/** Release tag (e.g. "v0.2.13"); null on fetch failure. */
|
||||
version: string | null;
|
||||
/** Current OS/arch guess. Used only to compute `matched_detect` on
|
||||
* the download_initiated event — the row UI itself is static. */
|
||||
detected: DetectResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full matrix of platform + arch + format links. Always visible
|
||||
* regardless of which platform the Hero resolved to — lets power
|
||||
* users grab any build directly.
|
||||
*/
|
||||
export function AllPlatforms({
|
||||
assets,
|
||||
fallbackHref,
|
||||
version,
|
||||
detected,
|
||||
}: Props) {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.allPlatforms;
|
||||
|
||||
const trackClick = (platform: Platform, arch: Arch, format: Format) => {
|
||||
if (!version) return;
|
||||
captureDownloadInitiated({
|
||||
platform,
|
||||
arch,
|
||||
format,
|
||||
version,
|
||||
// Manual pick from the matrix — Hero is the primary CTA.
|
||||
primary_cta: false,
|
||||
// True only when the row matches what we guessed client-side.
|
||||
// Lets us measure detect accuracy from the miss rate on this
|
||||
// event alone (no need to cross-join to download_page_viewed).
|
||||
matched_detect:
|
||||
!!detected &&
|
||||
detected.os === platform &&
|
||||
detected.arch === arch,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
id="all-platforms"
|
||||
className="bg-white py-20 text-[#0a0d12] sm:py-24"
|
||||
>
|
||||
<div className="mx-auto max-w-[920px] px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
|
||||
{d.title}
|
||||
</h2>
|
||||
|
||||
<div className="mt-10 overflow-hidden rounded-2xl border border-[#0a0d12]/10">
|
||||
<Row
|
||||
icon={<AppleIcon className="text-[#0a0d12]" />}
|
||||
label={d.macLabel}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatDmg,
|
||||
href: assets.macArm64Dmg,
|
||||
onClick: () => trackClick("mac", "arm64", "dmg"),
|
||||
},
|
||||
{
|
||||
label: d.formatZip,
|
||||
href: assets.macArm64Zip,
|
||||
onClick: () => trackClick("mac", "arm64", "zip"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<WindowsIcon className="text-[#0a0d12]" />}
|
||||
label={d.winX64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatExe,
|
||||
href: assets.winX64Exe,
|
||||
onClick: () => trackClick("windows", "x64", "exe"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<WindowsIcon className="text-[#0a0d12]" />}
|
||||
label={d.winArm64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatExe,
|
||||
href: assets.winArm64Exe,
|
||||
onClick: () => trackClick("windows", "arm64", "exe"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<LinuxIcon className="text-[#0a0d12]" />}
|
||||
label={d.linuxX64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatAppImage,
|
||||
href: assets.linuxAmd64AppImage,
|
||||
onClick: () => trackClick("linux", "x64", "appimage"),
|
||||
},
|
||||
{
|
||||
label: d.formatDeb,
|
||||
href: assets.linuxAmd64Deb,
|
||||
onClick: () => trackClick("linux", "x64", "deb"),
|
||||
},
|
||||
{
|
||||
label: d.formatRpm,
|
||||
href: assets.linuxAmd64Rpm,
|
||||
onClick: () => trackClick("linux", "x64", "rpm"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
/>
|
||||
<Row
|
||||
icon={<LinuxIcon className="text-[#0a0d12]" />}
|
||||
label={d.linuxArm64Label}
|
||||
formats={[
|
||||
{
|
||||
label: d.formatAppImage,
|
||||
href: assets.linuxArm64AppImage,
|
||||
onClick: () => trackClick("linux", "arm64", "appimage"),
|
||||
},
|
||||
{
|
||||
label: d.formatDeb,
|
||||
href: assets.linuxArm64Deb,
|
||||
onClick: () => trackClick("linux", "arm64", "deb"),
|
||||
},
|
||||
{
|
||||
label: d.formatRpm,
|
||||
href: assets.linuxArm64Rpm,
|
||||
onClick: () => trackClick("linux", "arm64", "rpm"),
|
||||
},
|
||||
]}
|
||||
unavailable={d.unavailable}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.intelNote}</p>
|
||||
|
||||
{isFallbackNeeded(assets) ? (
|
||||
<p className="mt-2 text-[13px] text-[#0a0d12]/60">
|
||||
<Link
|
||||
href={fallbackHref}
|
||||
className="underline decoration-[#0a0d12]/30 underline-offset-4 hover:text-[#0a0d12] hover:decoration-[#0a0d12]/70"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t.download.footer.allReleases}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Row
|
||||
// ------------------------------------------------------------
|
||||
|
||||
interface RowProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
formats: {
|
||||
label: string;
|
||||
href: string | undefined;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
unavailable: string;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
function Row({ icon, label, formats, unavailable, isLast }: RowProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-wrap items-center gap-x-6 gap-y-3 px-6 py-5 ${isLast ? "" : "border-b border-[#0a0d12]/8"}`}
|
||||
>
|
||||
<div className="flex min-w-[220px] items-center gap-3">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#0a0d12]/5">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="text-[14.5px] font-medium">{label}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{formats.map((f) =>
|
||||
f.href ? (
|
||||
<a
|
||||
key={f.label}
|
||||
href={f.href}
|
||||
onClick={f.onClick}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#0a0d12]/12 bg-white px-3 py-1.5 text-[13px] font-medium transition-colors hover:border-[#0a0d12]/30 hover:bg-[#0a0d12]/5"
|
||||
>
|
||||
{f.label}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
key={f.label}
|
||||
aria-disabled="true"
|
||||
className="inline-flex cursor-not-allowed items-center gap-1.5 rounded-lg border border-[#0a0d12]/8 bg-[#0a0d12]/5 px-3 py-1.5 text-[13px] text-[#0a0d12]/40"
|
||||
title={unavailable}
|
||||
>
|
||||
{f.label}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Ten desktop artifacts are expected per release (two Mac,
|
||||
// two Windows, six Linux). If any are missing, surface the GitHub
|
||||
// fallback link so users on an orphaned row have a way out.
|
||||
const EXPECTED_ASSET_COUNT = 10;
|
||||
|
||||
function isFallbackNeeded(assets: DownloadAssets): boolean {
|
||||
return Object.values(assets).filter(Boolean).length < EXPECTED_ASSET_COUNT;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, Terminal } from "lucide-react";
|
||||
import { useLocale } from "../../i18n";
|
||||
|
||||
const INSTALL_CMD =
|
||||
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
|
||||
const SETUP_CMD = "multica setup";
|
||||
|
||||
/**
|
||||
* Scenario-first CLI section. Copy leans into servers / remote dev
|
||||
* boxes / headless setups rather than positioning CLI as a
|
||||
* lightweight Desktop. Two copy-and-paste command blocks.
|
||||
*/
|
||||
export function CliSection() {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.cli;
|
||||
|
||||
return (
|
||||
<section id="cli" className="bg-[#f7f7f5] py-20 text-[#0a0d12] sm:py-24">
|
||||
<div className="mx-auto max-w-[820px] px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
|
||||
{d.title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[620px] text-[15px] leading-7 text-[#0a0d12]/72">
|
||||
{d.sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-5">
|
||||
<CommandBlock
|
||||
label={d.installLabel}
|
||||
cmd={INSTALL_CMD}
|
||||
copyLabel={d.copyLabel}
|
||||
copiedLabel={d.copiedLabel}
|
||||
/>
|
||||
<CommandBlock
|
||||
label={d.startLabel}
|
||||
cmd={SETUP_CMD}
|
||||
copyLabel={d.copyLabel}
|
||||
copiedLabel={d.copiedLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-[13px] text-[#0a0d12]/60">{d.sshNote}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandBlock({
|
||||
label,
|
||||
cmd,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
}: {
|
||||
label: string;
|
||||
cmd: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1800);
|
||||
} catch {
|
||||
// clipboard may be unavailable (insecure context) — silent no-op
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-[12px] font-medium uppercase tracking-[0.08em] text-[#0a0d12]/55">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex items-start gap-3 rounded-xl border border-[#0a0d12]/10 bg-white px-4 py-3 font-mono text-[13.5px]">
|
||||
<Terminal
|
||||
className="mt-0.5 size-4 shrink-0 text-[#0a0d12]/55"
|
||||
aria-hidden
|
||||
/>
|
||||
<code className="min-w-0 flex-1 whitespace-pre-wrap break-all">
|
||||
{cmd}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
aria-label={copied ? copiedLabel : copyLabel}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-[12px] font-medium text-[#0a0d12]/70 transition-colors hover:bg-[#0a0d12]/5 hover:text-[#0a0d12]"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="size-3.5" />
|
||||
{copiedLabel}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="size-3.5" />
|
||||
{copyLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CloudWaitlistExpand } from "@multica/views/onboarding";
|
||||
import { useLocale } from "../../i18n";
|
||||
|
||||
/**
|
||||
* Cloud runtime waitlist — thin wrapper around the shared
|
||||
* CloudWaitlistExpand form with a download-page-appropriate title
|
||||
* and subtitle. Submission persists via `joinCloudWaitlist` inside
|
||||
* the child; the submitted flag here only prevents double-submits
|
||||
* for the lifetime of the page.
|
||||
*/
|
||||
export function CloudSection() {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.cloud;
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="bg-white py-20 text-[#0a0d12] sm:py-24">
|
||||
<div className="mx-auto max-w-[720px] px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="font-[family-name:var(--font-serif)] text-[2.2rem] leading-[1.1] tracking-[-0.03em] sm:text-[2.6rem]">
|
||||
{d.title}
|
||||
</h2>
|
||||
<p className="mt-4 max-w-[560px] text-[15px] leading-7 text-[#0a0d12]/72">
|
||||
{d.sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-10">
|
||||
<CloudWaitlistExpand
|
||||
submitted={submitted}
|
||||
onSubmitted={() => setSubmitted(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Download } from "lucide-react";
|
||||
import {
|
||||
captureDownloadInitiated,
|
||||
type DownloadInitiatedPayload,
|
||||
} from "@multica/core/analytics";
|
||||
import { useLocale } from "../../i18n";
|
||||
import type { DetectResult } from "../../utils/os-detect";
|
||||
import type { DownloadAssets } from "../../utils/parse-release-assets";
|
||||
import { heroButtonClassName } from "../shared";
|
||||
|
||||
interface Props {
|
||||
detected: DetectResult | null;
|
||||
assets: DownloadAssets;
|
||||
/** True when the GitHub API fetch failed; disables all CTAs and
|
||||
* surfaces a "version unavailable" line. */
|
||||
versionUnavailable: boolean;
|
||||
/** Release tag (e.g. "v0.2.13"). Null when version lookup failed —
|
||||
* in that case CTAs are already disabled, no tracking fires. */
|
||||
version: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top CTA section. Server-renders a generic "Choose your platform"
|
||||
* placeholder (SEO + flash-before-hydration), then swaps to a
|
||||
* platform-specific CTA once the client detection resolves.
|
||||
*/
|
||||
export function DownloadHero({
|
||||
detected,
|
||||
assets,
|
||||
versionUnavailable,
|
||||
version,
|
||||
}: Props) {
|
||||
const { t } = useLocale();
|
||||
const d = t.download.hero;
|
||||
|
||||
const content = resolveContent(detected, assets, versionUnavailable, d);
|
||||
|
||||
// Fires download_initiated on primary CTA click. `primary_cta: true`
|
||||
// identifies the hero-recommended path; `matched_detect: true` is
|
||||
// always true here by construction (the primary is computed from
|
||||
// the detect result). All Platforms rows below emit with
|
||||
// matched_detect=false when the user overrides.
|
||||
const onPrimaryClick = (tracking: HeroTracking | undefined) => {
|
||||
if (!tracking || !version) return;
|
||||
captureDownloadInitiated({
|
||||
...tracking,
|
||||
version,
|
||||
primary_cta: true,
|
||||
matched_detect: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-[#05070b] text-white">
|
||||
<BackdropGradient />
|
||||
<div className="relative z-10 mx-auto max-w-[1120px] px-4 pb-24 pt-32 text-center sm:px-6 sm:pt-40 lg:px-8 lg:pb-28">
|
||||
<h1 className="mx-auto max-w-[880px] font-[family-name:var(--font-serif)] text-[3rem] leading-[1.02] tracking-[-0.035em] drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4rem] lg:text-[5rem]">
|
||||
{content.title}
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-[620px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
|
||||
{content.sub}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
{content.primary ? (
|
||||
<PrimaryCta
|
||||
href={content.primary.href}
|
||||
disabled={content.primary.disabled}
|
||||
onClick={() => onPrimaryClick(content.primary?.tracking)}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
{content.primary.label}
|
||||
{!content.primary.disabled && (
|
||||
<ArrowRight className="size-4" aria-hidden />
|
||||
)}
|
||||
</PrimaryCta>
|
||||
) : null}
|
||||
{content.alt ? (
|
||||
<Link
|
||||
href={content.alt.href}
|
||||
className={heroButtonClassName("ghost")}
|
||||
onClick={() => onPrimaryClick(content.alt?.tracking)}
|
||||
>
|
||||
{content.alt.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{content.hint ? (
|
||||
<p className="mx-auto mt-5 max-w-[520px] text-[13px] text-white/64">
|
||||
{content.hint}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{versionUnavailable ? (
|
||||
<p className="mx-auto mt-6 max-w-[520px] text-[12px] uppercase tracking-[0.14em] text-white/50">
|
||||
{t.download.footer.versionUnavailable}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Content resolver — maps (detect, assets) → CTA props
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type HeroTracking = Pick<
|
||||
DownloadInitiatedPayload,
|
||||
"platform" | "arch" | "format"
|
||||
>;
|
||||
|
||||
interface HeroContent {
|
||||
title: string;
|
||||
sub: string;
|
||||
primary?: {
|
||||
href: string;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
tracking?: HeroTracking;
|
||||
};
|
||||
alt?: { href: string; label: string; tracking?: HeroTracking };
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
type HeroDict = ReturnType<typeof useLocale>["t"]["download"]["hero"];
|
||||
|
||||
function resolveContent(
|
||||
detected: DetectResult | null,
|
||||
assets: DownloadAssets,
|
||||
versionUnavailable: boolean,
|
||||
d: HeroDict,
|
||||
): HeroContent {
|
||||
// Before hydration resolves, render a neutral prompt. Same copy
|
||||
// also catches `os === "unknown"`.
|
||||
if (!detected || detected.os === "unknown") {
|
||||
return { title: d.unknown.title, sub: d.unknown.sub };
|
||||
}
|
||||
|
||||
if (detected.os === "mac") {
|
||||
// Only Chromium high-entropy returns arch confidently. Safari
|
||||
// always reports Intel even on Apple Silicon, so we treat
|
||||
// "non-confident" as arm64 + add a small Intel disclaimer.
|
||||
if (detected.arch === "x64" && detected.archConfident) {
|
||||
return {
|
||||
title: d.macIntel.title,
|
||||
sub: d.macIntel.sub,
|
||||
primary: {
|
||||
href: "#cli",
|
||||
label: d.macIntel.disabledCta,
|
||||
disabled: true,
|
||||
},
|
||||
hint: d.macIntel.intelHint,
|
||||
};
|
||||
}
|
||||
const dmg = assets.macArm64Dmg;
|
||||
const zip = assets.macArm64Zip;
|
||||
return {
|
||||
title: d.macArm64.title,
|
||||
sub: d.macArm64.sub,
|
||||
primary: dmg
|
||||
? {
|
||||
href: dmg,
|
||||
label: d.macArm64.primary,
|
||||
disabled: false,
|
||||
tracking: { platform: "mac", arch: "arm64", format: "dmg" },
|
||||
}
|
||||
: versionUnavailable
|
||||
? { href: "#", label: d.macArm64.primary, disabled: true }
|
||||
: undefined,
|
||||
alt: zip
|
||||
? {
|
||||
href: zip,
|
||||
label: d.macArm64.altZip,
|
||||
tracking: { platform: "mac", arch: "arm64", format: "zip" },
|
||||
}
|
||||
: undefined,
|
||||
hint: detected.archConfident ? undefined : d.safariMacHint,
|
||||
};
|
||||
}
|
||||
|
||||
if (detected.os === "windows") {
|
||||
// Trust arch whenever the UA hints at it (even non-confident);
|
||||
// Windows-on-ARM can still run x64 via emulation so this is low
|
||||
// risk either way. Surface the arch-fallback hint when we're
|
||||
// guessing so users on uncommon setups know to scroll down.
|
||||
const isArm = detected.arch === "arm64";
|
||||
const copy = isArm ? d.winArm64 : d.winX64;
|
||||
const url = isArm ? assets.winArm64Exe : assets.winX64Exe;
|
||||
return {
|
||||
title: copy.title,
|
||||
sub: copy.sub,
|
||||
primary: url
|
||||
? {
|
||||
href: url,
|
||||
label: copy.primary,
|
||||
disabled: false,
|
||||
tracking: {
|
||||
platform: "windows",
|
||||
arch: isArm ? "arm64" : "x64",
|
||||
format: "exe",
|
||||
},
|
||||
}
|
||||
: versionUnavailable
|
||||
? { href: "#", label: copy.primary, disabled: true }
|
||||
: undefined,
|
||||
hint: detected.archConfident ? undefined : d.archFallbackHint,
|
||||
};
|
||||
}
|
||||
|
||||
// Linux — same principle: trust the arm64 signal, surface a hint
|
||||
// when we're not confident. Linux ARM has no binary emulation so
|
||||
// the hint matters more here than on Windows.
|
||||
const isArmLinux = detected.arch === "arm64";
|
||||
const primaryUrl = isArmLinux
|
||||
? assets.linuxArm64AppImage
|
||||
: assets.linuxAmd64AppImage;
|
||||
return {
|
||||
title: d.linux.title,
|
||||
sub: d.linux.sub,
|
||||
primary: primaryUrl
|
||||
? {
|
||||
href: primaryUrl,
|
||||
label: d.linux.primary,
|
||||
disabled: false,
|
||||
tracking: {
|
||||
platform: "linux",
|
||||
arch: isArmLinux ? "arm64" : "x64",
|
||||
format: "appimage",
|
||||
},
|
||||
}
|
||||
: versionUnavailable
|
||||
? { href: "#", label: d.linux.primary, disabled: true }
|
||||
: undefined,
|
||||
alt: { href: "#all-platforms", label: d.linux.altFormats },
|
||||
hint: detected.archConfident ? undefined : d.archFallbackHint,
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Pieces
|
||||
// ------------------------------------------------------------
|
||||
|
||||
function PrimaryCta({
|
||||
href,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
disabled: boolean;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<span
|
||||
aria-disabled="true"
|
||||
className="inline-flex cursor-not-allowed items-center justify-center gap-2 rounded-[12px] border border-white/15 bg-white/8 px-5 py-3 text-[14px] font-semibold text-white/60"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={href} onClick={onClick} className={heroButtonClassName("solid")}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function BackdropGradient() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse 70% 50% at 50% 0%, rgba(80,120,255,0.18), transparent 60%), radial-gradient(ellipse 50% 40% at 50% 80%, rgba(255,90,90,0.08), transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/**
|
||||
* Inline SVG marks for macOS / Windows / Linux.
|
||||
* Lucide lacks real Apple / Tux marks, and the download page needs
|
||||
* the recognizable brand glyphs next to platform rows. Kept as
|
||||
* minimal monochrome outlines so they inherit currentColor.
|
||||
*/
|
||||
|
||||
type IconProps = React.SVGProps<SVGSVGElement> & { size?: number };
|
||||
|
||||
export function AppleIcon({ size = 18, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M16.37 12.8c.02-1.9 1.56-2.83 1.63-2.87-.89-1.3-2.28-1.48-2.77-1.5-1.18-.12-2.3.69-2.9.69-.6 0-1.52-.68-2.5-.66-1.28.02-2.47.74-3.13 1.88-1.33 2.3-.34 5.7.96 7.57.63.92 1.38 1.94 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.14 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.1 1.04-2.15-.02-.01-2-.77-2.02-3.05-.02-1.9 1.55-2.81 1.63-2.87zm-2.05-5.24c.52-.63.88-1.52.78-2.4-.75.03-1.66.5-2.2 1.12-.48.55-.9 1.44-.79 2.32.84.06 1.69-.42 2.21-1.04z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function WindowsIcon({ size = 18, ...props }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M3 5.5 10.5 4.5v6.75H3V5.5Zm0 7.25h7.5v6.75L3 18.5v-5.75Zm8.75-8.4L21 3v9H11.75V4.35ZM11.75 12h9.25v9L11.75 19.65V12Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinuxIcon({ size = 18, ...props }: IconProps) {
|
||||
// Simplified Tux silhouette — round head + body.
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.3 2.3.8 3.2-.7.7-1.3 1.8-1.6 3-.4 1.4-.7 3.3-1.8 4.4-.6.6-1 .9-1 1.6 0 .9.8 1.3 2 1.6 1.5.3 2.6.1 3.6-.3.6-.2 1.3-.4 2-.4s1.4.2 2 .4c1 .4 2.1.6 3.6.3 1.2-.3 2-.7 2-1.6 0-.7-.4-1-1-1.6-1.1-1.1-1.4-3-1.8-4.4-.3-1.2-.9-2.3-1.6-3 .5-.9.8-2 .8-3.2 0-2.7-1.6-4.6-4-4.6Zm-1.5 5.2c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm3 0c.3 0 .5.3.5.8s-.2.8-.5.8-.5-.3-.5-.8.2-.8.5-.8Zm-3 2.6c.7.5 1.5.8 1.5.8s.8-.3 1.5-.8c0 .6-.7 1-1.5 1s-1.5-.4-1.5-1Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import Link from "next/link";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
@@ -72,11 +71,6 @@ export function LandingFooter() {
|
||||
{...(link.href.startsWith("http")
|
||||
? { target: "_blank", rel: "noreferrer" }
|
||||
: {})}
|
||||
onClick={
|
||||
link.href === "/download"
|
||||
? () => captureDownloadIntent("landing_footer")
|
||||
: undefined
|
||||
}
|
||||
className="text-[14px] text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Download } from "lucide-react";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
@@ -44,11 +42,25 @@ export function LandingHero() {
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href="/download"
|
||||
href="https://github.com/multica-ai/multica/releases/latest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
onClick={() => captureDownloadIntent("landing_hero")}
|
||||
>
|
||||
<Download className="size-4" aria-hidden />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
{t.hero.downloadDesktop}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceListOptions } from "@multica/core/workspace";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
/**
|
||||
* Client-side fallback redirect for authenticated visitors on the landing page.
|
||||
@@ -16,7 +16,7 @@ import { resolvePostAuthDestination, useHasOnboarded } from "@multica/core/paths
|
||||
* login* — before the user has ever visited a workspace — the cookie is
|
||||
* absent, so the proxy falls through to the landing page. This component
|
||||
* covers that gap: once auth is resolved and the workspace list has loaded,
|
||||
* push the user into their workspace (or /onboarding if they have none).
|
||||
* push the user into their workspace (or /workspaces/new if they have none).
|
||||
*
|
||||
* Renders nothing. Uses `router.replace` so the landing page never enters
|
||||
* browser history for authenticated users.
|
||||
@@ -25,17 +25,21 @@ export function RedirectIfAuthenticated() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
const { data: list = [], isFetched } = useQuery({
|
||||
const { data: list } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user || !isFetched) return;
|
||||
router.replace(resolvePostAuthDestination(list, hasOnboarded));
|
||||
}, [isLoading, user, isFetched, list, hasOnboarded, router]);
|
||||
if (isLoading || !user || !list) return;
|
||||
const [first] = list;
|
||||
if (!first) {
|
||||
router.replace(paths.newWorkspace());
|
||||
return;
|
||||
}
|
||||
router.replace(paths.workspace(first.slug).issues());
|
||||
}, [isLoading, user, list, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { createEnDict } from "./en";
|
||||
import { createZhDict } from "./zh";
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import { en } from "./en";
|
||||
import { zh } from "./zh";
|
||||
import type { LandingDict, Locale } from "./types";
|
||||
|
||||
const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict> = {
|
||||
en: createEnDict,
|
||||
zh: createZhDict,
|
||||
};
|
||||
const dictionaries: Record<Locale, LandingDict> = { en, zh };
|
||||
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
@@ -30,11 +26,6 @@ export function LocaleProvider({
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
const allowSignup = useConfigStore((state) => state.allowSignup);
|
||||
const t = useMemo(
|
||||
() => dictionaryFactories[locale](allowSignup),
|
||||
[allowSignup, locale],
|
||||
);
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
@@ -43,7 +34,7 @@ export function LocaleProvider({
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{ locale, t, setLocale }}
|
||||
value={{ locale, t: dictionaries[locale], setLocale }}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
return {
|
||||
export const en: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
@@ -121,10 +120,9 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
headlineFaded: "in the next hour.",
|
||||
steps: [
|
||||
{
|
||||
title: allowSignup ? "Sign up & create your workspace" : "Login to your workspace",
|
||||
description: allowSignup
|
||||
? "Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms."
|
||||
: "Enter your email, verify with a code, and you\u2019re logged into your workspace \u2014 no setup wizard, no configuration forms.",
|
||||
title: "Sign up & create your workspace",
|
||||
description:
|
||||
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
|
||||
},
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
@@ -226,7 +224,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "How it Works", href: "#how-it-works" },
|
||||
{ label: "Changelog", href: "/changelog" },
|
||||
{ label: "Download", href: "/download" },
|
||||
{ label: "Desktop", href: "https://github.com/multica-ai/multica/releases/latest" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
@@ -281,57 +279,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
title: "Desktop Cross-Platform Packaging, CLI Self-Update & Board Pagination",
|
||||
changes: [],
|
||||
features: [
|
||||
"Desktop app cross-platform packaging — macOS, Windows, and Linux artifacts from a single release pipeline",
|
||||
"`multica update` self-update command — upgrade the CLI and local daemon without reinstalling",
|
||||
"Issue board paginates every status column, not only Done — large backlogs stay responsive",
|
||||
],
|
||||
fixes: [
|
||||
"Workspace isolation enforced end-to-end for agent execution on the local daemon (security)",
|
||||
"Windows daemon stays alive after the terminal closes, so background agents keep running",
|
||||
"Board cards render their description preview again — list queries no longer strip the description field",
|
||||
"OpenClaw agent runtime now reads the real model from agent metadata instead of falling back to a default",
|
||||
"Comment Markdown preserved end-to-end — the HTML sanitizer that was stripping formatting has been removed",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Per-Agent Models, Kimi Runtime & Self-Host Auth",
|
||||
changes: [],
|
||||
features: [
|
||||
"Per-agent `model` field with a provider-aware dropdown — pick the LLM model for each agent from the UI or via `multica agent create/update --model`, with live discovery from each runtime's CLI",
|
||||
"Kimi CLI as a new agent runtime (Moonshot AI's `kimi-cli` over ACP), with model selection, auto-approved tool permissions, and streaming tool-call rendering",
|
||||
"Expand toggle on inline comment and reply editors for composing long text",
|
||||
],
|
||||
fixes: [
|
||||
"Posting the result comment is now an explicit, numbered step in agent workflows so final replies reach the issue instead of terminal output",
|
||||
"Agent live status card no longer leaks across issues when switching via Cmd+K",
|
||||
"Self-hosted session cookies honor the `FRONTEND_ORIGIN` scheme — plain-HTTP deployments stop silently dropping cookies, and `COOKIE_DOMAIN=<ip>` now falls back to host-only with a warning instead of breaking login",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
title: "Sub-Issues from Editor, Self-Host Gating & MCP",
|
||||
changes: [],
|
||||
features: [
|
||||
"Create sub-issue directly from selected text in the editor bubble menu",
|
||||
"Self-hosted instance gating — `ALLOW_SIGNUP` and `ALLOWED_EMAIL_*` env vars to restrict account creation",
|
||||
"Per-agent `mcp_config` field to restore MCP access",
|
||||
"Desktop app hourly update poll with manual check button in settings",
|
||||
],
|
||||
fixes: [
|
||||
"Session hand-off to desktop when already logged in on web",
|
||||
"Open redirect vulnerability on `?next=` validated",
|
||||
"OpenClaw stops passing unsupported flags and properly delivers AgentInstructions",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
@@ -725,80 +672,4 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
],
|
||||
},
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon · bundled daemon, zero setup",
|
||||
primary: "Download (.dmg)",
|
||||
altZip: "or download .zip",
|
||||
},
|
||||
macIntel: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon required — Intel Macs not yet supported.",
|
||||
disabledCta: "Apple Silicon required",
|
||||
intelHint:
|
||||
"On an Intel Mac? Use the CLI below — it runs the same daemon.",
|
||||
},
|
||||
winX64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "Bundled daemon, zero setup",
|
||||
primary: "Download (.exe)",
|
||||
},
|
||||
winArm64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "ARM · bundled daemon, zero setup",
|
||||
primary: "Download (.exe)",
|
||||
},
|
||||
linux: {
|
||||
title: "Multica for Linux",
|
||||
sub: "Bundled daemon, zero setup",
|
||||
primary: "Download AppImage",
|
||||
altFormats: "or .deb / .rpm",
|
||||
},
|
||||
unknown: {
|
||||
title: "Choose your platform",
|
||||
sub: "All installers are listed below.",
|
||||
},
|
||||
safariMacHint: "On an Intel Mac? Use the CLI below.",
|
||||
archFallbackHint: "Wrong architecture? See all formats below.",
|
||||
},
|
||||
allPlatforms: {
|
||||
title: "All platforms",
|
||||
macLabel: "macOS · Apple Silicon",
|
||||
winX64Label: "Windows · x64",
|
||||
winArm64Label: "Windows · ARM64",
|
||||
linuxX64Label: "Linux · x64",
|
||||
linuxArm64Label: "Linux · ARM64",
|
||||
formatDmg: ".dmg",
|
||||
formatZip: ".zip",
|
||||
formatExe: ".exe",
|
||||
formatAppImage: ".AppImage",
|
||||
formatDeb: ".deb",
|
||||
formatRpm: ".rpm",
|
||||
intelNote:
|
||||
"Apple Silicon only — Intel Macs not supported in this release.",
|
||||
unavailable: "Not available",
|
||||
},
|
||||
cli: {
|
||||
title: "Prefer the CLI?",
|
||||
sub: "For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal.",
|
||||
installLabel: "Install",
|
||||
startLabel: "Start daemon",
|
||||
sshNote: "Already on a server? Same commands work over SSH.",
|
||||
copyLabel: "Copy",
|
||||
copiedLabel: "Copied",
|
||||
},
|
||||
cloud: {
|
||||
title: "Cloud runtime (waitlist)",
|
||||
sub: "We’ll host the runtime for you. Not live yet — leave your email to be notified.",
|
||||
},
|
||||
footer: {
|
||||
releaseNotes: "What’s new in {version}",
|
||||
allReleases: "View all releases",
|
||||
currentVersion: "Current version: {version}",
|
||||
versionUnavailable: "Version unavailable — check GitHub",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,63 +101,4 @@ export type LandingDict = {
|
||||
fixes?: string[];
|
||||
}[];
|
||||
};
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: string;
|
||||
sub: string;
|
||||
primary: string;
|
||||
altZip: string;
|
||||
};
|
||||
macIntel: {
|
||||
title: string;
|
||||
sub: string;
|
||||
disabledCta: string;
|
||||
intelHint: string;
|
||||
};
|
||||
winX64: { title: string; sub: string; primary: string };
|
||||
winArm64: { title: string; sub: string; primary: string };
|
||||
linux: {
|
||||
title: string;
|
||||
sub: string;
|
||||
primary: string;
|
||||
altFormats: string;
|
||||
};
|
||||
unknown: { title: string; sub: string };
|
||||
safariMacHint: string;
|
||||
archFallbackHint: string;
|
||||
};
|
||||
allPlatforms: {
|
||||
title: string;
|
||||
macLabel: string;
|
||||
winX64Label: string;
|
||||
winArm64Label: string;
|
||||
linuxX64Label: string;
|
||||
linuxArm64Label: string;
|
||||
formatDmg: string;
|
||||
formatZip: string;
|
||||
formatExe: string;
|
||||
formatAppImage: string;
|
||||
formatDeb: string;
|
||||
formatRpm: string;
|
||||
intelNote: string;
|
||||
unavailable: string;
|
||||
};
|
||||
cli: {
|
||||
title: string;
|
||||
sub: string;
|
||||
installLabel: string;
|
||||
startLabel: string;
|
||||
sshNote: string;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
};
|
||||
cloud: { title: string; sub: string };
|
||||
footer: {
|
||||
releaseNotes: string;
|
||||
allReleases: string;
|
||||
currentVersion: string;
|
||||
versionUnavailable: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
return {
|
||||
export const zh: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
login: "\u767b\u5f55",
|
||||
@@ -121,10 +120,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headlineFaded: "\u53ea\u9700\u4e00\u5c0f\u65f6\u3002",
|
||||
steps: [
|
||||
{
|
||||
title: allowSignup ? "注册并创建您的工作空间" : "登录到您的工作空间",
|
||||
description: allowSignup
|
||||
? "输入您的邮箱,验证代码后即可使用。工作空间会自动创建——无需设置向导或配置表单。"
|
||||
: "输入您的邮箱,验证代码后即可登录到您的工作空间——无需设置向导或配置表单。",
|
||||
title: "\u6ce8\u518c\u5e76\u521b\u5efa\u5de5\u4f5c\u533a",
|
||||
description:
|
||||
"\u8f93\u5165\u90ae\u7bb1\uff0c\u9a8c\u8bc1\u7801\u786e\u8ba4\uff0c\u5373\u53ef\u8fdb\u5165\u3002\u5de5\u4f5c\u533a\u81ea\u52a8\u521b\u5efa\u2014\u2014\u65e0\u9700\u8bbe\u7f6e\u5411\u5bfc\uff0c\u65e0\u9700\u914d\u7f6e\u8868\u5355\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
@@ -226,7 +224,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{ label: "\u529f\u80fd\u7279\u6027", href: "#features" },
|
||||
{ label: "\u5982\u4f55\u5de5\u4f5c", href: "#how-it-works" },
|
||||
{ label: "更新日志", href: "/changelog" },
|
||||
{ label: "下载", href: "/download" },
|
||||
{ label: "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
@@ -281,57 +279,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.11",
|
||||
date: "2026-04-21",
|
||||
title: "桌面应用跨平台打包、CLI 自更新与看板分页",
|
||||
changes: [],
|
||||
features: [
|
||||
"桌面应用跨平台打包——同一条发布流水线产出 macOS、Windows 和 Linux 安装包",
|
||||
"新增 `multica update` 自更新命令——无需重装即可升级 CLI 和本地 Daemon",
|
||||
"Issue 看板所有状态列都支持分页(不再只是 Done 列),大积压下依然流畅",
|
||||
],
|
||||
fixes: [
|
||||
"本地 Daemon 对 Agent 执行强制端到端工作区隔离(安全)",
|
||||
"Windows 下 Daemon 终端关闭后继续常驻,后台 Agent 不再被意外终止",
|
||||
"看板卡片重新显示描述预览——列表查询不再丢掉 description 字段",
|
||||
"OpenClaw Agent 改为从 Agent 元数据读取真实模型,不再回退到默认值",
|
||||
"评论 Markdown 全链路保留——移除会误伤格式的 HTML sanitizer",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.8",
|
||||
date: "2026-04-20",
|
||||
title: "Agent 模型选择、Kimi Runtime 与自部署登录",
|
||||
changes: [],
|
||||
features: [
|
||||
"Agent 新增 `model` 字段及按 Provider 聚合的模型下拉框——可在界面或通过 `multica agent create/update --model` 为每个 Agent 选择 LLM 模型,并从各 Runtime CLI 实时发现可用模型",
|
||||
"新增 Kimi CLI Agent Runtime(Moonshot AI 的 `kimi-cli`,基于 ACP),支持模型选择、自动授权工具权限以及流式工具调用渲染",
|
||||
"评论和回复编辑器新增放大按钮,便于撰写长文本",
|
||||
],
|
||||
fixes: [
|
||||
"Agent 工作流将“发布结果评论”提升为独立的显式步骤,确保最终回复送达 Issue 而不是只留在终端输出",
|
||||
"通过 Cmd+K 切换 Issue 时不再出现其他 Issue 的 Agent 实时状态残留",
|
||||
"自部署会话 Cookie 的 Secure 标志改由 `FRONTEND_ORIGIN` 协议决定——HTTP 部署不再因浏览器丢弃 Cookie 导致登录失败;`COOKIE_DOMAIN=<ip>` 会自动回退到 host-only 并输出警告",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.7",
|
||||
date: "2026-04-18",
|
||||
title: "编辑器创建子 Issue、自部署门禁与 MCP",
|
||||
changes: [],
|
||||
features: [
|
||||
"直接从编辑器气泡菜单将选中文本创建为子 Issue",
|
||||
"自部署实例账户门禁——`ALLOW_SIGNUP` 和 `ALLOWED_EMAIL_*` 环境变量限制注册",
|
||||
"Agent 新增 `mcp_config` 字段恢复 MCP 支持",
|
||||
"桌面应用每小时检查更新,设置中新增手动检查按钮",
|
||||
],
|
||||
fixes: [
|
||||
"网页已登录时将会话交接给桌面应用",
|
||||
"修复 `?next=` 开放重定向漏洞",
|
||||
"OpenClaw 停止传递不支持的参数,正确传递 AgentInstructions",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.5",
|
||||
date: "2026-04-17",
|
||||
@@ -725,78 +672,4 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
],
|
||||
},
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon · 内置 daemon,无需配置",
|
||||
primary: "下载 (.dmg)",
|
||||
altZip: "或下载 .zip",
|
||||
},
|
||||
macIntel: {
|
||||
title: "Multica for macOS",
|
||||
sub: "需要 Apple Silicon——暂不支持 Intel Mac。",
|
||||
disabledCta: "需要 Apple Silicon",
|
||||
intelHint: "在 Intel Mac 上?请使用下方 CLI——底层跑的是同一个 daemon。",
|
||||
},
|
||||
winX64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "内置 daemon,无需配置",
|
||||
primary: "下载 (.exe)",
|
||||
},
|
||||
winArm64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "ARM · 内置 daemon,无需配置",
|
||||
primary: "下载 (.exe)",
|
||||
},
|
||||
linux: {
|
||||
title: "Multica for Linux",
|
||||
sub: "内置 daemon,无需配置",
|
||||
primary: "下载 AppImage",
|
||||
altFormats: "或 .deb / .rpm",
|
||||
},
|
||||
unknown: {
|
||||
title: "选择你的平台",
|
||||
sub: "下方是所有支持的安装包。",
|
||||
},
|
||||
safariMacHint: "在 Intel Mac 上?请使用下方 CLI。",
|
||||
archFallbackHint: "架构不对?下方是所有可选格式。",
|
||||
},
|
||||
allPlatforms: {
|
||||
title: "所有平台",
|
||||
macLabel: "macOS · Apple Silicon",
|
||||
winX64Label: "Windows · x64",
|
||||
winArm64Label: "Windows · ARM64",
|
||||
linuxX64Label: "Linux · x64",
|
||||
linuxArm64Label: "Linux · ARM64",
|
||||
formatDmg: ".dmg",
|
||||
formatZip: ".zip",
|
||||
formatExe: ".exe",
|
||||
formatAppImage: ".AppImage",
|
||||
formatDeb: ".deb",
|
||||
formatRpm: ".rpm",
|
||||
intelNote: "仅支持 Apple Silicon——Intel Mac 目前暂不支持。",
|
||||
unavailable: "暂不可用",
|
||||
},
|
||||
cli: {
|
||||
title: "想用 CLI?",
|
||||
sub: "适合服务器、远程开发机、无图形界面环境。底层 daemon 与 Desktop 相同,通过终端安装。",
|
||||
installLabel: "安装",
|
||||
startLabel: "启动 daemon",
|
||||
sshNote: "已经在服务器上?通过 SSH 执行同样的命令即可。",
|
||||
copyLabel: "复制",
|
||||
copiedLabel: "已复制",
|
||||
},
|
||||
cloud: {
|
||||
title: "Cloud runtime(等待名单)",
|
||||
sub: "我们将为你托管 runtime,目前尚未上线——留下邮箱,上线后通知你。",
|
||||
},
|
||||
footer: {
|
||||
releaseNotes: "v{version} 更新内容",
|
||||
allReleases: "查看所有版本",
|
||||
currentVersion: "当前版本:{version}",
|
||||
versionUnavailable: "版本获取失败——请前往 GitHub 查看",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import {
|
||||
parseReleaseAssets,
|
||||
type DownloadAssets,
|
||||
} from "./parse-release-assets";
|
||||
|
||||
/**
|
||||
* Server-side fetcher for the latest Multica release, designed to
|
||||
* run inside a Next.js server component. Response is cached by the
|
||||
* Next.js fetch cache for 5 minutes (Vercel ISR) so hitting /download
|
||||
* costs at most one GitHub API call per region per 5 minutes.
|
||||
*
|
||||
* On any failure (network, rate limit, malformed payload) returns a
|
||||
* `null`-shaped result and logs — the page degrades to a "version
|
||||
* unavailable" view rather than 500ing.
|
||||
*/
|
||||
|
||||
export interface LatestRelease {
|
||||
version: string | null;
|
||||
publishedAt: string | null;
|
||||
htmlUrl: string | null;
|
||||
assets: DownloadAssets;
|
||||
}
|
||||
|
||||
const GITHUB_LATEST_URL =
|
||||
"https://api.github.com/repos/multica-ai/multica/releases/latest";
|
||||
|
||||
const REVALIDATE_SECONDS = 300;
|
||||
|
||||
interface GitHubReleasePayload {
|
||||
tag_name?: string;
|
||||
published_at?: string;
|
||||
html_url?: string;
|
||||
assets?: Array<{ name: string; browser_download_url: string }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestRelease(): Promise<LatestRelease> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
};
|
||||
// Optional PAT for local development and self-hosted deploys where
|
||||
// the shared outbound IP keeps hitting the 60-requests/hour
|
||||
// unauthenticated limit. Vercel's fetch cache is shared across all
|
||||
// regions so production rarely needs this — but the env var lets
|
||||
// anyone running the site locally avoid the rate-limit dance. Never
|
||||
// prefix this with `NEXT_PUBLIC_`; the token must stay server-side.
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GITHUB_LATEST_URL, {
|
||||
next: { revalidate: REVALIDATE_SECONDS },
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub API responded ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as GitHubReleasePayload;
|
||||
return {
|
||||
version: data.tag_name ?? null,
|
||||
publishedAt: data.published_at ?? null,
|
||||
htmlUrl: data.html_url ?? null,
|
||||
assets: parseReleaseAssets(data.assets ?? []),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("[download] fetchLatestRelease failed:", err);
|
||||
return {
|
||||
version: null,
|
||||
publishedAt: null,
|
||||
htmlUrl: null,
|
||||
assets: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Client-side OS + architecture detection for the /download page.
|
||||
*
|
||||
* Prefers the modern `navigator.userAgentData.getHighEntropyValues`
|
||||
* API (Chromium), falling back to the UA string.
|
||||
*
|
||||
* Known limitation: Safari on macOS always reports `Intel Mac OS X`
|
||||
* in the UA string even on Apple Silicon, and Safari does not
|
||||
* implement userAgentData. This function therefore returns `arm64`
|
||||
* as the best default for any Mac — UI surfaces a small "On Intel
|
||||
* Mac? Use CLI." hint to cover the Intel minority.
|
||||
*/
|
||||
|
||||
export type OSName = "mac" | "windows" | "linux" | "unknown";
|
||||
export type Arch = "arm64" | "x64" | "unknown";
|
||||
|
||||
export interface DetectResult {
|
||||
os: OSName;
|
||||
arch: Arch;
|
||||
/** True when arch came from userAgentData high-entropy values
|
||||
* (i.e. we can trust the Intel vs arm distinction). False when
|
||||
* we defaulted — UI should show the Intel Mac disclaimer. */
|
||||
archConfident: boolean;
|
||||
}
|
||||
|
||||
interface UADataRecord {
|
||||
platform: string;
|
||||
architecture: string;
|
||||
}
|
||||
|
||||
interface UserAgentDataLike {
|
||||
getHighEntropyValues?: (hints: string[]) => Promise<UADataRecord>;
|
||||
}
|
||||
|
||||
function normalizePlatform(raw: string): OSName {
|
||||
const p = raw.toLowerCase();
|
||||
if (p.includes("mac") || p === "darwin") return "mac";
|
||||
if (p.includes("win")) return "windows";
|
||||
if (p.includes("linux")) return "linux";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function normalizeArch(raw: string): Arch {
|
||||
const a = raw.toLowerCase();
|
||||
if (a === "arm" || a === "arm64" || a === "aarch64") return "arm64";
|
||||
if (a === "x86" || a === "x86_64" || a === "amd64" || a === "x64") return "x64";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export async function detectOS(): Promise<DetectResult> {
|
||||
if (typeof navigator === "undefined") {
|
||||
return { os: "unknown", arch: "unknown", archConfident: false };
|
||||
}
|
||||
|
||||
// Modern Chromium: userAgentData with high-entropy values gives
|
||||
// both the platform name and CPU architecture unambiguously.
|
||||
const uaData = (navigator as unknown as { userAgentData?: UserAgentDataLike })
|
||||
.userAgentData;
|
||||
if (uaData?.getHighEntropyValues) {
|
||||
try {
|
||||
const data = await uaData.getHighEntropyValues([
|
||||
"platform",
|
||||
"architecture",
|
||||
]);
|
||||
const os = normalizePlatform(data.platform);
|
||||
const arch = normalizeArch(data.architecture);
|
||||
return { os, arch, archConfident: arch !== "unknown" };
|
||||
} catch {
|
||||
// Some browsers expose the API but reject high-entropy requests.
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: UA + navigator.platform. Safari on Mac lands here and
|
||||
// cannot distinguish Apple Silicon from Intel.
|
||||
const ua = navigator.userAgent;
|
||||
const platform = navigator.platform || "";
|
||||
|
||||
const os: OSName = /Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS X/i.test(ua)
|
||||
? "mac"
|
||||
: /Win/i.test(platform) || /Windows/i.test(ua)
|
||||
? "windows"
|
||||
: /Linux/i.test(platform) || /Linux/i.test(ua)
|
||||
? "linux"
|
||||
: "unknown";
|
||||
|
||||
let arch: Arch = "unknown";
|
||||
if (os === "mac") {
|
||||
// Best default. Real Intel Mac users will see the disclaimer.
|
||||
arch = "arm64";
|
||||
} else if (/arm|aarch/i.test(ua)) {
|
||||
arch = "arm64";
|
||||
} else if (os !== "unknown") {
|
||||
arch = "x64";
|
||||
}
|
||||
|
||||
return { os, arch, archConfident: false };
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Parses the GitHub Releases API asset array into a structured
|
||||
* download asset map. Skips auxiliary files (blockmaps, update
|
||||
* manifests, checksums) and the CLI tarballs — only desktop
|
||||
* installer artifacts are relevant on the /download page.
|
||||
*
|
||||
* Desktop artifact naming (see apps/desktop/electron-builder.yml):
|
||||
* multica-desktop-{version}-mac-{arch}.{dmg|zip}
|
||||
* multica-desktop-{version}-windows-{arch}.exe
|
||||
* multica-desktop-{version}-linux-{arch}.{AppImage|deb|rpm}
|
||||
*
|
||||
* Linux arch appears as amd64 / x86_64 / arm64 / aarch64 depending
|
||||
* on the format; we normalize to amd64 and arm64.
|
||||
*/
|
||||
|
||||
export interface GitHubAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
export interface DownloadAssets {
|
||||
macArm64Dmg?: string;
|
||||
macArm64Zip?: string;
|
||||
winX64Exe?: string;
|
||||
winArm64Exe?: string;
|
||||
linuxAmd64AppImage?: string;
|
||||
linuxAmd64Deb?: string;
|
||||
linuxAmd64Rpm?: string;
|
||||
linuxArm64AppImage?: string;
|
||||
linuxArm64Deb?: string;
|
||||
linuxArm64Rpm?: string;
|
||||
}
|
||||
|
||||
const DESKTOP_ARTIFACT_RE =
|
||||
/^multica-desktop-[^-]+-(mac|windows|linux)-([a-z0-9_]+)\.(dmg|zip|exe|AppImage|deb|rpm)$/i;
|
||||
|
||||
function normalizeLinuxArch(arch: string): "amd64" | "arm64" | null {
|
||||
const a = arch.toLowerCase();
|
||||
if (a === "amd64" || a === "x86_64") return "amd64";
|
||||
if (a === "arm64" || a === "aarch64") return "arm64";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseReleaseAssets(raw: GitHubAsset[]): DownloadAssets {
|
||||
const out: DownloadAssets = {};
|
||||
for (const asset of raw) {
|
||||
const name = asset.name;
|
||||
// Skip auxiliary files that share the release (update manifests,
|
||||
// blockmaps, checksums). CLI tarballs and other non-desktop
|
||||
// artifacts are excluded automatically because they don't match
|
||||
// DESKTOP_ARTIFACT_RE below.
|
||||
if (name.endsWith(".blockmap") || name.endsWith(".yml")) continue;
|
||||
if (name.startsWith("checksums")) continue;
|
||||
|
||||
const match = DESKTOP_ARTIFACT_RE.exec(name);
|
||||
if (!match) continue;
|
||||
const platform = match[1];
|
||||
const arch = match[2];
|
||||
const ext = match[3];
|
||||
if (!platform || !arch || !ext) continue;
|
||||
const archLower = arch.toLowerCase();
|
||||
const extLower = ext.toLowerCase();
|
||||
const url = asset.browser_download_url;
|
||||
|
||||
if (platform === "mac") {
|
||||
if (archLower !== "arm64") continue; // we only ship arm64 today
|
||||
if (extLower === "dmg") out.macArm64Dmg = url;
|
||||
else if (extLower === "zip") out.macArm64Zip = url;
|
||||
} else if (platform === "windows") {
|
||||
if (extLower !== "exe") continue;
|
||||
if (archLower === "x64") out.winX64Exe = url;
|
||||
else if (archLower === "arm64") out.winArm64Exe = url;
|
||||
} else if (platform === "linux") {
|
||||
const normalized = normalizeLinuxArch(arch);
|
||||
if (!normalized) continue;
|
||||
const e = extLower;
|
||||
if (normalized === "amd64") {
|
||||
if (e === "appimage") out.linuxAmd64AppImage = url;
|
||||
else if (e === "deb") out.linuxAmd64Deb = url;
|
||||
else if (e === "rpm") out.linuxAmd64Rpm = url;
|
||||
} else {
|
||||
if (e === "appimage") out.linuxArm64AppImage = url;
|
||||
else if (e === "deb") out.linuxArm64Deb = url;
|
||||
else if (e === "rpm") out.linuxArm64Rpm = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether any desktop asset was parsed out. Used for UI degradation. */
|
||||
export function hasAnyAsset(assets: DownloadAssets): boolean {
|
||||
return Object.values(assets).some((v) => typeof v === "string");
|
||||
}
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -9,11 +9,6 @@ export const mockUser: User = {
|
||||
name: "Test User",
|
||||
email: "test@multica.ai",
|
||||
avatar_url: null,
|
||||
onboarded_at: "2026-01-01T00:00:00Z",
|
||||
onboarding_questionnaire: {},
|
||||
// Matches real server behavior for anyone who onboarded before this
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
@@ -64,7 +59,6 @@ export const mockAgents: Agent[] = [
|
||||
custom_env_redacted: false,
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
model: "",
|
||||
owner_id: null,
|
||||
skills: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# Development override: build the backend/web images from the current checkout
|
||||
# instead of pulling the official GHCR images.
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: multica-backend:dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
|
||||
frontend:
|
||||
image: multica-web:dev
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
NEXT_PUBLIC_APP_VERSION: dev
|
||||
@@ -29,7 +29,9 @@ services:
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
image: ${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:${MULTICA_IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -59,7 +61,13 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: ${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:${MULTICA_IMAGE_TAG:-latest}
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
# Product Analytics
|
||||
|
||||
This document is the source of truth for the analytics events Multica ships
|
||||
to PostHog. Events feed the acquisition → activation → expansion funnel that
|
||||
drives our weekly Active Workspaces (WAW) north-star metric.
|
||||
|
||||
See [MUL-1122](https://github.com/multica-ai/multica) for the design context.
|
||||
|
||||
## Configuration
|
||||
|
||||
All analytics shipping is toggled by environment variables (see `.env.example`):
|
||||
|
||||
| Variable | Meaning | Default |
|
||||
|---|---|---|
|
||||
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
|
||||
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
|
||||
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
|
||||
|
||||
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
|
||||
events leave the process unless the operator explicitly opts in**.
|
||||
|
||||
### Self-hosted instances
|
||||
|
||||
Self-hosters should **never inherit a Multica-issued `POSTHOG_API_KEY`** —
|
||||
that would route their users' behavior to our analytics project. The
|
||||
defaults guarantee this:
|
||||
|
||||
- `.env.example` ships `POSTHOG_API_KEY=` empty. The Docker self-host
|
||||
compose does not set a default either.
|
||||
- With the key unset, `NewFromEnv` returns `NoopClient` and logs
|
||||
`analytics: POSTHOG_API_KEY not set, using noop client` at startup — a
|
||||
visible confirmation that nothing is shipped.
|
||||
- Operators who want their own analytics can set `POSTHOG_API_KEY` and
|
||||
`POSTHOG_HOST` to point at their own PostHog project (Cloud or
|
||||
self-hosted PostHog).
|
||||
- The frontend receives the key via `/api/config` (planned for PR 2), so
|
||||
self-hosts' blank server config also disables frontend event shipping
|
||||
automatically — no separate frontend opt-out plumbing required.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
handler → analytics.Client.Capture(Event) ← non-blocking, returns immediately
|
||||
│
|
||||
▼
|
||||
bounded queue (1024 events)
|
||||
│
|
||||
▼
|
||||
background worker: batch + POST /batch/
|
||||
│
|
||||
▼
|
||||
PostHog
|
||||
```
|
||||
|
||||
- `analytics.Capture` is **never allowed to block a request handler**. A
|
||||
broken backend must not degrade the product — when the queue is full,
|
||||
events are dropped and counted (visible via `slog` + the `dropped` counter
|
||||
on shutdown).
|
||||
- Batches flush either when `BatchSize` is reached or every `FlushEvery`
|
||||
(default 10 s), whichever comes first.
|
||||
- `Close()` drains remaining events during graceful shutdown. Called from
|
||||
`server/cmd/server/main.go` via `defer`.
|
||||
|
||||
## Identity model
|
||||
|
||||
- **`distinct_id`** — always the user's UUID for logged-in events. The
|
||||
frontend's `posthog.identify(user.id)` merges any prior anonymous events
|
||||
under the same identity, so acquisition attribution (UTM / referrer) stays
|
||||
intact across signup.
|
||||
- **`workspace_id`** — added to every event as a property when present. v1
|
||||
uses event property filtering (free tier) rather than PostHog Groups
|
||||
Analytics (paid) to compute workspace-level metrics.
|
||||
- **PII** — events carry `email_domain` (e.g. `gmail.com`), not the full
|
||||
email. Full email is stored once in person properties via `$set_once` so
|
||||
it's available for individual debugging but not broadcast with every
|
||||
event.
|
||||
- **Person properties (`$set`)** — use for mutable cohort signals
|
||||
(role, use_case, team_size, platform_preference) that a user can
|
||||
legitimately change during onboarding. `Event.Set` on the backend
|
||||
maps to `$set`; the frontend helper is
|
||||
`setPersonProperties()` in `@multica/core/analytics`. Use
|
||||
`$set_once` only for values that must never be overwritten (email,
|
||||
initial attribution, first-completion timestamp).
|
||||
|
||||
## Event contract
|
||||
|
||||
### `signup`
|
||||
|
||||
Fires when a new user is created. Covers both verification-code and Google
|
||||
OAuth entry points (`findOrCreateUser` is the single emission site).
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email_domain` | string | Lower-cased domain portion of the user's email. |
|
||||
| `signup_source` | string | Opaque attribution bundle from the frontend cookie `multica_signup_source` (UTM + referrer). Empty when the cookie is absent. |
|
||||
| `auth_method` | string | Optional. `"google"` for Google OAuth signups. Absent for verification-code signups. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `email` | string | Full email. Never broadcast per-event. |
|
||||
| `signup_source` | string | Same as above; kept on the person for later segmentation. |
|
||||
|
||||
### `workspace_created`
|
||||
|
||||
Fires after a `CreateWorkspace` transaction commits successfully.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Added globally; present here for clarity. |
|
||||
|
||||
**Note on "first workspace" segmentation** — we deliberately do *not* stamp
|
||||
an `is_first_workspace` boolean at emit time. Computing it correctly would
|
||||
require an extra column or transaction-scoped logic that still races under
|
||||
concurrent creates. Instead, PostHog answers the same question exactly by
|
||||
looking at whether the user has a prior `workspace_created` event (use a
|
||||
funnel with "first time user does X" or a cohort on
|
||||
`person_properties.$initial_event`). No information is lost.
|
||||
|
||||
### `runtime_registered`
|
||||
|
||||
Fires the first time a `(workspace_id, daemon_id, provider)` tuple is
|
||||
upserted. Heartbeats and repeat registrations never re-emit. First-time
|
||||
detection uses Postgres `xmax = 0` on the upsert RETURNING clause — no
|
||||
extra query, no race.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
|
||||
| `provider` | string | e.g. `"codex"`, `"claude"`. |
|
||||
| `runtime_version` | string | Version of the agent runtime binary. |
|
||||
| `cli_version` | string | Version of the `multica` CLI that registered it. |
|
||||
|
||||
`distinct_id` is the authenticated owner's user id when the daemon was
|
||||
registered via a member's JWT/PAT; daemon-token registrations fall back to
|
||||
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
|
||||
under a single "anonymous" person.
|
||||
|
||||
### `issue_executed`
|
||||
|
||||
Fires **at most once per issue** — when the first task on that issue
|
||||
reaches terminal `done` state. Backed by an atomic
|
||||
`UPDATE issue SET first_executed_at = now() WHERE id = $1 AND first_executed_at IS NULL RETURNING *`;
|
||||
retries, re-assignments, and comment-triggered follow-up tasks all hit the
|
||||
WHERE clause and no-op, so the `≥1 / ≥2 / ≥5 / ≥10` funnel buckets count
|
||||
distinct issues, not tasks.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `issue_id` | string (UUID) | |
|
||||
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
|
||||
|
||||
`distinct_id` prefers the issue's human creator so agent-executed events
|
||||
flow into the issue-author's person profile (same place `signup` and
|
||||
`workspace_created` land). Agent-created issues prefix with `agent:` to
|
||||
keep PostHog from merging the agent into a user record.
|
||||
|
||||
**Note on workspace-Nth ordinals** — we deliberately do *not* stamp
|
||||
`nth_issue_for_workspace` at emit time. Computing it correctly would
|
||||
require either a serialised transaction or an advisory lock per workspace;
|
||||
two concurrent first-completions could otherwise both read `count=1` and
|
||||
emit `n=1`. PostHog answers the same question at query time via
|
||||
`row_number() OVER (PARTITION BY properties.workspace_id ORDER BY timestamp)`,
|
||||
and funnel steps of the form "workspace has had ≥2 `issue_executed`
|
||||
events" are expressible without the property. No information is lost.
|
||||
|
||||
### `team_invite_sent`
|
||||
|
||||
Fires from `CreateInvitation` after the DB row is written.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `invited_email_domain` | string | Lower-cased domain; full email lives in the invitation row, not the event. |
|
||||
| `invite_method` | string | Currently always `"email"`. Future non-email invite flows (share link, SCIM) should pass their own value. |
|
||||
|
||||
`distinct_id` is the inviter's user id.
|
||||
|
||||
### `team_invite_accepted`
|
||||
|
||||
Fires from `AcceptInvitation` after both the invitation row is marked
|
||||
accepted and the member row is inserted in the same transaction.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `days_since_invite` | int64 | Whole days from invitation creation to acceptance. Lets us segment "accepted same day" (warm) from "dug out of email weeks later" (cold). |
|
||||
|
||||
`distinct_id` is the invitee's user id — this is the event that closes the
|
||||
expansion funnel.
|
||||
|
||||
### `onboarding_questionnaire_submitted`
|
||||
|
||||
Fires on the first PatchOnboarding that transitions the user's
|
||||
questionnaire JSONB from "at least one slot empty" to "all three
|
||||
filled" (team_size, role, use_case). Revisions past that point don't
|
||||
re-emit — the funnel counts users, not edits.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `team_size` | string | `solo` / `team` / `other`. |
|
||||
| `role` | string | `developer` / `product_lead` / `writer` / `founder` / `other`. |
|
||||
| `use_case` | string | `coding` / `planning` / `writing_research` / `explore` / `other`. |
|
||||
| `team_size_has_other` | bool | `true` when the user filled the Q1 free-text escape. |
|
||||
| `role_has_other` | bool | Ditto Q2. |
|
||||
| `use_case_has_other` | bool | Ditto Q3. |
|
||||
|
||||
Person properties set with `$set` (not once — users can go back and
|
||||
change answers before submitting again):
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `team_size` | string | Mirrors the event property for cohort queries. |
|
||||
| `role` | string | Same. |
|
||||
| `use_case` | string | Same. |
|
||||
|
||||
`distinct_id` is the user's id. No workspace_id — the questionnaire is
|
||||
per-user, not per-workspace.
|
||||
|
||||
### `agent_created`
|
||||
|
||||
Fires on every successful `POST /api/workspaces/:id/agents`. Not
|
||||
onboarding-specific — the `is_first_agent_in_workspace` property
|
||||
isolates the Step 4 signal from later agent additions.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `agent_id` | string (UUID) | |
|
||||
| `provider` | string | Runtime provider the agent is bound to (`claude`, `codex`, etc). |
|
||||
| `template` | string | Template slug used to seed the agent (`coding` / `planning` / `writing` / `assistant`). Empty when the caller didn't come from a template picker. |
|
||||
| `is_first_agent_in_workspace` | bool | `true` when the workspace had zero agents before this insert. |
|
||||
|
||||
`distinct_id` is the authenticated owner's user id.
|
||||
|
||||
### `onboarding_completed`
|
||||
|
||||
Fires from CompleteOnboarding on the first call that actually flips
|
||||
`user.onboarded_at` from NULL. Retries are idempotent server-side but
|
||||
deliberately do NOT re-emit, so the funnel counts first-completions
|
||||
only. The client sends `completion_path` in the POST body to label
|
||||
which exit the user took.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
|
||||
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `onboarded_at` | string (RFC3339) | Timestamp the first completion landed. Enables cohort queries like "users onboarded before X" directly from person_properties. |
|
||||
|
||||
`completion_path` values:
|
||||
|
||||
- `full` — Reached Step 5 (first_issue) with a runtime connected.
|
||||
- `runtime_skipped` — Completed without connecting a runtime (user hit Skip in Step 3).
|
||||
- `cloud_waitlist` — Submitted the cloud waitlist form and skipped Step 3.
|
||||
- `skip_existing` — "I've done this before" from Welcome. The user already had a workspace.
|
||||
- `unknown` — Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
|
||||
|
||||
### `cloud_waitlist_joined`
|
||||
|
||||
Fires from JoinCloudWaitlist whenever a user submits the Step 3 cloud
|
||||
waitlist form. Not a completion signal — it's orthogonal to the main
|
||||
funnel and used to size hosted-runtime interest.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `has_reason` | bool | Presence flag for the free-text reason field. The free text stays in the DB; we don't broadcast it. |
|
||||
|
||||
`distinct_id` is the user's id.
|
||||
|
||||
### `starter_content_decided`
|
||||
|
||||
Fires on the atomic NULL → terminal state transition in both
|
||||
ImportStarterContent and DismissStarterContent. The `branch` property
|
||||
mirrors what ImportStarterContent would emit for the same workspace,
|
||||
so import-vs-dismiss rates split cleanly by branch.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `decision` | string | `imported` or `dismissed`. |
|
||||
| `branch` | string | `agent_guided` (workspace had ≥1 agent at decision time) or `self_serve` (no agents). |
|
||||
|
||||
`distinct_id` is the user's id; `workspace_id` is attached from the
|
||||
request payload.
|
||||
|
||||
### Frontend-only events
|
||||
|
||||
- `$pageview` — fired by `apps/web/components/pageview-tracker.tsx` on
|
||||
every Next.js App Router path or query-string change. The tracker
|
||||
mounts once under `WebProviders` and drives the acquisition funnel's
|
||||
`/ → signup` step. posthog-js's automatic pageview capture is
|
||||
disabled in `initAnalytics` so we own the event shape.
|
||||
- `onboarding_runtime_path_selected` — fired from
|
||||
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
|
||||
user clicks one of the three Step 3 fork cards (before any server
|
||||
call happens, so it's frontend-only). Properties: `path`
|
||||
(`download_desktop` / `cli` / `cloud_waitlist`), `source` (`step3`;
|
||||
literal today but reserved for future surfaces reusing this event),
|
||||
`is_mac`. Also writes `platform_preference` (`web` / `desktop`) to
|
||||
person properties so every subsequent event on the user can be
|
||||
broken down by chosen platform. **Note**: semantic "download
|
||||
intent" is now better served by `download_intent_expressed` below —
|
||||
`path: "download_desktop"` signals Step 3 path choice specifically,
|
||||
not actual download start.
|
||||
|
||||
- `download_intent_expressed` — fired whenever a user clicks a CTA
|
||||
that points at the `/download` page. Surfaces five sources across
|
||||
the funnel, letting the top-of-funnel entry be split cleanly.
|
||||
Wrapper lives in `packages/core/analytics/download.ts`
|
||||
(`captureDownloadIntent`). Properties:
|
||||
- `source`: `landing_hero` / `landing_footer` / `login` / `welcome`
|
||||
/ `step3`
|
||||
Also writes `platform_preference: "desktop"` to person properties.
|
||||
|
||||
- `download_page_viewed` — fired once per `/download` mount after OS
|
||||
detect resolves (`apps/web/app/(landing)/download/download-client.tsx`).
|
||||
Properties:
|
||||
- `detected_os`: `mac` / `windows` / `linux` / `unknown`
|
||||
- `detected_arch`: `arm64` / `x64` / `unknown`
|
||||
- `detect_confident`: `true` when detect used
|
||||
`userAgentData.getHighEntropyValues` (Chromium); `false` when it
|
||||
fell back to the UA string (Safari on Mac always lands here —
|
||||
lets us isolate the arm64-default-for-Intel risk cohort).
|
||||
- `version_available`: `false` when the GitHub API fetch failed
|
||||
and the page is in the "Version unavailable" degraded state.
|
||||
Also writes `first_detected_os` / `first_detected_arch` via
|
||||
`$set_once` so every downstream event gains a platform dimension
|
||||
without re-emitting.
|
||||
|
||||
- `download_initiated` — fired when the user clicks a specific
|
||||
installer link on `/download`. Both the hero CTA and the All
|
||||
Platforms matrix rows emit this; split by `primary_cta`.
|
||||
Properties:
|
||||
- `platform`: `mac` / `windows` / `linux`
|
||||
- `arch`: `arm64` / `x64`
|
||||
- `format`: `dmg` / `zip` / `exe` / `appimage` / `deb` / `rpm`
|
||||
- `version`: release tag (e.g. `v0.2.13`) — correlates adoption
|
||||
with release cadence.
|
||||
- `primary_cta`: `true` for the hero-recommended installer, `false`
|
||||
for a manual pick from the All Platforms matrix.
|
||||
- `matched_detect`: `true` when the chosen platform+arch matches
|
||||
what the page detected. `false` lets us quantify detect misses
|
||||
from the single event (no cross-join needed).
|
||||
- Attribution is NOT a separate event; UTM + referrer origin are written
|
||||
to the `multica_signup_source` cookie on the first anonymous pageview
|
||||
and read by the backend's `signup` emission. The cookie carries a JSON
|
||||
payload URL-encoded at write time (`encodeURIComponent`) and
|
||||
URL-decoded at read time (`url.QueryUnescape`) — the JSON is never
|
||||
mid-truncated; individual values are capped at 96 chars before
|
||||
`JSON.stringify`, and the entire payload is dropped if it still exceeds
|
||||
512 chars. That way PostHog sees either intact JSON or nothing at all.
|
||||
|
||||
## Governance
|
||||
|
||||
Before adding, renaming, or removing any event:
|
||||
|
||||
1. Update this document first.
|
||||
2. Update `server/internal/analytics/events.go` constants and helpers to
|
||||
match.
|
||||
3. PR description must state which existing funnel / insight is affected.
|
||||
@@ -1,509 +0,0 @@
|
||||
# Desktop 下载体系 — 文案定位(Step 1 产出)
|
||||
|
||||
**目的**:为 `/download` 页面、onboarding、login、landing 所有 Desktop/CLI/Cloud 相关触点提供**唯一文案真相源**。后续 Step 2/3/4 实现时,UI 层只从这里拿文案,不临时发明。
|
||||
|
||||
**双语策略**:遵循当前项目 i18n 现状——
|
||||
- **Landing / `/download` / Web Login**:i18n 双语(en + zh),接入 `apps/web/features/landing/i18n/`
|
||||
- **Onboarding(共享 views 包)**:保持英文单语(当前现状,i18n 基建本次不引入)
|
||||
|
||||
---
|
||||
|
||||
## 一、三个 surface 的核心定位句
|
||||
|
||||
写 UI 时所有文案都派生自这三句。每一句都**以场景开头**,不以能力比较开头。
|
||||
|
||||
| Surface | EN | ZH |
|
||||
|---|---|---|
|
||||
| **Desktop** | Install the app. Agents run on your machine. | 下载桌面应用,agent 在你的电脑上运行。 |
|
||||
| **CLI** | For servers, remote dev boxes, and automation. | 适合服务器、远程开发机、自动化场景。 |
|
||||
| **Cloud** | We host the runtime. No local install. | 我们为你托管 runtime,无需本地安装。 |
|
||||
|
||||
### 一句话决策树(用户视角)
|
||||
|
||||
- "我就是想在自己电脑上用" → Desktop
|
||||
- "我想让 agent 跑在我的服务器 / 远程机器上" → CLI
|
||||
- "我一点都不想装东西" → Cloud(目前是 waitlist)
|
||||
|
||||
---
|
||||
|
||||
## 二、文案设计原则
|
||||
|
||||
| 原则 | 理由 | 例子 |
|
||||
|---|---|---|
|
||||
| 场景先于能力 | Desktop 和 CLI 运行后能力等价,差异在 setup moment 和使用场景 | ✅ "For servers and remote boxes" / ❌ "Lighter-weight Desktop" |
|
||||
| 避免"easy / simple / just" | 这些是 claim,用户不信;且会和现实冲突(CLI 的 `multica setup` 实际 10-30s) | ✅ "Terminal setup" / ❌ "Just one command" |
|
||||
| 诚实时间估计 | Welcome 当前 "Takes about 3 minutes" 对 web 用户是谎 | ✅ 差异化文案或去掉时间 |
|
||||
| 第二人称 + 直接语气 | 和 Linear / Cursor 一致 | ✅ "Agents run on your Mac." / ❌ "Our runtime operates locally." |
|
||||
| 不夸"强大"/"智能" | 现代用户免疫 marketing 形容词 | ✅ "Agents pick up tasks." / ❌ "Powerful AI agents tackle your work." |
|
||||
|
||||
---
|
||||
|
||||
## 三、触点文案对照表
|
||||
|
||||
### 3.1 Landing Hero(web only)
|
||||
|
||||
**位置**:`apps/web/features/landing/components/landing-hero.tsx:44-65` + `landing/i18n/en.ts:19` + `zh.ts:19`
|
||||
|
||||
**当前**:
|
||||
- EN: `"Download Desktop"` (ghost 按钮)
|
||||
- ZH: `"下载桌面端"` (ghost 按钮)
|
||||
- href: `https://github.com/multica-ai/multica/releases/latest`
|
||||
|
||||
**新**:
|
||||
- EN: `"Download Desktop"` ← 文案不变,**视觉升级为 primary/solid** + **href 改为 `/download`**
|
||||
- ZH: `"下载桌面端"` ← 同上
|
||||
- i18n key:复用现有 `hero.downloadDesktop`,**不新增 key**
|
||||
|
||||
**理由**:
|
||||
- 文案已经合适
|
||||
- 改动只在视觉权重(ghost → solid)和链接目标(GitHub releases → `/download`)
|
||||
- href 变更落在 `landing-hero.tsx:45` 的 hardcoded URL——改成相对路径 `/download`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Landing Nav / Footer(web only)
|
||||
|
||||
**位置**:`landing/i18n/en.ts:230` + `zh.ts:230`
|
||||
|
||||
**当前**:
|
||||
```ts
|
||||
{ label: "Desktop" / "桌面端", href: "https://github.com/multica-ai/multica/releases/latest" }
|
||||
```
|
||||
|
||||
**新**:
|
||||
```ts
|
||||
{ label: "Download" / "下载", href: "/download" }
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 把"Desktop"改成"Download"——`/download` 页面本身就是三个选项的聚合(Desktop/CLI/Cloud),不只是 desktop
|
||||
- href 统一到 `/download`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Web Login Page — 新增 Desktop CTA
|
||||
|
||||
**位置**:`apps/web/app/(auth)/login/page.tsx` 调用 `LoginPage`(`packages/views/auth/login-page.tsx`)时注入新 prop `extra`
|
||||
|
||||
**当前**:无此 UI
|
||||
|
||||
**新**(Google 按钮下方低调一行):
|
||||
- EN: `"Prefer the desktop app? Download →"`
|
||||
- ZH: `"想用桌面应用?下载 →"`
|
||||
|
||||
**i18n**:新增 key
|
||||
```ts
|
||||
auth: {
|
||||
login: {
|
||||
extraDownloadPrompt: "Prefer the desktop app?" / "想用桌面应用?",
|
||||
extraDownloadCta: "Download" / "下载",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 登录页是**最轻投入时刻**,推 Desktop 最便宜
|
||||
- 不强推,低调一行,不影响 Google OAuth 主流
|
||||
- Desktop app 的 Login Page(`apps/desktop/src/renderer/src/pages/login.tsx`)**不传** `extra` → 不显示。这条 CTA 只在 web 出现。
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Welcome 屏 — web 分支新增 Desktop 引导
|
||||
|
||||
**位置**:`packages/views/onboarding/steps/step-welcome.tsx`(单语英文)
|
||||
|
||||
**当前**(所有平台):
|
||||
```
|
||||
Takes about 3 minutes. You'll end with a real agent
|
||||
replying to a real issue.
|
||||
```
|
||||
按钮:`Start exploring` + (optional) `I've done this before`
|
||||
|
||||
**新**(web 分支,`isWeb=true`):
|
||||
- 上方文字保留 `"Your AI teammates, in one workspace."` 标题
|
||||
- 副文案改为:`"About 3 minutes on desktop. A bit more on web — you'll need a local runtime."`
|
||||
- 按钮区追加第三个按钮(视觉权重:primary > secondary ghost):
|
||||
- Primary: `"Start exploring"` (保留,引导继续 web 流程)
|
||||
- **新增 secondary**: `"Download Desktop — faster setup"`(指向 `/download`,新窗口打开)
|
||||
- Ghost: `"I've done this before"` (保留条件)
|
||||
|
||||
**新**(desktop 分支,`isWeb=false`):
|
||||
- 文案完全保持:`"Takes about 3 minutes. You'll end with a real agent replying to a real issue."`
|
||||
- 按钮不变
|
||||
|
||||
**理由**:
|
||||
- 首次向 web 用户承认"desktop 更顺"——早于任何投入
|
||||
- Primary CTA 仍是"Start exploring"——不强推 desktop,只是让它可见
|
||||
- 3 minutes 文案按平台差异化——对 desktop 用户诚实,对 web 用户不再骗
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Step 3 Platform Fork — Desktop 卡
|
||||
|
||||
**位置**:`packages/views/onboarding/steps/step-platform-fork.tsx:232-299`(`ForkPrimary` 组件)
|
||||
|
||||
**当前(Mac detect 命中)**:
|
||||
- 标题:`"Download the desktop app"`
|
||||
- 副文案:`"macOS · runtime bundled — detects your tools automatically, nothing to install."`
|
||||
|
||||
**当前(非 Mac)**:
|
||||
- 标题:`"Desktop app — macOS only for now"` (disabled card)
|
||||
- 副文案:`"Windows and Linux builds are on the way. In the meantime, install the CLI below — it takes about two minutes."`
|
||||
|
||||
**新(所有平台,按 detect 结果适配)**:
|
||||
- 标题:`"Download the desktop app"`
|
||||
- 副文案(按 detect 分支):
|
||||
- macOS arm64: `"macOS (Apple Silicon) · bundled daemon, zero setup."`
|
||||
- macOS Intel/unknown: `"macOS · bundled daemon, zero setup."` + 小字 `"Apple Silicon only — on Intel? Use CLI."`
|
||||
- Windows: `"Windows · bundled daemon, zero setup."`
|
||||
- Linux: `"Linux · bundled daemon, zero setup."`
|
||||
- 非 Mac 检测不到: `"Bundled daemon, zero setup."`
|
||||
- 按钮 pill:`"Download"`(不变)
|
||||
|
||||
**理由**:
|
||||
- 拆掉 `isMac` 门——Windows/Linux 包已经齐
|
||||
- 副文案主打**"zero setup"**——这才是和 CLI 的真差异
|
||||
- Intel Mac 诚实提示,不骗他们点 arm64 包
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Step 3 Platform Fork — CLI 卡
|
||||
|
||||
**位置**:同上,`ForkAlt` 调用
|
||||
|
||||
**当前**:
|
||||
- 标题:`"Install the CLI"`
|
||||
- 副文案:`"Run the Multica daemon yourself — a couple of terminal commands."`
|
||||
- 按钮:`"Show steps"`
|
||||
|
||||
**新**:
|
||||
- 标题:`"Install the CLI"` (不变)
|
||||
- 副文案:`"For servers, remote dev boxes, and headless setups. Terminal required."`
|
||||
- 按钮:`"Show steps"` (不变)
|
||||
|
||||
**理由**:
|
||||
- 副文案从"自己跑 daemon"改为"服务器 / 远程 / headless"——让 CLI 归位到它真正的场景
|
||||
- 加 "Terminal required" 给用户明确预期,不伪装成轻量路径
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Step 3 Platform Fork — Cloud 卡
|
||||
|
||||
**位置**:同上,`ForkAlt` 调用
|
||||
|
||||
**当前**:
|
||||
- 标题:`"Cloud runtime"`
|
||||
- 副文案:`"We host it for you. Not live yet — leave your email and we'll let you know."`
|
||||
- 按钮:`"Join waitlist"` / `"On the list"`
|
||||
|
||||
**新**:
|
||||
- 标题:`"Cloud runtime"` (不变)
|
||||
- 副文案:`"We host the runtime. Not live yet — join the waitlist."`
|
||||
- 按钮不变
|
||||
|
||||
**理由**:
|
||||
- 微调,对齐定位句
|
||||
- 不再把 "we'll let you know" 说得像客户支持
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Step 3 Footer Hint
|
||||
|
||||
**位置**:`step-platform-fork.tsx:101-112`
|
||||
|
||||
**当前 non-Mac**:`"Install the CLI to connect a runtime, or skip for now."`
|
||||
|
||||
**新(去掉 non-Mac 分支,因为 Desktop 对所有平台 active)**:
|
||||
```ts
|
||||
if (waitlistSubmitted) return "You're on the waitlist — pick Skip to keep exploring.";
|
||||
if (downloaded) return "Downloading… finish setup in the desktop app, or pick another path.";
|
||||
return "Pick a path above — or skip and configure a runtime later.";
|
||||
```
|
||||
|
||||
**理由**:删掉 non-Mac 专属分支,现在所有平台 Desktop 都可用。
|
||||
|
||||
---
|
||||
|
||||
### 3.9 CLI Install Dialog — Title + Description
|
||||
|
||||
**位置**:`step-platform-fork.tsx:378-384`
|
||||
|
||||
**当前**:
|
||||
```
|
||||
Title: "Install the CLI"
|
||||
Description: "Runs the same daemon the desktop app bundles — you install it yourself."
|
||||
```
|
||||
|
||||
**新**:
|
||||
```
|
||||
Title: "Install the CLI"
|
||||
Description: "Same daemon, installed on your terminal. Use it when Desktop doesn't fit — servers, remote dev boxes, or headless setups."
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 明确 CLI 和 Desktop 是**同一个 daemon**——消除"CLI 是否弱化版 Desktop"的误解
|
||||
- 直接说 CLI 的正当场景——当 Desktop 不适合时
|
||||
|
||||
---
|
||||
|
||||
### 3.10 CliInstallInstructions — 头部提示
|
||||
|
||||
**位置**:`packages/views/onboarding/steps/cli-install-instructions.tsx:65-68`
|
||||
|
||||
**当前**:
|
||||
```
|
||||
You'll need a local AI coding tool (Claude Code, Codex,
|
||||
Cursor, …) installed for the runtime to do real work.
|
||||
```
|
||||
|
||||
**新**:
|
||||
```
|
||||
You'll need an AI coding tool on this machine (Claude Code,
|
||||
Codex, Cursor, …) for the daemon to do real work. Also works
|
||||
on servers and remote dev boxes.
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 最后一句点出 CLI 的远程场景——和 Step 3 CLI 卡的副文案呼应
|
||||
|
||||
---
|
||||
|
||||
### 3.11 CLI Dialog Waiting — "Stalled" 文案
|
||||
|
||||
**位置**:`step-platform-fork.tsx:552-561`
|
||||
|
||||
**当前**:
|
||||
```
|
||||
Nothing coming through yet. Close this dialog and try another
|
||||
path on the previous screen — Skip for now (in the footer)
|
||||
enters your workspace in read-only mode, or the Cloud runtime
|
||||
card lets you join the waitlist.
|
||||
```
|
||||
|
||||
**新**:
|
||||
```
|
||||
Nothing coming through yet. If you're not comfortable with the
|
||||
terminal, Desktop is the smoother path — it bundles the daemon.
|
||||
Close this dialog and pick Desktop, or hit Skip to continue.
|
||||
```
|
||||
|
||||
**理由**:
|
||||
- 在 stall 发生时主动把 Desktop 作为退路——这是用户最需要听到的
|
||||
- 原文案把 Cloud waitlist 作为退路不合理(那是 soft exit,不解决问题)
|
||||
|
||||
---
|
||||
|
||||
### 3.12 `/download` 页面(全新,i18n 双语)
|
||||
|
||||
**位置**:`apps/web/app/(landing)/download/page.tsx`(新建)
|
||||
|
||||
**页面结构 + 文案**:
|
||||
|
||||
#### Hero 区(顶部主 CTA,按 detect 结果拼出)
|
||||
|
||||
**检测到 macOS arm64**:
|
||||
- EN:
|
||||
- H1: `"Multica for macOS"`
|
||||
- Sub: `"Apple Silicon · bundled daemon, zero setup"`
|
||||
- Primary button: `"Download (.dmg)"` → macArm64Dmg
|
||||
- Alt link: `"or download .zip"` → macArm64Zip
|
||||
- ZH:
|
||||
- H1: `"Multica for macOS"`
|
||||
- Sub: `"Apple Silicon · 内置 daemon,无需额外配置"`
|
||||
- Primary: `"下载 (.dmg)"`
|
||||
- Alt: `"或下载 .zip"`
|
||||
|
||||
**检测到 macOS Intel(Chromium)**:
|
||||
- EN:
|
||||
- H1: `"Multica for macOS"`
|
||||
- Sub: `"Apple Silicon required — Intel Macs not yet supported."`
|
||||
- Primary button 样式: **muted + disabled**,文案 `"Apple Silicon required"`
|
||||
- 次要段落:`"On an Intel Mac? Use the CLI below — it runs the same daemon."`
|
||||
- ZH:对应翻译
|
||||
|
||||
**检测到 Windows x64**:
|
||||
- EN:
|
||||
- H1: `"Multica for Windows"`
|
||||
- Sub: `"Bundled daemon, zero setup"`
|
||||
- Primary: `"Download (.exe)"` → winX64Exe
|
||||
- ZH: `"Multica for Windows"` / `"内置 daemon,无需额外配置"` / `"下载 (.exe)"`
|
||||
|
||||
**检测到 Linux**:
|
||||
- EN:
|
||||
- H1: `"Multica for Linux"`
|
||||
- Primary: `"Download AppImage"` → linuxAmd64AppImage
|
||||
- Alt links: `"or .deb / .rpm"`
|
||||
- ZH: 对应翻译
|
||||
|
||||
**未检测 / SSR 初始状态**:
|
||||
- 默认渲染 macOS arm64 作为 H1(占位);JS hydration 后按 detect 替换
|
||||
|
||||
#### All Platforms 区(永远可见,在 Hero 下方)
|
||||
|
||||
**标题**:
|
||||
- EN: `"All platforms"`
|
||||
- ZH: `"所有平台"`
|
||||
|
||||
**内容**:表格或卡片,每行一个包:
|
||||
```
|
||||
macOS · Apple Silicon (.dmg / .zip)
|
||||
Windows · x64 (.exe) · ARM64 (.exe)
|
||||
Linux · x64 (.AppImage / .deb / .rpm) · ARM64 (.AppImage / .deb / .rpm)
|
||||
```
|
||||
|
||||
**Intel Mac 说明**:
|
||||
- EN: `"Apple Silicon only — Intel Macs not supported in this release."`
|
||||
- ZH: `"仅支持 Apple Silicon——Intel Mac 目前暂不支持。"`
|
||||
|
||||
#### CLI 区(二级标题,独立 section)
|
||||
|
||||
**标题**:
|
||||
- EN: `"Prefer the CLI?"`
|
||||
- ZH: `"想用 CLI?"`
|
||||
|
||||
**副文案**:
|
||||
- EN: `"For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal."`
|
||||
- ZH: `"适合服务器、远程开发机、无图形界面环境。底层 daemon 和 Desktop 相同,通过终端安装。"`
|
||||
|
||||
**命令块**(复用 `CliInstallInstructions` 的样式):
|
||||
```bash
|
||||
# Install
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
|
||||
# Start daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
**底部说明**:
|
||||
- EN: `"Already on a server? Same commands work over SSH."`
|
||||
- ZH: `"已经在服务器上?通过 SSH 执行同样的命令即可。"`
|
||||
|
||||
#### Cloud 区(最小、置底)
|
||||
|
||||
**标题**:
|
||||
- EN: `"Cloud runtime (waitlist)"`
|
||||
- ZH: `"Cloud runtime(等待名单)"`
|
||||
|
||||
**副文案**:
|
||||
- EN: `"We'll host the runtime for you. Not live yet — leave your email to be notified."`
|
||||
- ZH: `"我们将为你托管 runtime,目前尚未上线——留下邮箱,上线后通知你。"`
|
||||
|
||||
**表单**:复用 `CloudWaitlistExpand`(`packages/views/onboarding/components/cloud-waitlist-expand.tsx`)
|
||||
|
||||
#### Footer 区
|
||||
|
||||
- Release notes 链接:`"What's new in {version}"` → GitHub release tag URL
|
||||
- All releases:`"View all releases →"` → `https://github.com/multica-ai/multica/releases`
|
||||
- 版本号小字:`"Current version: v0.2.13"`(来自 `/api/latest-version`)
|
||||
|
||||
---
|
||||
|
||||
## 四、i18n Keys 规划
|
||||
|
||||
### 4.1 现有 key 复用
|
||||
|
||||
- `hero.downloadDesktop` — 保持,landing hero 按钮文案
|
||||
- `nav.desktop` → **重命名为 `nav.download`**(需同步改 landing-nav 组件读的 key)
|
||||
|
||||
### 4.2 新增 key 命名空间
|
||||
|
||||
```ts
|
||||
// apps/web/features/landing/i18n/en.ts + zh.ts
|
||||
{
|
||||
// ... 现有 key ...
|
||||
|
||||
download: {
|
||||
hero: {
|
||||
macArm64: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon · bundled daemon, zero setup",
|
||||
primary: "Download (.dmg)",
|
||||
altZip: "or download .zip",
|
||||
},
|
||||
macIntel: {
|
||||
title: "Multica for macOS",
|
||||
sub: "Apple Silicon required — Intel Macs not yet supported.",
|
||||
disabledCta: "Apple Silicon required",
|
||||
intelHint: "On an Intel Mac? Use the CLI below — it runs the same daemon.",
|
||||
},
|
||||
winX64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "Bundled daemon, zero setup",
|
||||
primary: "Download (.exe)",
|
||||
},
|
||||
winArm64: {
|
||||
title: "Multica for Windows",
|
||||
sub: "ARM · bundled daemon, zero setup",
|
||||
primary: "Download (.exe)",
|
||||
},
|
||||
linux: {
|
||||
title: "Multica for Linux",
|
||||
sub: "Bundled daemon, zero setup",
|
||||
primary: "Download AppImage",
|
||||
altFormats: "or .deb / .rpm",
|
||||
},
|
||||
},
|
||||
allPlatforms: {
|
||||
title: "All platforms",
|
||||
macLabel: "macOS · Apple Silicon",
|
||||
winX64Label: "Windows · x64",
|
||||
winArm64Label: "Windows · ARM64",
|
||||
linuxX64Label: "Linux · x64",
|
||||
linuxArm64Label: "Linux · ARM64",
|
||||
intelNote: "Apple Silicon only — Intel Macs not supported in this release.",
|
||||
},
|
||||
cli: {
|
||||
title: "Prefer the CLI?",
|
||||
sub: "For servers, remote dev boxes, and headless setups. Same daemon as Desktop, installed via terminal.",
|
||||
installLabel: "Install",
|
||||
startLabel: "Start daemon",
|
||||
sshNote: "Already on a server? Same commands work over SSH.",
|
||||
},
|
||||
cloud: {
|
||||
title: "Cloud runtime (waitlist)",
|
||||
sub: "We'll host the runtime for you. Not live yet — leave your email to be notified.",
|
||||
},
|
||||
footer: {
|
||||
releaseNotes: "What's new in {version}",
|
||||
allReleases: "View all releases",
|
||||
currentVersion: "Current version: {version}",
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
login: {
|
||||
extraDownloadPrompt: "Prefer the desktop app?",
|
||||
extraDownloadCta: "Download",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
中文翻译按 ZH 对照表同步填入 `zh.ts`。
|
||||
|
||||
### 4.3 Onboarding 触点(单语英文)
|
||||
|
||||
所有 `step-welcome.tsx` / `step-platform-fork.tsx` / `cli-install-instructions.tsx` 的新文案**直接硬编码到 TSX**,不进 i18n——保持和当前 onboarding 代码风格一致。
|
||||
|
||||
---
|
||||
|
||||
## 五、文案审校清单
|
||||
|
||||
Step 2/3/4 实施时,逐条检查:
|
||||
|
||||
- [ ] 每个触点的文案都在本文档有定义(不临时发明)
|
||||
- [ ] Landing / `/download` / Login extra 走 i18n,双语齐备
|
||||
- [ ] Onboarding 触点英文硬编码,与现有代码风格一致
|
||||
- [ ] "3 minutes" 时间声明仅出现在 desktop 分支
|
||||
- [ ] 没有 "easy / simple / just" 出现在 Desktop 或 CLI 文案里
|
||||
- [ ] 所有 Download CTA 指向 `/download`,不再有直接指向 GitHub releases 的链接(landing nav、landing hero、step 3 Desktop 卡点击、登录页 extra)
|
||||
- [ ] CLI 文案强调 server/remote/headless 场景,不再暗示"Desktop 的轻量版"
|
||||
- [ ] Intel Mac 处处诚实标注,不欺骗
|
||||
|
||||
---
|
||||
|
||||
## 六、开放事项
|
||||
|
||||
- `/download` 页面的视觉风格是否跟 landing 一致(serif 标题 / 背景色)?→ **建议跟 landing 一致**,但本文档不锁死,Step 2 UI 实现时决定
|
||||
- 是否加"系统最低要求"区块?→ **不做**(Cursor 有,但我们产品期不引入这种 clutter)
|
||||
- 是否在 `/download` 置顶放一个 `<video>` 或产品截图?→ **不做**(保持克制;landing 已承担营销角色)
|
||||
@@ -1,356 +0,0 @@
|
||||
# Desktop 下载体系重设计 — 执行计划
|
||||
|
||||
**日期**:2026-04-22
|
||||
**作者**:Naiyuan
|
||||
**状态**:方案定稿,分步执行中
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
### 1.1 现状的核心矛盾
|
||||
|
||||
Multica 本质是"**本地 runtime + 云端协作**"的产品。Desktop app 内置 bundled daemon,登录即用;web 是预览/入口,不是等价平台。但**当前代码和文案把 Desktop 和 Web 当作等价路径**,结果:
|
||||
|
||||
1. **登录到 Step 3 之间,零 Desktop 推广**。用户建完 workspace 才被告知"其实你得装 app",此时沉没成本已高
|
||||
2. **Step 3 分流屏三张卡(Desktop / CLI / Cloud)视觉和文案伪对称**,用户感知不到 Desktop 是正解
|
||||
3. **`isMac` 过时门**:Windows/Linux 桌面包已齐(v0.2.13),代码却把非 Mac 用户推去 CLI
|
||||
4. **所有 Download 入口(landing / Step 3 / footer)都指向 GitHub releases 页面**——30+ assets 的列表,对非技术用户是灾难
|
||||
5. **Welcome 屏 "Takes about 3 minutes" 对 web 用户是谎**——不含下载/安装/换端时间
|
||||
|
||||
### 1.2 Cursor 对标带来的确认
|
||||
|
||||
Cursor 的 `/download` 页面(<https://cursor.com/cn/download>)模式:
|
||||
- **Client-side auto-detect**:用 `navigator.userAgentData.getHighEntropyValues(['architecture'])`,精确到 arch;fallback 到 UA 字符串
|
||||
- **SSR 发全量 HTML**(所有 OS 内容都在),hydration 后 JS 挑对应平台作主 CTA
|
||||
- **三个并列 surface**:Desktop / Terminal / Web——不是"三选一",是**三个场景**
|
||||
- 有 `useDownloadTracking` hook,埋点下载事件
|
||||
|
||||
这验证了"桌面主推 + 其他平台可见 + CLI 作为独立场景"的正确性。
|
||||
|
||||
---
|
||||
|
||||
## 二、核心洞察(从代码扒出来的)
|
||||
|
||||
### 2.1 两端用户是两种心态,不是两种路径
|
||||
|
||||
| 维度 | Desktop 用户 | Web 用户 |
|
||||
|---|---|---|
|
||||
| 入口 | 主动下 .app,越过安装门槛 | 浏览器打开,零投入 |
|
||||
| 心态 | **投入者**:"我认真对待 Multica" | **探索者**:"先试试" |
|
||||
| 对本地安装的态度 | 已接受 | **主动拒绝过**(选 web 就是为了不装) |
|
||||
| Step 3 的本质 | 确认屏(daemon 已在跑) | 决策屏(产品真相首次披露) |
|
||||
|
||||
这解释了为什么 Step 3 在 web 上是漏斗流失点——**那是"你以为是 web 产品 / 实际是本地产品"的期望违约时刻**。
|
||||
|
||||
### 2.2 CLI 不是 Desktop 的低配版,是另一个场景
|
||||
|
||||
Desktop 和 CLI 跑的是**同一个 Go 二进制**(`daemon-manager.ts` spawn 的 bundled CLI)。区别仅在 **setup moment**:
|
||||
|
||||
| 维度 | Desktop | CLI |
|
||||
|---|---|---|
|
||||
| 安装 | 双击 .dmg | `curl \| bash` |
|
||||
| 启动 | `daemonAPI.autoStart()` 登录后自动 | `multica setup` 手动 |
|
||||
| 运行后能力 | 完全等价 | 完全等价 |
|
||||
| **真正适合的场景** | 个人机器、交互使用 | **服务器、远程 dev box、on-prem、自动化、CI** |
|
||||
|
||||
CLI 的合法性不来自"Desktop 不可得时的替代",来自它**真的有 Desktop 永远覆盖不了的场景**:
|
||||
|
||||
- Self-host / on-prem:daemon 跑在自有服务器
|
||||
- 远程 dev box:SSH 到 Linux 机器,在那里跑 daemon
|
||||
- CI/CD:headless 环境里调度 agent
|
||||
- 多机部署:一个人,多台 runtime
|
||||
|
||||
**文案必须攻击 setup moment 不对称,而不是比较能力**——因为能力是一样的。
|
||||
|
||||
### 2.3 当前代码和资产状态
|
||||
|
||||
**已有的完整跨平台包(v0.2.13)**:
|
||||
|
||||
| 平台 | 包 | 大小 |
|
||||
|---|---|---|
|
||||
| macOS arm64 | `.dmg` / `.zip` | 184MB |
|
||||
| macOS Intel (x64) | ❌ 无(`electron-builder.yml` 只 target arm64) | — |
|
||||
| Windows x64 | `.exe` (NSIS) | 140MB |
|
||||
| Windows arm64 | `.exe` | 430MB |
|
||||
| Linux | `.AppImage` / `.deb` / `.rpm`(amd64 + arm64) | 113–189MB |
|
||||
|
||||
**代码当前状态**:
|
||||
|
||||
- Desktop 端 Step 3 里根本没有 CLI 这张卡(`StepRuntimeConnect.EmptyView` 只有 Skip + Cloud waitlist)——产品团队自己的立场是"bundled daemon 是 Multica 的本分"
|
||||
- Web 端 Step 3(`StepPlatformFork`)把 CLI 作为一等平级卡,`isMac` 门 disabled 非 Mac 的 Desktop CTA
|
||||
- 所有 Download 链指向 `https://github.com/multica-ai/multica/releases/latest`
|
||||
|
||||
---
|
||||
|
||||
## 三、定位策略(文案骨架)
|
||||
|
||||
每个 surface 用同一套"场景导向"句子,不比较能力:
|
||||
|
||||
| 形态 | 一句话定位 | 适合谁 |
|
||||
|---|---|---|
|
||||
| **Desktop** | "Your personal machine. Double-click, agents run locally." | 95% 新用户 |
|
||||
| **CLI** | "Servers, remote boxes, on-prem, automation. No GUI required." | 开发者 / 运维 / 自建 |
|
||||
| **Cloud**(waitlist) | "No local install — we host the runtime for you." | 评估 / 不想本地跑 |
|
||||
|
||||
**Welcome 屏在 web 分支追加一条引导**:诚实告诉用户"Better on desktop",给一个 Download 按钮 + "Continue on web" 次选。
|
||||
|
||||
---
|
||||
|
||||
## 四、已做的决定
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 语言 | **中英双语**,跟 landing 的 `en.ts` / `zh.ts` 一致 | 保持全站 i18n 体系 |
|
||||
| Intel Mac | **暂不支持**,`/download` 页诚实标注 | 2026 年 Intel Mac 已是 4+ 年前老机器;包体积翻倍影响所有人;CLI 对 Intel 用户是合理路径 |
|
||||
| 版本号获取 | **Next.js API route 代理 GitHub API**(`/api/latest-version`),Vercel ISR 5 分钟 cache | `latest.yml` 无 CORS;build-time 注入需每次重部署;GitHub API 未认证 60/hr 对 5min cache 绰绰有余 |
|
||||
| 部署 | Vercel(已确认) | ISR 原生支持,`export const revalidate = 300` 一行解决 |
|
||||
| Desktop 链接 URL | 客户端按 detect 结果拼 GitHub asset 直链 | 无需后端端点;带版本号的 URL 从 `/api/latest-version` 返回的 assets 里取 |
|
||||
| Auto-detect 方式 | `navigator.userAgentData.getHighEntropyValues(['platform','architecture'])` + UA 字符串 fallback | 抄 Cursor 模式;Safari 无此 API,macOS 下无法区分 Intel/arm——默认推 arm64 + 诚实文案 |
|
||||
| CLI 在 onboarding 的定位 | 保留 Step 3 第二张卡,但**文案重写为服务器/远程场景**,不再假装是 Desktop 的轻量版 | CLI 场景真实存在,但大多数 onboarding 用户不在这个场景里 |
|
||||
| 开发顺序 | **Step 1 文案 → Step 2 `/download` → Step 3 Onboarding → Step 4 Login + Landing** | Step 1 确立真相源,后续 UI 改动有唯一文案来源,可并行 |
|
||||
|
||||
---
|
||||
|
||||
## 五、执行步骤
|
||||
|
||||
### Step 1 · 文案与定位对齐(不写代码)
|
||||
|
||||
**做什么**:
|
||||
|
||||
- 建 `docs/download-positioning.md`(本文档的姊妹文档,专门放文案)
|
||||
- 三个 surface 的定位句(中英)
|
||||
- 盘点所有触点的**当前文案 vs 新文案**:
|
||||
- Landing hero(`landing-hero.tsx`)
|
||||
- Landing nav + footer 链接(`landing/i18n/en.ts`、`zh.ts`)
|
||||
- Login page(`packages/views/auth/login-page.tsx`)——新增 Desktop CTA
|
||||
- Welcome step(`step-welcome.tsx`)——web 分支新增 Desktop CTA
|
||||
- Step 3 三张卡(`step-platform-fork.tsx`)
|
||||
- `/download` 页面(全新)
|
||||
- `CliInstallInstructions`(`cli-install-instructions.tsx`)
|
||||
- 每条文案带中英双语对照
|
||||
|
||||
**目标**:后续所有 UI 改动有唯一文案真相源,不临时发明
|
||||
|
||||
**产出**:1 个 markdown doc
|
||||
**工期**:0.5 天
|
||||
**产物文件**:`docs/download-positioning.md`
|
||||
|
||||
### Step 2 · `/download` 页面
|
||||
|
||||
**做什么**:
|
||||
|
||||
- **路由**:`apps/web/app/(landing)/download/page.tsx`(放 landing group 共享 layout)
|
||||
- **SSR**:全量渲染 Desktop / CLI / Cloud 三块,所有平台包都在 HTML 里
|
||||
- **API route**:`apps/web/app/api/latest-version/route.ts`
|
||||
- 调 `https://api.github.com/repos/multica-ai/multica/releases/latest`
|
||||
- 解析 assets,按文件名模式抽出每个平台的 URL
|
||||
- 返回 `{ version, assets: { macArm64, winX64, winArm64, linuxDeb, linuxRpm, linuxAppImage, ... } }`
|
||||
- Vercel ISR:`export const revalidate = 300`
|
||||
- **Client detect**:`packages/views/utils/os-detect.ts`(新建)
|
||||
- 优先用 `navigator.userAgentData.getHighEntropyValues(['platform','architecture'])`
|
||||
- Fallback 到 `navigator.userAgent` + `navigator.platform`
|
||||
- 返回 `{ os: 'mac'|'windows'|'linux'|'unknown', arch: 'arm64'|'x64'|'unknown' }`
|
||||
- **UI 行为**:
|
||||
- 顶部大 CTA:按 detect 结果拼好的 Desktop 下载按钮(macOS arm64 / Windows x64 / Linux AppImage 作主推)
|
||||
- 检测到 Intel Mac(Chromium)→ 主 CTA 变成"Apple Silicon required — use CLI",CLI 区块置顶
|
||||
- 检测到 Safari on macOS → 默认推 arm64 + 小字提示"On Intel Mac? Use CLI"
|
||||
- 全平台直链列表(arch 清晰标注)
|
||||
- CLI 区块:`curl | bash` + 场景说明
|
||||
- Cloud 区块:复用 `CloudWaitlistExpand`
|
||||
- **i18n**:`apps/web/features/landing/i18n/en.ts` / `zh.ts` 新增 `download` 命名空间
|
||||
|
||||
**目标**:全站下载总入口,版本自动更新,用户下到对的包
|
||||
|
||||
**工期**:1-2 天
|
||||
**产物文件**:
|
||||
- `apps/web/app/(landing)/download/page.tsx`
|
||||
- `apps/web/app/api/latest-version/route.ts`
|
||||
- `packages/views/utils/os-detect.ts`(或 `packages/core/platform/os-detect.ts`)
|
||||
- `apps/web/features/landing/i18n/en.ts` + `zh.ts` 新增 keys
|
||||
- `apps/web/features/landing/components/download-page.tsx`(主组件,landing 风格)
|
||||
|
||||
### Step 3 · Onboarding 修缮
|
||||
|
||||
**做什么**:
|
||||
|
||||
- **Welcome 屏 web 分支**:
|
||||
- `OnboardingFlow` 里派生 `isWeb = !!runtimeInstructions`,传给 `StepWelcome`
|
||||
- `StepWelcome` 在 `isWeb` 时,CTA 区域追加一行 "Better on desktop — bundled daemon, zero setup" + **Download 按钮**(指 `/download`)+ "Continue on web" 次选
|
||||
- "Takes about 3 minutes" 文案按平台差异化
|
||||
- **Step 3 分流屏**:
|
||||
- 拆掉 `isMac` 门(`step-platform-fork.tsx:244-268`)
|
||||
- Desktop 卡对所有平台 active,按 detect 显示对应平台文案
|
||||
- Non-Mac 兜底卡改成 Cloud waitlist 强化,不再假装推 CLI
|
||||
- 三张卡的文案按 Step 1 确定的定位句重写
|
||||
- **CLI dialog**:
|
||||
- `CliInstallInstructions` 加一行场景说明:"Also great for servers and remote dev boxes."
|
||||
- `multica setup` 命令旁边保留现状
|
||||
- **"Downloading"后态**:
|
||||
- Desktop 卡点击后的 downloaded 态文案改得更明确("Check your Downloads folder. Open the .dmg to install.")
|
||||
|
||||
**目标**:Welcome 不再骗 web 用户;Step 3 三张卡场景清晰;Windows/Linux 用户不再被推 CLI
|
||||
|
||||
**工期**:0.5 天
|
||||
**产物文件**:
|
||||
- `packages/views/onboarding/onboarding-flow.tsx`
|
||||
- `packages/views/onboarding/steps/step-welcome.tsx`
|
||||
- `packages/views/onboarding/steps/step-platform-fork.tsx`
|
||||
- `packages/views/onboarding/steps/cli-install-instructions.tsx`
|
||||
|
||||
### Step 4 · 上游漏斗(Login + Landing)
|
||||
|
||||
**做什么**:
|
||||
|
||||
- **Login page**:
|
||||
- `packages/views/auth/login-page.tsx` 的 `LoginPageProps` 加 `extra?: ReactNode` prop
|
||||
- Google 按钮下方低调一行 "Prefer the desktop app? **Download →**"
|
||||
- Desktop 调用方(`apps/desktop/src/renderer/src/pages/login.tsx`)**不传** extra → 不显示
|
||||
- Web 调用方(`apps/web/app/(auth)/login/page.tsx`)**传** extra → 显示
|
||||
- **Landing hero**:
|
||||
- `landing-hero.tsx:44-65` 的 Download 按钮从 `heroButtonClassName("ghost")` 升级为 `heroButtonClassName("solid")`(或至少主次分明的 outline)
|
||||
- href 从 `https://github.com/multica-ai/multica/releases/latest` 改为 `/download`
|
||||
- **Landing nav + footer**:
|
||||
- `landing/i18n/en.ts:230` / `zh.ts:230` 的 Desktop 链接统一改为 `/download`
|
||||
|
||||
**目标**:用户最轻投入时刻就看到 Desktop;Step 3 之前已有两次 Desktop touch
|
||||
|
||||
**工期**:2 小时
|
||||
**产物文件**:
|
||||
- `packages/views/auth/login-page.tsx`
|
||||
- `apps/web/app/(auth)/login/page.tsx`
|
||||
- `apps/desktop/src/renderer/src/pages/login.tsx`(确认不传 extra 即可)
|
||||
- `apps/web/features/landing/components/landing-hero.tsx`
|
||||
- `apps/web/features/landing/i18n/en.ts` + `zh.ts`
|
||||
|
||||
---
|
||||
|
||||
## 六、不做的事(明确范围)
|
||||
|
||||
- **后端 `/api/download?os=X&arch=Y` 302 端点**:方案 A 已够用,后端不动
|
||||
- **下载埋点/数据分析**:本次不做,Cursor 有但我们暂缓
|
||||
- **下载后 "waiting on desktop" 屏**:让 handoff 更丝滑的想法,留到数据出现再决定
|
||||
- **Intel Mac universal build**:暂不补,`/download` 诚实标注"暂不支持"
|
||||
- **CLI 文档页 / 自托管文档**:Step 3 CLI 卡副文案引向 docs,docs 本身不在本次范围
|
||||
- **/download 页的 "system requirements" 区块**:不做详细 minimum specs,保持简洁
|
||||
|
||||
---
|
||||
|
||||
## 七、技术细节速查
|
||||
|
||||
### 7.1 OS + Arch Detection
|
||||
|
||||
```typescript
|
||||
// 推荐实现骨架
|
||||
export async function detectOS(): Promise<{ os: OSName; arch: Arch }> {
|
||||
// 优先用 userAgentData(Chromium)
|
||||
if (navigator.userAgentData?.getHighEntropyValues) {
|
||||
try {
|
||||
const data = await navigator.userAgentData.getHighEntropyValues([
|
||||
"platform",
|
||||
"architecture",
|
||||
]);
|
||||
// data.platform: "macOS" | "Windows" | "Linux"
|
||||
// data.architecture: "x86" | "arm"
|
||||
return normalizePlatform(data);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
// Fallback: UA 字符串 + navigator.platform
|
||||
const ua = navigator.userAgent;
|
||||
const platform = navigator.platform || "";
|
||||
// ... 按 "Mac" / "Windows" / "Linux" 分支
|
||||
}
|
||||
```
|
||||
|
||||
**已知限制**:Safari on macOS 无法区分 Intel/arm64(Apple 故意不暴露)。默认推 arm64 + 诚实文案。
|
||||
|
||||
### 7.2 `/api/latest-version` Response Shape
|
||||
|
||||
```typescript
|
||||
{
|
||||
version: "v0.2.13",
|
||||
publishedAt: "2026-04-21T13:13:52Z",
|
||||
assets: {
|
||||
macArm64Dmg: "https://github.com/.../multica-desktop-0.2.13-mac-arm64.dmg",
|
||||
macArm64Zip: "https://github.com/.../multica-desktop-0.2.13-mac-arm64.zip",
|
||||
winX64Exe: "https://github.com/.../multica-desktop-0.2.13-windows-x64.exe",
|
||||
winArm64Exe: "https://github.com/.../multica-desktop-0.2.13-windows-arm64.exe",
|
||||
linuxAmd64AppImage: "https://github.com/.../multica-desktop-0.2.13-linux-x86_64.AppImage",
|
||||
linuxAmd64Deb: "https://github.com/.../multica-desktop-0.2.13-linux-amd64.deb",
|
||||
linuxAmd64Rpm: "https://github.com/.../multica-desktop-0.2.13-linux-x86_64.rpm",
|
||||
linuxArm64AppImage: "...",
|
||||
linuxArm64Deb: "...",
|
||||
linuxArm64Rpm: "...",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Asset 文件名模式由 `electron-builder.yml` 定义:`multica-desktop-${version}-${platform}-${arch}.${ext}`。解析靠正则匹配。
|
||||
|
||||
### 7.3 Vercel ISR 配置
|
||||
|
||||
```typescript
|
||||
// apps/web/app/api/latest-version/route.ts
|
||||
export const revalidate = 300; // 5 min
|
||||
|
||||
export async function GET() {
|
||||
const res = await fetch(
|
||||
"https://api.github.com/repos/multica-ai/multica/releases/latest",
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) {
|
||||
return Response.json({ error: "upstream" }, { status: 502 });
|
||||
}
|
||||
const data = await res.json();
|
||||
return Response.json({
|
||||
version: data.tag_name,
|
||||
publishedAt: data.published_at,
|
||||
assets: parseAssets(data.assets),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 Welcome 屏 `isWeb` 派生
|
||||
|
||||
```typescript
|
||||
// onboarding-flow.tsx
|
||||
const isWeb = !!runtimeInstructions;
|
||||
|
||||
// 传给 StepWelcome
|
||||
<StepWelcome
|
||||
onNext={handleWelcomeNext}
|
||||
onSkip={canSkipWelcome ? handleWelcomeSkip : undefined}
|
||||
isWeb={isWeb}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、执行追踪
|
||||
|
||||
- [x] Step 1 · 文案 doc → `docs/download-positioning.md`
|
||||
- [x] Step 2 · `/download` 页面(分支 `NevilleQingNY/download-redesign`)
|
||||
- [ ] Step 3 · Onboarding 修缮
|
||||
- [ ] Step 4 · 上游漏斗
|
||||
|
||||
### Step 2 产出
|
||||
|
||||
- 新文件:`apps/web/app/(landing)/download/page.tsx` + `download-client.tsx`
|
||||
- 新组件:`apps/web/features/landing/components/download/{hero,all-platforms,cli-section,cloud-section,os-icons}.tsx`
|
||||
- 新 utils:`apps/web/features/landing/utils/{os-detect,parse-release-assets,github-release}.ts`
|
||||
- 扩展 i18n:`types.ts` 加 `download` + `auth.login.extra*`;`en.ts` + `zh.ts` 填双语
|
||||
- Nav 更新:landing footer 的 "Desktop" / "桌面端" 链接 → "Download" / "下载"(指 `/download`)
|
||||
- `@multica/views/onboarding` 新 export:`CloudWaitlistExpand`(`/download` 的 Cloud 区块复用)
|
||||
|
||||
### 本地开发注意
|
||||
|
||||
GitHub Releases API 未认证限流是 **60 req/hr per IP**。Vercel 生产环境的 fetch cache 跨所有 region 共享,每 5 分钟(`revalidate: 300`)全局最多 1 次调用,远低于限流。但**本地开发** + 共享办公室 IP 容易打爆限流,命中后页面降级到"Version unavailable"。
|
||||
|
||||
本地跑 `/download` 如遇到版本信息缺失:
|
||||
1. 设置 `GITHUB_TOKEN` 环境变量(Personal Access Token,公共仓库不需要 scope)
|
||||
2. `fetchLatestRelease` 会自动带 `Authorization: Bearer <token>` header,限流提到 5000 req/hr
|
||||
3. Token 只在 server-side 用,不会泄漏到客户端
|
||||
|
||||
每完成一步,勾掉 checkbox 并在对应 section 底部补一行实际 commit hash。
|
||||
@@ -1,611 +0,0 @@
|
||||
# Onboarding 重新设计 — 项目提案
|
||||
|
||||
**日期**:2026-04-21
|
||||
**作者**:Naiyuan
|
||||
**状态**:方案定稿,待评审后进入执行
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么要做
|
||||
|
||||
### 1.1 数据层面的两个漏斗
|
||||
|
||||
当前产品数据暴露了两个关键的用户流失点:
|
||||
|
||||
1. **第一漏斗**:很多用户创建完 workspace 后,**从未连接本地 daemon**。没有 runtime = 没有 agent = 产品价值归零。这是最严重的漏斗。
|
||||
2. **第二漏斗**:连接了 daemon 的用户中,**约一半从未创建 issue**。他们跨过了最难的技术门槛,却倒在了空 issue 列表面前——因为"该让 agent 做什么"对新用户并不直观。
|
||||
|
||||
这两个漏斗说明:**我们把用户送到了门口,但没有送他们进门**。
|
||||
|
||||
### 1.2 当前 Onboarding 的不足
|
||||
|
||||
代码层面现状(`packages/views/onboarding/` + `apps/web/app/(auth)/onboarding/page.tsx` + `apps/desktop/src/renderer/src/components/window-overlay.tsx`):
|
||||
|
||||
| 环节 | 现状 | 问题 |
|
||||
|---|---|---|
|
||||
| Welcome | 纯打招呼 + "Get started" 按钮 | 0 价值、+1 次点击、文案"takes about a minute"对 web 用户不诚实 |
|
||||
| Workspace 创建 | 复用 `CreateWorkspaceForm` | ✅ 基本合理,保留 |
|
||||
| Runtime 连接 | Desktop 静默、Web 显示 CLI 指南 | ✅ 机制对,但 web 体验上**一路走到第 3 步才撞上 CLI 这堵墙**,没有提前分流 |
|
||||
| Agent 创建 | 2 个模板(Master / Coding)+ 手填 name | Master 模板对 96% 的 solo 用户是噪音;手填 name 是多余决策;没有 Assistant 这种零门槛兜底 |
|
||||
| Complete | 仪式感庆祝 + "Enter workspace" | **aha moment 没发生**。用户被告知 agent 准备好,却看不到它工作,进去就是空 issue 列表——正好是第二漏斗 |
|
||||
| 个性化 | 无 | 所有用户看到同一套流程,不利用任何已知信息 |
|
||||
| 进度持久化 | `useHasOnboarded()` 硬编码 `false` | 中途退出会从头开始;跨端切换完全无法恢复 |
|
||||
|
||||
### 1.3 行业对标
|
||||
|
||||
调研多篇一线案例和数据后,业界已收敛到几条硬原则:
|
||||
|
||||
- **激活 > 教育**:Onboarding 唯一的 KPI 是用户到达 aha moment 的速度和比例。Slack 的 "2000 条消息 → 留存 93%" 是最经典案例
|
||||
- **2 分钟到首次价值**:通用 SaaS 目标
|
||||
- **<90 秒 TTFAC**:Stripe / Vercel 为开发者工具设定的标杆
|
||||
- **开发者工具转化率天然低**:通用 SaaS 试用转化 15–25%,开发者工具只有 8–15%,**68% 放弃原因是 setup 太复杂**
|
||||
- **问卷是杀手**:每多一个表单字段完成率下降 3–5%,某 case 强制问卷导致转化率下降 80%+,另一 case 6→3 题响应率 +11%
|
||||
- **Progressive disclosure 淘汰前置大 tour**:学习应该分散在使用过程中,不是一次性塞给用户
|
||||
- **Notion 模式是黄金范本**:1 题驱动模板选择 + 邮件路径 + 界面预览——"一题多用"
|
||||
|
||||
### 1.4 对标 Multica 的定位
|
||||
|
||||
Multica 不是"做一个 agent"的产品。它的核心价值是**把一支由用户编排的 AI agent 小队组织起来协作**——一个 agent 写代码、一个规划任务、一个做研究、一个写内容——每个 agent 是带配置(provider / runtime / instructions / skills)的独立工作者,像同事一样被指派 issue。
|
||||
|
||||
这意味着:
|
||||
- 用户不是单一场景("AI 帮我写代码"),而是多角色用户都在编排 agent:开发者、产品 / 项目负责人、writer、founder
|
||||
- "用户在用什么本地 CLI"是 daemon 自动探测的技术事实(`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor-agent` 扫 PATH 即可),**不需要问用户**
|
||||
- 真正值得问的是**用户是谁、想让 agent 干什么**——这个答案驱动 Step 4 模板、Step 5 first issue 和 Onboarding Project 的内容
|
||||
|
||||
---
|
||||
|
||||
## 二、调研结论与核心原则
|
||||
|
||||
- 主流程必须严格以激活为目的——Welcome、功能介绍、问卷这些"非激活"内容都要极限压缩或后置
|
||||
- 问卷题数 ≤3 题,且每题答案必须能直接改变下游某个屏的内容,否则砍掉
|
||||
- "Onboarding Project + sub-issues" 属于**教育载体**,不是 onboarding 主流程——它应该在 aha moment 发生后以侧边栏常驻形式出现
|
||||
- Web 不应该是 desktop 的"平行路径",而应该是**漏斗入口**:鼓励用户下载 desktop,保留 web+CLI 作为备选
|
||||
- 进度必须后端持久化,跨端 resume 是硬要求
|
||||
|
||||
主要 Sources 列在文末第八节。
|
||||
|
||||
---
|
||||
|
||||
## 三、方案要点
|
||||
|
||||
### 3.1 主流程:5 步(严格有序)
|
||||
|
||||
```
|
||||
Step 0: Welcome (产品介绍, 首次进入时展示, 不入后端 state)
|
||||
Step 1: 3-Q 问卷 (team_size / role / use_case)
|
||||
Step 2: 创建 workspace
|
||||
Step 3: 连接 runtime ← 两端最大差异在这一步
|
||||
Step 4: 创建 agent ← 按 Q1 × Q3 预填
|
||||
Step 5: 🎯 First Issue ← aha moment,按 Q3 驱动文案
|
||||
```
|
||||
|
||||
**Onboarding Project** 在 Step 5 完成的那一刻后台创建,作为进入 workspace 之后的侧边栏常驻项——**不算 onboarding 的一步**。
|
||||
|
||||
### 3.2 两端差异表
|
||||
|
||||
| Step | Desktop | Web |
|
||||
|---|---|---|
|
||||
| 1. 问卷 | 一屏 3 题 | 一屏 3 题(完全一致) |
|
||||
| 2. Workspace | `CreateWorkspaceForm` | 完全一致 |
|
||||
| 3. Runtime | **静默自动**:bundled daemon 1–2s 内 online → 直接跳 Step 4。只在失败时显示诊断 | **分流决策屏**(见 3.3) |
|
||||
| 4. Agent | 一键 Create(按 Q1×Q3 预填模板 + provider) | 完全一致 |
|
||||
| 5. First issue | 跳到 issue 详情页,观察 agent reply | 完全一致 |
|
||||
|
||||
唯一真正不同的是 Step 3。其他"差异"本质是问卷答案驱动的个性化,跨端一致。
|
||||
|
||||
### 3.3 Web 端 Step 3 分流屏
|
||||
|
||||
这是 web 用户创建完 workspace 后看到的屏,**取代当前直接展示 CLI install 指南的做法**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Multica runs on your machine │
|
||||
│ Agents need a local runtime to run. │
|
||||
│ How would you like to set up? │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ [Primary CTA, 80% 视觉权重] │ │
|
||||
│ │ ⬇ Download for macOS (recommended) │ │
|
||||
│ │ Fastest setup, bundled runtime │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Or: Continue on web with CLI │
|
||||
│ Or: I want cloud agents (join waitlist) │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
三条路径:
|
||||
|
||||
- **下载桌面端(默认,目标 60%+)**:点下载 → 写 `platform_preference: "desktop"` → 桌面端装完登录同账号 → 后端 state 触发跳 Step 3 → bundled daemon 1s pass → 进 Step 4
|
||||
- **CLI 继续(次选)**:保留现有 `CliInstallInstructions`,但新增预期管理("通常 2–4 分钟")和 60s stuck-state fallback("Stuck? 常见问题")
|
||||
- **Cloud waitlist(soft exit)**:邮箱 capture → 标记为"临时完成"(`onboarded_at` 写当前时间,保留 `cloud_waitlist_email`)→ 进 workspace + 顶部 banner
|
||||
|
||||
### 3.4 三个问题的设计
|
||||
|
||||
**Q1:Who will use this workspace?**(单选)
|
||||
- ○ Just me
|
||||
- ○ My team (2–10 people)
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
注意:删掉了"Just exploring for now"——它本质是"态度"而不是"人数结构",和这题的题意不契合;评估型用户如果真的选项都不合适,可以通过 Other 写自由文本("just trying it out" 等)表达。
|
||||
|
||||
**Q2:What best describes you?**(单选)
|
||||
- ○ Software developer
|
||||
- ○ Product / project lead
|
||||
- ○ Writer or content creator
|
||||
- ○ Founder / solo operator
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
**Q3:What do you want to do first?**(单选)
|
||||
- ○ Write and ship code
|
||||
- ○ Plan and manage projects
|
||||
- ○ Research or write
|
||||
- ○ Just explore what's possible
|
||||
- ○ Other ⇒ 展开 80 字符文本框
|
||||
|
||||
**提交策略(必答)**:
|
||||
- Continue 按钮只在**三题全部有具体选择**时启用;否则禁用
|
||||
- 任一问题选了 Other 但文本框为空 → 也禁用
|
||||
- 从 Other 切回其他选项 → 对应的 `*_other` 字段自动清空
|
||||
- **没有 Skip 路径**。理由:三个答案驱动 Step 4 agent template、Step 5 first-issue prompt、Onboarding Project sub-issue 排序;partial 答案会在下游每一步都留洞。Other 自由文本(+ 80 字符上限)已经兜住所有非典型用户,不需要再开 null 这个口子
|
||||
- 之前允许"全部不选 Skip"的策略在 commit 中已反悔——实测下来"给自由 = 问卷质量塌方"的风险比"多一点摩擦"更值得警惕
|
||||
|
||||
**"Other" 的下游价值——不是兜底,是 escape hatch + 个性化输入**
|
||||
|
||||
Q3 的 `use_case_other` 会**直接嵌入到 Step 5 first issue 的 prompt** 里:
|
||||
|
||||
> "Hi, I'm {user}. I told Multica I want to use you for \"{use_case_other}\". Introduce yourself and give me 3 concrete ways you could help with that."
|
||||
|
||||
也就是说,选 Other 的用户**反而**得到最个性化的 first issue——他们给 agent 的任务描述就是他们亲口写的。Q2 `role_other` 没有同样的嵌入位置,但会存进 state 给市场研究用。
|
||||
|
||||
**被砍掉的问题及理由**:
|
||||
|
||||
- ~~"你在用哪些 AI agent?"~~(原方案 Q1)→ daemon 启动时自动扫 PATH 探测已安装的 CLI(`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor-agent`),比问用户更准——用户可能说"我用 Claude Code"但 PATH 里并不存在。从"问"改成"测",问卷压掉一题
|
||||
- ~~"你是做什么的"(职业)~~ → 原方案砍掉过;现因为定位校准(Multica 不是 coding-focused 产品),重新作为 Q2 加回,驱动 agent template 选择
|
||||
- ~~"公司规模"~~ → solo/team 二分已经够用;具体公司规模属于 Day 3 邮件采集范围
|
||||
- ~~"从哪里知道 Multica"~~ → 归因数据走分析系统,不占问卷位
|
||||
|
||||
### 3.5 个性化映射
|
||||
|
||||
所有个性化来自这三个答案 + daemon 自动探测到的 runtime 列表。**不做 Q 之外的任何猜测**——透明、可预期、可调试。
|
||||
|
||||
#### Runtime 优先级(来自 daemon 探测,不来自问卷)
|
||||
|
||||
Step 3 结束时 daemon 会报告"当前 PATH 上探测到的 CLI 列表"。Step 4 的 provider 预选逻辑:
|
||||
|
||||
| daemon 探测结果 | Step 4 provider 预选 |
|
||||
|---|---|
|
||||
| 有 online runtime | 第一个 online 的 provider |
|
||||
| 列表非空但全 offline | 列表中第一个 |
|
||||
| 列表为空(Cloud waitlist 或 CLI 没装成功) | 不预选,在 Step 4 给用户手选或跳过 |
|
||||
|
||||
provider 值对齐 `packages/views/runtimes/components/provider-logo.tsx` 中已支持的:`claude` / `codex` / `opencode` / `openclaw` / `hermes` / `gemini` / `pi` / `cursor`。
|
||||
|
||||
#### Q1 (team_size) → Onboarding Project sub-issue 排序
|
||||
|
||||
| Q1 | Onboarding Project 顶部 sub-issue |
|
||||
|---|---|
|
||||
| `solo` | "Assign a real task to your agent" |
|
||||
| `team` | **"Invite teammates"** 置顶 |
|
||||
| `other` | 按 `solo` 路径处理(不强行归类;`team_size_other` 文本存下做市场研究) |
|
||||
|
||||
#### Q2 (role) → Step 4 agent template 默认选择(× Q3 细化)
|
||||
|
||||
Multica 是服务多角色 agent 编排用户的平台,不同 role 在 agent template 上应该看到默认的 role-matched 模板:
|
||||
|
||||
| Q2 role | Q3 use_case | 默认 template |
|
||||
|---|---|---|
|
||||
| `developer` | `coding` | Coding Agent |
|
||||
| `developer` | `planning` | Planning Agent |
|
||||
| `developer` | `writing_research` / `explore` / `other` | Coding Agent(仍默认,因为角色是开发者) |
|
||||
| `product_lead` | `coding` | Coding Agent |
|
||||
| `product_lead` | `planning` | Planning Agent |
|
||||
| `product_lead` | `writing_research` / `explore` / `other` | Planning Agent |
|
||||
| `writer` | `writing_research` | Writing Agent |
|
||||
| `writer` | 其他 | Writing Agent |
|
||||
| `founder` | 任意 | Assistant(founder 什么都干,通用兜底) |
|
||||
| `other` | 任意 | Assistant |
|
||||
|
||||
**Agent 模板集从 3 个扩到 4 个**:Coding Agent / Planning Agent / **Writing Agent(新增)** / Assistant。砍掉旧的 "Master Agent"(对 solo 用户完全不适用)。Writing Agent 的增加是因为产品定位校准——原方案默认 coding-focused,新方案支持 writer 作为一等用户。
|
||||
|
||||
#### Q3 (use_case) → Step 5 first issue prompt
|
||||
|
||||
First issue 的标题和 prompt 都由 Q3 单独驱动(与 Q2 role 解耦——同一个 role 做不同的 first task 是正常的):
|
||||
|
||||
| Q3 | First Issue 标题 | First Issue 描述(= 给 agent 的 prompt) |
|
||||
|---|---|---|
|
||||
| `coding` | "Welcome me and show me what you can do" | "Hi, I'm {user}. I'll use you mostly for coding work. Introduce yourself and suggest 3 concrete coding tasks I could try." |
|
||||
| `planning` | "Help me plan my first project" | "Hi, I'm {user}. I want you to help me plan and break down work. Introduce yourself and suggest 3 types of projects we could tackle." |
|
||||
| `writing_research` | "Show me how you help with research and writing" | "Hi, I'm {user}. I'll use you for research and writing. Introduce yourself and give me 3 examples of how you can help — drafting, summarizing, analysis, etc." |
|
||||
| `explore` | "What can you do?" | "Hi. I'm exploring what Multica can do. Give me a quick tour of what you can help with and suggest 3 concrete things to try." |
|
||||
| `other` | "Help me with what I had in mind" | "Hi, I'm {user}. I told Multica I want to use you for \"{use_case_other}\". Introduce yourself and give me 3 concrete ways you could help with that." |
|
||||
|
||||
`{use_case_other}` 的嵌入是 Other 选项的关键价值——选 Other 的用户不是被降级成通用兜底,反而得到最精准的 first issue。
|
||||
|
||||
### 3.6 Onboarding Project 设计
|
||||
|
||||
Project 名称:"Getting Started"。在 Step 5 完成那一刻后台创建,包含以下 sub-issues。
|
||||
|
||||
**Core sub-issues(所有用户都有)**:
|
||||
|
||||
1. **"Chat with your agent without creating an issue"**
|
||||
> Some tasks are quick back-and-forth — you don't need a full issue. Open the chat panel from the top-right and try asking your agent a question.
|
||||
|
||||
2. **"Assign a real task to your agent"**
|
||||
> You've seen your agent reply in this welcome issue. Now try assigning them something you actually need done. Create a new issue, describe the task, assign it to {agent_name}.
|
||||
|
||||
3. **"Write your Workspace Context"**
|
||||
> Workspace Context is the shared system prompt every agent in this workspace sees. Tell them who you are, what you're building, and how they should behave. Go to Workspace settings → Context.
|
||||
|
||||
4. **"Create a second agent with a different role"**
|
||||
> Multica's real power is running a small team of specialized agents. Create a Planning agent to complement your Coding agent, or a Writing agent to draft content. Go to Agents → "New agent".
|
||||
|
||||
5. **"Configure your agent's skills"**
|
||||
> Skills let you give your agent specific tools and capabilities. Go to your agent's settings and try toggling a skill.
|
||||
|
||||
6. **"Set up an Autopilot for recurring work"**
|
||||
> Autopilot creates issues on a schedule — daily standup summaries, weekly bug triage, monthly reports. Your agent picks them up automatically. Go to Autopilots → "New autopilot".
|
||||
|
||||
**Conditional sub-issues**(按答案插入 / 置顶 / 过滤):
|
||||
|
||||
- **Q1 = `team`** → "**Invite your teammates**" 置顶
|
||||
- **Q2 = `developer`** 或 **Q3 = `coding`** → "**Connect a repo to your workspace**" 加入 core #2 之后
|
||||
- **Q2 = `product_lead`** → "**Create a project with sub-issues**" 置顶
|
||||
- **Q2 = `writer`** → 跳过 "Connect a repo"(coding-specific),其余 core 保留
|
||||
- **runtime 列表为空**(Cloud waitlist 或 CLI 未装成功)→ 插入 "**Install your first local runtime**" 置顶
|
||||
|
||||
**设计原则**:每个 sub-issue 都可以直接 assign 给 agent。Agent 读到 description 后,用自然语言给用户一句引导 + 一个具体建议。这样 sub-issue 既是"教程"又是"和 agent 互动"的自然场景——学习动作本身就是使用产品。
|
||||
|
||||
### 3.7 Resume 策略
|
||||
|
||||
**核心原则**:恢复到上次 step,不重头开始,MVP 阶段不设过期时间,允许任意回退改答案。
|
||||
|
||||
理由:
|
||||
- Onboarding 总时长 <10 分钟,绝大多数用户一口气走完
|
||||
- 中途离开再回来的,基本都是被别的事打断——重头开始是侮辱
|
||||
- 过期策略(7 天后重置之类)是用代码解决还没发生的问题——**等真观察到 abandon-return 模式再加**
|
||||
|
||||
跨端 resume 的完整行为表:
|
||||
|
||||
| 场景 | 预期行为 |
|
||||
|---|---|
|
||||
| Web 完成 Step 1&2,关浏览器,2h 后重开 web | 读 state → 跳过 Step 1/2 → 直接 Step 3 |
|
||||
| Web 到 Step 3 点"下载桌面端",装完登录 desktop | Desktop 读 state → 跳 Step 3 → bundled daemon 1s pass → 进 Step 4 |
|
||||
| Web 到 Step 3 点"下载桌面端",没装,3 天后回 web | 检测到 `platform_preference=desktop` 但当前是 web → 显示 "Waiting for you on desktop" 屏 + "改用 web/CLI 继续" 入口 |
|
||||
| Desktop Step 5 first issue 刚创建但没看 agent reply 就关闭 | 重开 desktop → current_step 仍是 `first_issue` → 直接打开那个 issue 详情页 |
|
||||
| Onboarding 完成后再登录 | `onboarded_at` 非 null → 跳过 onboarding → 正常进 workspace |
|
||||
| Onboarding 中创建的 workspace 被删(边缘 case) | `workspace_id` 变 NULL → 下次进 onboarding 检测到 `current_step=runtime` 但 `workspace_id=null` → 回退到 Step 2 重新建 |
|
||||
|
||||
**"回退改答案" 的 UX 细节**:每一步有 "Back" 按钮回上一步。回退**不清空已保存的数据**——用户只是修改,不是重置。
|
||||
|
||||
---
|
||||
|
||||
## 四、后端数据设计
|
||||
|
||||
### 4.1 `user_onboarding` 表 schema
|
||||
|
||||
**设计决策**:稳定字段用列,灵活字段用 JSONB。问卷答案放 JSONB(题目可能演化),其他字段(FK、控制字段、enum)都是独立列。
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_onboarding (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
-- 控制状态
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
onboarded_at TIMESTAMPTZ, -- null = 未完成
|
||||
current_step TEXT, -- null after onboarded_at
|
||||
-- 'questionnaire'|'workspace'|'runtime'|'agent'|'first_issue'
|
||||
|
||||
-- 问卷答案(会演化,放 JSONB)
|
||||
questionnaire JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
-- 期望结构:
|
||||
-- {
|
||||
-- "team_size": "solo" | "team" | "other", -- Q1
|
||||
-- "team_size_other": "<= 80 chars" | null, -- Q1 自由文本(选 other 时必填)
|
||||
-- "role": "developer" | "product_lead" | "writer" | "founder" | "other", -- Q2
|
||||
-- "role_other": "<= 80 chars" | null, -- Q2 自由文本
|
||||
-- "use_case": "coding" | "planning" | "writing_research" | "explore" | "other", -- Q3
|
||||
-- "use_case_other": "<= 80 chars" | null -- Q3 自由文本(会嵌入 Step 5 prompt)
|
||||
-- }
|
||||
|
||||
-- Onboarding 产物(FK,要 join / 查询)
|
||||
workspace_id UUID REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
runtime_id UUID REFERENCES agent_runtimes(id) ON DELETE SET NULL,
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
|
||||
first_issue_id UUID REFERENCES issues(id) ON DELETE SET NULL,
|
||||
onboarding_project_id UUID REFERENCES projects(id) ON DELETE SET NULL,
|
||||
|
||||
-- Platform 偏好(决定 handoff 和 resume 行为)
|
||||
platform_preference TEXT, -- 'web' | 'desktop' | null
|
||||
|
||||
-- Cloud waitlist 支路(soft exit 记录)
|
||||
cloud_waitlist_email TEXT,
|
||||
|
||||
-- 约束
|
||||
CONSTRAINT current_step_valid CHECK (
|
||||
current_step IS NULL OR
|
||||
current_step IN ('questionnaire','workspace','runtime','agent','first_issue')
|
||||
),
|
||||
CONSTRAINT onboarded_clears_step CHECK (
|
||||
onboarded_at IS NULL OR current_step IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
-- 只对未完成的做 index(完成后不查),analytics 用
|
||||
CREATE INDEX idx_user_onboarding_incomplete
|
||||
ON user_onboarding (updated_at)
|
||||
WHERE onboarded_at IS NULL;
|
||||
```
|
||||
|
||||
**几个关键决策的理由**:
|
||||
|
||||
- **`ON DELETE SET NULL`** 而不是 CASCADE:用户手动删了 onboarding 中创建的 workspace,不应丢失整条 onboarding 记录。保留痕迹作为 analytics 信号,同时支持 3.7 表中"回退到 Step 2" 的自愈逻辑
|
||||
- **`onboarded_clears_step` 约束**:保证不会出现"已完成但还在某 step"的脏状态,发现非法组合直接 DB 层拒绝
|
||||
- **Partial index `WHERE onboarded_at IS NULL`**:绝大多数用户最终会完成,索引只关注未完成 cohort,省空间且 query 更快
|
||||
- **不存步骤时间戳历史**:步骤转化漏斗走 PostHog 事件系统(项目里 agent/j/db4fefb5 分支已经在做 analytics 基建);state 表负责流程控制,事件系统负责分析。分工清晰,不混
|
||||
|
||||
### 4.2 API 设计
|
||||
|
||||
**读**:
|
||||
```
|
||||
GET /api/me/onboarding
|
||||
→ 200 OK { current_step, questionnaire, workspace_id, ... }
|
||||
→ 404 if never started (客户端 treat as "start fresh")
|
||||
```
|
||||
|
||||
**写(每步结束时)**:
|
||||
```
|
||||
PATCH /api/me/onboarding
|
||||
Body: {
|
||||
current_step: "workspace", // 下一步
|
||||
questionnaire: { ... }, // 只在 Step 1 提交
|
||||
workspace_id: "ws_xxx", // 只在 Step 2 提交
|
||||
// ... 对应字段
|
||||
}
|
||||
→ 200 OK { 完整 state }
|
||||
```
|
||||
|
||||
**完成**:
|
||||
```
|
||||
POST /api/me/onboarding/complete
|
||||
Body: { first_issue_id, onboarding_project_id }
|
||||
→ 200 OK { onboarded_at, current_step: null }
|
||||
```
|
||||
|
||||
**关键**:每步结束立即 PATCH server。不要在前端 batch 到最后一起提交——这是 resume 能工作的前提。
|
||||
|
||||
### 4.3 State 流转
|
||||
|
||||
```
|
||||
状态机:
|
||||
(record not exists)
|
||||
↓ 用户首次进 onboarding
|
||||
current_step: "questionnaire"
|
||||
↓ PATCH 提交问卷
|
||||
current_step: "workspace" + questionnaire
|
||||
↓ PATCH 工作区创建成功
|
||||
current_step: "runtime" + workspace_id
|
||||
↓ PATCH runtime 选择
|
||||
current_step: "agent" + runtime_id
|
||||
↓ PATCH agent 创建
|
||||
current_step: "first_issue" + agent_id
|
||||
↓ POST /complete
|
||||
current_step: null + onboarded_at, first_issue_id, onboarding_project_id
|
||||
|
||||
支路(Cloud waitlist):
|
||||
current_step: "runtime"
|
||||
↓ 用户选 cloud waitlist
|
||||
current_step: null + onboarded_at + cloud_waitlist_email
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、当前代码影响面
|
||||
|
||||
### 5.1 后端(Go)
|
||||
|
||||
**新增**:
|
||||
- Migration:`server/migrations/0xx_create_user_onboarding.up.sql` + `.down.sql`
|
||||
- sqlc queries:`server/pkg/db/queries/onboarding.sql`(GetOnboarding / UpsertOnboarding / CompleteOnboarding)
|
||||
- Handler:`server/internal/handler/onboarding.go`(GET / PATCH / POST)
|
||||
- Router 挂载:`/api/me/onboarding` 路由组
|
||||
- 可能需要:`GetUserOnboarding` 也需暴露给认证回调决定重定向(或前端自取)
|
||||
|
||||
**迁移 sqlc**:`make sqlc` 重生成。
|
||||
|
||||
### 5.2 前端(TypeScript / React)
|
||||
|
||||
**新增**:
|
||||
- `packages/core/onboarding/types.ts` — `OnboardingState` 类型定义
|
||||
- `packages/core/onboarding/queries.ts` — TanStack Query options
|
||||
- `packages/core/onboarding/mutations.ts` — advance / complete mutation
|
||||
- `packages/views/onboarding/steps/step-welcome.tsx` — 产品介绍屏(首次进入时展示;回访自动跳过)
|
||||
- `packages/views/onboarding/steps/step-questionnaire.tsx` — 3 题问卷屏
|
||||
- `packages/views/onboarding/steps/step-platform-fork.tsx` — web Step 3 的分流屏
|
||||
- `packages/views/onboarding/steps/step-first-issue.tsx` — **关键**,aha moment 所在
|
||||
- 可能拆分 `packages/views/onboarding/utils/personalization.ts` — Q1/Q2/Q3 → 下游映射的纯函数(方便单测)
|
||||
|
||||
**需要改动的现有文件**:
|
||||
- `packages/views/onboarding/onboarding-flow.tsx` — 移除本地 `useState<OnboardingStep>`,改读 `useOnboardingStore`;每次 step 转换调 `advance` mutation
|
||||
- `packages/views/onboarding/steps/step-welcome.tsx` — **删除**,内容合并到新的 step-questionnaire
|
||||
- `packages/views/onboarding/steps/step-runtime.tsx` — web 分支改为渲染 `<StepPlatformFork />`
|
||||
- `packages/views/onboarding/steps/step-agent.tsx` — 模板集改为 Coding / Planning / Writing / Assistant,按 Q2×Q3 预填,新增"Advanced"折叠区让用户改 name
|
||||
- `packages/views/onboarding/steps/step-complete.tsx` — **替换**为 StepFirstIssue,或作为其前置过渡屏
|
||||
- `packages/core/paths/resolve.ts` — `useHasOnboarded` 当前已从 store 读;联调期替换为 TanStack Query against `GET /api/me/onboarding`
|
||||
- `packages/views/layout/use-dashboard-guard.ts` — guard 条件增加 `!hasOnboarded`,支持 "abandon 后回来自动回到 onboarding" 的 resume 行为
|
||||
- `apps/web/app/(auth)/onboarding/page.tsx` — 调整 shell 以支持 resume(读 state 决定进入哪一步)
|
||||
- `apps/desktop/src/renderer/src/components/window-overlay.tsx` — 同上
|
||||
- `apps/desktop/src/renderer/src/stores/window-overlay-store.ts` — 可能需要 `WindowOverlay` 类型微调
|
||||
|
||||
**不变**:
|
||||
- `packages/views/workspace/create-workspace-form.tsx` — 复用
|
||||
- `packages/views/onboarding/steps/cli-install-instructions.tsx` — 仍用,在 CLI 分支里渲染
|
||||
- 大部分 desktop 的 bundled daemon 启动逻辑 — Step 3 desktop 静默 pass 的前提
|
||||
|
||||
### 5.3 影响面估算
|
||||
|
||||
| 类别 | 数量 |
|
||||
|---|---|
|
||||
| 后端新文件 | ~4 |
|
||||
| 后端修改文件 | 1–2(router) |
|
||||
| 前端新文件 | ~6 |
|
||||
| 前端修改文件 | ~10 |
|
||||
| 测试新文件 | ~5(核心逻辑 + personalization 映射 + resume scenarios) |
|
||||
|
||||
---
|
||||
|
||||
## 六、成功指标(上线 30 天内评估)
|
||||
|
||||
参考调研结论设定:
|
||||
|
||||
| 指标 | 业界标杆 | Multica 目标 |
|
||||
|---|---|---|
|
||||
| Time-to-value | < 3 分钟 | Desktop 直达:≤ 3 min;Web→Desktop:≤ 5 min(含装机);Web→CLI:≤ 8 min |
|
||||
| Onboarding 完成率 | 60–80% | 目标 70% |
|
||||
| Day 7 留存 | 25–40% | 目标 30% |
|
||||
| Activation 率 | 40–60% | 目标 50% |
|
||||
| Web→Desktop 转化(Step 3 fork) | in-product 高于 42% 冷推上限 | 目标 50–70% |
|
||||
|
||||
**第一漏斗目标**:workspace → runtime 连接率从当前水平提升至 80%+(主要靠 web 分流推 desktop 降 CLI 门槛)。
|
||||
**第二漏斗目标**:runtime → 首个 issue 由产品主动创建,比例应接近 100%(因为 StepFirstIssue 自动完成这件事)。
|
||||
|
||||
---
|
||||
|
||||
## 七、已做的决策(不再讨论)
|
||||
|
||||
| 决策 | 选择 | 理由 |
|
||||
|---|---|---|
|
||||
| 前置问卷题数 | **3 题**:team_size / role / use_case | Notion 范式、调研甜蜜点;每题答案必须驱动下游内容 |
|
||||
| 问卷 Q1 "已在用哪些 agent" | **不问**,daemon 自动探测 PATH | 技术事实不该问用户;扫 PATH 比问答更准 |
|
||||
| 问卷 Q2 role | **问**,5 个具体选项 + Other | 驱动 Step 4 template 默认选择;用户画像数据回到一等位 |
|
||||
| "Other" 选项机制 | **每题都有 Other**,点击展开 80 字符文本框 | Escape hatch;Q3 use_case_other 还会嵌入 Step 5 first issue prompt |
|
||||
| 问卷必填 | **全可选**(Other 选了必填文本) | 给评估型用户零摩擦通道;0 选时 Continue 变 Skip |
|
||||
| Welcome 步骤 | **保留独立 welcome**,但改造为"产品介绍屏"(不是打招呼);只在首次进入时看到,回访 resume 自动跳过 | 多一次点击换来的是首次用户真正理解 Multica 是什么;Multica 无心智对标物,没有前置介绍就进问卷 = 用户没有 frame of reference;Welcome 不入后端 state,不影响 server schema |
|
||||
| Web Step 3 分流 | **默认推 desktop**,CLI 次选,cloud waitlist 兜底 | 96% 是个人用户,desktop 是最快路径 |
|
||||
| Cloud waitlist 放哪 | **Web Step 3 分流屏**,不作为主步骤 | 保留原方案 #3 的数据价值,但不侵占主流程 |
|
||||
| Agent 模板 | **4 个**:Coding / Planning / Writing / Assistant(砍 Master) | Multica 服务多角色 agent 编排用户;Writer 不能被 Assistant 兜底 |
|
||||
| Onboarding Project | **不算步骤**,Step 5 完成后台创建,侧边栏常驻 | Progressive disclosure 原则 |
|
||||
| Resume 策略 | **恢复到上次 step,不过期,允许回退改答案** | 未见 abandon-return 数据前不提前优化 |
|
||||
| Schema 方式 | **专门表 + JSONB 混合** | 稳定字段列化、灵活字段(问卷)JSON 化 |
|
||||
| FK 删除行为 | **ON DELETE SET NULL**,不 CASCADE | 保留 analytics 痕迹 + 自愈能力 |
|
||||
| 步骤时间戳 | **走 PostHog 事件系统**,不进 state 表 | 职责分离:state 管流程,events 管分析 |
|
||||
| 进度 handoff 机制 | **纯后端 state**,不用 token 或 deep link | 用户 auth session 已绑身份,简化架构 |
|
||||
| 开发顺序 | **前端全部搭完 → 后端实现 → 联调测试 → 上线** | 保持当前开发节奏不被后端阻塞;前端本身可以一个 step 一个 step 独立推进 |
|
||||
| State 访问抽象 | **全部走 `useOnboardingStore()` 一个 hook**,component 严禁直接碰 storage | 换后端时只动这一个文件,component 不感知——让"先前端后后端"成本低的关键 |
|
||||
|
||||
---
|
||||
|
||||
## 八、开放问题 / 不在本次范围
|
||||
|
||||
- **Cloud agent runtime 本身**:本次只实现 waitlist 邮箱捕获,不做 cloud runtime。这是下一阶段的产品决策
|
||||
- **Onboarding project sub-issue 文案的 iterate**:先上线现有文案(见 3.6),等真实用户反馈再打磨
|
||||
- **A/B test 框架**:等用户量达到业界标准(每组 ≥500)再启动,现阶段全量发
|
||||
- **个性化 Day 3 邮件**:问卷只问 3 题,剩余的用户画像数据(团队规模、角色等)可以后置到运营邮件收集,本次不实现
|
||||
- **Onboarding 完成后的 re-engagement**:如"用户 7 天没创建第 2 个 agent 时发通知",属于 retention loop,不属于 onboarding
|
||||
- **自定义 agent template**:当前 3 个硬编码模板够用,自定义模板留到后面
|
||||
|
||||
---
|
||||
|
||||
## 九、执行计划
|
||||
|
||||
### 9.1 详细执行文档
|
||||
|
||||
本提案评审通过后,拆出 `docs/plans/2026-04-21-onboarding-redesign.md`,按现有 plan 文档格式(参考 `docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md`)精确到文件 + 行号 + 代码片段。
|
||||
|
||||
### 9.2 执行阶段
|
||||
|
||||
**原则:前端全部搭完 → 后端实现 → 联调测试 → 上线。**
|
||||
|
||||
目的是让当前开发节奏不被后端阻塞——前端可以一个 step 一个 step 独立迭代,每完成一个 step 都能在浏览器里直接看到效果。后端在前端定稿之后一次性实现,联调阶段统一解决跨端 resume 等场景。
|
||||
|
||||
**前端阶段**(按顺序推进,每个 step 独立可交付):
|
||||
|
||||
1. **建立 `useOnboardingStore()` 骨架**(已完成)——位于 `packages/core/onboarding/`。dev 期间是内存 Zustand store(刷新重置,方便迭代),联调阶段换成 TanStack Query + PATCH mutation。严禁 component 绕过
|
||||
2. **Step 1(welcome + 问卷拆两屏)**:新建 `step-welcome.tsx`(产品介绍,首次进入时展示)+ `step-questionnaire.tsx`(3 题);抽出 `<OptionCard>` / `<OtherOptionCard>` 复用组件
|
||||
3. **Step 2(workspace)**:基本保留,接入 `useOnboardingStore()`
|
||||
4. **Step 3(runtime)**:在 web 分支里新建 `step-platform-fork.tsx`;desktop 分支保留静默自动;CLI 分支加预期管理和 60s fallback
|
||||
5. **Step 4(agent)**:模板集从 3 扩成 4(加 Writing),按 Q2×Q3 预填 template + provider(provider 来自 daemon 探测),移除手填 name 的强制性
|
||||
6. **Step 5(first issue)**:新建 `step-first-issue.tsx`,这是 aha moment 发生的地方;`use_case=other` 时把 `use_case_other` 嵌入 prompt
|
||||
7. **Flow orchestrator 改造**:`onboarding-flow.tsx` 改由 `useOnboardingStore()` 驱动,不再用本地 useState 管 step 切换
|
||||
8. **Web + Desktop shell 适配**:读 store 决定进入哪一步,支持单浏览器内的 resume
|
||||
|
||||
**后端阶段**:
|
||||
|
||||
9. Migration + sqlc queries + handler + router(API shape 见 4.2)
|
||||
10. 按 4.1 schema 实现 `user_onboarding` 表 + partial index + 约束
|
||||
|
||||
**联调阶段**:
|
||||
|
||||
11. `useOnboardingState()` 实现从 localStorage 切换为 TanStack Query + PATCH mutation——**component 0 改动**,这是 hook 抽象的回报
|
||||
12. 跨端 / 多 session resume 全场景验证(3.7 表)
|
||||
13. E2E 覆盖 4 类用户路径 + 分流屏三条支路 + resume 一条
|
||||
|
||||
建议独立 worktree 开发(参考 `superpowers:using-git-worktrees`),避免污染主 checkout。
|
||||
|
||||
### 9.3 测试阶段
|
||||
|
||||
**本地自测**(按用户类型逐一跑):
|
||||
- A 类:solo + Claude Code + coding → 最短路径 3 分钟
|
||||
- B 类:team + Claude Code + coding/planning → 完成后侧边栏 "Invite teammates" 置顶
|
||||
- C 类:无 agent + 评估 → web 分流选 cloud waitlist
|
||||
- D 类:solo + writing → Assistant 模板 + 对应 first issue 文案
|
||||
|
||||
**Resume 场景**(按 3.7 表逐一验证):
|
||||
- Web 中途关浏览器 → 重开恢复
|
||||
- Web → desktop 跨端 handoff
|
||||
- Web 选下载未装 → 回 web 的"waiting"屏
|
||||
- 已完成用户重登录 → 跳过 onboarding
|
||||
|
||||
**E2E** 测试必须覆盖:
|
||||
- 完整 happy path(至少 desktop A 类)
|
||||
- Resume 一条
|
||||
- 分流屏三条路径各一条
|
||||
|
||||
**上线指标监控**:PostHog 看板跟踪第六节定义的 5 个 KPI,上线后每周 review 一次,2 周内若主指标偏离 20%+ 需排查。
|
||||
|
||||
---
|
||||
|
||||
## 十、调研参考
|
||||
|
||||
### 核心理论与激活
|
||||
- [Chameleon — How to find your product's "Aha" moment](https://www.chameleon.io/blog/successful-user-onboarding)
|
||||
- [Amplitude — The "Aha" Moment: A Guide](https://amplitude.com/blog/aha-moment)
|
||||
- [Growth Letter — Slack's $3B Growth Loop](https://www.growth-letter.com/p/slacks-3-billion-growth-strategy)
|
||||
- [June.so — Activation Playbook](https://www.june.so/blog/activation-playbook)
|
||||
|
||||
### 开发者工具特有数据
|
||||
- [Daily.dev — Developer Onboarding Optimization](https://business.daily.dev/resources/developer-onboarding-optimization-from-first-click-to-paying-customer/)
|
||||
- [Startup Design Journal — Hidden Micro-Friction Killing Conversion](https://startupdesignjournal.com/p/the-hidden-micro-friction-thats-killing)
|
||||
|
||||
### 问卷 / 表单 drop-off
|
||||
- [involve.me — 6→3 题 +11% case](https://www.involve.me/blog/case-study-how-we-use-an-onboarding-survey-in-a-saas-product)
|
||||
- [SaaSFactor — Why Users Drop Off During Onboarding](https://www.saasfactor.co/blogs/why-users-drop-off-during-onboarding-and-how-to-fix-it)
|
||||
- [GrowthMentor — Friction Case Study](https://www.growthmentor.com/blog/user-onboarding-friction/)
|
||||
- [Formbricks — Essential Onboarding Survey Questions](https://formbricks.com/blog/onboarding-survey-questions)
|
||||
|
||||
### Progressive Disclosure
|
||||
- [LogRocket — Progressive Disclosure](https://blog.logrocket.com/ux-design/progressive-disclosure-ux-types-use-cases/)
|
||||
- [Pendo — Onboarding, Progressive Disclosure, Memory](https://www.pendo.io/pendo-blog/onboarding-progressive-disclosure/)
|
||||
- [Interaction Design Foundation — Progressive Disclosure](https://ixdf.org/literature/topics/progressive-disclosure)
|
||||
|
||||
### Notion / Linear 案例
|
||||
- [Candu — How Notion Crafts Personalized Onboarding](https://www.candu.ai/blog/how-notion-crafts-a-personalized-onboarding-experience-6-lessons-to-guide-new-users)
|
||||
- [Appcues Goodux — Notion's Lightweight Onboarding](https://goodux.appcues.com/blog/notions-lightweight-onboarding)
|
||||
- [DesignerUp — 200 Onboarding Flows Studied](https://designerup.co/blog/i-studied-the-ux-ui-of-over-200-onboarding-flows-heres-everything-i-learned/)
|
||||
|
||||
### Schema / 持久化
|
||||
- [Shekhar Gulati — When to use JSON data type](https://shekhargulati.com/2022/01/08/when-to-use-json-data-type-in-database-schema-design/)
|
||||
- [TigerData — Wide vs Narrow Postgres Tables](https://www.tigerdata.com/learn/designing-your-database-schema-wide-vs-narrow-postgres-tables)
|
||||
- [DbSchema — PostgreSQL JSONB Operators](https://dbschema.com/blog/postgresql/jsonb-in-postgresql/)
|
||||
- [Pravin Tripathi — Start and Resume Journey for Onboarding](https://medium.com/@pravinyo/approaches-for-start-and-resume-journey-for-user-onboarding-to-platform-part-i-e077c73b4cd7)
|
||||
|
||||
### A/B 测试 & 分段
|
||||
- [Appcues — A/B Testing Onboarding Flows](https://www.appcues.com/blog/flow-variation-a-b-testing)
|
||||
- [M Accelerator — A/B Testing Onboarding Guide](https://maccelerator.la/en/blog/entrepreneurship/ultimate-guide-to-ab-testing-onboarding-flows/)
|
||||
- [CXL — Segment A/B Test Results](https://cxl.com/blog/segment-ab-test-results/)
|
||||
|
||||
### 2025 综合最佳实践
|
||||
- [Aakash Gupta — 10 Customer Onboarding Best Practices for PMs 2025](https://www.aakashg.com/customer-onboarding-best-practices/)
|
||||
- [ProductLed — SaaS Onboarding Best Practices 2025](https://productled.com/blog/5-best-practices-for-better-saas-user-onboarding)
|
||||
- [Branch — Desktop-to-App Conversions](https://www.branch.io/resources/blog/optimizing-desktop-web-to-app-conversions/)
|
||||
@@ -1,983 +0,0 @@
|
||||
# Multica 产品全景文档
|
||||
|
||||
> **文档说明**
|
||||
>
|
||||
> 这份文档的目的是:**让任何没有写过代码的新同事,在 30 分钟内完全理解 Multica 这个产品到底有哪些功能、每个功能在整体中处于什么位置、一个功能和另一个功能如何协同**。
|
||||
>
|
||||
> 它的受众包括:
|
||||
>
|
||||
> - **新加入的工程师 / 产品 / 设计 / 运营**——用它做 onboarding 的第一份材料
|
||||
> - **产品介绍工作**——需要对外讲解 Multica 时的事实基础
|
||||
> - **文案工作者**——写交互文案、营销文案、帮助文档时,需要知道某个词(比如 "Skill"、"Runtime"、"Autopilot")在产品体系里代表什么
|
||||
> - **任何需要在修改某个局部前,先理解它与整体关系的人**
|
||||
>
|
||||
> 它**不是**:开发者文档、架构决策记录(ADR)、或者销售话术。它是**功能事实的汇总**——每一条描述都能在代码、schema 或 API 里找到对应。
|
||||
>
|
||||
> 文档基于对整个 monorepo(server、apps、packages、migrations、daemon、CLI)的系统性调研生成,数据截止日期 2026-04-21。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [Multica 是什么](#1-multica-是什么)
|
||||
2. [核心概念词典](#2-核心概念词典)
|
||||
3. [功能全景(按模块)](#3-功能全景按模块)
|
||||
- 3.1 [Workspace 工作区](#31-workspace-工作区)
|
||||
- 3.2 [Issue 议题管理](#32-issue-议题管理)
|
||||
- 3.3 [Project 项目](#33-project-项目)
|
||||
- 3.4 [Agent 智能体](#34-agent-智能体)
|
||||
- 3.5 [Runtime 运行时 & Daemon 守护进程](#35-runtime-运行时--daemon-守护进程)
|
||||
- 3.6 [Skill 技能](#36-skill-技能)
|
||||
- 3.7 [Autopilot 自动驾驶](#37-autopilot-自动驾驶)
|
||||
- 3.8 [Chat 对话](#38-chat-对话)
|
||||
- 3.9 [Inbox 收件箱与通知](#39-inbox-收件箱与通知)
|
||||
- 3.10 [成员、邀请与权限](#310-成员邀请与权限)
|
||||
- 3.11 [搜索与命令面板](#311-搜索与命令面板)
|
||||
- 3.12 [认证、登录与 Onboarding](#312-认证登录与-onboarding)
|
||||
- 3.13 [设置与个人资料](#313-设置与个人资料)
|
||||
- 3.14 [CLI 命令行工具](#314-cli-命令行工具)
|
||||
4. [系统架构全景](#4-系统架构全景)
|
||||
5. [产品地图(全部路由)](#5-产品地图全部路由)
|
||||
6. [跨平台差异:Web vs 桌面](#6-跨平台差异web-vs-桌面)
|
||||
7. [附录:关键数据表速查](#7-附录关键数据表速查)
|
||||
|
||||
---
|
||||
|
||||
## 1. Multica 是什么
|
||||
|
||||
### 一句话定位
|
||||
|
||||
**Multica 把编码智能体变成真正的团队成员。**
|
||||
|
||||
像给同事分配任务一样,把一个 issue 指派给一个 agent,它会自己认领、写代码、汇报进度、更新状态——不需要你一直守着。
|
||||
|
||||
### 解决的问题
|
||||
|
||||
传统方式用 AI coding agent 的痛点:
|
||||
|
||||
- 每次都要复制粘贴 prompt
|
||||
- 必须盯着终端,看它跑不跑得完
|
||||
- 没有跨任务的记忆,每次都从零开始
|
||||
- 多个 agent 同时工作时,没有一个"看板"能看到全局
|
||||
|
||||
Multica 做的事:
|
||||
|
||||
- Agent 和人**共用同一个任务看板**(issue board)
|
||||
- Agent **有 profile**,会出现在 assignee 下拉里、会在评论区发言、会自己创建 issue
|
||||
- 同一个 (agent, issue) 的多轮对话**自动恢复会话**——上一次的上下文、工作目录都保留
|
||||
- **Skill 系统**让历史上解决过的问题沉淀成可复用的能力
|
||||
- **Autopilot** 让 agent 按定时规则自动开工(比如每天早上 9 点做 bug triage)
|
||||
|
||||
### 定位一句话版本
|
||||
|
||||
> Multica 不是一个 AI 工具,而是一个**人 + AI 协作的任务管理平台**。agent 是一等公民,和人在同一个工作流里。
|
||||
|
||||
### 部署形态
|
||||
|
||||
- **云版本(Multica Cloud)**:官方托管服务,agent 通过你本地跑的 daemon 执行
|
||||
- **自托管(Self-Host)**:完整后端可以部署在自己的服务器
|
||||
- **客户端**:Next.js web 版 + Electron 桌面版(两端体验基本一致,桌面独有:多标签、原生托盘、自动更新)
|
||||
|
||||
### 支持的 Coding Agent
|
||||
|
||||
Multica **不自己训模型**,也不锁定某一家厂商。它是调度器,本地 daemon 会自动探测以下 CLI 工具并接入:
|
||||
|
||||
Claude Code · Codex · OpenClaw · OpenCode · Hermes · Gemini · Pi · Cursor Agent
|
||||
|
||||
每个 agent 可以配置自己的模型、API Key、环境变量、MCP 服务器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心概念词典
|
||||
|
||||
**理解这些名词是理解产品的前提。每个概念的定义都严格对应数据库表。**
|
||||
|
||||
| 概念 | 定义 | 映射的数据表 |
|
||||
|------|------|-------------|
|
||||
| **User 用户** | 一个人类账号,可以登录,属于多个 workspace | `user` |
|
||||
| **Workspace 工作区** | 一切资源的容器。issue、agent、project、skill 全部隔离在 workspace 里。就是 Linear/Notion 里的 workspace/team 概念 | `workspace` |
|
||||
| **Member 成员** | 用户在某个 workspace 里的身份。一个用户在不同 workspace 可以有不同角色(owner/admin/member) | `member` |
|
||||
| **Agent 智能体** | 可被指派任务的 AI 工作者。有 profile(名字、头像、说明)、会指定 runtime 和 provider、可以配自定义 prompt 和技能 | `agent` |
|
||||
| **Runtime 运行时** | Agent 实际跑在哪里的**执行环境**。可以是用户本地机器(通过 daemon)或云端实例。**一个 runtime = 一台可以跑 agent 的机器** | `agent_runtime` |
|
||||
| **Daemon 守护进程** | 用户本地运行的后台程序,自动发现已安装的 coding CLI 并注册为 runtime,然后不停轮询 server 认领任务 | (进程,不是表) |
|
||||
| **Issue 议题** | 一个工作单元——任务、bug、feature。最核心的产品对象。可以分配给人或 agent | `issue` |
|
||||
| **Comment 评论** | Issue 下的讨论回复。人和 agent 都能发。在评论里 `@某个 agent` 会自动触发这个 agent 的新任务 | `comment` |
|
||||
| **Task 任务** | Agent 执行一次 issue 所产生的一次运行。本质是"一次 agent 跑起来的会话"。队列化执行 | `agent_task_queue` |
|
||||
| **Skill 技能** | 工作区级别的可复用说明文档。作用是给 agent 提供"怎么做某件事"的上下文。Agent 开跑时会把挂载的 skill 内容注入到工作目录让 CLI 能读到 | `skill`, `skill_file`, `agent_skill` |
|
||||
| **Project 项目** | 议题的高层归属,类似"里程碑"或"版本"。issue 可以归属到 project | `project` |
|
||||
| **Autopilot 自动驾驶** | 定时或被触发的自动化规则。按 cron 或 webhook 触发,自动创建 issue 并分配给 agent | `autopilot`, `autopilot_trigger`, `autopilot_run` |
|
||||
| **Chat 对话** | 用户和 agent 的持久化多轮对话。不依附于 issue | `chat_session`, `chat_message` |
|
||||
| **Inbox 收件箱** | 个人通知中心。被 @、被分配、订阅的 issue 有更新都会进这里 | `inbox_item` |
|
||||
| **Subscriber 订阅者** | 谁关注某个 issue。被分配、被 @、评论过都会自动订阅。订阅者会收到 inbox 通知 | `issue_subscriber` |
|
||||
| **Activity 活动 / Timeline 时间线** | 所有关键动作的审计记录。issue 详情页的"时间线"就是这个表的数据 | `activity_log` |
|
||||
| **Pin 固定** | 个人侧边栏快捷方式,把常用的 issue/project 置顶 | `pinned_item` |
|
||||
| **Reaction 反应** | Issue 或评论上的 emoji 反应,跟 GitHub/Slack 一样 | `issue_reaction`, `comment_reaction` |
|
||||
| **Attachment 附件** | Issue 或评论的文件上传,支持 S3/CloudFront 或本地存储 | `attachment` |
|
||||
| **Personal Access Token (PAT)** | 用户级 API token,CLI 和自动化用。`mul_` 前缀 | `personal_access_token` |
|
||||
| **Daemon Token** | 单 workspace 单 daemon 的 token。`mdt_` 前缀,比 PAT 权限范围更小 | `daemon_token` |
|
||||
| **Session Resumption 会话恢复** | 同一对 (agent, issue) 的下一次任务会自动复用上次 Claude Code 的 `session_id` 和工作目录——历史对话、文件状态都保留 | `agent_task_queue.session_id`, `.work_dir` |
|
||||
| **MCP (Model Context Protocol)** | Anthropic 提出的协议,让 agent 通过标准接口调用外部工具。每个 agent 可配自己的 MCP server 列表 | `agent.mcp_config` (JSONB) |
|
||||
| **Workspace Context 工作区上下文** | 工作区级别的 agent 系统提示词。所有该工作区的 agent 都会感知到它 | `workspace.context` |
|
||||
| **Polymorphic Actor 多态行动者** | 设计范式:几乎所有"谁做了什么"的字段都是 `actor_type` (`member`/`agent`) + `actor_id`。这就是为什么 agent 能像人一样创建 issue、发评论、被订阅 | 贯穿所有表 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 功能全景(按模块)
|
||||
|
||||
### 3.1 Workspace 工作区
|
||||
|
||||
> **角色**:一切的容器。Multica 的多租户边界。
|
||||
|
||||
#### 功能
|
||||
|
||||
- **多工作区**:一个用户可以属于多个 workspace,每个 workspace 完全隔离(issue、agent、skill、成员都独立)。
|
||||
- **创建工作区**:只需要一个名字;自动生成 slug(URL 中使用的短 ID)。
|
||||
- **切换工作区**:侧边栏下拉;桌面端每个工作区有独立的标签组。
|
||||
- **离开工作区**:非 owner 成员可自行离开。
|
||||
- **删除工作区**:只有 owner 可以,硬删除+级联。
|
||||
- **Workspace 设置**:名称、slug、描述、**Workspace Context**(给该工作区所有 agent 的统一系统提示)、**仓库列表**(workspace 允许 agent 访问的 Git 仓库 URL 白名单)。
|
||||
- **Workspace 头像 / issue 前缀**:每个工作区可以有自己的 issue 编号前缀(如 `ACME-42`)。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Workspace 不是一个功能,而是**所有功能的坐标系**。URL 的形态永远是 `/{workspace-slug}/...`,API 请求永远带 `X-Workspace-Slug` 头。一个 issue、一个 agent、一个 skill,脱离了 workspace 就没有意义。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`workspace`, `member`, `workspace_invitation`
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Issue 议题管理
|
||||
|
||||
> **角色**:Multica 的核心工作对象。
|
||||
|
||||
Issue 对应的概念在 Linear 叫 Issue、在 Jira 叫 Ticket、在 GitHub 叫 Issue——就是一个任务单元。Multica 的特色在于**issue 可以分配给 agent,和分配给人完全对等**。
|
||||
|
||||
#### 核心字段
|
||||
|
||||
- 标题、描述(Tiptap 富文本)、状态、优先级
|
||||
- 编号(自动递增,带 workspace 前缀)
|
||||
- **Assignee(可以是 member 或 agent)**
|
||||
- **Creator(可以是 member 或 agent)**——agent 也能创建 issue
|
||||
- Parent issue(用来做子任务)
|
||||
- Project(归属的项目)
|
||||
- Due date(截止日期)
|
||||
- Labels(多对多标签)
|
||||
- Dependencies(依赖/阻塞关系)
|
||||
- Acceptance criteria(验收标准,JSONB)
|
||||
- Origin(如果是 autopilot 创建的,会记录来源 autopilot run)
|
||||
|
||||
#### 视图
|
||||
|
||||
- **List 列表视图**:表格形式,可按 status/priority/assignee/creator/project 过滤、按名称/优先级/截止日/手动位置排序;支持开放和已完成分页。
|
||||
- **Board 看板视图**:Kanban,按状态分列;支持拖拽(拖动会自动切到"手动排序"模式)。
|
||||
- **My Issues 我的议题**:专属视图,三个 scope:分配给我 / 我创建的 / 我的 agent 负责的。
|
||||
|
||||
#### 交互
|
||||
|
||||
- **快速创建**:侧边栏单行快速创建、或弹窗富文本创建(支持草稿本地持久化)
|
||||
- **批量操作**:多选后批量改 status/priority/assignee/删除
|
||||
- **子 issue**:父 issue 显示子任务完成比例圆环
|
||||
- **订阅(subscribe)**:默认 creator、assignee、被 @ 的人会自动订阅
|
||||
- **Reaction**:issue 和评论都能加 emoji 反应
|
||||
- **Pin 固定**:把 issue 置顶到侧边栏快捷栏
|
||||
- **复制链接 / 快捷键跳转(Cmd+K)**
|
||||
- **Timeline 时间线**:所有关键动作(状态变更、指派变更、评论)按时间顺序展示,混合 `activity_log` + `comment` 两类记录
|
||||
|
||||
#### 评论与讨论
|
||||
|
||||
- Tiptap 富文本编辑器,支持 `@` 提到 member 或 agent
|
||||
- 嵌套回复(一层)
|
||||
- emoji 反应
|
||||
- **@agent 触发任务**:在评论里提到某个 agent,会自动生成一个新的 agent task,让它来回复/处理
|
||||
|
||||
#### 附件
|
||||
|
||||
- 拖拽上传或按钮上传
|
||||
- 图片内联预览
|
||||
- 存储后端:S3/CloudFront 或本地磁盘(自托管)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Issue 是**所有工作流的载体**:
|
||||
- Agent 通过"被分配到 issue"获得任务
|
||||
- Autopilot 通过"创建 issue"来触发 agent
|
||||
- 评论通过"@agent" 追加任务
|
||||
- Inbox 通知围绕 issue 生成
|
||||
|
||||
#### 对应表
|
||||
|
||||
`issue`, `comment`, `issue_label`, `issue_to_label`, `issue_dependency`, `issue_subscriber`, `issue_reaction`, `comment_reaction`, `attachment`, `activity_log`, `pinned_item`
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Project 项目
|
||||
|
||||
> **角色**:多个 issue 的高层容器,类似 Linear 的 Project、Jira 的 Epic。
|
||||
|
||||
#### 功能
|
||||
|
||||
- 标题、描述、图标(emoji 或标识符)
|
||||
- 状态:`planned` / `in_progress` / `paused` / `completed` / `cancelled`
|
||||
- 优先级:urgent / high / medium / low / none
|
||||
- **Lead 负责人**:可以是 member 或 agent(跟 issue 的 assignee 一样是多态)
|
||||
- 详情页展示项目内的所有 issue
|
||||
- 支持搜索项目
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Project 相比 Issue 是更高层的组织单元。一个 issue 可以不属于任何 project,但如果属于,会在列表页的筛选、侧边栏导航、面包屑里集中展示。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`project`
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Agent 智能体
|
||||
|
||||
> **角色**:AI 工作者。Multica 最独特的对象。
|
||||
|
||||
一个 Agent 不是一个"AI 模型",而是一个**带配置的工作者身份**。它有名字、头像、个人描述、说明书(系统提示词)、绑定的运行时、挂载的技能。在 UI 上它和人一样会出现在 assignee 下拉、评论作者、订阅者列表里。
|
||||
|
||||
#### 配置字段
|
||||
|
||||
- **基本信息**:名字、描述、头像(自动生成)
|
||||
- **Provider**:选择底层是 Claude / Codex / OpenClaw / OpenCode / Hermes / Gemini / Pi / Cursor 中的哪一个
|
||||
- **Runtime**:绑定到哪个运行时(即在哪台机器上跑)
|
||||
- **Instructions 说明书**:agent 的系统提示词("你是一个资深工程师...")
|
||||
- **Custom Env**:要注入到 CLI 进程的环境变量(如 `ANTHROPIC_API_KEY`、`ANTHROPIC_BASE_URL`、`CLAUDE_CODE_USE_BEDROCK`)
|
||||
- **Custom Args**:附加给 CLI 的启动参数(如 `--model`, `--thinking`)
|
||||
- **MCP Config**:Model Context Protocol 服务器列表(让 agent 有额外工具能力)
|
||||
- **Max Concurrent Tasks**:同时最多跑几个任务
|
||||
- **Skills**:关联多个 skill(见 3.6)
|
||||
- **Visibility**:`workspace`(工作区可见)或 `private`(仅创建者可见)
|
||||
|
||||
#### 状态
|
||||
|
||||
- `idle` / `working` / `blocked` / `error` / `offline`——由 runtime heartbeat 决定
|
||||
- 可以被 archive(软删除)
|
||||
|
||||
#### 交互
|
||||
|
||||
- 在 **Settings → Agents** 页面创建、编辑、归档
|
||||
- 在 issue 的 assignee 下拉里选择
|
||||
- 在评论里 `@agent` 触发
|
||||
- 在 chat 面板里直接聊
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Agent 是 Multica 的灵魂。几乎所有功能都围绕"如何让一个 agent 干活"展开:
|
||||
- Issue 通过分配触发 agent
|
||||
- Skill 通过挂载赋能 agent
|
||||
- Runtime 提供 agent 的运行环境
|
||||
- Autopilot 调度 agent 自动开工
|
||||
- Chat 提供 agent 的对话界面
|
||||
|
||||
#### 对应表
|
||||
|
||||
`agent`, `agent_skill`
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Runtime 运行时 & Daemon 守护进程
|
||||
|
||||
> **角色**:Agent 真正跑起来的物理/虚拟机器。
|
||||
|
||||
这是 Multica **分布式执行架构**的核心设计:**agent 不在 server 上运行,而在用户自己的机器上运行**。Server 只做任务调度、状态同步、数据存储。
|
||||
|
||||
#### Daemon 是什么
|
||||
|
||||
`multica` CLI 在用户的机器上启动一个后台进程(macOS launchd / Linux systemd / Windows 服务风格),它:
|
||||
|
||||
1. **自动探测** `$PATH` 上安装的 coding CLI(`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`)
|
||||
2. 向 server **注册** 为一组 runtime(一个 CLI = 一个 runtime)
|
||||
3. 每 3 秒 **轮询** 一次 server,有任务就认领
|
||||
4. 每 15 秒 **心跳**(keepalive),报告自己还活着
|
||||
5. 认领任务后,在本机的隔离工作目录里**启动 agent CLI**,把 agent 的输出流**实时推回 server**
|
||||
6. 任务完成后上报结果、token 用量、session id 和工作目录(用于下次恢复)
|
||||
|
||||
#### Runtime 展示
|
||||
|
||||
在 **Settings → Runtimes** 页面可以看到:
|
||||
|
||||
- 每个 runtime 的名字、提供方(图标)、owner(谁的机器)、状态指示(在线/离线)、last seen 时间
|
||||
- Ping 诊断:手动戳一下看响应
|
||||
- Usage 用量:近期的 token 消耗统计
|
||||
- Activity:任务活动情况
|
||||
- CLI 安装指引(自托管模式下)
|
||||
- 桌面端独有:**本地 daemon 卡片**,显示本机 daemon 状态、可一键重启
|
||||
|
||||
#### Runtime 的生命周期
|
||||
|
||||
- **注册**:daemon 启动时 POST `/api/daemon/register` 得到 runtime ID
|
||||
- **在线**:15 秒一次心跳
|
||||
- **离线**:如果 server 45 秒没收到心跳,把 runtime 标记为离线(server 后台 sweeper 每 30 秒巡检)
|
||||
- **孤儿任务回收**:超过 5 分钟还在 dispatched 或超过 2.5 小时还在 running 的任务,sweeper 会把它标记为失败
|
||||
- **长期离线 GC**:7 天没心跳且没活跃 agent 的 runtime 会被回收
|
||||
|
||||
#### CLI 与 Daemon 的关系
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `multica setup` | 一键配置:填 URL + 登录 + 启动 daemon |
|
||||
| `multica login` | 浏览器打开 OAuth 登录,保存 90 天 PAT 到 `~/.multica/config.json` |
|
||||
| `multica login --token <pat>` | 无头登录(SSH/CI) |
|
||||
| `multica daemon start` | 后台启动 daemon(写 PID 到 `~/.multica/daemon.pid`,日志到 `~/.multica/daemon.log`) |
|
||||
| `multica daemon stop` | 发 SIGTERM,优雅关闭(等待进行中的任务完成,超时 30s) |
|
||||
| `multica daemon status` | 打印 daemon 状态、探测到的 agent、watch 中的 workspace |
|
||||
| `multica daemon logs -f` | 实时跟随日志 |
|
||||
| `multica daemon start --profile <name>` | 启动独立配置的 daemon(用于多环境,比如同时连 staging 和生产) |
|
||||
|
||||
#### 安全边界
|
||||
|
||||
- 每个任务一个**独立工作目录** `~/multica_workspaces/{ws}/{task_short_id}/workdir/`
|
||||
- 环境变量**过滤**:阻止 agent 覆盖 daemon 的认证变量(`MULTICA_TOKEN` 等)
|
||||
- 仓库访问**白名单**:agent 只能 checkout workspace 配置的仓库
|
||||
- Codex 有**版本相关的 sandbox 策略**
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Runtime 是让"给 agent 分配任务"这件事**能真正发生**的基础设施。没有 runtime,所有 agent 就是空壳。用户第一次 onboarding 时必须至少有一个 runtime 在线,否则 agent 没法干活。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`agent_runtime`, `daemon_token`, `daemon_pairing_session`(弃用中), `daemon_connection`(弃用中), `runtime_usage`
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Skill 技能
|
||||
|
||||
> **角色**:让 agent "学会"某种工作方式的可复用说明文档。
|
||||
|
||||
Skill 是一组 Markdown 文档 + 配套文件。它**不是代码**,**不是 prompt 模板**,而是**给 agent CLI 读的说明**。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
```
|
||||
skill
|
||||
├─ name: "react-patterns"
|
||||
├─ description: "Common React patterns and best practices"
|
||||
├─ content: "## Overview\n..." # 主要说明文档
|
||||
└─ files:
|
||||
├─ examples/hooks.md
|
||||
└─ examples/useState.jsx
|
||||
```
|
||||
|
||||
#### 它怎么工作
|
||||
|
||||
1. **创建**:在 **Settings → Skills** 页面创建或从 URL 导入(如 clawhub.ai、skills.sh)
|
||||
2. **挂载**:给某个 agent 勾选要用的 skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/agent/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
- 其他 → `.agent_context/skills/{name}/SKILL.md`
|
||||
4. **使用**:agent CLI 自己按照 provider 约定发现并读取这些文件
|
||||
|
||||
> 💡 **Skill 是静态的**——不是 AI 生成的,也不会随执行变化。它是人写的经验文档。未来可能扩展成"AI 从历史任务中沉淀技能",但当前版本不是。
|
||||
|
||||
#### CLI 对应命令
|
||||
|
||||
```bash
|
||||
multica skill list
|
||||
multica skill get <id>
|
||||
multica skill create --title ...
|
||||
multica skill import --url https://...
|
||||
multica skill files upsert <skill-id> --path ...
|
||||
```
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Skill 是 Multica 区别于"每次都要写长 prompt"的关键机制。它让团队的专业知识**沉淀成可复用的组件**,绑在 agent 上就生效——就像给员工写的 SOP/playbook。
|
||||
|
||||
从架构角度:skill 不参与执行逻辑,只参与**上下文注入**。它在整个任务生命周期里只出现一次——在 daemon 启动 CLI 之前的环境准备阶段。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`skill`, `skill_file`, `agent_skill`
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Autopilot 自动驾驶
|
||||
|
||||
> **角色**:让 agent 在没人触发的时候也能自己开工的调度器。
|
||||
|
||||
Autopilot 解决的问题:很多工作是**周期性**的——每天早上的 bug triage、每周的依赖审计、每月的安全扫描。人手动触发太烦,Autopilot 是规则化自动触发。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
```
|
||||
autopilot
|
||||
├─ title, description
|
||||
├─ assignee: <agent_id> # 指定哪个 agent 跑
|
||||
├─ execution_mode: create_issue | run_only
|
||||
├─ issue_title_template: "Daily triage - {{date}}"
|
||||
├─ concurrency_policy: skip | queue | replace
|
||||
└─ triggers (多个):
|
||||
├─ kind: schedule | webhook | api
|
||||
├─ cron_expression
|
||||
├─ timezone
|
||||
└─ webhook_token
|
||||
```
|
||||
|
||||
#### 两种执行模式
|
||||
|
||||
- **`create_issue`(默认)**:触发时先创建一个新 issue(标题用 `issue_title_template` 渲染),再把 issue 分配给 agent,走正常 agent 任务流程
|
||||
- **`run_only`**:直接创建 task,不关联 issue(适合"只执行不留下 ticket"的场景,比如每小时检查某状态)
|
||||
|
||||
#### 三种触发方式
|
||||
|
||||
- **Schedule(cron)**:server 后台每 30 秒扫一次 `autopilot_trigger`,到点的触发出去
|
||||
- **Webhook**:给出一个带 `webhook_token` 的 URL,外部 POST 即可触发
|
||||
- **API / Manual**:UI 上点"立即运行"按钮,或用 CLI `multica autopilot trigger <id>`
|
||||
|
||||
#### 并发策略
|
||||
|
||||
- `skip`:同一个 autopilot 上一次还没跑完,跳过这次(去重)
|
||||
- `queue`:排队等上一次跑完
|
||||
- `replace`:中止上一次,换成这次
|
||||
|
||||
#### 运行记录
|
||||
|
||||
每次触发都在 `autopilot_run` 里留一条记录:`pending → issue_created → running → completed/failed/skipped`。在 UI 的 autopilot 详情页可以看全部历史。
|
||||
|
||||
#### 内置模板
|
||||
|
||||
产品提供一些现成的 autopilot 模板,一键创建:
|
||||
|
||||
- Daily news digest(每天 9:00)
|
||||
- PR review reminder(工作日 10:00)
|
||||
- Bug triage(工作日 9:00)
|
||||
- Weekly progress report(每周 17:00)
|
||||
- Dependency audit(每周 10:00)
|
||||
- Security scan(每周 02:00)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Autopilot 让 Multica 从"你分配 → agent 做"升级到"agent 自己发起工作"。配合 `run_only` 模式,甚至可以在没有 issue 的前提下跑定时任务。Issue 上的 `origin_type=autopilot` + `origin_id` 字段留下了"这个 issue 是哪个 autopilot run 创建的"的追溯链。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`autopilot`, `autopilot_trigger`, `autopilot_run`
|
||||
|
||||
---
|
||||
|
||||
### 3.8 Chat 对话
|
||||
|
||||
> **角色**:用户和 agent 的持久多轮对话界面,不依附于 issue。
|
||||
|
||||
有时候你不想为了和 agent 说一句话就开一个 issue。Chat 就是为这种"轻量对话"准备的——像 ChatGPT 的对话界面,但是你在和你工作区的某个 agent 对话。
|
||||
|
||||
#### 功能
|
||||
|
||||
- **创建会话**:选一个 agent 开始
|
||||
- **消息列表**:支持 Markdown 渲染、代码块高亮
|
||||
- **发送消息**:消息会被 queue 成一个 task,agent 执行后把响应作为消息写回
|
||||
- **流式响应**:通过 WebSocket 实时推送
|
||||
- **未读跟踪**:`unread_since` 字段记录第一条未读消息的时间戳
|
||||
- **归档**:把旧会话移出活跃列表
|
||||
- **Session 复用**:同一个 chat session 下的多轮消息会复用底层 CLI 的 `session_id`(Claude Code 能保留对话上下文)
|
||||
|
||||
#### 和 Issue 评论的区别
|
||||
|
||||
| | Chat | Issue 评论 |
|
||||
|---|---|---|
|
||||
| 上下文载体 | 独立 session(chat_session) | 某个 issue |
|
||||
| 是否公开 | 个人和 agent 对话(私有) | 工作区所有成员可见 |
|
||||
| 触发 agent | 每条 user 消息都触发 | 需要 `@agent` |
|
||||
| 用途 | 探索、提问、一次性任务 | 和 issue 强绑定的工作推进 |
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Chat 填补了"不够正式到需要开 issue、但又需要持久化"的对话空白。同时也是体验上更像常规聊天软件的入口。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`chat_session`, `chat_message`;底层执行仍走 `agent_task_queue`(`chat_session_id` 字段区分)
|
||||
|
||||
---
|
||||
|
||||
### 3.9 Inbox 收件箱与通知
|
||||
|
||||
> **角色**:每个人的个人通知中心。
|
||||
|
||||
#### 数据形态
|
||||
|
||||
`inbox_item` 是推给特定"recipient"的条目:
|
||||
|
||||
- recipient_type = `member` 或 `agent`(agent 也能有 inbox!)
|
||||
- type(e.g. `issue_assigned`, `comment_mention`, `task_completed`, `invitation_created`)
|
||||
- severity(`action_required` / `attention` / `info`)
|
||||
- 关联的 issue(如果有)
|
||||
- read / archived 状态
|
||||
|
||||
#### 通知触发场景
|
||||
|
||||
- Issue 被分配给你
|
||||
- 被 @ 提到
|
||||
- 订阅的 issue 状态变化
|
||||
- 订阅的 issue 有新评论
|
||||
- 工作区邀请
|
||||
- 你的 agent 任务完成/失败
|
||||
|
||||
#### 订阅机制(自动)
|
||||
|
||||
Server 的 subscriber listener 自动把以下人加入 `issue_subscriber`:
|
||||
|
||||
- issue creator
|
||||
- 当前 assignee(变更会同步更新)
|
||||
- 评论里被 @ 的人
|
||||
- 手动订阅的人
|
||||
|
||||
#### UI
|
||||
|
||||
- **Inbox 页面**:两栏布局,左边列表 + 右边 issue 详情
|
||||
- **批量操作**:全部标记已读 / 仅归档已读 / 归档已完成 issue 的通知
|
||||
- **徽标**:侧边栏导航上显示未读数
|
||||
- **WebSocket 推送**:新 inbox 条目实时到达(`inbox:new` 事件只发给目标用户)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Inbox 是"主动注意力系统",让用户不必一直盯着看板也知道哪些事要自己处理。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`inbox_item`, `issue_subscriber`
|
||||
|
||||
---
|
||||
|
||||
### 3.10 成员、邀请与权限
|
||||
|
||||
#### 角色体系
|
||||
|
||||
| 角色 | 权限 |
|
||||
|------|------|
|
||||
| **Owner** | 全部;唯一能删除工作区的角色 |
|
||||
| **Admin** | 管理成员、管理设置;不能删工作区,不能移除其他 admin |
|
||||
| **Member** | 创建 issue、评论、自我分配、使用 agent |
|
||||
|
||||
#### 邀请流程
|
||||
|
||||
- Admin 在 **Settings → Members** 输入邮箱邀请
|
||||
- Server 生成 `workspace_invitation` 记录(7 天过期)
|
||||
- 发送邮件(Resend 集成,未配置时打到 stderr)
|
||||
- 被邀请人收到邀请:如果已有账号,会出现在个人 Inbox;如果没账号,邮件里有注册链接
|
||||
- 接受 / 拒绝 / 过期
|
||||
|
||||
#### UI
|
||||
|
||||
- 成员列表:头像、邮箱、角色徽章、操作菜单(改角色、移除)
|
||||
- 待处理邀请列表:可 resend、revoke
|
||||
- Invite 接受页面(`/invite/[id]`):展示工作区信息、接受/拒绝按钮
|
||||
|
||||
#### 邀请接受的桌面特殊处理
|
||||
|
||||
桌面端的 `multica://invite/{id}` 深链接**不是走路由**,而是触发 `WindowOverlay`——共享视图组件 `InvitePage` 装在原生窗口覆盖层里,保证拖拽移动窗口等原生体验。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
成员管理是**一切协作的前提**。但在 Multica 里它有一个独特之处:成员系统也管 agent。之所以要有 `assignee_type` 区分 member 和 agent,就是为了让两者在同一套 API 里表达"谁可以被分配"。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`member`, `workspace_invitation`
|
||||
|
||||
---
|
||||
|
||||
### 3.11 搜索与命令面板
|
||||
|
||||
#### 命令面板(Cmd+K)
|
||||
|
||||
全局搜索入口,覆盖:
|
||||
|
||||
- **Issues**(按标题、编号匹配)
|
||||
- **Projects**(按名称匹配)
|
||||
- **Workspaces**(按名称匹配,用于快速切换)
|
||||
- **Navigation**(跳转到设置、runtimes、skills 等)
|
||||
- **Actions**(新建 issue、新建 project、切换主题)
|
||||
- **Recent Issues**(最近访问过的,自动记录)
|
||||
|
||||
#### 列表过滤
|
||||
|
||||
Issue 列表、project 列表、inbox 等都有本地 filter chips 和 search input。
|
||||
|
||||
#### 全文搜索
|
||||
|
||||
`GET /api/issues/search` 支持对 issue 的标题、描述、评论内容做全文搜索,返回命中片段。
|
||||
|
||||
> **当前没有基于向量的语义搜索**——产品宣传是 AI-native,但没有用 pgvector。Schema 里也没启用向量扩展。未来可能扩展。
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Cmd+K 是 keyboard-first 用户(Linear-style)的主要导航方式,比点击侧边栏更快。
|
||||
|
||||
---
|
||||
|
||||
### 3.12 认证、登录与 Onboarding
|
||||
|
||||
#### 登录方式
|
||||
|
||||
- **邮箱验证码(Magic Link 风格)**:输入邮箱 → 收 6 位验证码 → 输入验证码登录
|
||||
- **Google OAuth**:一键 Google 登录
|
||||
- **PAT(CLI)**:用户在 Settings → API Tokens 里生成的 token,CLI/脚本场景
|
||||
|
||||
#### Onboarding 流程(正在重设计中)
|
||||
|
||||
位于 `packages/views/onboarding/` 和 `apps/web/app/(auth)/onboarding/`。
|
||||
|
||||
经典 5 步:
|
||||
|
||||
1. **Welcome** — 欢迎页
|
||||
2. **Workspace** — 创建工作区(或跳过,如果已有)
|
||||
3. **Runtime** — 展示可用的 runtime 和 CLI 安装指引
|
||||
4. **Agent** — 创建第一个 agent(需要有 runtime)
|
||||
5. **Complete** — 展示创建好的 workspace 和 agent,跳转到 dashboard
|
||||
|
||||
#### 邀请接受(Zero-workspace)
|
||||
|
||||
如果新用户是被邀请进来的(还没有自己的 workspace),接受邀请后直接进入该工作区,跳过 onboarding。
|
||||
|
||||
#### 认证后的跳转规则
|
||||
|
||||
- 已登录且有至少一个 workspace:跳到 `/{slug}/issues`
|
||||
- 已登录但没有 workspace:进入 `/workspaces/new` 或 onboarding
|
||||
- 未登录:跳到 `/login`
|
||||
|
||||
#### Signup 限流
|
||||
|
||||
Server 支持:
|
||||
- `ALLOW_SIGNUP=false` 关闭注册
|
||||
- `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 白名单
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Onboarding 是新用户能不能成功把 agent 跑起来的关键漏斗。任何一步没完成(尤其是 runtime 没连上),后续功能都是空壳。
|
||||
|
||||
#### 对应表
|
||||
|
||||
`user`, `verification_code`, `personal_access_token`
|
||||
|
||||
---
|
||||
|
||||
### 3.13 设置与个人资料
|
||||
|
||||
#### My Account 标签
|
||||
|
||||
- **Profile**:名字、头像(不可上传,系统生成)、邮箱(只读)
|
||||
- **Appearance**:主题(light / dark / system)
|
||||
- **API Tokens**:创建/查看/撤销 PAT;创建时一次性展示完整 token
|
||||
- **Daemon**(桌面独有):本机 daemon 状态、重启、开机自启开关
|
||||
- **Updates**(桌面独有):当前版本、检查更新、自动更新开关
|
||||
|
||||
#### Workspace 标签
|
||||
|
||||
- **General**:名字、描述、**Workspace Context**(agent 系统级提示)
|
||||
- **Members**:见 3.10
|
||||
- **Repositories**:GitHub 集成,连接仓库列表,agent 白名单
|
||||
- **Agents / Runtimes / Skills / Autopilots**:各自独立页面(实际上这些在侧边栏直接有入口,settings 里也有对应管理 tab)
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
Settings 是所有"配置即工作"动作的汇总:agent 的 prompt、workspace 的 context、仓库白名单、skill 的内容——都在这里。**对运营和文案来说最重要的一句话**:用户在 Multica 的 settings 页面做的配置,每一项都会影响 agent 实际执行时读到的上下文。
|
||||
|
||||
---
|
||||
|
||||
### 3.14 CLI 命令行工具
|
||||
|
||||
`multica` 不只是启动 daemon 的工具,也是完整的命令行操作层。很多用户喜欢在终端里推进工作而不是开 UI。
|
||||
|
||||
#### 工作区 / 议题
|
||||
|
||||
```bash
|
||||
multica workspace list | get | watch | unwatch
|
||||
multica issue list | get | create | update | assign | status
|
||||
multica issue comment list | add | delete
|
||||
multica issue runs <id> # 查看任务执行历史
|
||||
multica issue run-messages <task-id> # 查看某次执行的消息
|
||||
```
|
||||
|
||||
#### Agent / Skill / Autopilot / Project / Repo
|
||||
|
||||
```bash
|
||||
multica agent list | get | create | update | archive
|
||||
multica skill list | get | create | update | delete | import | files upsert
|
||||
multica autopilot list | get | create | update | trigger
|
||||
multica autopilot trigger-add --cron "0 9 * * 1-5"
|
||||
multica project list | get | create | update
|
||||
multica repo list | add | update | delete
|
||||
```
|
||||
|
||||
#### Runtime
|
||||
|
||||
```bash
|
||||
multica runtime list | get | ping | delete
|
||||
```
|
||||
|
||||
#### 配置 / 更新
|
||||
|
||||
```bash
|
||||
multica config show | set server_url ...
|
||||
multica auth status | logout
|
||||
multica version | update
|
||||
```
|
||||
|
||||
#### 产品里的位置
|
||||
|
||||
CLI 是 Multica 对开发者友好度的体现。对于 agent 自己来说,也同等重要——**agent 在执行任务时能调用 `multica` 命令读写 issue、评论、查文档**,这正是 CLI 在 "agent 作为一等公民"架构里的作用。
|
||||
|
||||
---
|
||||
|
||||
## 4. 系统架构全景
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────────┐
|
||||
│ Next.js Web App │ │ Electron Desktop │ │ multica CLI │
|
||||
│ apps/web │ │ apps/desktop │ │ server/cmd/ │
|
||||
└──────────┬──────────┘ └──────────┬─────────┘ └────────┬─────────┘
|
||||
│ HTTP + WebSocket │ │ HTTP
|
||||
│ │ │
|
||||
└──────────────┬────────────────┴───────────────┬───────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Go Backend (server/) │
|
||||
│ • Chi HTTP router • gorilla/websocket hub │
|
||||
│ • sqlc generated queries │
|
||||
│ • In-process event bus │
|
||||
│ • Background workers (sweeper / scheduler) │
|
||||
└──────────────────┬──────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ PostgreSQL 17 │
|
||||
│ + pgcrypto │
|
||||
│ (28 tables) │
|
||||
└──────────────────────┘
|
||||
|
||||
▲
|
||||
│ HTTPS poll + heartbeat
|
||||
│
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Local Daemon (用户机器上运行) │
|
||||
│ • 每 3s 认领任务 • 每 15s 心跳 │
|
||||
│ • 探测并启动 agent CLI 子进程 │
|
||||
│ • 为任务准备隔离工作目录 │
|
||||
└───────────────┬─────────────────────────────────┘
|
||||
│ spawns
|
||||
┌───────────────┼─────────────────────────────────┐
|
||||
▼ ▼ ▼ ▼
|
||||
Claude Code Codex OpenCode …其他 CLI
|
||||
(子进程) (子进程) (子进程)
|
||||
```
|
||||
|
||||
### 分层职责
|
||||
|
||||
| 层 | 负责什么 | 不负责什么 |
|
||||
|---|---|---|
|
||||
| **Web / Desktop 客户端** | UI、本地客户端状态(Zustand)、服务器状态缓存(TanStack Query)、WebSocket 订阅 | 业务规则、AI 调用 |
|
||||
| **Server** | 持久化、权限、任务编排、事件广播、Autopilot 调度、Runtime 健康监测 | 不直接执行 agent、不调 LLM |
|
||||
| **Daemon** | 探测并启动本地 CLI、管理任务工作目录、流式上报消息、session 恢复 | 不做业务决策、只认 server 给它的任务 |
|
||||
| **Agent CLI(Claude Code 等)** | 实际调用 LLM、执行工具调用、写文件、跑测试 | 不感知 Multica 的数据模型(所有上下文通过 `multica` CLI 命令读回) |
|
||||
|
||||
### 实时层(WebSocket)
|
||||
|
||||
Server 启动一个 WebSocket hub:
|
||||
|
||||
- **鉴权**:URL 参数里的 JWT 或 PAT + workspace_slug
|
||||
- **房间模型**:按 workspace 分房间,一个 workspace 的事件只广播给该房间的连接
|
||||
- **个人定向推送**:`inbox:new`, `invitation:created` 等个人事件用 `SendToUser`
|
||||
- **心跳**:server 每 54 秒 ping,客户端 60 秒内必须 pong
|
||||
|
||||
**全部事件类型(供文案参考,共约 60+ 个)**:
|
||||
- `issue:created` / `issue:updated` / `issue:deleted`
|
||||
- `comment:created` / `comment:updated` / `comment:deleted` / `reaction:added` / `issue_reaction:added`
|
||||
- `agent:created` / `agent:status` / `agent:archived`
|
||||
- `task:dispatch` / `task:progress` / `task:message` / `task:completed` / `task:failed` / `task:cancelled`
|
||||
- `inbox:new` / `inbox:read` / `inbox:archived` / `inbox:batch-*`
|
||||
- `workspace:updated` / `workspace:deleted` / `member:added` / `member:updated` / `member:removed`
|
||||
- `invitation:created` / `invitation:accepted` / `invitation:declined` / `invitation:revoked`
|
||||
- `chat:message` / `chat:done` / `chat:session_read`
|
||||
- `skill:created` / `skill:updated` / `skill:deleted`
|
||||
- `project:created` / `project:updated` / `project:deleted`
|
||||
- `autopilot:created` / `autopilot:updated` / `autopilot:run_start` / `autopilot:run_done`
|
||||
- `subscriber:added` / `activity:created`
|
||||
- `daemon:heartbeat` / `daemon:register`
|
||||
|
||||
客户端收到事件后的模式:要么直接 patch 本地缓存(issue / comment / task 这类需要即时更新的),要么触发对应 query 的失效重拉(less-critical 数据)。
|
||||
|
||||
### AI / LLM 在哪里
|
||||
|
||||
**Multica 本身不直接调 LLM API**。所有 LLM 调用都在 agent CLI 子进程里发生(Claude Code 调 Anthropic API、Codex 调 OpenAI API 等)。
|
||||
|
||||
Server 和 daemon 做的事情是:
|
||||
|
||||
1. 准备 prompt(见 `server/internal/daemon/prompt.go`)
|
||||
2. 准备环境变量(agent.custom_env 注入)
|
||||
3. 准备工作目录(注入 CLAUDE.md / AGENTS.md / skills / issue context)
|
||||
4. 启动 CLI 子进程
|
||||
5. 流式读 CLI 的 stdout,把消息分类并转发
|
||||
|
||||
**所以看不到大段的 prompt 工程代码**——prompt 只有几个模板(task prompt、chat prompt、comment-triggered prompt),核心内容是 agent instructions + issue context + skill files,真正的 LLM 对话由 CLI 自己管理。
|
||||
|
||||
### 后台任务
|
||||
|
||||
Server 启动三个 goroutine:
|
||||
|
||||
1. **Runtime Sweeper**(每 30s):标记离线 runtime、回收孤儿任务、GC 长期离线 runtime
|
||||
2. **Autopilot Scheduler**(每 30s):扫 cron 触发器,到点就 dispatch
|
||||
3. **DB Stats Logger**:周期性打印 pgxpool 连接池状态
|
||||
|
||||
---
|
||||
|
||||
## 5. 产品地图(全部路由)
|
||||
|
||||
### 公共 / 认证
|
||||
|
||||
- `/` — 首页
|
||||
- `/login` — 登录
|
||||
- `/auth/callback` — OAuth 回调
|
||||
- `/workspaces/new` — 创建工作区
|
||||
- `/invite/[id]` — 接受邀请
|
||||
- `/onboarding` — 首次引导
|
||||
|
||||
### 工作区内(`/{slug}/...`)
|
||||
|
||||
- `/issues` — Issue 列表(board / list 视图)
|
||||
- `/issues/[id]` — Issue 详情
|
||||
- `/my-issues` — 我的 issue(三 scope)
|
||||
- `/projects` — 项目列表
|
||||
- `/projects/[id]` — 项目详情
|
||||
- `/autopilots` — Autopilot 列表
|
||||
- `/autopilots/[id]` — Autopilot 详情
|
||||
- `/agents` — Agent 列表
|
||||
- `/runtimes` — Runtime 列表
|
||||
- `/skills` — Skill 库
|
||||
- `/inbox` — 收件箱
|
||||
- `/settings` — 设置(包含多个 tab:profile / appearance / tokens / workspace / members / repos / daemon / updates)
|
||||
|
||||
### 桌面端特有(不是路由,是 WindowOverlay)
|
||||
|
||||
- **Create workspace overlay**
|
||||
- **Invite accept overlay**(来自 `multica://invite/{id}` 深链接)
|
||||
- **Onboarding overlay**(首次或零工作区时)
|
||||
|
||||
---
|
||||
|
||||
## 6. 跨平台差异:Web vs 桌面
|
||||
|
||||
### 共享(绝大部分功能)
|
||||
|
||||
所有业务页面(issues / projects / autopilots / agents / runtimes / skills / inbox / settings / chat / login / onboarding)的实际 UI 都在 `packages/views/` 里,web 和桌面共用同一套组件。
|
||||
|
||||
### Web 特有
|
||||
|
||||
- 地址栏 + 浏览器前进后退
|
||||
- 服务端渲染(SSR)
|
||||
- `/login` 的 OAuth 回调处理 localhost 端口(方便 CLI 登录)
|
||||
|
||||
### 桌面特有
|
||||
|
||||
- **多标签**:每个 workspace 独立标签组,可以拖拽重排
|
||||
- **WindowOverlay**:邀请接受、创建工作区、onboarding 不走路由,而是原生窗口层
|
||||
- **Daemon 集成**:设置里能直接重启本机 daemon、看状态
|
||||
- **本地 daemon runtime 卡片**:在 Runtimes 页面自动显示本机 daemon
|
||||
- **自动更新**:`Settings → Updates` 检查/下载/安装新版本
|
||||
- **Immersive mode**:全屏模式,隐藏侧边栏
|
||||
- **深链接**:`multica://auth/callback?token=...` 和 `multica://invite/{id}`
|
||||
- **拖动区**:macOS 的红绿灯 + 顶部 48px 拖拽条(`h-12`)用来移动窗口
|
||||
- **Workspace 单例守护**:`setCurrentWorkspace()` 管理当前活跃工作区的全局身份
|
||||
|
||||
### 为什么两端要做差异
|
||||
|
||||
Web 有 URL 栏——错误状态(比如"你没有访问这个 workspace 的权限")作为一个可分享的 URL 页面是有意义的。桌面没有 URL 栏——同样的状态只会把用户困住,所以桌面选择**静默自愈**:把失效的 tab 从 store 里移除即可。这个差异直接影响多个细节:
|
||||
|
||||
- Web 有 `NoAccessPage`,桌面没有
|
||||
- Web 有 `/workspaces/new` 页面,桌面把它做成 overlay
|
||||
- Web 的 deep link 直接路由,桌面的深链接转 WindowOverlay
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录:关键数据表速查
|
||||
|
||||
共 **28 张表**,覆盖 10 个产品域。以下按域列出最重要的字段,供文案/产品查询"某个功能背后到底存了什么"。
|
||||
|
||||
### 身份 / 认证
|
||||
|
||||
- `user` — 基础账号(id, email, name, avatar_url)
|
||||
- `verification_code` — 邮箱验证码(code, expires_at, attempts)
|
||||
- `personal_access_token` — 用户 API token(token_hash, token_prefix, revoked)
|
||||
|
||||
### 工作区 / 成员
|
||||
|
||||
- `workspace` — 容器(name, slug, description, context, settings, repos, issue_prefix, issue_counter)
|
||||
- `member` — 成员身份(role: owner/admin/member)
|
||||
- `workspace_invitation` — 邀请(invitee_email, status: pending/accepted/declined/expired)
|
||||
|
||||
### Agent / Runtime / Skill
|
||||
|
||||
- `agent` — Agent 主表(instructions, custom_env, custom_args, mcp_config, runtime_mode, visibility, status)
|
||||
- `agent_runtime` — 运行时(daemon_id, provider, status: online/offline, last_seen_at)
|
||||
- `agent_skill` — agent 挂载 skill 的 n-n 关联
|
||||
- `skill` — 技能主文档(name, description, content)
|
||||
- `skill_file` — 技能附带文件(path, content)
|
||||
- `daemon_token` — 守护进程级 token
|
||||
- `daemon_connection` / `daemon_pairing_session` — 早期设计(弃用中)
|
||||
|
||||
### Issue / 协作
|
||||
|
||||
- `issue` — 议题(status, priority, assignee_type+assignee_id, creator_type+creator_id, parent_issue_id, project_id, origin_type, origin_id, acceptance_criteria, due_date, position)
|
||||
- `issue_label` / `issue_to_label` — 标签
|
||||
- `issue_dependency` — 依赖关系(blocks / blocked_by / related)
|
||||
- `issue_subscriber` — 订阅者(reason: creator/assignee/commenter/mentioned/manual)
|
||||
- `issue_reaction` / `comment_reaction` — emoji 反应
|
||||
- `comment` — 评论(type: comment/status_change/progress_update/system, parent_id for threading)
|
||||
- `attachment` — 附件
|
||||
|
||||
### 任务执行
|
||||
|
||||
- `agent_task_queue` — 任务主表(status: queued/dispatched/running/completed/failed/cancelled, context, result, session_id, work_dir, trigger_comment_id, chat_session_id, autopilot_run_id)
|
||||
- `task_message` — 每次执行的消息流水(seq, type, tool, input, output)
|
||||
- `task_usage` — Token 用量(input/output/cache_read/cache_write tokens)
|
||||
|
||||
### 对话
|
||||
|
||||
- `chat_session` — 聊天会话(unread_since, session_id, work_dir)
|
||||
- `chat_message` — 消息(role: user/assistant)
|
||||
|
||||
### 项目与组织
|
||||
|
||||
- `project` — 项目(status, priority, lead_type+lead_id, icon)
|
||||
- `pinned_item` — 侧边栏置顶(item_type, item_id, position)
|
||||
|
||||
### 自动化
|
||||
|
||||
- `autopilot` — 规则(assignee_id, execution_mode: create_issue/run_only, issue_title_template, concurrency_policy)
|
||||
- `autopilot_trigger` — 触发器(kind: schedule/webhook/api, cron_expression, timezone, next_run_at, webhook_token)
|
||||
- `autopilot_run` — 执行记录(status: pending/issue_created/running/skipped/completed/failed)
|
||||
|
||||
### 通知与审计
|
||||
|
||||
- `inbox_item` — 收件箱条目(recipient_type, type, severity, read, archived)
|
||||
- `activity_log` — 审计日志(actor_type: member/agent/system, action, details)
|
||||
- `runtime_usage` — 运行时按日聚合 token 用量(给计费/容量规划用)
|
||||
|
||||
---
|
||||
|
||||
## 尾声
|
||||
|
||||
Multica 的设计可以归结为一句话:**把"人在一个看板上协作"这件事,扩展到了"人 + AI agent 在同一个看板上协作"**。
|
||||
|
||||
所有功能都是围绕这个核心展开:
|
||||
- 为了让 agent 能像人一样被分配任务 → polymorphic actor(`assignee_type`)
|
||||
- 为了让 agent 能自己开工 → Autopilot
|
||||
- 为了让 agent 的工作方式能沉淀复用 → Skill
|
||||
- 为了让 agent 执行在用户控制的环境里 → Runtime + Daemon
|
||||
- 为了让人不被通知淹没 → Inbox + 自动订阅
|
||||
- 为了让一次会话有连续性 → Session Resumption
|
||||
|
||||
当你读到某段文案、某个 UI 模块、某张表时,请把它放回这个"人 + AI 协作"的坐标系里去理解它的位置。
|
||||
@@ -6,7 +6,6 @@
|
||||
"scripts": {
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"dev:desktop": "turbo dev --filter=@multica/desktop",
|
||||
"dev:desktop:staging": "turbo dev:staging --filter=@multica/desktop",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* Download funnel instrumentation.
|
||||
*
|
||||
* Complements the onboarding events added in PR #1489 by covering
|
||||
* every surface that advertises the desktop app — landing hero,
|
||||
* landing footer, login, Welcome (web branch), Step 3 — and the
|
||||
* /download page itself. Without this layer we can see Step 3
|
||||
* path selection but not the touchpoint that got the user there,
|
||||
* nor the /download → installer conversion.
|
||||
*
|
||||
* Event names and property shapes are governed by docs/analytics.md;
|
||||
* keep the two in sync when adding a new source or field.
|
||||
*/
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import { captureEvent, setPersonProperties } from "./index";
|
||||
|
||||
/**
|
||||
* Where the user clicked a CTA that points at `/download`. Typed union
|
||||
* prevents drift across the five touchpoints and lets PostHog funnels
|
||||
* split cleanly by top-of-funnel entry.
|
||||
*/
|
||||
export type DownloadIntentSource =
|
||||
| "landing_hero"
|
||||
| "landing_footer"
|
||||
| "login"
|
||||
| "welcome"
|
||||
| "step3";
|
||||
|
||||
/**
|
||||
* OS + arch detect result for the /download page. Mirrors the shape of
|
||||
* `@/features/landing/utils/os-detect.ts` without importing it (that
|
||||
* module lives in the web app; core packages can't depend on it). Keep
|
||||
* these enums in lockstep.
|
||||
*/
|
||||
export interface DownloadDetectPayload {
|
||||
detected_os: "mac" | "windows" | "linux" | "unknown";
|
||||
detected_arch: "arm64" | "x64" | "unknown";
|
||||
detect_confident: boolean;
|
||||
version_available: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific installer the user chose on /download. Version is the GitHub
|
||||
* tag name (e.g. "v0.2.13") so we can correlate adoption-by-release.
|
||||
*/
|
||||
export interface DownloadInitiatedPayload {
|
||||
platform: "mac" | "windows" | "linux";
|
||||
arch: "arm64" | "x64";
|
||||
format: "dmg" | "zip" | "exe" | "appimage" | "deb" | "rpm";
|
||||
version: string;
|
||||
primary_cta: boolean;
|
||||
matched_detect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when a user clicks any CTA that navigates to `/download`. We
|
||||
* also write `platform_preference` to person properties so the backend
|
||||
* can segment subsequent events — same convention the Step 3 handler
|
||||
* already uses (see `step-platform-fork.tsx`).
|
||||
*/
|
||||
export function captureDownloadIntent(source: DownloadIntentSource): void {
|
||||
captureEvent("download_intent_expressed", {
|
||||
source,
|
||||
});
|
||||
setPersonProperties({ platform_preference: "desktop" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires once on /download page mount, after OS detection resolves. The
|
||||
* first detection for a given person is mirrored into person properties
|
||||
* via `$set_once` so every downstream event gains a platform dimension
|
||||
* without re-emitting.
|
||||
*/
|
||||
export function captureDownloadPageViewed(
|
||||
payload: DownloadDetectPayload,
|
||||
): void {
|
||||
captureEvent("download_page_viewed", {
|
||||
detected_os: payload.detected_os,
|
||||
detected_arch: payload.detected_arch,
|
||||
detect_confident: payload.detect_confident,
|
||||
version_available: payload.version_available,
|
||||
});
|
||||
setPersonPropertiesOnce({
|
||||
first_detected_os: payload.detected_os,
|
||||
first_detected_arch: payload.detected_arch,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when the user clicks a concrete installer link on `/download`.
|
||||
* `primary_cta` marks the hero-level recommendation versus a manual
|
||||
* pick from the All Platforms matrix; `matched_detect` captures
|
||||
* whether the click matched what we detected (miss = detect got it
|
||||
* wrong / user overrode).
|
||||
*/
|
||||
export function captureDownloadInitiated(
|
||||
payload: DownloadInitiatedPayload,
|
||||
): void {
|
||||
captureEvent("download_initiated", { ...payload });
|
||||
}
|
||||
|
||||
/**
|
||||
* $set_once wire form. Mirrors the backend's `Event.SetOnce` path —
|
||||
* first write wins, subsequent ones are no-ops on PostHog's side.
|
||||
* Wrapping it here keeps call sites free of the no-op `$set_once`
|
||||
* envelope quirk.
|
||||
*/
|
||||
function setPersonPropertiesOnce(props: Record<string, unknown>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
posthog.capture("$set", { $set_once: props });
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Mock posthog-js before importing the module under test so the module's
|
||||
// top-level `import posthog from "posthog-js"` resolves to the mock.
|
||||
vi.mock("posthog-js", () => {
|
||||
const mock = {
|
||||
init: vi.fn(),
|
||||
register: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
};
|
||||
return { default: mock };
|
||||
});
|
||||
|
||||
// Re-import per test so module-level `initialized` / cached super-props
|
||||
// don't leak between cases.
|
||||
async function loadModule() {
|
||||
vi.resetModules();
|
||||
const analytics = await import("./index");
|
||||
const posthog = (await import("posthog-js")).default as unknown as {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
posthog.init.mockClear();
|
||||
posthog.register.mockClear();
|
||||
posthog.reset.mockClear();
|
||||
return { analytics, posthog };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", {});
|
||||
vi.stubGlobal("navigator", { userAgent: "Mozilla/5.0" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("initAnalytics super-properties", () => {
|
||||
it("registers client_type and app_version after posthog.init", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits app_version when not provided", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "web" });
|
||||
});
|
||||
|
||||
it("detects desktop when window.electron is present", async () => {
|
||||
vi.stubGlobal("window", { electron: {} });
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "desktop" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetAnalytics", () => {
|
||||
it("re-registers super-properties after reset so subsequent events keep client_type", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "", appVersion: "1.2.3" });
|
||||
posthog.register.mockClear();
|
||||
|
||||
analytics.resetAnalytics();
|
||||
|
||||
// reset() wipes persisted super-props; we re-register the cached set so
|
||||
// the next session's events keep client_type + app_version.
|
||||
expect(posthog.reset).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when analytics was never initialized", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.resetAnalytics();
|
||||
expect(posthog.reset).not.toHaveBeenCalled();
|
||||
expect(posthog.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,335 +0,0 @@
|
||||
// Frontend analytics glue. Thin wrapper over posthog-js.
|
||||
//
|
||||
// The source-of-truth event catalog is `docs/analytics.md`. This module only
|
||||
// handles the two things the backend can't do itself: attribution capture on
|
||||
// first anonymous pageview, and person-identity merge on login. Every funnel
|
||||
// event (signup, workspace_created, runtime_registered, issue_executed,
|
||||
// invite_sent, invite_accepted) is emitted server-side — see
|
||||
// `server/internal/analytics`.
|
||||
//
|
||||
// Configuration comes from the backend's `/api/config` response (populated
|
||||
// from POSTHOG_API_KEY on the server), NOT from NEXT_PUBLIC_* envs. That
|
||||
// keeps self-hosted Docker images from leaking our project key — their
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
|
||||
// Per-value cap keeps a long utm_content from blowing the budget. We drop
|
||||
// the entire cookie if the JSON still exceeds the overall limit — partial
|
||||
// JSON is worse than no attribution because PostHog can't parse it.
|
||||
const SIGNUP_SOURCE_VALUE_MAX_LEN = 96;
|
||||
const SIGNUP_SOURCE_MAX_LEN = 512;
|
||||
const UTM_KEYS = [
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_content",
|
||||
"utm_term",
|
||||
] as const;
|
||||
|
||||
let initialized = false;
|
||||
// auth-initializer fetches /api/config and /api/me in parallel — on a
|
||||
// slow-config path, identify() can fire before initAnalytics(). Buffer the
|
||||
// most recent pending identify (only one matters, since it's per-session)
|
||||
// and flush it inside initAnalytics.
|
||||
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
|
||||
// Likewise pageviews: the initial "/" pageview is the anchor of the
|
||||
// acquisition funnel, and the Next.js router fires it on mount before the
|
||||
// config fetch resolves. We keep the first pending pageview so that step
|
||||
// doesn't silently drop.
|
||||
let pendingPageview: string | undefined | null = null;
|
||||
// Frontend-emitted events (captureEvent) and person-property updates
|
||||
// (setPersonProperties) can also arrive before init — same config-race as
|
||||
// identify/pageview. We replay them in order once init succeeds. These
|
||||
// only ever carry user-triggered signals on identified users, so the
|
||||
// buffer stays small (~one step-transition worth).
|
||||
type PendingOp =
|
||||
| { kind: "event"; name: string; props?: Record<string, unknown> }
|
||||
| { kind: "set"; props: Record<string, unknown> };
|
||||
const pendingOps: PendingOp[] = [];
|
||||
// Cached super-properties so resetAnalytics() can re-register them after
|
||||
// posthog.reset() wipes the persisted set. Without this, logout / account
|
||||
// switch silently drops client_type + app_version from every subsequent
|
||||
// event until a full reload.
|
||||
let superProperties: Record<string, unknown> = {};
|
||||
|
||||
export {
|
||||
captureDownloadIntent,
|
||||
captureDownloadPageViewed,
|
||||
captureDownloadInitiated,
|
||||
type DownloadIntentSource,
|
||||
type DownloadDetectPayload,
|
||||
type DownloadInitiatedPayload,
|
||||
} from "./download";
|
||||
|
||||
export interface AnalyticsConfig {
|
||||
key: string;
|
||||
host: string;
|
||||
/**
|
||||
* Client app version — attached to every event as an `app_version`
|
||||
* super-property. Web injects the build-time tag / sha; desktop reads from
|
||||
* the Electron API. Optional because local dev may not have a version
|
||||
* available.
|
||||
*/
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export type ClientType = "desktop" | "web";
|
||||
|
||||
/**
|
||||
* Classify the current runtime as desktop (Electron renderer) or web. Used as
|
||||
* a super-property so every event can be split by client without relying on
|
||||
* PostHog's `$lib`, which reports "web" in both the Next.js app and the
|
||||
* Electron renderer (both Chromium).
|
||||
*
|
||||
* Signals we trust:
|
||||
* - `window.electron` is exposed by the preload script in every renderer.
|
||||
* - `navigator.userAgent` contains "Electron" as a fallback.
|
||||
*/
|
||||
export function detectClientType(): ClientType {
|
||||
if (typeof window === "undefined") return "web";
|
||||
const w = window as unknown as { electron?: unknown; desktopAPI?: unknown };
|
||||
if (w.electron || w.desktopAPI) return "desktop";
|
||||
if (typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent)) {
|
||||
return "desktop";
|
||||
}
|
||||
return "web";
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize posthog-js if a key is present. Safe to call multiple times;
|
||||
* subsequent calls with the same config are no-ops.
|
||||
*
|
||||
* Returns `true` when analytics is actually running; `false` when disabled
|
||||
* (no key, SSR, or already initialized with a conflicting key — which we
|
||||
* treat as "use the existing instance").
|
||||
*/
|
||||
export function initAnalytics(config: AnalyticsConfig | null | undefined): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (!config?.key) return false;
|
||||
if (initialized) return true;
|
||||
|
||||
posthog.init(config.key, {
|
||||
api_host: config.host || "https://us.i.posthog.com",
|
||||
// person_profiles=identified_only keeps anonymous drive-by traffic off
|
||||
// the billed events until they actually identify, which aligns with how
|
||||
// our funnel is set up: signup is the first real funnel step.
|
||||
person_profiles: "identified_only",
|
||||
// Turn off every on-by-default auto-capture surface. Our funnel is
|
||||
// narrow and explicit (the events in docs/analytics.md + a manual
|
||||
// $pageview). Autocapture floods the Activity view with anonymous
|
||||
// "clicked button" / "clicked link" noise, burns the billed event
|
||||
// budget, and risks capturing user-typed content in input values.
|
||||
// Turn things back on deliberately if we ever want them.
|
||||
capture_pageview: false,
|
||||
autocapture: false,
|
||||
capture_heatmaps: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_exceptions: false,
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
// Register super-properties — attached to every event emitted from this
|
||||
// client. `client_type` is the canonical split between desktop and web
|
||||
// (PostHog's own `$lib` reports "web" for both because Electron renderers
|
||||
// are Chromium). `app_version` is optional so self-hosted or local dev
|
||||
// builds without a version don't pollute the property.
|
||||
// We cache the set so resetAnalytics() can re-apply it after
|
||||
// posthog.reset() — reset() clears persisted super-properties otherwise.
|
||||
superProperties = { client_type: detectClientType() };
|
||||
if (config.appVersion) superProperties.app_version = config.appVersion;
|
||||
posthog.register(superProperties);
|
||||
initialized = true;
|
||||
|
||||
// Flush any identify() that arrived before init resolved.
|
||||
if (pendingIdentify) {
|
||||
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
|
||||
pendingIdentify = null;
|
||||
}
|
||||
// And any first pageview we captured while config was loading.
|
||||
if (pendingPageview !== null) {
|
||||
posthog.capture("$pageview", pendingPageview ? { $current_url: pendingPageview } : undefined);
|
||||
pendingPageview = null;
|
||||
}
|
||||
// Replay buffered events / person-property updates in their original
|
||||
// order — funnel correctness depends on sequence (e.g. a user submits
|
||||
// the questionnaire and then finishes onboarding within the same
|
||||
// config-race window).
|
||||
while (pendingOps.length > 0) {
|
||||
const op = pendingOps.shift()!;
|
||||
if (op.kind === "event") {
|
||||
posthog.capture(op.name, op.props);
|
||||
} else {
|
||||
capturePersonSet(op.props);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the current anonymous session into the logged-in person. Must be
|
||||
* called exactly once per auth transition (login / session-resume). Pulling
|
||||
* attribution properties into person_properties on identify is how we keep
|
||||
* UTM / referrer on the user profile without re-emitting them per event.
|
||||
*
|
||||
* Calls before initAnalytics() are buffered — auth-initializer fetches
|
||||
* config and user in parallel, so identify can arrive first.
|
||||
*/
|
||||
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
|
||||
if (!initialized) {
|
||||
pendingIdentify = { userId, props: userProperties };
|
||||
return;
|
||||
}
|
||||
posthog.identify(userId, userProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the client-side identity on logout so the next login merges cleanly
|
||||
* and doesn't bleed the previous user's events into a new session.
|
||||
*/
|
||||
export function resetAnalytics(): void {
|
||||
pendingIdentify = null;
|
||||
pendingPageview = null;
|
||||
pendingOps.length = 0;
|
||||
if (!initialized) return;
|
||||
posthog.reset();
|
||||
// reset() wipes persisted super-properties too, so re-register the ones
|
||||
// set at init time. Otherwise every event after logout / account-switch
|
||||
// would be missing client_type + app_version until a full reload.
|
||||
if (Object.keys(superProperties).length > 0) {
|
||||
posthog.register(superProperties);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a frontend-emitted event. Most funnel events fire server-side
|
||||
* (see `server/internal/analytics`); this wrapper is reserved for the
|
||||
* handful of signals the backend can't see — primarily the Step 3
|
||||
* platform-fork choice on web, where the user's click never round-trips
|
||||
* to a handler.
|
||||
*
|
||||
* Calls before initAnalytics() buffer in order so a late-arriving config
|
||||
* doesn't silently swallow a step transition.
|
||||
*/
|
||||
export function captureEvent(
|
||||
name: string,
|
||||
props?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!initialized) {
|
||||
pendingOps.push({ kind: "event", name, props });
|
||||
return;
|
||||
}
|
||||
posthog.capture(name, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (overwrite) person properties on the currently identified user.
|
||||
* Mirrors the backend's `Event.Set` path — keep these aligned so the
|
||||
* same cohort signals (role, use_case, platform_preference) are
|
||||
* queryable regardless of which side emitted last. Use for mutable
|
||||
* signals; use `identify(userId, { $set_once: {...} })` style for
|
||||
* attribution fields that must never be overwritten.
|
||||
*/
|
||||
export function setPersonProperties(props: Record<string, unknown>): void {
|
||||
if (!initialized) {
|
||||
pendingOps.push({ kind: "set", props });
|
||||
return;
|
||||
}
|
||||
capturePersonSet(props);
|
||||
}
|
||||
|
||||
// The public wire-level contract for `$set` is a no-op event carrying a
|
||||
// `$set` property. Wrapping it here (rather than calling
|
||||
// `posthog.setPersonProperties` directly) keeps us version-independent —
|
||||
// older posthog-js builds expose the same protocol under `posthog.people.set`,
|
||||
// and the capture form works uniformly.
|
||||
function capturePersonSet(props: Record<string, unknown>): void {
|
||||
posthog.capture("$set", { $set: props });
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a page view. Call once per client-side navigation. We disable
|
||||
* posthog's automatic pageview tracking in init() so this module owns the
|
||||
* event shape — that makes it trivial to add properties (e.g. workspace
|
||||
* slug) without fighting the SDK.
|
||||
*
|
||||
* Calls before initAnalytics() buffer the most-recent path so the first
|
||||
* pageview isn't dropped on slow /api/config fetches. Subsequent pre-init
|
||||
* pageviews overwrite the buffer; after init flushes, every navigation
|
||||
* captures synchronously as expected.
|
||||
*/
|
||||
export function capturePageview(path?: string): void {
|
||||
if (!initialized) {
|
||||
pendingPageview = path ?? "";
|
||||
return;
|
||||
}
|
||||
posthog.capture("$pageview", path ? { $current_url: path } : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* On the very first anonymous pageview in a browser session, read UTM +
|
||||
* referrer and stash them in a cookie that the backend reads during signup.
|
||||
*
|
||||
* Never use raw `document.referrer` as attribution — it can leak OAuth
|
||||
* callback URLs with `code` / `state` in the query string. We keep only the
|
||||
* referrer's origin (scheme + host), which is what a funnel actually needs.
|
||||
*
|
||||
* This cookie is what `signup_source` in the backend's signup event reads
|
||||
* from; both fields are intentionally opaque JSON so the schema can evolve
|
||||
* without a backend deploy.
|
||||
*/
|
||||
export function captureSignupSource(): void {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") return;
|
||||
if (readCookie(SIGNUP_SOURCE_COOKIE)) return;
|
||||
|
||||
const source: Record<string, string> = {};
|
||||
const cap = (v: string) =>
|
||||
v.length > SIGNUP_SOURCE_VALUE_MAX_LEN ? v.slice(0, SIGNUP_SOURCE_VALUE_MAX_LEN) : v;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const key of UTM_KEYS) {
|
||||
const v = params.get(key);
|
||||
if (v) source[key] = cap(v);
|
||||
}
|
||||
} catch {
|
||||
// URL APIs unavailable — skip silently.
|
||||
}
|
||||
|
||||
const refOrigin = safeReferrerOrigin(document.referrer);
|
||||
if (refOrigin) source.referrer_origin = cap(refOrigin);
|
||||
|
||||
if (Object.keys(source).length === 0) return;
|
||||
|
||||
const payload = JSON.stringify(source);
|
||||
// Drop rather than mid-JSON truncate — a half-string would fail to parse
|
||||
// on the backend and the attribution would be worse than missing.
|
||||
if (payload.length > SIGNUP_SOURCE_MAX_LEN) return;
|
||||
|
||||
// 30-day expiry covers the typical signup consideration window. Lax is
|
||||
// the right default — the cookie is only consumed by same-origin auth.
|
||||
const maxAge = 60 * 60 * 24 * 30;
|
||||
document.cookie = `${SIGNUP_SOURCE_COOKIE}=${encodeURIComponent(payload)}; path=/; max-age=${maxAge}; samesite=lax`;
|
||||
}
|
||||
|
||||
function safeReferrerOrigin(referrer: string): string {
|
||||
if (!referrer) return "";
|
||||
try {
|
||||
const url = new URL(referrer);
|
||||
if (url.origin === window.location.origin) return "";
|
||||
return url.origin;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
const prefix = `${name}=`;
|
||||
const parts = document.cookie ? document.cookie.split("; ") : [];
|
||||
for (const part of parts) {
|
||||
if (part.startsWith(prefix)) return decodeURIComponent(part.slice(prefix.length));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -106,42 +106,4 @@ describe("ApiClient", () => {
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits X-Client-* headers when identity is configured", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test", {
|
||||
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
|
||||
});
|
||||
await client.listWorkspaces();
|
||||
|
||||
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
||||
expect(headers["X-Client-Platform"]).toBe("desktop");
|
||||
expect(headers["X-Client-Version"]).toBe("1.2.3");
|
||||
expect(headers["X-Client-OS"]).toBe("macos");
|
||||
});
|
||||
|
||||
it("omits X-Client-* headers when identity is not configured", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify([]), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.listWorkspaces();
|
||||
|
||||
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
|
||||
expect(headers["X-Client-Platform"]).toBeUndefined();
|
||||
expect(headers["X-Client-Version"]).toBeUndefined();
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,10 +35,6 @@ import type {
|
||||
RuntimeHourlyActivity,
|
||||
RuntimePing,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
RuntimeLocalSkillImportRequest,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
@@ -68,29 +64,13 @@ import type {
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
|
||||
* X-Client-OS so the backend can log, gate, or split metrics by client.
|
||||
* See server/internal/middleware/client.go for the receiving end. */
|
||||
export interface ApiClientIdentity {
|
||||
/** Logical client kind. Server expects: "web" | "desktop" | "cli" | "daemon". */
|
||||
platform?: string;
|
||||
/** Client/app version string (e.g. "0.1.0", git tag, commit). */
|
||||
version?: string;
|
||||
/** Operating system the client is running on: "macos" | "windows" | "linux". */
|
||||
os?: string;
|
||||
}
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
onUnauthorized?: () => void;
|
||||
/** Identifies the client to the server. Sent as X-Client-* headers. */
|
||||
identity?: ApiClientIdentity;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@@ -98,52 +78,6 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
// The client sends both branches of sub-issues and an unbound welcome
|
||||
// issue template (title + description, no `agent_id`). The SERVER picks
|
||||
// the branch by inspecting the workspace's agent list inside the
|
||||
// import transaction. This removes the client as a trusted decider —
|
||||
// even if the client has a stale agent cache or lies, the server uses
|
||||
// the DB as source of truth.
|
||||
|
||||
export interface ImportStarterIssuePayload {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
/** Server uses `user_id` (per app-wide AssigneePicker convention)
|
||||
* as assignee when true. No member_id is threaded through. */
|
||||
assign_to_self: boolean;
|
||||
}
|
||||
|
||||
export interface ImportStarterWelcomeIssueTemplate {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Defaults to "high" on server when empty. */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
export interface ImportStarterContentPayload {
|
||||
workspace_id: string;
|
||||
project: { title: string; description: string; icon: string };
|
||||
/** Always sent. Server creates it only when an agent exists in the
|
||||
* workspace; ignored otherwise. Agent id is picked by the server. */
|
||||
welcome_issue_template: ImportStarterWelcomeIssueTemplate;
|
||||
/** Used when the workspace has at least one agent. */
|
||||
agent_guided_sub_issues: ImportStarterIssuePayload[];
|
||||
/** Used when the workspace has zero agents. */
|
||||
self_serve_sub_issues: ImportStarterIssuePayload[];
|
||||
}
|
||||
|
||||
export interface ImportStarterContentResponse {
|
||||
user: User;
|
||||
project_id: string;
|
||||
/** Non-null when server took the agent-guided branch. */
|
||||
welcome_issue_id: string | null;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
@@ -191,10 +125,6 @@ export class ApiClient {
|
||||
if (slug) headers["X-Workspace-Slug"] = slug;
|
||||
const csrf = this.readCsrfToken();
|
||||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||||
const id = this.options.identity;
|
||||
if (id?.platform) headers["X-Client-Platform"] = id.platform;
|
||||
if (id?.version) headers["X-Client-Version"] = id.version;
|
||||
if (id?.os) headers["X-Client-OS"] = id.os;
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -289,62 +219,6 @@ export class ApiClient {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/cloud-waitlist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async patchOnboarding(payload: {
|
||||
questionnaire?: Record<string, unknown>;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the Getting Started project + optional welcome issue + sub-issues
|
||||
* in a single server-side transaction. Gated by an atomic
|
||||
* starter_content_state: NULL → 'imported' claim — a second call returns
|
||||
* 409 (already decided) and creates nothing new.
|
||||
*
|
||||
* The content templates live in TypeScript (see
|
||||
* @multica/views/onboarding/utils/starter-content-templates) and are
|
||||
* rendered from the user's questionnaire answers before being sent.
|
||||
*/
|
||||
async importStarterContent(
|
||||
payload: ImportStarterContentPayload,
|
||||
): Promise<ImportStarterContentResponse> {
|
||||
return this.fetch("/api/me/starter-content/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async dismissStarterContent(payload?: {
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/starter-content/dismiss", {
|
||||
method: "POST",
|
||||
body: payload ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async updateMe(data: UpdateMeRequest): Promise<User> {
|
||||
return this.fetch("/api/me", {
|
||||
method: "PATCH",
|
||||
@@ -363,7 +237,6 @@ export class ApiClient {
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
@@ -597,49 +470,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/update/${updateId}`);
|
||||
}
|
||||
|
||||
async initiateListModels(runtimeId: string): Promise<RuntimeModelListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/models`, { method: "POST" });
|
||||
}
|
||||
|
||||
async getListModelsResult(
|
||||
runtimeId: string,
|
||||
requestId: string,
|
||||
): Promise<RuntimeModelListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/models/${requestId}`);
|
||||
}
|
||||
|
||||
async initiateListLocalSkills(
|
||||
runtimeId: string,
|
||||
): Promise<RuntimeLocalSkillListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/local-skills`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async getListLocalSkillsResult(
|
||||
runtimeId: string,
|
||||
requestId: string,
|
||||
): Promise<RuntimeLocalSkillListRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/${requestId}`);
|
||||
}
|
||||
|
||||
async initiateImportLocalSkill(
|
||||
runtimeId: string,
|
||||
data: CreateRuntimeLocalSkillImportRequest,
|
||||
): Promise<RuntimeLocalSkillImportRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getImportLocalSkillResult(
|
||||
runtimeId: string,
|
||||
requestId: string,
|
||||
): Promise<RuntimeLocalSkillImportRequest> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/local-skills/import/${requestId}`);
|
||||
}
|
||||
|
||||
async listAgentTasks(agentId: string): Promise<AgentTask[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/tasks`);
|
||||
}
|
||||
@@ -700,13 +530,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// App Config
|
||||
async getConfig(): Promise<{
|
||||
cdn_domain: string;
|
||||
allow_signup: boolean;
|
||||
google_client_id?: string;
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
}> {
|
||||
async getConfig(): Promise<{ cdn_domain: string }> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
ImportStarterContentResponse,
|
||||
ImportStarterIssuePayload,
|
||||
ImportStarterWelcomeIssueTemplate,
|
||||
} from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WSClient } from "./ws-client";
|
||||
|
||||
// Capture URL passed to WebSocket so we can assert the connect-time
|
||||
// query string. We don't simulate the full WS lifecycle here — only the
|
||||
// upgrade URL construction, which is what carries client identity.
|
||||
class FakeWebSocket {
|
||||
static lastUrl: string | null = null;
|
||||
// Fields read by WSClient.connect()/disconnect(), all no-op here.
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
readyState = 0;
|
||||
constructor(url: string) {
|
||||
FakeWebSocket.lastUrl = url;
|
||||
}
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
|
||||
describe("WSClient", () => {
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.lastUrl = null;
|
||||
vi.stubGlobal("WebSocket", FakeWebSocket as unknown as typeof WebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("includes client identity in the upgrade URL when configured", () => {
|
||||
const ws = new WSClient("ws://example.test/ws", {
|
||||
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
|
||||
});
|
||||
ws.setAuth("tok", "acme");
|
||||
ws.connect();
|
||||
|
||||
const url = new URL(FakeWebSocket.lastUrl!);
|
||||
expect(url.searchParams.get("workspace_slug")).toBe("acme");
|
||||
expect(url.searchParams.get("client_platform")).toBe("desktop");
|
||||
expect(url.searchParams.get("client_version")).toBe("1.2.3");
|
||||
expect(url.searchParams.get("client_os")).toBe("macos");
|
||||
// Token must never appear in the URL — it is delivered as the first
|
||||
// WS message in token mode.
|
||||
expect(url.searchParams.has("token")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits client_* params when identity is not configured", () => {
|
||||
const ws = new WSClient("ws://example.test/ws");
|
||||
ws.setAuth("tok", "acme");
|
||||
ws.connect();
|
||||
|
||||
const url = new URL(FakeWebSocket.lastUrl!);
|
||||
expect(url.searchParams.has("client_platform")).toBe(false);
|
||||
expect(url.searchParams.has("client_version")).toBe(false);
|
||||
expect(url.searchParams.has("client_os")).toBe(false);
|
||||
});
|
||||
|
||||
it("only includes the identity fields that are set", () => {
|
||||
const ws = new WSClient("ws://example.test/ws", {
|
||||
identity: { platform: "cli" },
|
||||
});
|
||||
ws.setAuth("tok", "acme");
|
||||
ws.connect();
|
||||
|
||||
const url = new URL(FakeWebSocket.lastUrl!);
|
||||
expect(url.searchParams.get("client_platform")).toBe("cli");
|
||||
expect(url.searchParams.has("client_version")).toBe(false);
|
||||
expect(url.searchParams.has("client_os")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,12 @@ import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
|
||||
/** Identifies the WS client to the server. Sent as `client_platform`,
|
||||
* `client_version`, and `client_os` query parameters on the upgrade URL —
|
||||
* browsers cannot set custom headers on WebSocket handshakes, so query
|
||||
* params are the only portable channel. */
|
||||
export interface WSClientIdentity {
|
||||
platform?: string;
|
||||
version?: string;
|
||||
os?: string;
|
||||
}
|
||||
|
||||
export class WSClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private cookieAuth = false;
|
||||
private identity: WSClientIdentity | undefined;
|
||||
private handlers = new Map<WSEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasConnectedBefore = false;
|
||||
@@ -27,18 +16,10 @@ export class WSClient {
|
||||
private anyHandlers = new Set<(msg: WSMessage) => void>();
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
url: string,
|
||||
options?: {
|
||||
logger?: Logger;
|
||||
cookieAuth?: boolean;
|
||||
identity?: WSClientIdentity;
|
||||
},
|
||||
) {
|
||||
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
|
||||
this.baseUrl = url;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
this.cookieAuth = options?.cookieAuth ?? false;
|
||||
this.identity = options?.identity;
|
||||
}
|
||||
|
||||
setAuth(token: string | null, workspaceSlug: string) {
|
||||
@@ -54,12 +35,6 @@ export class WSClient {
|
||||
// is delivered as the first WebSocket message after the connection opens.
|
||||
if (this.workspaceSlug)
|
||||
url.searchParams.set("workspace_slug", this.workspaceSlug);
|
||||
if (this.identity?.platform)
|
||||
url.searchParams.set("client_platform", this.identity.platform);
|
||||
if (this.identity?.version)
|
||||
url.searchParams.set("client_version", this.identity.version);
|
||||
if (this.identity?.os)
|
||||
url.searchParams.set("client_os", this.identity.os);
|
||||
|
||||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { User, StorageAdapter } from "../types";
|
||||
import { identify as identifyAnalytics, resetAnalytics } from "../analytics";
|
||||
import { ApiError, type ApiClient } from "../api/client";
|
||||
import { setCurrentWorkspace } from "../platform/workspace-storage";
|
||||
|
||||
@@ -24,7 +23,6 @@ export interface AuthState {
|
||||
loginWithToken: (token: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
refreshMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createAuthStore(options: AuthStoreOptions) {
|
||||
@@ -86,7 +84,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
@@ -98,7 +95,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
@@ -108,7 +104,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
api.setToken(token);
|
||||
const user = await api.getMe();
|
||||
onLogin?.();
|
||||
identifyAnalytics(user.id, { email: user.email, name: user.name });
|
||||
set({ user, isLoading: false });
|
||||
return user;
|
||||
},
|
||||
@@ -121,7 +116,6 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
storage.removeItem("multica_token");
|
||||
api.setToken(null);
|
||||
setCurrentWorkspace(null, null);
|
||||
resetAnalytics();
|
||||
onLogout?.();
|
||||
set({ user: null });
|
||||
},
|
||||
@@ -129,10 +123,5 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
|
||||
refreshMe: async () => {
|
||||
const user = await api.getMe();
|
||||
set({ user });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem, ContextAnchor } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
/** Focus mode is a personal preference — global across workspaces/sessions. */
|
||||
const FOCUS_MODE_KEY = "multica:chat:focusMode";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
@@ -60,21 +58,6 @@ export interface ChatTimelineItem {
|
||||
output?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A derived "where I am" pointer — not stored, recomputed each render from
|
||||
* the current route + react-query cache. The type is exported because
|
||||
* consumers (buildAnchorMarkdown, chip props) share the same shape.
|
||||
*/
|
||||
export interface ContextAnchor {
|
||||
type: "issue" | "project";
|
||||
/** UUID for `issue`, UUID for `project`. */
|
||||
id: string;
|
||||
/** Human-readable label: issue identifier (MUL-1) or project title. */
|
||||
label: string;
|
||||
/** Optional secondary text — issue title for issue anchors. */
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
@@ -82,12 +65,6 @@ export interface ChatState {
|
||||
showHistory: boolean;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/**
|
||||
* When on, the chat tracks whatever issue/project/inbox-item the user is
|
||||
* looking at and prepends it to outgoing messages. Persisted globally so
|
||||
* the preference survives workspace switches and reloads.
|
||||
*/
|
||||
focusMode: boolean;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
@@ -100,7 +77,6 @@ export interface ChatState {
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
setFocusMode: (on: boolean) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -124,7 +100,6 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
@@ -162,12 +137,6 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setFocusMode: (on) => {
|
||||
logger.info("setFocusMode", { to: on });
|
||||
if (on) storage.setItem(FOCUS_MODE_KEY, "true");
|
||||
else storage.removeItem(FOCUS_MODE_KEY);
|
||||
set({ focusMode: on });
|
||||
},
|
||||
clearInputDraft: (sessionId) => {
|
||||
const current = get().inputDrafts;
|
||||
if (!(sessionId in current)) {
|
||||
|
||||
@@ -3,19 +3,12 @@ import { useStore } from "zustand";
|
||||
|
||||
interface ConfigState {
|
||||
cdnDomain: string;
|
||||
allowSignup: boolean;
|
||||
googleClientId: string;
|
||||
setCdnDomain: (domain: string) => void;
|
||||
setAuthConfig: (config: { allowSignup: boolean; googleClientId?: string }) => void;
|
||||
}
|
||||
|
||||
export const configStore = createStore<ConfigState>((set) => ({
|
||||
cdnDomain: "",
|
||||
allowSignup: true,
|
||||
googleClientId: "",
|
||||
setCdnDomain: (domain) => set({ cdnDomain: domain }),
|
||||
setAuthConfig: ({ allowSignup, googleClientId = "" }) =>
|
||||
set({ allowSignup, googleClientId }),
|
||||
}));
|
||||
|
||||
export function useConfigStore(): ConfigState;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { onInboxIssueDeleted, onInboxIssueStatusChanged } from "./ws-updaters";
|
||||
import { inboxKeys } from "./queries";
|
||||
import type { InboxItem } from "../types";
|
||||
|
||||
const wsId = "ws-1";
|
||||
|
||||
function makeItem(
|
||||
id: string,
|
||||
issueId: string | null,
|
||||
overrides: Partial<InboxItem> = {},
|
||||
): InboxItem {
|
||||
return {
|
||||
id,
|
||||
workspace_id: wsId,
|
||||
recipient_type: "member",
|
||||
recipient_id: "user-1",
|
||||
actor_type: null,
|
||||
actor_id: null,
|
||||
type: "mentioned",
|
||||
severity: "info",
|
||||
issue_id: issueId,
|
||||
title: `item ${id}`,
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("onInboxIssueDeleted", () => {
|
||||
it("removes all inbox items referencing the deleted issue", () => {
|
||||
const qc = new QueryClient();
|
||||
const items = [
|
||||
makeItem("i1", "issue-a"),
|
||||
makeItem("i2", "issue-a"),
|
||||
makeItem("i3", "issue-b"),
|
||||
makeItem("i4", null),
|
||||
];
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
|
||||
|
||||
onInboxIssueDeleted(qc, wsId, "issue-a");
|
||||
|
||||
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
expect(after?.map((i) => i.id)).toEqual(["i3", "i4"]);
|
||||
});
|
||||
|
||||
it("is a no-op when the inbox cache is empty", () => {
|
||||
const qc = new QueryClient();
|
||||
expect(() => onInboxIssueDeleted(qc, wsId, "issue-a")).not.toThrow();
|
||||
expect(qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onInboxIssueStatusChanged", () => {
|
||||
it("updates issue_status only for items referencing the issue", () => {
|
||||
const qc = new QueryClient();
|
||||
const items = [
|
||||
makeItem("i1", "issue-a", { issue_status: "todo" }),
|
||||
makeItem("i2", "issue-b", { issue_status: "todo" }),
|
||||
];
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), items);
|
||||
|
||||
onInboxIssueStatusChanged(qc, wsId, "issue-a", "done");
|
||||
|
||||
const after = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
expect(after?.find((i) => i.id === "i1")?.issue_status).toBe("done");
|
||||
expect(after?.find((i) => i.id === "i2")?.issue_status).toBe("todo");
|
||||
});
|
||||
});
|
||||
@@ -25,19 +25,6 @@ export function onInboxIssueStatusChanged(
|
||||
);
|
||||
}
|
||||
|
||||
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
|
||||
// is deleted, all inbox items that referenced it are gone server-side, so drop
|
||||
// them from the cache too.
|
||||
export function onInboxIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.filter((i) => i.issue_id !== issueId),
|
||||
);
|
||||
}
|
||||
|
||||
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import type {
|
||||
Issue,
|
||||
IssueStatus,
|
||||
IssueStatusBucket,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
import { PAGINATED_STATUSES } from "./queries";
|
||||
|
||||
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
|
||||
|
||||
export function getBucket(
|
||||
resp: ListIssuesCache,
|
||||
status: IssueStatus,
|
||||
): IssueStatusBucket {
|
||||
return resp.byStatus[status] ?? EMPTY_BUCKET;
|
||||
}
|
||||
|
||||
export function setBucket(
|
||||
resp: ListIssuesCache,
|
||||
status: IssueStatus,
|
||||
bucket: IssueStatusBucket,
|
||||
): ListIssuesCache {
|
||||
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
|
||||
}
|
||||
|
||||
/** Locate which status bucket holds `id`, if any. */
|
||||
export function findIssueLocation(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
): { status: IssueStatus; issue: Issue } | null {
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = resp.byStatus[status];
|
||||
const found = bucket?.issues.find((i) => i.id === id);
|
||||
if (found) return { status, issue: found };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Add an issue to its status bucket (no-op if already present). */
|
||||
export function addIssueToBuckets(
|
||||
resp: ListIssuesCache,
|
||||
issue: Issue,
|
||||
): ListIssuesCache {
|
||||
const bucket = getBucket(resp, issue.status);
|
||||
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
|
||||
return setBucket(resp, issue.status, {
|
||||
issues: [...bucket.issues, issue],
|
||||
total: bucket.total + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an issue from whichever bucket contains it. */
|
||||
export function removeIssueFromBuckets(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
): ListIssuesCache {
|
||||
const loc = findIssueLocation(resp, id);
|
||||
if (!loc) return resp;
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
return setBucket(resp, loc.status, {
|
||||
issues: bucket.issues.filter((i) => i.id !== id),
|
||||
total: Math.max(0, bucket.total - 1),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
|
||||
* current bucket, the issue moves to the new bucket and both buckets' totals
|
||||
* are adjusted.
|
||||
*/
|
||||
export function patchIssueInBuckets(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
patch: Partial<Issue>,
|
||||
): ListIssuesCache {
|
||||
const loc = findIssueLocation(resp, id);
|
||||
if (!loc) return resp;
|
||||
const merged: Issue = { ...loc.issue, ...patch };
|
||||
const nextStatus = patch.status ?? loc.status;
|
||||
|
||||
if (nextStatus === loc.status) {
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
return setBucket(resp, loc.status, {
|
||||
...bucket,
|
||||
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
||||
});
|
||||
}
|
||||
|
||||
const fromBucket = getBucket(resp, loc.status);
|
||||
const toBucket = getBucket(resp, nextStatus);
|
||||
let next = setBucket(resp, loc.status, {
|
||||
issues: fromBucket.issues.filter((i) => i.id !== id),
|
||||
total: Math.max(0, fromBucket.total - 1),
|
||||
});
|
||||
next = setBucket(next, nextStatus, {
|
||||
issues: [...toBucket.issues, merged],
|
||||
total: toBucket.total + 1,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -1,26 +1,14 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
getBucket,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
setBucket,
|
||||
} from "./cache-helpers";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
import type { Issue, IssueReaction } from "../types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesCache,
|
||||
ListIssuesResponse,
|
||||
} from "../types";
|
||||
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
|
||||
|
||||
@@ -41,18 +29,10 @@ export type ToggleIssueReactionVars = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-status pagination
|
||||
// Done issue pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Paginate one status column into the cache. Works for both the workspace
|
||||
* issue list and per-scope My Issues lists (pass `myIssues` to target the
|
||||
* latter).
|
||||
*/
|
||||
export function useLoadMoreByStatus(
|
||||
status: IssueStatus,
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
) {
|
||||
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -60,38 +40,39 @@ export function useLoadMoreByStatus(
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
|
||||
const bucket = cache?.byStatus[status];
|
||||
const loaded = bucket?.issues.length ?? 0;
|
||||
const total = bucket?.total ?? 0;
|
||||
const hasMore = loaded < total;
|
||||
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
|
||||
const doneLoaded = cache
|
||||
? cache.issues.filter((i) => i.status === "done").length
|
||||
: 0;
|
||||
const doneTotal = cache?.doneTotal ?? 0;
|
||||
const hasMore = doneLoaded < doneTotal;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listIssues({
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: doneLoaded,
|
||||
...myIssues?.filter,
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
|
||||
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
const prev = getBucket(old, status);
|
||||
const existingIds = new Set(prev.issues.map((i) => i.id));
|
||||
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return setBucket(old, status, {
|
||||
issues: [...prev.issues, ...appended],
|
||||
total: res.total,
|
||||
});
|
||||
const existingIds = new Set(old.issues.map((i) => i.id));
|
||||
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, ...newIssues],
|
||||
doneTotal: res.total,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
|
||||
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
return { loadMore, hasMore, isLoading, doneTotal };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -104,8 +85,15 @@ export function useCreateIssue() {
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
||||
onSuccess: (newIssue) => {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? addIssueToBuckets(old, newIssue) : old,
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old && !old.issues.some((i) => i.id === newIssue.id)
|
||||
? {
|
||||
...old,
|
||||
issues: [...old.issues, newIssue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
// Surface the just-created issue in cmd+k's Recent list without
|
||||
// requiring the user to open it first.
|
||||
@@ -134,7 +122,7 @@ export function useUpdateIssue() {
|
||||
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||
// before the optimistic update lands.
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
@@ -142,14 +130,21 @@ export function useUpdateIssue() {
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
prevDetail?.parent_issue_id ??
|
||||
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
|
||||
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
|
||||
null;
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, id, data) : old,
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === id ? { ...i, ...data } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, ...data } : old,
|
||||
@@ -203,11 +198,18 @@ export function useDeleteIssue() {
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, id) : old,
|
||||
);
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = prevList?.issues.find((i) => i.id === id);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const d = old.issues.find((i) => i.id === id);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== id),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList, parentIssueId: deleted?.parent_issue_id };
|
||||
},
|
||||
@@ -237,13 +239,17 @@ export function useBatchUpdateIssues() {
|
||||
}) => api.batchUpdateIssues(ids, updates),
|
||||
onMutate: async ({ ids, updates }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
return next;
|
||||
});
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
ids.includes(i.id) ? { ...i, ...updates } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
@@ -262,19 +268,24 @@ export function useBatchDeleteIssues() {
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const parentIssueIds = new Set<string>();
|
||||
if (prevList) {
|
||||
for (const id of ids) {
|
||||
const loc = findIssueLocation(prevList, id);
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const idSet = new Set(ids);
|
||||
const parentIssueIds = new Set(
|
||||
prevList?.issues
|
||||
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
|
||||
.map((i) => i.parent_issue_id!) ?? [],
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
const doneDeleted = old.issues.filter(
|
||||
(i) => idSet.has(i.id) && i.status === "done",
|
||||
).length;
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => !idSet.has(i.id)),
|
||||
total: old.total - ids.length,
|
||||
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
|
||||
};
|
||||
});
|
||||
return { prevList, parentIssueIds };
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
|
||||
import { BOARD_STATUSES } from "./config";
|
||||
import type { ListIssuesParams } from "../types";
|
||||
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
@@ -24,55 +23,33 @@ export const issueKeys = {
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
};
|
||||
|
||||
export type MyIssuesFilter = Pick<
|
||||
ListIssuesParams,
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
>;
|
||||
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
|
||||
|
||||
/** Page size per status column. */
|
||||
export const ISSUE_PAGE_SIZE = 50;
|
||||
|
||||
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
|
||||
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
|
||||
|
||||
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
|
||||
export function flattenIssueBuckets(data: ListIssuesCache) {
|
||||
const out = [];
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = data.byStatus[status];
|
||||
if (bucket) out.push(...bucket.issues);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
||||
const responses = await Promise.all(
|
||||
PAGINATED_STATUSES.map((status) =>
|
||||
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
|
||||
),
|
||||
);
|
||||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||||
PAGINATED_STATUSES.forEach((status, i) => {
|
||||
const res = responses[i]!;
|
||||
byStatus[status] = { issues: res.issues, total: res.total };
|
||||
});
|
||||
return { byStatus };
|
||||
}
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
|
||||
* by status, each with `{ issues, total }`), and `select` flattens it to
|
||||
* `Issue[]` for consumers. Mutations and ws-updaters must use
|
||||
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
|
||||
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
|
||||
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
|
||||
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
|
||||
*
|
||||
* Fetches the first page of each paginated status in parallel. Use
|
||||
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
|
||||
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
|
||||
* to paginate additional done items into the cache.
|
||||
*/
|
||||
export function issueListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.list(wsId),
|
||||
queryFn: () => fetchFirstPages(),
|
||||
select: flattenIssueBuckets,
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true }),
|
||||
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,8 +64,23 @@ export function myIssueListOptions(
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.myList(wsId, scope, filter),
|
||||
queryFn: () => fetchFirstPages(filter),
|
||||
select: flattenIssueBuckets,
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true, ...filter }),
|
||||
api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import type { Issue } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
import type { ListIssuesResponse } from "../types";
|
||||
|
||||
export function onIssueCreated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Issue,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? addIssueToBuckets(old, issue) : old,
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, issue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
@@ -32,20 +32,36 @@ export function onIssueUpdated(
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
// shown on the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const oldParentId =
|
||||
detailData?.parent_issue_id ??
|
||||
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
null;
|
||||
// The NEW parent comes from the WS payload when parent_issue_id changed
|
||||
const newParentId = issue.parent_issue_id ?? null;
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, issue.id, issue) : old,
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const prev = old.issues.find((i) => i.id === issue.id);
|
||||
const wasDone = prev?.status === "done";
|
||||
const isDone = issue.status === "done";
|
||||
// Only adjust doneTotal when status field is present and actually changed
|
||||
let doneDelta = 0;
|
||||
if (issue.status !== undefined) {
|
||||
if (!wasDone && isDone) doneDelta = 1;
|
||||
else if (wasDone && !isDone) doneDelta = -1;
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === issue.id ? { ...i, ...issue } : i,
|
||||
),
|
||||
doneTotal: (old.doneTotal ?? 0) + doneDelta,
|
||||
};
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
@@ -78,12 +94,19 @@ export function onIssueDeleted(
|
||||
issueId: string,
|
||||
) {
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = listData?.issues.find((i) => i.id === issueId);
|
||||
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const del = old.issues.find((i) => i.id === issueId);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== issueId),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export type {
|
||||
OnboardingStep,
|
||||
OnboardingCompletionPath,
|
||||
QuestionnaireAnswers,
|
||||
TeamSize,
|
||||
Role,
|
||||
UseCase,
|
||||
} from "./types";
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
|
||||
@@ -1,117 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { recommendTemplate } from "./recommend-template";
|
||||
import type { Role, UseCase } from "./types";
|
||||
|
||||
const ALL_USE_CASES: UseCase[] = [
|
||||
"coding",
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate", () => {
|
||||
describe("identity fallbacks — role alone decides", () => {
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=other (use_case=%s) → assistant",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "other", use_case })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=founder (use_case=%s) → assistant",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "founder", use_case })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=writer (use_case=%s) → writing",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe(
|
||||
"writing",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("developer × use_case tiebreaker", () => {
|
||||
it("developer × planning → planning", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: "planning" }),
|
||||
).toBe("planning");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"coding",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("developer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "developer", use_case })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
|
||||
it("developer × null use_case → coding (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: null }),
|
||||
).toBe("coding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("product_lead × use_case tiebreaker", () => {
|
||||
it("product_lead × coding → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: "coding" }),
|
||||
).toBe("coding");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("product_lead × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
|
||||
it("product_lead × null use_case → planning (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: null }),
|
||||
).toBe("planning");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unanswered questionnaire", () => {
|
||||
it("null role → assistant regardless of use_case", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exhaustive role coverage", () => {
|
||||
const roles: Role[] = [
|
||||
"developer",
|
||||
"product_lead",
|
||||
"writer",
|
||||
"founder",
|
||||
"other",
|
||||
];
|
||||
it.each(roles)("role=%s returns a valid template id", (role) => {
|
||||
const result = recommendTemplate({ role, use_case: null });
|
||||
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { QuestionnaireAnswers } from "./types";
|
||||
|
||||
/**
|
||||
* Identifier for the four agent templates offered during onboarding Step 4.
|
||||
* Keep in sync with the template registry inside StepAgent in
|
||||
* `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
*/
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
/**
|
||||
* Pick a recommended agent template for a user based on their
|
||||
* questionnaire answers. Role is treated as the primary signal (who the
|
||||
* user is); use_case is only a tiebreaker for roles that legitimately
|
||||
* split between templates (developer / product_lead).
|
||||
*
|
||||
* `role = other` and `role = founder` both fall back to the generic
|
||||
* Assistant: "other" means the user declined to claim a role, and
|
||||
* "founder" means they wear every hat, so a single specialized agent is
|
||||
* a poor default.
|
||||
*
|
||||
* Pure / deterministic — safe to call on every render.
|
||||
*/
|
||||
export function recommendTemplate(
|
||||
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
|
||||
): AgentTemplateId {
|
||||
const { role, use_case } = answers;
|
||||
|
||||
if (role === "other" || role === "founder") return "assistant";
|
||||
if (role === "writer") return "writing";
|
||||
|
||||
if (role === "developer") {
|
||||
return use_case === "planning" ? "planning" : "coding";
|
||||
}
|
||||
|
||||
if (role === "product_lead") {
|
||||
return use_case === "coding" ? "coding" : "planning";
|
||||
}
|
||||
|
||||
// Unknown / null role — user hasn't answered Q2 yet.
|
||||
return "assistant";
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { OnboardingStep } from "./types";
|
||||
|
||||
/**
|
||||
* Canonical order of the persisted onboarding steps.
|
||||
*
|
||||
* Single source of truth for "what step comes after what" — consumed
|
||||
* by the UI progress indicator to compute `index of current_step` and
|
||||
* `total step count`. Inserting, reordering, or removing a step only
|
||||
* requires changing this array; every call site that reads it updates
|
||||
* automatically.
|
||||
*
|
||||
* Intentionally excludes "welcome": welcome is a first-entry product
|
||||
* intro, not a persisted step. It doesn't show a progress indicator
|
||||
* for the same reason — users shouldn't think of reading the intro
|
||||
* as progress toward completing setup.
|
||||
*/
|
||||
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"questionnaire",
|
||||
"workspace",
|
||||
"runtime",
|
||||
"agent",
|
||||
"first_issue",
|
||||
] as const;
|
||||
@@ -1,66 +0,0 @@
|
||||
import { api } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { setPersonProperties } from "../analytics";
|
||||
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
||||
|
||||
/**
|
||||
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
|
||||
* store. Source of truth is `user.onboarding_questionnaire` (JSONB on
|
||||
* the server). No client-side cache here.
|
||||
*
|
||||
* Resume-by-step is intentionally not persisted: every onboarding
|
||||
* entry starts at Welcome. The questionnaire is the only piece of
|
||||
* progress that survives a re-entry — it pre-fills Step 1 so the
|
||||
* user doesn't re-answer.
|
||||
*/
|
||||
export async function saveQuestionnaire(
|
||||
answers: Partial<QuestionnaireAnswers>,
|
||||
): Promise<void> {
|
||||
const user = await api.patchOnboarding({ questionnaire: answers });
|
||||
useAuthStore.getState().setUser(user);
|
||||
// Mirror the three cohort signals into person properties so every
|
||||
// PostHog event on this user can be broken down by role / use_case /
|
||||
// team_size without re-joining the DB. Matches the $set block the
|
||||
// server writes alongside `onboarding_questionnaire_submitted`.
|
||||
if (answers.team_size || answers.role || answers.use_case) {
|
||||
setPersonProperties({
|
||||
...(answers.team_size ? { team_size: answers.team_size } : {}),
|
||||
...(answers.role ? { role: answers.role } : {}),
|
||||
...(answers.use_case ? { use_case: answers.use_case } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize onboarding. POST /complete marks `onboarded_at` atomically
|
||||
* (COALESCE-guarded for idempotency). We then refresh the auth store
|
||||
* so every gate sees the updated user.
|
||||
*
|
||||
* `completionPath` is the client's view of which Step-3 exit the user
|
||||
* took; the server funnel-splits `onboarding_completed` on this value.
|
||||
* Legacy callers that don't pass a path get recorded as `unknown`.
|
||||
*/
|
||||
export async function completeOnboarding(
|
||||
completionPath?: OnboardingCompletionPath,
|
||||
): Promise<void> {
|
||||
await api.markOnboardingComplete(
|
||||
completionPath ? { completion_path: completionPath } : undefined,
|
||||
);
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interest in cloud runtimes. Pure side effect — does NOT
|
||||
* complete onboarding; the user still has to pick a real Step 3
|
||||
* path (CLI with a detected runtime) or Skip to move on.
|
||||
*
|
||||
* Returned user object is not synced into the auth store because no
|
||||
* user-visible field (`onboarded_at`, anything in `UserResponse`)
|
||||
* actually changes here.
|
||||
*/
|
||||
export async function joinCloudWaitlist(
|
||||
email: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await api.joinCloudWaitlist({ email, reason });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user