mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
156 Commits
agent/lamb
...
docs/rewri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4d7eca116 | ||
|
|
1faf5a214d | ||
|
|
ba3ff7040a | ||
|
|
8ac0b2cd3f | ||
|
|
4bc7e63c51 | ||
|
|
5078d51637 | ||
|
|
ff03ba25b1 | ||
|
|
e7c4edee0b | ||
|
|
0796f9cee3 | ||
|
|
f2ba087f74 | ||
|
|
059356cce7 | ||
|
|
7375bda9b5 | ||
|
|
9dcc082920 | ||
|
|
98edc6b9ff | ||
|
|
88b892f1ca | ||
|
|
2cced51d64 | ||
|
|
6717db1fad | ||
|
|
2a248b8548 | ||
|
|
f84d216794 | ||
|
|
101da19b02 | ||
|
|
dc8096fb6e | ||
|
|
2dae42f58a | ||
|
|
f6dd47c944 | ||
|
|
f98a67dd90 | ||
|
|
90ccd97469 | ||
|
|
180a534511 | ||
|
|
2d0916ee38 | ||
|
|
5335edd50d | ||
|
|
153e2b6245 | ||
|
|
205e8c1e9c | ||
|
|
cd6bb48283 | ||
|
|
fbf41bde73 | ||
|
|
936df59fa1 | ||
|
|
fa7e4cbdca | ||
|
|
747d9492cf | ||
|
|
c787546ede | ||
|
|
14a9b5293e | ||
|
|
b8b38381bb | ||
|
|
3036c6418e | ||
|
|
26a2db2540 | ||
|
|
aa9932e4e1 | ||
|
|
4a7de91ddf | ||
|
|
3b426d21ee | ||
|
|
b624cd98ad | ||
|
|
f247a4f544 | ||
|
|
0b1333fb00 | ||
|
|
387f76d328 | ||
|
|
3fd2fb2ae3 | ||
|
|
1a565a221a | ||
|
|
536f4286f1 | ||
|
|
c6d54e8ce5 | ||
|
|
20c9d985f5 | ||
|
|
6366e2f4ba | ||
|
|
642844c736 | ||
|
|
6ecf15e62c | ||
|
|
52c9bd72cb | ||
|
|
7ada72faa6 | ||
|
|
df86f559e0 | ||
|
|
d5071abb75 | ||
|
|
ba003eee83 | ||
|
|
a3a6158d96 | ||
|
|
9481350ef0 | ||
|
|
637bdc8eb3 | ||
|
|
6f63fae41a | ||
|
|
c5a00d8b8c | ||
|
|
4ac43e9e49 | ||
|
|
03e21aee80 | ||
|
|
632fdde700 | ||
|
|
cc1ccedaf3 | ||
|
|
8eb81aa396 | ||
|
|
965bf731ab | ||
|
|
0db7d2fb64 | ||
|
|
4368e1be18 | ||
|
|
bb31afbbce | ||
|
|
4a25b91590 | ||
|
|
9e47b83f02 | ||
|
|
b291db11c2 | ||
|
|
824d943848 | ||
|
|
779c72e835 | ||
|
|
e830575efc | ||
|
|
193046fabc | ||
|
|
c76c790b32 | ||
|
|
07034f4455 | ||
|
|
9fa08fb16a | ||
|
|
cf74327aa6 | ||
|
|
951f51408a | ||
|
|
be78b66e4e | ||
|
|
ec73710dd2 | ||
|
|
62a7c05589 | ||
|
|
c0be1b7ce9 | ||
|
|
4ce3e5ddf4 | ||
|
|
bd445782d5 | ||
|
|
5fa1da448f | ||
|
|
556c68292f | ||
|
|
96ee5bba52 | ||
|
|
2ab89d4690 | ||
|
|
b428f36ca6 | ||
|
|
239ce3d40f | ||
|
|
a7e9801c83 | ||
|
|
b8907dda8d | ||
|
|
6cd49e132d | ||
|
|
a6db465e46 | ||
|
|
965561a6cc | ||
|
|
163f34f918 | ||
|
|
2317533da4 | ||
|
|
d81e6a14a6 | ||
|
|
e198a67f8f | ||
|
|
0ed16fc1b1 | ||
|
|
746f33a38b | ||
|
|
aa9305f7e4 | ||
|
|
63800f05ff | ||
|
|
133a1f1c16 | ||
|
|
b1b66ab05d | ||
|
|
0fc9641bf6 | ||
|
|
4223d32b37 | ||
|
|
b2307a5ee9 | ||
|
|
c85c43ed0e | ||
|
|
eecb3a2bc8 | ||
|
|
2c1478a69c | ||
|
|
bf31fa4b39 | ||
|
|
9b45e0d4a6 | ||
|
|
7c6158f3c9 | ||
|
|
4bd8533269 | ||
|
|
488aed6abf | ||
|
|
a73336dcf8 | ||
|
|
ce610a6414 | ||
|
|
5a6a44a69e | ||
|
|
423ceaf8f4 | ||
|
|
9e15b17c92 | ||
|
|
e9131dfe2b | ||
|
|
462ff88df5 | ||
|
|
ea02a394dc | ||
|
|
fe01d58064 | ||
|
|
fc1938fe7d | ||
|
|
1ea6e6a078 | ||
|
|
c15212c0e4 | ||
|
|
b5de04da59 | ||
|
|
131fee36d7 | ||
|
|
c157f74a4d | ||
|
|
702156904a | ||
|
|
3ea6b5c7b8 | ||
|
|
c22a9bd88e | ||
|
|
dcd050ca69 | ||
|
|
80a24bf627 | ||
|
|
65e2bf937e | ||
|
|
763c0cd25f | ||
|
|
c2f7dc49f8 | ||
|
|
0a1c82730f | ||
|
|
7dc37e87df | ||
|
|
cf8a9647bb | ||
|
|
d7a8e9041e | ||
|
|
3b7abae5b4 | ||
|
|
7843da0315 | ||
|
|
caa18a6983 | ||
|
|
6e980925cf | ||
|
|
8bc20ce161 |
59
.env.example
59
.env.example
@@ -4,8 +4,23 @@ 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
|
||||
@@ -21,17 +36,28 @@ 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.
|
||||
# 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 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=
|
||||
@@ -40,6 +66,13 @@ 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)
|
||||
@@ -63,3 +96,25 @@ 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=
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Build, type check, and test
|
||||
run: pnpm build && pnpm typecheck && pnpm test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
59
.github/workflows/desktop-smoke.yml
vendored
Normal file
59
.github/workflows/desktop-smoke.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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
|
||||
346
.github/workflows/release.yml
vendored
346
.github/workflows/release.yml
vendored
@@ -3,13 +3,59 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# GitHub Actions uses glob patterns here, not regex. Match versioned
|
||||
# tags broadly at the trigger layer, then enforce strict semver below.
|
||||
- "v*.*.*"
|
||||
- "!v*-dirty*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
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
|
||||
with:
|
||||
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
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -23,9 +69,6 @@ jobs:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -34,3 +77,298 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
# Multi-arch images are built natively per platform on dedicated runners
|
||||
# (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm) and merged into a
|
||||
# manifest list. This avoids QEMU emulation, which was making the Next.js
|
||||
# arm64 build run for 30+ minutes per release.
|
||||
docker-backend-build:
|
||||
needs: verify
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute backend image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-backend
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Backend
|
||||
org.opencontainers.image.description=Multica self-hosted backend
|
||||
|
||||
- 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: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-backend-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=release-backend-${{ env.PLATFORM_PAIR }}
|
||||
build-args: |
|
||||
VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
COMMIT=${{ github.sha }}
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-backend,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-backend-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker-backend-merge:
|
||||
needs: [verify, docker-backend-build]
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-docker-backend-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-backend-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute backend image tags
|
||||
id: meta
|
||||
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-
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-backend@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
ghcr.io/${{ github.repository_owner }}/multica-backend:${{ steps.meta.outputs.version }}
|
||||
|
||||
docker-web-build:
|
||||
needs: verify
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runs-on: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
steps:
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute web image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/multica-web
|
||||
labels: |
|
||||
org.opencontainers.image.title=Multica Web
|
||||
org.opencontainers.image.description=Multica self-hosted web frontend
|
||||
|
||||
- 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: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
pull: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=release-web-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=gha,mode=max,scope=release-web-${{ env.PLATFORM_PAIR }}
|
||||
build-args: |
|
||||
REMOTE_API_URL=http://backend:8080
|
||||
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-web,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-web-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
docker-web-merge:
|
||||
needs: [verify, docker-web-build]
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: release-docker-web-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-web-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Compute web image tags
|
||||
id: meta
|
||||
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-
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-web@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect \
|
||||
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
|
||||
|
||||
# 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,3 +57,4 @@ _features/
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
@@ -21,12 +21,12 @@ builds:
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
# 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
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
@@ -34,6 +34,16 @@ 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"
|
||||
@@ -48,6 +58,8 @@ changelog:
|
||||
|
||||
brews:
|
||||
- name: multica
|
||||
ids:
|
||||
- versioned
|
||||
repository:
|
||||
owner: multica-ai
|
||||
name: homebrew-tap
|
||||
|
||||
85
.vercelignore
Normal file
85
.vercelignore
Normal file
@@ -0,0 +1,85 @@
|
||||
# Deploy the frontend apps from the monorepo root.
|
||||
# Keep apps/web, apps/docs, shared packages, and root workspace metadata.
|
||||
# Exclude unrelated workspaces and local artifacts that can make
|
||||
# `vercel deploy` upload far more than the app needs.
|
||||
|
||||
.agent_context
|
||||
.claude
|
||||
.context
|
||||
.env*
|
||||
.envrc
|
||||
.tool-versions
|
||||
_features
|
||||
.kilo
|
||||
.idea
|
||||
.DS_Store
|
||||
.husky
|
||||
.vscode
|
||||
|
||||
/.dockerignore
|
||||
/.goreleaser.yml
|
||||
/AGENTS.md
|
||||
/CLAUDE.md
|
||||
/CLI_AND_DAEMON.md
|
||||
/CLI_INSTALL.md
|
||||
/CONTRIBUTING.md
|
||||
/Dockerfile
|
||||
/Dockerfile.web
|
||||
/HANDOFF_ARCHITECTURE_AUDIT.md
|
||||
/Makefile
|
||||
/README.md
|
||||
/README.zh-CN.md
|
||||
/SELF_HOSTING.md
|
||||
/SELF_HOSTING_ADVANCED.md
|
||||
/SELF_HOSTING_AI.md
|
||||
/docker-compose*.yml
|
||||
/playwright.config.ts
|
||||
/skills-lock.json
|
||||
|
||||
/.github/
|
||||
/docker/
|
||||
/docs/
|
||||
/e2e/
|
||||
/server/
|
||||
/apps/desktop/
|
||||
/scripts/
|
||||
|
||||
*.log
|
||||
*.pid
|
||||
*.tsbuildinfo
|
||||
|
||||
.cache
|
||||
.next
|
||||
.pnpm-store
|
||||
.turbo
|
||||
.vercel
|
||||
coverage
|
||||
test-results
|
||||
playwright-report
|
||||
data
|
||||
|
||||
node_modules
|
||||
bin
|
||||
dist
|
||||
out
|
||||
build
|
||||
dist-electron
|
||||
|
||||
# Deployment-only trims: tests and lint configs are not used by `next build`.
|
||||
**/__tests__/**
|
||||
**/test/**
|
||||
**/*.test.*
|
||||
**/*.spec.*
|
||||
/packages/eslint-config/
|
||||
/apps/web/components.json
|
||||
/apps/web/eslint.config.mjs
|
||||
/apps/web/vitest.config.ts
|
||||
|
||||
# Root repo metadata not needed in the deployment source.
|
||||
/.env.example
|
||||
/.gitattributes
|
||||
/.gitignore
|
||||
/LICENSE
|
||||
|
||||
*.app
|
||||
*.dmg
|
||||
40
CLAUDE.md
40
CLAUDE.md
@@ -106,6 +106,7 @@ 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
|
||||
@@ -162,7 +163,7 @@ When the two apps need different behavior for the same concept (e.g., different
|
||||
When adding a new page or feature:
|
||||
|
||||
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
|
||||
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
|
||||
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
@@ -176,6 +177,43 @@ Both apps share the same CSS foundation from `packages/ui/styles/`.
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## Desktop-specific Rules
|
||||
|
||||
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
|
||||
|
||||
### Route categories
|
||||
|
||||
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
|
||||
|
||||
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
|
||||
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
|
||||
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
|
||||
|
||||
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
|
||||
|
||||
### Workspace context
|
||||
|
||||
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
|
||||
|
||||
1. Read destination from cached workspace list.
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)`.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS)
|
||||
|
||||
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
|
||||
|
||||
@@ -278,7 +278,7 @@ multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -293,7 +293,7 @@ multica issue get <id> --output json
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -332,6 +332,27 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
# List subscribers of an issue
|
||||
multica issue subscriber list <issue-id>
|
||||
|
||||
# Subscribe yourself to an issue
|
||||
multica issue subscriber add <issue-id>
|
||||
|
||||
# Subscribe another member or agent by name
|
||||
multica issue subscriber add <issue-id> --user "Lambda"
|
||||
|
||||
# Unsubscribe yourself
|
||||
multica issue subscriber remove <issue-id>
|
||||
|
||||
# Unsubscribe another member or agent
|
||||
multica issue subscriber remove <issue-id> --user "Lambda"
|
||||
```
|
||||
|
||||
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
|
||||
|
||||
### Execution History
|
||||
|
||||
```bash
|
||||
@@ -349,6 +370,70 @@ multica issue run-messages <task-id> --since 42 --output json
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Projects
|
||||
|
||||
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
|
||||
belongs to a workspace and can optionally have a lead (member or agent).
|
||||
|
||||
### List Projects
|
||||
|
||||
```bash
|
||||
multica project list
|
||||
multica project list --status in_progress
|
||||
multica project list --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`.
|
||||
|
||||
### Get Project
|
||||
|
||||
```bash
|
||||
multica project get <id>
|
||||
multica project get <id> --output json
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
```bash
|
||||
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Update Project
|
||||
|
||||
```bash
|
||||
multica project update <id> --title "New title" --status in_progress
|
||||
multica project update <id> --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
multica project status <id> in_progress
|
||||
```
|
||||
|
||||
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
|
||||
|
||||
### Delete Project
|
||||
|
||||
```bash
|
||||
multica project delete <id>
|
||||
```
|
||||
|
||||
### Associating Issues with Projects
|
||||
|
||||
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
|
||||
project, or on `issue list` to filter issues by project:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Login bug" --project <project-id>
|
||||
multica issue update <issue-id> --project <project-id>
|
||||
multica issue list --project <project-id>
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
@@ -385,6 +470,63 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
|
||||
### List Autopilots
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot list --status active --output json
|
||||
```
|
||||
|
||||
### Get Autopilot Details
|
||||
|
||||
```bash
|
||||
multica autopilot get <id>
|
||||
multica autopilot get <id> --output json # includes triggers
|
||||
```
|
||||
|
||||
### Create / Update / Delete
|
||||
|
||||
```bash
|
||||
multica autopilot create \
|
||||
--title "Nightly bug triage" \
|
||||
--description "Scan todo issues and prioritize." \
|
||||
--agent "Lambda" \
|
||||
--mode create_issue
|
||||
|
||||
multica autopilot update <id> --status paused
|
||||
multica autopilot update <id> --description "New prompt"
|
||||
multica autopilot delete <id>
|
||||
```
|
||||
|
||||
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
|
||||
|
||||
### Manual Trigger
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <id> # Fires the autopilot once, returns the run
|
||||
```
|
||||
|
||||
### Run History
|
||||
|
||||
```bash
|
||||
multica autopilot runs <id>
|
||||
multica autopilot runs <id> --limit 50 --output json
|
||||
```
|
||||
|
||||
### Schedule Triggers
|
||||
|
||||
```bash
|
||||
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
|
||||
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
|
||||
multica autopilot trigger-delete <autopilot-id> <trigger-id>
|
||||
```
|
||||
|
||||
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
|
||||
|
||||
## Other Commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -76,7 +76,8 @@ 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
|
||||
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
|
||||
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
|
||||
tar -xzf /tmp/multica.tar.gz -C /tmp multica
|
||||
sudo mv /tmp/multica /usr/local/bin/multica
|
||||
rm /tmp/multica.tar.gz
|
||||
|
||||
@@ -592,6 +592,19 @@ 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)
|
||||
|
||||
166
Makefile
166
Makefile
@@ -1,4 +1,4 @@
|
||||
.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
|
||||
.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
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,10 +36,23 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
# 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
|
||||
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
##@ 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
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
@@ -51,8 +64,16 @@ selfhost:
|
||||
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 --build
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
@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 \
|
||||
@@ -66,7 +87,11 @@ selfhost:
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
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 ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
@@ -77,16 +102,57 @@ selfhost:
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
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
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
##@ One-click
|
||||
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
setup:
|
||||
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
|
||||
$(REQUIRE_ENV)
|
||||
@echo "==> Using env file: $(ENV_FILE)"
|
||||
@echo "==> Installing dependencies..."
|
||||
@@ -97,8 +163,7 @@ setup:
|
||||
@echo ""
|
||||
@echo "✓ Setup complete! Run 'make start' to launch the app."
|
||||
|
||||
# Start all services (backend + frontend)
|
||||
start:
|
||||
start: ## Start backend and frontend for the current checkout and run migrations first
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Using env file: $(ENV_FILE)"
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@@ -112,8 +177,7 @@ start:
|
||||
pnpm dev:web & \
|
||||
wait
|
||||
|
||||
# Stop all services
|
||||
stop:
|
||||
stop: ## Stop backend and frontend processes for the current checkout
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@@ -125,33 +189,52 @@ stop:
|
||||
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
|
||||
esac
|
||||
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
|
||||
$(REQUIRE_ENV)
|
||||
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
|
||||
|
||||
db-up:
|
||||
db-up: ## Start the shared PostgreSQL container used by main and worktrees
|
||||
@$(COMPOSE) up -d postgres
|
||||
|
||||
db-down:
|
||||
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
|
||||
@$(COMPOSE) down
|
||||
|
||||
worktree-env:
|
||||
# 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
|
||||
@bash scripts/init-worktree-env.sh .env.worktree
|
||||
|
||||
setup-main:
|
||||
setup-main: ## Prepare the main checkout using .env
|
||||
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
start-main:
|
||||
start-main: ## Start the main checkout using .env
|
||||
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
stop-main:
|
||||
stop-main: ## Stop the main checkout processes defined by .env
|
||||
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
check-main:
|
||||
check-main: ## Run the full verification pipeline for the main checkout
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree:
|
||||
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
@@ -160,65 +243,68 @@ setup-worktree:
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree:
|
||||
start-worktree: ## Start this worktree using .env.worktree
|
||||
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
stop-worktree:
|
||||
stop-worktree: ## Stop this worktree's backend and frontend processes
|
||||
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
check-worktree:
|
||||
check-worktree: ## Run the full verification pipeline for this worktree
|
||||
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
# ---------- Individual commands ----------
|
||||
##@ Individual commands
|
||||
|
||||
# One-command dev: auto-setup env/deps/db/migrations, then start all services
|
||||
dev:
|
||||
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
|
||||
@bash scripts/dev.sh
|
||||
|
||||
# Go server only
|
||||
server:
|
||||
server: ## Run only the Go server for the current checkout
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon:
|
||||
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
|
||||
|
||||
cli:
|
||||
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
|
||||
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
|
||||
|
||||
multica:
|
||||
multica: ## Run the multica CLI entrypoint directly from the Go source tree
|
||||
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: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
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:
|
||||
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
migrate-up:
|
||||
##@ Database
|
||||
|
||||
migrate-up: ## Create the target DB if needed, then apply database migrations
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
|
||||
migrate-down:
|
||||
migrate-down: ## Create the target DB if needed, then roll back database migrations
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate down
|
||||
|
||||
sqlc:
|
||||
sqlc: ## Regenerate sqlc code
|
||||
cd server && sqlc generate
|
||||
|
||||
# Cleanup
|
||||
clean:
|
||||
##@ Cleanup
|
||||
|
||||
clean: ## Remove generated server binaries and temp files
|
||||
rm -rf server/bin server/tmp
|
||||
|
||||
@@ -85,7 +85,8 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,9 +24,9 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
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.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -54,6 +54,10 @@ 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
|
||||
@@ -63,9 +67,15 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
- **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
|
||||
|
||||
@@ -152,14 +162,15 @@ 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.
|
||||
|
||||
## Rebuilding After Updates
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -182,6 +193,7 @@ 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,6 +14,15 @@ 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).
|
||||
@@ -23,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -33,6 +42,18 @@ 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:
|
||||
@@ -44,7 +65,14 @@ 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) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### 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.
|
||||
|
||||
### Server
|
||||
|
||||
@@ -218,7 +246,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 (set before building the frontend image)
|
||||
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
@@ -234,15 +262,15 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then rebuild:
|
||||
Then restart the stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
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 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.
|
||||
> **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`.
|
||||
|
||||
## Health Check
|
||||
|
||||
@@ -258,8 +286,9 @@ Use this for load balancer health checks or monitoring.
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
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`.
|
||||
|
||||
@@ -21,25 +21,34 @@ mac:
|
||||
- zip
|
||||
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
|
||||
# `${name}` produces for scoped package names.
|
||||
artifactName: multica-desktop-${version}-${arch}.${ext}
|
||||
# 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}
|
||||
# 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}-${arch}.${ext}
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
# Align with our CLI release flow which pre-creates a *published* GitHub
|
||||
# Release via `gh release create`. The electron-builder default of
|
||||
# `releaseType: draft` conflicts with `existingType=release` and causes
|
||||
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
|
||||
# which breaks electron-updater auto-update on installed clients.
|
||||
releaseType: release
|
||||
npmRebuild: false
|
||||
|
||||
@@ -10,4 +10,28 @@ export default [
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
// Security: every renderer-controlled URL that reaches the OS shell must
|
||||
// flow through openExternalSafely in src/main/external-url.ts (scheme
|
||||
// allowlist). Enforce it statically so a direct shell.openExternal call
|
||||
// cannot silently regress the protection.
|
||||
{
|
||||
files: ["src/main/**/*.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.name='shell'][callee.property.name='openExternal']",
|
||||
message:
|
||||
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/main/external-url.ts"],
|
||||
rules: {
|
||||
"no-restricted-syntax": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,17 +2,31 @@
|
||||
"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"
|
||||
@@ -25,6 +39,7 @@
|
||||
"@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 } from "node:fs/promises";
|
||||
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
@@ -23,8 +23,54 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
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 destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
@@ -61,8 +107,9 @@ if (hasGo()) {
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
|
||||
);
|
||||
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
@@ -70,10 +117,19 @@ if (hasGo()) {
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
join("bin", binName),
|
||||
srcBinary,
|
||||
"./cmd/multica",
|
||||
],
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
{
|
||||
cwd: serverDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
GOOS: goos,
|
||||
GOARCH: goarch,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -88,9 +144,11 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// The electron-vite step is important: electron-builder only packages
|
||||
// whatever is already in out/, so skipping it (or relying on stale
|
||||
@@ -25,11 +25,50 @@
|
||||
// version-derivation logic without shelling out.
|
||||
|
||||
import { execFileSync, spawnSync, execSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { delimiter, 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 {
|
||||
@@ -77,20 +116,231 @@ function deriveVersion() {
|
||||
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
|
||||
}
|
||||
|
||||
function main() {
|
||||
// Step 1: build + bundle the Go CLI via the existing script.
|
||||
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
|
||||
stdio: "inherit",
|
||||
cwd: desktopRoot,
|
||||
});
|
||||
function uniqueOrdered(values) {
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
// Step 2: build the Electron main/preload/renderer bundles. Without
|
||||
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 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(
|
||||
@@ -103,7 +353,7 @@ function main() {
|
||||
process.exit(viteResult.status ?? 1);
|
||||
}
|
||||
|
||||
// Step 3: derive the version that should be written into the app.
|
||||
// Step 2: derive the version that should be written into the app.
|
||||
const version = deriveVersion();
|
||||
if (version) {
|
||||
console.log(`[package] Desktop version → ${version} (from git describe)`);
|
||||
@@ -113,43 +363,62 @@ function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
|
||||
if (disableMacNotarize) {
|
||||
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");
|
||||
}
|
||||
|
||||
builderArgs.push(...passthrough);
|
||||
const useScopedOutputDir = buildMatrix.length > 1;
|
||||
|
||||
// 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,
|
||||
// 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,
|
||||
},
|
||||
);
|
||||
process.exit(1);
|
||||
|
||||
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(result.status ?? 1);
|
||||
}
|
||||
|
||||
// Only run when invoked as a CLI, not when imported by a test file.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { delimiter, resolve } from "node:path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
|
||||
import {
|
||||
builderArgsForTarget,
|
||||
envWithLocalBins,
|
||||
normalizeGitVersion,
|
||||
parsePackageArgs,
|
||||
resolveBuildMatrix,
|
||||
stripLeadingSeparator,
|
||||
} from "./package.mjs";
|
||||
|
||||
describe("normalizeGitVersion", () => {
|
||||
it("returns null for empty / nullish input", () => {
|
||||
@@ -59,3 +67,207 @@ 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,35 +8,15 @@ import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
// 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.
|
||||
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.
|
||||
|
||||
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";
|
||||
}
|
||||
@@ -92,14 +72,8 @@ 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(
|
||||
@@ -118,7 +92,14 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const assetName = platformAssetName();
|
||||
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 url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
@@ -130,7 +111,7 @@ async function installFresh(): Promise<string> {
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
await verifyChecksum(archivePath, assetName, expectedChecksum);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
@@ -143,6 +124,7 @@ 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);
|
||||
|
||||
@@ -166,8 +148,10 @@ 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(): Promise<string> {
|
||||
export async function ensureManagedCli(
|
||||
options: { forceInstall?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target)) return target;
|
||||
if (existsSync(target) && !options.forceInstall) return target;
|
||||
return installFresh();
|
||||
}
|
||||
|
||||
59
apps/desktop/src/main/cli-release-asset.test.ts
Normal file
59
apps/desktop/src/main/cli-release-asset.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
62
apps/desktop/src/main/cli-release-asset.ts
Normal file
62
apps/desktop/src/main/cli-release-asset.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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,6 +316,36 @@ 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.
|
||||
@@ -339,27 +369,55 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
const version = await probeCliBinary(bundled, "bundled");
|
||||
if (version) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
cachedCliBinaryVersion = version;
|
||||
return bundled;
|
||||
}
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
const version = await probeCliBinary(managed, "managed");
|
||||
if (version) {
|
||||
cachedCliBinary = managed;
|
||||
cachedCliBinaryVersion = version;
|
||||
return managed;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
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`,
|
||||
);
|
||||
} 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 {
|
||||
@@ -370,11 +428,10 @@ async function resolveCliBinary(): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Reads the version of the currently resolved CLI binary. Cached for the
|
||||
* process lifetime — the bundled binary doesn't change after bundle time.
|
||||
* Returns null on any failure (unknown `go` at bundle time, broken binary,
|
||||
* etc.) so callers can fail open.
|
||||
* wrong-arch bundled binary, etc.) so callers can fail open.
|
||||
*/
|
||||
async function getCliBinaryVersion(): Promise<string | null> {
|
||||
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
|
||||
@@ -383,24 +440,7 @@ async function getCliBinaryVersion(): Promise<string | null> {
|
||||
cachedCliBinaryVersion = null;
|
||||
return 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 };
|
||||
cachedCliBinaryVersion = parsed.version ?? null;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] failed to read CLI binary version:", err);
|
||||
cachedCliBinaryVersion = null;
|
||||
}
|
||||
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
|
||||
return cachedCliBinaryVersion;
|
||||
}
|
||||
|
||||
|
||||
73
apps/desktop/src/main/external-url.test.ts
Normal file
73
apps/desktop/src/main/external-url.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
shell: { openExternal: vi.fn().mockResolvedValue(undefined) },
|
||||
}));
|
||||
|
||||
import { shell } from "electron";
|
||||
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
|
||||
|
||||
describe("isSafeExternalHttpUrl", () => {
|
||||
it("allows http and https URLs", () => {
|
||||
expect(isSafeExternalHttpUrl("https://multica.ai")).toBe(true);
|
||||
expect(isSafeExternalHttpUrl("http://localhost:3000/auth")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows https URLs with embedded credentials", () => {
|
||||
// WHATWG URL parses these as https; OS-level handling is the shell's concern.
|
||||
expect(isSafeExternalHttpUrl("https://user:pass@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes scheme casing so uppercase variants can't bypass", () => {
|
||||
expect(isSafeExternalHttpUrl("HTTPS://example.com")).toBe(true);
|
||||
expect(isSafeExternalHttpUrl("FILE:///etc/passwd")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects dangerous pseudo-schemes", () => {
|
||||
expect(isSafeExternalHttpUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(
|
||||
isSafeExternalHttpUrl("data:text/html,<script>alert(1)</script>"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects filesystem and network transport schemes", () => {
|
||||
expect(isSafeExternalHttpUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("ftp://example.com/x")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("smb://share/x")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects local-handler schemes used in past RCE chains", () => {
|
||||
expect(isSafeExternalHttpUrl("vscode://file/test")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("ms-msdt:/id%20PCWDiagnostic")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects mailto and other non-web schemes", () => {
|
||||
expect(isSafeExternalHttpUrl("mailto:test@example.com")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("tel:+15551234567")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty, whitespace, and malformed input", () => {
|
||||
expect(isSafeExternalHttpUrl("")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl(" ")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("not a url")).toBe(false);
|
||||
expect(isSafeExternalHttpUrl("http://")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openExternalSafely", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(shell.openExternal).mockClear();
|
||||
});
|
||||
|
||||
it("forwards http/https URLs to shell.openExternal", () => {
|
||||
openExternalSafely("https://multica.ai");
|
||||
expect(shell.openExternal).toHaveBeenCalledWith("https://multica.ai");
|
||||
});
|
||||
|
||||
it("does not call shell.openExternal for rejected schemes", () => {
|
||||
openExternalSafely("file:///etc/passwd");
|
||||
openExternalSafely("javascript:alert(1)");
|
||||
openExternalSafely("not a url");
|
||||
expect(shell.openExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
38
apps/desktop/src/main/external-url.ts
Normal file
38
apps/desktop/src/main/external-url.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { shell } from "electron";
|
||||
|
||||
// True when the URL parses and uses http/https — the only schemes we let
|
||||
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
|
||||
// URL parser lowercases the protocol field.
|
||||
export function isSafeExternalHttpUrl(url: string): boolean {
|
||||
return getHttpProtocol(url) !== null;
|
||||
}
|
||||
|
||||
// Canonical wrapper around shell.openExternal. All renderer-controlled URLs
|
||||
// that eventually reach the OS shell MUST flow through here; direct calls
|
||||
// to `shell.openExternal` elsewhere in the main process are banned by the
|
||||
// no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
|
||||
export function openExternalSafely(url: string): Promise<void> | void {
|
||||
if (getHttpProtocol(url) === null) {
|
||||
console.warn(`[security] blocked openExternal: ${describeScheme(url)}`);
|
||||
return;
|
||||
}
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
|
||||
function getHttpProtocol(url: string): "http:" | "https:" | null {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
if (protocol === "http:" || protocol === "https:") return protocol;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function describeScheme(url: string): string {
|
||||
try {
|
||||
return `scheme=${new URL(url).protocol}`;
|
||||
} catch {
|
||||
return "invalid URL";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { app, shell, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -48,6 +49,19 @@ function handleDeepLink(url: string): void {
|
||||
if (token && mainWindow) {
|
||||
mainWindow.webContents.send("auth:token", token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// multica://invite/<invitationId>
|
||||
// Dispatched from the web invite page when the user chooses "Open in
|
||||
// desktop app". The renderer opens the invite overlay — no tab, no
|
||||
// route persistence, so deep-linking the same invite twice stays safe.
|
||||
if (parsed.hostname === "invite") {
|
||||
const id = parsed.pathname.replace(/^\//, "");
|
||||
if (id && mainWindow) {
|
||||
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
@@ -91,7 +105,7 @@ function createWindow(): void {
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
@@ -109,7 +123,14 @@ function createWindow(): void {
|
||||
// is derived from the userData path. (Same approach VS Code uses for
|
||||
// Stable / Insiders coexistence.)
|
||||
|
||||
const DEV_APP_NAME = "Multica Canary";
|
||||
// DESKTOP_APP_SUFFIX lets parallel worktrees run dev Electron side-by-side
|
||||
// without fighting for the shared single-instance lock. The suffix is
|
||||
// appended to the app name + userData path, so each worktree gets its own
|
||||
// lock file. Default (no env var) keeps behavior unchanged — the common
|
||||
// single-worktree case still lands at "Multica Canary".
|
||||
const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
|
||||
: "Multica Canary";
|
||||
|
||||
if (is.dev) {
|
||||
app.setName(DEV_APP_NAME);
|
||||
@@ -163,9 +184,23 @@ if (!gotTheLock) {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// IPC: open URL in default browser (used by renderer for Google login)
|
||||
// IPC: open URL in default browser (used by renderer for Google login).
|
||||
// All scheme-allowlist enforcement lives in openExternalSafely — this
|
||||
// is the single audit point for renderer-controlled URLs reaching the
|
||||
// OS shell under the app's intentional webSecurity: false + sandbox:
|
||||
// false configuration.
|
||||
ipcMain.handle("shell:openExternal", (_event, url: string) => {
|
||||
return shell.openExternal(url);
|
||||
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
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
import { app, 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();
|
||||
@@ -37,10 +59,42 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
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.
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, 5000);
|
||||
}, 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);
|
||||
}
|
||||
|
||||
11
apps/desktop/src/preload/index.d.ts
vendored
11
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,8 +1,15 @@
|
||||
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. */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
@@ -51,6 +58,10 @@ 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,7 +1,32 @@
|
||||
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) =>
|
||||
@@ -11,6 +36,15 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener("auth:token", handler);
|
||||
};
|
||||
},
|
||||
/** Listen for invitation IDs delivered via deep link */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
|
||||
callback(invitationId);
|
||||
ipcRenderer.on("invite:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("invite:open", handler);
|
||||
};
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
@@ -87,6 +121,10 @@ 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,20 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, 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";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -31,6 +35,17 @@ function AppContent() {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
// in, InvitePage's queries will fail and render the "not found" state,
|
||||
// which is acceptable; the expected pre-flight happens in the web app
|
||||
// (login + next=/invite/... dance) before the deep link is ever dispatched.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInviteOpen((invitationId) => {
|
||||
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
|
||||
// daemonAPI.syncToken is handled separately by the [user] effect below, which
|
||||
// fires whenever a user logs in (deep link, session restore, account switch).
|
||||
@@ -77,28 +92,53 @@ 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 ?? 0;
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Validate persisted tab paths against the current user's workspace list.
|
||||
// Tabs survive across app restarts and account switches (persisted to
|
||||
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
|
||||
// reference a workspace the current user can't access — showing
|
||||
// NoAccessPage every time they open the app.
|
||||
// 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]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
// (synchronously after render, before paint) rather than the render
|
||||
// phase — the original render-phase pattern triggered React's
|
||||
// "Cannot update a component while rendering a different component"
|
||||
// warning because `switchWorkspace` is a Zustand setState that the
|
||||
// TabBar is subscribed to. useLayoutEffect flushes both renders before
|
||||
// the user sees anything, so there's no visible flicker.
|
||||
//
|
||||
// Run synchronously in render phase rather than in useEffect so the first
|
||||
// render already sees validated tabs. useEffect runs AFTER commit, which
|
||||
// means the initial render would briefly show NoAccessPage before the
|
||||
// effect resets the tab. Zustand supports render-phase setState; the
|
||||
// validator is idempotent (exits early if nothing changed) so this
|
||||
// doesn't loop.
|
||||
if (workspaces) {
|
||||
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
|
||||
// the first fetch, so without this guard we'd run validation against an
|
||||
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
|
||||
// back to `workspaces[0]` once the real list arrives — losing the user's
|
||||
// last-opened workspace on every app start.
|
||||
useLayoutEffect(() => {
|
||||
if (!workspaceListFetched) return;
|
||||
const validSlugs = new Set(workspaces.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
}
|
||||
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
|
||||
if (!activeWorkspaceSlug && workspaces.length > 0) {
|
||||
switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [workspaces, workspaceListFetched]);
|
||||
|
||||
// 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
|
||||
@@ -127,17 +167,29 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
// 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
|
||||
// useLogout clears the storage key, but the live stores stay populated until
|
||||
// we explicitly reset them here.
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
@@ -151,12 +203,20 @@ 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,11 +13,13 @@ 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";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
@@ -113,7 +115,8 @@ export function DesktopShell() {
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
|
||||
users are routed to /workspaces/new by IndexRedirect. */}
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
@@ -132,6 +135,8 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { isGlobalPath, paths } from "@multica/core/paths";
|
||||
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Inbox,
|
||||
@@ -67,16 +67,13 @@ function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolea
|
||||
const handleClick = () => {
|
||||
if (isActive) return;
|
||||
setActiveTab(tab.id);
|
||||
// No navigate() — Activity handles visibility
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
// No navigate() — store handles activeTabId switch
|
||||
};
|
||||
|
||||
// Stop pointer down on close so it doesn't start a drag on the parent button.
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
@@ -125,22 +122,13 @@ function NewTabButton() {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
|
||||
const handleClick = () => {
|
||||
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
|
||||
// opens in the same context as the active one. Read the slug from the
|
||||
// active tab's path directly rather than from getCurrentSlug(), because
|
||||
// that singleton is "last tab to render" (non-deterministic with N tabs
|
||||
// mounted under <Activity>), while activeTabId is the unambiguous truth.
|
||||
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
|
||||
// tab is on a global route (e.g. /workspaces/new, /login).
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
|
||||
let slug: string | null = null;
|
||||
if (activePath !== "/" && !isGlobalPath(activePath)) {
|
||||
slug = activePath.split("/").filter(Boolean)[0] ?? null;
|
||||
}
|
||||
const path = slug ? paths.workspace(slug).issues() : "/";
|
||||
// New tab opens in the currently active workspace — tabs are scoped
|
||||
// per workspace, so there is no cross-workspace ambiguity to resolve.
|
||||
const activeSlug = useTabStore.getState().activeWorkspaceSlug;
|
||||
if (!activeSlug) return;
|
||||
const path = paths.workspace(activeSlug).issues();
|
||||
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
|
||||
setActiveTab(tabId);
|
||||
if (tabId) setActiveTab(tabId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -155,17 +143,17 @@ function NewTabButton() {
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const group = useActiveGroup();
|
||||
const moveTab = useTabStore((s) => s.moveTab);
|
||||
|
||||
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
@@ -195,7 +183,7 @@ export function TabBar() {
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<NewTabButton />
|
||||
{group && <NewTabButton />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,52 @@
|
||||
import { Activity, useEffect } from "react";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useActiveGroup } from "@/stores/tab-store";
|
||||
import { TabNavigationProvider } from "@/platform/navigation";
|
||||
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
|
||||
import type { Tab } from "@/stores/tab-store";
|
||||
|
||||
/** Inner wrapper rendered inside each tab's RouterProvider. */
|
||||
function TabRouterInner({ tabId }: { tabId: string }) {
|
||||
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
|
||||
useTabRouterSync(tabId, tab!.router);
|
||||
/**
|
||||
* Inner wrapper rendered inside each tab's RouterProvider. The router
|
||||
* reference is stable for a tab's lifetime, so passing it in directly
|
||||
* (instead of re-deriving from the store) avoids needless re-renders.
|
||||
*/
|
||||
function TabRouterInner({ tab }: { tab: Tab }) {
|
||||
useTabRouterSync(tab.id, tab.router);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders all tabs using Activity for state preservation.
|
||||
* Renders the active workspace's tabs using Activity for state preservation.
|
||||
* Only the active tab is visible; hidden tabs keep their DOM and React state.
|
||||
*
|
||||
* When switching workspaces, the previous workspace's tabs unmount entirely
|
||||
* and the new workspace's tabs mount fresh — cross-workspace state
|
||||
* preservation is an explicit non-goal (keeping all workspaces' tabs warm
|
||||
* simultaneously would bloat memory and make workspace switching feel
|
||||
* anything but "switching").
|
||||
*/
|
||||
export function TabContent() {
|
||||
const tabs = useTabStore((s) => s.tabs);
|
||||
const activeTabId = useTabStore((s) => s.activeTabId);
|
||||
const group = useActiveGroup();
|
||||
|
||||
// Sync document.title when switching tabs
|
||||
// Sync document.title when switching tabs within the active workspace.
|
||||
useEffect(() => {
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
if (!group) return;
|
||||
const tab = group.tabs.find((t) => t.id === group.activeTabId);
|
||||
if (tab) document.title = tab.title;
|
||||
}, [activeTabId, tabs]);
|
||||
}, [group?.activeTabId, group?.tabs]);
|
||||
|
||||
if (!group) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{tabs.map((tab) => (
|
||||
{group.tabs.map((tab) => (
|
||||
<Activity
|
||||
key={tab.id}
|
||||
mode={tab.id === activeTabId ? "visible" : "hidden"}
|
||||
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
|
||||
>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tabId={tab.id} />
|
||||
<TabRouterInner tab={tab} />
|
||||
</TabNavigationProvider>
|
||||
</Activity>
|
||||
))}
|
||||
|
||||
@@ -110,12 +110,25 @@ export function UpdateNotification() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
79
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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";
|
||||
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).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function WindowOverlay() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
if (!overlay) return null;
|
||||
return <WindowOverlayInner />;
|
||||
}
|
||||
|
||||
function WindowOverlayInner() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const close = useWindowOverlayStore((s) => s.close);
|
||||
const { push } = useNavigation();
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
if (!overlay) return null;
|
||||
|
||||
// Back is only meaningful when there's somewhere to go — i.e. the user
|
||||
// has at least one workspace. Zero-workspace users can only Log out or
|
||||
// complete the flow.
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,14 @@ import { useEffect } from "react";
|
||||
import { Outlet, useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import {
|
||||
workspaceBySlugOptions,
|
||||
workspaceListOptions,
|
||||
} from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
@@ -17,9 +20,13 @@ import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we render NoAccessPage instead of silently redirecting — users get
|
||||
* explicit feedback for stale bookmarks or revoked access.
|
||||
* Unlike web, desktop never renders a "workspace not available" page: the
|
||||
* app has no URL bar and no clickable links from outside the session, so
|
||||
* landing on an inaccessible slug can only mean stale state (a persisted
|
||||
* tab group for a workspace the current user no longer has access to, or
|
||||
* active eviction). Both cases resolve by dropping the stale tab group
|
||||
* from the tab store — the TabBar then renders a different workspace or
|
||||
* the WindowOverlay takes over (zero valid workspaces).
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
@@ -27,10 +34,7 @@ export function WorkspaceRouteLayout() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated (token
|
||||
// expired, logged out from another tab, etc.), bounce to /login.
|
||||
// Without this, the layout renders null and the user sees a blank page
|
||||
// stuck on /{slug}/...
|
||||
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
|
||||
}, [isAuthLoading, user, navigate]);
|
||||
@@ -40,36 +44,41 @@ export function WorkspaceRouteLayout() {
|
||||
enabled: !!user && !!workspaceSlug,
|
||||
});
|
||||
|
||||
const { data: wsList } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Feed the URL slug into the platform singleton so the API client's
|
||||
// X-Workspace-Slug header and persist namespace follow the active tab.
|
||||
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
|
||||
// every render (matters on desktop, where N tabs each mount their own
|
||||
// layout). Rehydrate is the singleton's internal side effect.
|
||||
// setCurrentWorkspace self-dedupes on slug equality.
|
||||
if (workspace && workspaceSlug) {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
// Remember whether this slug has resolved before (see hook docs). Gates
|
||||
// the NoAccessPage render below so active workspace removal doesn't
|
||||
// flash "Workspace not available" before the navigate lands.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
// Stale-slug auto-heal: when this tab's slug fails to resolve, drop the
|
||||
// whole workspace group from the tab store. Per-workspace tab grouping
|
||||
// means the cleanup is a single validator call — the TabContent will
|
||||
// unmount this tab (and all siblings in the stale group) once the store
|
||||
// updates. We don't navigate this tab's router because the tab's path
|
||||
// is scoped to the stale slug; navigating to "/" would create an
|
||||
// inconsistent "tab in group X with path /" state.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!listFetched) return;
|
||||
if (workspace) return;
|
||||
if (hasBeenSeen) return; // active eviction in flight — let the other path win
|
||||
if (!wsList) return;
|
||||
const validSlugs = new Set(wsList.map((w) => w.slug));
|
||||
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
|
||||
}, [user, listFetched, workspace, hasBeenSeen, wsList]);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
// throws when the workspace list hasn't populated or the slug is
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// Active workspace just removed (delete/leave/realtime eviction) —
|
||||
// navigate is in flight; hold null briefly instead of flashing
|
||||
// NoAccessPage.
|
||||
if (hasBeenSeen) return null;
|
||||
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
|
||||
// link from a former teammate's workspace) → explicit feedback.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
if (!workspace) return null; // auto-heal effect above handles the cleanup
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
--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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
|
||||
@@ -9,32 +9,32 @@ import { useTabStore } from "@/stores/tab-store";
|
||||
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
|
||||
|
||||
/**
|
||||
* Per-tab back/forward navigation derived from the active tab's history state.
|
||||
* Replaces the old global useNavigationHistory() hook.
|
||||
* Per-tab back/forward navigation derived from the active workspace's
|
||||
* active tab.
|
||||
*
|
||||
* Subscribed via primitive selectors so this hook only re-renders when
|
||||
* the numeric history state actually changes — path ticks on the active
|
||||
* tab (which don't shift historyIndex) don't churn the back/forward
|
||||
* buttons.
|
||||
*/
|
||||
export function useTabHistory() {
|
||||
// Return the actual tab object from the store — stable reference.
|
||||
// Do NOT create a new object in the selector (causes infinite re-renders).
|
||||
const activeTab = useTabStore((s) =>
|
||||
s.tabs.find((t) => t.id === s.activeTabId),
|
||||
);
|
||||
const router = useActiveTabRouter();
|
||||
const { historyIndex, historyLength } = useActiveTabHistory();
|
||||
|
||||
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
|
||||
const canGoForward =
|
||||
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < historyLength - 1;
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex <= 0) return;
|
||||
popDirectionHints.set(activeTab.router, "back");
|
||||
activeTab.router.navigate(-1);
|
||||
}, [activeTab]);
|
||||
if (!router || historyIndex <= 0) return;
|
||||
popDirectionHints.set(router, "back");
|
||||
router.navigate(-1);
|
||||
}, [router, historyIndex]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
|
||||
return;
|
||||
popDirectionHints.set(activeTab.router, "forward");
|
||||
activeTab.router.navigate(1);
|
||||
}, [activeTab]);
|
||||
if (!router || historyIndex >= historyLength - 1) return;
|
||||
popDirectionHints.set(router, "forward");
|
||||
router.navigate(1);
|
||||
}, [router, historyIndex, historyLength]);
|
||||
|
||||
return { canGoBack, canGoForward, goBack, goForward };
|
||||
}
|
||||
|
||||
@@ -2,20 +2,23 @@ import { useEffect } from "react";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
* Watches document.title via MutationObserver and updates the active tab's title.
|
||||
*
|
||||
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
|
||||
* This observer picks up the change and syncs it to the tab store.
|
||||
* Watches document.title via MutationObserver and updates the active tab's
|
||||
* title. Pages set document.title via TitleSync (route handle.title) or
|
||||
* useDocumentTitle(). This observer picks up the change and syncs it to
|
||||
* the tab store.
|
||||
*/
|
||||
export function useActiveTitleSync() {
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const title = document.title;
|
||||
if (!title) return;
|
||||
const { tabs, activeTabId } = useTabStore.getState();
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
const state = useTabStore.getState();
|
||||
if (!state.activeWorkspaceSlug) return;
|
||||
const group = state.byWorkspace[state.activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
const activeTab = group.tabs.find((t) => t.id === group.activeTabId);
|
||||
if (activeTab && activeTab.title !== title) {
|
||||
useTabStore.getState().updateTab(activeTabId, { title });
|
||||
state.updateTab(activeTab.id, { title });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ 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,4 +1,5 @@
|
||||
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";
|
||||
@@ -14,11 +15,7 @@ export function DesktopLoginPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
<div
|
||||
className="h-[38px] shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<DragStrip />
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -5,16 +5,101 @@ import {
|
||||
type NavigationAdapter,
|
||||
} from "@multica/views/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { isReservedSlug } from "@multica/core/paths";
|
||||
import {
|
||||
useTabStore,
|
||||
resolveRouteIcon,
|
||||
useActiveTabIdentity,
|
||||
useActiveTabRouter,
|
||||
getActiveTab,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
// 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";
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
* workspace-scoped (root, login, any reserved prefix).
|
||||
*/
|
||||
function extractWorkspaceSlug(path: string): string | null {
|
||||
const first = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!first) return null;
|
||||
if (isReservedSlug(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept navigation to "transition" paths — pre-workspace flows that on
|
||||
* desktop are rendered as a window-level overlay instead of a tab route.
|
||||
* Returns `true` if the navigation was handled (caller should NOT proceed).
|
||||
*
|
||||
* Side effect: when opening the new-workspace overlay, the tab router is
|
||||
* ALSO reset to "/". Rationale — the only way a push lands on
|
||||
* /workspaces/new is that the workspace context is gone (fresh install,
|
||||
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
|
||||
* path would keep those components mounted under the overlay; the next
|
||||
* render after the list cache updates would then throw (useWorkspaceId
|
||||
* etc) because the slug no longer resolves.
|
||||
*/
|
||||
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
const overlay = useWindowOverlayStore.getState();
|
||||
if (path === "/workspaces/new") {
|
||||
overlay.open({ type: "new-workspace" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
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 {
|
||||
id = decodeURIComponent(path.slice("/invite/".length));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
if (id) {
|
||||
overlay.open({ type: "invite", invitationId: id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Any other navigation cancels a live overlay.
|
||||
if (overlay.overlay) overlay.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes that change workspace. Returns `true` if the navigation
|
||||
* was delegated to the tab store (caller should NOT proceed).
|
||||
*
|
||||
* This is the entry point that makes shared code platform-agnostic:
|
||||
* sidebar dropdown, cmd+k "switch workspace", post-delete redirects,
|
||||
* invite-accept flow — they all call `useNavigation().push(path)` with a
|
||||
* full workspace URL, and on desktop we translate "target slug differs
|
||||
* from active" into "switch the tab-group that's visible in the TabBar".
|
||||
*/
|
||||
function tryRouteToOtherWorkspace(path: string): boolean {
|
||||
const targetSlug = extractWorkspaceSlug(path);
|
||||
if (!targetSlug) return false;
|
||||
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
|
||||
if (targetSlug === activeWorkspaceSlug) return false;
|
||||
switchWorkspace(targetSlug, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab
|
||||
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
|
||||
*
|
||||
* Reads from the active tab's memory router via router.subscribe().
|
||||
* Does NOT use any react-router hooks — it's above all RouterProviders.
|
||||
@@ -24,50 +109,61 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
const [pathname, setPathname] = useState(
|
||||
router?.state.location.pathname ?? "/",
|
||||
);
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
setPathname(activeTab.router.state.location.pathname);
|
||||
return activeTab.router.subscribe((state) => {
|
||||
if (!router) {
|
||||
setPathname("/");
|
||||
return;
|
||||
}
|
||||
setPathname(router.state.location.pathname);
|
||||
return router.subscribe((state) => {
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId, router]);
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (path === "/login") {
|
||||
// DashboardGuard token expired — force back to login screen
|
||||
useAuthStore.getState().logout();
|
||||
return;
|
||||
}
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path);
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
active?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
active?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
tab?.router.navigate(-1);
|
||||
currentActiveTab()?.router.navigate(-1);
|
||||
},
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
store.switchWorkspace(slug, path);
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(tabId);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
@@ -77,6 +173,10 @@ export function DesktopNavigationProvider({
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
}
|
||||
|
||||
function currentActiveTab() {
|
||||
return getActiveTab(useTabStore.getState());
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
|
||||
* Subscribes to the tab's own router for up-to-date pathname.
|
||||
@@ -101,16 +201,29 @@ export function TabNavigationProvider({
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => router.navigate(path),
|
||||
replace: (path: string) => router.navigate(path, { replace: true }),
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const icon = resolveRouteIcon(path);
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
const newTabId = store.openTab(path, title ?? path, icon);
|
||||
store.setActiveTab(newTabId);
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
store.switchWorkspace(slug, path);
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useMatches,
|
||||
} from "react-router-dom";
|
||||
import type { RouteObject } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
@@ -14,19 +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 { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { Server } from "lucide-react";
|
||||
import { Download, 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";
|
||||
|
||||
/**
|
||||
@@ -59,77 +53,28 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorkspaceRoute() {
|
||||
const nav = useNavigation();
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root index route: resolves the URL-less `/` path to a concrete destination.
|
||||
*
|
||||
* Runs both on first login (App.tsx seeded the cache) and on app reopen
|
||||
* (AuthInitializer seeded the cache). Reading from React Query avoids
|
||||
* duplicate fetches across tabs — each tab's memory router hits this
|
||||
* component independently but the query is deduped.
|
||||
*
|
||||
* Sends first-time users without any workspace to /workspaces/new,
|
||||
* everyone else to their first workspace's issues page. Persisted tab
|
||||
* paths that already carry a workspace slug bypass this component
|
||||
* entirely.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
|
||||
|
||||
// Wait for the query to settle so we don't redirect to /workspaces/new
|
||||
// on the initial render before the seeded/fetched data arrives.
|
||||
if (!isFetched) return null;
|
||||
|
||||
const firstWorkspace = wsList?.[0];
|
||||
if (firstWorkspace) {
|
||||
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
|
||||
}
|
||||
return <Navigate to={paths.newWorkspace()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions shared by all tabs.
|
||||
*
|
||||
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
|
||||
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
|
||||
* slug to a workspace and syncing side-effects (api client, persist namespace,
|
||||
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
* Every tab path is workspace-scoped: `/{slug}/{route}/...`. Pre-workspace
|
||||
* flows (create workspace, accept invite) are NOT routes — they render as a
|
||||
* window-level overlay via `WindowOverlay`, dispatched by the navigation
|
||||
* adapter's transition-path interception. The `activeWorkspaceSlug` in the
|
||||
* tab store decides which workspace's tabs are visible in the TabBar;
|
||||
* workspace-less state (zero-workspace user) shows the overlay instead.
|
||||
*
|
||||
* The root index route stays as a harmless safety net. With per-workspace
|
||||
* tabs, nothing should construct a tab at `/` — but if one ever slips
|
||||
* through (malformed persisted state that dodges the migration, direct
|
||||
* router.navigate from unforeseen code), the index falls back to null
|
||||
* rather than 404; App.tsx's bootstrap repoints activeWorkspaceSlug on the
|
||||
* next render pass.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <PageShell />,
|
||||
children: [
|
||||
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
|
||||
// list from React Query cache (seeded by AuthInitializer on reopen
|
||||
// or App.tsx on deep-link login) and bounces to the first
|
||||
// workspace's issues page — or /workspaces/new if the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "workspaces/new",
|
||||
element: <NewWorkspaceRoute />,
|
||||
handle: { title: "Create Workspace" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{ index: true, element: null },
|
||||
{
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
@@ -168,7 +113,7 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
@@ -185,6 +130,12 @@ export const appRoutes: RouteObject[] = [
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
// createTabRouter transitively pulls in route modules that expect a browser
|
||||
// router context. For pure-function tests we stub it out.
|
||||
// router context. For pure store tests we stub it to a minimal disposable.
|
||||
const createTabRouterMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
dispose: vi.fn(),
|
||||
state: { location: { pathname: "/" } },
|
||||
navigate: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
})),
|
||||
);
|
||||
vi.mock("../routes", () => ({
|
||||
createTabRouter: vi.fn(() => ({ dispose: vi.fn() })),
|
||||
createTabRouter: createTabRouterMock,
|
||||
}));
|
||||
|
||||
import { sanitizeTabPath } from "./tab-store";
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
beforeEach(() => {
|
||||
createTabRouterMock.mockClear();
|
||||
useTabStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("sanitizeTabPath", () => {
|
||||
it("passes through root sentinel", () => {
|
||||
expect(sanitizeTabPath("/")).toBe("/");
|
||||
it("rejects the root sentinel — tabs must be workspace-scoped", () => {
|
||||
expect(sanitizeTabPath("/")).toBeNull();
|
||||
expect(sanitizeTabPath("")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes through global paths", () => {
|
||||
expect(sanitizeTabPath("/login")).toBe("/login");
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
|
||||
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
|
||||
it("silently rejects transition paths (no warn — navigation adapter intercepts them)", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBeNull();
|
||||
expect(sanitizeTabPath("/invite/abc")).toBeNull();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through valid workspace-scoped paths", () => {
|
||||
@@ -25,21 +44,181 @@ describe("sanitizeTabPath", () => {
|
||||
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
|
||||
});
|
||||
|
||||
it("rejects paths whose first segment is a reserved slug", () => {
|
||||
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
|
||||
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
|
||||
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/issues")).toBe("/");
|
||||
expect(sanitizeTabPath("/issues/abc-123")).toBe("/");
|
||||
expect(sanitizeTabPath("/settings")).toBe("/");
|
||||
expect(sanitizeTabPath("/issues")).toBeNull();
|
||||
expect(sanitizeTabPath("/settings")).toBeNull();
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through user slugs that happen to look path-like but aren't reserved", () => {
|
||||
// A workspace owner could legitimately pick "acme-issues" or
|
||||
// "project-x" as their slug — sanitize must not touch these.
|
||||
expect(sanitizeTabPath("/acme-issues/issues")).toBe("/acme-issues/issues");
|
||||
expect(sanitizeTabPath("/project-x/inbox")).toBe("/project-x/inbox");
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV1ToV2", () => {
|
||||
it("groups v1 flat tabs by workspace slug", () => {
|
||||
const v1 = {
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
{ id: "t3", path: "/butter/issues", title: "Issues", icon: "ListTodo" },
|
||||
],
|
||||
activeTabId: "t2",
|
||||
};
|
||||
const v2 = migrateV1ToV2(v1);
|
||||
expect(Object.keys(v2.byWorkspace).sort()).toEqual(["acme", "butter"]);
|
||||
expect(v2.byWorkspace.acme.tabs).toHaveLength(2);
|
||||
expect(v2.byWorkspace.butter.tabs).toHaveLength(1);
|
||||
expect(v2.byWorkspace.acme.activeTabId).toBe("t2");
|
||||
expect(v2.byWorkspace.butter.activeTabId).toBe("t3"); // first tab in group
|
||||
expect(v2.activeWorkspaceSlug).toBe("acme"); // contained v1.activeTabId
|
||||
});
|
||||
|
||||
it("drops tabs at root / transition / reserved-slug paths", () => {
|
||||
const v1 = {
|
||||
tabs: [
|
||||
{ id: "t1", path: "/", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/workspaces/new", title: "New", icon: "Plus" },
|
||||
{ id: "t3", path: "/invite/abc", title: "Invite", icon: "Mail" },
|
||||
{ id: "t4", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
],
|
||||
activeTabId: "t1",
|
||||
};
|
||||
const v2 = migrateV1ToV2(v1);
|
||||
expect(Object.keys(v2.byWorkspace)).toEqual(["acme"]);
|
||||
expect(v2.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
// v1.activeTabId was dropped; active falls back to first group's first tab.
|
||||
expect(v2.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v2.byWorkspace.acme.activeTabId).toBe("t4");
|
||||
});
|
||||
|
||||
it("handles empty v1 state gracefully", () => {
|
||||
const v2 = migrateV1ToV2({ tabs: [], activeTabId: "" });
|
||||
expect(v2.byWorkspace).toEqual({});
|
||||
expect(v2.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
|
||||
it("handles v1 with no tabs field (corrupted state)", () => {
|
||||
const v2 = migrateV1ToV2({});
|
||||
expect(v2.byWorkspace).toEqual({});
|
||||
expect(v2.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTabStore actions", () => {
|
||||
it("switchWorkspace creates a new group with a default tab on first entry", () => {
|
||||
useTabStore.getState().switchWorkspace("acme");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
|
||||
});
|
||||
|
||||
it("switchWorkspace without openPath restores the group's last active tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const acmeProjectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
store.setActiveTab(acmeProjectsId);
|
||||
|
||||
// Enter a different workspace then come back
|
||||
store.switchWorkspace("butter");
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("butter");
|
||||
|
||||
store.switchWorkspace("acme");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBe("acme");
|
||||
expect(s.byWorkspace.acme.activeTabId).toBe(acmeProjectsId);
|
||||
});
|
||||
|
||||
it("switchWorkspace with openPath dedupes into an existing tab with same path", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default /acme/issues
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
|
||||
store.switchWorkspace("acme", "/acme/issues");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(2); // no duplicate created
|
||||
const activeTab = s.byWorkspace.acme.tabs.find(
|
||||
(t) => t.id === s.byWorkspace.acme.activeTabId,
|
||||
);
|
||||
expect(activeTab?.path).toBe("/acme/issues");
|
||||
});
|
||||
|
||||
it("switchWorkspace with openPath not matching any tab adds a new tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("acme", "/acme/issues/bug-42");
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
|
||||
const activeTab = s.byWorkspace.acme.tabs.find(
|
||||
(t) => t.id === s.byWorkspace.acme.activeTabId,
|
||||
);
|
||||
expect(activeTab?.path).toBe("/acme/issues/bug-42");
|
||||
});
|
||||
|
||||
it("openTab dedupes by path within the active workspace", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const id1 = store.openTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const id2 = store.openTab("/acme/projects", "Projects", "FolderKanban");
|
||||
expect(id1).toBe(id2);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs).toHaveLength(2); // default + projects
|
||||
});
|
||||
|
||||
it("closeTab on the last tab in a workspace reseeds the default tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
store.closeTab(onlyTabId);
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
|
||||
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
|
||||
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("butter");
|
||||
store.switchWorkspace("acme");
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
|
||||
// Admin removed the user from acme
|
||||
store.validateWorkspaceSlugs(new Set(["butter"]));
|
||||
const s = useTabStore.getState();
|
||||
expect(Object.keys(s.byWorkspace)).toEqual(["butter"]);
|
||||
expect(s.activeWorkspaceSlug).toBe("butter");
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs sets activeWorkspaceSlug to null when all groups are dropped", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.validateWorkspaceSlugs(new Set());
|
||||
const s = useTabStore.getState();
|
||||
expect(s.byWorkspace).toEqual({});
|
||||
expect(s.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
|
||||
it("reset wipes the whole store", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("butter");
|
||||
store.reset();
|
||||
const s = useTabStore.getState();
|
||||
expect(s.activeWorkspaceSlug).toBeNull();
|
||||
expect(s.byWorkspace).toEqual({});
|
||||
});
|
||||
|
||||
it("setActiveTab across workspaces also flips the active workspace", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.switchWorkspace("butter");
|
||||
const acmeTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
store.setActiveTab(acmeTabId);
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
||||
import { isReservedSlug } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createTabRouter } from "../routes";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
/** Every tab path is workspace-scoped: `/{workspaceSlug}/{route}/...`. */
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
@@ -21,33 +22,77 @@ export interface Tab {
|
||||
historyLength: number;
|
||||
}
|
||||
|
||||
interface TabStore {
|
||||
export interface WorkspaceTabGroup {
|
||||
tabs: Tab[];
|
||||
/** Must be a valid tab.id in `tabs`; the empty-tabs state is transient only. */
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
/** Open a background tab. Deduplicates by path. Returns the tab id. */
|
||||
interface TabStore {
|
||||
/**
|
||||
* The workspace currently visible in the TabBar / TabContent. Null in three
|
||||
* cases:
|
||||
* - Fresh install, before any workspace exists or is selected.
|
||||
* - Logged-out state (reset() wipes it).
|
||||
* - Every workspace the user had access to got deleted / revoked.
|
||||
* When null, TabContent renders nothing and the WindowOverlay takes over.
|
||||
*/
|
||||
activeWorkspaceSlug: string | null;
|
||||
|
||||
/**
|
||||
* Tab groups keyed by workspace slug. Each slug maps to an independent
|
||||
* (tabs, activeTabId) pair; switching workspaces swaps the visible set
|
||||
* without affecting any other group. Cross-workspace tab leakage — the
|
||||
* bug that drove this refactor — is impossible by construction because
|
||||
* there is no global tab array anymore.
|
||||
*/
|
||||
byWorkspace: Record<string, WorkspaceTabGroup>;
|
||||
|
||||
/**
|
||||
* Switch to a workspace.
|
||||
* - If the group doesn't exist yet, create it with a single default tab.
|
||||
* - If `openPath` is given, find a tab with that exact path and activate
|
||||
* it; otherwise add a new tab and activate it.
|
||||
* - If `openPath` is omitted, restore the group's last active tab
|
||||
* (VSCode / Slack behavior — workspaces resume where you left off).
|
||||
*/
|
||||
switchWorkspace: (slug: string, openPath?: string) => void;
|
||||
/** Open-or-activate (dedupes by path) a tab in the active workspace. */
|
||||
openTab: (path: string, title: string, icon: string) => string;
|
||||
/** Always create a new tab (no dedup). Returns the tab id. */
|
||||
/** Always creates a new tab (no dedupe) in the active workspace. */
|
||||
addTab: (path: string, title: string, icon: string) => string;
|
||||
/** Close a tab. Disposes router. */
|
||||
/**
|
||||
* Close a tab. Finds it across all workspaces (callers like the X button
|
||||
* only know the tab id, not the owning workspace). If this is the last
|
||||
* tab in its workspace, reseed a default tab so the invariant
|
||||
* "every live workspace has at least one tab" holds.
|
||||
*/
|
||||
closeTab: (tabId: string) => void;
|
||||
/** Switch to a tab by id. */
|
||||
/**
|
||||
* Activate a tab. Finds it across all workspaces. Sets both the owning
|
||||
* workspace as active and that group's activeTabId; needed for any code
|
||||
* path that "jumps" to a tab belonging to a non-active workspace.
|
||||
*/
|
||||
setActiveTab: (tabId: string) => void;
|
||||
/** Update a tab's metadata (path, title, icon — partial). */
|
||||
/** Patch metadata of a tab (router-sync, title-sync). Finds across groups. */
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Update a tab's history tracking. */
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
|
||||
/** Reorder within the active workspace's group only. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Reset any tab whose first path segment references a workspace slug the
|
||||
* current user doesn't have access to. Called after login + workspace list
|
||||
* is populated (and on every subsequent list change, e.g. realtime
|
||||
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
|
||||
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
|
||||
* are untouched.
|
||||
* After the workspace list arrives/changes (login, realtime delete), drop
|
||||
* any tab group whose slug is no longer in `validSlugs`, and repoint
|
||||
* `activeWorkspaceSlug` if it pointed at one of the dropped groups.
|
||||
*/
|
||||
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
|
||||
/**
|
||||
* Wipe everything. Called from logout so the next user doesn't inherit
|
||||
* the prior user's tabs. Zustand persist only writes to localStorage;
|
||||
* clearing the storage key alone would leave this live store intact
|
||||
* until app restart.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,232 +112,594 @@ const ROUTE_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a route icon from a pathname. Title is NOT determined here — it
|
||||
* comes from document.title.
|
||||
* Resolve a route icon from a pathname.
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
|
||||
* Tab paths are always workspace-scoped: `/{slug}/{route}/...`, so the route
|
||||
* segment lives at index 1. Pre-workspace flows (create, invite) are rendered
|
||||
* by the window overlay, never as tabs.
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
* Title is NOT determined here — it comes from document.title.
|
||||
*/
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegment = isGlobalPath(pathname)
|
||||
? (segments[0] ?? "")
|
||||
: (segments[1] ?? "");
|
||||
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
|
||||
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
|
||||
}
|
||||
|
||||
/** Extract the leading workspace slug from a path, or null if the path
|
||||
* isn't workspace-scoped (global path, root, or empty). */
|
||||
function extractWorkspaceSlug(path: string): string | null {
|
||||
const first = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!first) return null;
|
||||
if (isReservedSlug(first)) return null;
|
||||
return first;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path sanitization (defensive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Defensive: catch paths that don't belong in the tab store.
|
||||
*
|
||||
* Two kinds of rejects:
|
||||
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
|
||||
* pre-workspace flows rendered by the window overlay on desktop, not
|
||||
* tab routes. The navigation adapter normally intercepts these before
|
||||
* they reach the store; this guard catches older persisted state.
|
||||
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
|
||||
* was constructed without the workspace prefix. The router would
|
||||
* interpret `issues` as a workspace slug → NoAccessPage.
|
||||
*
|
||||
* Returns null for rejects (caller decides how to recover — usually by
|
||||
* dropping the tab or substituting a default). Unlike the prior design,
|
||||
* there is no root "/" sentinel — tabs are always scoped.
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string | null {
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (!firstSegment) return null;
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// Don't log for known transition paths — these are legitimate inputs
|
||||
// at the interception boundary (older persisted state or stale callers).
|
||||
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
|
||||
if (!isTransition) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Dropping.`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createId(): string {
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
return {
|
||||
id: createId(),
|
||||
path,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Default entry point for a workspace — its issues list. */
|
||||
function defaultPathFor(slug: string): string {
|
||||
return `/${slug}/issues`;
|
||||
}
|
||||
|
||||
function defaultTabFor(slug: string): Tab {
|
||||
const path = defaultPathFor(slug);
|
||||
return makeTab(path, "Issues", resolveRouteIcon(path));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function findTabLocation(
|
||||
byWorkspace: Record<string, WorkspaceTabGroup>,
|
||||
tabId: string,
|
||||
): { slug: string; group: WorkspaceTabGroup; index: number } | null {
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
const group = byWorkspace[slug];
|
||||
const index = group.tabs.findIndex((t) => t.id === tabId);
|
||||
if (index >= 0) return { slug, group, index };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sentinel path for new tabs with no explicit destination. The tab store is
|
||||
* workspace-implicit — it doesn't know which workspace is active, so it can't
|
||||
* build a `/:slug/issues` path itself. Instead we hand off to the router: `/`
|
||||
* matches the top-level index route, which redirects to the workspace default
|
||||
* (slug-aware redirect lives in routes.tsx / App.tsx).
|
||||
*
|
||||
* `title` and `icon` on the placeholder tab get overwritten by
|
||||
* useTabRouterSync + useActiveTitleSync once the redirect resolves.
|
||||
*/
|
||||
const DEFAULT_PATH = "/";
|
||||
|
||||
function createId(): string {
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: catch tab paths that were constructed without a workspace slug
|
||||
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
|
||||
* paths would get matched as `workspaceSlug="issues"` by the router and
|
||||
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
|
||||
* a valid workspace).
|
||||
*
|
||||
* Passes through:
|
||||
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
|
||||
* - workspace-scoped paths whose first segment is not a reserved word
|
||||
*
|
||||
* Rejects (and rewrites to "/"):
|
||||
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
|
||||
* means the caller forgot to prefix the workspace. Logs a warning so the
|
||||
* buggy call site is easy to find.
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string {
|
||||
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
const safePath = sanitizeTabPath(path);
|
||||
return {
|
||||
id: createId(),
|
||||
path: safePath,
|
||||
title,
|
||||
icon,
|
||||
router: createTabRouter(safePath),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
|
||||
export const useTabStore = create<TabStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
tabs: [initialTab],
|
||||
activeTabId: initialTab.id,
|
||||
activeWorkspaceSlug: null,
|
||||
byWorkspace: {},
|
||||
|
||||
openTab(path, title, icon) {
|
||||
const { tabs } = get();
|
||||
const existing = tabs.find((t) => t.path === path);
|
||||
if (existing) return existing.id;
|
||||
switchWorkspace(slug, openPath) {
|
||||
// Defensive no-op if slug is empty/invalid — callers like the
|
||||
// NavigationAdapter's path-parser should already have filtered
|
||||
// these, but belt-and-braces keeps garbage out of the store.
|
||||
if (!slug) return;
|
||||
const { byWorkspace } = get();
|
||||
const existing = byWorkspace[slug];
|
||||
|
||||
const tab = makeTab(path, title, icon);
|
||||
set({ tabs: [...tabs, tab] });
|
||||
return tab.id;
|
||||
},
|
||||
// Decide the desired active path for this workspace.
|
||||
const desiredPath = openPath ?? (existing ? null : defaultPathFor(slug));
|
||||
|
||||
addTab(path, title, icon) {
|
||||
const tab = makeTab(path, title, icon);
|
||||
set((s) => ({ tabs: [...s.tabs, tab] }));
|
||||
return tab.id;
|
||||
},
|
||||
if (!existing) {
|
||||
// First time entering this workspace — create the group.
|
||||
const seedPath =
|
||||
desiredPath && sanitizeTabPath(desiredPath) === desiredPath
|
||||
? desiredPath
|
||||
: defaultPathFor(slug);
|
||||
const tab = makeTab(seedPath, "Issues", resolveRouteIcon(seedPath));
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: [tab], activeTabId: tab.id },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
closeTab(tabId) {
|
||||
const { tabs, activeTabId } = get();
|
||||
// Workspace already has tabs. Either dedupe into an existing tab or
|
||||
// add a new one (when openPath was supplied and no tab matches it).
|
||||
if (desiredPath) {
|
||||
const clean = sanitizeTabPath(desiredPath);
|
||||
if (clean) {
|
||||
const match = existing.tabs.find((t) => t.path === clean);
|
||||
if (match) {
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...existing, activeTabId: match.id },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const tab = makeTab(clean, "Issues", resolveRouteIcon(clean));
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: {
|
||||
tabs: [...existing.tabs, tab],
|
||||
activeTabId: tab.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const closingTab = tabs.find((t) => t.id === tabId);
|
||||
// No openPath (or openPath was rejected) — just restore the group.
|
||||
set({ activeWorkspaceSlug: slug });
|
||||
},
|
||||
|
||||
// Never close the last tab — replace with default
|
||||
if (tabs.length === 1) {
|
||||
closingTab?.router.dispose();
|
||||
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
set({ tabs: [fresh], activeTabId: fresh.id });
|
||||
return;
|
||||
}
|
||||
openTab(path, title, icon) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
const clean = sanitizeTabPath(path);
|
||||
if (!activeWorkspaceSlug || !clean) return "";
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return "";
|
||||
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
const existing = group.tabs.find((t) => t.path === clean);
|
||||
if (existing) {
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: { ...group, activeTabId: existing.id },
|
||||
},
|
||||
});
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
closingTab?.router.dispose();
|
||||
const next = tabs.filter((t) => t.id !== tabId);
|
||||
const tab = makeTab(clean, title, icon);
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
tabs: [...group.tabs, tab],
|
||||
activeTabId: group.activeTabId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
const newActive = next[Math.min(idx, next.length - 1)];
|
||||
set({ tabs: next, activeTabId: newActive.id });
|
||||
} else {
|
||||
set({ tabs: next });
|
||||
}
|
||||
},
|
||||
addTab(path, title, icon) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
const clean = sanitizeTabPath(path);
|
||||
if (!activeWorkspaceSlug || !clean) return "";
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return "";
|
||||
|
||||
setActiveTab(tabId) {
|
||||
set({ activeTabId: tabId });
|
||||
},
|
||||
const tab = makeTab(clean, title, icon);
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
tabs: [...group.tabs, tab],
|
||||
activeTabId: group.activeTabId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return tab.id;
|
||||
},
|
||||
|
||||
updateTab(tabId, patch) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, ...patch } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
closeTab(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
|
||||
updateTabHistory(tabId, historyIndex, historyLength) {
|
||||
set((s) => ({
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
|
||||
),
|
||||
}));
|
||||
},
|
||||
const closing = group.tabs[index];
|
||||
closing.router.dispose();
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
|
||||
},
|
||||
if (group.tabs.length === 1) {
|
||||
// Last tab in this workspace — reseed a default so the workspace
|
||||
// always has at least one tab. Closing a workspace as an explicit
|
||||
// action is a separate concern (Leave/Delete in Settings).
|
||||
const fresh = defaultTabFor(slug);
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: [fresh], activeTabId: fresh.id },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { tabs } = get();
|
||||
let changed = false;
|
||||
const nextTabs = tabs.map((t) => {
|
||||
// Skip tabs on non-workspace-scoped paths — nothing to validate.
|
||||
if (t.path === "/" || isGlobalPath(t.path)) return t;
|
||||
const nextTabs = group.tabs.filter((t) => t.id !== tabId);
|
||||
const nextActiveTabId =
|
||||
group.activeTabId === tabId
|
||||
? nextTabs[Math.min(index, nextTabs.length - 1)].id
|
||||
: group.activeTabId;
|
||||
|
||||
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (validSlugs.has(firstSegment)) return t;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
// Stale slug: dispose the old router and replace with a fresh one
|
||||
// pointing at `/`. IndexRedirect will send the tab to a valid
|
||||
// workspace (or /workspaces/new if the user now has none).
|
||||
changed = true;
|
||||
t.router.dispose();
|
||||
return {
|
||||
...t,
|
||||
path: DEFAULT_PATH,
|
||||
title: "Issues",
|
||||
icon: resolveRouteIcon(DEFAULT_PATH),
|
||||
router: createTabRouter(DEFAULT_PATH),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
setActiveTab(tabId) {
|
||||
const { byWorkspace, activeWorkspaceSlug } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group } = hit;
|
||||
if (slug === activeWorkspaceSlug && group.activeTabId === tabId) return;
|
||||
set({
|
||||
activeWorkspaceSlug: slug,
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, activeTabId: tabId },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
if (!changed) return;
|
||||
set({ tabs: nextTabs });
|
||||
},
|
||||
updateTab(tabId, patch) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const next: Tab = { ...current, ...patch };
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
updateTabHistory(tabId, historyIndex, historyLength) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const next: Tab = { ...current, historyIndex, historyLength };
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
moveTab(fromIndex, toIndex) {
|
||||
if (fromIndex === toIndex) return;
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
...group,
|
||||
tabs: arrayMove(group.tabs, fromIndex, toIndex),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
let changed = false;
|
||||
const nextByWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
if (validSlugs.has(slug)) {
|
||||
nextByWorkspace[slug] = byWorkspace[slug];
|
||||
} else {
|
||||
changed = true;
|
||||
for (const t of byWorkspace[slug].tabs) t.router.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
let nextActive = activeWorkspaceSlug;
|
||||
if (nextActive && !validSlugs.has(nextActive)) {
|
||||
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
|
||||
},
|
||||
|
||||
reset() {
|
||||
const { byWorkspace } = get();
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
for (const t of byWorkspace[slug].tabs) t.router.dispose();
|
||||
}
|
||||
set({ activeWorkspaceSlug: null, byWorkspace: {} });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 1,
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
}
|
||||
return persistedState as V2Persisted;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
tabs: state.tabs.map(
|
||||
({ router, historyIndex, historyLength, ...rest }) => rest,
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
byWorkspace: Object.fromEntries(
|
||||
Object.entries(state.byWorkspace).map(([slug, group]) => [
|
||||
slug,
|
||||
{
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map(
|
||||
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
|
||||
rest,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
activeTabId: state.activeTabId,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as
|
||||
| Pick<TabStore, "tabs" | "activeTabId">
|
||||
| undefined;
|
||||
if (!persisted?.tabs?.length) return currentState;
|
||||
const persisted = persistedState as Partial<V2Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const tabs: Tab[] = persisted.tabs.map((tab) => {
|
||||
// Sanitize persisted paths against reserved-slug rules. Catches
|
||||
// both pre-refactor paths like "/issues/abc" (missing workspace
|
||||
// slug) and any other malformed paths that slipped past the
|
||||
// write-time guard. The defense across makeTab + merge + runtime
|
||||
// validate ensures stale or malformed paths never reach the
|
||||
// router.
|
||||
const path = sanitizeTabPath(tab.path);
|
||||
return {
|
||||
...tab,
|
||||
path,
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
};
|
||||
});
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
for (const [slug, pGroup] of Object.entries(persisted.byWorkspace)) {
|
||||
const tabs: Tab[] = [];
|
||||
for (const pTab of pGroup.tabs) {
|
||||
const clean = sanitizeTabPath(pTab.path);
|
||||
// Persisted path may have come from a stale version or a
|
||||
// manual edit. Drop rather than rewrite so we never silently
|
||||
// put users on a path that doesn't match the group's slug.
|
||||
if (!clean || extractWorkspaceSlug(clean) !== slug) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] dropping persisted tab "${pTab.path}" from ` +
|
||||
`group "${slug}" — path/slug mismatch`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tabs.push({
|
||||
id: pTab.id,
|
||||
path: clean,
|
||||
title: pTab.title,
|
||||
icon: pTab.icon,
|
||||
router: createTabRouter(clean),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
});
|
||||
}
|
||||
if (tabs.length === 0) continue;
|
||||
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
|
||||
? pGroup.activeTabId
|
||||
: tabs[0].id;
|
||||
byWorkspace[slug] = { tabs, activeTabId };
|
||||
}
|
||||
|
||||
// Validate activeTabId — fall back to first tab if stale
|
||||
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
|
||||
? persisted.activeTabId
|
||||
: tabs[0].id;
|
||||
const activeWorkspaceSlug =
|
||||
persisted.activeWorkspaceSlug && byWorkspace[persisted.activeWorkspaceSlug]
|
||||
? persisted.activeWorkspaceSlug
|
||||
: (Object.keys(byWorkspace)[0] ?? null);
|
||||
|
||||
return { ...currentState, tabs, activeTabId };
|
||||
return { ...currentState, byWorkspace, activeWorkspaceSlug };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted shapes (for migration)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface V1Tab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface V1Persisted {
|
||||
tabs: V1Tab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V2PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface V2PersistedGroup {
|
||||
tabs: V2PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V2Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
for (const tab of oldTabs) {
|
||||
const slug = extractWorkspaceSlug(tab.path);
|
||||
if (!slug) continue; // drop root / global-path tabs
|
||||
if (!byWorkspace[slug]) byWorkspace[slug] = { tabs: [], activeTabId: "" };
|
||||
byWorkspace[slug].tabs.push({
|
||||
id: tab.id,
|
||||
path: tab.path,
|
||||
title: tab.title,
|
||||
icon: tab.icon,
|
||||
});
|
||||
}
|
||||
|
||||
// Each group needs a valid activeTabId. Prefer the one from v1 if it
|
||||
// landed in this group; otherwise fall back to the first tab.
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
const group = byWorkspace[slug];
|
||||
const hasOldActive = group.tabs.some((t) => t.id === v1.activeTabId);
|
||||
group.activeTabId = hasOldActive
|
||||
? (v1.activeTabId as string)
|
||||
: group.tabs[0].id;
|
||||
}
|
||||
|
||||
// Active workspace: whichever group inherited the v1 activeTab, falling
|
||||
// back to the first group we created (arbitrary but deterministic given
|
||||
// Object.keys iteration order on string keys).
|
||||
let activeWorkspaceSlug: string | null = null;
|
||||
for (const slug of Object.keys(byWorkspace)) {
|
||||
if (byWorkspace[slug].activeTabId === v1.activeTabId) {
|
||||
activeWorkspaceSlug = slug;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!activeWorkspaceSlug) {
|
||||
activeWorkspaceSlug = Object.keys(byWorkspace)[0] ?? null;
|
||||
}
|
||||
|
||||
return { activeWorkspaceSlug, byWorkspace };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors (convenience hooks)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pure non-hook helper — useful from event handlers / effects that already
|
||||
* need `.getState()`. For React subscriptions prefer the stable selectors
|
||||
* below.
|
||||
*/
|
||||
export function getActiveTab(s: TabStore): Tab | null {
|
||||
if (!s.activeWorkspaceSlug) return null;
|
||||
const group = s.byWorkspace[s.activeWorkspaceSlug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active workspace's tab group, or null when no workspace is active.
|
||||
*
|
||||
* Zustand compares selector returns with `Object.is`. Because `updateTab`
|
||||
* / `updateTabHistory` replace the group object on every router tick
|
||||
* (immutable update), this selector returns a new reference on every
|
||||
* router event — that's fine for TabBar which needs to observe tab-list
|
||||
* changes, but don't use this selector from components that only care
|
||||
* about one primitive (use `useActiveTabHistory` / `useActiveTabRouter`
|
||||
* instead).
|
||||
*/
|
||||
export function useActiveGroup(): WorkspaceTabGroup | null {
|
||||
return useTabStore((s) =>
|
||||
s.activeWorkspaceSlug ? (s.byWorkspace[s.activeWorkspaceSlug] ?? null) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active tab id + active workspace slug as a compact pair. Both primitives
|
||||
* are stable across unrelated store updates — e.g. an inactive tab's
|
||||
* router tick doesn't churn these, so consumers don't re-render.
|
||||
*
|
||||
* Useful anywhere you'd previously have reached for `useActiveTab()` and
|
||||
* only needed the identity (for memoization, effect deps, ipc).
|
||||
*/
|
||||
export function useActiveTabIdentity(): { slug: string | null; tabId: string | null } {
|
||||
const slug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const tabId = useTabStore((s) =>
|
||||
s.activeWorkspaceSlug
|
||||
? (s.byWorkspace[s.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
);
|
||||
return { slug, tabId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Active tab's router — a stable reference across tab updates, because
|
||||
* routers are created once per tab and never replaced by `updateTab`.
|
||||
* Subscribers only re-render when the active tab *changes*, not on
|
||||
* router events within the current tab.
|
||||
*/
|
||||
export function useActiveTabRouter(): DataRouter | null {
|
||||
return useTabStore((s) => getActiveTab(s)?.router ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* History tracking for the active tab as primitives. Subscribers re-render
|
||||
* only when the numeric index / length change (i.e. on actual navigations),
|
||||
* not on unrelated store updates.
|
||||
*/
|
||||
export function useActiveTabHistory(): {
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
} {
|
||||
const historyIndex = useTabStore((s) => getActiveTab(s)?.historyIndex ?? 0);
|
||||
const historyLength = useTabStore((s) => getActiveTab(s)?.historyLength ?? 1);
|
||||
return { historyIndex, historyLength };
|
||||
}
|
||||
|
||||
30
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
30
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: pre-workspace flows that are NOT pages
|
||||
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
|
||||
* auto-redirect, or deep link; rendered above the tab system as a full-window
|
||||
* takeover.
|
||||
*
|
||||
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
|
||||
* desktop the URL is invisible to users — routes are an implementation detail
|
||||
* of the tab system. Representing transitions as routes meant tabs tried to
|
||||
* persist them, TabBar rendered on top, and invite deep-linking had no clean
|
||||
* dispatch target. Modeling them as application state removes all three.
|
||||
*/
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
overlay: WindowOverlay | null;
|
||||
open: (overlay: WindowOverlay) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
|
||||
overlay: null,
|
||||
open: (overlay) => set({ overlay }),
|
||||
close: () => set({ overlay: null }),
|
||||
}));
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
|
||||
Multica Documentation
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg text-fd-muted-foreground">
|
||||
The open-source managed agents platform. Turn coding agents into real
|
||||
teammates — assign tasks, track progress, compound skills.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="/docs"
|
||||
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/multica-ai/multica"
|
||||
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
@@ -29,19 +30,20 @@ export default async function Page(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
export function generateStaticParams() {
|
||||
return source.generateParams().filter((p) => p.slug.length > 0);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates(params.slug),
|
||||
};
|
||||
}
|
||||
118
apps/docs/app/[lang]/layout.tsx
Normal file
118
apps/docs/app/[lang]/layout.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import "../global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { uiTranslations, localeLabels } from "@/lib/translations";
|
||||
import { DocsSettings } from "@/components/docs-settings";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
fallback: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans CJK SC",
|
||||
"sans-serif",
|
||||
],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
|
||||
// Editorial serif used for headings and showpiece elements. Italic style is
|
||||
// deliberately NOT loaded — italic in CJK is a synthetic slant that breaks
|
||||
// glyph design. Emphasis in docs is carried by brand color + weight, never
|
||||
// font-style. Mirrors apps/web/app/layout.tsx for the upright family.
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
style: ["normal"],
|
||||
variable: "--font-serif",
|
||||
fallback: [
|
||||
"ui-serif",
|
||||
"Iowan Old Style",
|
||||
"Apple Garamond",
|
||||
"Baskerville",
|
||||
"Times New Roman",
|
||||
"serif",
|
||||
],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Multica Docs",
|
||||
default: "Multica Docs",
|
||||
},
|
||||
description:
|
||||
"Documentation for Multica — the open-source managed agents platform.",
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = (i18n.languages as readonly string[]).includes(rawLang)
|
||||
? (rawLang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
const locales = i18n.languages.map((l) => ({
|
||||
locale: l,
|
||||
name: localeLabels[l],
|
||||
}));
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang}
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"antialiased",
|
||||
inter.variable,
|
||||
geistMono.variable,
|
||||
sourceSerif.variable,
|
||||
)}
|
||||
>
|
||||
<body className="font-sans">
|
||||
<RootProvider
|
||||
i18n={{
|
||||
locale: lang,
|
||||
locales,
|
||||
translations: uiTranslations[lang],
|
||||
}}
|
||||
search={{ options: { api: "/docs/api/search" } }}
|
||||
>
|
||||
<DocsLayout
|
||||
tree={source.getPageTree(lang)}
|
||||
// Suppress Fumadocs's default sidebar-footer icons (theme +
|
||||
// language + search). Our custom <DocsSettings> is mounted as
|
||||
// the sidebar footer instead — two labelled buttons, not three
|
||||
// icons.
|
||||
themeSwitch={{ enabled: false }}
|
||||
searchToggle={{ enabled: false }}
|
||||
sidebar={{ footer: <DocsSettings locale={lang} /> }}
|
||||
{...baseOptions}
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
18
apps/docs/app/[lang]/not-found.tsx
Normal file
18
apps/docs/app/[lang]/not-found.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 py-24 text-center">
|
||||
<h1 className="text-3xl font-semibold">Page not found</h1>
|
||||
<p className="text-fd-muted-foreground">
|
||||
The page you are looking for doesn't exist.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center rounded-md bg-fd-primary px-4 py-2 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
|
||||
>
|
||||
Back to docs
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
84
apps/docs/app/[lang]/page.tsx
Normal file
84
apps/docs/app/[lang]/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { DocsPage, DocsBody } from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { DocsHero } from "@/components/hero";
|
||||
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { homeCopy } from "@/lib/translations";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
// A layout's `generateStaticParams` does NOT cascade — every page that
|
||||
// wants SSG must declare its own. Without this, both `/docs/` and
|
||||
// `/docs/zh` (the busiest URLs on the site) render dynamically on every
|
||||
// request.
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = asLang(rawLang);
|
||||
const page = source.getPage([], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const copy = homeCopy[lang];
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsHero
|
||||
eyebrow={copy.eyebrow}
|
||||
title={
|
||||
<>
|
||||
{copy.titleLead}
|
||||
<em className="font-medium not-italic text-[var(--primary)]">
|
||||
{copy.titleAccent}
|
||||
</em>
|
||||
</>
|
||||
}
|
||||
subtitle={page.data.description}
|
||||
/>
|
||||
<Byline items={[...copy.byline]} />
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = asLang(rawLang);
|
||||
const page = source.getPage([], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates([]),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,32 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
// Orama doesn't ship a Chinese tokenizer and its built-in English regex
|
||||
// strips Han characters entirely, so `locale=zh` would either return empty
|
||||
// results or throw. Tokenize CJK input character-by-character and keep
|
||||
// Latin/digit runs whole — gives serviceable recall for Chinese docs while
|
||||
// letting Romanized terms (product names, CLI commands) still match.
|
||||
function tokenizeCJK(raw: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const regex = /[一-鿿㐀-䶿]|[A-Za-z0-9]+/g;
|
||||
const lower = raw.toLowerCase();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(lower)) !== null) {
|
||||
tokens.push(match[0]);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export const { GET } = createFromSource(source, {
|
||||
localeMap: {
|
||||
zh: {
|
||||
components: {
|
||||
tokenizer: {
|
||||
language: "english",
|
||||
normalizationCache: new Map(),
|
||||
tokenize: tokenizeCJK,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,679 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
@import "../../../packages/ui/styles/tokens.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@source "../../../packages/ui/**/*.{ts,tsx}";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Multica Docs — editorial visual identity (v2)
|
||||
*
|
||||
* Docs site is intentionally distinct from the product app: warm-paper
|
||||
* background, editorial serif headings (Source Serif 4), indigo accent,
|
||||
* ruled dividers. Product app keeps its cool-gray dense Linear-style; docs
|
||||
* reads like a literary publication. Same split as Stripe, Cursor, Linear.
|
||||
*
|
||||
* Implementation: docs-scoped token override on top of Multica tokens
|
||||
* (whose @theme inline references read --background / --foreground / etc
|
||||
* at runtime, so re-pointing the vars cascades through fumadocs's full
|
||||
* --color-fd-* bridge below).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--fd-page-width: 1080px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editorial palette — light
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--background: oklch(0.972 0.003 85); /* near-white, faint warm — matches landing #f7f7f5 */
|
||||
--foreground: oklch(0.182 0.012 50); /* warm ink */
|
||||
--muted: oklch(0.955 0.006 85); /* hairline, slightly warmer than bg */
|
||||
--muted-foreground: oklch(0.482 0.012 65); /* warm muted */
|
||||
--card: oklch(0.99 0.002 85); /* paper — near white */
|
||||
--card-foreground: oklch(0.182 0.012 50);
|
||||
--popover: oklch(0.99 0.002 85);
|
||||
--popover-foreground: oklch(0.182 0.012 50);
|
||||
--primary: oklch(0.55 0.16 255); /* Multica brand */
|
||||
--primary-foreground: oklch(0.985 0.008 85);
|
||||
--secondary: oklch(0.945 0.012 85);
|
||||
--secondary-foreground: oklch(0.182 0.012 50);
|
||||
--accent: oklch(0.945 0.022 255); /* brand soft wash */
|
||||
--accent-foreground: oklch(0.46 0.16 255); /* brand ink */
|
||||
--border: oklch(0.91 0.014 85); /* ruled lines */
|
||||
--input: oklch(0.91 0.014 85);
|
||||
--ring: oklch(0.55 0.16 255);
|
||||
--sidebar: oklch(0.99 0.002 85); /* paper — same as card */
|
||||
--sidebar-foreground: oklch(0.182 0.012 50);
|
||||
--sidebar-accent: oklch(0.945 0.006 85); /* subtle cream, hover/active fill */
|
||||
--sidebar-accent-foreground: oklch(0.182 0.012 50);
|
||||
--sidebar-border: oklch(0.91 0.014 85);
|
||||
|
||||
/* Docs-only extras (not bridged to fumadocs slots) */
|
||||
--docs-rule: oklch(0.835 0.018 85); /* heavier rule */
|
||||
--docs-faint: oklch(0.72 0.018 75); /* faintest accent */
|
||||
--docs-code-bg: oklch(0.94 0.018 85); /* warm beige code surface */
|
||||
--docs-code-border: oklch(0.89 0.018 85);
|
||||
--docs-terminal-bg: oklch(0.18 0.012 50); /* terminal warm dark */
|
||||
--docs-terminal-fg: oklch(0.92 0.012 80);
|
||||
--docs-terminal-accent: oklch(0.65 0.16 255);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editorial palette — dark (warm dark, NOT Multica's cool dark)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.18 0.008 50);
|
||||
--foreground: oklch(0.95 0.012 85);
|
||||
--muted: oklch(0.22 0.008 50);
|
||||
--muted-foreground: oklch(0.65 0.012 75);
|
||||
--card: oklch(0.21 0.008 50);
|
||||
--card-foreground: oklch(0.95 0.012 85);
|
||||
--popover: oklch(0.22 0.008 50);
|
||||
--popover-foreground: oklch(0.95 0.012 85);
|
||||
--primary: oklch(0.7 0.15 255); /* Multica brand — dark */
|
||||
--primary-foreground: oklch(0.18 0.008 50);
|
||||
--secondary: oklch(0.24 0.008 50);
|
||||
--secondary-foreground: oklch(0.95 0.012 85);
|
||||
--accent: oklch(0.3 0.05 255); /* brand soft wash — dark */
|
||||
--accent-foreground: oklch(0.78 0.14 255); /* brand ink — dark */
|
||||
--border: oklch(0.28 0.012 50);
|
||||
--input: oklch(0.28 0.012 50);
|
||||
--ring: oklch(0.7 0.15 255);
|
||||
--sidebar: oklch(0.21 0.008 50);
|
||||
--sidebar-foreground: oklch(0.95 0.012 85);
|
||||
--sidebar-accent: oklch(0.26 0.01 50); /* warm neutral, hover/active fill — dark */
|
||||
--sidebar-accent-foreground: oklch(0.95 0.012 85);
|
||||
--sidebar-border: oklch(0.28 0.012 50);
|
||||
|
||||
--docs-rule: oklch(0.36 0.012 50);
|
||||
--docs-faint: oklch(0.42 0.012 50);
|
||||
--docs-code-bg: oklch(0.165 0.008 50);
|
||||
--docs-code-border: oklch(0.26 0.012 50);
|
||||
--docs-terminal-bg: oklch(0.155 0.012 50);
|
||||
--docs-terminal-fg: oklch(0.92 0.012 80);
|
||||
--docs-terminal-accent: oklch(0.78 0.14 255);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Fumadocs slot bridge
|
||||
*
|
||||
* Map fumadocs's --color-fd-* slots to our (now warm) Multica tokens.
|
||||
* @theme inline keeps the var() reference live so the cascade resolves
|
||||
* at runtime — same pattern tokens.css uses.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
@theme inline {
|
||||
--color-fd-background: var(--background);
|
||||
--color-fd-foreground: var(--foreground);
|
||||
--color-fd-muted: var(--muted);
|
||||
--color-fd-muted-foreground: var(--muted-foreground);
|
||||
--color-fd-popover: var(--popover);
|
||||
--color-fd-popover-foreground: var(--popover-foreground);
|
||||
--color-fd-card: var(--card);
|
||||
--color-fd-card-foreground: var(--card-foreground);
|
||||
--color-fd-border: var(--border);
|
||||
--color-fd-primary: var(--primary);
|
||||
--color-fd-primary-foreground: var(--primary-foreground);
|
||||
--color-fd-secondary: var(--secondary);
|
||||
--color-fd-secondary-foreground: var(--secondary-foreground);
|
||||
--color-fd-accent: var(--accent);
|
||||
--color-fd-accent-foreground: var(--accent-foreground);
|
||||
--color-fd-ring: var(--ring);
|
||||
}
|
||||
|
||||
/* Sidebar uses dedicated --sidebar-* tokens so it sits a hair off the main
|
||||
* canvas. Fumadocs renders it as #nd-sidebar (desktop) and
|
||||
* #nd-sidebar-mobile (mobile drawer); both IDs need the override. */
|
||||
#nd-sidebar,
|
||||
#nd-sidebar-mobile {
|
||||
--color-fd-background: var(--sidebar);
|
||||
--color-fd-foreground: var(--sidebar-foreground);
|
||||
--color-fd-muted: var(--sidebar-accent);
|
||||
--color-fd-muted-foreground: var(--sidebar-foreground);
|
||||
--color-fd-accent: var(--sidebar-accent);
|
||||
--color-fd-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-fd-border: var(--sidebar-border);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editorial typography
|
||||
*
|
||||
* Body keeps Inter for legibility (especially CJK where serif Latin clashes
|
||||
* with sans CJK). Headings switch to Source Serif 4 for the editorial
|
||||
* signature. Italic is intentionally avoided — Chinese italic is a CSS
|
||||
* synthetic slant against upright-designed glyphs and reads as broken.
|
||||
* Emphasis is carried by serif/sans contrast, brand color, and weight.
|
||||
*
|
||||
* Sizing:
|
||||
* - DocsHero h1 (welcome page only): 44px serif, brand-color em accent
|
||||
* - prose h1 (guide / reference pages): 30px serif
|
||||
* - prose h2: 26px serif (no italic)
|
||||
* - prose h3: 13px sans uppercase label
|
||||
* - body: 15.5px (kept from previous build — proven reading size for CN)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
article:has(.prose),
|
||||
.prose {
|
||||
font-size: 0.96875rem; /* 15.5px */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* DocsTitle h1 (Fumadocs hardcodes text-[1.75em] font-semibold — utility
|
||||
* specificity 0,1,0 beats plain article > h1 0,0,2; !important wins). */
|
||||
article > h1 {
|
||||
font-family: var(--font-serif), ui-serif, serif !important;
|
||||
font-size: 1.875rem !important; /* 30px guide-page heading */
|
||||
font-weight: 400 !important;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Lead paragraph below DocsTitle */
|
||||
article > p.text-lg {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.125rem; /* 18px serif lede */
|
||||
line-height: 1.55;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Paragraph rhythm */
|
||||
.prose :where(p):not(:where([class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.875rem;
|
||||
color: oklch(from var(--foreground) calc(l + 0.06) c h);
|
||||
}
|
||||
.prose :where(p):not(:where([class~="not-prose"] *)):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose :where(p) strong {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose :where(ul, ol) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.875rem; /* 30px */
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
/* Italic is avoided sitewide (Chinese italic = synthetic slant, looks broken).
|
||||
* Force any italicized element to non-italic in prose. Tailwind Typography
|
||||
* defaults blockquote to italic; we also undo it here. Emphasis is carried
|
||||
* by brand color + font-weight in headings, foreground+weight in body. */
|
||||
.prose em,
|
||||
.prose i,
|
||||
.prose cite,
|
||||
.prose blockquote,
|
||||
.prose blockquote p {
|
||||
font-style: normal;
|
||||
}
|
||||
.prose h1 em {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose p em,
|
||||
.prose li em {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.625rem; /* 26px */
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.3;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--foreground);
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
/* h3 = small uppercase sans label, ruled-bottom — v2 editorial signature */
|
||||
.prose h3 {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.8125rem; /* 13px */
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2.25em;
|
||||
margin-bottom: 0.75em;
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.0625rem; /* 17px */
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.4;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.375em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Description paragraph (fumadocs adds text-lg + muted) */
|
||||
.prose > p:first-of-type:has(+ *) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Links — Vercel-style hairline underline, reveal brand on hover
|
||||
*
|
||||
* Markdown-heavy prose can put 4+ inline links in a single sentence; a
|
||||
* permanent brand-color underline on every one turns the paragraph into
|
||||
* highlighter spam. The trick isn't "no underline" — it's underlining
|
||||
* in the hairline border color so the line exists but visually recedes.
|
||||
* Hover swaps both text and underline to brand color (no thickness
|
||||
* change) — the link "arrives" as a single color shift.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.prose a:not([data-card]):not(.not-prose) {
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
transition: text-decoration-color 150ms, color 150ms;
|
||||
}
|
||||
.prose a:not([data-card]):not(.not-prose):hover {
|
||||
color: var(--primary);
|
||||
text-decoration-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Callout already carries four visual signals (left brand bar, brand-wash
|
||||
* bg, uppercase NOTE label, body). Another decoration over-loads it — so
|
||||
* links inside a callout drop the underline entirely. Color shift on
|
||||
* hover is the full affordance. */
|
||||
.prose div.shadow-md:has(> [role="none"]) a:not([data-card]):not(.not-prose),
|
||||
.prose div.shadow-md:has(> [role="none"]) a:not([data-card]):not(.not-prose):hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Inline code — warm beige chip, accent-color text */
|
||||
.prose :not(pre) > code {
|
||||
background: var(--docs-code-bg);
|
||||
color: var(--accent-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
.prose :not(pre) > code::before,
|
||||
.prose :not(pre) > code::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.prose :where(ul, ol) > li {
|
||||
margin-top: 0.375em;
|
||||
margin-bottom: 0.375em;
|
||||
padding-inline-start: 0.375em;
|
||||
}
|
||||
.prose :where(ul) > li::marker {
|
||||
color: var(--docs-faint);
|
||||
content: "— ";
|
||||
font-family: var(--font-serif), serif;
|
||||
}
|
||||
.prose :where(ol) > li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Blockquote — editorial accent rule, serif voice */
|
||||
.prose blockquote {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.55;
|
||||
color: var(--foreground);
|
||||
border-inline-start-width: 2px;
|
||||
border-inline-start-color: var(--primary);
|
||||
padding-inline-start: 1.25em;
|
||||
margin-block: 1.5em;
|
||||
quotes: none;
|
||||
}
|
||||
.prose blockquote p::before,
|
||||
.prose blockquote p::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Tables — hairline below thead only, no outer frame (Stripe / Linear
|
||||
* docs convention). The heavier ink-color top rule v2 used on its API
|
||||
* reference block is intentionally not applied here — that treatment is
|
||||
* "this is a formal declaration"; regular guide tables want quiet. */
|
||||
.prose table {
|
||||
font-size: 0.9375em;
|
||||
border-collapse: collapse;
|
||||
margin-block: 1.5em;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.prose thead th {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
padding-block: 0.5rem 0.625rem;
|
||||
text-align: start;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.prose tbody td {
|
||||
padding-block: 0.875rem;
|
||||
}
|
||||
|
||||
/* HR — heavier ruled separator */
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--docs-rule);
|
||||
margin-block: 3em;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Callout — editorial 2px accent bar + soft accent wash
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) {
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 4px 4px 0 !important;
|
||||
background: var(--accent) !important;
|
||||
border: none !important;
|
||||
border-inline-start: 2px solid var(--primary) !important;
|
||||
padding: 0.875rem 1.125rem !important;
|
||||
gap: 0.625rem !important;
|
||||
align-items: flex-start;
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) > [role="none"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) > div:last-child > p {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) > div:last-child > div {
|
||||
color: var(--foreground) !important;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Cards — fallback editorial treatment for fumadocs's <Cards>/<Card>
|
||||
* (NumberedCards is the showpiece; this keeps non-showpiece pages on tone)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.prose [data-card]:not(.peer) {
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--card);
|
||||
padding: 1.125rem !important;
|
||||
transition: border-color 150ms, background-color 150ms !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer):hover {
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--card) !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) > div:first-child {
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--accent-foreground) !important;
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) > div:first-child svg {
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) h3 {
|
||||
font-family: var(--font-serif), serif !important;
|
||||
font-size: 1.125rem !important;
|
||||
font-weight: 500 !important;
|
||||
font-style: normal !important;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.25rem !important;
|
||||
margin-top: 0 !important;
|
||||
text-transform: none !important;
|
||||
border-bottom: none !important;
|
||||
padding-bottom: 0 !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) p {
|
||||
color: var(--muted-foreground) !important;
|
||||
line-height: 1.6;
|
||||
font-size: 0.9375rem !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Sidebar — editorial chrome
|
||||
*
|
||||
* Section headers: small uppercase sans label, ruled bottom border.
|
||||
* Items: muted-foreground at rest, foreground on hover.
|
||||
* Active: solid background fill (mirrors product app's app-sidebar.tsx —
|
||||
* data-active:bg-sidebar-accent / data-active:text-sidebar-accent-foreground).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#nd-sidebar p,
|
||||
#nd-sidebar-mobile p {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
height: auto;
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
padding-block: 0 0.375rem;
|
||||
padding-inline-start: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#nd-sidebar p:first-child,
|
||||
#nd-sidebar-mobile p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#nd-sidebar a[data-active],
|
||||
#nd-sidebar-mobile a[data-active] {
|
||||
height: auto;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.84375rem; /* 13.5px */
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.005em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#nd-sidebar a[data-active="false"],
|
||||
#nd-sidebar-mobile a[data-active="false"] {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
#nd-sidebar a[data-active="false"]:hover,
|
||||
#nd-sidebar-mobile a[data-active="false"]:hover {
|
||||
background: color-mix(in oklab, var(--sidebar-accent) 70%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Active — solid background fill, no left mark (matches product app) */
|
||||
#nd-sidebar a[data-active="true"],
|
||||
#nd-sidebar-mobile a[data-active="true"] {
|
||||
background: var(--sidebar-accent) !important;
|
||||
color: var(--sidebar-accent-foreground) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sidebar footer — drop the hard top rule. The scroll viewport already
|
||||
* fades content into the footer, so a 1px line on top reads as a
|
||||
* double-weight edge. Fumadocs hardcodes `border-t p-4 pt-2` on its
|
||||
* SidebarFooter div; target that exact class trio inside the sidebar IDs
|
||||
* so we don't touch any other border-t in the app. */
|
||||
#nd-sidebar .border-t.p-4.pt-2,
|
||||
#nd-sidebar-mobile .border-t.p-4.pt-2 {
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Top nav — quiet, ruled bottom
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#nd-nav,
|
||||
#nd-subnav {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
#nd-nav a,
|
||||
#nd-subnav a {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
#nd-nav a:hover,
|
||||
#nd-subnav a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* TOC (right rail) — quiet sans, brand-color when active
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#nd-toc a {
|
||||
font-size: 0.84375rem;
|
||||
color: var(--muted-foreground);
|
||||
padding-block: 0.3125rem;
|
||||
letter-spacing: -0.005em;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
#nd-toc a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#nd-toc a[data-active="true"] {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* TOC heading (Fumadocs renders "On this page" as an h3 / first p) */
|
||||
#nd-toc h3,
|
||||
#nd-toc > p:first-child {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.625rem;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Code blocks — warm beige (light) / warm dark (dark), NOT pinned
|
||||
*
|
||||
* Removes the previous "always-dark hero black" treatment. Code surface
|
||||
* now follows page theme so it harmonizes with the warm-paper background
|
||||
* in light mode and warm-dark in dark mode. Terminal-style blocks
|
||||
* (handled by the custom <Terminal> component, not here) stay pinned to
|
||||
* the deeper warm dark for the "shell session" feel.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
article figure.shiki {
|
||||
background: var(--docs-code-bg) !important;
|
||||
border: 1px solid var(--docs-code-border) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
margin-block: 1.25rem !important;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
article figure.shiki pre {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
color: inherit !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
article figure.shiki > div[class*="overflow-auto"] {
|
||||
font-size: 0.84375rem !important;
|
||||
line-height: 1.7;
|
||||
padding: 1rem 1.125rem !important;
|
||||
}
|
||||
|
||||
/* Header bar (filename via ```lang filename="x.ts") */
|
||||
article figure.shiki > div[class*="border-b"] {
|
||||
border-bottom-color: var(--docs-code-border) !important;
|
||||
background: var(--muted) !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
/* Shiki tokens — pick the palette that matches page theme.
|
||||
* Default (light): use --shiki-light. Override under .dark to --shiki-dark.
|
||||
* Specificity: article figure.shiki code span (0,1,4) beats fumadocs's
|
||||
* default, so no !important needed for the light path. */
|
||||
article figure.shiki code span {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
.dark article figure.shiki code span {
|
||||
color: var(--shiki-dark);
|
||||
}
|
||||
|
||||
/* Copy button on code blocks */
|
||||
article figure.shiki button {
|
||||
color: var(--muted-foreground) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
article figure.shiki button:hover {
|
||||
color: var(--foreground) !important;
|
||||
background: var(--muted) !important;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
// Docs-local stateless Multica mark — matches @multica/ui's MulticaIcon
|
||||
// visually (same 8-pointed-asterisk clip-path), but without useState/
|
||||
// useEffect so it's safe to render from Server Components such as
|
||||
// layout.config.tsx / layout.tsx. Keep in sync with
|
||||
// packages/ui/components/common/multica-icon.tsx if the mark changes.
|
||||
const MULTICA_CLIP = `polygon(
|
||||
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
|
||||
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
|
||||
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
|
||||
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
|
||||
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
|
||||
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
|
||||
)`;
|
||||
|
||||
function MulticaMark() {
|
||||
return (
|
||||
<span className="inline-block size-[1em]" aria-hidden="true">
|
||||
<span
|
||||
className="block size-full bg-current"
|
||||
style={{ clipPath: MULTICA_CLIP }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub mark — inlined SVG (lucide-react dropped the Github icon for brand
|
||||
// trademark reasons). Path matches apps/web/features/landing/components/
|
||||
// shared.tsx GitHubMark.
|
||||
function GitHubMark() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className="size-[1em]"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.65 7.65 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// External links shown at the top of the sidebar (and in the top nav on
|
||||
// desktop). Leading icon = brand identity (GitHub mark / Multica asterisk);
|
||||
// trailing ArrowUpRight = "opens externally" glyph, same pattern as
|
||||
// `packages/views/layout/help-launcher.tsx` from PR #1560.
|
||||
const externalLinkText = (label: string) => (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/60" />
|
||||
</span>
|
||||
);
|
||||
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
@@ -9,17 +61,16 @@ export const baseOptions: BaseLayoutProps = {
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: "Documentation",
|
||||
url: "/docs",
|
||||
active: "nested-url",
|
||||
},
|
||||
{
|
||||
text: "GitHub",
|
||||
icon: <GitHubMark />,
|
||||
text: externalLinkText("GitHub"),
|
||||
url: "https://github.com/multica-ai/multica",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
text: "Cloud",
|
||||
icon: <MulticaMark />,
|
||||
text: externalLinkText("Multica"),
|
||||
url: "https://multica.ai",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import "./global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Multica Docs",
|
||||
default: "Multica Docs",
|
||||
},
|
||||
description:
|
||||
"Documentation for Multica — the open-source managed agents platform.",
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<RootProvider>{children}</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
50
apps/docs/app/sitemap.ts
Normal file
50
apps/docs/app/sitemap.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { source } from "@/lib/source";
|
||||
import { i18n } from "@/lib/i18n";
|
||||
import { absoluteDocsUrl } from "@/lib/site";
|
||||
|
||||
/**
|
||||
* Dynamic sitemap — pulls the full page list from Fumadocs' source at build
|
||||
* time. Each logical page emits one entry; all available language variants
|
||||
* are declared as hreflang alternates so Google treats them as the same
|
||||
* article, not as duplicates.
|
||||
*
|
||||
* Served at `/docs/sitemap.xml` (because of basePath). The root
|
||||
* `apps/web/app/robots.ts` references this URL so crawlers discover it.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
// Group pages by canonical slug so multiple locales collapse to one entry.
|
||||
const bySlug = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const { language, pages } of source.getLanguages()) {
|
||||
for (const page of pages) {
|
||||
const slugKey = page.slugs.join("/");
|
||||
const languages = bySlug.get(slugKey) ?? new Map<string, string>();
|
||||
languages.set(language, page.url);
|
||||
bySlug.set(slugKey, languages);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const languages of bySlug.values()) {
|
||||
// Canonical is the default-language URL when available, otherwise the
|
||||
// first available locale (covers pages still mid-translation).
|
||||
const canonicalRelative =
|
||||
languages.get(i18n.defaultLanguage) ?? languages.values().next().value;
|
||||
if (!canonicalRelative) continue;
|
||||
|
||||
const alternates: Record<string, string> = {};
|
||||
for (const [lang, relative] of languages) {
|
||||
alternates[lang] = absoluteDocsUrl(relative);
|
||||
}
|
||||
alternates["x-default"] = absoluteDocsUrl(canonicalRelative);
|
||||
|
||||
entries.push({
|
||||
url: absoluteDocsUrl(canonicalRelative),
|
||||
alternates: { languages: alternates },
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
157
apps/docs/components/architecture-diagram.tsx
Normal file
157
apps/docs/components/architecture-diagram.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Multica architecture diagram for §1.2 "How Multica Works".
|
||||
*
|
||||
* Boundary-style layout: one large panel for "Your side" (where all the
|
||||
* interesting stuff happens — code, keys, compute), one smaller panel for
|
||||
* "Multica" (metadata store and coordinator). The asymmetric sizes and the
|
||||
* brand-tinted left panel visually argue Multica's core thesis: AI runs on
|
||||
* your machine, not ours.
|
||||
*
|
||||
* No SVG arrows. Relationships are carried by the layout itself — client
|
||||
* side vs. server side is the universal mental model, readers don't need
|
||||
* arrows to understand it.
|
||||
*/
|
||||
export function ArchitectureDiagram() {
|
||||
return (
|
||||
<div className="not-prose my-8">
|
||||
{/* Desktop: asymmetric two-panel with connector */}
|
||||
<div className="hidden md:grid md:grid-cols-[1.7fr_auto_1fr] md:gap-4 md:items-stretch">
|
||||
<YourSide />
|
||||
<Connector horizontal />
|
||||
<MulticaSide />
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked */}
|
||||
<div className="md:hidden space-y-4">
|
||||
<YourSide />
|
||||
<Connector horizontal={false} />
|
||||
<MulticaSide />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YourSide() {
|
||||
return (
|
||||
<div className="rounded-lg border border-brand/30 bg-brand/[0.03] p-6 flex flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-brand mb-5">
|
||||
Your side
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5">
|
||||
{/* Client surfaces */}
|
||||
<div>
|
||||
<SectionLabel>Client</SectionLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Pill>Web app</Pill>
|
||||
<Pill>CLI</Pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal separator */}
|
||||
<div className="h-px bg-brand/15" />
|
||||
|
||||
{/* Daemon + local tools */}
|
||||
<div>
|
||||
<SectionLabel>Daemon</SectionLabel>
|
||||
<div className="text-xs text-muted-foreground mb-2.5">
|
||||
Polls work from Multica. Invokes local AI coding tools:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Pill>Claude Code</Pill>
|
||||
<Pill>Codex</Pill>
|
||||
<Pill>Cursor</Pill>
|
||||
<Pill>Copilot</Pill>
|
||||
<Pill muted>+ 6 more</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<div className="mt-6 pt-4 border-t border-brand/20 flex items-center justify-center gap-3 text-[13px] font-medium text-brand">
|
||||
<span>Your code.</span>
|
||||
<span className="text-brand/40">·</span>
|
||||
<span>Your keys.</span>
|
||||
<span className="text-brand/40">·</span>
|
||||
<span>Your CPU.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MulticaSide() {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/25 p-6 flex flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground mb-5">
|
||||
Multica
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<SectionLabel>Server</SectionLabel>
|
||||
<div className="text-xs text-muted-foreground mb-4">
|
||||
Cloud or self-hosted
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1.5 text-foreground/80">
|
||||
<div>Workspaces</div>
|
||||
<div>Issues & tasks</div>
|
||||
<div>Agent definitions</div>
|
||||
<div>Realtime (WebSocket)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border/60 text-[11px] text-muted-foreground text-center uppercase tracking-[0.08em]">
|
||||
No AI execution here.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Connector({ horizontal }: { horizontal: boolean }) {
|
||||
if (horizontal) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-muted-foreground/50 text-xl select-none px-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⇄
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="text-center text-muted-foreground/50 text-xl select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⇅
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70 mb-1.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
muted = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-1 text-[11px] font-medium ${
|
||||
muted
|
||||
? "border-border/50 bg-background/50 text-muted-foreground"
|
||||
: "border-border/70 bg-background text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
131
apps/docs/components/docs-settings.tsx
Normal file
131
apps/docs/components/docs-settings.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { i18n } from "@/lib/i18n";
|
||||
import { localeLabels } from "@/lib/translations";
|
||||
|
||||
// Sidebar-footer chrome: a language switch on the left and a theme switch
|
||||
// on the right. Replaces Fumadocs's default icon-only row, which buried
|
||||
// the language option behind a tiny globe. Each control shows the current
|
||||
// value as a label so the affordance is obvious at a glance.
|
||||
|
||||
const BASE_PATH = "/docs";
|
||||
|
||||
function switchLocalePath(pathname: string, target: string): string {
|
||||
// Next strips basePath before the router, so `pathname` starts at `/`
|
||||
// or `/<locale>/...`. Default-locale URLs are prefix-less.
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
const hasLocalePrefix =
|
||||
first && i18n.languages.some((l) => l === first && l !== i18n.defaultLanguage);
|
||||
|
||||
const rest = hasLocalePrefix ? segments.slice(1) : segments;
|
||||
const prefixed =
|
||||
target === i18n.defaultLanguage ? rest : [target, ...rest];
|
||||
|
||||
return "/" + prefixed.join("/");
|
||||
}
|
||||
|
||||
const THEME_OPTIONS: { value: string; label: string; icon: ReactNode }[] = [
|
||||
{ value: "light", label: "Light", icon: <Sun className="size-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="size-4" /> },
|
||||
{ value: "system", label: "System", icon: <Monitor className="size-4" /> },
|
||||
];
|
||||
|
||||
export function DocsSettings({ locale }: { locale: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// Gate theme reads until mount — next-themes is SSR-incompatible and
|
||||
// would otherwise cause a hydration flash of the wrong icon.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const activeTheme = mounted ? (theme ?? "system") : "system";
|
||||
const activeThemeOption =
|
||||
THEME_OPTIONS.find((o) => o.value === activeTheme) ?? THEME_OPTIONS[2]!;
|
||||
|
||||
const handleLocaleChange = (next: string) => {
|
||||
if (next === locale) return;
|
||||
const internal = pathname.startsWith(BASE_PATH)
|
||||
? pathname.slice(BASE_PATH.length) || "/"
|
||||
: pathname;
|
||||
router.push(switchLocalePath(internal, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{/* Language — left pill. Shows current language name. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="font-normal text-muted-foreground"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
{localeLabels[locale as keyof typeof localeLabels] ?? locale}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" side="top" className="min-w-[140px]">
|
||||
{i18n.languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang}
|
||||
onClick={() => handleLocaleChange(lang)}
|
||||
className={cn(lang === locale && "bg-accent")}
|
||||
>
|
||||
{localeLabels[lang as keyof typeof localeLabels]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Theme — right icon button. Matched height to the sm pill via
|
||||
the icon-sm size token; without this the icon variant defaults
|
||||
to 32px while size="sm" is 28px, misaligning them. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Switch theme"
|
||||
>
|
||||
{activeThemeOption.icon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" side="top" className="min-w-[140px]">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
className={cn(
|
||||
"gap-2",
|
||||
opt.value === activeTheme && "bg-accent",
|
||||
)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
apps/docs/components/editorial.tsx
Normal file
113
apps/docs/components/editorial.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Byline — editorial metadata strip with ruled top + bottom borders.
|
||||
*
|
||||
* Sits below DocsHero on showpiece pages (welcome). Carries the small
|
||||
* uppercase metadata: section · updated · read time. Mirrors the v2
|
||||
* editorial pattern of a "by-line" between title and body, separating
|
||||
* the heading hero from the article proper.
|
||||
*/
|
||||
export function Byline({ items }: { items: string[] }) {
|
||||
return (
|
||||
<div className="not-prose mb-9 flex items-center gap-3.5 border-y border-[var(--docs-rule)] py-3.5 text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className="flex items-center gap-3.5">
|
||||
{i > 0 ? (
|
||||
<span className="size-[3px] rounded-full bg-[var(--docs-faint)]" />
|
||||
) : null}
|
||||
<span>{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberedCards — three-column ruled-divider grid with No.01/02/03 serif
|
||||
* numbers. Showpiece component; replaces fumadocs's <Cards> on the welcome
|
||||
* page. Top + bottom heavy rules frame the row.
|
||||
*/
|
||||
export function NumberedCards({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="not-prose my-9 grid grid-cols-1 border-y border-[var(--docs-rule)] md:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberedCard — child of NumberedCards. Internally numbered by CSS counter,
|
||||
* but we also accept an explicit `number` prop in case the consumer wants
|
||||
* to override (e.g. start at "03").
|
||||
*/
|
||||
export function NumberedCard({
|
||||
number,
|
||||
title,
|
||||
href,
|
||||
tag,
|
||||
children,
|
||||
}: {
|
||||
number?: string;
|
||||
title: string;
|
||||
href: string;
|
||||
tag?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col gap-2.5 border-r border-border px-0 py-5 pr-4 no-underline last:border-r-0 md:px-4 md:first:pl-0 md:last:pr-0"
|
||||
>
|
||||
<div className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{number ? `No. ${number}` : null}
|
||||
</div>
|
||||
<div className="font-[family-name:var(--font-serif)] text-[1.375rem] leading-[1.25] tracking-[-0.015em] text-foreground transition-colors group-hover:text-[var(--primary)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[0.84375rem] leading-[1.55] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
{tag ? (
|
||||
<div className="mt-1 font-mono text-[0.625rem] uppercase tracking-[0.06em] text-[var(--primary)]">
|
||||
{tag}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberedSteps — large serif step numbers, ruled-row separators.
|
||||
* Use for sequential walkthroughs (install → login → start → assign).
|
||||
*/
|
||||
export function NumberedSteps({ children }: { children: ReactNode }) {
|
||||
return <div className="not-prose my-7 border-t border-border">{children}</div>;
|
||||
}
|
||||
|
||||
export function Step({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[3.5rem_1fr] gap-5 border-b border-border py-5">
|
||||
<div className="font-[family-name:var(--font-serif)] text-[2rem] font-normal leading-none tracking-[-0.02em] text-[var(--primary)]">
|
||||
{number}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-[family-name:var(--font-serif)] text-[1.25rem] leading-[1.3] tracking-[-0.01em] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[0.9375rem] leading-[1.6] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/docs/components/hero.tsx
Normal file
81
apps/docs/components/hero.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* DocsHero — editorial showpiece header for landing-style pages.
|
||||
*
|
||||
* Escapes prose scope to run its own type scale. Title accepts ReactNode so
|
||||
* callers can pass <em> spans for brand-color emphasis (italic is avoided —
|
||||
* Chinese italic is a synthetic slant and reads as broken).
|
||||
*/
|
||||
export function DocsHero({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="not-prose mb-7 pt-2">
|
||||
{eyebrow ? (
|
||||
<p className="mb-5 text-[0.6875rem] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="mb-5 font-[family-name:var(--font-serif)] text-[2.25rem] font-normal leading-[1.05] tracking-[-0.025em] text-foreground sm:text-[2.75rem]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? (
|
||||
<p className="max-w-[36rem] font-[family-name:var(--font-serif)] text-[1.25rem] leading-[1.5] tracking-[-0.005em] text-[oklch(from_var(--foreground)_calc(l+0.06)_c_h)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DocsFeatureGrid / DocsFeatureCard — kept for back-compat with any pages
|
||||
* still using the old card grid before the editorial migration. Prefer
|
||||
* <NumberedCards>/<NumberedCard> from editorial.tsx for showpiece pages.
|
||||
*/
|
||||
export function DocsFeatureGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="not-prose my-8 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsFeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col gap-3 rounded-[4px] border border-border bg-card p-5 no-underline transition-all hover:border-[var(--primary)]"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center text-[var(--accent-foreground)] [&_svg]:size-[20px]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-[family-name:var(--font-serif)] text-[1.0625rem] font-medium tracking-[-0.01em] text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<p className="text-sm leading-[1.55] text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
156
apps/docs/components/mermaid.tsx
Normal file
156
apps/docs/components/mermaid.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
/**
|
||||
* Client-side Mermaid diagram renderer.
|
||||
*
|
||||
* Dynamic-imports the mermaid package so it's only loaded on pages that
|
||||
* actually use it (~400 KB). Re-renders when the page theme flips.
|
||||
*
|
||||
* Themed to pick up Multica design tokens at runtime via getComputedStyle,
|
||||
* so the diagram tracks both light / dark mode and any future token changes
|
||||
* without a rebuild.
|
||||
*/
|
||||
export function Mermaid({ chart }: { chart: string }) {
|
||||
const reactId = useId();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void import("mermaid").then(({ default: mermaid }) => {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
// Mermaid's khroma parser only understands legacy color syntax (hex /
|
||||
// rgb / hsl / named). Our tokens are authored in oklch(), which
|
||||
// getComputedStyle preserves verbatim, and a `color-mix(in srgb, ...)`
|
||||
// round-trip still serializes as `color(srgb r g b)` per CSS Color 4.
|
||||
// Rasterize each token through a 1x1 canvas: fillStyle accepts any CSS
|
||||
// <color>, getImageData returns concrete 8-bit sRGB bytes regardless
|
||||
// of the input's color space.
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
|
||||
const v = (name: string, fallback: string) => {
|
||||
const raw = css.getPropertyValue(name).trim();
|
||||
if (!raw || !ctx) return fallback;
|
||||
// fillStyle silently ignores unparseable input; prime with a known
|
||||
// baseline so a parse failure paints black, not whatever was last set.
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillStyle = raw;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
const brand = v("--brand", "#3b82f6");
|
||||
const brandFg = v("--brand-foreground", "#ffffff");
|
||||
const background = v("--background", "#ffffff");
|
||||
const foreground = v("--foreground", "#111111");
|
||||
const muted = v("--muted", "#f5f5f5");
|
||||
const mutedFg = v("--muted-foreground", "#6b7280");
|
||||
const border = v("--border", "#e5e5e5");
|
||||
const accent = v("--accent", muted);
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "base",
|
||||
securityLevel: "strict",
|
||||
fontFamily: "inherit",
|
||||
themeVariables: {
|
||||
// Canvas
|
||||
background,
|
||||
mainBkg: background,
|
||||
// Nodes — soft muted fill with full-contrast text and a subtle border
|
||||
primaryColor: muted,
|
||||
primaryTextColor: foreground,
|
||||
primaryBorderColor: border,
|
||||
secondaryColor: accent,
|
||||
secondaryTextColor: foreground,
|
||||
secondaryBorderColor: border,
|
||||
tertiaryColor: background,
|
||||
tertiaryTextColor: foreground,
|
||||
tertiaryBorderColor: border,
|
||||
// Edges + labels
|
||||
lineColor: mutedFg,
|
||||
textColor: foreground,
|
||||
edgeLabelBackground: background,
|
||||
labelBackground: background,
|
||||
// Clusters (subgraph boxes)
|
||||
clusterBkg: accent,
|
||||
clusterBorder: border,
|
||||
titleColor: foreground,
|
||||
// Notes / callouts
|
||||
noteBkgColor: muted,
|
||||
noteTextColor: foreground,
|
||||
noteBorderColor: border,
|
||||
// Brand accent — used for active / start states in state diagrams,
|
||||
// user-decision diamonds in flowcharts, etc.
|
||||
activeTaskBkgColor: brand,
|
||||
activeTaskBorderColor: brand,
|
||||
altBackground: muted,
|
||||
// Sequence / git diagrams (harmless if unused)
|
||||
actorBkg: muted,
|
||||
actorBorder: border,
|
||||
actorTextColor: foreground,
|
||||
actorLineColor: mutedFg,
|
||||
signalColor: foreground,
|
||||
signalTextColor: foreground,
|
||||
// Fine print
|
||||
errorBkgColor: muted,
|
||||
errorTextColor: foreground,
|
||||
},
|
||||
});
|
||||
|
||||
// mermaid requires a DOM-valid id; useId returns ":r0:" which isn't.
|
||||
const domId = `mermaid-${reactId.replace(/:/g, "")}`;
|
||||
|
||||
mermaid
|
||||
.render(domId, chart.trim())
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setSvg(result.svg);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSvg(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, reactId, resolvedTheme]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre className="my-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
Mermaid error: {error}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (!svg) {
|
||||
return (
|
||||
<div className="my-4 text-sm text-muted-foreground">
|
||||
Rendering diagram…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="my-6 flex justify-center overflow-x-auto rounded-md border border-border/60 bg-muted/20 p-6 [&_.label_foreignObject>div]:!font-[inherit] [&_.nodeLabel]:!font-[inherit] [&_.edgeLabel]:!font-[inherit] [&_text]:!font-[inherit]"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
127
apps/docs/content/docs/agents-create.mdx
Normal file
127
apps/docs/content/docs/agents-create.mdx
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: Create and configure an agent
|
||||
description: The minimum fields to create an agent, plus every optional setting — system instructions, environment variables, visibility, concurrency limit, and archiving.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Creating an [agent](/agents) takes only two things: **a name** and **a choice of [AI coding tool](/providers)**. Everything else is optional — system instructions, model, environment variables, CLI arguments, visibility, concurrency limit — the defaults work fine. Get it running first and tune later; every field can be changed at any time.
|
||||
|
||||
## Create an agent
|
||||
|
||||
Prerequisite: you already have at least one supported [AI coding tool](/providers) installed on your machine (Claude Code, Codex, etc.) and a [daemon](/daemon-runtimes) running. If you're not there yet, start with [Cloud quickstart](/cloud-quickstart) or [Self-host quickstart](/self-host-quickstart).
|
||||
|
||||
Once that's in place, go to the **Agents** page in your workspace and click **+ New**, or use the CLI:
|
||||
|
||||
```bash
|
||||
multica agent create
|
||||
```
|
||||
|
||||
The form has only two required fields: **name** (unique within the workspace) and **runtime** (= pick an AI coding tool). Every other field is covered section by section below.
|
||||
|
||||
## Pick an AI coding tool
|
||||
|
||||
Each runtime is backed by a specific AI coding tool. Multica supports 10 of them. The most common choices:
|
||||
|
||||
| Tool | Good for |
|
||||
|---|---|
|
||||
| **Claude Code** | Anthropic's official tool, most complete feature set; **best first pick** |
|
||||
| **Codex** | OpenAI, the mainstream alternative |
|
||||
| **Cursor** | Users in the Cursor editor ecosystem |
|
||||
| **Copilot** | Teams leveraging their GitHub account entitlements |
|
||||
| **Gemini** | Users in the Google ecosystem |
|
||||
|
||||
The other five (Hermes, Kimi, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
|
||||
|
||||
## Writing system instructions
|
||||
|
||||
**System instructions** (`instructions`) are prepended to every task, telling the agent what role it plays and what rules to follow:
|
||||
|
||||
```text
|
||||
You're a frontend code-review agent. When an issue comes in, read the diff first. Focus only on:
|
||||
- Styling issues (tailwind class names, box model)
|
||||
- Accessibility (a11y)
|
||||
Don't change code — leave suggestions in a comment.
|
||||
```
|
||||
|
||||
When left blank (the default), the agent uses the native behavior of its underlying AI coding tool with no extra constraints.
|
||||
|
||||
## Picking a model
|
||||
|
||||
Most AI coding tools support model selection (for example, Claude Code lets you pick between Sonnet and Opus). Leave it blank and the tool's own default is used; pick one explicitly and that's what runs. Each tool's supported models are listed in [AI coding tools comparison](/providers).
|
||||
|
||||
Changing the model **only applies to new tasks**. Already-dispatched tasks continue with the model that was locked in at dispatch time.
|
||||
|
||||
## Custom environment variables (custom_env)
|
||||
|
||||
**Custom environment variables** (`custom_env`) let you inject extra env vars at task execution time — typical uses are API keys or switching the upstream endpoint:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY = sk-...
|
||||
ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
```
|
||||
|
||||
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
|
||||
|
||||
<Callout type="warning">
|
||||
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
|
||||
|
||||
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
|
||||
</Callout>
|
||||
|
||||
## Custom CLI arguments (custom_args)
|
||||
|
||||
**Custom CLI arguments** (`custom_args`) is a string array appended one-by-one to the AI coding tool's command line:
|
||||
|
||||
```json
|
||||
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
|
||||
```
|
||||
|
||||
The final command comes out as:
|
||||
|
||||
```bash
|
||||
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
|
||||
```
|
||||
|
||||
Arguments are passed as-is, not through a shell (no injection risk), but whether a given flag is recognized is up to the AI coding tool itself — tools differ substantially here.
|
||||
|
||||
<Callout type="tip">
|
||||
`custom_env` and `custom_args` have no hard caps, but in practice **keep each under 10 entries**. Too many makes the command line long, slows startup, and gets harder to maintain.
|
||||
</Callout>
|
||||
|
||||
## Visibility
|
||||
|
||||
- **Workspace** (`workspace`) — any member of the workspace can assign it
|
||||
- **Private** (`private`) — only workspace owners, admins, or the agent's creator can assign it
|
||||
|
||||
New agents default to `private`.
|
||||
|
||||
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
|
||||
|
||||
## Concurrency limit
|
||||
|
||||
**Concurrency limit** (`max_concurrent_tasks`) controls how many tasks this agent can run in parallel at once. The default is **6**. New tasks that hit the cap queue up — they aren't rejected.
|
||||
|
||||
This is only the "agent layer" of a two-tier limit — the daemon itself enforces a broader cap (default 20), and whichever is tighter wins. Details in [Daemon and runtimes → How many tasks can run in parallel](/daemon-runtimes#how-many-tasks-can-run-in-parallel).
|
||||
|
||||
Changing this value **does not cancel tasks already running** — it only applies to the next task about to be picked up.
|
||||
|
||||
## Attaching domain expertise: Skills
|
||||
|
||||
A created agent can have **Skills** attached — **knowledge packs** (`SKILL.md` + supporting files) automatically delivered to the AI coding tool at task execution time. You can create a new skill, import from GitHub or ClawHub, or scan one from an existing skill directory on your machine. See [Skills](/skills).
|
||||
|
||||
## Archive and restore
|
||||
|
||||
Agents you no longer use can be **archived** — they disappear from everyday views, but their historical data (tasks run, comments posted) is fully preserved. **Restore** them anytime to put them back to work.
|
||||
|
||||
<Callout type="warning">
|
||||
**Archiving immediately cancels every unfinished task belonging to the agent** — running, dispatched, and queued tasks are all marked `cancelled` and won't continue. If you have an important task in flight, let it finish before archiving.
|
||||
</Callout>
|
||||
|
||||
Archived agents can't be assigned new tasks.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [AI coding tools comparison](/providers) — full capability matrix across all 10 tools
|
||||
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
|
||||
127
apps/docs/content/docs/agents-create.zh.mdx
Normal file
127
apps/docs/content/docs/agents-create.zh.mdx
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
title: 创建和配置智能体
|
||||
description: 创建一个智能体的最小字段,以及所有可选配置项——系统指令、环境变量、可见性、并发上限,和归档机制。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
创建一个 [智能体](/agents) 只要两件事:**名字** 和 **选一款 [AI 编程工具](/providers)**。其他全部可选——系统指令、模型、环境变量、命令行参数、可见性、并发上限——默认值都能用,先跑起来再慢慢调,所有字段随时能改。
|
||||
|
||||
## 创建一个智能体
|
||||
|
||||
前置条件:你本机已经装好至少一款受支持的 [AI 编程工具](/providers)(Claude Code、Codex 等),并跑着 [守护进程](/daemon-runtimes)。如果还没走到这一步,先看 [Cloud 快速开始](/cloud-quickstart) 或 [自部署快速开始](/self-host-quickstart)。
|
||||
|
||||
满足之后,在工作区的**智能体**页点 **+ 新建**,或者用命令行:
|
||||
|
||||
```bash
|
||||
multica agent create
|
||||
```
|
||||
|
||||
表单里只有两项必填:**名字**(工作区内唯一)和 **运行时**(= 选一款 AI 编程工具)。其他字段下面一节一节讲。
|
||||
|
||||
## 选一款 AI 编程工具
|
||||
|
||||
运行时背后是一款具体的 AI 编程工具。Multica 支持 10 款,最常用的几款:
|
||||
|
||||
| 工具 | 适合 |
|
||||
|---|---|
|
||||
| **Claude Code** | Anthropic 官方,功能最完整;**新手首选** |
|
||||
| **Codex** | OpenAI,主流替代 |
|
||||
| **Cursor** | Cursor 编辑器生态用户 |
|
||||
| **Copilot** | 用 GitHub 账号权益的团队 |
|
||||
| **Gemini** | Google 生态用户 |
|
||||
|
||||
另外 5 款(Hermes、Kimi、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
|
||||
|
||||
## 写系统指令
|
||||
|
||||
**系统指令**(`instructions`)会被拼在每次任务最前面,告诉这个智能体它扮演什么角色、遵守什么规则:
|
||||
|
||||
```text
|
||||
你是一个前端代码审查智能体。拿到 issue 先读 diff,只关注:
|
||||
- 样式问题(tailwind 类名、盒模型)
|
||||
- 可访问性(a11y)
|
||||
不改代码,只在评论里给建议。
|
||||
```
|
||||
|
||||
留空时(默认),智能体用它背后 AI 编程工具的原生行为,没有额外约束。
|
||||
|
||||
## 选模型
|
||||
|
||||
大多数 AI 编程工具支持选模型(例如 Claude Code 能在 Sonnet / Opus 里选)。留空 → 用工具自己的默认;明确选了 → 用选的。每款工具支持的模型见 [AI 编程工具对照](/providers)。
|
||||
|
||||
改模型**只对新任务生效**。已经派发出去的任务继续用派发时固化下来的模型。
|
||||
|
||||
## 自定义环境变量(custom_env)
|
||||
|
||||
**自定义环境变量**(`custom_env`)让你在任务执行时注入额外的 env var——典型用途是 API key 或切换上游 endpoint:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY = sk-...
|
||||
ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
```
|
||||
|
||||
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn,不会报错)。
|
||||
|
||||
<Callout type="warning">
|
||||
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值(API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
|
||||
|
||||
**不要把高价值 secret 放进 `custom_env`**(生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT),定期轮换。
|
||||
</Callout>
|
||||
|
||||
## 自定义命令行参数(custom_args)
|
||||
|
||||
**自定义命令行参数**(`custom_args`)是一串字符串数组,会被逐个追加到 AI 编程工具的命令行尾部:
|
||||
|
||||
```json
|
||||
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
|
||||
```
|
||||
|
||||
拼完会是:
|
||||
|
||||
```bash
|
||||
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
|
||||
```
|
||||
|
||||
参数按原样传,不走 shell 解析(没有注入风险),但传什么 flag 能不能被识别看 AI 编程工具本身——不同工具差异很大。
|
||||
|
||||
<Callout type="tip">
|
||||
`custom_env` 和 `custom_args` 没有硬限制,但**实际使用建议控制在 10 条以内**。太多会让命令行变长、启动变慢,也更难维护。
|
||||
</Callout>
|
||||
|
||||
## 可见性
|
||||
|
||||
- **工作区可见**(`workspace`)—— 工作区里任何成员都能分配
|
||||
- **私有**(`private`)—— 只有工作区的 owner、admin,或智能体的创建者能分配
|
||||
|
||||
新建默认 `private`。
|
||||
|
||||
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
|
||||
|
||||
## 并发上限
|
||||
|
||||
**并发上限**(`max_concurrent_tasks`)决定这个智能体同一时间最多同时跑几个任务,默认 **6**。达到上限的新任务留在队列排队,不会被拒绝。
|
||||
|
||||
这只是两层限额里的"智能体层"——守护进程本身还有一层更粗的限额(默认 20),两层中更紧的那层生效。详见 [守护进程与运行时 → 一次能并发跑多少任务](/daemon-runtimes#一次能并发跑多少任务)。
|
||||
|
||||
修改这个值**不会取消已经在跑的任务**——只对下一个要被领走的任务生效。
|
||||
|
||||
## 挂专业知识:Skill
|
||||
|
||||
创建好的智能体可以挂 **Skill**——一种**专业知识包**(`SKILL.md` + 支持文件),任务执行时自动送到对应的 AI 编程工具。可以新建、从 GitHub / ClawHub 导入、或从你本机已有的 skill 目录扫入。详见 [Skills](/skills)。
|
||||
|
||||
## 归档和恢复
|
||||
|
||||
不再用的智能体可以**归档**——它从日常视图里消失,但历史数据(跑过的任务、发过的评论)全部保留。想重新用时**恢复**即可。
|
||||
|
||||
<Callout type="warning">
|
||||
**归档会立刻取消这个智能体所有未结束的任务**——正在跑的、已派发的、还在排队的都会被标为 `cancelled`,不会继续执行。如果有重要任务在跑,先让它完成再归档。
|
||||
</Callout>
|
||||
|
||||
已归档的智能体无法被分配新任务。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Skills](/skills) —— 给智能体挂专业知识包
|
||||
- [AI 编程工具对照](/providers) —— 10 款工具的完整能力差别
|
||||
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来
|
||||
48
apps/docs/content/docs/agents.mdx
Normal file
48
apps/docs/content/docs/agents.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Agents
|
||||
description: "An agent is a first-class member of a Multica workspace — it can be assigned issues, post comments, and be @-mentioned. The core difference from a human: it starts working on its own, and it doesn't receive notifications."
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
|
||||
## What an agent can do
|
||||
|
||||
Agents use the same "member" surface as humans, and the UI barely distinguishes them:
|
||||
|
||||
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
|
||||
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
|
||||
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
|
||||
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
|
||||
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
|
||||
|
||||
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
|
||||
|
||||
## How it differs from a human
|
||||
|
||||
A few key differences only surface once you actually start using agents:
|
||||
|
||||
- **It starts on its own** — after you assign it an issue or `@` it, Multica dispatches the task to its runtime immediately. Unlike a human, it doesn't wait to see the message and respond. For trigger details, see [Assigning issues to agents](/assigning-issues) and [@-mentioning agents in comments](/mentioning-agents).
|
||||
- **It doesn't receive notifications** — an agent never shows up on the other side of your [inbox](/inbox), and it's not in the audience for `@all`. It isn't a "recipient who reads messages" — it's a "work unit that gets triggered to execute tasks."
|
||||
- **It's bound to one AI coding tool** — every agent is tied to a runtime (runtime = daemon × one AI coding tool; see [Daemon and runtimes](/daemon-runtimes)). If the tool is offline, the agent can't work; new tasks wait until the runtime comes back.
|
||||
- **It can be archived** — archive an agent you don't use anymore and it disappears from everyday views; restore it whenever you want. Archiving cancels any tasks currently running.
|
||||
|
||||
## Who can assign an agent
|
||||
|
||||
When you create an agent, you pick a **visibility** that controls who can assign it to an issue or set it as project lead:
|
||||
|
||||
- **Workspace** — any member of the workspace can assign it
|
||||
- **Private** — only workspace owners, admins, or the agent's creator can assign it
|
||||
|
||||
New agents default to **private**. To make one available to the whole workspace, set visibility to `workspace` at creation time, or change it later in the agent's config. For the full role-permission matrix, see [Members and roles](/members-roles).
|
||||
|
||||
<Callout type="info">
|
||||
**Private means "restricted who can assign," not "hidden from everyone else."** Every member of the workspace sees a private agent's name and description in the agents list — they just can't see its config details (custom environment variables, MCP config, and other sensitive fields are masked). If you need "visible to only one person," that's not currently possible.
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create and configure an agent](/agents-create) — how to build one
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run
|
||||
48
apps/docs/content/docs/agents.zh.mdx
Normal file
48
apps/docs/content/docs/agents.zh.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: 智能体
|
||||
description: 智能体(agent)是 Multica 工作区里的一等公民成员——能被分配 issue、发评论、被 @ 点名;和人最大的不同是它自动开工、不收通知。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
|
||||
## 智能体能做什么
|
||||
|
||||
智能体和人用的是同一套"成员"接口,界面上几乎没有区别:
|
||||
|
||||
- **[被分配 issue](/assigning-issues)** —— 作为 assignee,分配后它会自动开工
|
||||
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
|
||||
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
|
||||
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
## 它和人不一样在哪
|
||||
|
||||
几个关键差异在你真正开始用之后才会浮现:
|
||||
|
||||
- **它自动开工**——分配 issue 或 `@` 它之后,Multica 会立刻把任务派给它所在的运行时。不像人那样要等 TA 看到消息再响应。触发方式的细节见 [分配 issue 给智能体](/assigning-issues) 和 [在评论里 @智能体](/mentioning-agents)。
|
||||
- **它不收通知**——智能体永远不会出现在你的 [收件箱](/inbox) 对面;它也不在 `@all` 的接收范围内。它不是"读消息的收信人",而是"被触发执行任务的工作单元"。
|
||||
- **它绑一款 AI 编程工具**——每个智能体关联一个运行时(runtime = 守护进程 × 一款 AI 编程工具,详见 [守护进程与运行时](/daemon-runtimes))。工具不在线,它干不了活,新任务会等到运行时回来。
|
||||
- **它可以被归档**——不用时把它归档起来,会从日常视图里消失;以后想用随时恢复。归档时正在跑的任务会被取消。
|
||||
|
||||
## 谁能把智能体分配出去
|
||||
|
||||
创建智能体时会选一个**可见性**(visibility),决定谁能把它分配给 issue 或设为 project lead:
|
||||
|
||||
- **工作区可见(workspace)** —— 工作区里任何成员都能分配
|
||||
- **私有(private)** —— 只有工作区的 owner、admin,或智能体的创建者能分配
|
||||
|
||||
新建的智能体**默认是私有的**。想让全工作区都能用,在创建时把可见性选为 `workspace`,或之后在配置里改。角色权限完整对照见 [成员与权限](/members-roles)。
|
||||
|
||||
<Callout type="info">
|
||||
**私有 = 限制谁能分配,不是对其他人隐藏**。工作区里所有成员都能在智能体列表里看到私有智能体的名字和描述——只是看不到它的配置细节(自定义环境变量、MCP 配置等敏感字段被打码)。如果你需要"只对一个人可见",目前做不到。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
|
||||
- [Skills](/skills) —— 给智能体挂上专业知识包
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么
|
||||
81
apps/docs/content/docs/assigning-issues.mdx
Normal file
81
apps/docs/content/docs/assigning-issues.mdx
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: Assign issues to agents
|
||||
description: Hand an issue to an agent and it takes over as the official assignee until the work is done — with full context and the ability to change issue status and fields.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
|
||||
|
||||
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|
||||
|---|---|---|---|---|---|
|
||||
| **Assign** | Hand an agent ownership | Changes assignee | Issue + all comments | Inherits from issue | ✓ |
|
||||
| [**@-mention**](/mentioning-agents) | Pull it in to take a look | No changes | Issue + trigger comment | Inherits from issue | ✓ |
|
||||
| [**Chat**](/chat) | One-to-one conversation outside any issue | No issue involved | Current conversation history | Fixed medium | ✓ |
|
||||
| [**Routines**](/routines) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by routine | ✗ |
|
||||
|
||||
"Auto retry" refers to retries after infrastructure failures (runtime offline, timeout). Business errors on the agent side (for example, the model reporting an error) are not retried. See [**Tasks**](/tasks) for details.
|
||||
|
||||
## Assign from the UI
|
||||
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
|
||||
|
||||
A few rules:
|
||||
|
||||
- **Workspace agents** can be assigned by any member; **private agents** can only be assigned by their owner or a workspace admin.
|
||||
- You can only assign to agents that have **an online runtime** — agents with no one running them show as unavailable in the picker.
|
||||
- When the issue status is **Backlog**, assigning **does not trigger the agent** — Backlog is a parking lot; the agent only gets enqueued once you move the issue to Todo or In Progress.
|
||||
|
||||
## Assign from the CLI
|
||||
|
||||
The command-line equivalent:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name. Giving agents memorable names makes this step smoother — if multiple agents share a name in the workspace, the first one listed wins, so rename before assigning.
|
||||
|
||||
Unassign:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --unassign
|
||||
```
|
||||
|
||||
## What happens after assignment
|
||||
|
||||
When a non-Backlog issue is assigned to an agent, Multica immediately does the following in the background:
|
||||
|
||||
1. Enqueues a `queued` `task` with priority inherited from the issue, routed to the runtime where the agent lives.
|
||||
2. The agent's daemon picks up the `task` on its next poll and transitions it to `dispatched`.
|
||||
3. The agent starts working and the `task` moves to `running`; on completion it becomes `completed` or `failed`.
|
||||
4. During execution the agent can change the issue's status, post comments, and edit fields — these actions appear under the agent's identity.
|
||||
|
||||
**If the agent is offline**, the `task` waits in the queue — **it times out and fails after 5 minutes** with reason `runtime_offline`. For retryable sources (assign, @-mention, chat), Multica automatically re-enqueues it. See [**Tasks**](/tasks) for the full retry rules.
|
||||
|
||||
Assigning also auto-subscribes the agent to the issue — but in Multica **agents do not receive inbox notifications** (only members do). This subscription is internal bookkeeping with no user-visible side effect.
|
||||
|
||||
## Reassign or unassign
|
||||
|
||||
When you change the assignee from Agent A to Agent B:
|
||||
|
||||
1. **Everything A has in flight is cancelled** — every `task` in `queued`, `dispatched`, or `running` state is marked `cancelled`.
|
||||
2. **B is enqueued a new `task` immediately** (if the issue is not in Backlog and B has an online runtime).
|
||||
|
||||
<Callout type="warning">
|
||||
**Reassignment cancels every active `task` on this issue — not just the old assignee's.** If another agent is working on this issue because of an @-mention, its `task` is cancelled too. There is currently no UI action to cancel a single agent's `task` in isolation.
|
||||
</Callout>
|
||||
|
||||
Unassigning (`--unassign` or picking "none" in the picker) marks all active `task` entries as `cancelled` and **does not enqueue a new one**. Existing subscriptions are not cleared automatically — the old assignee stays on the subscription list (but still receives no inbox notifications).
|
||||
|
||||
## Why only one active `task` per agent per issue
|
||||
|
||||
**A single agent can have at most one `queued` or `dispatched` `task` on the same issue at any time.** A unique index at the database level plus the claim logic enforces this — it prevents duplicate enqueues and concurrent executions overwriting each other.
|
||||
|
||||
But **different agents can work on the same issue in parallel** — for example, Agent A is the assignee and Agent B is @-mentioned; both `task` entries can coexist, each running on its own runtime. See [**Tasks**](/tasks) for the full serial/concurrent rules.
|
||||
|
||||
## Next
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
81
apps/docs/content/docs/assigning-issues.zh.mdx
Normal file
81
apps/docs/content/docs/assigning-issues.zh.mdx
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 分配 issue 给智能体
|
||||
description: 把 issue 交给智能体,它作为正式负责人一直工作到结束——拿到完整上下文,也能改 issue 状态和字段。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
|
||||
|
||||
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|
||||
|---|---|---|---|---|---|
|
||||
| **分配** | 让智能体正式负责 | 改 assignee | issue + 全部 comments | 继承 issue | ✓ |
|
||||
| [**@ 提及**](/mentioning-agents) | 评论里让它看一眼 | 都不改 | issue + 触发评论 | 继承 issue | ✓ |
|
||||
| [**对话**](/chat) | 独立于 issue 的一对一聊天 | 不涉及 issue | 当前对话历史 | 固定中 | ✓ |
|
||||
| [**Routines**](/routines) | 定时 / 手动自动化 | 视模式 | 视模式 | routine 自定 | ✗ |
|
||||
|
||||
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
|
||||
|
||||
## 在界面里分配
|
||||
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员和未归档的智能体。选一个智能体,issue 立刻分给它。
|
||||
|
||||
几条规则:
|
||||
|
||||
- **工作区智能体**任何成员都能分配;**私人智能体**只有它的 owner 或工作区 admin 能分配
|
||||
- 只能分配给**有在线运行时**的智能体——没人在跑的智能体,picker 会提示不可选
|
||||
- Issue 状态是 **Backlog** 时,分配**不会立刻触发**智能体——Backlog 是停泊场,切到 Todo / In Progress 才会真正入队
|
||||
|
||||
## 用 CLI 分配
|
||||
|
||||
等价的命令行操作:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配。
|
||||
|
||||
取消分配:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --unassign
|
||||
```
|
||||
|
||||
## 分配之后会发生什么
|
||||
|
||||
非 Backlog 的 issue 分配给智能体之后,Multica 会立刻在后台做以下事情:
|
||||
|
||||
1. 入队一个 `queued` 状态的 `task`,优先级继承自 issue,路由到该智能体所在的运行时
|
||||
2. 该智能体的守护进程下次轮询时把 `task` 领走,状态变成 `dispatched`
|
||||
3. 智能体开始执行,`task` 转成 `running`;完成后转成 `completed` / `failed`
|
||||
4. 执行过程中智能体可以改 issue 状态、发评论、改字段——这些动作以智能体的身份出现
|
||||
|
||||
**如果智能体离线**,`task` 会在队列里等——**5 分钟没被领走就超时失败**,失败原因 `runtime_offline`。对可重试的来源(分配、@ 提及、对话),Multica 会自动重新排队;完整重试规则见 [**执行任务**](/tasks)。
|
||||
|
||||
分配还会自动把这个智能体加进 issue 的订阅列表——但 Multica 里**智能体不接收 inbox 通知**(只有成员收)。这个订阅只是内部 bookkeeping,用户侧没有可见的副作用。
|
||||
|
||||
## 换分配人或取消分配
|
||||
|
||||
把 assignee 从 Agent A 换成 Agent B 时:
|
||||
|
||||
1. **A 这边在跑的一切都被取消**——所有 `queued` / `dispatched` / `running` 状态的 `task` 都被标记 `cancelled`
|
||||
2. **B 立刻被入队一个新 `task`**(如果 issue 不是 Backlog 且 B 有在线运行时)
|
||||
|
||||
<Callout type="warning">
|
||||
**换分配人会 cancel 掉这个 issue 上所有活跃的 `task`——不只是旧 assignee 的**。如果另一个智能体因为 @ 提及也正在这个 issue 上干活,它的 `task` 也会被一并取消。目前没有只 cancel 单个智能体 `task` 的 UI 操作。
|
||||
</Callout>
|
||||
|
||||
取消分配(`--unassign` 或 picker 里选"无")把所有活跃 `task` 标记 `cancelled`,**不入队新的**。已有的订阅关系不会自动清除——旧 assignee 仍留在订阅名单里(但同样收不到 inbox 通知)。
|
||||
|
||||
## 为什么同一 issue 同时只能一个活跃 `task`
|
||||
|
||||
**同一个智能体在同一个 issue 上,同时只能有一个 `queued` 或 `dispatched` 的 `task`**。数据库层的 unique index 加上 claim 逻辑保证这一点——避免重复入队、避免并发执行互相覆盖。
|
||||
|
||||
但**不同智能体在同一个 issue 上可以各自独立跑**——比如 Agent A 是 assignee,Agent B 被 @ 提及,两者的 `task` 可以同时存在,各走各的运行时。完整的串行 / 并发规则见 [**执行任务**](/tasks)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
121
apps/docs/content/docs/auth-setup.mdx
Normal file
121
apps/docs/content/docs/auth-setup.mdx
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Sign-in and signup configuration
|
||||
description: Configure email + verification code sign-in, Google OAuth, and signup allowlists. Avoid the 888888 trap.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica supports two sign-in methods: **email + verification code** (default) and **Google OAuth** (optional). On successful sign-in, the server issues a JWT cookie with a 30-day lifetime. This page covers how to configure each method, how to restrict who can sign up, and the single biggest trap for self-hosted deployments.
|
||||
|
||||
For the list of environment variables referenced below, see [Environment variables](/environment-variables); for token usage and lifecycle details, see [Authentication and tokens](/auth-tokens).
|
||||
|
||||
## How email + verification code sign-in works
|
||||
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
|
||||
|
||||
1. Create a Resend account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
|
||||
```
|
||||
|
||||
4. Restart the server
|
||||
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## The 888888 trap
|
||||
|
||||
<Callout type="warning">
|
||||
**If `APP_ENV` is not set to `production`, anyone can sign in to any account with the code `888888`.**
|
||||
|
||||
Multica has a development-only master code, `888888` — a backdoor so local development doesn't depend on Resend. The rule is straightforward: when `APP_ENV != "production"`, **any email** plus `888888` passes verification.
|
||||
|
||||
**Production deployments must set `APP_ENV=production`**. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, this value is already set to `production` by default; but if you deploy from source yourself, write your own Docker config, or redefine environment variables in Kubernetes — you must add `APP_ENV=production` yourself.
|
||||
</Callout>
|
||||
|
||||
To check whether your deployment has this trap: open the sign-in page, enter **any email** to request a code, then enter `888888`. If you get in, your `APP_ENV` is not set to `production`, and **the entire instance is wide open**.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
Optional. Without it, only email + verification code is available; with it, the sign-in page gets a "Sign in with Google" button.
|
||||
|
||||
1. Create an OAuth 2.0 client in the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Set the **Authorized redirect URIs** to your Multica frontend address plus `/auth/callback`, for example:
|
||||
|
||||
```text
|
||||
https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
3. Once you have the client ID and client secret, set three environment variables:
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
|
||||
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
4. Restart the server.
|
||||
|
||||
**Takes effect at runtime**: the frontend reads these settings at runtime via `/api/config` — after changing them, restart the server and the frontend picks up the new values with no rebuild or redeploy.
|
||||
|
||||
<Callout type="warning">
|
||||
**The redirect URI must match exactly in both the Google Console and `GOOGLE_REDIRECT_URI`** — including protocol (`http` vs `https`), trailing slash, and port. Any mismatch and Google rejects the entire OAuth flow; the error shown to the user is `redirect_uri_mismatch`.
|
||||
</Callout>
|
||||
|
||||
## Restricting who can sign up
|
||||
|
||||
Three environment variables combine by priority:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
Start[New user first sign-in] --> A{Email in<br/>ALLOWED_EMAILS?}
|
||||
A -- Yes --> Allow[Allow signup]
|
||||
A -- No --> B{Domain in<br/>ALLOWED_EMAIL_DOMAINS?}
|
||||
B -- Yes --> Allow
|
||||
B -- No --> C{Any allowlist<br/>non-empty?}
|
||||
C -- Yes --> Block[Reject]
|
||||
C -- No --> D{ALLOW_SIGNUP<br/>= true?}
|
||||
D -- Yes --> Allow
|
||||
D -- No --> Block
|
||||
`} />
|
||||
|
||||
**Existing users can always sign in again** — the signup allowlist only applies to **first-time signup**, not returning users.
|
||||
|
||||
- **`ALLOWED_EMAILS`** (highest priority) — explicit email allowlist, comma-separated. **When non-empty, only listed emails can sign up.**
|
||||
- **`ALLOWED_EMAIL_DOMAINS`** — domain allowlist, comma-separated (for example `company.io,partner.com`).
|
||||
- **`ALLOW_SIGNUP`** — master switch, default `true`. Set `false` to disable signup entirely.
|
||||
|
||||
<Callout type="warning">
|
||||
**The three layers are AND semantics, not OR.** A common wrong intuition is that `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` means "allow company.io plus everyone else." It does **not**. If any layer has a non-empty value, **emails not matching it are rejected outright** — `ALLOW_SIGNUP=true` does not override that.
|
||||
|
||||
To actually "allow everyone," leave all three variables empty (or keep `ALLOW_SIGNUP=true`).
|
||||
</Callout>
|
||||
|
||||
**Typical configurations**:
|
||||
|
||||
| Goal | Configuration |
|
||||
|---|---|
|
||||
| Internal only, employees of `company.io` | `ALLOWED_EMAIL_DOMAINS=company.io` |
|
||||
| Internal + a few external collaborators | `ALLOWED_EMAIL_DOMAINS=company.io` + collaborator addresses added to `ALLOWED_EMAILS` |
|
||||
| Disable self-serve signup entirely, invite-only | `ALLOW_SIGNUP=false` |
|
||||
| Open signup (not recommended for production) | All three empty |
|
||||
|
||||
## Can you still invite people when signup is disabled?
|
||||
|
||||
**Only people who already have a Multica account.** Accepting an invite doesn't check the signup allowlist — if the invitee has signed up already (for example in another workspace), clicking the invite link and signing in lets them accept.
|
||||
|
||||
**But people who have never signed up cannot be rescued by an invite.** Before accepting, they must sign in, and the first step of sign-in (requesting the verification code) passes through the signup allowlist check. If `ALLOW_SIGNUP=false`, or their email isn't in `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS`, they **cannot complete signup**, and therefore cannot accept the invite.
|
||||
|
||||
To invite an external collaborator who hasn't signed up yet: temporarily add their email to `ALLOWED_EMAILS`, wait for them to sign up and accept the invite, then remove the entry.
|
||||
|
||||
For how to create and use invites, see [Members and roles](/members-roles).
|
||||
|
||||
## Next
|
||||
|
||||
- [Environment variables](/environment-variables) — full definitions of every variable used on this page
|
||||
- [Authentication and tokens](/auth-tokens) — JWT / PAT / daemon token categories and usage
|
||||
- [Troubleshooting](/troubleshooting) — verification code not received, OAuth `redirect_uri_mismatch`, signup rejected
|
||||
121
apps/docs/content/docs/auth-setup.zh.mdx
Normal file
121
apps/docs/content/docs/auth-setup.zh.mdx
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: 登录与注册配置
|
||||
description: 配 Email 验证码登录 + Google OAuth + 注册白名单。避开最坑的 888888 陷阱。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及自部署最容易踩的一个陷阱。
|
||||
|
||||
上面用到的环境变量的清单见 [环境变量](/environment-variables);token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 必须是 Resend 已验证的域名
|
||||
```
|
||||
|
||||
4. 重启 server
|
||||
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 888888 陷阱
|
||||
|
||||
<Callout type="warning">
|
||||
**`APP_ENV` 不设为 `production`,任何人都能用验证码 `888888` 登录任何账号。**
|
||||
|
||||
Multica 有一个开发用的主验证码(master code)`888888`——为了本地开发不依赖 Resend 而设的后门。判定逻辑很简单:`APP_ENV != "production"` 时,**任何邮箱**输 `888888` 都能通过。
|
||||
|
||||
**生产部署必须设 `APP_ENV=production`**。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,这个值已经默认设为 `production`;但如果你自己从源码部署、自己写 Docker 配置、或者在 Kubernetes 里重新定义环境变量——一定要自己把 `APP_ENV=production` 加上。
|
||||
</Callout>
|
||||
|
||||
检查你的部署是否有这个陷阱:打开登录页,输入**任意邮箱**请求验证码,再在验证码栏输 `888888`。如果能登进去 = 你的 `APP_ENV` 没设成 `production`,**整个实例处于完全开放状态**。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
可选。不配就只有 Email + 验证码登录;配了后登录页会多出「用 Google 登录」按钮。
|
||||
|
||||
1. 去 [Google Cloud Console](https://console.cloud.google.com/) 创建一个 OAuth 2.0 client
|
||||
2. **授权的回调 URI**(Authorized redirect URIs)填你的 Multica 前端地址加 `/auth/callback`,例如:
|
||||
|
||||
```text
|
||||
https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
3. 拿到 client ID 和 client secret 后设三个环境变量:
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
|
||||
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
4. 重启 server。
|
||||
|
||||
**热生效**:前端通过 `/api/config` 运行时读这些配置——改完只要重启 server,前端不用重建镜像、不用重新部署。
|
||||
|
||||
<Callout type="warning">
|
||||
**回调 URI 在 Google Console 和 `GOOGLE_REDIRECT_URI` 两处必须完全一致**,包括协议(`http` vs `https`)、尾部斜杠、端口。不一致 Google 会拒绝整个 OAuth 流程,用户看到的错误是 `redirect_uri_mismatch`。
|
||||
</Callout>
|
||||
|
||||
## 怎么限制谁能注册
|
||||
|
||||
三层环境变量按优先级组合:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
Start[新用户首次登录] --> A{email 在<br/>ALLOWED_EMAILS 里?}
|
||||
A -- 是 --> Allow[允许注册]
|
||||
A -- 否 --> B{domain 在<br/>ALLOWED_EMAIL_DOMAINS 里?}
|
||||
B -- 是 --> Allow
|
||||
B -- 否 --> C{任一白名单<br/>非空?}
|
||||
C -- 是 --> Block[拒绝]
|
||||
C -- 否 --> D{ALLOW_SIGNUP<br/>= true?}
|
||||
D -- 是 --> Allow
|
||||
D -- 否 --> Block
|
||||
`} />
|
||||
|
||||
**已经登录过的老用户永远可以再次登录**——signup 白名单只对**首次注册**生效,不拦截老用户。
|
||||
|
||||
- **`ALLOWED_EMAILS`**(最高优先级)—— 显式邮箱白名单,逗号分隔。**非空时只有列表里的邮箱能注册**。
|
||||
- **`ALLOWED_EMAIL_DOMAINS`**—— 域名白名单,逗号分隔(例如 `company.io,partner.com`)。
|
||||
- **`ALLOW_SIGNUP`** —— 总开关,默认 `true`。设 `false` 完全关闭注册。
|
||||
|
||||
<Callout type="warning">
|
||||
**三层白名单是 AND 语义,不是 OR。** 很多人第一直觉是「设 `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` 就是允许 company.io 和其他所有人」——**不是**。任何一层白名单只要设了非空值,**不匹配的邮箱直接拒**,`ALLOW_SIGNUP=true` 挡不住。
|
||||
|
||||
要真的「允许所有人」,所有三个环境变量都留空(或 `ALLOW_SIGNUP=true`)。
|
||||
</Callout>
|
||||
|
||||
**典型配法**:
|
||||
|
||||
| 需求 | 配置 |
|
||||
|---|---|
|
||||
| 公司内网,只允许 `company.io` 员工 | `ALLOWED_EMAIL_DOMAINS=company.io` |
|
||||
| 公司内网 + 几个外部合作者 | `ALLOWED_EMAIL_DOMAINS=company.io` + 合作者个人邮箱加到 `ALLOWED_EMAILS` |
|
||||
| 完全关闭自助注册,只能邀请 | `ALLOW_SIGNUP=false` |
|
||||
| 开放注册(不推荐生产用)| 三个都留空 |
|
||||
|
||||
## 关了注册还能邀请人进来吗
|
||||
|
||||
**只对已经有 Multica 账号的人能**。接受邀请那一步不检查 signup 白名单——如果对方已经注册过(比如在别的工作区),他们点链接登录就能直接接受。
|
||||
|
||||
**但还没注册过的人,邀请救不了他们**。他们接受邀请前必须先登录,登录的第一步(发验证码)会过 signup 白名单检查。如果你 `ALLOW_SIGNUP=false`、或他们的邮箱不在 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 里,他们**没法完成注册**,也就没法接受邀请。
|
||||
|
||||
要邀请一个还没注册的外部协作者:临时把他们的邮箱加到 `ALLOWED_EMAILS`,等他们注册 + 接受邀请之后再把这条移掉。
|
||||
|
||||
邀请的创建和使用见 [成员与权限](/members-roles)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [环境变量](/environment-variables) —— 这一页用到的环境变量完整定义
|
||||
- [认证与令牌](/auth-tokens) —— JWT / PAT / Daemon Token 的分类和使用
|
||||
- [故障排查](/troubleshooting) —— 验证码收不到、OAuth 报 `redirect_uri_mismatch`、注册被拒的常见排查
|
||||
80
apps/docs/content/docs/auth-tokens.mdx
Normal file
80
apps/docs/content/docs/auth-tokens.mdx
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: Authentication and tokens
|
||||
description: Multica has three kinds of tokens — one each for the browser, the CLI, and the daemon. When to use which.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica has three kinds of tokens, one for each context: the browser Web UI, the command line and scripts, and the daemon. All three represent the same you, but their scopes and lifetimes differ.
|
||||
|
||||
## The three tokens
|
||||
|
||||
| Token | Format | Where it's used | Lifetime |
|
||||
|---|---|---|---|
|
||||
| **JWT cookie** | `multica_auth` cookie (HttpOnly) | Web browser | 30 days |
|
||||
| **Personal access token (PAT)** | Prefixed with `mul_` | CLI, scripts, direct API calls | No expiry by default; when you create one via the API you can pass `expires_in_days` |
|
||||
| **Daemon token** | Prefixed with `mdt_` | Daemon-to-server communication | Managed by the daemon itself |
|
||||
|
||||
In day-to-day use you'll only touch the first two directly. The **[daemon](/daemon-runtimes) token** is created and refreshed automatically by `multica daemon login` — you don't have to think about it.
|
||||
|
||||
## What each token can hit
|
||||
|
||||
| API route | JWT cookie | PAT | Daemon token |
|
||||
|---|---|---|---|
|
||||
| `/api/user/*` (user-level actions) | ✓ | ✓ | ✗ |
|
||||
| `/api/workspaces/:id/*` (workspace-level) | ✓ | ✓ | ✗ |
|
||||
| `/api/daemon/*` (daemon-only) | ✗ | ✓ | ✓ |
|
||||
| WebSocket `/ws` (real-time push) | ✓ (cookie) | ✓ (authenticates via first message) | ✗ |
|
||||
|
||||
**A PAT can hit almost anything** — it represents "the full you." A daemon token can only do what the daemon needs: fetch tasks and report results.
|
||||
|
||||
**Both can hit `/api/daemon/*`, but their scopes differ.** A PAT represents an **entire user** — once authenticated, it can see every workspace you belong to. A daemon token is pinned to a single workspace at creation time and can only touch resources in that workspace. In production, run your daemon with a daemon token — don't take the shortcut of using a PAT, or you'll be granting far more privilege than the daemon needs.
|
||||
|
||||
## Logging in
|
||||
|
||||
### Email + verification code
|
||||
|
||||
1. Enter your email; the server sends a 6-digit code.
|
||||
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
|
||||
|
||||
<Callout type="warning">
|
||||
**Self-hosting operators, take note**: if `APP_ENV` is not set to `production`, the verification code is always `888888` — anyone can sign in as anyone. See [Self-host auth configuration](/auth-setup).
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
Click **Sign in with Google** and go through the standard OAuth callback. Self-hosting requires `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and the redirect URI to be configured — see [Self-host auth configuration](/auth-setup).
|
||||
|
||||
## Creating, viewing, and revoking a PAT
|
||||
|
||||
**Creating** a PAT can be done two ways:
|
||||
|
||||
- **Web UI**: Settings → Personal Access Tokens → New token
|
||||
- **CLI**: `multica login` creates one automatically if there's no local PAT yet
|
||||
|
||||
<Callout type="warning">
|
||||
**The full PAT is displayed exactly once when it's created.** After you refresh or close the dialog, you won't be able to see it again.
|
||||
|
||||
Multica stores only the hash of the PAT in the database — not even the server can retrieve the original. Copy and save it immediately. If you lose it, your only option is to revoke it and create a new one.
|
||||
</Callout>
|
||||
|
||||
**Viewing** existing PATs (name, creation time, last-used time — **not** the full token) lives under Settings → Personal Access Tokens.
|
||||
|
||||
**Revoking** a PAT: click Revoke in the list. Revocation takes effect immediately — the next request made with that PAT will be rejected with a 401.
|
||||
|
||||
## Logging out only deletes the local token
|
||||
|
||||
When you run `multica auth logout` or click log out in the Web UI:
|
||||
|
||||
- **The local token is cleared** — the CLI removes the PAT from `~/.multica/config.json`; the browser deletes the cookie.
|
||||
- **The PAT is still valid on the server** — if someone obtained your PAT before you logged out (for example, by copying it to another machine), they **can still use it**.
|
||||
|
||||
<Callout type="warning">
|
||||
**If you suspect your PAT has leaked, don't just log out.** Go to Settings → Personal Access Tokens and **revoke** the token. Only revocation invalidates a leaked token immediately.
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [CLI command reference](/cli) — authentication is automatic for every CLI command
|
||||
- [Self-host auth configuration](/auth-setup) — how to configure email, OAuth, and signup allowlists when self-hosting
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where the daemon token comes from
|
||||
80
apps/docs/content/docs/auth-tokens.zh.mdx
Normal file
80
apps/docs/content/docs/auth-tokens.zh.mdx
Normal file
@@ -0,0 +1,80 @@
|
||||
---
|
||||
title: 认证与令牌
|
||||
description: Multica 有三种令牌——浏览器、CLI、守护进程各用一种。什么场景用哪种。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令行 / 脚本、守护进程(daemon)。三种都代表同一个你,但作用域和有效期不同。
|
||||
|
||||
## 三种令牌
|
||||
|
||||
| 令牌 | 格式 | 用在哪 | 有效期 |
|
||||
|---|---|---|---|
|
||||
| **JWT Cookie** | `multica_auth` cookie(HttpOnly) | Web 浏览器 | 30 天 |
|
||||
| **个人访问令牌(PAT)** | 以 `mul_` 开头 | CLI / 脚本 / 直接调 API | 默认不过期;用 API 创建时可选传 `expires_in_days` |
|
||||
| **守护进程令牌(Daemon Token)** | 以 `mdt_` 开头 | Daemon 内部和 server 通信 | 由 daemon 自己管理 |
|
||||
|
||||
日常使用你只会直接接触前两种。**[守护进程](/daemon-runtimes)令牌**是 `multica daemon login` 自动生成和刷新的,你不用关心。
|
||||
|
||||
## 三种令牌能访问哪些 API
|
||||
|
||||
| API 路由 | JWT Cookie | PAT | Daemon Token |
|
||||
|---|---|---|---|
|
||||
| `/api/user/*`(用户级操作) | ✓ | ✓ | ✗ |
|
||||
| `/api/workspaces/:id/*`(工作区级) | ✓ | ✓ | ✗ |
|
||||
| `/api/daemon/*`(daemon 专用) | ✗ | ✓ | ✓ |
|
||||
| WebSocket `/ws`(实时推送) | ✓(cookie) | ✓(首条消息认证) | ✗ |
|
||||
|
||||
**PAT 几乎什么都能命中**——它代表"完整的你"。Daemon Token 能做的事非常有限,只够 daemon 拉任务和汇报结果。
|
||||
|
||||
**同样是访问 `/api/daemon/*`,两者作用域不同**:PAT 代表**一整个用户**——进来之后能看到你所有的工作区;daemon token 在创建时就绑死一个工作区,只能动这一个工作区的资源。生产部署用 daemon token 跑 daemon,不要图方便用 PAT——权限会被放大。
|
||||
|
||||
## 登录
|
||||
|
||||
### Email + 验证码
|
||||
|
||||
1. 填邮箱,server 发一封带 6 位验证码的邮件
|
||||
2. 输入验证码,server 签发 JWT cookie(浏览器)或交换出 PAT(CLI)
|
||||
|
||||
<Callout type="warning">
|
||||
**自部署运维注意**:如果环境变量 `APP_ENV` 不是 `production`,验证码恒为 `888888`——任何人能登录任何账号。详见 [自部署的认证配置](/auth-setup)。
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
点 **Sign in with Google**,走标准 OAuth 回调。自部署时需要配好 `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / redirect URI——详见 [自部署的认证配置](/auth-setup)。
|
||||
|
||||
## 创建、查看、撤销 PAT
|
||||
|
||||
**创建**有两种方式:
|
||||
|
||||
- **Web UI**:Settings → Personal Access Tokens → New token
|
||||
- **CLI**:`multica login` 在本地没有 PAT 时会自动创建一个
|
||||
|
||||
<Callout type="warning">
|
||||
**PAT 创建时完整内容只显示一次。** 刷新页面或关闭对话框之后就看不到了。
|
||||
|
||||
Multica 在数据库里只保存 PAT 的哈希值——服务端也查不回来。创建时**立即复制保存**。丢了只能撤销后重新创建。
|
||||
</Callout>
|
||||
|
||||
**查看**已签发的 PAT 列表(名字、创建时间、最后使用时间,**不含**完整令牌):Settings → Personal Access Tokens。
|
||||
|
||||
**撤销** PAT:在列表里点 Revoke。撤销是立即生效的——被撤销的 PAT 下一次请求就 401。
|
||||
|
||||
## 退出登录只是删本地令牌
|
||||
|
||||
执行 `multica auth logout` 或在 Web UI 点退出时:
|
||||
|
||||
- **本地令牌被清除** —— CLI 从 `~/.multica/config.json` 里删掉 PAT;Web 删 cookie
|
||||
- **服务端的 PAT 仍然有效** —— 如果登出前有人已经拿到过你的 PAT(比如复制到了另一台机器),他们**还能继续用**
|
||||
|
||||
<Callout type="warning">
|
||||
**如果怀疑 PAT 泄露,不要只 logout。** 去 Settings → Personal Access Tokens 把那个 PAT **撤销**。撤销才会让泄露出去的令牌立刻失效。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [CLI 命令速查](/cli) —— 每条 CLI 命令的认证是自动的
|
||||
- [自部署的认证配置](/auth-setup) —— 自部署时怎么配邮件 / OAuth / signup 白名单
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程令牌是从哪来的
|
||||
63
apps/docs/content/docs/chat.mdx
Normal file
63
apps/docs/content/docs/chat.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: Chat
|
||||
description: One-to-one conversation with an agent outside any issue — fully sandboxed. The agent cannot see or change issues, and nobody else can see the conversation.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
**Chat is a one-to-one conversation between you and an [agent](/agents)** — stepping outside the [issue](/issues) board. The agent sees no issues and cannot change any issue, and the entire conversation is **fully private** (nobody else in the [workspace](/workspaces), including admins, can see it). It fits discussing an approach with an agent, brainstorming, or asking a question that does not belong to any issue.
|
||||
|
||||
## Why not just @-mention the agent?
|
||||
|
||||
[@-mention](/mentioning-agents) **pulls the agent into** an issue's context — it reads the issue description and every historical comment, and it can change the issue. Chat flips this: **it pulls you out of** the issue — the agent only sees this single conversation, has no awareness of any issue, and has no entry point to modify one.
|
||||
|
||||
Two rules of thumb:
|
||||
|
||||
- You want feedback grounded in the context of a specific issue → [@-mention](/mentioning-agents)
|
||||
- You want to discuss a topic unrelated to any issue (or you do not want anyone else to see the discussion) → Chat
|
||||
|
||||
## Start a conversation
|
||||
|
||||
Open **Chat** from the sidebar, pick an agent, and start a new conversation. The interface feels like any messaging app: you send a message, the agent replies. Each message triggers a run in the background (an enqueued `task`), so replies may take a few seconds.
|
||||
|
||||
## What an agent can and cannot do in chat
|
||||
|
||||
Agents run in a **fully sandboxed** mode inside a conversation.
|
||||
|
||||
**Can do:**
|
||||
|
||||
- Answer the questions in your current message
|
||||
- Use its configured [skills](/skills) and MCP
|
||||
- Read and write files in its own working directory
|
||||
- Call `multica` CLI commands that do not need issue context (for example, querying basic workspace info)
|
||||
|
||||
**Cannot do:**
|
||||
|
||||
- **See any issue** — the prompt the agent receives has no issue IDs, and commands like `multica issue list` return empty
|
||||
- **Change any issue** — without issue context, API calls are blocked by permission checks
|
||||
- **See other conversations** — conversations are fully isolated
|
||||
- **@-mention anyone or any agent** — chat is a private space with no path to notify others
|
||||
|
||||
## How multi-turn context is preserved
|
||||
|
||||
Chat maintains multi-turn context via **provider session resumption** — the agent establishes a provider session on its first reply (for example, a Claude session), and the session ID is stored. On the next message, the task dispatch passes that ID back so the agent **resumes from where it left off** without re-reading history every time.
|
||||
|
||||
If **one turn fails**, Multica looks up the previous task that had established a session ID (whether that task succeeded or failed) and tries to resume — a single failure in the middle does not drop the memory of the whole conversation.
|
||||
|
||||
Note: not every provider actually implements session resumption — see the [**Providers Matrix**](/providers) for support status.
|
||||
|
||||
## Archive a conversation
|
||||
|
||||
Conversations you no longer want to see can be archived — right-click in the conversation list or use the "Archive" button on the detail page. After archiving:
|
||||
|
||||
- The conversation disappears from the active list (you can still find it in the "Archived" view)
|
||||
- Historical messages, session ID, and the working directory are all preserved — nothing is deleted
|
||||
|
||||
<Callout type="warning">
|
||||
**There is no "restore" button after archiving.** There is currently no entry point to move an archived conversation back to active. If you want to continue the thread later, you will need to start a new conversation. To revisit content in an archived conversation, open the "Archived" view and read through the history.
|
||||
</Callout>
|
||||
|
||||
## Next
|
||||
|
||||
- [**Routines**](/routines) — let agents start work automatically on a schedule
|
||||
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board
|
||||
63
apps/docs/content/docs/chat.zh.mdx
Normal file
63
apps/docs/content/docs/chat.zh.mdx
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: 对话
|
||||
description: 和智能体一对一独立聊天——完全沙盒,智能体看不到 issue、改不了 issue,也没人能看到你的对话。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
**对话(Chat)是你和 [智能体](/agents) 的一对一独立沟通**——跳出 [issue](/issues) 看板,智能体看不到任何 issue、也改不了 issue,整段对话**完全私人**([工作区](/workspaces) 里其他人、包括 admin 都看不到)。适合和智能体讨论方案、做 brainstorming、问一个不属于任何 issue 的问题。
|
||||
|
||||
## 为什么不用 @ 智能体就够
|
||||
|
||||
[@ 提及](/mentioning-agents) 把智能体**拉进** issue 的上下文——它会读 issue 的描述和所有历史评论,也能改 issue。对话反过来:**把你拉出** issue——智能体只看得到这一次对话,不知道 issue 存在,也没有修改 issue 的入口。
|
||||
|
||||
两条判据:
|
||||
|
||||
- 要智能体基于某个具体 issue 的上下文给反馈 → [@ 提及](/mentioning-agents)
|
||||
- 要和智能体聊一个不属于任何 issue 的话题(或不想让任何人看到讨论)→ 对话
|
||||
|
||||
## 开始一次对话
|
||||
|
||||
从侧边栏的 **Chat** 入口进,选一个智能体,开一段新对话。界面和普通聊天软件一样:你发消息,智能体回复。每条消息都会在后台触发一次执行(入队一个 `task`),所以回复可能要等几秒。
|
||||
|
||||
## 智能体在对话里能做什么、不能做什么
|
||||
|
||||
智能体在对话里跑在**完全沙盒**下。
|
||||
|
||||
**能做的**:
|
||||
|
||||
- 回答你当前消息里提的问题
|
||||
- 使用自己配置的 [skill](/skills) 和 MCP
|
||||
- 在自己的工作目录里读写文件
|
||||
- 调用不需要 issue 上下文的 `multica` CLI 命令(比如查询工作区基本信息)
|
||||
|
||||
**不能做的**:
|
||||
|
||||
- **看到任何 issue**——智能体收到的提示里没有 issue ID,`multica issue list` 之类命令对它返回空
|
||||
- **改任何 issue**——没有 issue 上下文,API 调用会被权限 check 拦截
|
||||
- **看到别的对话**——对话之间完全隔离
|
||||
- **@ 任何人或智能体**——对话是私人空间,没有通知别人的路径
|
||||
|
||||
## 多轮对话怎么保留上下文
|
||||
|
||||
对话用 **provider 会话恢复**机制维持多轮上下文——智能体第一次回复时建立一个 provider 会话(比如 Claude 的 session),session ID 被存起来;下一条消息派任务时把这个 ID 传回去,智能体**接着上次的状态继续**,不需要每次重新读历史。
|
||||
|
||||
如果**某一轮失败**,Multica 会查找上一轮建立过 session ID 的任务(不论它当时成功还是失败)并尝试 resume——不会因为中间一次出错就丢掉整段对话的记忆。
|
||||
|
||||
注意:并非所有 provider 都真正实现了 session 恢复——支持情况见 [**Providers Matrix**](/providers)。
|
||||
|
||||
## 归档对话
|
||||
|
||||
不想再看到的对话可以归档——在对话列表右键或详情页的"归档"按钮。归档后:
|
||||
|
||||
- 对话从活跃列表隐藏(可以在"已归档"视图里翻到)
|
||||
- 历史消息、session ID、工作目录完整保留,不会被删
|
||||
|
||||
<Callout type="warning">
|
||||
**归档之后没有"恢复"按钮**——目前没有把归档对话重新设回活跃的入口。如果后续还想继续这段对话,只能另起一个新对话。需要翻看归档对话里的内容时,去"已归档"视图读历史消息。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**Routines**](/routines) —— 让智能体定时自动开工
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上
|
||||
131
apps/docs/content/docs/cli.mdx
Normal file
131
apps/docs/content/docs/cli.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: CLI command reference
|
||||
description: One-page overview of every top-level Multica CLI command. For full usage, run `multica <command> --help`.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
The Multica CLI mirrors almost everything the Web UI can do (create [issues](/issues), assign [agents](/agents), start the [daemon](/daemon-runtimes), and more). This page lists every top-level command with a one-line description. For the full set of flags and examples, run `multica <command> --help`.
|
||||
|
||||
## Getting authenticated
|
||||
|
||||
Run this the first time you use the CLI to obtain a **personal access token (PAT)**:
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
Your browser opens automatically. After you approve in the web app, the CLI saves the PAT (prefixed with `mul_`) to `~/.multica/config.json`. Every subsequent command authenticates with that PAT.
|
||||
|
||||
<Callout type="tip">
|
||||
For CI or headless environments, skip the browser flow: create a PAT in the web app under **Settings → Personal Access Tokens**, then run `multica login --token <mul_...>` to supply it directly.
|
||||
</Callout>
|
||||
|
||||
For the difference between token types, see [Authentication and tokens](/auth-tokens).
|
||||
|
||||
## Auth and setup
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica login` | Log in and save a PAT |
|
||||
| `multica auth status` | Show current login status, user, and workspace |
|
||||
| `multica auth logout` | Clear the local PAT |
|
||||
| `multica setup cloud` | One-shot setup for Multica Cloud (login + install daemon) |
|
||||
| `multica setup self-host` | One-shot setup for a self-hosted backend |
|
||||
|
||||
## Workspaces and members
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica workspace list` | List every workspace you can access |
|
||||
| `multica workspace get <slug>` | Show details for one workspace |
|
||||
| `multica workspace members` | List members of the current workspace |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica issue list` | List issues |
|
||||
| `multica issue get <id>` | Show a single issue |
|
||||
| `multica issue create --title "..."` | Create a new issue |
|
||||
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
|
||||
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
|
||||
| `multica issue status <id> --set <status>` | Shortcut to change status |
|
||||
| `multica issue search <query>` | Keyword search |
|
||||
| `multica issue runs <id>` | Show agent runs on an issue |
|
||||
| `multica issue rerun <id>` | Rerun the most recent agent task |
|
||||
| `multica issue comment <id> ...` | Nested: view / post comments |
|
||||
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
|
||||
## Agents and skills
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica agent list` | List the workspace's agents |
|
||||
| `multica agent get <slug>` | Show an agent's configuration |
|
||||
| `multica agent create ...` | Create an agent |
|
||||
| `multica agent update <slug> ...` | Update an agent |
|
||||
| `multica agent archive <slug>` | Archive |
|
||||
| `multica agent restore <slug>` | Restore an archived agent |
|
||||
| `multica agent tasks <slug>` | Show an agent's task history |
|
||||
| `multica agent skills ...` | Nested: attach / detach skills |
|
||||
| `multica skill list/get/create/update/delete` | Skill CRUD |
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
## Routines (CLI command name: `autopilot`)
|
||||
|
||||
In the docs this feature is called **Routines**, but the CLI subcommand name is still `autopilot` — a future release will unify the two. If you're searching for "routines" and can't find it, try `multica autopilot --help`.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica autopilot list` | List every routine in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single routine |
|
||||
| `multica autopilot create ...` | Create a routine |
|
||||
| `multica autopilot update <id> ...` | Update |
|
||||
| `multica autopilot delete <id>` | Delete |
|
||||
| `multica autopilot runs <id>` | Show run history |
|
||||
| `multica autopilot trigger <id>` | Trigger a run manually |
|
||||
|
||||
## Daemon and runtimes
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica daemon start` | Start the daemon (background by default; add `--foreground` to run in the foreground) |
|
||||
| `multica daemon stop` | Stop the daemon |
|
||||
| `multica daemon restart` | Restart the daemon |
|
||||
| `multica daemon status` | Check whether the daemon is online and its concurrency |
|
||||
| `multica daemon logs` | View daemon logs |
|
||||
| `multica runtime list` | List runtimes in the current workspace |
|
||||
| `multica runtime usage` | Show resource usage |
|
||||
| `multica runtime activity` | Recent activity log |
|
||||
| `multica runtime ping <id>` | Ping a runtime to check it's online |
|
||||
| `multica runtime update <id> ...` | Update a runtime's configuration |
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica repo checkout <url>` | Clone a repo locally for agents to use |
|
||||
| `multica config` | View or edit local CLI configuration |
|
||||
| `multica version` | Print the CLI version |
|
||||
| `multica update` | Upgrade the CLI to the latest release |
|
||||
| `multica attachment download <id>` | Download an attachment from an issue or comment |
|
||||
|
||||
## Getting full flags
|
||||
|
||||
Every command supports `--help`:
|
||||
|
||||
```bash
|
||||
multica issue create --help
|
||||
multica agent update --help
|
||||
```
|
||||
|
||||
v2 will ship a dedicated detailed reference page for each command.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Authentication and tokens](/auth-tokens) — PAT vs. JWT vs. daemon token
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the `daemon` commands work under the hood
|
||||
- [Creating and configuring agents](/agents-create) — all options for `multica agent create`
|
||||
131
apps/docs/content/docs/cli.zh.mdx
Normal file
131
apps/docs/content/docs/cli.zh.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: CLI 命令速查
|
||||
description: Multica CLI 的所有顶级命令一页概览。完整用法查 `multica <命令> --help`。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica CLI 把 Web UI 能做的事几乎全部搬到了命令行上(创建 [issue](/issues)、分配 [智能体](/agents)、启动 [守护进程](/daemon-runtimes) 等等)。这一页把所有顶级命令列出来,每条配一句用途。完整 flag 和示例用 `multica <命令> --help` 查。
|
||||
|
||||
## 认证入口
|
||||
|
||||
第一次用 CLI 时先登录,拿一个**个人访问令牌(Personal Access Token,PAT)**:
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
浏览器会自动打开,你在 Web 端同意后,CLI 把 PAT(`mul_` 前缀)保存到 `~/.multica/config.json`。此后所有命令都会自动用这个 PAT 认证。
|
||||
|
||||
<Callout type="tip">
|
||||
CI / 无浏览器环境跳过浏览器流程:先在 Web 端 **Settings → Personal Access Tokens** 创建一个 PAT,然后 `multica login --token <mul_...>` 直接填入。
|
||||
</Callout>
|
||||
|
||||
Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
## 认证与初始化
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica login` | 登录并保存 PAT |
|
||||
| `multica auth status` | 查看当前登录状态、用户、工作区 |
|
||||
| `multica auth logout` | 清除本地 PAT |
|
||||
| `multica setup cloud` | Multica Cloud 一键初始化(登录 + 装 daemon) |
|
||||
| `multica setup self-host` | 自部署后端的一键初始化 |
|
||||
|
||||
## 工作区和成员
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica issue list` | 列出 issue |
|
||||
| `multica issue get <id>` | 查看单条 issue |
|
||||
| `multica issue create --title "..."` | 创建新 issue |
|
||||
| `multica issue update <id> ...` | 修改 issue(状态、优先级、分配人等) |
|
||||
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务) |
|
||||
| `multica issue status <id> --set <status>` | 快捷改状态 |
|
||||
| `multica issue search <query>` | 关键字搜索 |
|
||||
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
|
||||
| `multica issue rerun <id>` | 重跑最近一次智能体任务 |
|
||||
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
|
||||
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
|
||||
## 智能体和 Skill
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica agent list` | 列出工作区的智能体 |
|
||||
| `multica agent get <slug>` | 查看智能体配置 |
|
||||
| `multica agent create ...` | 创建智能体 |
|
||||
| `multica agent update <slug> ...` | 修改智能体 |
|
||||
| `multica agent archive <slug>` | 归档 |
|
||||
| `multica agent restore <slug>` | 恢复归档的智能体 |
|
||||
| `multica agent tasks <slug>` | 查看智能体的任务历史 |
|
||||
| `multica agent skills ...` | 嵌套:挂载 / 卸载 Skill |
|
||||
| `multica skill list/get/create/update/delete` | Skill CRUD |
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## Routines(CLI 命令名:`autopilot`)
|
||||
|
||||
文档里叫 **Routines**,但 CLI 子命令名保留为 `autopilot`——后续版本会统一。如果你在搜索 "routines" 相关命令但找不到,用 `multica autopilot --help`。
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica autopilot list` | 列出工作区所有 routine |
|
||||
| `multica autopilot get <id>` | 查看单个 routine |
|
||||
| `multica autopilot create ...` | 创建 routine |
|
||||
| `multica autopilot update <id> ...` | 修改 |
|
||||
| `multica autopilot delete <id>` | 删除 |
|
||||
| `multica autopilot runs <id>` | 查看运行历史 |
|
||||
| `multica autopilot trigger <id>` | 手动触发一次 |
|
||||
|
||||
## 守护进程和运行时
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica daemon start` | 启动 daemon(默认后台;加 `--foreground` 前台跑)|
|
||||
| `multica daemon stop` | 停止 daemon |
|
||||
| `multica daemon restart` | 重启 daemon |
|
||||
| `multica daemon status` | 查看 daemon 是否在线 + 并发情况 |
|
||||
| `multica daemon logs` | 查看 daemon 日志 |
|
||||
| `multica runtime list` | 列出当前工作区的 runtime |
|
||||
| `multica runtime usage` | 查看资源使用情况 |
|
||||
| `multica runtime activity` | 近期活动记录 |
|
||||
| `multica runtime ping <id>` | 立即戳一次 runtime 检查在线 |
|
||||
| `multica runtime update <id> ...` | 更新 runtime 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica repo checkout <url>` | 把 repo 拉到本地以供智能体使用 |
|
||||
| `multica config` | 查看 / 修改 CLI 本地配置 |
|
||||
| `multica version` | 显示 CLI 版本 |
|
||||
| `multica update` | 升级 CLI 到最新版 |
|
||||
| `multica attachment download <id>` | 下载 issue / 评论的附件 |
|
||||
|
||||
## 查完整 flag
|
||||
|
||||
每条命令都支持 `--help`:
|
||||
|
||||
```bash
|
||||
multica issue create --help
|
||||
multica agent update --help
|
||||
```
|
||||
|
||||
v2 会给每条命令一个独立的详细 reference 页。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [认证与令牌](/auth-tokens) —— PAT / JWT / Daemon Token 的区别
|
||||
- [守护进程与运行时](/daemon-runtimes) —— `daemon` 命令背后的工作机制
|
||||
- [创建和配置智能体](/agents-create) —— `multica agent create` 的完整选项
|
||||
@@ -68,7 +68,7 @@ multica setup
|
||||
|
||||
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
|
||||
|
||||
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
|
||||
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/getting-started/self-hosting) for details.
|
||||
|
||||
## Verify
|
||||
|
||||
@@ -212,7 +212,7 @@ multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -227,7 +227,7 @@ multica issue get <id> --output json
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -281,6 +281,70 @@ multica issue run-messages <task-id> --output json
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
## Projects
|
||||
|
||||
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
|
||||
belongs to a workspace and can optionally have a lead (member or agent).
|
||||
|
||||
### List Projects
|
||||
|
||||
```bash
|
||||
multica project list
|
||||
multica project list --status in_progress
|
||||
multica project list --output json
|
||||
```
|
||||
|
||||
Available filters: `--status`.
|
||||
|
||||
### Get Project
|
||||
|
||||
```bash
|
||||
multica project get <id>
|
||||
multica project get <id> --output json
|
||||
```
|
||||
|
||||
### Create Project
|
||||
|
||||
```bash
|
||||
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Update Project
|
||||
|
||||
```bash
|
||||
multica project update <id> --title "New title" --status in_progress
|
||||
multica project update <id> --lead "Lambda"
|
||||
```
|
||||
|
||||
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
multica project status <id> in_progress
|
||||
```
|
||||
|
||||
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
|
||||
|
||||
### Delete Project
|
||||
|
||||
```bash
|
||||
multica project delete <id>
|
||||
```
|
||||
|
||||
### Associating Issues with Projects
|
||||
|
||||
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
|
||||
project, or on `issue list` to filter issues by project:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Login bug" --project <project-id>
|
||||
multica issue update <issue-id> --project <project-id>
|
||||
multica issue list --project <project-id>
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
119
apps/docs/content/docs/cloud-quickstart.mdx
Normal file
119
apps/docs/content/docs/cloud-quickstart.mdx
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Cloud quickstart
|
||||
description: From sign-up to assigning your first task to an agent in 5 minutes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
|
||||
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
|
||||
## 1. Create an account
|
||||
|
||||
Sign up at [multica.ai](https://multica.ai). You can log in with email (6-digit verification code) or Google.
|
||||
|
||||
After sign-up you're automatically placed in a default workspace (generated from your account name). You can rename it later, or create new workspaces.
|
||||
|
||||
## 2. Install the Multica CLI
|
||||
|
||||
**macOS / Linux (Homebrew recommended)**:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
**macOS / Linux (no Homebrew)**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Verify the install:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
## 3. Log in + start the daemon
|
||||
|
||||
A single command handles login and starts the daemon:
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
`multica setup` will:
|
||||
|
||||
1. Configure the CLI to connect to Multica Cloud
|
||||
2. Open your browser for login (same email verification code / Google OAuth as the web)
|
||||
3. Store the generated PAT in `~/.multica/config.json`
|
||||
4. **Start the daemon automatically** — it begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds
|
||||
|
||||
<Callout type="info">
|
||||
**Using the desktop app?** The desktop app **starts the daemon automatically** on launch — no need to run `multica setup` by hand. See [Desktop app](/desktop-app).
|
||||
</Callout>
|
||||
|
||||
Verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
`online` means it has registered with the server.
|
||||
|
||||
## 4. Verify the runtime is online
|
||||
|
||||
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
|
||||
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
|
||||
## 5. Create an agent
|
||||
|
||||
In the web UI, go to **Settings → Agents** and click **New Agent**:
|
||||
|
||||
- **Name** — the name shown for this agent on boards and in comments. Pick something you like
|
||||
- **Provider** — choose an AI coding tool you have installed locally (the dropdown only lists tools detected by your runtimes)
|
||||
- **Model** (optional) — the model selection inside that tool (a static list or dynamic discovery, depending on the provider)
|
||||
- **Instructions** (optional) — system prompt for this agent
|
||||
|
||||
Once created, the agent shows up in your workspace member list and can be assigned work like a human member.
|
||||
|
||||
## 6. Assign your first task
|
||||
|
||||
Create an issue in the web UI, or from the CLI:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Add an ASCII architecture diagram to the README"
|
||||
```
|
||||
|
||||
Assign the issue to the agent you just created — click its avatar in the web UI, or use the CLI:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
1. It picks up the task within 3 seconds (status goes from `queued` to `dispatched`)
|
||||
2. It invokes the matching AI coding tool to start work (status becomes `running`)
|
||||
3. The AI works locally — it may read your code directory, run commands, edit files
|
||||
4. When done, it reports the result back to Multica (status becomes `completed` or `failed`, depending on whether auto-retry kicks in)
|
||||
|
||||
The web UI updates in **real time** (via WebSocket) — no refresh needed.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
|
||||
- [Tasks](/tasks) — task lifecycle and retry rules
|
||||
- [AI coding tools compared](/providers) — capability differences across the 10 tools
|
||||
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
|
||||
- [Self-host quickstart](/self-host-quickstart) — run your own backend
|
||||
119
apps/docs/content/docs/cloud-quickstart.zh.mdx
Normal file
119
apps/docs/content/docs/cloud-quickstart.zh.mdx
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: Cloud 快速上手
|
||||
description: 5 分钟从注册到给智能体分配第一个任务。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
|
||||
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
|
||||
## 1. 注册账号
|
||||
|
||||
到 [multica.ai](https://multica.ai) 注册账号。可以用邮箱(6 位验证码)或 Google 登录。
|
||||
|
||||
注册完成后你会被自动分到一个默认工作区(以你的账号名生成)。之后可以改名字,也可以创建新的工作区。
|
||||
|
||||
## 2. 装 Multica 命令行工具
|
||||
|
||||
**macOS / Linux(推荐走 Homebrew)**:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
**macOS / Linux(没有 Homebrew)**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
装完验证一下:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
## 3. 登录 + 启动守护进程
|
||||
|
||||
一条命令完成登录 + 启动守护进程:
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
`multica setup` 会:
|
||||
|
||||
1. 把命令行工具配置成连接 Multica Cloud
|
||||
2. 打开浏览器让你登录(和 Web 登录一样的邮箱验证码 / Google OAuth)
|
||||
3. 把生成的 PAT 存到 `~/.multica/config.json`
|
||||
4. **自动启动守护进程**——开始每 3 秒轮询任务、每 15 秒发心跳
|
||||
|
||||
<Callout type="info">
|
||||
**用的是桌面应用?** 桌面应用启动时**自动拉起守护进程**,不需要手动跑 `multica setup`。见 [桌面应用](/desktop-app)。
|
||||
</Callout>
|
||||
|
||||
验证守护进程在运行:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
看到 `online` 就说明它成功注册到服务器了。
|
||||
|
||||
## 4. 验证 Runtime 在线
|
||||
|
||||
到 Web 界面的 **Settings → Runtimes**,你应该能看到你刚启动的守护进程作为一个或多个活跃 Runtime 列出——每款你本地装好的 AI 编程工具对应一个。
|
||||
|
||||
看到"离线"不要慌,先看 [故障排查 → 守护进程连不上服务器](/troubleshooting#守护进程连不上服务器)。
|
||||
|
||||
## 5. 创建智能体
|
||||
|
||||
到 Web 界面的 **Settings → Agents**,点 **New Agent**:
|
||||
|
||||
- **名字**——智能体在看板上、评论里显示的名字,自己起一个
|
||||
- **Provider**——选一款你本地装好的 AI 编程工具(下拉里只会出现运行时里检测到的那些)
|
||||
- **Model**(可选)——这款工具内部的模型选择(静态列表或动态发现,取决于 provider)
|
||||
- **Instructions**(可选)——给这个智能体的系统提示词
|
||||
|
||||
创建完成后智能体就进入你的工作区成员列表,可以像人类成员一样被分配任务。
|
||||
|
||||
## 6. 分配第一个任务
|
||||
|
||||
在 Web 界面创建一条 issue,或者用命令行:
|
||||
|
||||
```bash
|
||||
multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
```
|
||||
|
||||
把这条 issue 分配给你刚创建的那个智能体——可以在 Web 上点它的头像,或用命令行:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
1. 3 秒内领走这条任务(任务状态从 `queued` 变 `dispatched`)
|
||||
2. 调用对应的 AI 编程工具开始执行(状态变 `running`)
|
||||
3. AI 在本地工作——可能会读你的代码目录、执行命令、编辑文件
|
||||
4. 结束后把结果发回 Multica(状态变 `completed` 或 `failed`,根据是否自动重试)
|
||||
|
||||
Web 界面会**实时**(通过 WebSocket)显示进度——不需要刷新。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
|
||||
- [执行任务](/tasks) —— 任务生命周期、重试规则
|
||||
- [AI 编程工具对照](/providers) —— 10 款工具的能力差异
|
||||
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
|
||||
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套
|
||||
59
apps/docs/content/docs/comments.mdx
Normal file
59
apps/docs/content/docs/comments.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: Comments and mentions
|
||||
description: Collaborating under an issue — comments, replies, `@` mentions, reactions, and triggering agents from a comment.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Every [issue](/issues) has a comment thread. Post comments, reply to someone, `@` a [member](/members-roles) or an [agent](/agents), add reactions — the same moves you make in any task manager you've used. The one difference: **mentioning an agent with `@` triggers it to start working.**
|
||||
|
||||
## Posting a comment
|
||||
|
||||
Type into the input at the bottom of the issue detail page and hit **Send**. The comment appears in the thread immediately. Comments support Markdown — headings, lists, code blocks, links, all available.
|
||||
|
||||
## Replying to a comment
|
||||
|
||||
Click **Reply** on the top-right of any comment to open a nested input underneath it. Your reply is displayed as a child of that comment, forming a conversation thread. Replies can have their own replies, nesting as deep as you need.
|
||||
|
||||
The issue list shows only the top-level comment count; opening the issue reveals the full conversation tree.
|
||||
|
||||
## Reactions
|
||||
|
||||
Each comment has a reaction button in the top-right for quick signals (👍, 👀, 🎉) — no need to post a "+1" comment to agree.
|
||||
|
||||
## `@` mentions
|
||||
|
||||
Typing `@` in a comment opens a picker. Choose a member or an agent, and `@` plus the target's slug gets inserted (`@alice` or `@reviewer-bot`). The mentioned party gets a notification in their [inbox](/inbox).
|
||||
|
||||
**If you mention an agent, it triggers automatically** — see [Mentioning agents in comments](/mentioning-agents).
|
||||
|
||||
Mentioning the same person multiple times in one comment still produces **only one** notification.
|
||||
|
||||
### `@all` notifies the entire workspace
|
||||
|
||||
`@all` is a special target: it pushes a notification to every member of the workspace. Both people and agents can use `@all` — which means an agent reporting progress could also `@all`, so remind agents in their instructions to use it sparingly.
|
||||
|
||||
<Callout type="warning">
|
||||
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not routine updates.
|
||||
</Callout>
|
||||
|
||||
## Editing and deleting a comment
|
||||
|
||||
Only the author of a comment can edit or delete it.
|
||||
|
||||
Deleting a comment also **deletes every reply** under it (including replies to replies). To change content only, use edit instead.
|
||||
|
||||
<Callout type="warning">
|
||||
**Adding an `@` while editing a comment does not trigger the agent.** The trigger fires the moment a comment is **created** — editing to add a new `@`, or changing the target, does not send a new notification or wake the agent. To summon an agent you missed, **post a new comment** that `@`s it.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
Everything we've covered so far is "the human world" — workspaces, members, issues, projects, comments. If you've used Linear or Jira, none of it should feel unfamiliar.
|
||||
|
||||
But Multica's defining trait hasn't entered the picture yet: **treating agents as first-class members of a workspace**. That's what we turn to next.
|
||||
|
||||
## Next
|
||||
|
||||
- [Agents](/agents) — what they are, and how they differ from people
|
||||
- [Mentioning agents in comments](/mentioning-agents) — use `@` in a comment to start an agent
|
||||
59
apps/docs/content/docs/comments.zh.mdx
Normal file
59
apps/docs/content/docs/comments.zh.mdx
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: 评论与提及
|
||||
description: 在 issue 下协作——评论、回复、@ 提及、表情反应,以及在评论里触发智能体工作。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
每个 [issue](/issues) 都有一个评论区。你可以在里面发评论、回复别人、用 `@` 点名 [成员](/members-roles) 或 [智能体](/agents)、加表情反应——和你在熟悉的任务管理工具里做的是同一件事。唯一不同的是:**`@` 一个智能体会自动触发它开始工作**。
|
||||
|
||||
## 发评论
|
||||
|
||||
在 issue 详情页底部的输入框里写内容,点**发送**,评论立刻出现在评论流里。评论支持 Markdown——标题、列表、代码块、链接都能用。
|
||||
|
||||
## 回复某条评论
|
||||
|
||||
点任意一条评论右上角的**回复**,会在这条评论下方展开嵌套输入框。你写的回复会显示为这条评论的子项,形成一条对话线。回复之下还能继续回复,层层展开。
|
||||
|
||||
在 issue 列表里看到的只是顶层评论数,点进 issue 里才能看到完整的对话树。
|
||||
|
||||
## 表情反应
|
||||
|
||||
每条评论右上角可以加表情反应(比如 👍、👀、🎉),用来快速表态——不用为了赞同单独发一条"+1"。
|
||||
|
||||
## `@` 提及
|
||||
|
||||
在评论里输入 `@` 会弹出提示,从里面选一个成员或智能体,`@` 后面会填入对方的 slug(比如 `@alice` 或 `@reviewer-bot`)。被提及的人会在自己的 [收件箱](/inbox) 里收到通知。
|
||||
|
||||
**如果你提及的是一个智能体,它会被自动触发开始工作**——详见 [在评论里召唤智能体](/mentioning-agents)。
|
||||
|
||||
同一条评论里 `@` 同一个人多次,对方只会收到**一条**通知。
|
||||
|
||||
### `@all` 会通知整个工作区
|
||||
|
||||
`@all` 是一个特殊目标:它会把通知推送给工作区里的每一个成员。人和智能体都能发 `@all`——这意味着被触发的智能体在汇报进展时也可能 `@all`,需要在智能体的指令里提醒它谨慎使用。
|
||||
|
||||
<Callout type="warning">
|
||||
**谨慎使用 `@all`**。工作区人数较多时,一条 `@all` 的评论会瞬间生成同等数量的收件箱通知。只在确实需要全员知晓的重大事项上使用——不是日常琐事。
|
||||
</Callout>
|
||||
|
||||
## 编辑和删除评论
|
||||
|
||||
只有评论的作者能编辑或删除自己的评论。
|
||||
|
||||
删除一条评论会**一并删除**它下面的所有回复(包括回复的回复)。如果只是想改内容,用编辑功能。
|
||||
|
||||
<Callout type="warning">
|
||||
**编辑评论里加 `@` 不会触发智能体**。触发发生在评论**创建**那一刻——事后修改评论内容加入新的 `@`、或改 `@` 对象,系统不会重新发通知、也不会唤醒智能体。要召唤一个没触发到的智能体,**发一条新的评论** `@` 它。
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
到这里,我们讲的都是"人的世界"——工作区、成员、issue、project、评论。如果你熟悉 Linear 或 Jira 之类的产品,到目前为止的内容应该没有陌生感。
|
||||
|
||||
但 Multica 的核心特色还没登场:**把智能体作为工作区的一等公民成员**。下一章开始,我们正式认识这个新物种。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [智能体](/agents) —— 它们是什么、和人有什么区别
|
||||
- [在评论里召唤智能体](/mentioning-agents) —— 用 `@` 在评论里触发智能体开工
|
||||
111
apps/docs/content/docs/daemon-runtimes.mdx
Normal file
111
apps/docs/content/docs/daemon-runtimes.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: Daemon and runtimes
|
||||
description: Agents don't run on Multica's servers — they run on your own machines.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
In Multica, [agents](/agents) do **not** run on our servers — they run on your own machines, driven by a small program called the **daemon** that invokes the [AI coding tools](/providers) installed locally. The Multica server only coordinates: it stores [issues](/issues), queues [tasks](/tasks), and dispatches them to the right **runtime** (runtime = daemon × one AI coding tool).
|
||||
|
||||
This structure is the biggest difference between Multica and Linear / Jira: **your API keys, toolchain, and code directories stay on your machine** — the Multica server never sees any of them. That means "my agent isn't working" is almost always a local problem — the daemon isn't running, an AI tool isn't installed, a key has expired. Check locally first; see [Troubleshooting](/troubleshooting) for a guide.
|
||||
|
||||
## Starting the daemon
|
||||
|
||||
The daemon is part of the Multica CLI. Once you've installed the [Multica CLI](/cli), run on your own machine:
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
On startup it does four things:
|
||||
|
||||
1. Reads the credentials saved when you logged in
|
||||
2. Detects AI coding tools installed on your `PATH` (10 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. Registers itself with the server, along with a runtime for each detected tool
|
||||
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
|
||||
|
||||
Common commands:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica daemon start` | Start (background by default; add `--foreground` to run in the foreground) |
|
||||
| `multica daemon stop` | Stop |
|
||||
| `multica daemon restart` | Restart |
|
||||
| `multica daemon status` | Show status |
|
||||
| `multica daemon logs` | Show logs (add `-f` to follow) |
|
||||
|
||||
Full CLI reference in [CLI commands](/cli).
|
||||
|
||||
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup.
|
||||
|
||||
## Why one machine has multiple runtimes
|
||||
|
||||
A runtime is not a server and not a container — it's the combination of "**daemon × one AI coding tool**". For example: you start the daemon on a MacBook with both Claude Code and Codex installed, and you're a member of two workspaces. Multica then registers 4 runtimes:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
D["Your daemon<br/>MacBook"]
|
||||
D --> R1["Runtime<br/>Workspace A × Claude Code"]
|
||||
D --> R2["Runtime<br/>Workspace A × Codex"]
|
||||
D --> R3["Runtime<br/>Workspace B × Claude Code"]
|
||||
D --> R4["Runtime<br/>Workspace B × Codex"]
|
||||
`} />
|
||||
|
||||
Key points:
|
||||
|
||||
- **One daemon can map to multiple runtimes** — one per combination of installed tool and workspace you belong to
|
||||
- **The same daemon, workspace, and tool produces exactly one runtime** — restarting the daemon never creates duplicate records
|
||||
- The **Runtimes** page in the Multica UI lists these rows
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently in a waitlist phase. Once available, you'll be able to execute agent tasks directly on Multica Cloud without running a local daemon. Sign up with your email on the [download page](https://multica.ai/download) to get notified.
|
||||
</Callout>
|
||||
|
||||
## When a runtime is marked offline
|
||||
|
||||
Multica uses heartbeats to decide whether a runtime is online. Three key numbers:
|
||||
|
||||
| Event | Threshold |
|
||||
|---|---|
|
||||
| Daemon heartbeat frequency | Every **15 seconds** |
|
||||
| Marked as missing | No heartbeat for **45 seconds** (3 missed beats) |
|
||||
| Auto-deleted | Missing with no associated agents for over **7 days** |
|
||||
|
||||
Missing is not permanent — as soon as the daemon sends another heartbeat it returns to online, and the runtime record is preserved. Restarting the daemon does not lose runtimes.
|
||||
|
||||
<Callout type="warning">
|
||||
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Routines-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
|
||||
</Callout>
|
||||
|
||||
## How many tasks can run in parallel
|
||||
|
||||
Multica enforces concurrency limits at two layers:
|
||||
|
||||
- **Daemon layer**: **20 concurrent tasks** by default (tunable via env var `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`)
|
||||
- **Agent layer**: **6 concurrent tasks per agent** by default (configured per-agent)
|
||||
|
||||
The tighter of the two wins. If your daemon is already running 20 tasks, new tasks wait even if an agent still has headroom.
|
||||
|
||||
If you see tasks stuck in `queued` without moving to `dispatched`, one of these two limits is usually saturated.
|
||||
|
||||
## What happens to in-flight tasks after a daemon crash
|
||||
|
||||
When the daemon crashes or is force-killed, the tasks it had picked up are left in `dispatched` or `running`. On the next start, the daemon tells the server: "these tasks are no longer mine, please mark them failed." The server flips them to `failed` with reason `runtime_recovery` — for retryable sources, the tasks are automatically requeued.
|
||||
|
||||
Even if this step fails due to a network issue, there's a server-side scan **every 30 seconds** as a backstop: any runtime without a heartbeat for over 45 seconds is marked missing, and its tasks are reclaimed along with it.
|
||||
|
||||
## Troubleshooting agents that aren't working
|
||||
|
||||
When you hit a "my agent isn't working" problem, run this three-step checklist first:
|
||||
|
||||
1. Run `multica daemon status` — confirm the daemon is running and online
|
||||
2. Run `multica daemon logs -f` — check for errors
|
||||
3. Open the **Runtimes** page in the Multica UI — confirm your runtime shows "online"
|
||||
|
||||
More scenarios in [Troubleshooting](/troubleshooting).
|
||||
|
||||
## Next
|
||||
|
||||
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
|
||||
- [Providers Matrix](/providers) — capability differences across the 10 AI coding tools
|
||||
111
apps/docs/content/docs/daemon-runtimes.zh.mdx
Normal file
111
apps/docs/content/docs/daemon-runtimes.zh.mdx
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
title: 守护进程与运行时
|
||||
description: 智能体不在 Multica 服务器上运行——它们跑在你自己的机器上。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
在 Multica 里,[智能体](/agents) **不**在我们的服务器上运行——它们跑在你自己的机器上,由一个叫**守护进程**(daemon)的小程序调用本地安装的 [AI 编程工具](/providers)。Multica 服务器只做协调:存 [issue](/issues)、排 [任务](/tasks)、派发给正确的**运行时**(runtime = 守护进程 × 一款 AI 编程工具)。
|
||||
|
||||
这个结构带来 Multica 和 Linear / Jira 最大的差别:**你的 API 密钥、工具链、代码目录都留在本地**,Multica 服务器一个都看不到。"我的智能体不工作"类问题几乎都是本地问题——守护进程没启动、某款 AI 工具没装、密钥过期——请先从本地查起;定位指引见 [故障排查](/troubleshooting)。
|
||||
|
||||
## 启动守护进程
|
||||
|
||||
守护进程是 Multica CLI 的一部分。装好 [Multica CLI](/cli) 后,在自己机器上跑:
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
启动后它会做四件事:
|
||||
|
||||
1. 读取你登录时保存的凭证
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 10 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
|
||||
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
|
||||
|
||||
常用命令:
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `multica daemon start` | 启动(默认后台,加 `--foreground` 前台运行)|
|
||||
| `multica daemon stop` | 停止 |
|
||||
| `multica daemon restart` | 重启 |
|
||||
| `multica daemon status` | 查看状态 |
|
||||
| `multica daemon logs` | 查看日志(加 `-f` 跟随)|
|
||||
|
||||
完整 CLI 参考见 [CLI 命令速查](/cli)。
|
||||
|
||||
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。
|
||||
|
||||
## 为什么一台机器会有多个运行时
|
||||
|
||||
运行时不是一个服务器,也不是一个容器——它是「**守护进程 × 一款 AI 编程工具**」的组合。举例:你在一台 MacBook 上启动守护进程,本机装了 Claude Code 和 Codex;你是两个工作区的成员。那么 Multica 会注册 4 个运行时:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
D["你的守护进程<br/>MacBook"]
|
||||
D --> R1["运行时<br/>工作区 A × Claude Code"]
|
||||
D --> R2["运行时<br/>工作区 A × Codex"]
|
||||
D --> R3["运行时<br/>工作区 B × Claude Code"]
|
||||
D --> R4["运行时<br/>工作区 B × Codex"]
|
||||
`} />
|
||||
|
||||
关键的点:
|
||||
|
||||
- **一个守护进程可以对应多个运行时**——装了多款工具、加入了多个工作区,每个组合就各一个
|
||||
- **同一个守护进程在同一个工作区同一款工具上只会有一条运行时**——重启守护进程不会产生重复记录
|
||||
- Multica 界面的 **Runtimes** 页面列的就是这些行
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
</Callout>
|
||||
|
||||
## 运行时什么时候被判定为离线
|
||||
|
||||
Multica 用心跳判断运行时是否在线。三个关键数字:
|
||||
|
||||
| 事件 | 阈值 |
|
||||
|---|---|
|
||||
| 守护进程心跳频率 | 每 **15 秒** |
|
||||
| 标记为失联 | 超过 **45 秒** 没心跳(漏了 3 次)|
|
||||
| 自动删除 | 失联且无关联智能体超过 **7 天** |
|
||||
|
||||
失联不是永久的——守护进程只要再次发出心跳就立刻回到在线,运行时记录也会保留。重启守护进程不会丢运行时。
|
||||
|
||||
<Callout type="warning">
|
||||
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`)。对可重试的来源(issue、chat),Multica 会自动重新排队;Routines 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
|
||||
</Callout>
|
||||
|
||||
## 一次能并发跑多少任务
|
||||
|
||||
Multica 对并发有两层限额:
|
||||
|
||||
- **守护进程层**:默认 **20 个执行任务并发**(环境变量 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` 可调)
|
||||
- **智能体层**:每个智能体默认 **6 个执行任务并发**(智能体配置里改)
|
||||
|
||||
两层中更紧的那层生效。如果你的守护进程已经在跑 20 个任务,即使某个智能体还有余量,新的任务也要等。
|
||||
|
||||
如果你看到执行任务卡在 `queued` 状态不 `dispatched`,通常就是这两层里某一层打满了。
|
||||
|
||||
## 守护进程崩溃后,没跑完的任务会怎样
|
||||
|
||||
守护进程崩溃或被强行结束时,它领走的执行任务会停在 `dispatched` 或 `running` 状态。下次启动时,守护进程会告诉服务器:「这些任务不是我的了,请标记失败。」服务器把它们改成 `failed`,失败原因 `runtime_recovery`——对可重试的来源,任务自动重新排队。
|
||||
|
||||
即使这一步因网络问题没完成,还有**每 30 秒**一次的服务器端扫描作为后备:超过 45 秒没心跳的运行时会被统一标记为失联,上面的任务也一并回收。
|
||||
|
||||
## Agent 不工作怎么排查
|
||||
|
||||
遇到「我的智能体不工作」类问题,先过一遍这三步:
|
||||
|
||||
1. 跑 `multica daemon status`,确认守护进程在运行且在线
|
||||
2. 跑 `multica daemon logs -f`,看是否有错误
|
||||
3. 去 Multica 界面的 **Runtimes** 页面,确认你的运行时显示「在线」
|
||||
|
||||
更多场景见 [Troubleshooting](/troubleshooting)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
|
||||
- [Providers Matrix](/providers) —— 10 款 AI 编程工具的能力差异对照
|
||||
77
apps/docs/content/docs/desktop-app.mdx
Normal file
77
apps/docs/content/docs/desktop-app.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: Desktop app
|
||||
description: What Multica Desktop is, how it differs from the web app, and when it's worth using.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. It talks to the same backend as the web app and shows the same data, but it adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
|
||||
## Desktop or web — which to pick
|
||||
|
||||
| | Web | Desktop |
|
||||
|---|---|---|
|
||||
| Access | Open a URL in your browser | Install a native app |
|
||||
| Multiple tabs | Your browser's own tabs (no workspace separation) | **One independent tab group per workspace** |
|
||||
| Daemon | You run `multica daemon start` yourself | **Started automatically** on launch |
|
||||
| Upgrades | Refresh to get the latest | App checks in the background and installs on next launch |
|
||||
| Signed-in data | Identical | Identical |
|
||||
|
||||
**Pick web** for one-off use, working on someone else's machine, or when you'd rather not install anything.
|
||||
**Pick desktop** for daily use, juggling multiple workspaces, or avoiding manual daemon management.
|
||||
|
||||
## Multiple tabs: what happens when you switch workspaces
|
||||
|
||||
Desktop maintains an independent tab group for **every workspace you've joined**. When you switch workspaces, the current workspace's tabs are hidden as a unit and the previous workspace's tabs are restored as you left them — similar to VSCode's multi-workspace behavior or switching workspaces in Slack.
|
||||
|
||||
Example: you open 3 issue tabs in workspace A and switch to workspace B. A's 3 tabs disappear, and B shows whatever you last had open in B. Switch back to A and those 3 tabs come back exactly as they were. **Tabs never leak across workspaces.**
|
||||
|
||||
Logging out **clears every workspace's tab state**, so you don't leak data when a machine is shared between users.
|
||||
|
||||
## How Desktop auto-updates
|
||||
|
||||
On launch, Desktop checks GitHub Releases for a newer version. If one is found:
|
||||
|
||||
1. It downloads the new version silently in the background.
|
||||
2. It tells you "ready — will install on next launch."
|
||||
3. When you quit (or next restart), the app installs the update before closing.
|
||||
4. The next launch runs the new version.
|
||||
|
||||
The whole process **doesn't interrupt what you're working on**.
|
||||
|
||||
<Callout type="warning">
|
||||
**On Windows, ARM64 and x64 are separate update channels** — install the wrong architecture and updates won't be detected. When you download, pick the `.exe` that matches your machine (the ARM build has an `arm64` suffix).
|
||||
</Callout>
|
||||
|
||||
The macOS build is signed and notarized, so you won't see an "unidentified developer" warning on first launch. The Linux build is an `.AppImage` — auto-updates rely on electron-updater, which can be flaky on some distros. **If auto-update doesn't work, download the new version manually and replace the old file.**
|
||||
|
||||
## Do I still need the standalone CLI and daemon?
|
||||
|
||||
**No.** Desktop ships with the same `multica` CLI binary embedded inside it, and it launches its own daemon profile at startup (isolated from any daemon you may be running manually from the terminal).
|
||||
|
||||
If you've already installed the CLI and run `multica daemon start` by hand, Desktop won't take over your daemon — it starts its own with a separate profile. Both register as **different runtimes**, and you'll see two independent runtimes in the UI.
|
||||
|
||||
If you want to run CLI commands in your terminal, Desktop doesn't offer a special path — use the CLI you installed separately, or run the bundled copy at `resources/bin/multica` inside the app's resources directory.
|
||||
|
||||
## Downloading and installing
|
||||
|
||||
Grab the installer for your platform from the [Multica downloads page](https://multica.ai/download):
|
||||
|
||||
| Platform | File |
|
||||
|---|---|
|
||||
| macOS (Intel or Apple Silicon) | `.dmg` |
|
||||
| Windows x64 | `.exe` (standard) |
|
||||
| Windows ARM64 | `.exe` (with `arm64` suffix) |
|
||||
| Linux | `.AppImage` |
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="info">
|
||||
**Which backend Desktop connects to** is determined by the address you select at sign-in. It defaults to Multica Cloud; if you're running self-hosted, click "Connect to a self-hosted instance" on the first login screen and fill in your server address.
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — connecting Desktop to a self-hosted backend
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
77
apps/docs/content/docs/desktop-app.zh.mdx
Normal file
77
apps/docs/content/docs/desktop-app.zh.mdx
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
title: 桌面应用
|
||||
description: Multica Desktop 是什么、和 Web 有什么区别、什么时候值得用。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端,看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
|
||||
## Desktop 和 Web 该用哪个
|
||||
|
||||
| | Web | Desktop |
|
||||
|---|---|---|
|
||||
| 访问方式 | 浏览器打开 URL | 装一个本地应用 |
|
||||
| 多标签页 | 浏览器自己的标签页(不区分工作区)| **每个工作区一组独立标签页** |
|
||||
| 守护进程 | 要你自己跑 `multica daemon start` | 启动时**自动拉起** |
|
||||
| 升级 | 刷新页面就是最新 | 应用自动检查 + 下次启动安装 |
|
||||
| 登录后的数据 | 完全一样 | 完全一样 |
|
||||
|
||||
**选 Web**:临时用、在别人电脑上、不想装应用的场景。
|
||||
**选 Desktop**:每天用 Multica、会同时操作多个工作区、不想自己管守护进程的场景。
|
||||
|
||||
## 多 tab:工作区之间切换怎么表现
|
||||
|
||||
Desktop 为**每个你加入的工作区**独立维护一组标签页。切换工作区时,当前工作区的标签页会被整体隐藏,上次那个工作区的标签页会原样恢复——像 VSCode 的多 workspace 行为或 Slack 的 workspace 切换。
|
||||
|
||||
举例:你在工作区 A 打开了 3 个 issue 标签页,切到工作区 B,A 的那 3 个标签页消失,B 里显示你上次在 B 留下的标签页;切回 A,那 3 个原样回来。**不同工作区的标签页不会互相串到对方**。
|
||||
|
||||
登出会**清空所有工作区的标签页状态**,防止多用户共用同一台机器时的数据泄露。
|
||||
|
||||
## Desktop 怎么自动更新
|
||||
|
||||
Desktop 启动时会去 GitHub Releases 检查新版本。检查到新版本:
|
||||
|
||||
1. 在后台静默下载新版本
|
||||
2. 提示你「准备就绪,下次启动时安装」
|
||||
3. 你点击退出(或下次重启)时,应用关闭前把新版本装好
|
||||
4. 再次打开时就是新版本
|
||||
|
||||
整个过程**不中断你正在做的事**。
|
||||
|
||||
<Callout type="warning">
|
||||
**Windows 的 ARM64 和 x64 是独立的更新通道**——装错架构会识别不到更新。安装时下载对应你机器架构的那个 `.exe`(带 `arm64` 后缀的是 ARM 版)。
|
||||
</Callout>
|
||||
|
||||
macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的警告。Linux 版是 `.AppImage`——自动更新机制依赖 electron-updater,在某些发行版可能不稳定,**不工作时手动下载新版本覆盖**。
|
||||
|
||||
## 还要单独装 CLI 和守护进程吗
|
||||
|
||||
**不用**。Desktop 包里**内置了同一个 `multica` CLI 二进制**——Desktop 启动时会自动启动守护进程的独立 profile(和你命令行手动跑的守护进程互不干扰)。
|
||||
|
||||
如果你已经装过 CLI 并手动跑过 `multica daemon start`,Desktop 不会抢占你那个守护进程——它起自己的,用不同的 profile 隔离。两边注册的是**不同的运行时**,在 UI 里能看到两个独立运行时。
|
||||
|
||||
想在终端里跑 CLI 命令,Desktop 不提供特殊方式——照常用系统的 CLI(如果你单独装了),或者用 Desktop 自带的版本(在应用的资源目录里,`resources/bin/multica`)。
|
||||
|
||||
## 怎么下载安装
|
||||
|
||||
去 [多卡下载页](https://multica.ai/download) 拿对应平台的安装包:
|
||||
|
||||
| 平台 | 文件 |
|
||||
|---|---|
|
||||
| macOS(Intel 或 Apple Silicon)| `.dmg` |
|
||||
| Windows x64 | `.exe`(常规)|
|
||||
| Windows ARM64 | `.exe`(带 `arm64` 后缀)|
|
||||
| Linux | `.AppImage` |
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="info">
|
||||
**桌面版连哪个后端** 由登录时选的地址决定。默认连 Multica Cloud;如果你用自部署版本,在首次登录页点"连接到自部署实例"填你的 server 地址即可。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— Desktop 连自部署后端
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
@@ -169,6 +169,16 @@ 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
|
||||
151
apps/docs/content/docs/environment-variables.mdx
Normal file
151
apps/docs/content/docs/environment-variables.mdx
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: Environment variables
|
||||
description: The full list of environment variables for running a self-hosted Multica server.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A self-hosted Multica [server](/self-host-quickstart) reads its configuration from environment variables at startup — database, sign-in, email, storage, signup allowlists all live here. This page groups every variable by purpose: each section spells out **what happens if you leave it unset** and **which ones you must set in production**. For how to actually configure the auth-related ones, see [Sign-in and signup configuration](/auth-setup).
|
||||
|
||||
## The five required at startup
|
||||
|
||||
These are the five you must think about before deploying — some have defaults that let the server start, but in production you should set all of them explicitly.
|
||||
|
||||
| Variable | Default | Required in production? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **Yes** |
|
||||
| `PORT` | `8080` | No (unless you change the port) |
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **Yes** (the default is unsafe) |
|
||||
| `APP_ENV` | empty | **Yes** (must be `production` — see the next section for the trap) |
|
||||
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
|
||||
|
||||
<Callout type="warning">
|
||||
**If `APP_ENV` is not set to `production`, anyone can sign in to any account using the code `888888`.** Multica has a development-only master code, `888888` — when `APP_ENV != "production"`, **any email** plus `888888` passes verification. The behavior is intentional for local development (no Resend dependency); **in production, failing to set `production` is equivalent to disabling auth entirely**. See [Sign-in and signup configuration → The 888888 trap](/auth-setup#the-888888-trap).
|
||||
</Callout>
|
||||
|
||||
### Database connection pool
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `DATABASE_MAX_CONNS` | `25` | pgxpool max connections. The daemon polls frequently (every 3s) and uses connections; larger deployments may need a higher value |
|
||||
| `DATABASE_MIN_CONNS` | `5` | Minimum idle connections |
|
||||
|
||||
**When unset**, the values above are used — **not** pgx's built-in 4/NumCPU defaults, which previously caused pool exhaustion in production.
|
||||
|
||||
## Email configuration
|
||||
|
||||
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | empty | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
|
||||
|
||||
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
Optional. Leave unset for email + verification code only; configure it to add "Sign in with Google" on the sign-in page.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GOOGLE_CLIENT_ID` | empty | Google Cloud OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | empty | Google Cloud OAuth secret |
|
||||
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth callback URL (self-host: replace with your frontend domain) |
|
||||
|
||||
**Takes effect at runtime**: the frontend reads these settings via `/api/config` at runtime, so **changing them requires no frontend rebuild or redeploy** — restart the server and they apply.
|
||||
|
||||
Full setup (including Google Cloud Console steps) is in [Sign-in and signup configuration](/auth-setup#google-oauth-configuration).
|
||||
|
||||
## File storage configuration
|
||||
|
||||
Multica stores user-uploaded attachments (images and files in comments). **S3 is preferred**; if S3 is not configured, it falls back to local disk.
|
||||
|
||||
### S3 / S3-compatible storage
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | empty | Setting this enables S3 storage |
|
||||
| `S3_REGION` | `us-west-2` | AWS region |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | empty | Static credentials. When both are unset, the AWS SDK default credential chain is used (IAM role / environment credentials) |
|
||||
| `AWS_ENDPOINT_URL` | empty | Custom S3-compatible endpoint (for example [MinIO](https://min.io/)). Setting this switches to path-style URLs |
|
||||
|
||||
**When `S3_BUCKET` is unset**: the server logs `"S3_BUCKET not set, cloud upload disabled"` at startup, and all uploads fall back to local disk.
|
||||
|
||||
### Local disk (when S3 is not configured)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | Local storage directory |
|
||||
| `LOCAL_UPLOAD_BASE_URL` | empty (returns relative paths) | Public base URL — leave unset and the frontend can't resolve a full URL for attachments |
|
||||
|
||||
### CloudFront (optional)
|
||||
|
||||
If you front S3 with CloudFront, three variables apply: `CLOUDFRONT_DOMAIN`, `CLOUDFRONT_KEY_PAIR_ID`, `CLOUDFRONT_PRIVATE_KEY` (or `CLOUDFRONT_PRIVATE_KEY_SECRET` to read from Secrets Manager). Skip them if you don't use CloudFront — they don't conflict with S3 configuration.
|
||||
|
||||
### Cookie domain
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `COOKIE_DOMAIN` | empty | Scope of the session cookie |
|
||||
|
||||
- **Empty**: the cookie is valid only on the exact host visited (correct for single-host deployments)
|
||||
- **Set to `.example.com`**: the cookie is shared across subdomains (so `app.example.com` and `api.example.com` share a sign-in session)
|
||||
- Warning: it cannot be an IP address (browsers ignore it)
|
||||
|
||||
## Restricting who can sign up
|
||||
|
||||
Three allowlist layers combine by priority. **If any layer is set to a non-empty value, emails that don't match are rejected** — even `ALLOW_SIGNUP=true` won't override that.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ALLOWED_EMAILS` | empty | Explicit email allowlist (comma-separated). When non-empty, only listed emails can sign up |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | empty | Domain allowlist (comma-separated). When non-empty, only listed domains can sign up |
|
||||
| `ALLOW_SIGNUP` | `true` | Signup master switch. Set `false` to disable signup entirely |
|
||||
|
||||
**The counterintuitive part**: `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` does **not** mean "allow company.io or everyone" — it means **only allow company.io**. The allowlist layers are AND semantics — the full decision tree is in [Sign-in and signup configuration → Signup allowlists](/auth-setup#restricting-who-can-sign-up).
|
||||
|
||||
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
|
||||
|
||||
## Daemon tuning parameters
|
||||
|
||||
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | Server address (self-host: replace with your domain) |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat interval |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | Task polling interval |
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | Max concurrent tasks |
|
||||
| `MULTICA_<PROVIDER>_PATH` | matches the CLI name | Path to each AI coding tool's executable (for example `MULTICA_CLAUDE_PATH`) |
|
||||
| `MULTICA_<PROVIDER>_MODEL` | empty | Default model for each AI coding tool |
|
||||
|
||||
For a full explanation of how each parameter affects daemon behavior, see [Daemon and runtimes](/daemon-runtimes).
|
||||
|
||||
## Frontend access control
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `FRONTEND_ORIGIN` | empty | Frontend address. Invite email links, the CORS allowlist, and the cookie domain are all derived from this. When unset, invite email links fall back to the hosted domain `https://app.multica.ai` — self-host must set this explicitly |
|
||||
| `CORS_ALLOWED_ORIGINS` | empty | Additional allowed CORS origins (comma-separated) |
|
||||
| `ALLOWED_ORIGINS` | empty | WebSocket-specific origin allowlist (comma-separated); when unset, fallback order is `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` |
|
||||
|
||||
<Callout type="warning">
|
||||
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
|
||||
</Callout>
|
||||
|
||||
## Usage analytics
|
||||
|
||||
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `ANALYTICS_DISABLED` | `false` | Set `true` to disable backend analytics entirely |
|
||||
| `POSTHOG_API_KEY` | built-in default key | Set when pointing at your own PostHog instance |
|
||||
| `POSTHOG_HOST` | `https://us.i.posthog.com` | Change to your own host if you self-host PostHog |
|
||||
|
||||
## Next
|
||||
|
||||
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
|
||||
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do
|
||||
151
apps/docs/content/docs/environment-variables.zh.mdx
Normal file
151
apps/docs/content/docs/environment-variables.zh.mdx
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: 环境变量
|
||||
description: self-host Multica 服务器需要配置的环境变量清单。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 启动必填的五个
|
||||
|
||||
这五个是你部署前必须考虑的——有些有默认值能让 server 启动,但生产环境里你应该全部显式配。
|
||||
|
||||
| 环境变量 | 默认值 | 生产必须设? |
|
||||
|---|---|---|
|
||||
| `DATABASE_URL` | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` | **是** |
|
||||
| `PORT` | `8080` | 否(除非换端口)|
|
||||
| `JWT_SECRET` | `multica-dev-secret-change-in-production` | **是**(默认值不安全)|
|
||||
| `APP_ENV` | 空 | **是**(必须 `production`——见下一节陷阱)|
|
||||
| `FRONTEND_ORIGIN` | 空 | **是**(self-host 要填你自己的域名)|
|
||||
|
||||
<Callout type="warning">
|
||||
**`APP_ENV` 不设为 `production`,任何人都能用 `888888` 登录任何账号。** Multica 有一个开发用的主验证码(master code)`888888`——`APP_ENV != "production"` 时**任何邮箱**输 `888888` 都能通过。本地开发时故意留空方便调试;**生产环境一旦不设 `production`,等于 auth 完全失效**。详见 [登录与注册配置 → 888888 陷阱](/auth-setup#888888-陷阱)。
|
||||
</Callout>
|
||||
|
||||
### 数据库连接池
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `DATABASE_MAX_CONNS` | `25` | pgxpool 最大连接数。守护进程高频轮询(每 3 秒)会占用连接;大规模部署可能需要调高 |
|
||||
| `DATABASE_MIN_CONNS` | `5` | 最小常驻连接 |
|
||||
|
||||
**不设时**使用上表默认值,**不是** pgx 内置的 4/NumCPU——后者在生产曾引发连接池耗尽。
|
||||
|
||||
## 怎么配邮件
|
||||
|
||||
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
|
||||
|
||||
**不设 `RESEND_API_KEY` 时的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
可选。不设则只有邮箱 + 验证码登录;设了就在登录页出现「用 Google 登录」。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GOOGLE_CLIENT_ID` | 空 | Google Cloud OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | 空 | Google Cloud OAuth secret |
|
||||
| `GOOGLE_REDIRECT_URI` | `http://localhost:3000/auth/callback` | OAuth 回调地址(self-host 换成你的前端域名)|
|
||||
|
||||
**热生效**:前端在运行时通过 `/api/config` 拿这些配置,**改了不用重启前端也不用重建镜像**——改完重启 server 即可。
|
||||
|
||||
完整配置步骤(含 Google Cloud Console 操作)详见 [登录与注册配置](/auth-setup#怎么配-google-oauth)。
|
||||
|
||||
## 怎么配文件存储
|
||||
|
||||
Multica 存储用户上传的附件(评论里的图片、文件等)。**优先走 S3**;不配 S3 就回落本地磁盘。
|
||||
|
||||
### S3 / S3 兼容存储
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | 空 | 设了就启用 S3 存储 |
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域 |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | 空 | 静态凭证。全未设时用 AWS SDK 默认凭证链(IAM role / 环境凭证)|
|
||||
| `AWS_ENDPOINT_URL` | 空 | 自定义 S3 兼容端点(例如 [MinIO](https://min.io/))。设了会切到 path-style URL |
|
||||
|
||||
**`S3_BUCKET` 未设时**:server 启动时打 info 日志 `"S3_BUCKET not set, cloud upload disabled"`,所有上传回落到本地磁盘。
|
||||
|
||||
### 本地磁盘(S3 未配时)
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `LOCAL_UPLOAD_DIR` | `./data/uploads` | 本地存储目录 |
|
||||
| `LOCAL_UPLOAD_BASE_URL` | 空(返回相对路径)| 公开访问的 base URL——不设前端就拿不到附件的完整 URL |
|
||||
|
||||
### CloudFront(可选)
|
||||
|
||||
如果你用 CloudFront 给 S3 做 CDN,三个相关的环境变量:`CLOUDFRONT_DOMAIN`、`CLOUDFRONT_KEY_PAIR_ID`、`CLOUDFRONT_PRIVATE_KEY`(或从 Secrets Manager 读的 `CLOUDFRONT_PRIVATE_KEY_SECRET`)。不用 CloudFront 就不用管——和配置 S3 不冲突。
|
||||
|
||||
### Cookie 域
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `COOKIE_DOMAIN` | 空 | session cookie 的作用域 |
|
||||
|
||||
- **空**:cookie 只对访问的那个 host 生效(单主机部署正确)
|
||||
- **设成 `.example.com`**:cookie 在所有子域共享(让 `app.example.com` 和 `api.example.com` 共用登录态)
|
||||
- ⚠️ 不能是 IP 地址(浏览器会忽略)
|
||||
|
||||
## 怎么限制谁能注册
|
||||
|
||||
三层白名单按优先级组合。**任何一层白名单一旦设置非空,不匹配的邮箱就会被拒**——即使 `ALLOW_SIGNUP=true` 也挡不住。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `ALLOWED_EMAILS` | 空 | 显式邮箱白名单(逗号分隔)。非空时只有列表里的邮箱能注册 |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | 空 | 域名白名单(逗号分隔)。非空时只有列表里的域名能注册 |
|
||||
| `ALLOW_SIGNUP` | `true` | 注册总开关。设 `false` 完全关闭注册 |
|
||||
|
||||
**不直观的点**:`ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` 的组合**不是**「允许 company.io 或所有人」,而是**只允许 company.io**。白名单的 AND 语义——决策树详见 [登录与注册配置 → Signup 白名单](/auth-setup#怎么限制谁能注册)。
|
||||
|
||||
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
|
||||
|
||||
## 守护进程的调节参数
|
||||
|
||||
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | server 地址(self-host 换成你的域名)|
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | 心跳频率 |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | 任务轮询频率 |
|
||||
| `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` | 并发任务上限 |
|
||||
| `MULTICA_<PROVIDER>_PATH` | 对应 CLI 名 | 各 AI 编程工具的可执行文件路径(如 `MULTICA_CLAUDE_PATH`)|
|
||||
| `MULTICA_<PROVIDER>_MODEL` | 空 | 各 AI 编程工具的默认模型 |
|
||||
|
||||
完整解释每个参数对守护进程行为的影响,见 [守护进程与运行时](/daemon-runtimes)。
|
||||
|
||||
## 前端访问控制
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `FRONTEND_ORIGIN` | 空 | 前端地址。邀请邮件里的链接、CORS 白名单、cookie domain 都从这里推导。邮件链接在不设时会 fallback 到托管版域名 `https://app.multica.ai`——self-host 必须显式填 |
|
||||
| `CORS_ALLOWED_ORIGINS` | 空 | 额外的 CORS 允许来源(逗号分隔)|
|
||||
| `ALLOWED_ORIGINS` | 空 | WebSocket 专用的 origin 白名单(逗号分隔);不设就按 `CORS_ALLOWED_ORIGINS` → `FRONTEND_ORIGIN` → `localhost:3000/5173/5174` 顺序回落 |
|
||||
|
||||
<Callout type="warning">
|
||||
**`FRONTEND_ORIGIN` 不设就有两个静默失败**:(1)邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例;(2)WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
|
||||
</Callout>
|
||||
|
||||
## 用量统计
|
||||
|
||||
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `ANALYTICS_DISABLED` | `false` | 设 `true` 完全关闭后端上报 |
|
||||
| `POSTHOG_API_KEY` | 内置默认 key | 换成你自己的 PostHog 实例时填 |
|
||||
| `POSTHOG_HOST` | `https://us.i.posthog.com` | 自建 PostHog 的话改成你自己的地址 |
|
||||
|
||||
## 下一步
|
||||
|
||||
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
|
||||
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
|
||||
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user