mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 12:18:45 +02:00
Compare commits
1 Commits
agent/lamb
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02f4c461b |
53
.env.example
53
.env.example
@@ -11,21 +11,17 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
|
||||
# "production" by default. Local dev can leave it unset.
|
||||
# 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=
|
||||
# Optional local/testing shortcut. Empty by default, so there is no fixed
|
||||
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
|
||||
# If you need deterministic local automation, set a 6-digit value such as
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
# HTTP request metrics start accumulating only when this listener is enabled.
|
||||
# METRICS_ADDR=127.0.0.1:9090
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
@@ -40,27 +36,18 @@ 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 — generated codes print to stdout.
|
||||
# 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=
|
||||
@@ -88,16 +75,6 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
# callers with no forwarding headers and returns 404 to everything else —
|
||||
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
|
||||
# terminating TLS in front of localhost:8080) MUST set this token, since
|
||||
# proxied requests look like loopback at the Go layer; with no token, those
|
||||
# requests are refused with 404. Pass the token as
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
@@ -113,9 +90,10 @@ NEXT_PUBLIC_WS_URL=
|
||||
# ==================== 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.
|
||||
# Must match ALLOW_SIGNUP for the UI to reflect the same signup setting.
|
||||
# Note: in typical Next.js builds, NEXT_PUBLIC_* values are baked into the client bundle,
|
||||
# so changing this usually requires rebuilding/redeploying the frontend (not just restarting the backend).
|
||||
NEXT_PUBLIC_ALLOW_SIGNUP=true
|
||||
|
||||
# Optional: Only allow emails from these domains (comma-separated)
|
||||
ALLOWED_EMAIL_DOMAINS=
|
||||
@@ -131,3 +109,4 @@ 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=
|
||||
|
||||
|
||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -48,22 +48,8 @@ jobs:
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
|
||||
# (which would flip the server binary itself onto the Redis-backed
|
||||
# realtime relay + request stores); the tests talk to this Redis
|
||||
# directly so they run alongside the Postgres-backed suite.
|
||||
REDIS_TEST_URL: redis://localhost:6379/1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
273
.github/workflows/release.yml
vendored
273
.github/workflows/release.yml
vendored
@@ -10,14 +10,10 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.release_meta.outputs.tag_name }}
|
||||
is_stable: ${{ steps.release_meta.outputs.is_stable }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -25,8 +21,6 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate tag name
|
||||
id: release_meta
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
echo "Triggered by tag: $tag"
|
||||
@@ -38,12 +32,6 @@ jobs:
|
||||
echo "::error::Refusing to release from dirty tag '$tag'."
|
||||
exit 1
|
||||
fi
|
||||
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
|
||||
if [[ "$tag" == *-* ]]; then
|
||||
echo "is_stable=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_stable=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -54,27 +42,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
# Only run on the canonical upstream repo. Forks don't have the
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
|
||||
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
|
||||
# tag push fails this job (401 against the upstream tap), which makes
|
||||
# downstream CI go red without affecting the actual artifact pipeline.
|
||||
if: github.repository_owner == 'multica-ai'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -84,244 +51,6 @@ jobs:
|
||||
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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,4 +57,3 @@ _features/
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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
|
||||
61
CLAUDE.md
61
CLAUDE.md
@@ -136,17 +136,6 @@ make start-worktree # Start using .env.worktree
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
@@ -202,28 +191,64 @@ Every path in the desktop app falls into exactly one category. Choosing the wron
|
||||
|
||||
**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
|
||||
### Workspace identity singleton
|
||||
|
||||
`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.
|
||||
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
|
||||
|
||||
1. API client's `X-Workspace-Slug` header.
|
||||
2. Zustand per-workspace storage namespace.
|
||||
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
|
||||
|
||||
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
|
||||
Leave / Delete workspace flows must follow this order:
|
||||
|
||||
1. Read destination from cached workspace list.
|
||||
1. Read destination from cached workspace list (no extra fetch).
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)`.
|
||||
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
Reversing step 4 with steps 1–3 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
|
||||
|
||||
### 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)
|
||||
### Drag region (macOS window-move)
|
||||
|
||||
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.
|
||||
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
|
||||
|
||||
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
|
||||
|
||||
```tsx
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col px-6 pb-12">
|
||||
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
|
||||
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
|
||||
|
||||
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
|
||||
|
||||
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
|
||||
|
||||
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
|
||||
|
||||
### UX vs platform chrome
|
||||
|
||||
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
|
||||
@@ -146,8 +146,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| Gemini | `gemini` | Google's coding agent |
|
||||
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -168,28 +166,11 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
|
||||
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
|
||||
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
|
||||
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
|
||||
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
|
||||
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
|
||||
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
|
||||
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
|
||||
|
||||
#### Workspace garbage collection
|
||||
|
||||
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
|
||||
|
||||
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
|
||||
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
|
||||
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
|
||||
|
||||
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
@@ -197,10 +178,8 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -213,12 +192,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
|
||||
@@ -373,8 +373,7 @@ done
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
|
||||
in your env file before starting the backend:
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
@@ -477,9 +476,7 @@ This automatically:
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and the generated code from the
|
||||
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
|
||||
the backend, you can use `888888` instead.
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
147
Makefile
147
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-stop
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,23 +36,10 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# Default target changed from selfhost to help: bare `make` now prints this help
|
||||
# instead of launching a full Docker Compose build, which is safer for onboarding.
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
##@ Help
|
||||
|
||||
help: ## Show available make targets and common local workflows
|
||||
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
|
||||
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
|
||||
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
makehelp: help ## Alias for `make help`
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
##@ Self-hosting
|
||||
|
||||
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
@@ -64,58 +51,8 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Pulling official Multica images..."
|
||||
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
|
||||
echo ""; \
|
||||
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
|
||||
echo "If this is before the first GHCR release, build from the current checkout:"; \
|
||||
echo " make selfhost-build"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
@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 "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 read the generated code from backend logs when Resend is unset."; \
|
||||
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-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
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
@@ -130,10 +67,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo ""; \
|
||||
echo "Built images locally via docker-compose.selfhost.build.yml."; \
|
||||
echo "Local tags: multica-backend:dev and multica-web:dev."; \
|
||||
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"; \
|
||||
@@ -144,15 +78,16 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
selfhost-stop: ## Stop the self-hosted Docker Compose stack
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
##@ One-click
|
||||
|
||||
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
setup:
|
||||
$(REQUIRE_ENV)
|
||||
@echo "==> Using env file: $(ENV_FILE)"
|
||||
@echo "==> Installing dependencies..."
|
||||
@@ -163,7 +98,8 @@ setup: ## Prepare the current checkout from its env file: install deps, ensure D
|
||||
@echo ""
|
||||
@echo "✓ Setup complete! Run 'make start' to launch the app."
|
||||
|
||||
start: ## Start backend and frontend for the current checkout and run migrations first
|
||||
# Start all services (backend + frontend)
|
||||
start:
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Using env file: $(ENV_FILE)"
|
||||
@echo "Backend: http://localhost:$(PORT)"
|
||||
@@ -177,7 +113,8 @@ start: ## Start backend and frontend for the current checkout and run migrations
|
||||
pnpm dev:web & \
|
||||
wait
|
||||
|
||||
stop: ## Stop backend and frontend processes for the current checkout
|
||||
# Stop all services
|
||||
stop:
|
||||
$(REQUIRE_ENV)
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@@ -189,21 +126,22 @@ stop: ## Stop backend and frontend processes for the current checkout
|
||||
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
|
||||
esac
|
||||
|
||||
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
$(REQUIRE_ENV)
|
||||
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
|
||||
|
||||
db-up: ## Start the shared PostgreSQL container used by main and worktrees
|
||||
db-up:
|
||||
@$(COMPOSE) up -d postgres
|
||||
|
||||
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
|
||||
db-down:
|
||||
@$(COMPOSE) down
|
||||
|
||||
# Drop + recreate the current env's database, then run all migrations.
|
||||
# Use for a clean slate in local dev. Only affects the DB named in
|
||||
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
|
||||
# worktree DBs are untouched. Refuses to run against a remote host.
|
||||
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
|
||||
db-reset:
|
||||
$(REQUIRE_ENV)
|
||||
@case "$(DATABASE_URL)" in \
|
||||
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
|
||||
@@ -219,22 +157,22 @@ db-reset: ## Drop and recreate the current env's database, then re-run all migra
|
||||
@echo ""
|
||||
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
|
||||
|
||||
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
|
||||
worktree-env:
|
||||
@bash scripts/init-worktree-env.sh .env.worktree
|
||||
|
||||
setup-main: ## Prepare the main checkout using .env
|
||||
setup-main:
|
||||
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
start-main: ## Start the main checkout using .env
|
||||
start-main:
|
||||
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
stop-main: ## Stop the main checkout processes defined by .env
|
||||
stop-main:
|
||||
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
|
||||
|
||||
check-main: ## Run the full verification pipeline for the main checkout
|
||||
check-main:
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
|
||||
setup-worktree:
|
||||
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
|
||||
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
|
||||
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
|
||||
@@ -243,68 +181,65 @@ setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
|
||||
fi
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree: ## Start this worktree using .env.worktree
|
||||
start-worktree:
|
||||
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
stop-worktree: ## Stop this worktree's backend and frontend processes
|
||||
stop-worktree:
|
||||
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
check-worktree: ## Run the full verification pipeline for this worktree
|
||||
check-worktree:
|
||||
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
# ---------- Individual commands ----------
|
||||
##@ Individual commands
|
||||
|
||||
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
|
||||
# One-command dev: auto-setup env/deps/db/migrations, then start all services
|
||||
dev:
|
||||
@bash scripts/dev.sh
|
||||
|
||||
server: ## Run only the Go server for the current checkout
|
||||
# Go server only
|
||||
server:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/server
|
||||
|
||||
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
|
||||
daemon:
|
||||
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
|
||||
|
||||
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
|
||||
cli:
|
||||
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
|
||||
|
||||
multica: ## Run the multica CLI entrypoint directly from the Go source tree
|
||||
multica:
|
||||
cd server && go run ./cmd/multica $(MULTICA_ARGS)
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
|
||||
test:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
##@ Database
|
||||
|
||||
migrate-up: ## Create the target DB if needed, then apply database migrations
|
||||
migrate-up:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
|
||||
migrate-down: ## Create the target DB if needed, then roll back database migrations
|
||||
migrate-down:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate down
|
||||
|
||||
sqlc: ## Regenerate sqlc code
|
||||
sqlc:
|
||||
cd server && sqlc generate
|
||||
|
||||
# Cleanup
|
||||
##@ Cleanup
|
||||
|
||||
clean: ## Remove generated server binaries and temp files
|
||||
clean:
|
||||
rm -rf server/bin server/tmp
|
||||
|
||||
36
README.md
36
README.md
@@ -30,24 +30,12 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Why "Multica"?
|
||||
|
||||
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
|
||||
|
||||
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
|
||||
|
||||
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
|
||||
|
||||
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
|
||||
|
||||
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
@@ -97,8 +85,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
@@ -110,7 +97,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -120,7 +107,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -174,8 +161,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent, Kimi,
|
||||
Kiro CLI)
|
||||
Pi, Cursor Agent)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -183,7 +169,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -198,3 +184,13 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -36,18 +36,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 为什么叫 "Multica"?
|
||||
|
||||
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
|
||||
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统,让多个用户能够共享同一台机器,同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
|
||||
|
||||
我们认为,类似的转折点正在再次出现。几十年来,软件团队一直处于一种单线程的工作模式,一个工程师处理一个任务,一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
|
||||
|
||||
在 Multica 中,agents 是一级团队成员。它们会被分配 issue,汇报进展,提出阻塞,并交付代码,就像人类同事一样。任务分配、活动时间线、任务生命周期,以及运行时基础设施,Multica 从第一天起就是围绕这一理念构建的。
|
||||
|
||||
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents,就能发挥出二十人团队的推进速度。
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
@@ -184,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -24,9 +24,9 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
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,10 +54,6 @@ make selfhost
|
||||
|
||||
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
@@ -67,15 +63,13 @@ Once ready:
|
||||
|
||||
### Step 2 — 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`), and there is no fixed verification code by default. Pick one of the following 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:
|
||||
|
||||
- **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).
|
||||
- **Without email configured:** 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.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
- **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 `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
> **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
|
||||
|
||||
@@ -98,8 +92,6 @@ You also need at least one AI agent CLI installed:
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
- Kimi (`kimi` on PATH)
|
||||
- Kiro CLI (`kiro-cli` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
@@ -164,15 +156,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
|
||||
|
||||
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
|
||||
## Upgrading
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
@@ -195,7 +186,6 @@ JWT_SECRET=$(openssl rand -hex 32)
|
||||
Then start everything:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
|
||||
@@ -32,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:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
> **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)
|
||||
|
||||
@@ -42,18 +42,6 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### Signup Controls (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
@@ -79,7 +67,6 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
@@ -247,7 +234,7 @@ When using separate domains for frontend and backend, set these environment vari
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
|
||||
# Frontend (set before building the frontend image)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
@@ -263,80 +250,32 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then restart the stack:
|
||||
Then rebuild:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
### WebSocket for LAN / Non-localhost Access
|
||||
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.
|
||||
|
||||
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
|
||||
|
||||
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
|
||||
|
||||
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
|
||||
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
|
||||
|
||||
# Rebuild the web image so the build-time value is baked in
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
```
|
||||
|
||||
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
|
||||
|
||||
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes public health endpoints:
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```text
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The backend can expose Prometheus metrics on a separate management listener:
|
||||
|
||||
```bash
|
||||
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
|
||||
public API port does not serve `/metrics`; keep it that way for internet-facing
|
||||
deployments. HTTP request metrics start accumulating only after the metrics
|
||||
listener is enabled. Metrics can reveal internal routes, traffic volume,
|
||||
dependency state, and runtime health.
|
||||
|
||||
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
|
||||
metrics listener to an internal interface and protect it with private
|
||||
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
|
||||
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
|
||||
trusted network, for example a host-local mapping such as
|
||||
`127.0.0.1:9090:9090`.
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
|
||||
@@ -37,7 +37,7 @@ multica setup self-host
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
|
||||
@@ -37,14 +37,6 @@ linux:
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
rpm:
|
||||
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
|
||||
# binary, whose GNU build-id is identical across every app shipping the same
|
||||
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
|
||||
# would own /usr/lib/.build-id/<hash> paths and collide with any other
|
||||
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
|
||||
fpm:
|
||||
- "--rpm-rpmbuild-define=_build_id_links none"
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
|
||||
|
||||
// Electron ships with no default right-click menu, so a user selecting text
|
||||
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
|
||||
// menu using `roles`, which keeps i18n + accelerator handling native.
|
||||
export function installContextMenu(webContents: WebContents): void {
|
||||
webContents.on("context-menu", (_event, params) => {
|
||||
const { editFlags, selectionText, isEditable } = params;
|
||||
const hasSelection = selectionText.trim().length > 0;
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
if (isEditable && editFlags.canCut) {
|
||||
menu.append(new MenuItem({ role: "cut" }));
|
||||
}
|
||||
if (hasSelection && editFlags.canCopy) {
|
||||
menu.append(new MenuItem({ role: "copy" }));
|
||||
}
|
||||
if (isEditable && editFlags.canPaste) {
|
||||
menu.append(new MenuItem({ role: "paste" }));
|
||||
}
|
||||
if (isEditable && editFlags.canSelectAll) {
|
||||
if (menu.items.length > 0) {
|
||||
menu.append(new MenuItem({ type: "separator" }));
|
||||
}
|
||||
menu.append(new MenuItem({ role: "selectAll" }));
|
||||
}
|
||||
|
||||
if (menu.items.length === 0) return;
|
||||
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
|
||||
menu.popup({ window });
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
@@ -914,20 +914,6 @@ export function setupDaemonManager(
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, Notification } 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";
|
||||
@@ -6,8 +6,6 @@ import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -111,24 +109,6 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
@@ -213,16 +193,6 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
|
||||
// preload can attach the values to `desktopAPI.appInfo` before any renderer
|
||||
// code reads them, ensuring the very first HTTP request from the renderer
|
||||
// already carries X-Client-Version and X-Client-OS.
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
@@ -231,64 +201,6 @@ if (!gotTheLock) {
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
// IPC: show a native OS notification for a new inbox item. The renderer
|
||||
// only fires this when the app is unfocused (it gates on
|
||||
// `document.hasFocus()`), so we don't fight macOS foreground suppression
|
||||
// here. Clicking the banner focuses the main window and routes to the
|
||||
// inbox item via a renderer-side listener.
|
||||
ipcMain.on(
|
||||
"notification:show",
|
||||
(
|
||||
_event,
|
||||
{
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
},
|
||||
) => {
|
||||
if (!Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body });
|
||||
notification.on("click", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
// Ship the full context back — the renderer pins the route to the
|
||||
// source workspace (slug), marks the row read (itemId), and uses
|
||||
// issueKey as the ?issue=<…> selector.
|
||||
mainWindow.webContents.send("inbox:open", {
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
});
|
||||
});
|
||||
notification.show();
|
||||
},
|
||||
);
|
||||
|
||||
// IPC: update the dock / taskbar unread badge. Values above 99 render as
|
||||
// "99+". macOS is the primary target (user-visible dock badge); Linux
|
||||
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
|
||||
// needs a pre-rendered PNG and is deferred — the OS notification + the
|
||||
// in-app inbox sidebar cover the core UX there for now.
|
||||
ipcMain.on("badge:set", (_event, rawCount: number) => {
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
if (process.platform === "darwin") {
|
||||
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
|
||||
app.dock?.setBadge(label);
|
||||
} else {
|
||||
app.setBadgeCount(count);
|
||||
}
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
|
||||
24
apps/desktop/src/preload/index.d.ts
vendored
24
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,11 +1,6 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
appInfo: {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -14,24 +9,6 @@ interface DesktopAPI {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
/** Show a native OS notification for a new inbox item. */
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => void;
|
||||
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
|
||||
setUnreadBadge: (count: number) => void;
|
||||
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -68,7 +45,6 @@ interface DaemonAPI {
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
openLogFile: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
|
||||
@@ -1,32 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
// (async ipc.invoke) would race the ApiClient construction in initCore and
|
||||
// the first few HTTP requests would go out without X-Client-Version/OS.
|
||||
function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" | "unknown" } {
|
||||
try {
|
||||
const info = ipcRenderer.sendSync("app:get-info") as
|
||||
| { version: string; os: "macos" | "windows" | "linux" | "unknown" }
|
||||
| undefined;
|
||||
if (info && typeof info.version === "string" && typeof info.os === "string") return info;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
// Fallback: derive OS from process.platform; version unknown.
|
||||
const p = process.platform;
|
||||
const os: "macos" | "windows" | "linux" | "unknown" =
|
||||
p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -50,50 +25,6 @@ const desktopAPI = {
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
/**
|
||||
* Show a native OS notification for a new inbox item. Fired from the
|
||||
* renderer only when the app is unfocused — in-focus feedback is the
|
||||
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
|
||||
* all round-tripped on click: slug pins routing to the source workspace
|
||||
* (the user may switch workspaces before clicking the banner), itemId
|
||||
* lets the renderer mark the row read, issueKey maps to the inbox URL
|
||||
* param.
|
||||
*/
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => ipcRenderer.send("notification:show", payload),
|
||||
/**
|
||||
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
|
||||
* above 99 render as "99+" (capping is handled in the main process).
|
||||
*/
|
||||
setUnreadBadge: (count: number) =>
|
||||
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
|
||||
/**
|
||||
* Subscribe to "open this inbox row" requests sent by the main process
|
||||
* when the user clicks an OS notification banner. Returns an unsubscribe
|
||||
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
|
||||
* were passed to `showNotification`.
|
||||
*/
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { slug: string; itemId: string; issueKey: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("inbox:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -145,8 +76,6 @@ const daemonAPI = {
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:open-log-file"),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -7,14 +7,12 @@ 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 "@multica/ui/components/ui/sonner";
|
||||
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";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -100,68 +98,21 @@ function AppContent() {
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Bridge local daemon IPC status into the runtimes cache so this user's
|
||||
// own daemon flips to offline/online sub-second instead of waiting on the
|
||||
// server's 75s sweeper. Resolves wsId from the active tab so workspace
|
||||
// switches automatically rebind the subscription.
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const activeWsId = activeWorkspaceSlug
|
||||
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
//
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
// 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 undefined;
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (overlay) return;
|
||||
if (!hasOnboarded) {
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
// missed invites later once they're onboarded.
|
||||
let cancelled = false;
|
||||
void api
|
||||
.listMyInvitations()
|
||||
.then((invites) => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
latestOpen({ type: "invitations" });
|
||||
} else {
|
||||
latestOpen({ type: "onboarding" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
latestOpen({ type: "onboarding" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
open({ type: "new-workspace" });
|
||||
return undefined;
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
|
||||
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
|
||||
@@ -171,21 +122,15 @@ function AppContent() {
|
||||
// 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.
|
||||
//
|
||||
// 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;
|
||||
if (!workspaces) 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);
|
||||
const tabStore = useTabStore.getState();
|
||||
tabStore.validateWorkspaceSlugs(validSlugs);
|
||||
if (!tabStore.activeWorkspaceSlug && workspaces.length > 0) {
|
||||
tabStore.switchWorkspace(workspaces[0].slug);
|
||||
}
|
||||
}, [workspaces, workspaceListFetched]);
|
||||
}, [workspaces]);
|
||||
|
||||
// null = undecided (pre-login or list hasn't settled yet)
|
||||
// true = session started with zero workspaces; next transition to >=1 triggers restart
|
||||
@@ -215,15 +160,8 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
// Pageview tracker sits at the app root so it covers every visible
|
||||
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
|
||||
// would miss the logged-out and overlay states.
|
||||
return (
|
||||
<>
|
||||
<PageviewTracker />
|
||||
{user ? <DesktopShell /> : <DesktopLoginPage />}
|
||||
</>
|
||||
);
|
||||
if (!user) return <DesktopLoginPage />;
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
@@ -251,20 +189,12 @@ async function handleDaemonLogout() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "desktop", version, os }),
|
||||
[version, os],
|
||||
);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
|
||||
@@ -1,261 +1,150 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Copy as CopyIcon,
|
||||
Search,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
/** Number of runtimes this local daemon has registered (for the context badge). */
|
||||
runtimeCount: number;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
let logIdCounter = 0;
|
||||
|
||||
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
|
||||
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
|
||||
INFO: "border-foreground/15 text-foreground/80",
|
||||
WARN: "border-warning/40 text-warning",
|
||||
ERROR: "border-destructive/40 text-destructive",
|
||||
};
|
||||
|
||||
// What gets rendered in the viewport — a single line or a folded group of
|
||||
// consecutive lines that share the same `message`. The group form is what
|
||||
// turns a wall of `DBG poll: no tasks` into a single placeholder.
|
||||
type DisplayItem =
|
||||
| { kind: "line"; line: ParsedLogLine }
|
||||
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
|
||||
|
||||
export function DaemonPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
runtimeCount,
|
||||
}: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
// Each level chip is an independent toggle. DEBUG is off by default so
|
||||
// poll-loop noise doesn't drown out real events when the panel opens —
|
||||
// users opt in if they want to see it.
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
|
||||
);
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
|
||||
const idCounterRef = useRef(0);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// --- Log stream subscription ---
|
||||
// Active only while the modal is open. On open we replay the file's tail
|
||||
// (~200 lines) so users have context for "what just happened"; on close
|
||||
// we tear down the watcher so the main process isn't doing work for a
|
||||
// hidden UI.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
idCounterRef.current = 0;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const id = ++idCounterRef.current;
|
||||
const parsed = parseLogLine(line, id);
|
||||
const next =
|
||||
prev.length >= MAX_LOG_LINES
|
||||
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
|
||||
: [...prev, parsed];
|
||||
return next;
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// --- Derived: counts per level (for filter chip badges) ---
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
};
|
||||
for (const l of logs) {
|
||||
if (l.level) counts[l.level] += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
// --- Derived: filtered list (level toggle + search) ---
|
||||
// Lines that didn't parse (level = null) always pass — they're typically
|
||||
// panic stack traces / partial writes; never silently drop them.
|
||||
const filtered = useMemo(() => {
|
||||
let result = logs;
|
||||
result = result.filter((l) => {
|
||||
if (!l.level) return true;
|
||||
return enabledLevels.has(l.level);
|
||||
});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((l) => l.raw.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [logs, enabledLevels, search]);
|
||||
|
||||
// --- Derived: collapse runs of consecutive lines that share the same
|
||||
// message into a single group placeholder. The most common case is the
|
||||
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
|
||||
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
|
||||
// doesn't strand groups.
|
||||
const displayed = useMemo<DisplayItem[]>(() => {
|
||||
const out: DisplayItem[] = [];
|
||||
for (const line of filtered) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last) {
|
||||
out.push({ kind: "line", line });
|
||||
continue;
|
||||
}
|
||||
const lastMessage =
|
||||
last.kind === "line" ? last.line.message : last.first.message;
|
||||
if (lastMessage && lastMessage === line.message) {
|
||||
if (last.kind === "line") {
|
||||
out[out.length - 1] = {
|
||||
kind: "group",
|
||||
first: last.line,
|
||||
rest: [line],
|
||||
};
|
||||
} else {
|
||||
last.rest.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: "line", line });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
|
||||
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [displayed, autoScroll]);
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const handleLogScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
|
||||
// here. Re-enabling lives in the "Jump to latest" footer button so a
|
||||
// burst of lines doesn't yank a reading user back to the bottom.
|
||||
if (!atBottom && autoScroll) setAutoScroll(false);
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setAutoScroll(true);
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
setAutoScroll(atBottom);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
const toggleLevel = useCallback((lv: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lv)) next.delete(lv);
|
||||
else next.add(lv);
|
||||
return next;
|
||||
});
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleFields = useCallback((id: number) => {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((id: number) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Server className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Local daemon logs
|
||||
</DialogTitle>
|
||||
<ContextBadge status={status} runtimeCount={runtimeCount} />
|
||||
</div>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -264,412 +153,157 @@ export function DaemonPanel({
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level toggle chips. Each chip is independent — click to
|
||||
show/hide that level. DEBUG starts hidden because the
|
||||
poll-loop heartbeat dominates otherwise. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((lv) => (
|
||||
<FilterChip
|
||||
key={lv}
|
||||
active={enabledLevels.has(lv)}
|
||||
onClick={() => toggleLevel(lv)}
|
||||
label={lv}
|
||||
count={levelCounts[lv]}
|
||||
variant={lv}
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
<CopyIcon className="size-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs viewport */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{displayed.length === 0 ? (
|
||||
<EmptyState
|
||||
hasLogs={logs.length > 0}
|
||||
hasFilter={hasActiveFilter}
|
||||
isRunning={status.state === "running"}
|
||||
/>
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{displayed.map((item) =>
|
||||
item.kind === "line" ? (
|
||||
<LogLineRow
|
||||
key={item.line.id}
|
||||
line={item.line}
|
||||
expanded={expandedFields.has(item.line.id)}
|
||||
onToggle={() => toggleFields(item.line.id)}
|
||||
search={search}
|
||||
/>
|
||||
) : (
|
||||
<GroupRows
|
||||
key={item.first.id}
|
||||
first={item.first}
|
||||
rest={item.rest}
|
||||
expanded={expandedGroups.has(item.first.id)}
|
||||
onToggle={() => toggleGroup(item.first.id)}
|
||||
expandedFields={expandedFields}
|
||||
onToggleFields={toggleFields}
|
||||
search={search}
|
||||
/>
|
||||
),
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar — count only. The "is the user following" state is
|
||||
communicated implicitly by the presence of the Jump-to-latest
|
||||
button below; an explicit "Paused" word read as "log stream is
|
||||
paused" (it isn't — data keeps flowing into the buffer). */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
Showing {filtered.length} of {logs.length}
|
||||
{logs.length === MAX_LOG_LINES && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
(buffer full)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function ContextBadge({
|
||||
status,
|
||||
runtimeCount,
|
||||
}: {
|
||||
status: DaemonStatus;
|
||||
runtimeCount: number;
|
||||
}) {
|
||||
const isRunning = status.state === "running";
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && runtimeCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
variant?: LogLevel;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
|
||||
active
|
||||
? variant
|
||||
? LEVEL_BADGE_CLASS[variant]
|
||||
: "bg-accent text-accent-foreground"
|
||||
: "border-dashed text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
active ? "text-current/80" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
|
||||
LEVEL_BADGE_CLASS[level],
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLineRow({
|
||||
line,
|
||||
expanded,
|
||||
onToggle,
|
||||
search,
|
||||
}: {
|
||||
line: ParsedLogLine;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
search: string;
|
||||
}) {
|
||||
const fieldEntries = Object.entries(line.fields);
|
||||
const hasFields = fieldEntries.length > 0;
|
||||
|
||||
// Unparseable line — render the raw text so nothing is hidden. Common
|
||||
// for panic stack traces and partial writes during log rotation.
|
||||
if (!line.timestamp || !line.level) {
|
||||
return (
|
||||
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
|
||||
{highlight(line.raw, search)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
|
||||
hasFields && "cursor-pointer",
|
||||
)}
|
||||
onClick={hasFields ? onToggle : undefined}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<LevelBadge level={line.level} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="break-words">{highlight(line.message, search)}</span>
|
||||
{hasFields && !expanded && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60">
|
||||
{fieldEntries
|
||||
.map(([k, v]) => `${k}=${truncateValue(v)}`)
|
||||
.join(" ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && hasFields && (
|
||||
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{fieldEntries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-muted-foreground/70">{k}</span>
|
||||
<span className="break-all text-foreground/85">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
first,
|
||||
rest,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedFields,
|
||||
onToggleFields,
|
||||
search,
|
||||
}: {
|
||||
first: ParsedLogLine;
|
||||
rest: ParsedLogLine[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedFields: Set<number>;
|
||||
onToggleFields: (id: number) => void;
|
||||
search: string;
|
||||
}) {
|
||||
// Folded: show the first occurrence so the user still sees a sample
|
||||
// (timestamp, level, message), then a click-to-expand placeholder for
|
||||
// the suppressed run. The placeholder uses a dashed border + italics
|
||||
// so the eye reads it as "not a real line".
|
||||
if (!expanded) {
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>
|
||||
{rest.length} more “{truncateValue(first.message, 48)}
|
||||
” — click to expand
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unfolded: render every line, then a small "collapse" affordance at
|
||||
// the end so the user can put the toothpaste back in the tube.
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
{rest.map((l) => (
|
||||
<LogLineRow
|
||||
key={l.id}
|
||||
line={l}
|
||||
expanded={expandedFields.has(l.id)}
|
||||
onToggle={() => onToggleFields(l.id)}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>collapse {rest.length + 1} repeated</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasLogs,
|
||||
hasFilter,
|
||||
isRunning,
|
||||
}: {
|
||||
hasLogs: boolean;
|
||||
hasFilter: boolean;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
if (hasFilter) {
|
||||
title = "No matching log lines";
|
||||
subtitle = "Try a different search or level toggle.";
|
||||
} else if (!isRunning) {
|
||||
title = "Daemon isn't running";
|
||||
subtitle = "Start the daemon to see logs here.";
|
||||
} else if (!hasLogs) {
|
||||
title = "Waiting for logs…";
|
||||
subtitle = "New entries will appear in real time.";
|
||||
} else {
|
||||
title = "";
|
||||
subtitle = "";
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
|
||||
<p className="text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function truncateValue(value: string, max = 32): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const q = query.toLowerCase();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(q);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,22 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
runtimes
|
||||
.filter((r) => r.daemon_id === status.daemonId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}, [runtimes, status.daemonId]);
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
(t) =>
|
||||
localRuntimeIds.has(t.runtime_id) &&
|
||||
(t.status === "running" || t.status === "dispatched"),
|
||||
),
|
||||
[snapshot, localRuntimeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
@@ -108,10 +36,7 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
@@ -119,214 +44,112 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
} else {
|
||||
setConfirmStop(true);
|
||||
}
|
||||
}, [affectedTasks.length, performStop]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRetryInstall = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
status={status}
|
||||
runtimeCount={runtimeCount}
|
||||
/>
|
||||
|
||||
<StopConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
affectedCount={affectedTasks.length}
|
||||
onConfirm={() => {
|
||||
setConfirmStop(false);
|
||||
void performStop();
|
||||
}}
|
||||
/>
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function StopConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
affectedCount,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
affectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const plural = affectedCount === 1 ? "" : "s";
|
||||
const verb = affectedCount === 1 ? "is" : "are";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Stop daemon with {affectedCount} active task{plural}?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{affectedCount} task{plural} {verb} currently running on this
|
||||
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
|
||||
— affected tasks get marked <strong>failed</strong> once the
|
||||
timeout hits. The daemon won't auto-restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Stop daemon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -16,7 +10,7 @@ function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
@@ -29,44 +23,14 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
// One row inside the diagnostics block. Values that are likely to be
|
||||
// long IDs / URLs render as monospaced + truncated with a tooltip.
|
||||
function DiagnosticsRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
@@ -134,68 +98,6 @@ export function DaemonSettingsTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics — moved out of the logs panel so the panel can focus
|
||||
on logs. These fields matter for support tickets and bug reports,
|
||||
not for everyday use. */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold">Diagnostics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Identification and connection details. Useful when filing a bug
|
||||
report or investigating why a runtime isn't showing up.
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
|
||||
<DiagnosticsRow
|
||||
label="State"
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Uptime"
|
||||
value={status.uptime ? formatUptime(status.uptime) : "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="PID"
|
||||
value={status.pid ?? "—"}
|
||||
mono={!!status.pid}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Daemon ID"
|
||||
value={status.daemonId ?? "—"}
|
||||
mono={!!status.daemonId}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Profile"
|
||||
value={status.profile || "default"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Server URL"
|
||||
value={status.serverUrl ?? "—"}
|
||||
mono={!!status.serverUrl}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Device name"
|
||||
value={status.deviceName ?? "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Workspaces"
|
||||
value={
|
||||
typeof status.workspaceCount === "number"
|
||||
? status.workspaceCount
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ 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, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
@@ -98,38 +97,6 @@ function useInternalLinkHandler() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between the renderer and the Electron main process for inbox-level
|
||||
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
|
||||
* current workspace's id for the badge hook.
|
||||
*
|
||||
* Two responsibilities:
|
||||
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
|
||||
* 2. When the user clicks an OS notification, open the notified
|
||||
* workspace's inbox focused on that item. The route uses the `slug`
|
||||
* that the notification was *emitted* with — not the currently active
|
||||
* workspace — so a notification from workspace A always opens A's
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
@@ -151,7 +118,6 @@ export function DesktopShell() {
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<DesktopInboxBridge />
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
|
||||
* `daemonAPI` (main-process daemon state) into the page so its empty
|
||||
* state can distinguish "no runtime registered" from "runtime is on its
|
||||
* way" — without the bundled daemon's status, the page shows a
|
||||
* misleading "Run multica daemon start" hint during the few seconds
|
||||
* between page load and the daemon's first registration.
|
||||
*
|
||||
* `bootstrapping` is true while the daemon is installing, starting, or
|
||||
* already running but hasn't surfaced as a server-side runtime yet.
|
||||
* RuntimeList only shows the spinner when the runtime list is also
|
||||
* empty, so once the daemon registers (and the list fills) the flag
|
||||
* has no visible effect.
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
status.state === "installing_cli" ||
|
||||
status.state === "starting" ||
|
||||
status.state === "running";
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
* 1. Logged-out state → `/login`. No workspace context, no tabs.
|
||||
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
|
||||
* that match the equivalent web routes. Overlays are NOT tab routes on
|
||||
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
|
||||
* tab path alone would either miss them or mislabel them as "/".
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
* implementation in `apps/web/components/pageview-tracker.tsx`.
|
||||
*/
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
|
||||
useEffect(() => {
|
||||
if (!path) return;
|
||||
capturePageview(path);
|
||||
}, [path]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
return "/workspaces/new";
|
||||
case "onboarding":
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
case "invitations":
|
||||
return "/invitations";
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLogLine } from "./parse-daemon-log";
|
||||
|
||||
// All sample lines below are taken verbatim from real daemon output (Go
|
||||
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
|
||||
// stay aligned with what tint actually writes — not what we assume.
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses tint's 3-letter INF level", () => {
|
||||
const line =
|
||||
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.timestamp).toBe("17:52:35.587");
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("task completed");
|
||||
expect(r.fields).toEqual({
|
||||
component: "daemon",
|
||||
task: "c45266e5",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-letter DBG / WRN / ERR levels", () => {
|
||||
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
|
||||
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
|
||||
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("still accepts 4-letter level names (defensive against config changes)", () => {
|
||||
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("regular");
|
||||
});
|
||||
|
||||
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
|
||||
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
|
||||
// We treat the base level as canonical and drop the delta from the UI.
|
||||
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("unusual delta");
|
||||
});
|
||||
|
||||
it("preserves message text containing colons and special chars", () => {
|
||||
// Real sample: "tool #1: Skill component=daemon task=..."
|
||||
const r = parseLogLine(
|
||||
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("tool #1: Skill");
|
||||
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
|
||||
});
|
||||
|
||||
it("unquotes a double-quoted value containing escaped quotes", () => {
|
||||
// Real sample with escaped quotes inside the agent's emitted text.
|
||||
const line =
|
||||
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent");
|
||||
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
|
||||
expect(r.fields.task).toBe("8791b717");
|
||||
});
|
||||
|
||||
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
|
||||
// Real sample: error="Post \"http://...\": dial tcp ..."
|
||||
const line =
|
||||
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.level).toBe("WARN");
|
||||
expect(r.message).toBe("claim task failed");
|
||||
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
|
||||
expect(r.fields.error).toBe(
|
||||
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
|
||||
const line =
|
||||
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent command");
|
||||
expect(r.fields.exec).toBe("claude");
|
||||
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
|
||||
});
|
||||
|
||||
it("handles message words ending with characters before the field block", () => {
|
||||
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
|
||||
const r = parseLogLine(
|
||||
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("execenv: prepared env");
|
||||
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
|
||||
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
|
||||
expect(r.fields).toEqual({});
|
||||
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for unrecognised level tokens", () => {
|
||||
// If tint ever emits something we don't know, never crash; show raw.
|
||||
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
|
||||
});
|
||||
|
||||
it("attaches an id to every parsed line for stable React keys", () => {
|
||||
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
|
||||
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
|
||||
expect(a.id).toBe(7);
|
||||
expect(b.id).toBe(8);
|
||||
});
|
||||
|
||||
it("returns empty fields object when there are no key=value pairs", () => {
|
||||
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
|
||||
expect(r.message).toBe("a bare message with no fields");
|
||||
expect(r.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
// Pure parser for daemon log lines. The daemon writes via Go's slog with
|
||||
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
|
||||
// has a stable shape:
|
||||
//
|
||||
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
|
||||
//
|
||||
// We split it into structured pieces so the UI can render timestamp,
|
||||
// level, message and structured fields in separate columns and let users
|
||||
// filter / search across them. Anything that doesn't match (panic stack
|
||||
// traces, third-party prints, partial writes during log rotation) falls
|
||||
// back to a raw view — we never drop input.
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ParsedLogLine {
|
||||
/** Monotonic id assigned at receive time; stable across re-renders. */
|
||||
id: number;
|
||||
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
|
||||
timestamp: string | null;
|
||||
level: LogLevel | null;
|
||||
/** Human-readable message body, with structured fields stripped off. */
|
||||
message: string;
|
||||
/** key/value pairs trailing the message. Empty if there were none. */
|
||||
fields: Record<string, string>;
|
||||
/** The original line, kept for fallback rendering and copy-to-clipboard. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
|
||||
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
|
||||
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
|
||||
// against future config changes) and normalize them to a canonical
|
||||
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
|
||||
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
|
||||
// and complicates the level filter chips.
|
||||
const HEADER_RE =
|
||||
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
|
||||
|
||||
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
|
||||
DEBUG: "DEBUG",
|
||||
DBG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
INF: "INFO",
|
||||
WARN: "WARN",
|
||||
WRN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
ERR: "ERROR",
|
||||
};
|
||||
// Anchored to the END of the remaining string so we peel one field at a
|
||||
// time from the right. `value` is either a double-quoted string (which may
|
||||
// contain escaped chars) or any non-whitespace run.
|
||||
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractTrailingFields(rest: string): {
|
||||
message: string;
|
||||
fields: Record<string, string>;
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
let work = rest;
|
||||
while (true) {
|
||||
const match = work.match(TRAILING_FIELD_RE);
|
||||
if (!match || match.index === undefined) break;
|
||||
fields[match[1]!] = unquote(match[2]!);
|
||||
work = work.slice(0, match.index);
|
||||
}
|
||||
return { message: work.trim(), fields };
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string, id: number): ParsedLogLine {
|
||||
const match = raw.match(HEADER_RE);
|
||||
if (!match) {
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const [, timestamp, level, rest] = match;
|
||||
const normalized = LEVEL_NORMALIZE[level!];
|
||||
if (!normalized) {
|
||||
// Unknown level token — keep raw shape so we don't mis-categorize.
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const { message, fields } = extractTrailingFields(rest!);
|
||||
return {
|
||||
id,
|
||||
timestamp: timestamp!,
|
||||
level: normalized,
|
||||
message,
|
||||
fields,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -110,25 +110,12 @@ export function UpdateNotification() {
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,12 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date" }
|
||||
| { 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 currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
@@ -23,7 +22,7 @@ export function UpdatesSettingsTab() {
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date" },
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -36,15 +35,6 @@ export function UpdatesSettingsTab() {
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@@ -55,7 +45,7 @@ export function UpdatesSettingsTab() {
|
||||
{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.
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
@@ -59,7 +58,6 @@ function WindowOverlayInner() {
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -83,7 +82,6 @@ export function WorkspaceRouteLayout() {
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agent = agents.find((a) => a.id === id) ?? null;
|
||||
|
||||
useDocumentTitle(agent?.name ?? "Agent");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedAgentDetailPage agentId={id} />;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function RuntimeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const runtime = runtimes?.find((r) => r.id === id);
|
||||
|
||||
useDocumentTitle(runtime?.name ?? "Runtime");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedRuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SkillDetailPage as SharedSkillDetailPage } from "@multica/views/skills";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillDetailOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: skill } = useQuery(skillDetailOptions(wsId, id ?? ""));
|
||||
|
||||
useDocumentTitle(skill?.name ?? "Skill");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedSkillDetailPage skillId={id} />;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { runtimeKeys } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
|
||||
* type — we redeclare the fields we consume here to avoid coupling the bridge
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
|
||||
* field is overridden; other fields (name, provider, last_seen_at, etc)
|
||||
* remain server-authoritative. We deliberately ignore intermediate states
|
||||
* (starting / stopping / installing_cli / cli_not_found) so the cache
|
||||
* doesn't flap during boot — if the daemon is in such a state, the runtime
|
||||
* is effectively offline anyway, and the server-side sweeper will mark it
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return {
|
||||
...rt,
|
||||
status: "online",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local daemon status changes via Electron IPC and writes them
|
||||
* into the runtimes Query cache for the active workspace.
|
||||
*
|
||||
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
|
||||
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
|
||||
* we know about local daemon state instantly via IPC, so we use it to
|
||||
* pre-populate the cache and give users a sub-second feedback loop. Web and
|
||||
* "looking at someone else's daemon" still go through the server path.
|
||||
*
|
||||
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
|
||||
* in the same workspace (one per provider). We map across all matches so
|
||||
* every related runtime row sees the same status flip.
|
||||
*/
|
||||
export function useDaemonIPCBridge(wsId: string | undefined): void {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
|
||||
if (!daemonAPI?.onStatusChange) return;
|
||||
|
||||
const unsubscribe = daemonAPI.onStatusChange((status) => {
|
||||
if (!status.daemonId) return;
|
||||
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((rt) =>
|
||||
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [wsId, qc]);
|
||||
}
|
||||
@@ -61,13 +61,6 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/invitations") {
|
||||
overlay.open({ type: "invitations" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
@@ -121,32 +114,18 @@ export function DesktopNavigationProvider({
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() — ChatWindow in particular —
|
||||
// can read URL search params. Must stay in sync with TabNavigationProvider
|
||||
// below; a partial shape here (just pathname) silently broke focus-mode
|
||||
// anchor resolution on `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
search: router?.state.location.search ?? "",
|
||||
}),
|
||||
const [pathname, setPathname] = useState(
|
||||
router?.state.location.pathname ?? "/",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router) {
|
||||
setLocation({ pathname: "/", search: "" });
|
||||
setPathname("/");
|
||||
return;
|
||||
}
|
||||
setLocation({
|
||||
pathname: router.state.location.pathname,
|
||||
search: router.state.location.search,
|
||||
});
|
||||
setPathname(router.state.location.pathname);
|
||||
return router.subscribe((state) => {
|
||||
setLocation({
|
||||
pathname: state.location.pathname,
|
||||
search: state.location.search,
|
||||
});
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
}, [activeTabId, router]);
|
||||
|
||||
@@ -171,8 +150,8 @@ export function DesktopNavigationProvider({
|
||||
back: () => {
|
||||
currentActiveTab()?.router.navigate(-1);
|
||||
},
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
@@ -188,7 +167,7 @@ export function DesktopNavigationProvider({
|
||||
},
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[location],
|
||||
[pathname],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -9,15 +9,13 @@ import type { RouteObject } from "react-router-dom";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
@@ -116,26 +114,11 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <DesktopRuntimesPage />,
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{
|
||||
path: "runtimes/:id",
|
||||
element: <RuntimeDetailPage />,
|
||||
handle: { title: "Runtime" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
element: <SkillDetailPage />,
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "settings",
|
||||
|
||||
@@ -15,7 +15,6 @@ import { create } from "zustand";
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "invitations" }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
|
||||
@@ -51,35 +51,3 @@ export function formatUptime(uptime?: string): string {
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
* "what does this mean for me?" — i.e. whether tasks can run on this device.
|
||||
*
|
||||
* `runtimeCount` is the number of runtimes the local daemon has registered
|
||||
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
|
||||
* when state === "running".
|
||||
*/
|
||||
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
if (runtimeCount === 0) {
|
||||
return "Running, but no runtimes have registered yet.";
|
||||
}
|
||||
if (runtimeCount === 1) {
|
||||
return "Running here · 1 runtime available for tasks.";
|
||||
}
|
||||
return `Running here · ${runtimeCount} runtimes available for tasks.`;
|
||||
case "stopped":
|
||||
return "Not running · this device can't take new tasks.";
|
||||
case "starting":
|
||||
return "Starting up the local daemon…";
|
||||
case "stopping":
|
||||
return "Shutting down the local daemon…";
|
||||
case "installing_cli":
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,13 +8,12 @@ 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<{ lang: string; slug: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
@@ -35,15 +34,14 @@ export function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates(params.slug),
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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,32 +1,4 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { GET } = createFromSource(source);
|
||||
|
||||
@@ -1,679 +1,3 @@
|
||||
@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,57 +1,4 @@
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
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: {
|
||||
@@ -61,16 +8,12 @@ export const baseOptions: BaseLayoutProps = {
|
||||
},
|
||||
links: [
|
||||
{
|
||||
icon: <GitHubMark />,
|
||||
text: externalLinkText("GitHub"),
|
||||
text: "GitHub",
|
||||
url: "https://github.com/multica-ai/multica",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: <MulticaMark />,
|
||||
text: externalLinkText("Multica"),
|
||||
text: "Cloud",
|
||||
url: "https://multica.ai",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
30
apps/docs/app/layout.tsx
Normal file
30
apps/docs/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import "./global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
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>
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
37
apps/docs/app/page.tsx
Normal file
37
apps/docs/app/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { source } from "@/lib/source";
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsTitle,
|
||||
} from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export default function Page() {
|
||||
const page = source.getPage([]);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
const page = source.getPage([]);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
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 11 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 six (Hermes, Kimi, Kiro CLI, 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 11 tools
|
||||
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
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 支持 11 款,最常用的几款:
|
||||
|
||||
| 工具 | 适合 |
|
||||
|---|---|
|
||||
| **Claude Code** | Anthropic 官方,功能最完整;**新手首选** |
|
||||
| **Codex** | OpenAI,主流替代 |
|
||||
| **Cursor** | Cursor 编辑器生态用户 |
|
||||
| **Copilot** | 用 GitHub 账号权益的团队 |
|
||||
| **Gemini** | Google 生态用户 |
|
||||
|
||||
另外 6 款(Hermes、Kimi、Kiro CLI、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) —— 11 款工具的完整能力差别
|
||||
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
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) —— 智能体真正跑起来需要什么
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
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 | ✓ |
|
||||
| [**Autopilots**](/autopilots) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by autopilot | ✗ |
|
||||
|
||||
"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
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
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 | 当前对话历史 | 固定中 | ✓ |
|
||||
| [**Autopilots**](/autopilots) | 定时 / 手动自动化 | 视模式 | 视模式 | autopilot 自定 | ✗ |
|
||||
|
||||
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/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 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
title: Sign-in and signup configuration
|
||||
description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
## Fixed local testing codes
|
||||
|
||||
<Callout type="warning">
|
||||
**Do not enable a fixed verification code on a publicly reachable instance.**
|
||||
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
This shortcut is ignored when `APP_ENV=production`.
|
||||
</Callout>
|
||||
|
||||
Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
|
||||
|
||||
## 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
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
title: 登录与注册配置
|
||||
description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。
|
||||
---
|
||||
|
||||
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**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
<Callout type="warning">
|
||||
**不要在公网可访问实例上启用固定验证码。**
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
`APP_ENV=production` 时这个快捷码会被忽略。
|
||||
</Callout>
|
||||
|
||||
生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`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`、注册被拒的常见排查
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
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**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. 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
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
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">
|
||||
**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/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) —— 守护进程令牌是从哪来的
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Autopilots is that they are **time-driven**.
|
||||
|
||||
## Configure an autopilot
|
||||
|
||||
Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
- **Name** — display name
|
||||
- **Agent** — who the run is dispatched to
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
A few examples:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
|
||||
- `*/30 * * * *`, `UTC` — every 30 minutes
|
||||
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
|
||||
|
||||
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
|
||||
|
||||
## Trigger once manually
|
||||
|
||||
To avoid waiting for cron while debugging an autopilot, trigger it manually:
|
||||
|
||||
- UI: click "Run now" on the autopilot detail page
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
|
||||
|
||||
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
|
||||
</Callout>
|
||||
|
||||
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
|
||||
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Autopilots 的核心差别是**时间驱动**。
|
||||
|
||||
## 配置一个 Autopilot
|
||||
|
||||
在工作区的 **Autopilot** 页新建一条 autopilot,要定下:
|
||||
|
||||
- **名字** — 显示名
|
||||
- **执行智能体** — 到点派给谁
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
</Callout>
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
几个例子:
|
||||
|
||||
- `0 9 * * 1-5`,`Asia/Shanghai` —— 工作日北京时间早上 9 点
|
||||
- `*/30 * * * *`,`UTC` —— 每 30 分钟一次
|
||||
- `0 3 * * *`,`UTC` —— 每天 UTC 凌晨 3 点
|
||||
|
||||
Multica 服务器每 **30 秒**扫一次到期的触发器——**触发时刻最多延迟 30 秒**,不是秒级精准。服务器重启时如果恰好错过触发点,启动时会补扫漏掉的触发(不会丢触发,但会立刻补跑)。
|
||||
|
||||
## 手动触发一次
|
||||
|
||||
调试 Autopilot 时不想等 cron,可以手动触发一次:
|
||||
|
||||
- UI:在 Autopilot 详情页点"手动运行"
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed`)
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Autopilot 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run),但这一次失败的工作不会被自动补跑。
|
||||
|
||||
如果 Autopilot 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
|
||||
</Callout>
|
||||
|
||||
不自动重试的理由:Autopilot 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
|
||||
|
||||
## 暂不可用的能力
|
||||
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 一次性把 issue 指派给智能体
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 评论里让智能体看一眼
|
||||
- [**对话**](/chat) —— 独立于 issue 的一对一聊天
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
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
|
||||
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
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>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上
|
||||
@@ -1,129 +0,0 @@
|
||||
---
|
||||
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 |
|
||||
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica autopilot list` | List every autopilot in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single autopilot |
|
||||
| `multica autopilot create ...` | Create an autopilot |
|
||||
| `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`
|
||||
@@ -1,129 +0,0 @@
|
||||
---
|
||||
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 的文件 |
|
||||
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica autopilot list` | 列出工作区所有 autopilot |
|
||||
| `multica autopilot get <id>` | 查看单个 autopilot |
|
||||
| `multica autopilot create ...` | 创建 autopilot |
|
||||
| `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` 的完整选项
|
||||
@@ -78,7 +78,7 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, `kiro`, or `pi`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
@@ -88,8 +88,6 @@ If the agents list is empty, install at least one supported AI agent CLI:
|
||||
- OpenCode (`opencode`)
|
||||
- OpenClaw (`openclaw`)
|
||||
- Hermes (`hermes`)
|
||||
- Kimi (`kimi`)
|
||||
- Kiro CLI (`kiro-cli`)
|
||||
|
||||
Then restart the daemon:
|
||||
|
||||
@@ -92,10 +92,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
| Pi | `pi` | Inflection coding agent |
|
||||
| Cursor Agent | `cursor-agent` | Cursor coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -138,14 +134,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
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), [Kiro CLI](/providers#kiro-cli), [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 11 tools
|
||||
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
|
||||
- [Self-host quickstart](/self-host-quickstart) — run your own backend
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
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)、[Kiro CLI](/providers#kiro-cli)、[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) —— 11 款工具的能力差异
|
||||
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
|
||||
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
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 day-to-day 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
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
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) —— 用 `@` 在评论里触发智能体开工
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
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` (11 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), [Kiro CLI](/providers#kiro-cli), [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; Autopilot-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 11 AI coding tools
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
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 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[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 会自动重新排队;Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/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) —— 11 款 AI 编程工具的能力差异对照
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
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="warning">
|
||||
**Released Desktop builds are pinned to Multica Cloud.** The backend, websocket, and web URLs are baked in at build time (`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`) — there is no in-app option to point Desktop at a self-hosted instance. To use Desktop against a self-hosted backend you need to build it yourself:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# Edit apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
```
|
||||
|
||||
If you'd rather not build from source, the supported self-hosted path is **web frontend + CLI** — see [Self-host quickstart](/self-host-quickstart). Runtime backend configuration in Desktop is tracked in [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend (Desktop against self-host requires a custom build, see the callout above)
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
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="warning">
|
||||
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
# 编辑 apps/desktop/.env.production:
|
||||
# VITE_API_URL=https://api.your-domain
|
||||
# VITE_WS_URL=wss://api.your-domain/ws
|
||||
# VITE_APP_URL=https://your-domain
|
||||
pnpm install
|
||||
pnpm --filter @multica/desktop package
|
||||
```
|
||||
|
||||
不想自己 build 的话,自部署的官方路径是 **Web 前端 + CLI**——见 [自部署快速上手](/self-host-quickstart)。Desktop 运行时切换后端的能力跟踪在 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
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).
|
||||
|
||||
## Core server variables
|
||||
|
||||
These are the core variables you must think about before deploying — some have defaults that let the server start, but in production you should set the required ones 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`) |
|
||||
| `FRONTEND_ORIGIN` | empty | **Yes** (self-host must set its own domain) |
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | empty | No (must stay empty in production) |
|
||||
|
||||
<Callout type="warning">
|
||||
**Keep `MULTICA_DEV_VERIFICATION_CODE` empty in production.** A fixed local test code is disabled by default, but if you opt in with `MULTICA_DEV_VERIFICATION_CODE=888888`, anyone who can request a code can sign in with that fixed value while `APP_ENV` is non-production. The shortcut is ignored when `APP_ENV=production`.
|
||||
</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
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
title: 环境变量
|
||||
description: self-host Multica 服务器需要配置的环境变量清单。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量读取配置——数据库、登录、邮件、存储、注册白名单都在这里配。这一页按用途分组给完整清单:每组说清楚**不设会怎样**、**生产必须设哪几个**。Auth 相关那几个怎么真正配见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 核心 server 环境变量
|
||||
|
||||
这些是你部署前必须考虑的核心变量——有些有默认值能让 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 要填你自己的域名)|
|
||||
| `MULTICA_DEV_VERIFICATION_CODE` | 空 | 否(生产必须保持为空)|
|
||||
|
||||
<Callout type="warning">
|
||||
**生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。** 固定本地测试验证码默认关闭;如果你设置 `MULTICA_DEV_VERIFICATION_CODE=888888`,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用这个固定值登录。`APP_ENV=production` 时该快捷码会被忽略。
|
||||
</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_*` 参数的行为含义
|
||||
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This installs the CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 and pick a login method: configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
@@ -53,31 +53,25 @@ make selfhost
|
||||
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
|
||||
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
|
||||
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml pull && docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
|
||||
Open http://localhost:3000. 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:
|
||||
|
||||
- **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 [Configuration](#configuration) below.
|
||||
- **Without email configured:** 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.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
<Callout>
|
||||
**Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
**Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
@@ -157,15 +151,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
|
||||
Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
</Callout>
|
||||
|
||||
## Upgrading
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
@@ -198,18 +191,6 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### Signup Controls (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
|
||||
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
|
||||
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
|
||||
|
||||
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
@@ -408,23 +389,14 @@ NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes public health endpoints:
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```text
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Upgrading
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user