mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 20:58:56 +02:00
Compare commits
1 Commits
agent/lamb
...
fix/code-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565d45cdcc |
@@ -63,9 +63,6 @@ GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
CLOUDFRONT_KEY_PAIR_ID=
|
||||
@@ -132,8 +129,5 @@ ALLOWED_EMAILS=
|
||||
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
# Optional override for the `environment` PostHog event property.
|
||||
# Defaults from APP_ENV and normalizes to production / staging / dev.
|
||||
ANALYTICS_ENVIRONMENT=
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,8 +40,6 @@ Closes #
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
|
||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -29,17 +29,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Verify reserved-slugs.ts is up to date
|
||||
# Re-runs the generator and fails on any drift from the
|
||||
# checked-in TypeScript output. The Go side embeds the JSON
|
||||
# source directly, so a passing diff here proves both sides
|
||||
# share one source of truth.
|
||||
run: |
|
||||
pnpm generate:reserved-slugs
|
||||
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
|
||||
- name: Build, type check, and test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
34
CLAUDE.md
34
CLAUDE.md
@@ -2,21 +2,6 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Conventions reference
|
||||
|
||||
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
|
||||
|
||||
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
|
||||
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
|
||||
|
||||
Read that page before:
|
||||
|
||||
- Writing or editing translations (`packages/views/locales/`)
|
||||
- Naming a new route, package, file, DB column, or TS type
|
||||
- Writing Chinese product copy (UI strings, error messages, docs)
|
||||
|
||||
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
|
||||
|
||||
## Project Context
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
@@ -146,27 +131,10 @@ make start-worktree # Start using .env.worktree
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
|
||||
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- 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.
|
||||
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
|
||||
|
||||
### API Response Compatibility
|
||||
|
||||
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
|
||||
|
||||
When writing code that consumes an API response, follow these rules:
|
||||
|
||||
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
|
||||
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
|
||||
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
|
||||
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
|
||||
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
|
||||
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
|
||||
|
||||
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
|
||||
@@ -305,12 +305,10 @@ multica workspace members <workspace-id>
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -323,10 +321,9 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -338,12 +335,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
@@ -394,19 +388,17 @@ Subscribers receive notifications about issue activity (new comments, status cha
|
||||
```bash
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --full-id
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <short-task-id> --issue <issue-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Projects
|
||||
|
||||
@@ -516,12 +508,9 @@ Autopilots are scheduled/triggered automations that dispatch agent tasks (either
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot list --full-id
|
||||
multica autopilot list --status active --output json
|
||||
```
|
||||
|
||||
Autopilot table IDs are short UUID prefixes; follow-up autopilot commands accept copied prefixes when they are unique in the current workspace. Use `--full-id` to print canonical UUIDs.
|
||||
|
||||
### Get Autopilot Details
|
||||
|
||||
```bash
|
||||
|
||||
@@ -56,15 +56,13 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
|
||||
12
apps/desktop/.env.production
Normal file
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 491 KiB After Width: | Height: | Size: 35 KiB |
@@ -8,8 +8,6 @@ import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
@@ -39,10 +37,6 @@ if (process.platform !== "win32") {
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
};
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
@@ -78,25 +72,7 @@ function handleDeepLink(url: string): void {
|
||||
|
||||
// --- Window creation -----------------------------------------------------
|
||||
|
||||
// Tracks the OS-preferred language as last seen by the running process.
|
||||
// Updated on each window-focus check so we can emit a `locale:system-changed`
|
||||
// event to the renderer when the user changes their OS language without
|
||||
// quitting the app — without restart, app.getPreferredSystemLanguages()
|
||||
// would still report the boot value forever.
|
||||
let lastKnownSystemLocale = "en";
|
||||
|
||||
function getSystemLocale(): string {
|
||||
return app.getPreferredSystemLanguages()[0] ?? "en";
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Pass the OS-preferred language to the renderer via additionalArguments
|
||||
// instead of a sync IPC call. process.argv is available to the preload
|
||||
// script before the first network request, so the renderer's i18next
|
||||
// instance can initialize with the right locale on the very first paint.
|
||||
const systemLocale = getSystemLocale();
|
||||
lastKnownSystemLocale = systemLocale;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
@@ -113,7 +89,6 @@ function createWindow(): void {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,18 +106,6 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
// dedicated event for this on any platform, so we poll on focus regain —
|
||||
// catches the common case where users switch System Settings → Language
|
||||
// and bring the app back. The renderer decides whether to act (it ignores
|
||||
// the signal when the user has an explicit Settings choice).
|
||||
mainWindow.on("focus", () => {
|
||||
const current = getSystemLocale();
|
||||
if (current === lastKnownSystemLocale) return;
|
||||
lastKnownSystemLocale = current;
|
||||
mainWindow?.webContents.send("locale:system-changed", current);
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
@@ -224,25 +187,7 @@ if (!gotTheLock) {
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const viteEnv = import.meta.env as ImportMetaEnv & {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
};
|
||||
|
||||
runtimeConfigResult = await loadRuntimeConfig({
|
||||
isDev: is.dev,
|
||||
// electron-vite exposes VITE_* on import.meta.env for the main process;
|
||||
// keep dev URL overrides on the same source the renderer used before
|
||||
// runtime config moved endpoint resolution into main/preload.
|
||||
env: {
|
||||
apiUrl: viteEnv.VITE_API_URL,
|
||||
wsUrl: viteEnv.VITE_WS_URL,
|
||||
appUrl: viteEnv.VITE_APP_URL,
|
||||
},
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
@@ -278,13 +223,6 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
ipcMain.on("runtime-config:get", (event) => {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mkdtemp, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
|
||||
describe("loadRuntimeConfig", () => {
|
||||
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: true,
|
||||
configPath,
|
||||
env: {
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cloud defaults when packaged config is absent", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: false,
|
||||
configPath: join(dir, "missing.json"),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a valid packaged desktop.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when packaged desktop.json is invalid", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(configPath, "{");
|
||||
|
||||
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain(configPath);
|
||||
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
type RuntimeConfig,
|
||||
type RuntimeConfigEnv,
|
||||
type RuntimeConfigResult,
|
||||
} from "../shared/runtime-config";
|
||||
|
||||
export async function loadRuntimeConfig(options: {
|
||||
isDev: boolean;
|
||||
env: RuntimeConfigEnv;
|
||||
configPath?: string;
|
||||
}): Promise<RuntimeConfigResult> {
|
||||
if (options.isDev) {
|
||||
try {
|
||||
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: { message: errorMessage(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = options.configPath ?? desktopConfigPath();
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
return { ok: true, config: parseRuntimeConfig(raw) };
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Invalid ${configPath}: ${errorMessage(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopConfigPath(): string {
|
||||
return join(app.getPath("home"), ".multica", "desktop.json");
|
||||
}
|
||||
|
||||
function isMissingFileError(err: unknown): boolean {
|
||||
return Boolean(
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export type { RuntimeConfig, RuntimeConfigResult };
|
||||
7
apps/desktop/src/preload/index.d.ts
vendored
7
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -7,12 +6,6 @@ interface DesktopAPI {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
|
||||
systemLocale: string;
|
||||
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** 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. */
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -22,53 +21,12 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
function fetchRuntimeConfig(): RuntimeConfigResult {
|
||||
try {
|
||||
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
|
||||
if (result && typeof result === "object" && "ok" in result) return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: "Runtime config unavailable" } };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
const runtimeConfig = fetchRuntimeConfig();
|
||||
|
||||
// Read the OS-preferred locale that main injected via additionalArguments.
|
||||
// Zero IPC, zero blocking — process.argv is populated before preload runs.
|
||||
function fetchSystemLocale(): string {
|
||||
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
|
||||
return arg?.split("=")[1] ?? "en";
|
||||
}
|
||||
|
||||
const systemLocale = fetchSystemLocale();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
|
||||
* Used by the renderer's LocaleAdapter as the system-preference signal. */
|
||||
systemLocale,
|
||||
/** Subscribe to OS language changes detected after boot. The renderer
|
||||
* decides whether to act (no-op when the user has an explicit Settings
|
||||
* choice). Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
|
||||
callback(locale);
|
||||
ipcRenderer.on("locale:system-changed", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("locale:system-changed", handler);
|
||||
};
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { pickLocale } from "@multica/core/i18n";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -16,8 +15,6 @@ 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";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -33,16 +30,11 @@ function AppContent() {
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
|
||||
? window.desktopAPI.runtimeConfig.config
|
||||
: null;
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) return;
|
||||
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
|
||||
}, [runtimeConfig]);
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
@@ -234,21 +226,9 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
|
||||
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
|
||||
</p>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
@@ -272,61 +252,22 @@ async function handleDaemonLogout() {
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
// 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],
|
||||
);
|
||||
// Locale resolution happens once at app boot. Switching language goes
|
||||
// through window.location.reload() to avoid hydration mismatch.
|
||||
const localeAdapter = useMemo(
|
||||
() => createDesktopLocaleAdapter(systemLocale),
|
||||
[systemLocale],
|
||||
);
|
||||
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
|
||||
const resources = useMemo(
|
||||
() => ({ [locale]: RESOURCES[locale] }),
|
||||
[locale],
|
||||
);
|
||||
|
||||
// React to OS-level language changes detected by main on focus regain.
|
||||
// Only act when the user is following the system signal (no explicit
|
||||
// Settings choice) — otherwise their preference wins. Cross-device sync
|
||||
// for the explicit-choice case is handled inside CoreProvider.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
|
||||
if (localeAdapter.getUserChoice()) return;
|
||||
const next = pickLocale({
|
||||
...localeAdapter,
|
||||
getSystemPreferences: () =>
|
||||
nextSystemLocale ? [nextSystemLocale] : [],
|
||||
});
|
||||
if (next === locale) return;
|
||||
localeAdapter.persist(next);
|
||||
window.location.reload();
|
||||
});
|
||||
}, [localeAdapter, locale]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{runtimeConfigResult.ok ? (
|
||||
<CoreProvider
|
||||
apiBaseUrl={runtimeConfigResult.config.apiUrl}
|
||||
wsUrl={runtimeConfigResult.config.wsUrl}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
localeAdapter={localeAdapter}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
) : (
|
||||
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
|
||||
)}
|
||||
<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>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
@@ -14,9 +13,5 @@ export function IssueDetailPage() {
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
|
||||
@@ -2,23 +2,14 @@ import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
function requireRuntimeAppUrl(): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const webUrl = requireRuntimeAppUrl();
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${webUrl}/login?platform=desktop`,
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { LocaleAdapter, SupportedLocale } from "@multica/core/i18n";
|
||||
|
||||
const STORAGE_KEY = "multica-locale";
|
||||
|
||||
// Desktop adapter:
|
||||
// - User choice: localStorage (set by Settings switcher).
|
||||
// - System preference: locale main injected via additionalArguments
|
||||
// (read from preload, exposed on window.desktopAPI.systemLocale).
|
||||
// - Persist: localStorage. The Settings switcher additionally PATCHes
|
||||
// /api/me when logged in so user.language follows the user across devices.
|
||||
export function createDesktopLocaleAdapter(systemLocale: string): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice() {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSystemPreferences() {
|
||||
return systemLocale ? [systemLocale] : [];
|
||||
},
|
||||
persist(locale: SupportedLocale) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,15 +15,11 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
function requireRuntimeAppUrl(scope: string): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
`Invariant violated: ${scope} rendered before App accepted runtime config`,
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -120,7 +116,6 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
@@ -191,9 +186,9 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, location],
|
||||
[location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
@@ -216,7 +211,6 @@ export function TabNavigationProvider({
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -252,9 +246,9 @@ export function TabNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, router, location],
|
||||
[router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -21,7 +21,6 @@ import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
@@ -84,15 +83,7 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <WorkspaceRouteLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{
|
||||
path: "issues",
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
handle: { title: "Issues" },
|
||||
},
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
deriveWsUrl,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
} from "./runtime-config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
it("uses cloud defaults without a desktop.json file", () => {
|
||||
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives https/wss compatible URLs from apiUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
|
||||
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the leading api. label when deriving appUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.multica.ai" }),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ws for http api URLs", () => {
|
||||
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
|
||||
});
|
||||
|
||||
it("accepts explicit appUrl and wsUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com/",
|
||||
wsUrl: "wss://ws.example.com/socket/",
|
||||
appUrl: "https://app.example.com/",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://ws.example.com/socket",
|
||||
appUrl: "https://app.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
|
||||
).toThrow(/schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects non-http api schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
|
||||
).toThrow(/apiUrl must use http or https/);
|
||||
});
|
||||
|
||||
it("rejects non-ws websocket schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "https://api.example.com/ws",
|
||||
}),
|
||||
),
|
||||
).toThrow(/wsUrl must use ws or wss/);
|
||||
});
|
||||
|
||||
it("preserves electron-vite dev env precedence", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "http://dev-api.example.test:8080/",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws/",
|
||||
appUrl: "http://dev-app.example.test:3000/",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://dev-api.example.test:8080",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws",
|
||||
appUrl: "http://dev-app.example.test:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to local web URL when dev apiUrl is localhost", () => {
|
||||
expect(runtimeConfigFromDevEnv({ apiUrl: "http://localhost:8080" })).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives dev appUrl by stripping the leading api. label", () => {
|
||||
// When the dev renderer is pointed at a remote backend (e.g. a test
|
||||
// environment), copy-link / share URLs must reflect that environment's
|
||||
// public web host, not the api host. Multica's convention exposes the
|
||||
// api at `api.<web-host>`, so stripping the leading label gives the
|
||||
// right web origin without a separate VITE_APP_URL.
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({ apiUrl: "https://api.test.multica.ai" }),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
wsUrl: "wss://api.test.multica.ai/ws",
|
||||
appUrl: "https://test.multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("dev VITE_APP_URL still wins over apiUrl-derived value", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
appUrl: "https://staging.multica.ai",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
wsUrl: "wss://api.test.multica.ai/ws",
|
||||
appUrl: "https://staging.multica.ai",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
export interface RuntimeConfig {
|
||||
schemaVersion: 1;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RuntimeConfigResult =
|
||||
| { ok: true; config: RuntimeConfig }
|
||||
| { ok: false; error: RuntimeConfigError };
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
|
||||
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface RuntimeConfigEnv {
|
||||
apiUrl?: string;
|
||||
wsUrl?: string;
|
||||
appUrl?: string;
|
||||
}
|
||||
|
||||
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
const apiUrl = normalizeHttpUrl(
|
||||
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
|
||||
"VITE_API_URL",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl,
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: env.appUrl
|
||||
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
|
||||
: deriveDevAppUrl(apiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConfig(raw: string): RuntimeConfig {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid desktop runtime config: expected a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
|
||||
}
|
||||
|
||||
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
|
||||
const appUrl = optionalString(obj.appUrl, "appUrl");
|
||||
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
|
||||
|
||||
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl: normalizedApiUrl,
|
||||
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
|
||||
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveWsUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error("apiUrl must use http or https");
|
||||
url.pathname = joinPath(url.pathname, "/ws");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
// Convention: api hosts are exposed at `api.<web-host>` (api.multica.ai →
|
||||
// multica.ai, api.test.multica.ai → test.multica.ai). Strip the leading
|
||||
// `api.` label so a single `apiUrl` configuration produces the right
|
||||
// shareable web URL. Hosts that don't match the convention (no leading
|
||||
// `api.` label, or short two-label hosts like `api.local`) fall through
|
||||
// untouched — those deployments must set `appUrl` explicitly.
|
||||
export function deriveAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
if (url.hostname.startsWith("api.") && url.hostname.split(".").length >= 3) {
|
||||
url.hostname = url.hostname.slice("api.".length);
|
||||
}
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
// Dev variant: when the api host is the local backend (`localhost:8080` /
|
||||
// `127.0.0.1:8080`), the renderer is served from a different port (3000),
|
||||
// so deriving by host alone is wrong. Fall back to the local dev web URL
|
||||
// in that case; for any non-local host (e.g. a remote test environment),
|
||||
// trust the production-style derivation so `apiUrl=https://api.test.x`
|
||||
// yields `appUrl=https://test.x` without a separate VITE_APP_URL.
|
||||
export function deriveDevAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
||||
return LOCAL_DEV_RUNTIME_CONFIG.appUrl;
|
||||
}
|
||||
return deriveAppUrl(apiUrl);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function joinPath(base: string, suffix: string): string {
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}${suffix}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -9,14 +9,6 @@ import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
@@ -26,16 +18,13 @@ export default async function Page(props: {
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const lang = asLang(params.lang);
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
|
||||
</DocsLocaleProvider>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/comp
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { homeCopy } from "@/lib/translations";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
@@ -53,18 +52,15 @@ export default async function Page({
|
||||
/>
|
||||
<Byline items={[...copy.byline]} />
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
a: LocaleLink,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
</DocsLocaleProvider>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDocsLocale } from "@/components/locale-link";
|
||||
import { prefixLocale } from "@/lib/locale-link";
|
||||
|
||||
/**
|
||||
* Byline — editorial metadata strip with ruled top + bottom borders.
|
||||
@@ -59,10 +55,9 @@ export function NumberedCard({
|
||||
tag?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const lang = useDocsLocale();
|
||||
return (
|
||||
<Link
|
||||
href={prefixLocale(href, lang)}
|
||||
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">
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
type AnchorHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { prefixLocale } from "@/lib/locale-link";
|
||||
|
||||
const DocsLocaleContext = createContext<Lang>(i18n.defaultLanguage as Lang);
|
||||
|
||||
// Wraps the rendered MDX subtree so descendant <LocaleLink>s and any
|
||||
// editorial component using `useDocsLocale()` know which language the page
|
||||
// was rendered in. Mounted at each docs page entry; never elsewhere.
|
||||
export function DocsLocaleProvider({
|
||||
lang,
|
||||
children,
|
||||
}: {
|
||||
lang: Lang;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DocsLocaleContext.Provider value={lang}>
|
||||
{children}
|
||||
</DocsLocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDocsLocale(): Lang {
|
||||
return useContext(DocsLocaleContext);
|
||||
}
|
||||
|
||||
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same
|
||||
// surface shape as the default `a` from `defaultMdxComponents` but routes
|
||||
// internal links through the locale prefixer + next/link so client-side
|
||||
// navigation stays inside the active locale.
|
||||
export function LocaleLink({
|
||||
href,
|
||||
...rest
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) {
|
||||
const lang = useDocsLocale();
|
||||
if (!href) return <a {...rest} />;
|
||||
const final = prefixLocale(href, lang);
|
||||
return <Link href={final} {...rest} />;
|
||||
}
|
||||
@@ -32,10 +32,9 @@ The command-line equivalent:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
`--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:
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
`--to` 后跟成员用户名或智能体名字。给智能体起个好记的名字会让这一步顺很多——工作区里重名的会按列出顺序选第一个,建议先改名再分配。
|
||||
|
||||
取消分配:
|
||||
|
||||
|
||||
@@ -40,25 +40,20 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `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 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
<Callout type="info">
|
||||
`list` commands (`multica issue list`, `autopilot list`, `project list`, etc.) print short, copy-paste-ready IDs by default — issue keys like `MUL-123` for issues, short UUID prefixes for the rest. The `<id>` argument on the follow-up commands below accepts either the short ID or the full UUID, so the typical flow is `multica issue list` → copy the key → `multica issue get MUL-123`. Pass `--full-id` to a list command when you need the canonical UUID.
|
||||
</Callout>
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica issue list` | List issues (prints copy-paste-ready issue keys) |
|
||||
| `multica issue get <id>` | Show a single issue (accepts an issue key or a UUID) |
|
||||
| `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>` | Re-enqueue a fresh task for the issue's current agent assignee |
|
||||
| `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 |
|
||||
@@ -103,6 +98,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `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
|
||||
|
||||
@@ -40,25 +40,20 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
<Callout type="info">
|
||||
`list` 类命令(`multica issue list`、`autopilot list`、`project list` 等)表格里默认显示**可直接复制**的短 ID:issue 是 key(如 `MUL-123`),其余资源是 UUID 短前缀。下面表格里的 `<id>` 同时接受短 ID 和完整 UUID,所以典型用法是 `multica issue list` → 复制 key → `multica issue get MUL-123`。需要完整 UUID 时给 `list` 加 `--full-id`。
|
||||
</Callout>
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica issue list` | 列出 issue(默认显示可复制的 issue key) |
|
||||
| `multica issue get <id>` | 查看单条 issue(接受 issue key 或 UUID) |
|
||||
| `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>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
|
||||
| `multica issue rerun <id>` | 重跑最近一次智能体任务 |
|
||||
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
|
||||
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
@@ -103,6 +98,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica runtime list` | 列出当前工作区的 runtime |
|
||||
| `multica runtime usage` | 查看资源使用情况 |
|
||||
| `multica runtime activity` | 近期活动记录 |
|
||||
| `multica runtime ping <id>` | 立即戳一次 runtime 检查在线 |
|
||||
| `multica runtime update <id> ...` | 更新 runtime 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
@@ -213,28 +213,6 @@ multica workspace get <workspace-id> --output json
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
### Update Workspace
|
||||
|
||||
需要 admin 或 owner 权限。所有字段都是部分更新(PATCH 语义):未传的字段保持不变。
|
||||
|
||||
```bash
|
||||
multica workspace update <workspace-id> --name "Acme Eng"
|
||||
multica workspace update <workspace-id> \
|
||||
--description "Engineering team workspace" \
|
||||
--issue-prefix ENG
|
||||
```
|
||||
|
||||
长文本走 stdin(保留换行/反斜杠):
|
||||
|
||||
```bash
|
||||
cat <<'CTX' | multica workspace update <workspace-id> --context-stdin
|
||||
我们是一支 5 人 AI-native 团队。
|
||||
工作语言:中文 + 英文混合。
|
||||
CTX
|
||||
```
|
||||
|
||||
可编辑字段:`--name`、`--description` / `--description-stdin`、`--context` / `--context-stdin`、`--issue-prefix`。`slug` 创建后只读,不暴露在 CLI。`--description` 与 `--description-stdin`(以及 `context` 同名对)互斥。未传任何字段 flag 时命令拒绝执行,避免空 PATCH 触发无意义的 workspace 更新事件。`--issue-prefix ""` 也会被拒绝:当前后端在 prefix 为空时静默跳过该字段,CLI 在本地拦下避免“看似成功的 no-op”。
|
||||
|
||||
## Issues
|
||||
|
||||
### List Issues
|
||||
@@ -243,31 +221,25 @@ CTX
|
||||
multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
表格输出默认显示可直接复制到后续命令的 issue `KEY`(例如 `MUL-123`);需要完整 UUID 时使用 `--full-id`。Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
```bash
|
||||
multica issue get MUL-123
|
||||
multica issue get <uuid>
|
||||
multica issue get <id>
|
||||
multica issue get <id> --output json
|
||||
```
|
||||
|
||||
`<id>` 同时接受 issue key(`multica issue list` 表格里直接显示,例如 `MUL-123`)和完整 UUID(给 `list` 加 `--full-id` 可显示)。同样的规则适用于下面 `update` / `assign` / `status` / `comment` / `subscriber` / `runs` 等接受 `<id>` 的命令。
|
||||
|
||||
### Create Issue
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -279,12 +251,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
@@ -314,20 +283,16 @@ multica issue comment delete <comment-id>
|
||||
```bash
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --full-id
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <short-task-id> --issue <issue-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
`runs` 的表格输出默认显示 task UUID 短前缀;需要完整 task UUID 时使用 `--full-id`。`run-messages` 可直接接受完整 task UUID;从 `runs` 表格复制短前缀时需要同时传 `--issue <issue-id>`,CLI 只会在该 issue 的 runs 内解析。
|
||||
|
||||
## Projects
|
||||
|
||||
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
|
||||
|
||||
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
|
||||
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. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
|
||||
`--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**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Common commands:
|
||||
|
||||
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. See the [Desktop app](/desktop-app) page for which option fits your workflow.
|
||||
**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
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ multica daemon start
|
||||
|
||||
完整 CLI 参考见 [CLI 命令速查](/cli)。
|
||||
|
||||
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。哪种方式更适合你的工作流,详见 [桌面应用](/desktop-app) 页面。
|
||||
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。
|
||||
|
||||
## 为什么一台机器会有多个运行时
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: What Multica Desktop is, how it differs from the web app, and when
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
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
|
||||
|
||||
@@ -66,34 +66,25 @@ Grab the installer for your platform from the [Multica downloads page](https://m
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
|
||||
<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:
|
||||
|
||||
Minimal self-host config:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```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
|
||||
```
|
||||
|
||||
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
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 and connecting with the CLI or Desktop runtime config
|
||||
- [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)
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Multica Desktop 是什么、和 Web 有什么区别、什么时候
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端、看到的数据完全一样。Desktop 默认使用 Multica Cloud;自部署实例可以通过本地运行时配置文件接入。它还给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。它和 Web 版连同一个后端,看到的数据完全一样,但给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
|
||||
## Desktop 和 Web 该用哪个
|
||||
|
||||
@@ -66,34 +66,25 @@ macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop 默认连接 Multica Cloud,但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
|
||||
<Callout type="warning">
|
||||
**发布版的 Desktop 是锁死连 Multica Cloud 的**。后端 / WebSocket / Web 前端 URL(`VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`)在构建时就写死了,应用内**没有切换后端的入口**。要让 Desktop 连自部署后端,需要你自己从源码 build:
|
||||
|
||||
最小自部署配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```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
|
||||
```
|
||||
|
||||
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws`,`https` 对应 `wss`,`http` 对应 `ws`)和 `appUrl`(API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
如果 `desktop.json` 存在但内容无效,Desktop 会 fail closed,显示阻塞式配置错误,而不是悄悄回退到 Cloud。开发构建里,`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
不想自己 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) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端(Desktop 连自部署需要自行构建,见上方提示)
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
---
|
||||
title: Conventions
|
||||
description: Single source of truth for code naming, i18n translation glossary, and Chinese voice guide.
|
||||
---
|
||||
|
||||
This page is the single source of truth for code naming, the i18n translation glossary, and the Chinese voice guide. Anything that used to live in `packages/views/locales/glossary.md` or in scattered comments now lives here.
|
||||
|
||||
If you write Multica code, change a translation, or write Chinese product copy, this is the page to reference.
|
||||
|
||||
---
|
||||
|
||||
## 1. Code naming
|
||||
|
||||
### Routes
|
||||
|
||||
Pre-workspace routes (the routes that exist before the user is in a workspace) MUST use either a single word or the `/{noun}/{verb}` pattern.
|
||||
|
||||
- ✅ `/login`, `/inbox`, `/workspaces/new`
|
||||
- ❌ `/new-workspace`, `/create-team`, `/accept-invite`
|
||||
|
||||
Hyphenated word groups at the root collide with user-chosen workspace slugs and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
|
||||
### Workspace-scoped routes
|
||||
|
||||
Always live under `/{slug}/{section}` — `/{slug}/issues`, `/{slug}/agents`, `/{slug}/settings`. Never duplicate workspace routing logic; use `useNavigation().push()` from shared code, never framework-specific link APIs.
|
||||
|
||||
### Packages and modules
|
||||
|
||||
The monorepo enforces strict package boundaries:
|
||||
|
||||
| Package | May depend on | Must NOT depend on |
|
||||
| --- | --- | --- |
|
||||
| `packages/core` | nothing app-specific | `react-dom`, `localStorage`, `process.env`, `next/*`, UI libraries |
|
||||
| `packages/ui` | nothing | `@multica/core`, business logic |
|
||||
| `packages/views` | `core/`, `ui/` | `next/*`, `react-router-dom`, stores |
|
||||
| `apps/web/platform/` | `next/*` | other apps |
|
||||
| `apps/desktop/.../platform/` | `react-router-dom`, electron | other apps |
|
||||
|
||||
If logic appears in both apps, it MUST be extracted to a shared package. There are no exceptions for "small" duplication.
|
||||
|
||||
### Files and components
|
||||
|
||||
- Files: `kebab-case.tsx` / `kebab-case.ts` (e.g. `agent-row-actions.tsx`)
|
||||
- Components: `PascalCase` (e.g. `AgentRowActions`)
|
||||
- Hooks: `useCamelCase` (e.g. `useWorkspaceId`)
|
||||
- Tests: colocated as `<file>.test.ts(x)`
|
||||
- Stores (Zustand): `<feature>-store.ts`, exported as `use<Feature>Store`
|
||||
|
||||
### Database (Go + sqlc)
|
||||
|
||||
- Tables: `snake_case` singular (`user`, `workspace`, `agent_runtime`)
|
||||
- Columns: `snake_case` (`workspace_id`, `created_at`, `last_seen_at`)
|
||||
- Foreign keys: `<table>_id`
|
||||
- Booleans: `is_<state>` or `<state>_at` (timestamp form preferred for state changes)
|
||||
- Migration files: `NNN_descriptive_name.up.sql` + `.down.sql` — always provide both directions
|
||||
|
||||
### Go
|
||||
|
||||
- Standard `gofmt` + `go vet`. No exceptions.
|
||||
- Handler files mirror domain: `agent.go`, `auth.go`, `runtime.go`
|
||||
- Tests: `<file>_test.go` colocated
|
||||
- For UUID parsing in handlers, follow the rule in the root `CLAUDE.md` — `parseUUIDOrBadRequest` for boundary input, `parseUUID` (panicking) for trusted round-trips, never `util.ParseUUID` directly without checking the error.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- API responses on the wire are `snake_case`; the api client converts to `camelCase` at the boundary. Inside TS code, **always camelCase**.
|
||||
- Types: `PascalCase` (`Issue`, `AgentRuntime`); never `IPrefix`, never `_t` suffix.
|
||||
- Enums: prefer string literal unions; reserve `enum` for runtime-iterable cases.
|
||||
- TanStack Query keys: factory functions in `<feature>/queries.ts`, e.g. `issueKeys.detail(id)`.
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
|
||||
### Comments in code
|
||||
|
||||
English only. The repo enforces this for both Go and TypeScript. If you find a Chinese comment in code, it's a bug — replace it.
|
||||
|
||||
### Commit messages
|
||||
|
||||
Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`. Atomic commits grouped by intent.
|
||||
|
||||
---
|
||||
|
||||
## 2. i18n translation glossary
|
||||
|
||||
This is the **mandatory** glossary for every translation PR. It used to live at `packages/views/locales/glossary.md`; that file is now a stub pointing here.
|
||||
|
||||
### The core distinction: entity vs concept
|
||||
|
||||
Multica's product nouns split into two categories:
|
||||
|
||||
- **Entity** — has a URL, a database row, an API type. In Chinese text, render as **lowercase English** so it visually reads like a type name and signals "this is a Multica system entity".
|
||||
- **Concept** — generic noun, not a database entity. **Translate fully** so Chinese users don't see jagged English embedded in flowing text.
|
||||
|
||||
This rule is aligned with `apps/docs/content/docs/*.zh.mdx` — the docs are the de facto Chinese voice standard and have been battle-tested across 20+ pages.
|
||||
|
||||
### Entities — mixed rule (`issue` / `skill` / `task`)
|
||||
|
||||
`issue` / `skill` / `task` are Multica's core entities. They have schema columns, API fields, and product UI labels that are all English. In Chinese text, they follow a **mixed rule** — what to use depends on where the word appears:
|
||||
|
||||
| Context | Render | Example |
|
||||
| --- | --- | --- |
|
||||
| **UI strings, state names, code references** | lowercase English | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
|
||||
| **Doc titles / section headings** | Title-case English **or** the Chinese term | "Issue 与 project"、"Skills"、"执行任务" |
|
||||
| **Long-form doc prose, when the entity is the running subject** | Chinese term, with English in parentheses on first mention | "**执行任务**(task)是智能体每一次工作的单位" |
|
||||
| **API / DB fields** | always `task` / `issue` / `skill` | `task_id`, `issue_status`, `skill_uuid` |
|
||||
|
||||
Chinese term reference:
|
||||
|
||||
- `task` ↔ `执行任务` (or shortened to `任务` once context is clear)
|
||||
- `issue` has no settled Chinese translation — leave English; titles may capitalize as `Issue`
|
||||
- `skill` has no settled Chinese translation — leave English; titles may capitalize as `Skills`
|
||||
|
||||
**Why `issue` / `skill` / `task` aren't forced into Chinese the way `project` / `autopilot` are**:
|
||||
|
||||
- **`issue` / `task`**: dev teams talk in English. The Chinese candidates ("任务" — too vague, almost synonymous with "工作"; "工单" — IT ticket connotation; "议题" — GitHub-style but doesn't match the product feel) all read worse than `issue`. **But** in long-form doc prose, repeating lowercase `task` 50× breaks the rhythm — so prose is allowed to use `执行任务`, while UI strings and state names stay lowercase English.
|
||||
- **`skill`**: Multica-specific concept with no established Chinese term.
|
||||
- **`project` → "项目"**: settled mainstream Chinese word. Feishu / Tower / Teambition / PingCode / GitHub Projects — every Chinese product translates it. No product keeps `project` in Chinese context.
|
||||
- **`autopilot` → "自动化"**: in Chinese, "autopilot" associates with Tesla's "自动驾驶" and doesn't match what the feature does (run tasks on a schedule). Notion and Feishu both use "自动化"; that's the industry consensus.
|
||||
|
||||
### Don't translate — brands and acronyms
|
||||
|
||||
| Category | Terms |
|
||||
| --- | --- |
|
||||
| Brands | **Multica**, GitHub, Slack, Google, Anthropic, OpenAI, Claude, Codex, Cursor, Linear, Jira |
|
||||
| Acronyms | API, CLI, URL, SDK, OAuth, JWT, SSO, WebSocket, HTTP, JSON, YAML, SQL |
|
||||
|
||||
### Translate fully — concepts
|
||||
|
||||
| English | Chinese |
|
||||
| --- | --- |
|
||||
| Workspace | **工作区** |
|
||||
| Agent | **智能体** |
|
||||
| Project | **项目** |
|
||||
| Autopilot | **自动化** |
|
||||
| Daemon | **守护进程** |
|
||||
| Runtime | **运行时** |
|
||||
| Inbox | **收件箱** |
|
||||
| Comment | **评论** |
|
||||
| Reply | **回复** |
|
||||
| Notifications | **通知** |
|
||||
| Member | **成员** |
|
||||
| Label | **标签** |
|
||||
| Settings | **设置** |
|
||||
| Onboarding | **上手引导** |
|
||||
|
||||
### Translate fully — generic UI words
|
||||
|
||||
| English | Chinese |
|
||||
| --- | --- |
|
||||
| Invite / Invitation | 邀请 |
|
||||
| Search | 搜索 |
|
||||
| Email | 邮箱 (label) / 邮件 (action) |
|
||||
| Password | 密码 |
|
||||
| Sign in / Log in | 登录 |
|
||||
| Sign up | 注册 |
|
||||
| Sign out / Log out | 退出登录 |
|
||||
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
|
||||
| Active / Archived | 活跃 (or 启用) / 已归档 |
|
||||
| Status / Priority | 状态 / 优先级 |
|
||||
| Assignee / Reporter | 负责人 / 报告人 |
|
||||
| Description / Title | 描述 / 标题 |
|
||||
| Date / Time | 日期 / 时间 |
|
||||
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
|
||||
| Empty / Failed / Success | 空 / 失败 / 成功 |
|
||||
| Error / Warning | 错误 / 警告 |
|
||||
|
||||
### Roles and status enums (lowercase English, not translated)
|
||||
|
||||
These are schema-level identifiers; render as lowercase English even in Chinese context.
|
||||
|
||||
- Roles: `owner` / `admin` / `member`
|
||||
- Issue status: `backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
|
||||
|
||||
In UI, surface them in English (optionally `code-style` wrapped):
|
||||
|
||||
- "你需要 owner 权限"
|
||||
- "已切换到 in_progress"
|
||||
|
||||
### Word combination rules
|
||||
|
||||
Always put **a single space** between an English word (entity / brand / acronym) and surrounding Chinese:
|
||||
|
||||
- "Create new issue" → "新建 issue"
|
||||
- "Assign to agent" → "分配给智能体"
|
||||
- "Configure runtime" → "配置运行时"
|
||||
- "Stop daemon" → "停止守护进程"
|
||||
|
||||
### Plurals and counts
|
||||
|
||||
i18next uses `_one` / `_other`; Chinese has no grammatical number, only fill `_other`.
|
||||
|
||||
```json
|
||||
// en/issues.json
|
||||
{
|
||||
"issue_count_one": "{{count}} issue",
|
||||
"issue_count_other": "{{count}} issues"
|
||||
}
|
||||
|
||||
// zh-Hans/issues.json
|
||||
{
|
||||
"issue_count_other": "{{count}} 个 issue"
|
||||
}
|
||||
```
|
||||
|
||||
Common count formats:
|
||||
|
||||
- `{{count}} issues` → `{{count}} 个 issue`
|
||||
- `{{count}} agents` → `{{count}} 个智能体`
|
||||
- `{{count}} workspaces` → `{{count}} 个工作区`
|
||||
- `{{count}} comments` → `{{count}} 条评论`
|
||||
- `{{count}} members` → `{{count}} 位成员`
|
||||
- `{{count}} skills` → `{{count}} 个 skill`
|
||||
|
||||
### Interpolation
|
||||
|
||||
Use `{{var}}`. Chinese translations may reorder for natural sentence flow.
|
||||
|
||||
```json
|
||||
// en
|
||||
{ "welcome_message": "Welcome back, {{name}}!" }
|
||||
|
||||
// zh-Hans
|
||||
{ "welcome_message": "欢迎回来,{{name}}!" }
|
||||
```
|
||||
|
||||
### Translation key naming
|
||||
|
||||
Three-level nesting: `feature.component.action`.
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_or_component": {
|
||||
"subcomponent_or_section": {
|
||||
"action_or_label": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web-only / desktop-only copy
|
||||
|
||||
- Shared copy: top level of the namespace JSON
|
||||
- Web-only: `web` section
|
||||
- Desktop-only: `desktop` section
|
||||
|
||||
See `auth.json` for the canonical example (the `web` section contains `prefer_desktop` / `desktop_handoff.*`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Chinese voice and style
|
||||
|
||||
### Punctuation
|
||||
|
||||
- Full-width punctuation in Chinese: `,。:;!?`
|
||||
- Quotes: straight double quotes `"..."` to match the English source. Do not use `「」` or curly quotes.
|
||||
- Ellipsis: three dots `...` not the single character `…`. Match the English source.
|
||||
- Mixed Chinese-English: a single space on each side of the English word (see Word combination rules).
|
||||
|
||||
### Style principles
|
||||
|
||||
- **Concise and direct.** Avoid translation-ese: "对于 X 来说"、"作为 X"、"我们的"。
|
||||
- **Error messages**: gentle but clear. "无法保存修改" beats "保存修改失败了!".
|
||||
- **Buttons**: verb first, 2–4 characters. "取消"、"保存修改"、"立即同步".
|
||||
- **Tooltips**: full short sentence. "复制链接到剪贴板".
|
||||
- **Placeholders**: example-style. "输入 issue 标题...".
|
||||
|
||||
### Where to look when in doubt
|
||||
|
||||
When the glossary doesn't cover a term, look at:
|
||||
|
||||
1. `apps/docs/content/docs/*.zh.mdx` — the de facto Chinese voice standard, 20+ pages of consistent translation
|
||||
2. `packages/views/locales/zh-Hans/auth.json` and `editor.json` — JSON structure + selector API patterns
|
||||
3. `packages/views/auth/login-page.tsx` — component-level selector API call site
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` — language switcher reference
|
||||
|
||||
---
|
||||
|
||||
## Updating this page
|
||||
|
||||
If you change a rule here, also:
|
||||
|
||||
1. Apply it in the relevant locale JSONs / CLAUDE.md / docs page
|
||||
2. Note the change in the PR description so reviewers know to look for downstream sweep
|
||||
|
||||
This page is the contract; nothing else overrides it.
|
||||
@@ -1,301 +0,0 @@
|
||||
---
|
||||
title: 规范
|
||||
description: 代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。
|
||||
---
|
||||
|
||||
本页是代码命名规范、i18n 翻译术语表、中文风格指南的唯一权威来源。原本散落在 `packages/views/locales/glossary.md` 和各处注释里的规则现在都收拢到这里。
|
||||
|
||||
写 Multica 代码、改翻译、写中文产品文案,都从这一页查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码命名
|
||||
|
||||
### 路由
|
||||
|
||||
工作区前置路由(用户进入工作区之前能访问的路由)必须用单个单词,或者 `/{noun}/{verb}` 格式。
|
||||
|
||||
- ✅ `/login`、`/inbox`、`/workspaces/new`
|
||||
- ❌ `/new-workspace`、`/create-team`、`/accept-invite`
|
||||
|
||||
根目录的连字符词组会跟用户自选 workspace slug 冲突,逼着团队不停审保留字列表。把名词(`workspaces`)保留下来,整个 `/workspaces/*` 子树自动受保护。
|
||||
|
||||
### 工作区路由
|
||||
|
||||
永远用 `/{slug}/{section}` —— `/{slug}/issues`、`/{slug}/agents`、`/{slug}/settings`。共享代码不要复制路由逻辑,统一走 `useNavigation().push()`,不要直接用框架的 link API。
|
||||
|
||||
### 包与模块
|
||||
|
||||
monorepo 的包边界是硬约束:
|
||||
|
||||
| 包 | 可依赖 | 不能依赖 |
|
||||
| --- | --- | --- |
|
||||
| `packages/core` | 仅平台无关基础库 | `react-dom`、`localStorage`、`process.env`、`next/*`、UI 库 |
|
||||
| `packages/ui` | 无业务依赖 | `@multica/core`、业务逻辑 |
|
||||
| `packages/views` | `core/`、`ui/` | `next/*`、`react-router-dom`、stores |
|
||||
| `apps/web/platform/` | `next/*` | 其他 app |
|
||||
| `apps/desktop/.../platform/` | `react-router-dom`、electron | 其他 app |
|
||||
|
||||
两个 app 都有的逻辑,**必须**抽到共享包。"小段重复"也不算例外。
|
||||
|
||||
### 文件与组件
|
||||
|
||||
- 文件名:`kebab-case.tsx` / `kebab-case.ts`(如 `agent-row-actions.tsx`)
|
||||
- 组件:`PascalCase`(如 `AgentRowActions`)
|
||||
- Hook:`useCamelCase`(如 `useWorkspaceId`)
|
||||
- 测试:与源文件同目录,命名 `<file>.test.ts(x)`
|
||||
- Zustand store:`<feature>-store.ts`,导出名 `use<Feature>Store`
|
||||
|
||||
### 数据库(Go + sqlc)
|
||||
|
||||
- 表名:`snake_case` 单数(`user`、`workspace`、`agent_runtime`)
|
||||
- 字段:`snake_case`(`workspace_id`、`created_at`、`last_seen_at`)
|
||||
- 外键:`<table>_id`
|
||||
- 布尔:`is_<state>` 或者 `<state>_at`(状态变化优先用时间戳形式)
|
||||
- 迁移文件:`NNN_descriptive_name.up.sql` + `.down.sql`,**永远写双向**
|
||||
|
||||
### Go
|
||||
|
||||
- 标准 `gofmt` + `go vet`,无例外
|
||||
- Handler 文件按域命名:`agent.go`、`auth.go`、`runtime.go`
|
||||
- 测试:`<file>_test.go` 同目录
|
||||
- handler 里 UUID 解析遵守根 `CLAUDE.md` 的规则:边界输入用 `parseUUIDOrBadRequest`,可信回环用 `parseUUID`(panic 版),永远不要直接用 `util.ParseUUID` 不查 error
|
||||
|
||||
### TypeScript
|
||||
|
||||
- 网络上 API 响应是 `snake_case`,api client 在边界处转成 `camelCase`。**TS 代码内部一律 camelCase**
|
||||
- 类型:`PascalCase`(`Issue`、`AgentRuntime`),不加 `IPrefix`,不加 `_t` 后缀
|
||||
- 枚举:优先用 string literal union,需要 runtime 迭代时才用 `enum`
|
||||
- TanStack Query key:用 `<feature>/queries.ts` 里的工厂函数,例如 `issueKeys.detail(id)`
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
|
||||
### 代码注释
|
||||
|
||||
**只允许英文**。Go 和 TypeScript 都强制。如果在代码里看到中文注释,那就是 bug,替换掉。
|
||||
|
||||
### Commit message
|
||||
|
||||
Conventional 格式:`feat(scope)`、`fix(scope)`、`refactor(scope)`、`docs`、`test(scope)`、`chore(scope)`。按意图原子化分组。
|
||||
|
||||
---
|
||||
|
||||
## 2. i18n 翻译术语表
|
||||
|
||||
这是每个翻译 PR 都必须遵守的术语表。原本在 `packages/views/locales/glossary.md`,那个文件现在是个 stub,指向这一页。
|
||||
|
||||
### 核心区分:实体 vs 概念
|
||||
|
||||
Multica 的产品名词分两类:
|
||||
|
||||
- **实体(typed entity)** —— 有 URL、有数据库 row、是 API 响应里某种 type 的东西。中文里**用小写英文**呈现,视觉上像类型名,告诉读者"这是 Multica 系统里的特定实体"。
|
||||
- **概念(concept)** —— 不是数据库实体的普通名词。**完整翻译成中文**,CN 用户看不到生硬的英文。
|
||||
|
||||
这套规则与 `apps/docs/content/docs/*.zh.mdx` 完全对齐 —— docs 是已经实战 20+ 篇的 CN voice 标准。
|
||||
|
||||
### 实体词的混合规则(`issue` / `skill` / `task`)
|
||||
|
||||
`issue` / `skill` / `task` 是 Multica 的核心实体。schema 字段、API 字段、产品 UI 标签都用英文。中文里采用**混合规则** —— 词出现在哪里决定怎么写:
|
||||
|
||||
| 场景 | 写法 | 例 |
|
||||
| --- | --- | --- |
|
||||
| **UI 短句 / 状态名 / 代码上下文** | 小写英文 | "排队中的 task"、"创建子 issue"、"为智能体注入 skill" |
|
||||
| **doc 标题 / 章节标题** | 首字母大写英文,**或**对应中文术语 | "Issue 与 project"、"Skills"、"执行任务" |
|
||||
| **doc 正文长篇讨论中作为主语** | 中文术语,首次出现配括号英文 | "**执行任务**(task)是智能体每一次工作的单位" |
|
||||
| **API / DB 字段** | 永远 `task` / `issue` / `skill` | `task_id`、`issue_status`、`skill_uuid` |
|
||||
|
||||
中文术语对照:
|
||||
|
||||
- `task` ↔ `执行任务`(上下文清楚后可简写为「任务」)
|
||||
- `issue` 没有公认中文译法 —— 保留英文;标题可大写为 `Issue`
|
||||
- `skill` 没有公认中文译法 —— 保留英文;标题可大写为 `Skills`
|
||||
|
||||
**为什么 `issue` / `skill` / `task` 不强制译,而 `project` / `autopilot` 必译**:
|
||||
|
||||
- **`issue` / `task`**:dev 团队习惯说英文,"任务"在中文里和"工作"几乎同义太空泛,"工单"是 IT 工单语义,"议题"是 GitHub 风格但用户场景不匹配 —— 三个候选都不如 `issue` 准确。**但**在长篇 doc 正文里,重复 50 次 `task` 节奏不顺,所以正文允许用 `执行任务`,UI 短句、状态名仍保持小写英文。
|
||||
- **`skill`**:Multica 特有概念,没有公认中文译法。
|
||||
- **`project` 翻成「项目」**:中文里早就稳定的日常词。飞书 / Tower / Teambition / PingCode / GitHub Projects 中文版 0 例外都翻译成「项目」,没有产品保留 `project`。
|
||||
- **`autopilot` 翻成「自动化」**:autopilot 在中文里联想到特斯拉的「自动驾驶」,跟产品功能(按周期跑 task)对应不上。Notion / 飞书都用「自动化」,是行业共识。
|
||||
|
||||
### 完整翻译 —— 概念词
|
||||
|
||||
| 英 | 中 |
|
||||
| --- | --- |
|
||||
| Workspace | **工作区** |
|
||||
| Agent | **智能体** |
|
||||
| Project | **项目** |
|
||||
| Autopilot | **自动化** |
|
||||
| Daemon | **守护进程** |
|
||||
| Runtime | **运行时** |
|
||||
| Inbox | **收件箱** |
|
||||
| Comment | **评论** |
|
||||
| Reply | **回复** |
|
||||
| Notifications | **通知** |
|
||||
| Member | **成员** |
|
||||
| Label | **标签** |
|
||||
| Settings | **设置** |
|
||||
| Onboarding | **上手引导** |
|
||||
|
||||
### 不翻 —— 品牌名 + 通用缩写
|
||||
|
||||
| 类别 | 词 |
|
||||
| --- | --- |
|
||||
| 品牌 | **Multica**、GitHub、Slack、Google、Anthropic、OpenAI、Claude、Codex、Cursor、Linear、Jira |
|
||||
| 缩写 | API、CLI、URL、SDK、OAuth、JWT、SSO、WebSocket、HTTP、JSON、YAML、SQL |
|
||||
|
||||
### 完整翻译 —— 通用 UI 词
|
||||
|
||||
| 英 | 中 |
|
||||
| --- | --- |
|
||||
| Invite / Invitation | 邀请 |
|
||||
| Search | 搜索 |
|
||||
| Email | 邮箱(label)/ 邮件(action) |
|
||||
| Password | 密码 |
|
||||
| Sign in / Log in | 登录 |
|
||||
| Sign up | 注册 |
|
||||
| Sign out / Log out | 退出登录 |
|
||||
| Save / Cancel / Delete | 保存 / 取消 / 删除 |
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
| Light / Dark / System | 浅色 / 深色 / 跟随系统 |
|
||||
| Active / Archived | 活跃(或 启用)/ 已归档 |
|
||||
| Status / Priority | 状态 / 优先级 |
|
||||
| Assignee / Reporter | 负责人 / 报告人 |
|
||||
| Description / Title | 描述 / 标题 |
|
||||
| Date / Time | 日期 / 时间 |
|
||||
| Today / Yesterday / Tomorrow | 今天 / 昨天 / 明天 |
|
||||
| Empty / Failed / Success | 空 / 失败 / 成功 |
|
||||
| Error / Warning | 错误 / 警告 |
|
||||
|
||||
### 角色名 + 状态名(小写英文,不翻)
|
||||
|
||||
这些是 schema-level 标识符,中文环境也保持小写英文:
|
||||
|
||||
- 角色:`owner` / `admin` / `member`
|
||||
- Issue 状态:`backlog` / `todo` / `in_progress` / `in_review` / `done` / `blocked` / `cancelled`
|
||||
|
||||
UI 里展示这些值时保持英文(必要时用 code-style 包起来):
|
||||
|
||||
- "你需要 owner 权限"
|
||||
- "已切换到 in_progress"
|
||||
|
||||
### 词组组合规则
|
||||
|
||||
英文词(实体名 + 品牌名 + 缩写)与中文之间**加单空格**:
|
||||
|
||||
- "Create new issue" → "新建 issue"
|
||||
- "Assign to agent" → "分配给智能体"
|
||||
- "Configure runtime" → "配置运行时"
|
||||
- "Stop daemon" → "停止守护进程"
|
||||
|
||||
### 复数与计数
|
||||
|
||||
i18next 用 `_one` / `_other`;中文不区分语法单复数,只填 `_other`。
|
||||
|
||||
```json
|
||||
// en/issues.json
|
||||
{
|
||||
"issue_count_one": "{{count}} issue",
|
||||
"issue_count_other": "{{count}} issues"
|
||||
}
|
||||
|
||||
// zh-Hans/issues.json
|
||||
{
|
||||
"issue_count_other": "{{count}} 个 issue"
|
||||
}
|
||||
```
|
||||
|
||||
常见计数格式:
|
||||
|
||||
- `{{count}} issues` → `{{count}} 个 issue`
|
||||
- `{{count}} agents` → `{{count}} 个智能体`
|
||||
- `{{count}} workspaces` → `{{count}} 个工作区`
|
||||
- `{{count}} comments` → `{{count}} 条评论`
|
||||
- `{{count}} members` → `{{count}} 位成员`
|
||||
- `{{count}} skills` → `{{count}} 个 skill`
|
||||
|
||||
### 插值
|
||||
|
||||
用 `{{var}}` 形式。中文翻译可以调整位置以符合中文语序。
|
||||
|
||||
```json
|
||||
// en
|
||||
{ "welcome_message": "Welcome back, {{name}}!" }
|
||||
|
||||
// zh-Hans
|
||||
{ "welcome_message": "欢迎回来,{{name}}!" }
|
||||
```
|
||||
|
||||
### Key 命名约定
|
||||
|
||||
3 层嵌套:`feature.component.action`。
|
||||
|
||||
```json
|
||||
{
|
||||
"feature_or_component": {
|
||||
"subcomponent_or_section": {
|
||||
"action_or_label": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实例:
|
||||
|
||||
- `issues.toolbar.batch_update_success`
|
||||
- `issues.detail.comment_form.placeholder`
|
||||
- `inbox.empty.title`
|
||||
- `settings.preferences.language.title`
|
||||
|
||||
### Web-only / Desktop-only 文案位置
|
||||
|
||||
- 共享文案:放 namespace JSON 顶层
|
||||
- Web-only:放 `web` 段
|
||||
- Desktop-only:放 `desktop` 段
|
||||
|
||||
参考 `auth.json`(`web` 段含 `prefer_desktop` / `desktop_handoff.*`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 中文风格
|
||||
|
||||
### 标点
|
||||
|
||||
- 中文用全角标点:`,。:;!?`
|
||||
- 引号:用 `"..."`(直引号),与英文 source 保持一致。**不要**用 `「」` 或弯引号
|
||||
- 省略号:用 `...`(三点)而非 `…`(单字符),与英文 source 保持一致
|
||||
- 中英混排:英文词左右各加 1 个空格(详见词组组合规则)
|
||||
|
||||
### 风格原则
|
||||
|
||||
- **简洁直白**:避免翻译腔,"对于 X 来说"、"作为 X"、"我们的"
|
||||
- **错误信息**:温和但明确,"无法保存修改" 优于 "保存修改失败了!"
|
||||
- **按钮**:动词开头,2-4 字最佳。"取消"、"保存修改"、"立即同步"
|
||||
- **Tooltip**:完整短句。"复制链接到剪贴板"
|
||||
- **placeholder**:示例性提示。"输入 issue 标题..."
|
||||
|
||||
### 拿不准的时候去哪查
|
||||
|
||||
术语表没覆盖的词,按这个顺序查:
|
||||
|
||||
1. `apps/docs/content/docs/*.zh.mdx` —— CN voice 事实标准,20+ 篇高度一致
|
||||
2. `packages/views/locales/zh-Hans/auth.json` 和 `editor.json` —— JSON 结构 + selector API 用法参考
|
||||
3. `packages/views/auth/login-page.tsx` —— 组件层 selector API 调用参考
|
||||
4. `packages/views/settings/components/preferences-tab.tsx` —— 语言切换器参考
|
||||
|
||||
---
|
||||
|
||||
## 修改这一页时
|
||||
|
||||
改本页规则的同时还要:
|
||||
|
||||
1. 把规则在相关 locale JSON / CLAUDE.md / docs 页面里同步落地
|
||||
2. PR 描述里写明改了什么,方便 reviewer 检查下游是否跟着改了
|
||||
|
||||
本页是契约,其他文档不能 override。
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"title": "Developers",
|
||||
"pages": ["conventions"]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "Developers",
|
||||
"pages": ["contributing", "architecture", "conventions"]
|
||||
"pages": ["contributing", "architecture"]
|
||||
}
|
||||
|
||||
@@ -66,19 +66,13 @@ Multica stores user-uploaded attachments (images and files in comments). **S3 is
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | empty | **Bucket name only** (for example `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public host from `S3_BUCKET` + `S3_REGION`. Setting this enables S3 storage |
|
||||
| `S3_REGION` | `us-west-2` | AWS region. Must match the bucket's actual region — it is used both for SDK signing and for building the public URL |
|
||||
| `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.
|
||||
|
||||
**Public URLs** are constructed in this order of priority:
|
||||
|
||||
1. `https://<CLOUDFRONT_DOMAIN>/<key>` if `CLOUDFRONT_DOMAIN` is set.
|
||||
2. `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>` (path-style) if `AWS_ENDPOINT_URL` is set.
|
||||
3. `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>` (virtual-hosted-style). When `S3_BUCKET` contains dots, the server falls back to `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>` (path-style) because the AWS-issued wildcard TLS certificate does not validate dotted bucket hosts.
|
||||
|
||||
### Local disk (when S3 is not configured)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|
||||
@@ -66,19 +66,13 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `S3_BUCKET` | 空 | **只填 bucket 名**(例如 `my-bucket`),**不要**带 `.s3.<region>.amazonaws.com` 后缀——server 会用 `S3_BUCKET` + `S3_REGION` 自己拼公开 host。设了就启用 S3 存储 |
|
||||
| `S3_REGION` | `us-west-2` | AWS 区域。必须和 bucket 所在区域一致——SDK 签名和公开 URL 都用它 |
|
||||
| `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"`,所有上传回落到本地磁盘。
|
||||
|
||||
**公开 URL** 按优先级拼装:
|
||||
|
||||
1. 设了 `CLOUDFRONT_DOMAIN` → `https://<CLOUDFRONT_DOMAIN>/<key>`
|
||||
2. 设了 `AWS_ENDPOINT_URL` → `<AWS_ENDPOINT_URL>/<S3_BUCKET>/<key>`(path-style)
|
||||
3. 默认走 AWS S3 → `https://<S3_BUCKET>.s3.<S3_REGION>.amazonaws.com/<key>`(virtual-hosted-style)。bucket 名含点时会回落到 `https://s3.<S3_REGION>.amazonaws.com/<S3_BUCKET>/<key>`(path-style),因为 AWS 通配证书无法覆盖含点 host。
|
||||
|
||||
### 本地磁盘(S3 未配时)
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|
||||
@@ -212,15 +212,13 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"workspaces",
|
||||
"members-roles",
|
||||
"issues",
|
||||
"projects",
|
||||
"comments",
|
||||
"project-resources",
|
||||
"---Agents---",
|
||||
@@ -34,8 +33,6 @@
|
||||
"---Reference---",
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"---Developers---",
|
||||
"developers"
|
||||
"desktop-app"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"workspaces",
|
||||
"members-roles",
|
||||
"issues",
|
||||
"projects",
|
||||
"comments",
|
||||
"---智能体---",
|
||||
"agents",
|
||||
@@ -33,8 +32,6 @@
|
||||
"---参考---",
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"---开发者---",
|
||||
"developers"
|
||||
"desktop-app"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: Projects
|
||||
description: Group related issues and track them as one unit — with priority, status, progress, and an owner.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **project** in Multica is a container for related [issues](/issues). Use it when a body of work is bigger than one issue but smaller than a full workspace — a launch, a migration, a feature with multiple parts, an investigation that branches into several threads.
|
||||
|
||||
Each project has a name, an icon, a description, a **lead** (a member or an [agent](/agents)), a **status** (`planned` / `in_progress` / `paused` / `completed` / `cancelled`), a **priority** (`urgent` / `high` / `medium` / `low` / `none`), and a **progress** percentage that's auto-derived from the status of its linked issues.
|
||||
|
||||
## How projects relate to issues
|
||||
|
||||
Projects and issues are independent objects with a many-to-one relationship: an issue can belong to **at most one** project; a project holds **any number of** issues. Linking and unlinking is reversible at any time — drag in the board view, or use the project picker on the issue's right-side properties panel.
|
||||
|
||||
The progress bar on a project is computed from its linked issues — the more issues hit `done`, the further it fills. Issues that are `cancelled` are excluded from the count; issues in `backlog` count toward the denominator but not the numerator.
|
||||
|
||||
## Pinning to the sidebar
|
||||
|
||||
Click the pin icon in a project's top-right corner to add it to your sidebar's pinned list. Pinned projects stay one click away no matter where you are in the workspace; everyone on the team can pin independently — pins are personal.
|
||||
|
||||
The sidebar **Workspace → Projects** link always shows every project in the workspace; pinning is a personal shortcut on top of that.
|
||||
|
||||
## Attaching resources
|
||||
|
||||
Each project has a **Resources** section where you attach GitHub repositories. Once attached, any [agent](/agents) assigned to issues in this project can read and write to those repos when executing tasks — Multica passes the repo URLs as context to the [daemon](/daemon-runtimes).
|
||||
|
||||
Resources are per-project; if multiple projects share a repo, attach it to each one.
|
||||
|
||||
## Deleting a project
|
||||
|
||||
Deleting a project **does not delete its issues**. The linked issues are simply unlinked and revert to the workspace's flat issue list. This is intentional — work that was scoped to a project is rarely throwaway, even when the framing of the project changes.
|
||||
|
||||
<Callout type="info">
|
||||
If you want to delete the work too, archive or delete the issues first, then delete the project.
|
||||
</Callout>
|
||||
|
||||
## Project lead
|
||||
|
||||
The lead is the person — or agent — accountable for the project. It's a soft signal, not an access control: any workspace member can edit a project regardless of who's lead. A project's lead can be:
|
||||
|
||||
- A workspace member (human teammate)
|
||||
- An [agent](/agents) — useful when the project's work is mostly delegated to an agent (e.g., "Weekly bug triage" led by a triage agent)
|
||||
|
||||
## Next
|
||||
|
||||
- [Issues](/issues) — the unit of work that lives inside projects
|
||||
- [Agents as project lead](/agents) — when an agent is the right owner
|
||||
- [How Multica works](/how-multica-works) — the broader picture
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: 项目
|
||||
description: 把相关的 issue 归为一组当成一个单元来跟进 —— 有优先级、状态、进度和负责人。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 里的**项目**(project)是相关 [issue](/issues) 的容器。当一摊工作比单个 issue 大、又比整个工作区小的时候用它 —— 一次发布、一次迁移、一个分多块做的功能、一个会拆出多个线索的调研。
|
||||
|
||||
每个项目有名字、图标、描述、**负责人**(lead,可以是成员,也可以是 [智能体](/agents))、**状态**(`planned` / `in_progress` / `paused` / `completed` / `cancelled`)、**优先级**(`urgent` / `high` / `medium` / `low` / `none`),以及一个根据关联 issue 状态自动算出来的**进度**百分比。
|
||||
|
||||
## 项目和 issue 的关系
|
||||
|
||||
项目和 issue 是独立对象,多对一关系:一个 issue **最多属于一个**项目;一个项目可以容纳**任意多个** issue。关联和解除关联随时可逆 —— 在看板视图里拖动,或者在 issue 右侧 properties 面板用项目选择器。
|
||||
|
||||
项目的进度条是按关联 issue 状态自动算出来的 —— 越多 issue 到 `done`,进度条越满。`cancelled` 的 issue 不计入分母;`backlog` 的 issue 计入分母但不计入分子。
|
||||
|
||||
## pin 到侧边栏
|
||||
|
||||
点项目右上角的 pin 图标,可以把这个项目加到侧边栏的固定区。pin 过的项目无论你在工作区哪里都一键可达;每个人独立 pin —— pin 是个人偏好。
|
||||
|
||||
侧边栏 **Workspace → Projects** 链接始终展示工作区里所有项目;pin 只是在这之上的个人快捷方式。
|
||||
|
||||
## 关联 resources
|
||||
|
||||
每个项目有一个 **Resources** 区,可以挂 GitHub 仓库。挂上之后,被分配到这个项目里 issue 的 [智能体](/agents) 在执行 task 时可以读写这些仓库 —— Multica 会把仓库 URL 作为上下文传给 [守护进程](/daemon-runtimes)。
|
||||
|
||||
Resources 是项目级别的;多个项目要共享同一个仓库,要分别挂上。
|
||||
|
||||
## 删除项目
|
||||
|
||||
删除项目**不会**删除它的 issue。关联的 issue 只是解除关联,回到工作区的扁平 issue 列表。这是刻意的 —— 即使项目本身的框架变了,里面的工作通常也不会是一次性的。
|
||||
|
||||
<Callout type="info">
|
||||
如果你确实想把工作也删掉,先归档或删除 issue,再删除项目。
|
||||
</Callout>
|
||||
|
||||
## 项目负责人
|
||||
|
||||
负责人是为这个项目负总责的人 —— 或者智能体。这是一个软信号,不是权限控制:工作区任何成员都可以编辑项目,不管谁是负责人。项目负责人可以是:
|
||||
|
||||
- 工作区里的成员(人)
|
||||
- [智能体](/agents) —— 当项目里的工作大部分要交给智能体时合适(例如"每周 bug 巡检"由一个巡检智能体担任 lead)
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Issues](/issues) —— 项目里装的工作单元
|
||||
- [智能体担任项目负责人](/agents) —— 什么时候由智能体当 lead 合适
|
||||
- [Multica 怎么运转](/how-multica-works) —— 整体视图
|
||||
@@ -21,7 +21,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | Dynamic discovery |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | Dynamic discovery |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | Dynamic discovery |
|
||||
| **OpenClaw** | Open source | ✅ | ❌ | `.agent_context/skills/` (fallback) | Bound to the agent, can't be switched per task |
|
||||
| **Pi** | Inflection AI | ✅ (session is a file path) | ❌ | `.pi/skills/` | Dynamic discovery |
|
||||
|
||||
@@ -103,7 +103,7 @@ Each tool uses **its own** skill discovery path. Before a task runs, the Multica
|
||||
| Cursor | `.cursor/skills/` | ✅ Native |
|
||||
| Kimi | `.kimi/skills/` | ✅ Native |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ Native |
|
||||
| OpenCode | `.opencode/skills/` | ✅ Native |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ Native |
|
||||
| Pi | `.pi/skills/` | ✅ Native |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ Generic fallback |
|
||||
|
||||
@@ -21,7 +21,7 @@ Multica 内置支持 **11 款 AI 编程工具**。它们都实现了同一套接
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 动态发现 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 动态发现 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.config/opencode/skills/` | 动态发现 |
|
||||
| **OpenClaw** | 开源项目 | ✅ | ❌ | `.agent_context/skills/` (fallback)| 绑定在智能体上,不能在任务里切换 |
|
||||
| **Pi** | Inflection AI | ✅(session 为文件路径)| ❌ | `.pi/skills/` | 动态发现 |
|
||||
|
||||
@@ -103,7 +103,7 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
| Cursor | `.cursor/skills/` | ✅ 原生 |
|
||||
| Kimi | `.kimi/skills/` | ✅ 原生 |
|
||||
| Kiro CLI | `.kiro/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.opencode/skills/` | ✅ 原生 |
|
||||
| OpenCode | `.config/opencode/skills/` | ✅ 原生 |
|
||||
| Pi | `.pi/skills/` | ✅ 原生 |
|
||||
| Gemini | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
| Hermes | `.agent_context/skills/` | ⚠️ 通用 fallback |
|
||||
|
||||
@@ -116,4 +116,4 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path
|
||||
- [Desktop app](/desktop-app) — released Desktop builds connect to Multica Cloud only; using Desktop with self-host requires a custom build (see the callout in the desktop-app page)
|
||||
|
||||
@@ -115,4 +115,4 @@ multica setup self-host
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
- [桌面应用](/desktop-app) —— 发布版 Desktop 只连 Multica Cloud;要让 Desktop 连自部署后端需要自行构建(详见 desktop-app 页的提示)
|
||||
|
||||
@@ -63,13 +63,11 @@ Automatic retry also has two extra conditions:
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot tasks don't retry automatically** by design. An Autopilot has its own firing cadence (e.g. daily); automatic retries on failure would overlap with the next scheduled run. If you need an immediate re-run after failure, use a manual rerun (next section).
|
||||
|
||||
**How you'll know an Autopilot task failed**: a notification lands in your [Inbox](/inbox), and the associated issue's status reverts from `in_progress` back to `todo`. The [Autopilots](/autopilots) page also shows the latest run result per autopilot.
|
||||
</Callout>
|
||||
|
||||
## Manual rerun vs. automatic retry
|
||||
|
||||
A **manual rerun** is one you trigger from the CLI or the API (`POST /api/issues/{id}/rerun`):
|
||||
A **manual rerun** is one you trigger from the UI or CLI:
|
||||
|
||||
```bash
|
||||
multica issue rerun <issue-id>
|
||||
@@ -77,10 +75,9 @@ multica issue rerun <issue-id>
|
||||
|
||||
Behavior:
|
||||
|
||||
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
|
||||
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
|
||||
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
|
||||
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
|
||||
- **Cancels** the currently running task (if any)
|
||||
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling
|
||||
- Inherits the previous session ID; if the corresponding AI coding tool supports session resumption, the new task continues from the previous context
|
||||
|
||||
Comparison:
|
||||
|
||||
@@ -88,9 +85,8 @@ Comparison:
|
||||
|---|---|---|
|
||||
| Trigger | System, based on failure reason | You, manually |
|
||||
| Ceiling | 2 attempts | No limit |
|
||||
| Applicable sources | Issues, chat | Issues with an agent assignee |
|
||||
| Agent picked | Same agent as the failed task | Issue's current assignee |
|
||||
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
|
||||
| Applicable sources | Issues, chat | All sources |
|
||||
| Session inheritance | Yes | Yes |
|
||||
|
||||
## How a failed task affects issue status
|
||||
|
||||
@@ -100,7 +96,7 @@ If an issue-triggered task fails (and no automatic retry succeeds) because the i
|
||||
|
||||
Yes — as long as the AI coding tool supports session resumption.
|
||||
|
||||
Multica pins the session ID **twice** during a task: once at the start (when the AI tool returns its first system message), and once at the end (on completion or failure). The first lets the daemon recover if it crashes mid-run; the second is reserved for the next **automatic retry**, where that ID is passed back so the agent can pick up the previous conversation and file state. **Manual rerun deliberately skips this** and starts a fresh session — see [Manual rerun vs. automatic retry](#manual-rerun-vs-automatic-retry).
|
||||
Multica pins the session ID **twice** during a task: once at the start (when the AI tool returns its first system message), and once at the end (on completion or failure). The first lets the daemon recover if it crashes mid-run; the second is reserved for future reruns. On the next rerun or automatic retry, that ID is passed back so the agent can pick up the previous conversation and file state.
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
|
||||
@@ -63,13 +63,11 @@ Multica 服务器每 30 秒扫描一次,有两种超时会触发失败:
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilots 任务不自动重试**是刻意设计。Autopilot 有自己的触发周期(例如每天一次);如果失败又自动重试,会和下一个周期的任务重叠。需要失败后立即重跑,用手动重跑(下一节)。
|
||||
|
||||
**怎么知道 Autopilot 失败了**:失败的 Autopilot 任务会在你的 [收件箱](/inbox) 里出现一条通知,关联的 issue 状态也会从 `in_progress` 退回 `todo`。直接打开 [Autopilots](/autopilots) 页面也能看到每条 autopilot 的最近运行结果。
|
||||
</Callout>
|
||||
|
||||
## 手动重跑和自动重试的区别
|
||||
|
||||
**手动重跑**(rerun)是你通过命令行或 API(`POST /api/issues/{id}/rerun`)主动发起的:
|
||||
**手动重跑**(rerun)是你从 UI 或命令行主动发起的:
|
||||
|
||||
```bash
|
||||
multica issue rerun <issue-id>
|
||||
@@ -77,10 +75,9 @@ multica issue rerun <issue-id>
|
||||
|
||||
行为:
|
||||
|
||||
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了,rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun。
|
||||
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试。
|
||||
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行,再继续之前的对话只会重放被污染的上下文。(自动重试则相反,会继承会话——那条路径处理的是基础设施层面的失败,不是产出不好。)
|
||||
- **取消**当前正在跑的任务(如果有)
|
||||
- 创建一个**全新**的执行任务——尝试次数重置为 1,即使原任务已达最大尝试
|
||||
- 继承上一次的会话 ID;如果对应的 AI 编程工具支持会话恢复,会接着上次的上下文继续
|
||||
|
||||
对比:
|
||||
|
||||
@@ -88,9 +85,8 @@ multica issue rerun <issue-id>
|
||||
|---|---|---|
|
||||
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
|
||||
| 上限 | 2 次 | 无上限 |
|
||||
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
|
||||
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
|
||||
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
|
||||
| 适用来源 | issue、聊天 | 所有来源 |
|
||||
| 会话继承 | 是 | 是 |
|
||||
|
||||
## 失败的任务对 issue 状态有什么影响
|
||||
|
||||
@@ -100,7 +96,7 @@ multica issue rerun <issue-id>
|
||||
|
||||
可以——前提是对应的 AI 编程工具支持会话恢复。
|
||||
|
||||
Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 工具返回第一条系统消息时)pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者留给下一次**自动重试**——届时把这个 ID 传回去,智能体就能接着上次的对话和文件状态继续。**手动重跑会主动跳过这一步**,永远从全新会话开始——见 [手动重跑和自动重试的区别](#手动重跑和自动重试的区别)。
|
||||
Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI 工具返回第一条系统消息时)pin 一次,任务结束(完成或失败)时再 pin 一次。前者让守护进程中途崩溃时也能恢复,后者给之后的重跑用。下次重跑或自动重试时把这个 ID 传回去,智能体就能接着上次的对话、文件状态继续。
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { prefixLocale } from "./locale-link";
|
||||
|
||||
describe("prefixLocale", () => {
|
||||
it("prefixes root-relative paths with the active non-default locale", () => {
|
||||
expect(prefixLocale("/workspaces", "zh")).toBe("/zh/workspaces");
|
||||
expect(prefixLocale("/agents-create", "zh")).toBe("/zh/agents-create");
|
||||
});
|
||||
|
||||
it("preserves anchors and query strings on prefixed paths", () => {
|
||||
expect(prefixLocale("/providers#claude-code", "zh")).toBe(
|
||||
"/zh/providers#claude-code",
|
||||
);
|
||||
expect(prefixLocale("/agents?from=docs", "zh")).toBe(
|
||||
"/zh/agents?from=docs",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites the bare root path to the locale root", () => {
|
||||
expect(prefixLocale("/", "zh")).toBe("/zh");
|
||||
});
|
||||
|
||||
it("leaves the default language untouched (URLs are prefix-less)", () => {
|
||||
expect(prefixLocale("/workspaces", "en")).toBe("/workspaces");
|
||||
expect(prefixLocale("/", "en")).toBe("/");
|
||||
});
|
||||
|
||||
it("does not double-prefix paths that already carry a known locale", () => {
|
||||
expect(prefixLocale("/zh/workspaces", "zh")).toBe("/zh/workspaces");
|
||||
expect(prefixLocale("/en/workspaces", "zh")).toBe("/en/workspaces");
|
||||
});
|
||||
|
||||
it("leaves external URLs alone", () => {
|
||||
expect(prefixLocale("https://multica.ai/download", "zh")).toBe(
|
||||
"https://multica.ai/download",
|
||||
);
|
||||
expect(prefixLocale("mailto:hello@multica.ai", "zh")).toBe(
|
||||
"mailto:hello@multica.ai",
|
||||
);
|
||||
expect(prefixLocale("tel:+1234567890", "zh")).toBe("tel:+1234567890");
|
||||
});
|
||||
|
||||
it("leaves in-page anchors and relative paths alone", () => {
|
||||
expect(prefixLocale("#section", "zh")).toBe("#section");
|
||||
expect(prefixLocale("./sibling", "zh")).toBe("./sibling");
|
||||
expect(prefixLocale("../sibling", "zh")).toBe("../sibling");
|
||||
});
|
||||
|
||||
it("returns empty/undefined hrefs unchanged", () => {
|
||||
expect(prefixLocale("", "zh")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { i18n } from "./i18n";
|
||||
|
||||
// Add the active locale prefix to root-relative MDX links so internal
|
||||
// navigation inside Chinese (or any non-default-language) docs stays in
|
||||
// that language. Without this, `[xx](/workspaces)` written in a `*.zh.mdx`
|
||||
// renders as `<a href="/workspaces">`, which Next's basePath rewrites to
|
||||
// `/docs/workspaces` and the docs middleware then routes to English —
|
||||
// leaking the reader out of their chosen locale.
|
||||
//
|
||||
// We deliberately do NOT touch:
|
||||
// - external links (`https:`, `mailto:`, `tel:`, etc.)
|
||||
// - in-page anchors (`#section`)
|
||||
// - relative paths (`./foo`, `../bar`)
|
||||
// - paths already prefixed with a known locale
|
||||
// - the default language (URLs are intentionally prefix-less under
|
||||
// `hideLocale: 'default-locale'`)
|
||||
export function prefixLocale(href: string, lang: string): string {
|
||||
if (!href) return href;
|
||||
if (lang === i18n.defaultLanguage) return href;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href;
|
||||
if (href.startsWith("#")) return href;
|
||||
if (!href.startsWith("/")) return href;
|
||||
|
||||
const segments = href.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
if (first && (i18n.languages as readonly string[]).includes(first)) {
|
||||
return href;
|
||||
}
|
||||
|
||||
return href === "/" ? `/${lang}` : `/${lang}${href}`;
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
"build": "fumadocs-mdx && next build",
|
||||
"start": "next start",
|
||||
"typecheck": "fumadocs-mdx && tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -28,7 +27,6 @@
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
globals: true,
|
||||
include: ["**/*.test.{ts,tsx}"],
|
||||
exclude: ["node_modules/**", ".next/**", ".source/**"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2,22 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "@multica/views/locales/en/common.json";
|
||||
import enAuth from "@multica/views/locales/en/auth.json";
|
||||
import enSettings from "@multica/views/locales/en/settings.json";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TEST_RESOURCES = {
|
||||
en: { common: enCommon, auth: enAuth, settings: enSettings },
|
||||
};
|
||||
|
||||
function createWrapper() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import Link from "next/link";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
|
||||
/**
|
||||
* Pick where a logged-in user with no explicit `?next=` should land.
|
||||
@@ -57,7 +56,6 @@ async function resolveLoggedInDestination(
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const qc = useQueryClient();
|
||||
const { t } = useT("auth");
|
||||
const googleClientId = useConfigStore((state) => state.googleClientId);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
@@ -95,9 +93,7 @@ function LoginPageContent() {
|
||||
})
|
||||
.catch((err) => {
|
||||
setDesktopError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.web.desktop_handoff.prepare_failed),
|
||||
err instanceof Error ? err.message : "Failed to prepare Desktop sign-in",
|
||||
);
|
||||
});
|
||||
return;
|
||||
@@ -144,9 +140,7 @@ function LoginPageContent() {
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">
|
||||
{t(($) => $.web.desktop_handoff.failed_title)}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-2xl">Sign-in Failed</CardTitle>
|
||||
<CardDescription>{desktopError}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -157,13 +151,11 @@ function LoginPageContent() {
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">
|
||||
{t(($) => $.web.desktop_handoff.opening_title)}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-2xl">Opening Multica</CardTitle>
|
||||
<CardDescription>
|
||||
{desktopToken
|
||||
? t(($) => $.web.desktop_handoff.opening_description)
|
||||
: t(($) => $.web.desktop_handoff.preparing)}
|
||||
? "You should see a prompt to open the Multica desktop app. If nothing happens, click the button below."
|
||||
: "Preparing Desktop sign-in..."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
@@ -174,7 +166,7 @@ function LoginPageContent() {
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
|
||||
}}
|
||||
>
|
||||
{t(($) => $.web.desktop_handoff.open_button)}
|
||||
Open Multica Desktop
|
||||
</Button>
|
||||
) : (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
@@ -204,14 +196,18 @@ function LoginPageContent() {
|
||||
}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
extra={
|
||||
// Web-only nudge toward the desktop app. Copy is hardcoded EN
|
||||
// for now because the login route sits outside the landing
|
||||
// group's LocaleProvider — if this page ever becomes
|
||||
// locale-aware, the strings live in positioning doc §3.3.
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.web.prefer_desktop)}{" "}
|
||||
Prefer the desktop app?{" "}
|
||||
<Link
|
||||
href="/download"
|
||||
onClick={() => captureDownloadIntent("login")}
|
||||
className="font-medium text-foreground underline decoration-foreground/30 underline-offset-4 hover:decoration-foreground/70"
|
||||
>
|
||||
{t(($) => $.web.download)}
|
||||
Download
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
|
||||
import { LOCALE_COOKIE } from "@multica/core/i18n";
|
||||
import { LocaleProvider } from "@/features/landing/i18n";
|
||||
import type { Locale } from "@/features/landing/i18n";
|
||||
|
||||
@@ -44,7 +43,7 @@ const jsonLd = {
|
||||
async function getInitialLocale(): Promise<Locale> {
|
||||
// 1. User's explicit preference (cookie set when they switch language)
|
||||
const cookieStore = await cookies();
|
||||
const stored = cookieStore.get(LOCALE_COOKIE)?.value;
|
||||
const stored = cookieStore.get("multica-locale")?.value;
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
|
||||
// 2. Detect from Accept-Language header
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { use } from "react";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export default function IssueDetailPage({
|
||||
params,
|
||||
@@ -10,9 +9,5 @@ export default function IssueDetailPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <IssuesPage />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { WebProviders } from "@/components/web-providers";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
type SupportedLocale,
|
||||
} from "@multica/core/i18n";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
// Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
|
||||
@@ -103,40 +97,21 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
function isSupportedLocale(value: string | null): value is SupportedLocale {
|
||||
return value !== null && (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
// HTML lang attribute uses BCP-47 region tags that screen readers and font
|
||||
// stacks recognize widely. i18next keeps `zh-Hans` as its internal locale
|
||||
// (script subtag is what we actually translate against), but the html element
|
||||
// expects a region-flavoured tag for accessibility tooling and CJK fallback.
|
||||
const HTML_LANG: Record<SupportedLocale, string> = {
|
||||
en: "en",
|
||||
"zh-Hans": "zh-CN",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const h = await headers();
|
||||
const headerLocale = h.get("x-multica-locale");
|
||||
const locale: SupportedLocale = isSupportedLocale(headerLocale)
|
||||
? headerLocale
|
||||
: DEFAULT_LOCALE;
|
||||
const resources = { [locale]: RESOURCES[locale] };
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={HTML_LANG[locale]}
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<WebProviders locale={locale} resources={resources}>
|
||||
<WebProviders>
|
||||
{children}
|
||||
</WebProviders>
|
||||
<Toaster />
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
"use client";
|
||||
import { Instrument_Serif } from "next/font/google";
|
||||
|
||||
import Link from "next/link";
|
||||
import { buttonVariants } from "@multica/ui/components/ui/button";
|
||||
// Editorial-style 404. Cream + ink + terracotta palette is intentionally
|
||||
// inline — these brand experiments have not been promoted to design tokens.
|
||||
// The route lives outside the (landing) group's font scope, so we attach
|
||||
// Instrument Serif locally to match the editorial direction.
|
||||
const CREAM = "#faf9f6";
|
||||
const INK = "#1b1812";
|
||||
const TERRACOTTA = "#a64a2c";
|
||||
|
||||
const editorialSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-24 text-center">
|
||||
<p className="text-sm font-medium text-muted-foreground">404</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
|
||||
<p className="max-w-md text-sm text-muted-foreground">
|
||||
The page you are looking for doesn’t exist or has been moved.
|
||||
<section
|
||||
className={`${editorialSerif.variable} relative flex min-h-screen flex-col items-center justify-center px-6 py-16`}
|
||||
style={{ backgroundColor: CREAM, color: INK }}
|
||||
>
|
||||
{/* tracking is wider than Tailwind's tracking-widest (0.1em) — editorial eyebrow detail, deliberate. */}
|
||||
<div
|
||||
className="flex items-center gap-3 text-xs uppercase tracking-[0.25em]"
|
||||
style={{ color: TERRACOTTA }}
|
||||
>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
<span>error · not found</span>
|
||||
<span aria-hidden="true" className="inline-block h-px w-10" style={{ background: TERRACOTTA }} />
|
||||
</div>
|
||||
|
||||
{/* Fluid hero size + ultra-tight leading; outside the Tailwind type scale by design. */}
|
||||
<h1 className="mt-12 font-serif text-[clamp(7rem,16vw,15rem)] leading-[0.85] tracking-tight">
|
||||
404
|
||||
</h1>
|
||||
|
||||
<p className="mt-10 max-w-xl text-center font-serif text-3xl leading-tight">
|
||||
This page{" "}
|
||||
<em className="not-italic" style={{ color: TERRACOTTA }}>
|
||||
doesn’t exist
|
||||
</em>
|
||||
.
|
||||
</p>
|
||||
<Link href="/" className={buttonVariants({ className: "mt-2" })}>
|
||||
<p
|
||||
className="mt-5 max-w-md text-center text-sm leading-relaxed"
|
||||
style={{ color: INK, opacity: 0.6 }}
|
||||
>
|
||||
The URL may have changed, the resource may be deleted, or you arrived from a stale link.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/"
|
||||
className="mt-12 inline-flex h-10 items-center rounded-full px-6 text-sm font-medium transition hover:opacity-90"
|
||||
style={{ background: INK, color: CREAM }}
|
||||
>
|
||||
Back to Multica
|
||||
</Link>
|
||||
</main>
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
20
apps/web/components/locale-sync.tsx
Normal file
20
apps/web/components/locale-sync.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Reads the locale cookie on the client and updates <html lang>.
|
||||
* This avoids calling cookies() in the root Server Component layout,
|
||||
* which would mark the entire app as dynamic and disable the Router Cache.
|
||||
*/
|
||||
export function LocaleSync() {
|
||||
useEffect(() => {
|
||||
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
|
||||
const locale = match?.[1];
|
||||
if (locale === "zh") {
|
||||
document.documentElement.lang = "zh";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { createBrowserCookieLocaleAdapter } from "@multica/core/i18n/browser";
|
||||
import type { LocaleResources, SupportedLocale } from "@multica/core/i18n";
|
||||
import packageJson from "../package.json";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import {
|
||||
@@ -43,15 +41,7 @@ function deriveWsUrl(): string | undefined {
|
||||
const WEB_VERSION =
|
||||
process.env.NEXT_PUBLIC_APP_VERSION || packageJson.version || "dev";
|
||||
|
||||
export function WebProviders({
|
||||
children,
|
||||
locale,
|
||||
resources,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
locale: SupportedLocale;
|
||||
resources: Record<string, LocaleResources>;
|
||||
}) {
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
const cookieAuth = !hasLegacyToken();
|
||||
// Stable identity reference so downstream effects keyed on it don't see a
|
||||
// new object on every parent render.
|
||||
@@ -59,7 +49,6 @@ export function WebProviders({
|
||||
() => ({ platform: "web", version: WEB_VERSION }),
|
||||
[],
|
||||
);
|
||||
const localeAdapter = useMemo(() => createBrowserCookieLocaleAdapter(), []);
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
@@ -68,9 +57,6 @@ export function WebProviders({
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
localeAdapter={localeAdapter}
|
||||
>
|
||||
{/* Suspense boundary is required by Next.js for useSearchParams in
|
||||
a client component mounted this high in the tree. */}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useMemo } from "react";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import { LOCALE_COOKIE } from "@multica/core/i18n";
|
||||
import { createEnDict } from "./en";
|
||||
import { createZhDict } from "./zh";
|
||||
import type { LandingDict, Locale } from "./types";
|
||||
@@ -12,6 +11,7 @@ const dictionaryFactories: Record<Locale, (allowSignup: boolean) => LandingDict>
|
||||
zh: createZhDict,
|
||||
};
|
||||
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
type LocaleContextValue = {
|
||||
@@ -38,11 +38,7 @@ export function LocaleProvider({
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
const secure =
|
||||
typeof location !== "undefined" && location.protocol === "https:"
|
||||
? "; Secure"
|
||||
: "";
|
||||
document.cookie = `${LOCALE_COOKIE}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax${secure}`;
|
||||
document.cookie = `${COOKIE_NAME}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,7 +94,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
label: "RUNTIMES",
|
||||
title: "One dashboard for all your compute",
|
||||
description:
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects 11 supported coding tools on your machine.",
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects local CLIs \u2014 plug in and go.",
|
||||
cards: [
|
||||
{
|
||||
title: "Unified runtime panel",
|
||||
@@ -107,9 +107,9 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
"Online/offline status, usage charts, and activity heatmaps. Know exactly what your compute is doing at any moment.",
|
||||
},
|
||||
{
|
||||
title: "Auto-detection on first run",
|
||||
title: "Auto-detection & plug-and-play",
|
||||
description:
|
||||
"Multica scans for 11 supported coding tools \u2014 Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi \u2014 and registers a runtime for each one it finds.",
|
||||
"Multica detects available CLIs like Claude Code, Codex, OpenClaw, and OpenCode automatically. Connect a machine, and it\u2019s ready to work.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -129,7 +129,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
description:
|
||||
"Run multica setup \u2014 it walks you through OAuth, starts the daemon, and scans for the 11 supported coding tools (Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi). Whichever ones you already have installed get registered as runtimes automatically.",
|
||||
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
|
||||
},
|
||||
{
|
||||
title: "Create your first agent",
|
||||
@@ -185,7 +185,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
question: "What coding agents does Multica support?",
|
||||
answer:
|
||||
"Multica supports 11 coding tools out of the box: Claude Code, Codex, Cursor, Copilot, Gemini, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, and Pi. The daemon auto-detects whichever CLIs you already have installed and registers a runtime for each one. Since it's open source, you can also add your own backends.",
|
||||
"Multica currently supports Claude Code, Codex, OpenClaw, and OpenCode out of the box. The daemon auto-detects whichever CLIs you have installed. Since it\u2019s open source, you can also add your own backends.",
|
||||
},
|
||||
{
|
||||
question: "Do I need to self-host, or is there a cloud version?",
|
||||
@@ -283,90 +283,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
title: "Daemon Disk-Usage CLI, Timeline Polish & Task Usage Rollup",
|
||||
changes: [],
|
||||
features: [
|
||||
"New `multica daemon disk-usage` CLI surfaces per-task and per-workspace disk footprint",
|
||||
"Skill picker in agent settings has a search box for fast lookup",
|
||||
"Daemon GC extends to chat, autopilot, and quick-create tasks",
|
||||
"Issue detail breadcrumb now shows the MUL-xxxx identifier for quick reference",
|
||||
],
|
||||
improvements: [
|
||||
"Timeline page size bumped to 50, with per-pool keyset cursors for comments and activities",
|
||||
"'Show older / newer' affordances now appear in edge cases and look clearly clickable",
|
||||
"Server `task_usage` rolls up into a daily aggregate table, dropping DB load significantly",
|
||||
"Daemon health check stays responsive while repo lookups are in flight",
|
||||
"Runtime stats exclude archived agents for accurate active counts",
|
||||
],
|
||||
fixes: [
|
||||
"Linux daemon self-restart uses `brew prefix` symlinks, so Homebrew Cellar deletion no longer orphans runtimes",
|
||||
"CLI short IDs now route correctly — copied prefixes no longer 404",
|
||||
"Windows non-ASCII comment / description input lands via new `--content-file` / `--description-file` flags",
|
||||
"Windows / Linux desktop replaces the Electron placeholder icon with the Multica asterisk",
|
||||
"Orphaned timeline replies are now correctly surfaced",
|
||||
"Timeline comment pagination budget excludes activities, so heavy activity no longer crowds out real comments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.27",
|
||||
date: "2026-05-07",
|
||||
title: "Smoother Chat, GitHub Skill Import & Stability Fixes",
|
||||
changes: [],
|
||||
features: [
|
||||
"Import reusable skills directly from GitHub links",
|
||||
],
|
||||
improvements: [
|
||||
"Chat and Inbox feel smoother, with clearer history, easier reply copying, and faster triage after archiving",
|
||||
"Issue actions keep more context, from easier access to the local folder to sub-issues inheriting the right project and status",
|
||||
"Autopilots pause themselves after repeated failures, so noisy automations are easier to catch and fix",
|
||||
],
|
||||
fixes: [
|
||||
"Chinese input, desktop updates, long issue timelines, and live status updates are more reliable",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.26",
|
||||
date: "2026-05-06",
|
||||
title: "Full i18n Rollout, Long-Issue Timeline & System Notifications Toggle",
|
||||
changes: [],
|
||||
features: [
|
||||
"Web app fully translated to Simplified Chinese (21 namespaces), with per-user locale",
|
||||
"System Notifications toggle in Settings",
|
||||
"Delete chat sessions; History panel surfaced on the chat header",
|
||||
"Runtime liveness backed by Redis, with DB fallback",
|
||||
"Desktop loads runtime self-host config",
|
||||
"CLI adds `--assignee-id` / `--to-id` / `--user-id` for unambiguous targeting",
|
||||
],
|
||||
improvements: [
|
||||
"Settings 'Appearance' tab is renamed to 'Preferences', and the active tab is reflected in the URL so deep links work",
|
||||
"Long issues open instantly — Timeline switched to cursor-based keyset pagination, and repeated `task_completed` / `task_failed` activity entries are coalesced",
|
||||
"Runtime poll and heartbeat schedules are isolated per-runtime, so one busy runtime can no longer starve others",
|
||||
"CLI update requests persist in Redis, so a server restart no longer drops them",
|
||||
"Runtime cost usage window narrowed from 180 days to 14 days, dropping query load",
|
||||
"Project list returns a `resource_count` instead of inlining all resources, keeping responses lean",
|
||||
"404 page redesigned, with the No-Access redirect loop fixed",
|
||||
"Quick Create exempts git-describe daemons from the CLI version gate",
|
||||
"CI now enforces lint on every PR, and the existing lint debt has been cleared",
|
||||
],
|
||||
fixes: [
|
||||
"Daemon cancels the running agent when the task is deleted server-side, eliminating orphan processes",
|
||||
"Daemon refreshes a stale Codex `auth.json` when reusing an exec env, fixing intermittent auth errors",
|
||||
"Daemon refuses to write `.gc_meta.json` when `issue_id` is empty",
|
||||
"Session / resume across ACP backends now trusts the agent-reported session id, fixing cross-session bleed",
|
||||
"OpenCode skills are written under `.opencode/skills/` so they are discovered natively",
|
||||
"404 task-not-found semantics tightened on both server and the final guard",
|
||||
"Pinned sidebar rows are auto-unpinned when the underlying entity disappears",
|
||||
"Project detail page splits desktop and mobile sidebar state",
|
||||
"Runtime detail page hides archived agents",
|
||||
"Already-attached repos in Add Resource show a URL tooltip; empty project state has a New Issue button",
|
||||
"S3 public URLs are region-qualified, fixing cross-region access",
|
||||
"Windows installer parses version numbers and decodes checksums correctly",
|
||||
"Quick Create submit button no longer shows a duplicate keyboard shortcut",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
|
||||
@@ -13,42 +13,42 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headlineLine1: "\u4f60\u7684\u4e0b\u4e00\u6279\u5458\u5de5",
|
||||
headlineLine2: "\u4e0d\u662f\u4eba\u7c7b\u3002",
|
||||
subheading:
|
||||
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 智能体 \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + 智能体 \u56e2\u961f\u3002",
|
||||
"Multica \u662f\u4e00\u4e2a\u5f00\u6e90\u5e73\u53f0\uff0c\u5c06\u7f16\u7801 Agent \u53d8\u6210\u771f\u6b63\u7684\u961f\u53cb\u3002\u5206\u914d\u4efb\u52a1\u3001\u8ddf\u8e2a\u8fdb\u5ea6\u3001\u79ef\u7d2f\u6280\u80fd\u2014\u2014\u5728\u4e00\u4e2a\u5730\u65b9\u7ba1\u7406\u4f60\u7684\u4eba\u7c7b + Agent \u56e2\u961f\u3002",
|
||||
cta: "免费开始",
|
||||
downloadDesktop: "下载桌面端",
|
||||
worksWith: "支持",
|
||||
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c 智能体 \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
|
||||
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c Agent \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
|
||||
},
|
||||
|
||||
features: {
|
||||
teammates: {
|
||||
label: "\u56e2\u961f\u534f\u4f5c",
|
||||
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 智能体",
|
||||
title: "\u50cf\u5206\u914d\u7ed9\u540c\u4e8b\u4e00\u6837\u5206\u914d\u7ed9 Agent",
|
||||
description:
|
||||
"智能体 \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c 智能体 \u5e76\u80a9\u5de5\u4f5c\u3002",
|
||||
"Agent \u4e0d\u662f\u88ab\u52a8\u5de5\u5177\u2014\u2014\u5b83\u4eec\u662f\u4e3b\u52a8\u53c2\u4e0e\u8005\u3002\u5b83\u4eec\u62e5\u6709\u4e2a\u4eba\u8d44\u6599\u3001\u62a5\u544a\u72b6\u6001\u3001\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u3002\u4f60\u7684\u6d3b\u52a8\u6d41\u5c55\u793a\u4eba\u7c7b\u548c Agent \u5e76\u80a9\u5de5\u4f5c\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "智能体 \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
|
||||
title: "Agent \u51fa\u73b0\u5728\u6307\u6d3e\u4eba\u9009\u62e9\u5668\u4e2d",
|
||||
description:
|
||||
"\u4eba\u7c7b\u548c 智能体 \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 智能体 \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
|
||||
"\u4eba\u7c7b\u548c Agent \u51fa\u73b0\u5728\u540c\u4e00\u4e2a\u4e0b\u62c9\u83dc\u5355\u91cc\u3002\u628a\u4efb\u52a1\u5206\u914d\u7ed9 Agent \u548c\u5206\u914d\u7ed9\u540c\u4e8b\u6ca1\u6709\u4efb\u4f55\u533a\u522b\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u81ea\u4e3b\u53c2\u4e0e",
|
||||
description:
|
||||
"智能体 \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
|
||||
"Agent \u4e3b\u52a8\u521b\u5efa Issue\u3001\u53d1\u8868\u8bc4\u8bba\u3001\u66f4\u65b0\u72b6\u6001\u2014\u2014\u800c\u4e0d\u662f\u53ea\u5728\u88ab\u63d0\u793a\u65f6\u624d\u884c\u52a8\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u7edf\u4e00\u7684\u6d3b\u52a8\u65f6\u95f4\u7ebf",
|
||||
description:
|
||||
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c 智能体 \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
|
||||
"\u6574\u4e2a\u56e2\u961f\u5171\u7528\u4e00\u4e2a\u6d3b\u52a8\u6d41\u3002\u4eba\u7c7b\u548c Agent \u7684\u64cd\u4f5c\u4ea4\u66ff\u5c55\u793a\uff0c\u4f60\u59cb\u7ec8\u77e5\u9053\u53d1\u751f\u4e86\u4ec0\u4e48\u3001\u662f\u8c01\u505a\u7684\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
autonomous: {
|
||||
label: "\u81ea\u4e3b\u6267\u884c",
|
||||
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014智能体 \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
|
||||
title: "\u8bbe\u7f6e\u540e\u65e0\u9700\u7ba1\u7406\u2014\u2014Agent \u5728\u4f60\u7761\u89c9\u65f6\u5de5\u4f5c",
|
||||
description:
|
||||
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
|
||||
"\u4e0d\u53ea\u662f\u63d0\u793a-\u54cd\u5e94\u3002\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u7ba1\u7406\uff1a\u5165\u961f\u3001\u9886\u53d6\u3001\u542f\u52a8\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\uff0c\u4f60\u901a\u8fc7 WebSocket \u83b7\u53d6\u5b9e\u65f6\u8fdb\u5ea6\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f",
|
||||
@@ -58,12 +58,12 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u4e3b\u52a8\u62a5\u544a\u963b\u585e",
|
||||
description:
|
||||
"\u5f53 智能体 \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
|
||||
"\u5f53 Agent \u9047\u5230\u56f0\u96be\u65f6\uff0c\u4f1a\u7acb\u5373\u53d1\u51fa\u8b66\u62a5\u3002\u4e0d\u7528\u7b49\u51e0\u4e2a\u5c0f\u65f6\u540e\u624d\u53d1\u73b0\u4ec0\u4e48\u90fd\u6ca1\u53d1\u751f\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5b9e\u65f6\u8fdb\u5ea6\u63a8\u9001",
|
||||
description:
|
||||
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b 智能体 \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
|
||||
"\u57fa\u4e8e WebSocket \u7684\u5b9e\u65f6\u66f4\u65b0\u3002\u5b9e\u65f6\u89c2\u770b Agent \u5de5\u4f5c\uff0c\u6216\u968f\u65f6\u67e5\u770b\u2014\u2014\u65f6\u95f4\u7ebf\u59cb\u7ec8\u662f\u6700\u65b0\u7684\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -71,22 +71,22 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
label: "\u6280\u80fd\u5e93",
|
||||
title: "\u6bcf\u4e2a\u89e3\u51b3\u65b9\u6848\u90fd\u6210\u4e3a\u5168\u56e2\u961f\u53ef\u590d\u7528\u7684\u6280\u80fd",
|
||||
description:
|
||||
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a 智能体 \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
|
||||
"\u6280\u80fd\u662f\u53ef\u590d\u7528\u7684\u80fd\u529b\u5b9a\u4e49\u2014\u2014\u4ee3\u7801\u3001\u914d\u7f6e\u548c\u4e0a\u4e0b\u6587\u6253\u5305\u5728\u4e00\u8d77\u3002\u53ea\u9700\u7f16\u5199\u4e00\u6b21\uff0c\u56e2\u961f\u4e2d\u6bcf\u4e2a Agent \u90fd\u80fd\u4f7f\u7528\u3002\u4f60\u7684\u6280\u80fd\u5e93\u968f\u65f6\u95f4\u4e0d\u65ad\u79ef\u7d2f\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u53ef\u590d\u7528\u7684\u6280\u80fd\u5b9a\u4e49",
|
||||
description:
|
||||
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 智能体 \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
|
||||
"\u5c06\u77e5\u8bc6\u5c01\u88c5\u6210\u4efb\u4f55 Agent \u90fd\u80fd\u6267\u884c\u7684\u6280\u80fd\u3002\u90e8\u7f72\u5230\u6d4b\u8bd5\u73af\u5883\u3001\u7f16\u5199\u8fc1\u79fb\u3001\u5ba1\u67e5 PR\u2014\u2014\u5168\u90e8\u4ee3\u7801\u5316\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u5168\u56e2\u961f\u5171\u4eab",
|
||||
description:
|
||||
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a 智能体 \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
|
||||
"\u4e00\u4e2a\u4eba\u7684\u6280\u80fd\u5c31\u662f\u6bcf\u4e2a Agent \u7684\u6280\u80fd\u3002\u7f16\u5199\u4e00\u6b21\uff0c\u5168\u56e2\u961f\u53d7\u76ca\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u590d\u5408\u589e\u957f",
|
||||
description:
|
||||
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 智能体 \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a 智能体 \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
|
||||
"\u7b2c 1 \u5929\uff1a\u4f60\u6559 Agent \u90e8\u7f72\u3002\u7b2c 30 \u5929\uff1a\u6bcf\u4e2a Agent \u90fd\u80fd\u90e8\u7f72\u3001\u5199\u6d4b\u8bd5\u3001\u505a\u4ee3\u7801\u5ba1\u67e5\u3002\u56e2\u961f\u80fd\u529b\u6307\u6570\u7ea7\u589e\u957f\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -94,7 +94,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
label: "\u8fd0\u884c\u65f6",
|
||||
title: "\u4e00\u4e2a\u63a7\u5236\u53f0\u7ba1\u7406\u6240\u6709\u7b97\u529b",
|
||||
description:
|
||||
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u3002",
|
||||
"\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\u548c\u4e91\u7aef\u8fd0\u884c\u65f6\uff0c\u5728\u540c\u4e00\u4e2a\u9762\u677f\u4e2d\u7ba1\u7406\u3002\u5b9e\u65f6\u76d1\u63a7\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u81ea\u52a8\u68c0\u6d4b\u672c\u5730 CLI\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
|
||||
cards: [
|
||||
{
|
||||
title: "\u7edf\u4e00\u8fd0\u884c\u65f6\u9762\u677f",
|
||||
@@ -107,9 +107,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
"\u5728\u7ebf/\u79bb\u7ebf\u72b6\u6001\u3001\u4f7f\u7528\u91cf\u56fe\u8868\u548c\u6d3b\u52a8\u70ed\u529b\u56fe\u3002\u968f\u65f6\u4e86\u89e3\u4f60\u7684\u7b97\u529b\u5728\u505a\u4ec0\u4e48\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u9996\u6b21\u542f\u52a8\u81ea\u52a8\u6ce8\u518c",
|
||||
title: "\u81ea\u52a8\u68c0\u6d4b\u4e0e\u5373\u63d2\u5373\u7528",
|
||||
description:
|
||||
"Multica \u626b\u63cf\u672c\u673a\u7684 11 \u6b3e\u652f\u6301\u7684 AI \u7f16\u7a0b\u5de5\u5177\u2014\u2014Claude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u2014\u2014\u5e76\u4e3a\u6bcf\u6b3e\u5df2\u5b89\u88c5\u7684\u5de5\u5177\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002",
|
||||
"Multica \u81ea\u52a8\u68c0\u6d4b Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode \u7b49\u53ef\u7528 CLI\u3002\u8fde\u63a5\u4e00\u53f0\u673a\u5668\uff0c\u5373\u53ef\u5f00\u59cb\u5de5\u4f5c\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -129,17 +129,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
description:
|
||||
"运行 multica setup——它会引导你完成 OAuth 登录、启动守护进程、并扫描 11 款支持的 AI 编程工具(Claude Code、Codex、Cursor、Copilot、Gemini、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi)。本机已安装的工具会被自动注册成运行时。",
|
||||
"运行 multica setup 一键完成配置、认证和启动。守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw 和 OpenCode——插上就用。",
|
||||
},
|
||||
{
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a 智能体",
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
|
||||
description:
|
||||
"\u7ed9\u5b83\u8d77\u4e2a\u540d\u5b57\uff0c\u5199\u597d\u6307\u4ee4\uff0c\u9644\u52a0\u6280\u80fd\uff0c\u8bbe\u7f6e\u89e6\u53d1\u5668\u3002\u9009\u62e9\u5b83\u4f55\u65f6\u6fc0\u6d3b\uff1a\u88ab\u6307\u6d3e\u65f6\u3001\u6709\u8bc4\u8bba\u65f6\u3001\u88ab @\u63d0\u53ca\u65f6\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u6307\u6d3e\u4e00\u4e2a Issue \u5e76\u89c2\u5bdf\u5b83\u5de5\u4f5c",
|
||||
description:
|
||||
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 智能体\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
|
||||
"\u4ece\u6307\u6d3e\u4eba\u4e0b\u62c9\u83dc\u5355\u4e2d\u9009\u62e9\u4f60\u7684 Agent\u2014\u2014\u5c31\u50cf\u6307\u6d3e\u7ed9\u540c\u4e8b\u4e00\u6837\u3002\u4efb\u52a1\u81ea\u52a8\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3002\u5b9e\u65f6\u89c2\u770b\u8fdb\u5ea6\u3002",
|
||||
},
|
||||
],
|
||||
cta: "\u5f00\u59cb\u4f7f\u7528",
|
||||
@@ -152,7 +152,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headlineLine1: "\u5f00\u6e90",
|
||||
headlineLine2: "\u4e3a\u6240\u6709\u4eba\u3002",
|
||||
description:
|
||||
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + 智能体 \u534f\u4f5c\u7684\u672a\u6765\u3002",
|
||||
"Multica \u5b8c\u5168\u5f00\u6e90\u3002\u5ba1\u67e5\u6bcf\u4e00\u884c\u4ee3\u7801\uff0c\u6309\u4f60\u7684\u65b9\u5f0f\u81ea\u6258\u7ba1\uff0c\u5851\u9020\u4eba\u7c7b + Agent \u534f\u4f5c\u7684\u672a\u6765\u3002",
|
||||
cta: "\u5728 GitHub \u4e0a Star",
|
||||
highlights: [
|
||||
{
|
||||
@@ -163,17 +163,17 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{
|
||||
title: "\u65e0\u4f9b\u5e94\u5546\u9501\u5b9a",
|
||||
description:
|
||||
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 智能体 \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
|
||||
"\u81ea\u5e26 LLM \u63d0\u4f9b\u5546\u3001\u66f4\u6362 Agent \u540e\u7aef\u3001\u6269\u5c55 API\u3002\u4f60\u62e5\u6709\u6574\u4e2a\u6280\u672f\u6808\u7684\u63a7\u5236\u6743\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u9ed8\u8ba4\u900f\u660e",
|
||||
description:
|
||||
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 智能体 \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
|
||||
"\u6bcf\u4e00\u884c\u4ee3\u7801\u90fd\u53ef\u5ba1\u8ba1\u3002\u786e\u5207\u4e86\u89e3\u4f60\u7684 Agent \u5982\u4f55\u505a\u51b3\u7b56\u3001\u4efb\u52a1\u5982\u4f55\u8def\u7531\u3001\u6570\u636e\u6d41\u5411\u4f55\u65b9\u3002",
|
||||
},
|
||||
{
|
||||
title: "\u793e\u533a\u9a71\u52a8",
|
||||
description:
|
||||
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c 智能体 \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
|
||||
"\u4e0e\u793e\u533a\u4e00\u8d77\u5efa\u8bbe\uff0c\u800c\u4e0d\u4ec5\u4ec5\u662f\u4e3a\u793e\u533a\u5efa\u8bbe\u3002\u8d21\u732e\u6280\u80fd\u3001\u96c6\u6210\u548c Agent \u540e\u7aef\uff0c\u8ba9\u6bcf\u4e2a\u4eba\u53d7\u76ca\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -183,9 +183,9 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
headline: "\u95ee\u4e0e\u7b54\u3002",
|
||||
items: [
|
||||
{
|
||||
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 智能体\uff1f",
|
||||
question: "Multica \u652f\u6301\u54ea\u4e9b\u7f16\u7801 Agent\uff1f",
|
||||
answer:
|
||||
"Multica \u5f00\u7bb1\u5373\u7528\u652f\u6301 11 \u6b3e AI \u7f16\u7a0b\u5de5\u5177\uff1aClaude Code\u3001Codex\u3001Cursor\u3001Copilot\u3001Gemini\u3001Hermes\u3001Kimi\u3001Kiro CLI\u3001OpenCode\u3001OpenClaw\u3001Pi\u3002\u5b88\u62a4\u8fdb\u7a0b\u4f1a\u81ea\u52a8\u68c0\u6d4b\u672c\u673a\u5df2\u5b89\u88c5\u7684 CLI \u5e76\u4e3a\u6bcf\u6b3e\u6ce8\u518c\u4e00\u4e2a\u8fd0\u884c\u65f6\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
"Multica \u76ee\u524d\u5f00\u7bb1\u5373\u7528\u652f\u6301 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u5b89\u88c5\u7684 CLI\u3002\u56e0\u4e3a\u5f00\u6e90\uff0c\u4f60\u4e5f\u53ef\u4ee5\u81ea\u5df1\u6dfb\u52a0\u540e\u7aef\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u9700\u8981\u81ea\u6258\u7ba1\u5417\uff0c\u8fd8\u662f\u6709\u4e91\u7248\u672c\uff1f",
|
||||
@@ -194,31 +194,31 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
{
|
||||
question:
|
||||
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 智能体 \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
|
||||
"\u8fd9\u548c\u76f4\u63a5\u7528\u7f16\u7801 Agent \u6709\u4ec0\u4e48\u533a\u522b\uff1f",
|
||||
answer:
|
||||
"\u7f16\u7801 智能体 \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a 智能体 \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 智能体 \u7684\u9879\u76ee\u7ecf\u7406\u3002",
|
||||
"\u7f16\u7801 Agent \u64c5\u957f\u6267\u884c\u3002Multica \u6dfb\u52a0\u7684\u662f\u7ba1\u7406\u5c42\uff1a\u4efb\u52a1\u961f\u5217\u3001\u56e2\u961f\u534f\u4f5c\u3001\u6280\u80fd\u590d\u7528\u3001\u8fd0\u884c\u65f6\u76d1\u63a7\uff0c\u4ee5\u53ca\u6bcf\u4e2a Agent \u5728\u505a\u4ec0\u4e48\u7684\u7edf\u4e00\u89c6\u56fe\u3002\u628a\u5b83\u60f3\u8c61\u6210\u4f60\u7684 Agent \u7684\u9879\u76ee\u7ecf\u7406\u3002",
|
||||
},
|
||||
{
|
||||
question: "智能体 \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
|
||||
question: "Agent \u80fd\u81ea\u4e3b\u5904\u7406\u957f\u65f6\u95f4\u4efb\u52a1\u5417\uff1f",
|
||||
answer:
|
||||
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002智能体 \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
|
||||
"\u53ef\u4ee5\u3002Multica \u7ba1\u7406\u5b8c\u6574\u7684\u4efb\u52a1\u751f\u547d\u5468\u671f\u2014\u2014\u5165\u961f\u3001\u9886\u53d6\u3001\u6267\u884c\u3001\u5b8c\u6210\u6216\u5931\u8d25\u3002Agent \u4e3b\u52a8\u62a5\u544a\u963b\u585e\u5e76\u5b9e\u65f6\u63a8\u9001\u8fdb\u5ea6\u3002\u4f60\u53ef\u4ee5\u968f\u65f6\u67e5\u770b\uff0c\u4e5f\u53ef\u4ee5\u8ba9\u5b83\u4eec\u8fd0\u884c\u6574\u665a\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1f智能体 \u5728\u54ea\u91cc\u6267\u884c\uff1f",
|
||||
question: "\u6211\u7684\u4ee3\u7801\u5b89\u5168\u5417\uff1fAgent \u5728\u54ea\u91cc\u6267\u884c\uff1f",
|
||||
answer:
|
||||
"智能体 \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
|
||||
"Agent \u5728\u4f60\u7684\u673a\u5668\uff08\u672c\u5730\u5b88\u62a4\u8fdb\u7a0b\uff09\u6216\u4f60\u81ea\u5df1\u7684\u4e91\u57fa\u7840\u8bbe\u65bd\u4e0a\u6267\u884c\u3002\u4ee3\u7801\u6c38\u8fdc\u4e0d\u4f1a\u7ecf\u8fc7 Multica \u670d\u52a1\u5668\u3002\u5e73\u53f0\u53ea\u534f\u8c03\u4efb\u52a1\u72b6\u6001\u548c\u5e7f\u64ad\u4e8b\u4ef6\u3002",
|
||||
},
|
||||
{
|
||||
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a 智能体\uff1f",
|
||||
question: "\u6211\u53ef\u4ee5\u8fd0\u884c\u591a\u5c11\u4e2a Agent\uff1f",
|
||||
answer:
|
||||
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a 智能体 \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
|
||||
"\u53d6\u51b3\u4e8e\u4f60\u7684\u786c\u4ef6\u3002\u6bcf\u4e2a Agent \u6709\u53ef\u914d\u7f6e\u7684\u5e76\u53d1\u9650\u5236\uff0c\u4f60\u53ef\u4ee5\u8fde\u63a5\u591a\u53f0\u673a\u5668\u4f5c\u4e3a\u8fd0\u884c\u65f6\u3002\u5f00\u6e90\u7248\u672c\u6ca1\u6709\u4efb\u4f55\u4eba\u4e3a\u9650\u5236\u3002",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
footer: {
|
||||
tagline:
|
||||
"\u4eba\u7c7b + 智能体 \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
|
||||
"\u4eba\u7c7b + Agent \u56e2\u961f\u7684\u9879\u76ee\u7ba1\u7406\u3002\u5f00\u6e90\u3001\u53ef\u81ea\u6258\u7ba1\u3001\u4e3a\u672a\u6765\u7684\u5de5\u4f5c\u65b9\u5f0f\u800c\u5efa\u3002",
|
||||
cta: "\u5f00\u59cb\u4f7f\u7528",
|
||||
groups: {
|
||||
product: {
|
||||
@@ -283,90 +283,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
title: "Daemon 磁盘占用 CLI、Timeline 打磨与任务用量聚合提速",
|
||||
changes: [],
|
||||
features: [
|
||||
"新增 `multica daemon disk-usage` CLI,按 task / workspace 维度查看磁盘占用",
|
||||
"Skill Picker 弹窗新增搜索框,Agent 设置里挑技能更快",
|
||||
"Daemon GC 覆盖扩展到 chat、autopilot、quick-create 任务",
|
||||
"Issue 详情页面包屑直接显示 MUL-xxxx identifier",
|
||||
],
|
||||
improvements: [
|
||||
"Timeline 分页 size 提到 50,评论与活动按池独立 keyset 游标,长 Issue 翻页更顺",
|
||||
"Show older / newer 按钮在边界场景也能正确出现,且视觉上更明显是可点击的",
|
||||
"服务端 `task_usage` 聚合到每日 rollup 表,DB 负载明显下降",
|
||||
"Daemon health check 在 repo 查询时不再阻塞,始终保持响应",
|
||||
"Runtime 统计排除已归档的 agent,活跃数字更准",
|
||||
],
|
||||
fixes: [
|
||||
"Linux 上 daemon self-restart 改走 `brew prefix` 软链,Homebrew Cellar 删除后不再让 runtime 失联",
|
||||
"CLI 短 ID 现在可以正确路由,复制粘贴的短前缀不再 404",
|
||||
"Windows 上非 ASCII 字符评论 / 描述输入新增 `--content-file` / `--description-file`",
|
||||
"Windows / Linux 桌面端用 Multica asterisk 替换 Electron 默认占位图标",
|
||||
"Timeline 中孤立的 reply 现在会被正确捞回展示",
|
||||
"Timeline 评论分页预算不再把 activity 算进去,避免活动多时挤掉真实评论",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.27",
|
||||
date: "2026-05-07",
|
||||
title: "Chat 更顺手,Skill 支持 GitHub 导入,稳定性更好",
|
||||
changes: [],
|
||||
features: [
|
||||
"支持直接通过 GitHub 链接导入可复用 Skill",
|
||||
],
|
||||
improvements: [
|
||||
"Chat 和 Inbox 更顺手,历史更清晰,复制回复更方便,归档后能更快处理下一项",
|
||||
"Issue 操作会保留更多上下文,例如更容易找到对应本地文件夹,子 Issue 也会带上正确的项目和状态",
|
||||
"Autopilot 连续失败后会自动暂停,异常自动化更容易发现和修复",
|
||||
],
|
||||
fixes: [
|
||||
"中文输入、桌面端升级、长 Issue 时间线和实时状态展示更稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.26",
|
||||
date: "2026-05-06",
|
||||
title: "i18n 全量铺开、长 Issue Timeline 提速与系统通知开关",
|
||||
changes: [],
|
||||
features: [
|
||||
"Web 端完成简中翻译,21 个命名空间齐全,语言偏好按账号同步",
|
||||
"Settings 新增 System Notifications 开关",
|
||||
"支持删除 Chat 会话,History 面板移至 chat header",
|
||||
"Runtime 在线判断改走 Redis(DB 兜底)",
|
||||
"Desktop 支持加载 runtime 自托管配置",
|
||||
"CLI 新增 `--assignee-id` / `--to-id` / `--user-id`,重名时定位更准",
|
||||
],
|
||||
improvements: [
|
||||
"Settings 的 Appearance Tab 改名为 Preferences,并把当前激活的 Tab 反映到 URL,深链可分享",
|
||||
"长 Issue 打开秒开 —— Timeline 改为基于游标的 keyset 分页,重复的 `task_completed` / `task_failed` 活动条目合并展示",
|
||||
"Runtime poll 与 heartbeat 调度按 runtime 隔离,单个忙碌 runtime 不再拖慢其他",
|
||||
"CLI 更新请求落 Redis,server 重启也不丢",
|
||||
"Runtime 用量统计窗口由 180 天收窄到 14 天,降低查询压力",
|
||||
"项目列表返回 `resource_count` 摘要,不再内联全部 resource,响应体更小",
|
||||
"404 页面重新设计,并修复 No-Access 重定向死循环",
|
||||
"Quick Create 对 git-describe 类 daemon 跳过 CLI 版本闸",
|
||||
"CI 启用 lint 强制门禁,历史 lint 债同步清理完毕",
|
||||
],
|
||||
fixes: [
|
||||
"Task 在服务端被删后,daemon 主动取消正在运行的 agent,避免孤儿进程",
|
||||
"复用 execenv 时刷新陈旧的 Codex `auth.json`,修复偶发鉴权失败",
|
||||
"`issue_id` 为空时拒绝写入 `.gc_meta.json`",
|
||||
"跨 ACP 后端的 session/resume 信任 agent 自报的 session id,修复串号问题",
|
||||
"OpenCode 的 skills 写到 `.opencode/skills/` 让其原生发现",
|
||||
"Daemon 对 task-not-found 的 404 语义在 server 和最终 guard 双重收紧",
|
||||
"侧边栏中失效的 Pin 自动取消挂载",
|
||||
"项目详情页桌面端与移动端侧边栏状态独立保存",
|
||||
"Runtime 详情页隐藏已归档的 agent",
|
||||
"Add Resource 列表中已挂载的 repo 显示 URL tooltip;空项目页加上 New Issue 入口",
|
||||
"S3 公开 URL 携带 region,修复跨区访问失败",
|
||||
"Windows 安装器修正版本号解析与 checksum 解码",
|
||||
"Quick Create 提交按钮去掉重复的快捷键提示",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.24",
|
||||
date: "2026-05-03",
|
||||
|
||||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -22,8 +22,6 @@ function NavigationProviderInner({
|
||||
back: router.back,
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
getShareableUrl: (path: string) =>
|
||||
typeof window === "undefined" ? path : window.location.origin + path,
|
||||
};
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { matchLocale, LOCALE_COOKIE } from "@multica/core/i18n";
|
||||
|
||||
// Old workspace-scoped route segments that existed before the URL refactor
|
||||
// (pre-#1131). Any URL with these as the FIRST segment is a legacy URL that
|
||||
@@ -17,34 +16,7 @@ const LEGACY_ROUTE_SEGMENTS = new Set([
|
||||
"settings",
|
||||
]);
|
||||
|
||||
// Resolve the active locale per request. Cookie wins over Accept-Language;
|
||||
// matchLocale() falls back to DEFAULT_LOCALE when neither yields a match.
|
||||
function resolveLocale(req: NextRequest): string {
|
||||
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
|
||||
const acceptLanguage = req.headers.get("accept-language") ?? "";
|
||||
const candidates: string[] = [];
|
||||
if (cookieLocale) candidates.push(cookieLocale);
|
||||
for (const part of acceptLanguage.split(",")) {
|
||||
const tag = part.split(";")[0]?.trim();
|
||||
if (tag) candidates.push(tag);
|
||||
}
|
||||
return matchLocale(candidates);
|
||||
}
|
||||
|
||||
// Forward the resolved locale to RSC layouts via the `x-multica-locale`
|
||||
// request header. layout.tsx reads it through `await headers()`. The
|
||||
// `request: { headers }` form is what makes the header land on the upstream
|
||||
// request — without it the value would only sit on the response.
|
||||
function nextWithLocale(req: NextRequest): NextResponse {
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("x-multica-locale", resolveLocale(req));
|
||||
return NextResponse.next({ request: { headers } });
|
||||
}
|
||||
|
||||
// Next.js 16 renamed `middleware` → `proxy`. API surface (NextRequest /
|
||||
// NextResponse / cookies / matcher) is identical; the only behavioral
|
||||
// change is the runtime — proxy is forced to nodejs and cannot opt into
|
||||
// edge.
|
||||
// Next.js 16 renamed `middleware` → `proxy`. The runtime API is identical.
|
||||
export function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const hasSession = req.cookies.has("multica_logged_in");
|
||||
@@ -76,21 +48,34 @@ export function proxy(req: NextRequest) {
|
||||
}
|
||||
|
||||
// --- Root path: redirect logged-in users to their last workspace ---
|
||||
if (pathname === "/" && hasSession && lastSlug) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${lastSlug}/issues`;
|
||||
return NextResponse.redirect(url);
|
||||
if (pathname === "/") {
|
||||
if (!hasSession) return NextResponse.next();
|
||||
|
||||
if (lastSlug) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = `/${lastSlug}/issues`;
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// No last_workspace_slug cookie → let landing page pick the first workspace
|
||||
// client-side (features/landing/components/redirect-if-authenticated.tsx).
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// --- Default: forward locale header to RSC, no redirect/rewrite ---
|
||||
// Covers logged-out root path, /login, /:slug/*, and everything else.
|
||||
return nextWithLocale(req);
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// i18n header must land on every page request, so we use the standard
|
||||
// negative-lookahead pattern from Next's i18n guide: skip API routes
|
||||
// (Go backend), Next internals, and any path with a file extension
|
||||
// (favicons, sw.js, public/* assets).
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)"],
|
||||
matcher: [
|
||||
"/",
|
||||
"/issues/:path*",
|
||||
"/projects/:path*",
|
||||
"/agents/:path*",
|
||||
"/inbox/:path*",
|
||||
"/my-issues/:path*",
|
||||
"/autopilots/:path*",
|
||||
"/runtimes/:path*",
|
||||
"/skills/:path*",
|
||||
"/settings/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ export const mockUser: User = {
|
||||
// Matches real server behavior for anyone who onboarded before this
|
||||
// field shipped — migration 054 backfills 'skipped_legacy'.
|
||||
starter_content_state: "skipped_legacy",
|
||||
language: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ All analytics shipping is toggled by environment variables (see `.env.example`):
|
||||
|---|---|---|
|
||||
| `POSTHOG_API_KEY` | PostHog project API key. Empty = no events are shipped. | `""` |
|
||||
| `POSTHOG_HOST` | PostHog host (US or EU cloud, or self-hosted URL). | `https://us.i.posthog.com` |
|
||||
| `ANALYTICS_ENVIRONMENT` | Optional override for the standard `environment` event property. Normalized to `production`, `staging`, or `dev`; defaults from `APP_ENV`. | `APP_ENV` / `dev` |
|
||||
| `ANALYTICS_DISABLED` | Set to `true`/`1` to force the no-op client even when `POSTHOG_API_KEY` is set. | `""` |
|
||||
|
||||
Local dev and self-hosted instances run with `POSTHOG_API_KEY=""`, so **no
|
||||
@@ -83,50 +82,6 @@ handler → analytics.Client.Capture(Event) ← non-blocking, returns immediat
|
||||
`$set_once` only for values that must never be overwritten (email,
|
||||
initial attribution, first-completion timestamp).
|
||||
|
||||
## Taxonomy
|
||||
|
||||
Every event is assigned to one dashboard category:
|
||||
|
||||
| Category | Events |
|
||||
|---|---|
|
||||
| `core_loop` | `workspace_created`, `runtime_registered`, `runtime_ready`, `runtime_failed`, `runtime_offline`, `agent_created`, `issue_created`, `chat_message_sent`, `agent_task_queued`, `agent_task_dispatched`, `agent_task_started`, `agent_task_completed`, `agent_task_failed`, `agent_task_cancelled`, `autopilot_run_started`, `autopilot_run_completed`, `autopilot_run_failed` |
|
||||
| `onboarding_support` | `onboarding_started`, `onboarding_questionnaire_submitted`, `onboarding_completed`, `onboarding_runtime_path_selected`, `onboarding_runtime_detected`, `starter_content_decided` |
|
||||
| `acquisition` | `signup`, `download_intent_expressed`, `download_page_viewed`, `download_initiated`, `cloud_waitlist_joined` |
|
||||
| `ops_feedback` | `feedback_opened`, `feedback_submitted` |
|
||||
| `system/noise` | `$pageview`, `$set`, `$identify`, `$autocapture`, `$rageclick` |
|
||||
|
||||
The v0 core dashboard must use only `core_loop` plus the specific
|
||||
`onboarding_support` steps used by the activation funnel. Acquisition,
|
||||
feedback, and system/noise events stay in separate dashboards.
|
||||
|
||||
## Standard core properties
|
||||
|
||||
Canonical core events should carry these properties whenever the entity exists:
|
||||
|
||||
| Property | Type | Notes |
|
||||
|---|---|---|
|
||||
| `environment` | string | `production` / `staging` / `dev`; stamped by backend and frontend analytics clients. |
|
||||
| `event_schema_version` | int | Current version: `2`. |
|
||||
| `user_id` | string UUID | Human user ID when known. Agent/system events may omit it. |
|
||||
| `workspace_id` | string UUID | Required for workspace-scoped events. |
|
||||
| `agent_id` | string UUID | Required for agent/task events. |
|
||||
| `task_id` | string UUID | Required for `agent_task_*` events. |
|
||||
| `issue_id` / `chat_session_id` / `autopilot_run_id` | string UUID | Relevant source entity for the task/entry event. |
|
||||
| `source` | string | Canonical values: `onboarding`, `manual`, `chat`, `autopilot`, `api`. UI surface details use `surface` or `trigger_source`. |
|
||||
| `runtime_mode` | string | `cloud` / `local` when a runtime/agent task is involved. |
|
||||
| `provider` | string | `claude`, `codex`, `cursor`, etc. when a runtime/agent task is involved. |
|
||||
| `is_demo` | bool | Currently always `false`; reserved for future demo/test workspace filtering. |
|
||||
|
||||
Task terminal events additionally carry `duration_ms`; failures carry
|
||||
`failure_reason`, `error_type`, and `will_retry`. Runtime failure events carry
|
||||
`recoverable`; runtime ready events carry `runtime_id`, `ready_duration_ms`
|
||||
only when it is actually measured, and `daemon_id` for local runtimes.
|
||||
|
||||
Schema v2 is the first canonical core-metrics schema. It replaces early v1
|
||||
drafts that mirrored `failure_reason` into `error_type`, used `recoverable`
|
||||
for task/autopilot failures, and emitted `ready_duration_ms: 0` before the
|
||||
registration path had a measured duration.
|
||||
|
||||
## Event contract
|
||||
|
||||
### `signup`
|
||||
@@ -173,8 +128,6 @@ extra query, no race.
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `runtime_id` | string (UUID) | The newly created agent_runtime row id. |
|
||||
| `daemon_id` | string | Local daemon identity when available. |
|
||||
| `runtime_mode` | string | Currently `local`; reserved for cloud runtimes. |
|
||||
| `provider` | string | e.g. `"codex"`, `"claude"`. |
|
||||
| `runtime_version` | string | Version of the agent runtime binary. |
|
||||
| `cli_version` | string | Version of the `multica` CLI that registered it. |
|
||||
@@ -184,118 +137,6 @@ registered via a member's JWT/PAT; daemon-token registrations fall back to
|
||||
`workspace:<workspace_id>` so PostHog doesn't bucket unrelated daemons
|
||||
under a single "anonymous" person.
|
||||
|
||||
### `runtime_ready`
|
||||
|
||||
Fires when a runtime is first registered in an online/ready state. This is the
|
||||
activation-funnel step that should replace treating `runtime_registered` as
|
||||
proof of readiness. The backend emits this only on the INSERT path for a new
|
||||
`agent_runtime` row; ordinary daemon reconnects update the existing row and do
|
||||
not emit another `runtime_ready`. Dashboard funnels should still count
|
||||
distinct `runtime_id`.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `runtime_id` | string (UUID) | The `agent_runtime` row id. |
|
||||
| `daemon_id` | string | Local daemon identity when available. |
|
||||
| `ready_duration_ms` | int64 | Optional. Time from registration start to ready; omitted until the registration path can measure it. |
|
||||
| `runtime_mode` | string | `local` / `cloud`. |
|
||||
| `provider` | string | Runtime provider. |
|
||||
|
||||
### `runtime_failed`
|
||||
|
||||
Fires when runtime setup/registration fails before a ready runtime can be
|
||||
recorded. Today this is scoped to backend registration persistence failures;
|
||||
future setup flows should reuse it for provider detection or daemon boot
|
||||
failures.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `daemon_id` | string | Local daemon identity when available. |
|
||||
| `provider` | string | Runtime provider attempted. |
|
||||
| `failure_reason` | string | Stable coarse reason. |
|
||||
| `error_type` | string | Stable error classifier. |
|
||||
| `recoverable` | bool | Whether retrying setup may succeed. |
|
||||
|
||||
### `runtime_offline`
|
||||
|
||||
Fires when a runtime is explicitly deregistered or the backend sweeper marks it
|
||||
offline after missed heartbeats. This is not an activation step; it supports
|
||||
local runtime retention and drop-off diagnosis.
|
||||
|
||||
### `issue_created`
|
||||
|
||||
Fires after an issue row is created, including manual UI/API issue creation,
|
||||
quick-create issue creation by an agent, and autopilot `create_issue` runs.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `issue_id` | string (UUID) | Created issue. |
|
||||
| `agent_id` | string (UUID) | Agent assignee or creating agent when applicable. |
|
||||
| `task_id` | string (UUID) | Present for quick-create issue creation. |
|
||||
| `autopilot_run_id` | string (UUID) | Present for autopilot-created issues. |
|
||||
| `source` | string | `manual`, `api`, or `autopilot`. |
|
||||
|
||||
### `chat_message_sent`
|
||||
|
||||
Fires after a user chat message is persisted and the corresponding agent task
|
||||
is queued.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `chat_session_id` | string (UUID) | Chat session. |
|
||||
| `task_id` | string (UUID) | Queued agent task. |
|
||||
| `agent_id` | string (UUID) | Chat agent. |
|
||||
| `source` | string | Always `chat`. |
|
||||
|
||||
### `agent_task_queued` / `agent_task_dispatched` / `agent_task_started` / `agent_task_completed`
|
||||
|
||||
Canonical task lifecycle events emitted from `agent_task_queue` state
|
||||
transitions. `agent_task_dispatched` fires when the backend claims a queued
|
||||
task for a runtime, before the daemon marks it running with
|
||||
`agent_task_started`. These events replace `issue_executed` for core loop
|
||||
success metrics and allow the activation funnel to split queue backlog from
|
||||
claim/start handoff.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `task_id` | string (UUID) | `agent_task_queue.id`; required. |
|
||||
| `agent_id` | string (UUID) | Owning agent. |
|
||||
| `issue_id` | string (UUID) | Present for issue-linked tasks. |
|
||||
| `chat_session_id` | string (UUID) | Present for chat tasks. |
|
||||
| `autopilot_run_id` | string (UUID) | Present for run-only autopilot tasks. |
|
||||
| `source` | string | `manual`, `chat`, or `autopilot`. |
|
||||
| `runtime_mode` | string | `local` / `cloud`. |
|
||||
| `provider` | string | Runtime provider. |
|
||||
| `duration_ms` | int64 | Terminal events only; measured from `started_at` when available. |
|
||||
|
||||
### `agent_task_failed` / `agent_task_cancelled`
|
||||
|
||||
Terminal task lifecycle events. They use the same join fields as
|
||||
`agent_task_completed`. `agent_task_failed` also carries:
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `failure_reason` | string | Stable reason from `agent_task_queue.failure_reason`, default `agent_error`. |
|
||||
| `error_type` | string | Stable coarse classifier, e.g. `runtime`, `timeout`, `agent_output`, `cancelled`, `agent_error`. |
|
||||
| `will_retry` | bool | Whether the backend auto-retry policy will create another task attempt. |
|
||||
|
||||
### `autopilot_run_started` / `autopilot_run_completed` / `autopilot_run_failed`
|
||||
|
||||
Fires from `autopilot_run` lifecycle changes. `source` is always
|
||||
`autopilot`; the trigger origin is carried in `trigger_source` (`manual`,
|
||||
`schedule`, `webhook`, or `api`).
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `autopilot_id` | string (UUID) | Autopilot definition. |
|
||||
| `autopilot_run_id` | string (UUID) | Run row. |
|
||||
| `agent_id` | string (UUID) | Assigned agent. |
|
||||
| `trigger_source` | string | `manual`, `schedule`, `webhook`, or `api`. |
|
||||
| `duration_ms` | int64 | Terminal events only. |
|
||||
| `failure_reason` | string | Failed events only. |
|
||||
| `error_type` | string | Failed events only; stable coarse classifier such as `configuration`, `issue_terminal`, `dispatch_error`, `task_error`, or `autopilot_error`. |
|
||||
| `will_retry` | bool | Failed events only; currently `false` because autopilot retry cadence is owned by triggers/schedules. |
|
||||
|
||||
### `issue_executed`
|
||||
|
||||
Fires **at most once per issue** — when the first task on that issue
|
||||
@@ -308,11 +149,6 @@ distinct issues, not tasks.
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `issue_id` | string (UUID) | |
|
||||
| `task_id` | string (UUID) | Completing task. |
|
||||
| `agent_id` | string (UUID) | Completing agent. |
|
||||
| `source` | string | `manual`, `chat`, or `autopilot`. |
|
||||
| `runtime_mode` | string | `local` / `cloud`. |
|
||||
| `provider` | string | Runtime provider. |
|
||||
| `task_duration_ms` | int64 | Wall-clock time between `task.started_at` and `task.completed_at`. Zero when the task was created in a completed state (rare). |
|
||||
|
||||
`distinct_id` prefers the issue's human creator so agent-executed events
|
||||
@@ -329,10 +165,6 @@ emit `n=1`. PostHog answers the same question at query time via
|
||||
and funnel steps of the form "workspace has had ≥2 `issue_executed`
|
||||
events" are expressible without the property. No information is lost.
|
||||
|
||||
Compatibility: `issue_executed` remains a historical compatibility event for
|
||||
old dashboards. New core-loop success dashboards should use
|
||||
`agent_task_completed` and filter by `source`/`issue_id` as needed.
|
||||
|
||||
### `team_invite_sent`
|
||||
|
||||
Fires from `CreateInvitation` after the DB row is written.
|
||||
@@ -356,17 +188,6 @@ accepted and the member row is inserted in the same transaction.
|
||||
`distinct_id` is the invitee's user id — this is the event that closes the
|
||||
expansion funnel.
|
||||
|
||||
### `onboarding_started`
|
||||
|
||||
Fires once when the onboarding shell mounts and the initial workspace list has
|
||||
resolved. Existing-workspace users carry `workspace_id`; brand-new users do
|
||||
not have a workspace yet.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Present only when the user already has a workspace. |
|
||||
| `source` | string | Always `onboarding`. |
|
||||
|
||||
### `onboarding_questionnaire_submitted`
|
||||
|
||||
Fires on the first PatchOnboarding that transitions the user's
|
||||
@@ -405,7 +226,6 @@ isolates the Step 4 signal from later agent additions.
|
||||
|---|---|---|
|
||||
| `agent_id` | string (UUID) | |
|
||||
| `provider` | string | Runtime provider the agent is bound to (`claude`, `codex`, etc). |
|
||||
| `runtime_mode` | string | Runtime mode copied from the bound runtime. |
|
||||
| `template` | string | Template slug used to seed the agent (`coding` / `planning` / `writing` / `assistant`). Empty when the caller didn't come from a template picker. |
|
||||
| `is_first_agent_in_workspace` | bool | `true` when the workspace had zero agents before this insert. |
|
||||
|
||||
@@ -421,8 +241,7 @@ which exit the user took.
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Present for workspace-linked onboarding completions. |
|
||||
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `invite_accept` / `unknown`. See below. |
|
||||
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `unknown`. See below. |
|
||||
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
@@ -437,7 +256,6 @@ Person properties set with `$set_once`:
|
||||
- `runtime_skipped` — Completed without connecting a runtime (user hit Skip in Step 3).
|
||||
- `cloud_waitlist` — Submitted the cloud waitlist form and skipped Step 3.
|
||||
- `skip_existing` — "I've done this before" from Welcome. The user already had a workspace.
|
||||
- `invite_accept` — Accepted at least one workspace invitation.
|
||||
- `unknown` — Legacy fallback when the client didn't send a path. Should stay near zero after rollout.
|
||||
|
||||
### `cloud_waitlist_joined`
|
||||
@@ -496,11 +314,11 @@ request payload.
|
||||
`packages/views/onboarding/steps/step-platform-fork.tsx` when the web
|
||||
user clicks one of the three Step 3 fork cards (before any server
|
||||
call happens, so it's frontend-only). Properties: `path`
|
||||
(`download_desktop` / `cli` / `cloud_waitlist`), `source`
|
||||
(`onboarding`), `surface` (`step3`), `workspace_id`, and `is_mac`.
|
||||
Also writes `platform_preference` (`web` / `desktop`) to person
|
||||
properties so every subsequent event on the user can be broken down
|
||||
by chosen platform. **Note**: semantic "download
|
||||
(`download_desktop` / `cli` / `cloud_waitlist`), `source` (`step3`;
|
||||
literal today but reserved for future surfaces reusing this event),
|
||||
`is_mac`. Also writes `platform_preference` (`web` / `desktop`) to
|
||||
person properties so every subsequent event on the user can be
|
||||
broken down by chosen platform. **Note**: semantic "download
|
||||
intent" is now better served by `download_intent_expressed` below —
|
||||
`path: "download_desktop"` signals Step 3 path choice specifically,
|
||||
not actual download start.
|
||||
@@ -516,9 +334,8 @@ request payload.
|
||||
`runtime_registered` is silent on that cohort. Splits
|
||||
`completion_path=runtime_skipped` into "had CLIs, skipped anyway"
|
||||
vs "no CLIs available, had no choice". Properties:
|
||||
- `source`: `onboarding`.
|
||||
- `surface`: `step3_desktop`.
|
||||
- `workspace_id`: current onboarding workspace.
|
||||
- `source`: `step3_desktop` (literal; reserved for a future web
|
||||
emission under a different value).
|
||||
- `outcome`: `found` (at least one runtime registered before the
|
||||
5 s grace window expired) or `empty` (none registered by then).
|
||||
- `runtime_count`: number of runtimes visible to this user at
|
||||
@@ -602,38 +419,6 @@ request payload.
|
||||
`JSON.stringify`, and the entire payload is dropped if it still exceeds
|
||||
512 chars. That way PostHog sees either intact JSON or nothing at all.
|
||||
|
||||
## Reconciliation
|
||||
|
||||
`agent_task_completed` is the canonical PostHog-side task success event. It
|
||||
should reconcile daily against the operational source of truth:
|
||||
|
||||
```sql
|
||||
SELECT date_trunc('day', completed_at AT TIME ZONE 'UTC') AS day,
|
||||
count(*) AS db_completed_tasks
|
||||
FROM agent_task_queue
|
||||
WHERE status = 'completed'
|
||||
AND completed_at >= now() - interval '30 days'
|
||||
GROUP BY 1
|
||||
ORDER BY 1;
|
||||
```
|
||||
|
||||
Equivalent HogQL:
|
||||
|
||||
```sql
|
||||
SELECT toStartOfDay(timestamp) AS day,
|
||||
count() AS posthog_completed_tasks
|
||||
FROM events
|
||||
WHERE event = 'agent_task_completed'
|
||||
AND properties.environment = 'production'
|
||||
AND timestamp >= now() - interval 30 day
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
```
|
||||
|
||||
The expected difference should be near zero. Allow a small delay window for
|
||||
PostHog ingestion and backend analytics queue drops; sustained drift means
|
||||
either an emission site is missing or PostHog shipping is unhealthy.
|
||||
|
||||
## Governance
|
||||
|
||||
Before adding, renaming, or removing any event:
|
||||
|
||||
@@ -372,7 +372,7 @@ skill
|
||||
3. **注入**:当 agent 认领任务时,daemon 把挂载的 skill 内容写到任务工作目录的 **provider 原生位置**:
|
||||
- Claude Code → `.claude/skills/{name}/SKILL.md`
|
||||
- Codex → `CODEX_HOME/skills/{name}/`
|
||||
- OpenCode → `.opencode/skills/{name}/SKILL.md`
|
||||
- OpenCode → `.config/opencode/skills/{name}/SKILL.md`
|
||||
- Pi → `.pi/skills/{name}/SKILL.md`
|
||||
- Cursor → `.cursor/skills/{name}/SKILL.md`
|
||||
- GitHub Copilot → `.github/skills/{name}/SKILL.md`
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# RFC: Per-mention agent task enqueue (drop @mention coalescing dedup)
|
||||
|
||||
- Issue: [MUL-1913](mention://issue/9f54962b-e055-43eb-a649-1b16db52fea2)
|
||||
- Status: Accepted
|
||||
- Date: 2026-05-09
|
||||
|
||||
## Background
|
||||
|
||||
When a member @mentions an agent on an issue (or a member comments on an
|
||||
issue assigned to an agent), the trigger path enqueues an `agent_task_queue`
|
||||
row. Today both paths short-circuit when the same agent already has a
|
||||
`queued` or `dispatched` task on the same issue:
|
||||
|
||||
- `server/internal/handler/comment.go` `enqueueMentionedAgentTasks` — @mention
|
||||
trigger
|
||||
- `server/internal/handler/issue.go` `shouldEnqueueOnComment` — assignee-on-
|
||||
comment trigger
|
||||
|
||||
Both call `Queries.HasPendingTaskForIssueAndAgent` and skip enqueue when it
|
||||
returns true. The intent was a coalescing queue: rapid-fire comments fold
|
||||
into a single pending task, and when that task picks up it reads all the
|
||||
latest comments anyway.
|
||||
|
||||
## Problem
|
||||
|
||||
The coalescing model has three user-visible costs:
|
||||
|
||||
1. **No UI feedback for the merged comment.** A second @mention does not
|
||||
create a task, so no queued banner appears and there is no toast saying
|
||||
"merged into pending task". Users perceive the @mention as lost.
|
||||
2. **Trigger comment provenance is lost.** Only the first trigger comment
|
||||
is recorded on the task; subsequent triggers are not referenced by any
|
||||
task. Auditing "what made this run happen" fails.
|
||||
3. **Distinct intents collapse.** When two @mentions live in different
|
||||
threads with different requests ("add a test" vs. "fix copy"), folding
|
||||
them into one task forces the agent to disambiguate, and the user cannot
|
||||
cancel one without cancelling both.
|
||||
|
||||
Different threads and different mention text are strong signals that the
|
||||
two triggers are distinct intents — coalescing throws that signal away.
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopt option C: every @mention or assignee-comment trigger creates its
|
||||
own task.** No `(issue, agent)` dedup at enqueue time.
|
||||
|
||||
Per-(issue, agent) execution stays serial because `ClaimAgentTask`
|
||||
(`server/pkg/db/queries/agent.sql`) refuses to dispatch a queued row when
|
||||
the same agent has another `dispatched` or `running` row on the same
|
||||
issue. Multiple queued rows pile up safely and drain in
|
||||
`(priority DESC, created_at ASC)` order. This is a coordination-side
|
||||
property — `FOR UPDATE SKIP LOCKED` locks the row being claimed, not the
|
||||
`(issue, agent)` key — and relies on the daemon today never invoking
|
||||
`ClaimAgentTask` concurrently for the same agent. Tightening that into a
|
||||
real DB-level guarantee (e.g. an advisory lock keyed on `(issue, agent)`)
|
||||
is out of scope for this RFC.
|
||||
|
||||
### Mutual exclusion between on_comment and @mention paths
|
||||
|
||||
Without `(issue, agent)` dedup at enqueue time, a single member comment
|
||||
that @mentions the assignee would otherwise enqueue twice with identical
|
||||
`trigger_comment_id`: once via the on_comment path
|
||||
(`shouldEnqueueOnComment` → `EnqueueTaskForIssue`) and once via the
|
||||
@mention path (`enqueueMentionedAgentTasks` → `EnqueueTaskForMention`).
|
||||
Same trick applies to a plain reply that inherits the assignee mention
|
||||
from the thread root.
|
||||
|
||||
The on_comment gate gains a `commentMentionsAssignee` clause that uses the
|
||||
same effective-mention computation as the @mention path
|
||||
(`shouldInheritParentMentions` for inheritance). When the @mention path
|
||||
will enqueue for the assignee, on_comment skips. The two paths become
|
||||
mutually exclusive on a `(comment, assignee)` pair.
|
||||
|
||||
### Considered alternatives
|
||||
|
||||
- **A. Keep coalescing, add a UI hint** ("merged into pending task"). Fixes
|
||||
visibility but not provenance and not the distinct-intent case.
|
||||
- **B. Allow up to N queued tasks per (issue, agent), coalesce above N.**
|
||||
Combines the worst of both — still loses the Nth+1 trigger comment, and
|
||||
introduces a magic number.
|
||||
- **C. No dedup, every trigger creates a task. (Chosen.)**
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **True rapid-fire duplicate suppression.** A user double-clicks @ within
|
||||
a second; both create tasks and the agent runs twice on identical
|
||||
context. Acceptable cost — the agent reads its own previous comment in
|
||||
the second run and can early-exit. We may revisit by having the
|
||||
scheduler skip a queued task when "no relevant comments since the
|
||||
previous task for this (issue, agent) completed", but that is a
|
||||
follow-up, not a blocker for this RFC.
|
||||
- **Cross-agent dedup.** Different agents on the same issue continue to
|
||||
run in parallel; nothing changes there.
|
||||
- **Queued banner UI.** Already shipped in
|
||||
[MUL-1897](mention://issue/14fdefb4-3a36-4406-a840-1f6700ac95b5).
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Remove the `HasPendingTaskForIssueAndAgent` short-circuit in
|
||||
`enqueueMentionedAgentTasks`.
|
||||
2. Remove the `HasPendingTaskForIssueAndAgent` short-circuit in
|
||||
`shouldEnqueueOnComment`. The function reduces to the
|
||||
assignee-readiness check (`isAgentAssigneeReady` + non-backlog status).
|
||||
3. Add the `commentMentionsAssignee` clause to the on_comment gate so
|
||||
the on_comment and @mention paths are mutually exclusive on a
|
||||
`(comment, assignee)` pair (see "Mutual exclusion" above).
|
||||
4. Drop `HasPendingTaskForIssueAndAgent` and the unused
|
||||
`HasPendingTaskForIssue` from `server/pkg/db/queries/agent.sql`. Re-run
|
||||
`make sqlc`.
|
||||
5. Tests:
|
||||
- `TestRepeatedMentionsEnqueueSeparateTasks` — two @mentions on an
|
||||
unassigned issue produce two `queued` rows with distinct
|
||||
`trigger_comment_id` values.
|
||||
- `TestAssigneeMentionDoesNotDoubleEnqueue` — a member comment that
|
||||
@mentions the assignee on an assigned issue produces exactly one
|
||||
`queued` row (the mention path), not two.
|
||||
6. No migration needed. No frontend changes needed: the queued banner
|
||||
already aggregates over `ListActiveTasksByIssue`, so multiple queued
|
||||
rows render correctly.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Cost:** users who comment frequently on agent-assigned issues will
|
||||
trigger more runs than today. Mitigated by per-(issue, agent) serial
|
||||
execution — no extra concurrency, just more sequential work — and by
|
||||
the future "skip if no relevant comments" optimization noted above.
|
||||
- **Replay:** the second queued task reads issue state that the first
|
||||
task may have already addressed. Agents already need to read recent
|
||||
comments and judge whether work is still required; this RFC does not
|
||||
change that contract.
|
||||
@@ -13,8 +13,7 @@
|
||||
"test": "turbo test",
|
||||
"lint": "turbo lint",
|
||||
"clean": "turbo clean && rm -rf node_modules",
|
||||
"ui:add": "cd packages/ui && npx shadcn@latest add",
|
||||
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"
|
||||
"ui:add": "cd packages/ui && npx shadcn@latest add"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"pnpm": {
|
||||
|
||||
@@ -45,33 +45,20 @@ describe("initAnalytics super-properties", () => {
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits app_version when not provided", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "web" });
|
||||
});
|
||||
|
||||
it("detects desktop when window.electron is present", async () => {
|
||||
vi.stubGlobal("window", { electron: {} });
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "desktop",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
expect(posthog.register).toHaveBeenCalledWith({ client_type: "desktop" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,9 +76,6 @@ describe("resetAnalytics", () => {
|
||||
expect(posthog.register).toHaveBeenCalledWith({
|
||||
client_type: "web",
|
||||
app_version: "1.2.3",
|
||||
environment: "dev",
|
||||
event_schema_version: 2,
|
||||
is_demo: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const EVENT_SCHEMA_VERSION = 2;
|
||||
|
||||
const SIGNUP_SOURCE_COOKIE = "multica_signup_source";
|
||||
// Per-value cap keeps a long utm_content from blowing the budget. We drop
|
||||
// the entire cookie if the JSON still exceeds the overall limit — partial
|
||||
@@ -36,8 +34,6 @@ let initialized = false;
|
||||
// most recent pending identify (only one matters, since it's per-session)
|
||||
// and flush it inside initAnalytics.
|
||||
let pendingIdentify: { userId: string; props?: Record<string, unknown> } | null = null;
|
||||
let currentUserId: string | null = null;
|
||||
let analyticsEnvironment = "dev";
|
||||
// Likewise pageviews: the initial "/" pageview is the anchor of the
|
||||
// acquisition funnel, and the Next.js router fires it on mount before the
|
||||
// config fetch resolves. We keep the first pending pageview so that step
|
||||
@@ -82,7 +78,6 @@ export interface AnalyticsConfig {
|
||||
* available.
|
||||
*/
|
||||
appVersion?: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
export type ClientType = "desktop" | "web";
|
||||
@@ -140,7 +135,6 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
analyticsEnvironment = normalizeEnvironment(config.environment);
|
||||
// Register super-properties — attached to every event emitted from this
|
||||
// client. `client_type` is the canonical split between desktop and web
|
||||
// (PostHog's own `$lib` reports "web" for both because Electron renderers
|
||||
@@ -148,19 +142,13 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
// builds without a version don't pollute the property.
|
||||
// We cache the set so resetAnalytics() can re-apply it after
|
||||
// posthog.reset() — reset() clears persisted super-properties otherwise.
|
||||
superProperties = {
|
||||
client_type: detectClientType(),
|
||||
event_schema_version: EVENT_SCHEMA_VERSION,
|
||||
environment: analyticsEnvironment,
|
||||
is_demo: false,
|
||||
};
|
||||
superProperties = { client_type: detectClientType() };
|
||||
if (config.appVersion) superProperties.app_version = config.appVersion;
|
||||
posthog.register(superProperties);
|
||||
initialized = true;
|
||||
|
||||
// Flush any identify() that arrived before init resolved.
|
||||
if (pendingIdentify) {
|
||||
currentUserId = pendingIdentify.userId;
|
||||
posthog.identify(pendingIdentify.userId, pendingIdentify.props);
|
||||
pendingIdentify = null;
|
||||
}
|
||||
@@ -176,7 +164,7 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
while (pendingOps.length > 0) {
|
||||
const op = pendingOps.shift()!;
|
||||
if (op.kind === "event") {
|
||||
posthog.capture(op.name, withClientEventProperties(op.props));
|
||||
posthog.capture(op.name, op.props);
|
||||
} else {
|
||||
capturePersonSet(op.props);
|
||||
}
|
||||
@@ -194,7 +182,6 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
* config and user in parallel, so identify can arrive first.
|
||||
*/
|
||||
export function identify(userId: string, userProperties?: Record<string, unknown>): void {
|
||||
currentUserId = userId;
|
||||
if (!initialized) {
|
||||
pendingIdentify = { userId, props: userProperties };
|
||||
return;
|
||||
@@ -207,7 +194,6 @@ export function identify(userId: string, userProperties?: Record<string, unknown
|
||||
* and doesn't bleed the previous user's events into a new session.
|
||||
*/
|
||||
export function resetAnalytics(): void {
|
||||
currentUserId = null;
|
||||
pendingIdentify = null;
|
||||
pendingPageview = null;
|
||||
pendingOps.length = 0;
|
||||
@@ -239,7 +225,7 @@ export function captureEvent(
|
||||
pendingOps.push({ kind: "event", name, props });
|
||||
return;
|
||||
}
|
||||
posthog.capture(name, withClientEventProperties(props));
|
||||
posthog.capture(name, props);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,43 +253,6 @@ function capturePersonSet(props: Record<string, unknown>): void {
|
||||
posthog.capture("$set", { $set: props });
|
||||
}
|
||||
|
||||
function withClientEventProperties(
|
||||
props?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = { ...(props ?? {}) };
|
||||
if (currentUserId && next.user_id === undefined) {
|
||||
next.user_id = currentUserId;
|
||||
}
|
||||
if (next.event_schema_version === undefined) {
|
||||
next.event_schema_version = EVENT_SCHEMA_VERSION;
|
||||
}
|
||||
if (next.environment === undefined) {
|
||||
next.environment = analyticsEnvironment;
|
||||
}
|
||||
if (next.is_demo === undefined) {
|
||||
next.is_demo = false;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeEnvironment(value: string | undefined): string {
|
||||
switch ((value || "").trim().toLowerCase()) {
|
||||
case "production":
|
||||
case "prod":
|
||||
return "production";
|
||||
case "staging":
|
||||
case "stage":
|
||||
return "staging";
|
||||
case "development":
|
||||
case "dev":
|
||||
case "test":
|
||||
case "local":
|
||||
return "dev";
|
||||
default:
|
||||
return "dev";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture a page view. Call once per client-side navigation. We disable
|
||||
* posthog's automatic pageview tracking in init() so this module owns the
|
||||
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
MemberWithUser,
|
||||
User,
|
||||
Skill,
|
||||
SkillSummary,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
SetAgentSkillsRequest,
|
||||
@@ -43,8 +42,7 @@ import type {
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
RuntimeLocalSkillImportRequest,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
TimelineEntry,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
@@ -87,16 +85,6 @@ import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
import { parseWithFallback } from "./schema";
|
||||
import {
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_TIMELINE_PAGE,
|
||||
ListIssuesResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelinePageSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
* Sent on every HTTP request as X-Client-Platform / X-Client-Version /
|
||||
@@ -334,7 +322,6 @@ export class ApiClient {
|
||||
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
@@ -409,11 +396,7 @@ export class ApiClient {
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
const path = `/api/issues?${search}`;
|
||||
const raw = await this.fetch<unknown>(path);
|
||||
return parseWithFallback(raw, ListIssuesResponseSchema, EMPTY_LIST_ISSUES_RESPONSE, {
|
||||
endpoint: "GET /api/issues",
|
||||
});
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
||||
@@ -469,10 +452,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async listChildIssues(id: string): Promise<{ issues: Issue[] }> {
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${id}/children`);
|
||||
return parseWithFallback(raw, ChildIssuesResponseSchema, { issues: [] }, {
|
||||
endpoint: "GET /api/issues/:id/children",
|
||||
});
|
||||
return this.fetch(`/api/issues/${id}/children`);
|
||||
}
|
||||
|
||||
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
||||
@@ -499,10 +479,7 @@ export class ApiClient {
|
||||
|
||||
// Comments
|
||||
async listComments(issueId: string): Promise<Comment[]> {
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments`);
|
||||
return parseWithFallback(raw, CommentsListSchema, [], {
|
||||
endpoint: "GET /api/issues/:id/comments",
|
||||
});
|
||||
return this.fetch(`/api/issues/${issueId}/comments`);
|
||||
}
|
||||
|
||||
async createComment(issueId: string, content: string, type?: string, parentId?: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
@@ -517,22 +494,8 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listTimeline(
|
||||
issueId: string,
|
||||
pageParam: TimelinePageParam = { mode: "latest" },
|
||||
limit = 50,
|
||||
): Promise<TimelinePage> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(limit));
|
||||
if (pageParam.mode === "before") params.set("before", pageParam.cursor);
|
||||
else if (pageParam.mode === "after") params.set("after", pageParam.cursor);
|
||||
else if (pageParam.mode === "around") params.set("around", pageParam.id);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/issues/${issueId}/timeline?${params.toString()}`,
|
||||
);
|
||||
return parseWithFallback(raw, TimelinePageSchema, EMPTY_TIMELINE_PAGE, {
|
||||
endpoint: "GET /api/issues/:id/timeline",
|
||||
});
|
||||
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
|
||||
return this.fetch(`/api/issues/${issueId}/timeline`);
|
||||
}
|
||||
|
||||
async getAssigneeFrequency(): Promise<AssigneeFrequencyEntry[]> {
|
||||
@@ -550,14 +513,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async resolveComment(commentId: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "POST" });
|
||||
}
|
||||
|
||||
async unresolveComment(commentId: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}/resolve`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async addReaction(commentId: string, emoji: string): Promise<Reaction> {
|
||||
return this.fetch(`/api/comments/${commentId}/reactions`, {
|
||||
method: "POST",
|
||||
@@ -588,10 +543,7 @@ export class ApiClient {
|
||||
|
||||
// Subscribers
|
||||
async listIssueSubscribers(issueId: string): Promise<IssueSubscriber[]> {
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/subscribers`);
|
||||
return parseWithFallback(raw, SubscribersListSchema, [], {
|
||||
endpoint: "GET /api/issues/:id/subscribers",
|
||||
});
|
||||
return this.fetch(`/api/issues/${issueId}/subscribers`);
|
||||
}
|
||||
|
||||
async subscribeToIssue(issueId: string, userId?: string, userType?: string): Promise<void> {
|
||||
@@ -855,7 +807,6 @@ export class ApiClient {
|
||||
google_client_id?: string;
|
||||
posthog_key?: string;
|
||||
posthog_host?: string;
|
||||
analytics_environment?: string;
|
||||
}> {
|
||||
return this.fetch("/api/config");
|
||||
}
|
||||
@@ -952,7 +903,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// Skills
|
||||
async listSkills(): Promise<SkillSummary[]> {
|
||||
async listSkills(): Promise<Skill[]> {
|
||||
return this.fetch("/api/skills");
|
||||
}
|
||||
|
||||
@@ -985,7 +936,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAgentSkills(agentId: string): Promise<SkillSummary[]> {
|
||||
async listAgentSkills(agentId: string): Promise<Skill[]> {
|
||||
return this.fetch(`/api/agents/${agentId}/skills`);
|
||||
}
|
||||
|
||||
@@ -1058,7 +1009,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteChatSession(id: string): Promise<void> {
|
||||
async archiveChatSession(id: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ export type {
|
||||
ImportStarterIssuePayload,
|
||||
ImportStarterWelcomeIssueTemplate,
|
||||
} from "./client";
|
||||
export { parseWithFallback, setSchemaLogger } from "./schema";
|
||||
export type { ParseOptions } from "./schema";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ApiClient } from "./client";
|
||||
import { parseWithFallback } from "./schema";
|
||||
|
||||
// Helper: stub fetch with a single JSON response. Status defaults to 200.
|
||||
function stubFetchJson(body: unknown, status = 200) {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// These tests cover the five failure modes that white-screened the desktop
|
||||
// app in past incidents. The contract is: a malformed response degrades to
|
||||
// an empty/safe shape, never throws into React.
|
||||
describe("ApiClient schema fallback", () => {
|
||||
describe("listTimeline", () => {
|
||||
it("falls back to an empty page when required fields are missing", async () => {
|
||||
stubFetchJson({});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page).toEqual({
|
||||
entries: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when a field has the wrong type", async () => {
|
||||
stubFetchJson({
|
||||
entries: "not-an-array",
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toEqual([]);
|
||||
expect(page.has_more_after).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a new entry type rather than crashing on enum drift", async () => {
|
||||
stubFetchJson({
|
||||
entries: [
|
||||
{
|
||||
type: "future_kind", // not in TS union
|
||||
id: "e-1",
|
||||
actor_type: "member",
|
||||
actor_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toHaveLength(1);
|
||||
expect(page.entries[0]?.type).toBe("future_kind");
|
||||
});
|
||||
|
||||
// Forward-compat: when the server adds a new field to an existing
|
||||
// shape, `.loose()` lets it pass through unchanged. Without `.loose()`
|
||||
// zod 4 strips it, which would silently break a future TS type that
|
||||
// adopts the field — see schemas.ts header comment.
|
||||
it("preserves unknown fields the schema didn't list", async () => {
|
||||
stubFetchJson({
|
||||
entries: [
|
||||
{
|
||||
type: "comment",
|
||||
id: "e-1",
|
||||
actor_type: "member",
|
||||
actor_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
// New server-side field not present in TimelineEntrySchema:
|
||||
future_field: { nested: "value" },
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
// New top-level field not present in TimelinePageSchema:
|
||||
page_metadata: { took_ms: 42 },
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
const raw = page as unknown as Record<string, unknown>;
|
||||
const entry = page.entries[0] as unknown as Record<string, unknown>;
|
||||
expect(entry.future_field).toEqual({ nested: "value" });
|
||||
expect(raw.page_metadata).toEqual({ took_ms: 42 });
|
||||
});
|
||||
|
||||
it("returns an empty page when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats null arrays as empty arrays", async () => {
|
||||
stubFetchJson({
|
||||
entries: null,
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listIssues", () => {
|
||||
it("falls back to an empty list when the response is malformed", async () => {
|
||||
// `issues` having the wrong type triggers the fallback. An object
|
||||
// with only unexpected keys would *succeed* parsing now (every
|
||||
// declared field has a default) and just pass the extras through
|
||||
// via `.loose()`, so we use a wrong-type payload here instead.
|
||||
stubFetchJson({ issues: "not-an-array", total: 0 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listIssues();
|
||||
expect(res).toEqual({ issues: [], total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listComments", () => {
|
||||
it("returns [] when the response is not an array", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const comments = await client.listComments("issue-1");
|
||||
expect(comments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listIssueSubscribers", () => {
|
||||
it("returns [] when the response is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const subs = await client.listIssueSubscribers("issue-1");
|
||||
expect(subs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listChildIssues", () => {
|
||||
it("returns { issues: [] } when the issues field is missing", async () => {
|
||||
stubFetchJson({});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listChildIssues("issue-1");
|
||||
expect(res).toEqual({ issues: [] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Direct tests for the helper, decoupled from any specific endpoint —
|
||||
// guards against an endpoint refactor masking a regression in the helper.
|
||||
describe("parseWithFallback", () => {
|
||||
const opts = { endpoint: "TEST /unit" };
|
||||
|
||||
it("returns parsed data on success", () => {
|
||||
const schema = z.object({ id: z.string() });
|
||||
const out = parseWithFallback({ id: "x" }, schema, { id: "fallback" }, opts);
|
||||
expect(out).toEqual({ id: "x" });
|
||||
});
|
||||
|
||||
it("returns the fallback when validation fails", () => {
|
||||
const schema = z.object({ id: z.string() });
|
||||
const fallback = { id: "fallback" };
|
||||
const out = parseWithFallback({ id: 123 }, schema, fallback, opts);
|
||||
expect(out).toBe(fallback);
|
||||
});
|
||||
|
||||
it("returns the fallback when data is null", () => {
|
||||
const schema = z.object({ id: z.string() });
|
||||
const fallback = { id: "fallback" };
|
||||
const out = parseWithFallback(null, schema, fallback, opts);
|
||||
expect(out).toBe(fallback);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
// Module-level logger for schema warnings. Defaults to no-op so test
|
||||
// runs don't spam stderr; the platform layer wires a real logger via
|
||||
// `setSchemaLogger` at app boot.
|
||||
let schemaLogger: Logger = noopLogger;
|
||||
|
||||
export function setSchemaLogger(logger: Logger): void {
|
||||
schemaLogger = logger;
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
/** Endpoint identifier used in the warning log so we can grep for which
|
||||
* contract drifted in production telemetry. */
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a JSON value parsed from an API response against a zod schema,
|
||||
* returning the parsed value on success or `fallback` on failure.
|
||||
*
|
||||
* On failure we log a warning with the endpoint and zod's structured error,
|
||||
* but never throw — the UI layer must keep rendering. This is the boundary
|
||||
* defense that turns "API contract drifted" from a white-screen incident
|
||||
* into a degraded-but-rendering page.
|
||||
*
|
||||
* The return type is anchored to `T` (inferred from `fallback`), not to the
|
||||
* schema's `z.infer` type. Schemas are intentionally **lenient** — string
|
||||
* enums kept as `z.string()` so an unknown enum value still parses, etc. —
|
||||
* so the parsed runtime value can be wider than the strict TS type at the
|
||||
* call site. The caller asserts compatibility by typing the fallback to the
|
||||
* expected `T`; downstream code is already responsible for handling unknown
|
||||
* enum values via `default`-bearing switches and optional chaining.
|
||||
*
|
||||
* See CLAUDE.md "API Response Compatibility" for when to reach for this.
|
||||
*/
|
||||
export function parseWithFallback<T>(
|
||||
data: unknown,
|
||||
schema: ZodType,
|
||||
fallback: T,
|
||||
opts: ParseOptions,
|
||||
): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) return result.data as T;
|
||||
schemaLogger.warn(
|
||||
`API response failed schema validation: ${opts.endpoint}`,
|
||||
{
|
||||
endpoint: opts.endpoint,
|
||||
issues: result.error.issues,
|
||||
received: data,
|
||||
},
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import type { ListIssuesResponse, TimelinePage } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
// the issue detail page (timeline, comments, subscribers) and the issues
|
||||
// list. These are the surfaces that white-screened in #2143 / #2147 / #2192.
|
||||
//
|
||||
// These schemas are intentionally LENIENT:
|
||||
// - String enums are stored as `z.string()` rather than `z.enum([...])`.
|
||||
// A new server-side enum value should render as a generic fallback in
|
||||
// the UI, never crash a `safeParse`.
|
||||
// - Optional fields are unioned with `null` and given fallbacks where
|
||||
// existing UI code already coerces them.
|
||||
// - Arrays default to `[]` so a missing `reactions` / `attachments` /
|
||||
// `entries` field doesn't take the page down.
|
||||
// - Every object schema ends with `.loose()` so unknown server-side
|
||||
// fields pass through unchanged. zod 4's `.object()` defaults to STRIP,
|
||||
// which would silently delete fields the schema didn't explicitly list
|
||||
// — fine while the TS type doesn't claim them, but the moment a future
|
||||
// PR adds a TS field without updating the schema, the cast `as T` lies
|
||||
// and the field shows up as `undefined` at runtime. `.loose()` removes
|
||||
// that synchronisation hazard.
|
||||
//
|
||||
// These schemas are deliberately not typed as `z.ZodType<TimelineEntry>` /
|
||||
// `z.ZodType<Issue>` etc. — the strict TS types narrow string fields to
|
||||
// literal unions, which would defeat the leniency above. `parseWithFallback`
|
||||
// returns the parsed value cast to the caller-supplied `T`, so the strict
|
||||
// type still flows out at the call site; the schema only guards shape.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ReactionSchema = z.object({
|
||||
id: z.string(),
|
||||
comment_id: z.string(),
|
||||
actor_type: z.string(),
|
||||
actor_id: z.string(),
|
||||
emoji: z.string(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
const AttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// All object schemas use `.loose()` so unknown server-side fields pass
|
||||
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
||||
// silently drop new fields and surface as a "field neither showed up in
|
||||
// the UI" mystery the next time the TS type adopted them but the schema
|
||||
// wasn't updated in lock-step. `.loose()` removes that synchronisation
|
||||
// hazard — the schema validates the shape it knows about and leaves the
|
||||
// rest alone.
|
||||
const TimelineEntrySchema = z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
actor_type: z.string(),
|
||||
actor_id: z.string(),
|
||||
created_at: z.string(),
|
||||
action: z.string().optional(),
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
content: z.string().optional(),
|
||||
parent_id: z.string().nullable().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
comment_type: z.string().optional(),
|
||||
reactions: z.array(ReactionSchema).optional(),
|
||||
attachments: z.array(AttachmentSchema).optional(),
|
||||
coalesced_count: z.number().optional(),
|
||||
}).loose();
|
||||
|
||||
export const TimelinePageSchema = z.object({
|
||||
entries: z.array(TimelineEntrySchema).default([]),
|
||||
next_cursor: z.string().nullable().default(null),
|
||||
prev_cursor: z.string().nullable().default(null),
|
||||
has_more_before: z.boolean().default(false),
|
||||
has_more_after: z.boolean().default(false),
|
||||
target_index: z.number().optional(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_TIMELINE_PAGE: TimelinePage = {
|
||||
entries: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
};
|
||||
|
||||
export const CommentSchema = z.object({
|
||||
id: z.string(),
|
||||
issue_id: z.string(),
|
||||
author_type: z.string(),
|
||||
author_id: z.string(),
|
||||
content: z.string(),
|
||||
type: z.string(),
|
||||
parent_id: z.string().nullable(),
|
||||
reactions: z.array(ReactionSchema).default([]),
|
||||
attachments: z.array(AttachmentSchema).default([]),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CommentsListSchema = z.array(CommentSchema);
|
||||
|
||||
const IssueSchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
number: z.number(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
status: z.string(),
|
||||
priority: z.string(),
|
||||
assignee_type: z.string().nullable(),
|
||||
assignee_id: z.string().nullable(),
|
||||
creator_type: z.string(),
|
||||
creator_id: z.string(),
|
||||
parent_issue_id: z.string().nullable(),
|
||||
project_id: z.string().nullable(),
|
||||
position: z.number(),
|
||||
due_date: z.string().nullable(),
|
||||
reactions: z.array(z.unknown()).optional(),
|
||||
labels: z.array(z.unknown()).optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const ListIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
||||
issues: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const SubscriberSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
user_type: z.string(),
|
||||
user_id: z.string(),
|
||||
reason: z.string(),
|
||||
created_at: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const SubscribersListSchema = z.array(SubscriberSchema);
|
||||
|
||||
export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
@@ -15,7 +15,6 @@
|
||||
export function sanitizeNextUrl(raw: string | null): string | null {
|
||||
if (!raw) return null;
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return null;
|
||||
// eslint-disable-next-line no-control-regex -- intentional: rejecting control chars is the whole point
|
||||
if (/[\x00-\x1f\\]/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,14 @@ export function useCreateChatSession() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached list so the FAB badge drops
|
||||
* has_unread to false in the cached lists so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
@@ -45,58 +46,68 @@ export function useMarkChatSessionRead() {
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
const clear = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
|
||||
|
||||
return { prevSessions };
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from the
|
||||
* sessions list so the dropdown updates instantly; rolls back on error.
|
||||
* The matching `chat:session_deleted` WS event keeps other tabs/devices
|
||||
* in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useDeleteChatSession() {
|
||||
export function useArchiveChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("deleteChatSession.start", { sessionId });
|
||||
return api.deleteChatSession(sessionId);
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return api.archiveChatSession(sessionId);
|
||||
},
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
const drop = (old?: ChatSession[]) => old?.filter((s) => s.id !== sessionId);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), drop);
|
||||
// Optimistic: remove from active, mark as archived in allSessions
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
||||
old ? old.filter((s) => s.id !== sessionId) : old,
|
||||
);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
|
||||
old?.map((s) =>
|
||||
s.id === sessionId ? { ...s, status: "archived" as const } : s,
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug("deleteChatSession.optimistic", { sessionId });
|
||||
return { prevSessions };
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("deleteChatSession.error.rollback", { sessionId, err });
|
||||
logger.error("archiveChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("deleteChatSession.settled", { sessionId });
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import { api } from "../api";
|
||||
|
||||
export const chatKeys = {
|
||||
all: (wsId: string) => ["chat", wsId] as const,
|
||||
/** Full sessions list (active + archived); the dropdown splits locally. */
|
||||
sessions: (wsId: string) => [...chatKeys.all(wsId), "sessions"] as const,
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
@@ -24,6 +24,14 @@ export const chatKeys = {
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.sessions(wsId),
|
||||
queryFn: () => api.listChatSessions(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
export function allChatSessionsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.allSessions(wsId),
|
||||
queryFn: () => api.listChatSessions({ status: "all" }),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface ChatState {
|
||||
isOpen: boolean;
|
||||
activeSessionId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, string>;
|
||||
/**
|
||||
@@ -103,6 +104,7 @@ export interface ChatState {
|
||||
toggle: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
@@ -134,6 +136,7 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
isOpen: initialIsOpen,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_KEY)),
|
||||
focusMode: storage.getItem(FOCUS_MODE_KEY) === "true",
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
@@ -164,6 +167,10 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import type { LocaleAdapter } from "./types";
|
||||
|
||||
const LocaleAdapterContext = createContext<LocaleAdapter | null>(null);
|
||||
|
||||
export function LocaleAdapterProvider({
|
||||
adapter,
|
||||
children,
|
||||
}: {
|
||||
adapter: LocaleAdapter;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LocaleAdapterContext.Provider value={adapter}>
|
||||
{children}
|
||||
</LocaleAdapterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocaleAdapter(): LocaleAdapter {
|
||||
const ctx = useContext(LocaleAdapterContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useLocaleAdapter must be used within <LocaleAdapterProvider>",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LOCALE_COOKIE,
|
||||
createBrowserCookieLocaleAdapter,
|
||||
} from "./browser-cookie-adapter";
|
||||
|
||||
function clearCookies() {
|
||||
document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim().split("=")[0])
|
||||
.filter(Boolean)
|
||||
.forEach((name) => {
|
||||
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
||||
});
|
||||
}
|
||||
|
||||
describe("createBrowserCookieLocaleAdapter", () => {
|
||||
beforeEach(clearCookies);
|
||||
afterEach(clearCookies);
|
||||
|
||||
it("getUserChoice returns null when no cookie is set", () => {
|
||||
const adapter = createBrowserCookieLocaleAdapter();
|
||||
expect(adapter.getUserChoice()).toBe(null);
|
||||
});
|
||||
|
||||
it("getUserChoice round-trips a persisted value", () => {
|
||||
const adapter = createBrowserCookieLocaleAdapter();
|
||||
adapter.persist("zh-Hans");
|
||||
expect(adapter.getUserChoice()).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("getUserChoice decodes URI-encoded cookie values", () => {
|
||||
document.cookie = `${LOCALE_COOKIE}=${encodeURIComponent("zh-Hans")}; path=/`;
|
||||
expect(createBrowserCookieLocaleAdapter().getUserChoice()).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("getUserChoice ignores unrelated cookies that share a prefix", () => {
|
||||
document.cookie = `${LOCALE_COOKIE}-other=should-not-match; path=/`;
|
||||
expect(createBrowserCookieLocaleAdapter().getUserChoice()).toBe(null);
|
||||
});
|
||||
|
||||
it("persist writes a cookie with SameSite=Lax", () => {
|
||||
const adapter = createBrowserCookieLocaleAdapter();
|
||||
adapter.persist("en");
|
||||
expect(document.cookie).toContain(`${LOCALE_COOKIE}=en`);
|
||||
});
|
||||
|
||||
it("getSystemPreferences mirrors navigator.languages", () => {
|
||||
Object.defineProperty(navigator, "languages", {
|
||||
value: ["zh-Hans-CN", "en-US"],
|
||||
configurable: true,
|
||||
});
|
||||
expect(createBrowserCookieLocaleAdapter().getSystemPreferences()).toEqual([
|
||||
"zh-Hans-CN",
|
||||
"en-US",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { LocaleAdapter, SupportedLocale } from "./types";
|
||||
|
||||
export const LOCALE_COOKIE = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365;
|
||||
|
||||
// Web-only adapter: persists via document.cookie so the Next.js proxy can
|
||||
// read the active locale on the next request. Desktop has no server-side
|
||||
// proxy and must use createDesktopLocaleAdapter (apps/desktop/.../i18n-adapter)
|
||||
// which persists via window.localStorage instead.
|
||||
export function createBrowserCookieLocaleAdapter(): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice() {
|
||||
if (typeof document === "undefined") return null;
|
||||
const m = document.cookie.match(
|
||||
new RegExp(`(?:^|;\\s*)${LOCALE_COOKIE}=([^;]+)`),
|
||||
);
|
||||
const value = m?.[1];
|
||||
return value ? decodeURIComponent(value) : null;
|
||||
},
|
||||
getSystemPreferences() {
|
||||
if (typeof navigator === "undefined") return [];
|
||||
return [...navigator.languages];
|
||||
},
|
||||
persist(locale: SupportedLocale) {
|
||||
if (typeof document === "undefined") return;
|
||||
const secure =
|
||||
typeof location !== "undefined" && location.protocol === "https:"
|
||||
? ";Secure"
|
||||
: "";
|
||||
document.cookie =
|
||||
`${LOCALE_COOKIE}=${encodeURIComponent(locale)};` +
|
||||
`path=/;max-age=${COOKIE_MAX_AGE};SameSite=Lax${secure}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Browser-only entry: anything that touches `document` / `navigator` /
|
||||
// `window` lives here, so accidental imports from RSC, Edge middleware, or
|
||||
// nodejs proxy.ts crash at the import site instead of at first call.
|
||||
//
|
||||
// `LOCALE_COOKIE` (a plain string constant) is also exported from the
|
||||
// server-safe `@multica/core/i18n` entry — proxy.ts needs it to read the
|
||||
// cookie from a NextRequest. Only the adapter factory is browser-restricted.
|
||||
export { createBrowserCookieLocaleAdapter } from "./browser-cookie-adapter";
|
||||
@@ -1,23 +0,0 @@
|
||||
import i18next, { type i18n as I18n } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import type { LocaleResources, SupportedLocale } from "./types";
|
||||
|
||||
// Both server (RSC) and client must call this with the SAME locale + resources
|
||||
// to avoid hydration mismatch. `initAsync: false` forces synchronous init
|
||||
// (renamed from `initImmediate` in i18next v25+); `useSuspense: false`
|
||||
// prevents fallback rendering during hydration.
|
||||
export function createI18n(
|
||||
locale: SupportedLocale,
|
||||
resources: Record<string, LocaleResources>,
|
||||
): I18n {
|
||||
const instance = i18next.createInstance();
|
||||
instance.use(initReactI18next).init({
|
||||
lng: locale,
|
||||
fallbackLng: "en",
|
||||
resources,
|
||||
interpolation: { escapeValue: false },
|
||||
initAsync: false,
|
||||
react: { useSuspense: false },
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// Server-safe i18n entry: zero React imports + zero DOM/document/navigator
|
||||
// access anywhere in this transitive graph. Safe to import from proxy.ts /
|
||||
// RSC / Edge / nodejs middleware.
|
||||
//
|
||||
// React-side helpers (I18nProvider, useLocaleAdapter, createI18n) live in
|
||||
// "@multica/core/i18n/react" — split because Next.js gives RSC a vendored
|
||||
// React build that lacks createContext, and react-i18next's top-level
|
||||
// React.createContext() call would crash any non-client load of this file.
|
||||
//
|
||||
// Browser-only helpers (createBrowserCookieLocaleAdapter) live in
|
||||
// "@multica/core/i18n/browser" — they read document.cookie / navigator.languages
|
||||
// at construction time and would crash in any non-DOM context.
|
||||
export type {
|
||||
LocaleAdapter,
|
||||
LocaleResources,
|
||||
SupportedLocale,
|
||||
} from "./types";
|
||||
export { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "./types";
|
||||
export { matchLocale, pickLocale } from "./pick-locale";
|
||||
export { LOCALE_COOKIE } from "./browser-cookie-adapter";
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { matchLocale, pickLocale } from "./pick-locale";
|
||||
import type { LocaleAdapter } from "./types";
|
||||
|
||||
function makeAdapter(
|
||||
overrides: Partial<LocaleAdapter> = {},
|
||||
): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice: () => null,
|
||||
getSystemPreferences: () => [],
|
||||
persist: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matchLocale", () => {
|
||||
it("returns DEFAULT_LOCALE when given an empty list", () => {
|
||||
expect(matchLocale([])).toBe("en");
|
||||
});
|
||||
|
||||
it("matches a clean supported tag", () => {
|
||||
expect(matchLocale(["zh-Hans"])).toBe("zh-Hans");
|
||||
expect(matchLocale(["en"])).toBe("en");
|
||||
});
|
||||
|
||||
it("collapses region-tagged BCP-47 to the supported base", () => {
|
||||
expect(matchLocale(["en-US"])).toBe("en");
|
||||
expect(matchLocale(["zh-Hans-CN"])).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("falls back to DEFAULT_LOCALE when no candidate matches", () => {
|
||||
expect(matchLocale(["fr", "ja", "ko"])).toBe("en");
|
||||
});
|
||||
|
||||
it("zh-Hant (traditional) collapses to zh-Hans — same base subtag, better UX than English fallback", () => {
|
||||
expect(matchLocale(["zh-Hant"])).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("uses the first supported candidate when multiple appear", () => {
|
||||
expect(matchLocale(["fr", "zh-Hans", "en"])).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("returns DEFAULT_LOCALE for malformed BCP-47 tags rather than throwing", () => {
|
||||
expect(matchLocale(["----"])).toBe("en");
|
||||
expect(matchLocale(["x-private-only"])).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickLocale", () => {
|
||||
it("prefers explicit user choice over system signal", () => {
|
||||
const adapter = makeAdapter({
|
||||
getUserChoice: () => "zh-Hans",
|
||||
getSystemPreferences: () => ["en-US"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("falls back to system preferences when no user choice", () => {
|
||||
const adapter = makeAdapter({
|
||||
getSystemPreferences: () => ["zh-Hans-CN", "en-US"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("zh-Hans");
|
||||
});
|
||||
|
||||
it("returns DEFAULT_LOCALE when neither choice nor preference yields a match", () => {
|
||||
const adapter = makeAdapter({
|
||||
getUserChoice: () => null,
|
||||
getSystemPreferences: () => ["fr", "ja"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("en");
|
||||
});
|
||||
|
||||
it("ignores empty-string user choice and falls through to system", () => {
|
||||
const adapter = makeAdapter({
|
||||
getUserChoice: () => "",
|
||||
getSystemPreferences: () => ["zh-Hans"],
|
||||
});
|
||||
expect(pickLocale(adapter)).toBe("zh-Hans");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { match } from "@formatjs/intl-localematcher";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
SUPPORTED_LOCALES,
|
||||
type LocaleAdapter,
|
||||
type SupportedLocale,
|
||||
} from "./types";
|
||||
|
||||
export function matchLocale(candidates: string[]): SupportedLocale {
|
||||
if (candidates.length === 0) return DEFAULT_LOCALE;
|
||||
try {
|
||||
return match(
|
||||
candidates,
|
||||
SUPPORTED_LOCALES,
|
||||
DEFAULT_LOCALE,
|
||||
) as SupportedLocale;
|
||||
} catch {
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
}
|
||||
|
||||
export function pickLocale(adapter: LocaleAdapter): SupportedLocale {
|
||||
const choice = adapter.getUserChoice();
|
||||
if (choice) return matchLocale([choice]);
|
||||
return matchLocale(adapter.getSystemPreferences());
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { createI18n } from "./create-i18n";
|
||||
import type { LocaleResources, SupportedLocale } from "./types";
|
||||
|
||||
export interface I18nProviderProps {
|
||||
locale: SupportedLocale;
|
||||
resources: Record<string, LocaleResources>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function I18nProvider({
|
||||
locale,
|
||||
resources,
|
||||
children,
|
||||
}: I18nProviderProps) {
|
||||
// Lazy init via useState so the instance survives re-renders.
|
||||
// Locale + resources are determined at boot and never change at runtime —
|
||||
// language switching goes through window.location.reload().
|
||||
const [instance] = useState(() => createI18n(locale, resources));
|
||||
return <I18nextProvider i18n={instance}>{children}</I18nextProvider>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// React-only i18n entry: depends on react-i18next, which calls
|
||||
// React.createContext() at module load. Importing this from a non-client
|
||||
// context (RSC / proxy.ts) will crash with "createContext is not a function"
|
||||
// because Next.js vendors a stripped React build for those contexts.
|
||||
// Always pair with "use client" or import only inside client trees.
|
||||
export { createI18n } from "./create-i18n";
|
||||
export { I18nProvider, type I18nProviderProps } from "./provider";
|
||||
export { LocaleAdapterProvider, useLocaleAdapter } from "./adapter-context";
|
||||
export { UserLocaleSync } from "./user-locale-sync";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user