mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f47e65d215 | |||
| 6dcbd387f7 | |||
| fa58fd12a0 | |||
| 913ee4210d | |||
| 99411041b9 | |||
| 39bce329fd |
@@ -19,9 +19,23 @@ also run as full cloud automation. Every test session follows the same
|
||||
four-step contract:
|
||||
|
||||
```
|
||||
Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
Step -1: Plan approval → Step 0: Env + Auth → Step 1: Pick surface → Step 2: Run → Step 3: Structured report
|
||||
```
|
||||
|
||||
## Step -1 — Plan approval for non-trivial tests
|
||||
|
||||
Skip directly to Step 0 if: the test is a single re-run after a fix, the plan
|
||||
was already agreed on, or the user gave exact commands.
|
||||
|
||||
Otherwise, propose a test plan (surface, cases, expected evidence, assumptions)
|
||||
and use the runtime structured question tool (`request_user_input` /
|
||||
ask-user-question equivalent) with two fixed choices:
|
||||
|
||||
1. `开始执行 (Recommended)` — 测试方案没问题,开始执行
|
||||
2. `先讨论下` — 方案有问题,先讨论下
|
||||
|
||||
Wait for the user's choice before proceeding.
|
||||
|
||||
## Step 0 — Environment setup + auth check (mandatory)
|
||||
|
||||
Step 0 is about getting the environment ready: **dependencies are healthy**
|
||||
@@ -29,6 +43,36 @@ and **auth is green**. A test run that dies halfway on a missing dependency or
|
||||
a login wall wastes the whole session — clear both gates BEFORE writing a
|
||||
single test step.
|
||||
|
||||
### 0.0 Resolve the current test environment
|
||||
|
||||
Before starting a dev server, checking auth, opening agent-browser, or writing
|
||||
test steps, print and confirm the current local test environment:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/test-env.sh
|
||||
```
|
||||
|
||||
This command is the source of truth for local test ports. It reads the current
|
||||
shell plus `.env` files using the same precedence as `scripts/runWithEnv.mts`,
|
||||
then prints:
|
||||
|
||||
- `APP_URL`
|
||||
- `PORT`
|
||||
- `SERVER_URL`
|
||||
- `AUTH_TRUSTED_ORIGINS`
|
||||
- `SPA_PORT`
|
||||
- `MOBILE_SPA_PORT`
|
||||
- `DESKTOP_PORT`
|
||||
|
||||
For commands that need these values, export them from the same resolver:
|
||||
|
||||
```bash
|
||||
eval "$(./.agents/skills/agent-testing/scripts/test-env.sh --exports)"
|
||||
```
|
||||
|
||||
Do not rely on hard-coded port tables. If the printed values do not match the
|
||||
running dev server, fix/export the env first, then continue.
|
||||
|
||||
### 0.1 Dependencies are installed — root AND standalone apps
|
||||
|
||||
The root pnpm workspace does **NOT** cover every app: `pnpm-workspace.yaml`
|
||||
@@ -113,10 +157,12 @@ full-stack `dev` command so Next can proxy the SPA HTML from Vite:
|
||||
Useful subcommands:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh env # print exports
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh write # write .records/env/agent-testing-dev.env
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate # migrations only
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user # seed user + CLI API key
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash # local QStash for workflow paths
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh clean-db # remove managed DB container
|
||||
```
|
||||
|
||||
Default script env:
|
||||
@@ -125,14 +171,18 @@ Default script env:
|
||||
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
|
||||
- `DATABASE_DRIVER=node`
|
||||
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
|
||||
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
|
||||
run `init-dev-env.sh qstash` in a separate terminal when the path under test
|
||||
triggers QStash/Workflow.
|
||||
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
|
||||
- S3 mock vars
|
||||
- Managed DB container: `lobehub-agent-testing-postgres`
|
||||
|
||||
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
|
||||
onboarding already completed for manual or agent-browser checks. When running
|
||||
Cucumber against this dev server, pass the same script env into the test process
|
||||
too; Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
|
||||
onboarding already completed, plus a local API key in
|
||||
`.records/env/agent-testing-cli.env` for CLI automation. When running Cucumber
|
||||
against this dev server, pass the same script env into the test process too;
|
||||
Cucumber has its own `BeforeAll` seed path and it must see `DATABASE_URL`
|
||||
instead of silently skipping setup:
|
||||
|
||||
```bash
|
||||
@@ -142,27 +192,36 @@ eval "$(../.agents/skills/agent-testing/scripts/init-dev-env.sh env)"
|
||||
BASE_URL=http://localhost:3010 HEADLESS=true bun run test:smoke
|
||||
```
|
||||
|
||||
### 0.4 Auth is green
|
||||
### 0.4 Auth is green for the selected surface
|
||||
|
||||
**Auth is the gate for all automated testing.**
|
||||
**Auth is the gate for automated testing, but the gate is surface-scoped.**
|
||||
Pick the intended surface first when it is already clear from the task, then
|
||||
check only that surface. Do not block a Web test on CLI device-code auth or an
|
||||
Electron login state unless the test spans those surfaces.
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status --surface web
|
||||
```
|
||||
|
||||
| Surface | Mechanism | One-key path | Standard check |
|
||||
| -------- | ------------------------------------------------- | ------------------------------ | -------------------------- |
|
||||
| CLI | OIDC Device Code Flow (`apps/cli/.lobehub-dev`) | `setup-auth.sh cli` | `setup-auth.sh status` |
|
||||
| Web | better-auth cookie injection into `agent-browser` | `pbpaste \| setup-auth.sh web` | `setup-auth.sh web-verify` |
|
||||
| Electron | App's own persistent login state | Log in once in the app | `app-probe.sh auth` |
|
||||
| Bot | Native apps already logged in | — | per-platform screenshot |
|
||||
Use `status` with no `--surface` only for cross-surface test plans.
|
||||
|
||||
| Surface | Mechanism | One-key path | Standard check |
|
||||
| -------- | --------------------------------------------- | ------------------------ | ----------------------------------------- |
|
||||
| CLI | Seeded API key, device-code fallback | `setup-auth.sh cli-seed` | `setup-auth.sh status --surface cli` |
|
||||
| Web | Seeded better-auth login into `agent-browser` | `setup-auth.sh web-seed` | `setup-auth.sh status --surface web` |
|
||||
| Electron | App's own persistent login state | Log in once in the app | `setup-auth.sh status --surface electron` |
|
||||
| Bot | Native apps already logged in | — | per-platform screenshot |
|
||||
|
||||
Login-state checks are standardized — do NOT hand-roll `window.__LOBE_STORES`
|
||||
eval snippets; use `scripts/app-probe.sh auth` (returns `{ isSignedIn, userId }`,
|
||||
works for Electron CDP and web sessions via `AB_TARGET`).
|
||||
|
||||
If `status` is not all green, fix auth first (the steps that need a human must be
|
||||
requested from the user explicitly). Full background and failure modes:
|
||||
For Web tests, the test surface is always `agent-browser --session lobehub-dev`.
|
||||
Use `setup-auth.sh web-seed` first in the seeded local env. The user's normal
|
||||
Chrome is only a source for copying the Cookie header when seed auth is not
|
||||
available or `status --surface web` still fails. If Chrome is already logged in,
|
||||
do not open a login page; verify agent-browser first, then request the Network
|
||||
`Cookie:` header only if that verification fails. Full background and failure modes:
|
||||
[references/auth.md](./references/auth.md).
|
||||
|
||||
## Step 1 — Pick the surface by change scope
|
||||
@@ -237,6 +296,7 @@ All under `.agents/skills/agent-testing/scripts/`:
|
||||
|
||||
| Script | Usage |
|
||||
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `test-env.sh` | Print/export the resolved local test env and ports |
|
||||
| `setup-auth.sh` | One-stop auth setup & status check (`status` / `cli` / `web`) |
|
||||
| `init-dev-env.sh` | Self-contained local dev env (`setup-db` / `seed-user` / `dev-next` / `dev`) |
|
||||
| `app-probe.sh` | LobeHub app probes: `auth` / `route` / `ops` / `goto <path>` / `errors` |
|
||||
@@ -284,6 +344,13 @@ Two hard rules worth front-loading:
|
||||
must use Markdown image syntax like ``. Do not
|
||||
use bare file paths, Markdown links, or local file links as the primary
|
||||
visual evidence; those make the report unreadable without opening each asset.
|
||||
- **Final replies must include visual evidence links.** When a run includes UI
|
||||
screenshots or GIFs, include the report directory and the most important
|
||||
visual artifacts in the final chat response. Each item must include a stable
|
||||
label, an evidence caption describing the observed UI outcome, and a
|
||||
repo-relative path, for example:
|
||||
`[Image #1 - error toast shows provider auth failure](<report-dir>/assets/foo.png)`.
|
||||
Use repo-relative paths, not absolute paths.
|
||||
- **Time-based behavior needs a GIF, not a screenshot.** If a case asserts
|
||||
change over time (streaming output, a ticking timer, loading states,
|
||||
animations), record it with `scripts/record-gif.sh` and embed the GIF —
|
||||
|
||||
@@ -13,17 +13,18 @@ flakiness.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Details |
|
||||
| ------------ | --------------------------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
|
||||
| Requirement | Details |
|
||||
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dev server | `localhost:3010` — see [../references/dev-server.md](../references/dev-server.md) |
|
||||
| CLI source | `apps/cli/` — runs from source, no rebuild; standalone `node_modules` — run `pnpm install` inside `apps/cli/` (root install does not cover it) |
|
||||
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
|
||||
| Auth | Device Code Flow login — see [../references/auth.md](../references/auth.md) |
|
||||
| CLI dev mode | `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated settings |
|
||||
| Auth | Seeded API key first; Device Code Flow only as fallback — see [../references/auth.md](../references/auth.md) |
|
||||
|
||||
All CLI dev commands run from `apps/cli/`. Subsequent examples use `$CLI`:
|
||||
|
||||
```bash
|
||||
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
|
||||
source ../../.records/env/agent-testing-cli.env
|
||||
CLI="bun src/index.ts"
|
||||
```
|
||||
|
||||
## Workflow
|
||||
@@ -39,14 +40,23 @@ check, start, and restart commands. Server-side code changes require a restart.
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh status
|
||||
```
|
||||
|
||||
If the CLI is not logged in, **the user must run the login themselves**
|
||||
(interactive browser authorization):
|
||||
If the CLI is not ready in the seeded local environment:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
source .records/env/agent-testing-cli.env
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
|
||||
```
|
||||
|
||||
If the target environment is not seeded, use the interactive fallback:
|
||||
|
||||
```bash
|
||||
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
|
||||
```
|
||||
|
||||
Credentials persist in `apps/cli/.lobehub-dev/`. Details:
|
||||
Seeded API-key auth does not store credentials. It writes local settings under
|
||||
`$HOME/.lobehub-dev` and requires the generated env file to be sourced before
|
||||
CLI commands. Details:
|
||||
[../references/auth.md](../references/auth.md).
|
||||
|
||||
### Step 3 — Test with CLI commands
|
||||
@@ -133,10 +143,10 @@ $CLI provider test <provider-id>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| --------------------------- | ----------------------------------------------- |
|
||||
| `No authentication found` | Run `login --server http://localhost:3010` |
|
||||
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
|
||||
| Issue | Solution |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `No authentication found` | Source `.records/env/agent-testing-cli.env`, or run device-code `login --server http://localhost:3010` |
|
||||
| `UNAUTHORIZED` on API calls | Re-run `init-dev-env.sh seed-user` and re-source the env file; for device-code fallback, re-run login |
|
||||
| `ECONNREFUSED` | Dev server not running — see dev-server.md |
|
||||
| CLI shows old data/behavior | Server needs restart to pick up code changes |
|
||||
| Login opens wrong server | Must use `--server` flag (env var doesn't work) |
|
||||
|
||||
@@ -1,37 +1,72 @@
|
||||
# Auth Setup for Local Agent Testing
|
||||
|
||||
**Auth is the gate for all automated testing.** Prepare and verify it before
|
||||
writing any test step. The one-stop entry point is:
|
||||
**Auth is the gate for all automated testing.** Complete
|
||||
[Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) first so
|
||||
`SERVER_URL` and ports are resolved, then verify auth before writing any test
|
||||
step.
|
||||
|
||||
Initialize helpers first:
|
||||
|
||||
```bash
|
||||
SCRIPT=".agents/skills/agent-testing/scripts/setup-auth.sh"
|
||||
|
||||
$SCRIPT status # check server + CLI + web auth readiness
|
||||
$SCRIPT cli # interactive CLI device-code login (must be run by the user)
|
||||
pbpaste | $SCRIPT web # inject a copied Cookie header into the agent-browser session
|
||||
$SCRIPT web-verify # live-check that the agent-browser session is authenticated
|
||||
SCRIPT="./.agents/skills/agent-testing/scripts/setup-auth.sh"
|
||||
TEST_ENV="./.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
eval "$($TEST_ENV --exports)"
|
||||
```
|
||||
|
||||
`SERVER_URL` defaults to `http://localhost:3010` (this repo's `dev:next` port).
|
||||
Override it when testing against another server (e.g. `SERVER_URL=http://localhost:3011`
|
||||
in the cloud repo).
|
||||
Quick reference after initialization:
|
||||
|
||||
| Command | Purpose |
|
||||
| ------------------------------ | -------------------------------------------------- |
|
||||
| `$SCRIPT status` | Check all surfaces (server + CLI + web + Electron) |
|
||||
| `$SCRIPT status --surface web` | Check only the Web surface gate |
|
||||
| `$SCRIPT cli-seed` | Configure CLI API-key auth from the seeded key |
|
||||
| `$SCRIPT cli` | Interactive CLI device-code login (user must run) |
|
||||
| `$SCRIPT open-chrome` | Open Chrome at `SERVER_URL` with DevTools |
|
||||
| `$SCRIPT web-seed` | Sign in the seeded user and inject cookies |
|
||||
| `pbpaste \| $SCRIPT web` | Inject a copied Cookie header into agent-browser |
|
||||
| `$SCRIPT web-verify` | Live-check agent-browser session auth |
|
||||
|
||||
Use `localhost` for Web auth; better-auth cookies are stored for `localhost`,
|
||||
not `127.0.0.1`.
|
||||
|
||||
## Per-surface overview
|
||||
|
||||
| Surface | Mechanism | Persistence | Human interaction |
|
||||
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| CLI | OIDC Device Code Flow | `apps/cli/.lobehub-dev/settings.json` | Yes — browser authorization, every token expiry |
|
||||
| Web | better-auth cookie injection | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | Copy the Cookie header once per token rotation |
|
||||
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
|
||||
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
|
||||
| Surface | Mechanism | Persistence | Human interaction |
|
||||
| -------- | ---------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| CLI | Seeded API key or OIDC Device Code Flow | `.records/env/agent-testing-cli.env` + `$HOME/.lobehub-dev` | No for seed path; yes for device-code fallback |
|
||||
| Web | Seeded better-auth login or cookie copy | `~/.lobehub-agent-testing/web-state.json` + agent-browser session | No for seed path; copy cookie only as fallback |
|
||||
| Electron | App's own login state | Electron user-data dir | Log in once manually in the app |
|
||||
| Bot | Native apps (Discord/WeChat/…) logged in | Each app's own session | Once per app |
|
||||
|
||||
## CLI — Device Code Flow
|
||||
## CLI — Seeded API key
|
||||
|
||||
For the self-contained no-root-`.env` dev environment, seed the baseline user
|
||||
and API key once:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
source .records/env/agent-testing-cli.env
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh cli-seed
|
||||
```
|
||||
|
||||
The seed step writes `LOBE_API_KEY` for humans and maps it to the CLI's current
|
||||
auth variable, `LOBEHUB_CLI_API_KEY`. It also sets `LOBEHUB_SERVER` so CLI
|
||||
commands hit the local server without needing a stored device-code token.
|
||||
|
||||
Use this for automated CLI verification:
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
source ../../.records/env/agent-testing-cli.env
|
||||
bun src/index.ts <command>
|
||||
```
|
||||
|
||||
## CLI — Device Code Flow fallback
|
||||
|
||||
Use device-code login only when testing against a non-seeded environment.
|
||||
Credentials are isolated from the user's real CLI config via
|
||||
`LOBEHUB_CLI_HOME=.lobehub-dev` (kept inside `apps/cli/`, gitignored).
|
||||
|
||||
Login requires interactive browser authorization, so **the user must run it
|
||||
themselves** (e.g. via the `!` prefix in Claude Code):
|
||||
`LOBEHUB_CLI_HOME=.lobehub-dev`, which the current CLI stores under
|
||||
`$HOME/.lobehub-dev`.
|
||||
|
||||
```bash
|
||||
cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3010
|
||||
@@ -40,10 +75,30 @@ cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server htt
|
||||
- The `--server` flag is required — an env var does NOT work and login will hit
|
||||
the wrong server without it.
|
||||
- Check state without logging in: `setup-auth.sh status` (verifies
|
||||
`settings.json` exists and `serverUrl` matches).
|
||||
`LOBEHUB_CLI_API_KEY` when present, otherwise checks the stored server URL).
|
||||
- `UNAUTHORIZED` on API calls means the token expired — re-run login.
|
||||
|
||||
## Web — better-auth cookie injection (agent-browser)
|
||||
## Web — seeded better-auth login
|
||||
|
||||
The Web test surface is `agent-browser --session lobehub-dev`. The user's
|
||||
ordinary Chrome is only a cookie source; Chrome screenshots, Chrome Network
|
||||
records, and Chrome logged-in state do not prove the agent-browser test session
|
||||
is authenticated.
|
||||
|
||||
For the seeded local dev environment, use the automatic path:
|
||||
|
||||
```bash
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
|
||||
```
|
||||
|
||||
`web-seed` posts the seeded email/password to
|
||||
`/api/auth/sign-in/email`, stores the returned cookie jar under
|
||||
`~/.lobehub-agent-testing/`, converts it to Playwright `storageState`, loads it
|
||||
into the `agent-browser` session, and verifies the session does not land on
|
||||
`/signin`.
|
||||
|
||||
## Web — manual cookie injection fallback
|
||||
|
||||
`agent-browser --headed` on macOS often creates the Chromium window off-screen —
|
||||
the user can't see or interact with it, so manual login inside the agent-browser
|
||||
@@ -53,31 +108,19 @@ user's own logged-in Chrome and inject it as a Playwright-style state file.
|
||||
Do **not** use this on production URLs — only local dev. Treat the cookie as a
|
||||
secret: don't paste it into shared logs, PRs, or commit it anywhere.
|
||||
|
||||
### One-key path
|
||||
### Web — decision flow
|
||||
|
||||
1. Ask the user to copy the Cookie header **from a Network request, NOT
|
||||
`document.cookie`** (`document.cookie` cannot see HttpOnly cookies, which is
|
||||
exactly where better-auth puts its session):
|
||||
- Open the logged-in tab (`http://localhost:<port>/…`) in Chrome.
|
||||
- `Cmd+Option+I` → **Network** tab → refresh → click any same-origin request.
|
||||
- Under **Request Headers**, right-click the `Cookie:` line → **Copy value**.
|
||||
2. Inject and verify in one shot:
|
||||
|
||||
```bash
|
||||
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web
|
||||
```
|
||||
|
||||
The script filters the header down to the better-auth cookies
|
||||
(`better-auth.session_token`, `better-auth.state`), builds the Playwright
|
||||
`storageState` JSON, loads it into the `agent-browser` session (default name
|
||||
`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
1. `$SCRIPT status --surface web` — green? Start testing. Do not ask for a Cookie header.
|
||||
2. Not green and using the seeded local env → `$SCRIPT web-seed`.
|
||||
3. Still not green or not using the seed env → `$SCRIPT open-chrome` opens Chrome at `SERVER_URL` with DevTools.
|
||||
4. User copies the `Cookie:` header from Network tab → any same-origin request → Request Headers → right-click `Cookie:` → **Copy value**. Must be from Network, NOT `document.cookie` (HttpOnly cookies are invisible to `document.cookie`).
|
||||
5. `pbpaste | $SCRIPT web` — filters to better-auth cookies (`session_token`, `session_data`, `state`), builds Playwright `storageState`, loads it into the `agent-browser` session (`lobehub-dev`), opens `SERVER_URL`, and asserts the URL is not `/signin`.
|
||||
|
||||
### Using the authenticated session
|
||||
|
||||
```bash
|
||||
agent-browser --session lobehub-dev open "http://localhost:3010/"
|
||||
agent-browser --session lobehub-dev open "$SERVER_URL/"
|
||||
agent-browser --session lobehub-dev snapshot -i | head -20
|
||||
# Look for the user's avatar/name in the sidebar, or absence of the signin form.
|
||||
```
|
||||
|
||||
### Notes
|
||||
@@ -90,12 +133,12 @@ agent-browser --session lobehub-dev snapshot -i | head -20
|
||||
|
||||
### Common failure modes
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
|
||||
| Script reports `no better-auth cookies found` | Separator wrong, or user pasted URL-decoded value | Keep the raw `Cookie:` header as-is |
|
||||
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
|
||||
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
|
||||
| Symptom | Cause | Fix |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Still redirects to `/signin` after injection | User pasted from `document.cookie` → missed HttpOnly session | Re-pull from Network request Headers, not console |
|
||||
| Script reports `no better-auth cookies found` | User pasted the wrong value, or the cookie parser regressed | Keep the raw `Cookie:` header as-is; run `scripts/setup-auth.test.sh` if the input looks valid |
|
||||
| Login works briefly then expires | `better-auth.session_token` rotated (user logged out / signed in again) | Re-copy and re-inject |
|
||||
| Domain mismatch | Cookie domain must be `localhost` literally, no leading dot for local dev | — |
|
||||
|
||||
## Electron
|
||||
|
||||
|
||||
@@ -3,22 +3,44 @@
|
||||
Single source of truth for starting / restarting the backend that all test
|
||||
surfaces (CLI, Electron, Web) hit.
|
||||
|
||||
## Resolve ports first
|
||||
|
||||
Run `test-env.sh` as described in
|
||||
[SKILL.md Step 0.0](../SKILL.md#00-resolve-the-current-test-environment)
|
||||
before starting or probing any local test surface.
|
||||
|
||||
## Ports & modes
|
||||
|
||||
| Command | What it runs | Port |
|
||||
| ------------------- | --------------------------------------------------------- | --------------------------------- |
|
||||
| `pnpm run dev:next` | Next.js backend (API + auth) | `3010` |
|
||||
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `3010` (API) + SPA on `9876` |
|
||||
| `bun run dev:spa` | Vite SPA only, proxies API to `3010` | `9876` (prints a Debug Proxy URL) |
|
||||
| Command | What it runs | Port source |
|
||||
| ------------------- | --------------------------------------------------------- | ------------------- |
|
||||
| `pnpm run dev:next` | Next.js backend (API + auth) | `PORT` |
|
||||
| `bun run dev` | Full-stack (Next.js + Vite SPA, via `devStartupSequence`) | `PORT` + `SPA_PORT` |
|
||||
| `bun run dev:spa` | Vite SPA only, proxies API to `PORT` | `SPA_PORT` |
|
||||
|
||||
In the **cloud repo** (where this repo is the `lobehub/` submodule) the dev
|
||||
server conventionally runs on `3011` — set `SERVER_URL=http://localhost:3011`
|
||||
for the scripts in this skill when testing there.
|
||||
In the **cloud repo** (where this repo is the `lobehub/` submodule), local
|
||||
worktree names map to fallback defaults only when `.env` and shell env do not
|
||||
provide values:
|
||||
|
||||
| Workspace directory | Default `SERVER_URL` |
|
||||
| ------------------- | -------------------------------- |
|
||||
| `lobehub` | `http://localhost:3010` |
|
||||
| `lobehub-cloud` | `http://localhost:3020` |
|
||||
| `lobehub-cloud-1` | `http://localhost:3021` |
|
||||
| `lobehub-cloud-N` | `http://localhost:$((3020 + N))` |
|
||||
|
||||
`test-env.sh` and `setup-auth.sh` both use the resolved env first and these
|
||||
worktree defaults only as fallback. Treat the dev-server terminal output as the
|
||||
final source of truth when testing a non-standard port, then export it for every
|
||||
agent-testing command:
|
||||
|
||||
```bash
|
||||
export SERVER_URL=http://localhost:<port-from-dev-output>
|
||||
```
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:3010/
|
||||
curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
|
||||
```
|
||||
|
||||
## Start / restart
|
||||
@@ -38,8 +60,11 @@ bun run dev
|
||||
# Without root .env:
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
|
||||
|
||||
# Local QStash. Run in a separate terminal only when testing workflow paths.
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh qstash
|
||||
|
||||
# Restart — required to pick up server-side code changes
|
||||
lsof -ti:3010 | xargs kill
|
||||
lsof -ti:"$PORT" | xargs kill
|
||||
pnpm run dev:next
|
||||
# or, when no root .env exists:
|
||||
# ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
|
||||
@@ -61,8 +86,13 @@ in doubt.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
| ------------------------- | ------------------------------------------------------- |
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
| Issue | Solution |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `ECONNREFUSED` | Server not running — start it |
|
||||
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
|
||||
| Stale data / old behavior | Server needs a restart to pick up code changes |
|
||||
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
|
||||
|
||||
Marketplace/community endpoints are not part of the local agent-testing auth
|
||||
gate. Do not block local product-chain verification on marketplace API auth
|
||||
unless the change explicitly targets marketplace behavior.
|
||||
|
||||
@@ -64,7 +64,10 @@ output):
|
||||
not acceptable as primary visual evidence.
|
||||
|
||||
4. **Set the verdict** in both `report.md` and `result.json`, then link the
|
||||
report directory in your final answer to the user.
|
||||
report directory in your final answer to the user. If UI evidence exists,
|
||||
list the key screenshot/GIF links in the final chat response. Use Markdown
|
||||
link text as the evidence caption, for example:
|
||||
`[Image #1 - observed outcome](<report-dir>/assets/case1.png)`.
|
||||
|
||||
## Report language (hard rule)
|
||||
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
# init-dev-env.sh write [file] # write a source-able env file
|
||||
# init-dev-env.sh setup-db # start local Postgres and run migrations
|
||||
# init-dev-env.sh migrate # run DB migrations against the configured DB
|
||||
# init-dev-env.sh seed-user # seed the baseline test user
|
||||
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
|
||||
# init-dev-env.sh qstash # run local Upstash QStash dev server
|
||||
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
|
||||
# init-dev-env.sh dev # exec `bun run dev` with this env
|
||||
# init-dev-env.sh clean-db # remove the managed Postgres container
|
||||
#
|
||||
# Overrides:
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres
|
||||
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -32,6 +33,12 @@ DB_PORT="${DB_PORT:-5433}"
|
||||
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
|
||||
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
|
||||
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
|
||||
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
|
||||
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
|
||||
QSTASH_DEV_PORT="${QSTASH_DEV_PORT:-8080}"
|
||||
QSTASH_LOCAL_TOKEN="${QSTASH_LOCAL_TOKEN:-eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=}"
|
||||
QSTASH_LOCAL_CURRENT_SIGNING_KEY="${QSTASH_LOCAL_CURRENT_SIGNING_KEY:-sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r}"
|
||||
QSTASH_LOCAL_NEXT_SIGNING_KEY="${QSTASH_LOCAL_NEXT_SIGNING_KEY:-sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs}"
|
||||
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
|
||||
@@ -57,6 +64,11 @@ apply_env() {
|
||||
export NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION="${NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION:-0}"
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=6144}"
|
||||
export PORT="${PORT:-$SERVER_PORT}"
|
||||
export QSTASH_CURRENT_SIGNING_KEY="${QSTASH_CURRENT_SIGNING_KEY:-$QSTASH_LOCAL_CURRENT_SIGNING_KEY}"
|
||||
export QSTASH_DEV_PORT
|
||||
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
|
||||
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
|
||||
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
|
||||
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
|
||||
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
|
||||
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
|
||||
@@ -75,6 +87,11 @@ env_keys() {
|
||||
NEXT_PUBLIC_AUTH_EMAIL_VERIFICATION \
|
||||
NODE_OPTIONS \
|
||||
PORT \
|
||||
QSTASH_CURRENT_SIGNING_KEY \
|
||||
QSTASH_DEV_PORT \
|
||||
QSTASH_NEXT_SIGNING_KEY \
|
||||
QSTASH_TOKEN \
|
||||
QSTASH_URL \
|
||||
S3_ACCESS_KEY_ID \
|
||||
S3_BUCKET \
|
||||
S3_ENDPOINT \
|
||||
@@ -148,9 +165,14 @@ migrate_db() {
|
||||
|
||||
seed_user() {
|
||||
apply_env
|
||||
export AGENT_TESTING_API_KEY
|
||||
export AGENT_TESTING_CLI_ENV_FILE="${AGENT_TESTING_CLI_ENV_FILE:-$CLI_ENV_FILE_DEFAULT}"
|
||||
cd "$REPO_ROOT"
|
||||
node <<'NODE'
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const pg = require('pg');
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -166,13 +188,72 @@ const TEST_USER = {
|
||||
username: 'agent_testing_user',
|
||||
};
|
||||
|
||||
const TEST_API_KEY = {
|
||||
id: 'api_key_agent_testing_001',
|
||||
key: process.env.AGENT_TESTING_API_KEY || 'sk-lh-agenttesting0001',
|
||||
name: 'Agent Testing CLI API Key',
|
||||
};
|
||||
|
||||
const validateApiKeyFormat = (apiKey) => /^sk-lh-[\da-z]{16}$/.test(apiKey);
|
||||
|
||||
const hashApiKey = (apiKey) => {
|
||||
const secret = process.env.KEY_VAULTS_SECRET;
|
||||
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
|
||||
|
||||
return crypto.createHmac('sha256', secret).update(apiKey).digest('hex');
|
||||
};
|
||||
|
||||
const encryptWithKeyVaultsSecret = (plaintext) => {
|
||||
const secret = process.env.KEY_VAULTS_SECRET;
|
||||
if (!secret) throw new Error('KEY_VAULTS_SECRET is required to seed the baseline API key.');
|
||||
|
||||
const rawKey = Buffer.from(secret, 'base64');
|
||||
if (![16, 24, 32].includes(rawKey.length)) {
|
||||
throw new Error(
|
||||
`KEY_VAULTS_SECRET must decode to 16, 24, or 32 bytes, got ${rawKey.length} bytes.`,
|
||||
);
|
||||
}
|
||||
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv(`aes-${rawKey.length * 8}-gcm`, rawKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
};
|
||||
|
||||
const writeCliEnvFile = () => {
|
||||
const file = process.env.AGENT_TESTING_CLI_ENV_FILE || '.records/env/agent-testing-cli.env';
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
[
|
||||
'# Source this file before running LobeHub CLI agent tests.',
|
||||
'# Generated by init-dev-env.sh seed-user',
|
||||
`export LOBE_API_KEY=${TEST_API_KEY.key}`,
|
||||
`export LOBEHUB_CLI_API_KEY="${'${LOBE_API_KEY}'}"`,
|
||||
`export LOBEHUB_SERVER=${process.env.APP_URL}`,
|
||||
'export LOBEHUB_CLI_HOME=.lobehub-dev',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
|
||||
(async () => {
|
||||
if (!validateApiKeyFormat(TEST_API_KEY.key)) {
|
||||
throw new Error(`Invalid AGENT_TESTING_API_KEY format: ${TEST_API_KEY.key}`);
|
||||
}
|
||||
|
||||
await client.connect();
|
||||
const now = new Date().toISOString();
|
||||
const onboarding = JSON.stringify({ finishedAt: now, version: 1 });
|
||||
const passwordHash = await bcrypt.hash(TEST_USER.password, 10);
|
||||
const encryptedApiKey = encryptWithKeyVaultsSecret(TEST_API_KEY.key);
|
||||
const apiKeyHash = hashApiKey(TEST_API_KEY.key);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO users (id, email, normalized_email, username, full_name, email_verified, onboarding, created_at, updated_at, last_active_at)
|
||||
@@ -204,9 +285,35 @@ const client = new pg.Client({ connectionString: databaseUrl });
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO api_keys (id, name, key, key_hash, enabled, expires_at, user_id, workspace_id, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, $6, NULL, $7, $7)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
key = EXCLUDED.key,
|
||||
key_hash = EXCLUDED.key_hash,
|
||||
enabled = EXCLUDED.enabled,
|
||||
expires_at = NULL,
|
||||
updated_at = EXCLUDED.updated_at`,
|
||||
[
|
||||
TEST_API_KEY.id,
|
||||
TEST_API_KEY.name,
|
||||
encryptedApiKey,
|
||||
apiKeyHash,
|
||||
true,
|
||||
TEST_USER.id,
|
||||
now,
|
||||
],
|
||||
);
|
||||
|
||||
const cliEnvFile = writeCliEnvFile();
|
||||
|
||||
console.log('seeded baseline user:');
|
||||
console.log(` email: ${TEST_USER.email}`);
|
||||
console.log(` password: ${TEST_USER.password}`);
|
||||
console.log('seeded baseline API key:');
|
||||
console.log(` LOBE_API_KEY: ${TEST_API_KEY.key}`);
|
||||
console.log(` CLI env: ${cliEnvFile}`);
|
||||
})()
|
||||
.finally(() => client.end())
|
||||
.catch((error) => {
|
||||
@@ -222,6 +329,7 @@ cmd_status() {
|
||||
note "APP_URL=$APP_URL"
|
||||
note "DATABASE_URL=$DATABASE_URL"
|
||||
note "PORT=$PORT"
|
||||
note "QSTASH_URL=$QSTASH_URL"
|
||||
if command -v docker > /dev/null 2>&1; then
|
||||
ok "docker CLI available"
|
||||
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
|
||||
@@ -234,6 +342,14 @@ cmd_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_qstash() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
note "starting local QStash dev server at $QSTASH_URL"
|
||||
note "keep this process running while testing workflow paths"
|
||||
exec pnpm run qstash -- -port "$QSTASH_DEV_PORT"
|
||||
}
|
||||
|
||||
cmd_dev_next() {
|
||||
apply_env
|
||||
cd "$REPO_ROOT"
|
||||
@@ -279,6 +395,7 @@ case "$COMMAND" in
|
||||
;;
|
||||
migrate) migrate_db ;;
|
||||
seed-user) seed_user ;;
|
||||
qstash) cmd_qstash ;;
|
||||
dev-next) cmd_dev_next ;;
|
||||
dev) cmd_dev ;;
|
||||
clean-db) cmd_clean_db ;;
|
||||
|
||||
@@ -5,29 +5,114 @@
|
||||
# test step. Background and failure modes: ../references/auth.md
|
||||
#
|
||||
# Usage:
|
||||
# setup-auth.sh status # check server + CLI + web auth readiness
|
||||
# setup-auth.sh status # check server + CLI + web + Electron readiness
|
||||
# setup-auth.sh status --surface web # check only the Web surface gate
|
||||
# setup-auth.sh cli-seed # configure CLI API-key auth from seeded local env
|
||||
# setup-auth.sh cli # interactive CLI device-code login (run by a human)
|
||||
# setup-auth.sh open-chrome # open SERVER_URL in Chrome and show DevTools
|
||||
# setup-auth.sh web-seed # sign in seeded user and inject cookies automatically
|
||||
# setup-auth.sh web # stdin = Cookie header -> inject into agent-browser session
|
||||
# setup-auth.sh web-verify # live-check the agent-browser session is authenticated
|
||||
#
|
||||
# Env:
|
||||
# SERVER_URL (default http://localhost:3010) dev server under test
|
||||
# SERVER_URL (default from test-env.sh) dev server under test
|
||||
# SESSION (default lobehub-dev) agent-browser session name
|
||||
# AUTH_DIR (default ~/.lobehub-agent-testing) where web state is persisted
|
||||
# SEED_EMAIL / SEED_PASSWORD seeded better-auth login
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVER_URL="${SERVER_URL:-http://localhost:3010}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
|
||||
workspace_root_for_port() {
|
||||
local root="$REPO_ROOT"
|
||||
local name
|
||||
name="$(basename "$root")"
|
||||
|
||||
if [[ "$name" == "lobehub" ]]; then
|
||||
local parent
|
||||
parent="$(cd "$root/.." && pwd)"
|
||||
local parent_name
|
||||
parent_name="$(basename "$parent")"
|
||||
if [[ "$parent_name" == lobehub-cloud* ]]; then
|
||||
root="$parent"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
default_server_url() {
|
||||
local env_resolver resolved
|
||||
env_resolver="$(dirname "${BASH_SOURCE[0]}")/test-env.sh"
|
||||
if [[ -x "$env_resolver" ]]; then
|
||||
resolved="$("$env_resolver" --value SERVER_URL 2> /dev/null || true)"
|
||||
if [[ -n "$resolved" ]]; then
|
||||
printf '%s\n' "$resolved"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
local root name suffix port
|
||||
root="$(workspace_root_for_port)"
|
||||
name="$(basename "$root")"
|
||||
|
||||
case "$name" in
|
||||
lobehub-cloud)
|
||||
port=3020
|
||||
;;
|
||||
lobehub-cloud-*)
|
||||
suffix="${name#lobehub-cloud-}"
|
||||
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
|
||||
port=$((3020 + 10#$suffix))
|
||||
else
|
||||
port=3010
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
port=3010
|
||||
;;
|
||||
esac
|
||||
|
||||
printf 'http://localhost:%s\n' "$port"
|
||||
}
|
||||
|
||||
SERVER_URL="${SERVER_URL:-$(default_server_url)}"
|
||||
SESSION="${SESSION:-lobehub-dev}"
|
||||
AUTH_DIR="${AUTH_DIR:-$HOME/.lobehub-agent-testing}"
|
||||
STATE_FILE="$AUTH_DIR/web-state.json"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||
CLI_HOME="$REPO_ROOT/apps/cli/.lobehub-dev"
|
||||
CLI_HOME_NAME="${LOBEHUB_CLI_HOME:-.lobehub-dev}"
|
||||
CLI_HOME="$HOME/${CLI_HOME_NAME#/}"
|
||||
CLI_CREDENTIALS_FILE="$CLI_HOME/credentials.json"
|
||||
SEED_EMAIL="${SEED_EMAIL:-agent-testing@lobehub.com}"
|
||||
SEED_PASSWORD="${SEED_PASSWORD:-TestPassword123!}"
|
||||
SEED_API_KEY="${SEED_API_KEY:-${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}}"
|
||||
CLI_ENV_FILE="${CLI_ENV_FILE:-$REPO_ROOT/.records/env/agent-testing-cli.env}"
|
||||
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; }
|
||||
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; }
|
||||
note() { printf ' %s\n' "$1"; }
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage:
|
||||
$0 status [--surface all|cli|web|electron]
|
||||
$0 cli-seed
|
||||
$0 cli
|
||||
$0 open-chrome [--dry-run]
|
||||
$0 web-seed
|
||||
$0 web
|
||||
$0 web-verify
|
||||
|
||||
Env:
|
||||
SERVER_URL=$SERVER_URL
|
||||
SESSION=$SESSION
|
||||
AUTH_DIR=$AUTH_DIR
|
||||
SEED_EMAIL=$SEED_EMAIL
|
||||
CLI_HOME=$CLI_HOME
|
||||
EOF
|
||||
}
|
||||
|
||||
check_server() {
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/" 2> /dev/null || true)
|
||||
@@ -41,11 +126,35 @@ check_server() {
|
||||
}
|
||||
|
||||
check_cli() {
|
||||
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json"; then
|
||||
ok "CLI logged in to $SERVER_URL (creds: apps/cli/.lobehub-dev)"
|
||||
local api_key="${LOBEHUB_CLI_API_KEY:-${LOBE_API_KEY:-}}"
|
||||
if [[ -n "$api_key" ]]; then
|
||||
local body_file code
|
||||
body_file="$(mktemp)"
|
||||
code=$(curl -sS -o "$body_file" -w '%{http_code}' \
|
||||
-H "Authorization: Bearer $api_key" \
|
||||
"$SERVER_URL/api/v1/users/me?includeCount=0" 2> /dev/null || true)
|
||||
|
||||
if [[ "$code" =~ ^[23] ]]; then
|
||||
rm -f "$body_file"
|
||||
ok "CLI API-key auth valid for $SERVER_URL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
bad "CLI API-key auth failed for $SERVER_URL (http_code='$code')"
|
||||
note "seed the local API key first:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
note "source $CLI_ENV_FILE"
|
||||
rm -f "$body_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -f "$CLI_HOME/settings.json" ]] && grep -q "$SERVER_URL" "$CLI_HOME/settings.json" && [[ -f "$CLI_CREDENTIALS_FILE" ]]; then
|
||||
ok "CLI device-code credentials configured for $SERVER_URL (creds: $CLI_HOME)"
|
||||
else
|
||||
bad "CLI not logged in to $SERVER_URL"
|
||||
note "ask the user to run:"
|
||||
note "automated path:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user && source $CLI_ENV_FILE && $0 cli-seed"
|
||||
note "interactive fallback:"
|
||||
note "cd apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server $SERVER_URL"
|
||||
return 1
|
||||
fi
|
||||
@@ -54,13 +163,24 @@ check_cli() {
|
||||
check_web() {
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
ok "web auth state saved ($STATE_FILE)"
|
||||
note "live-verify: $0 web-verify"
|
||||
else
|
||||
bad "no web auth state for agent-browser"
|
||||
note "copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
note "for the seeded local user, run: $0 web-seed"
|
||||
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
note "pbpaste | $0 web (see references/auth.md)"
|
||||
return 1
|
||||
fi
|
||||
cmd_web_verify --skip-server-check
|
||||
}
|
||||
|
||||
check_agent_browser() {
|
||||
if command -v agent-browser > /dev/null 2>&1; then
|
||||
ok "agent-browser available"
|
||||
else
|
||||
bad "agent-browser command not found"
|
||||
note "install or expose agent-browser before Web/Electron UI testing"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_electron() {
|
||||
@@ -84,16 +204,75 @@ check_electron() {
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "agent-testing auth status (SERVER_URL=$SERVER_URL):"
|
||||
local surface="all"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--surface)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "--surface requires one of: all, cli, web, electron" >&2
|
||||
return 2
|
||||
fi
|
||||
surface="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--surface=*)
|
||||
surface="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
all|cli|web|electron)
|
||||
surface="$1"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown status option: $1" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$surface" in
|
||||
all|cli|web|electron) ;;
|
||||
"")
|
||||
echo "--surface requires one of: all, cli, web, electron" >&2
|
||||
return 2
|
||||
;;
|
||||
*)
|
||||
echo "unknown surface: $surface" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "agent-testing auth status (surface=$surface, SERVER_URL=$SERVER_URL):"
|
||||
local rc=0
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
check_web || rc=1
|
||||
check_electron || rc=1
|
||||
case "$surface" in
|
||||
all)
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
check_web || rc=1
|
||||
check_electron || rc=1
|
||||
;;
|
||||
cli)
|
||||
check_server || rc=1
|
||||
check_cli || rc=1
|
||||
;;
|
||||
web)
|
||||
check_server || rc=1
|
||||
check_web || rc=1
|
||||
;;
|
||||
electron)
|
||||
check_electron || rc=1
|
||||
;;
|
||||
esac
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
echo "all green — safe to start automated testing."
|
||||
echo "$surface auth green — safe to start automated testing on this surface."
|
||||
else
|
||||
echo "auth NOT ready — fix the ✘ items before writing any test step."
|
||||
echo "$surface auth NOT ready — fix the ✘ items before writing any test step."
|
||||
fi
|
||||
return $rc
|
||||
}
|
||||
@@ -105,23 +284,148 @@ cmd_cli() {
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server "$SERVER_URL"
|
||||
}
|
||||
|
||||
write_cli_seed_env() {
|
||||
mkdir -p "$(dirname "$CLI_ENV_FILE")"
|
||||
cat > "$CLI_ENV_FILE" << EOF
|
||||
# Source this file before running LobeHub CLI agent tests.
|
||||
# Generated by setup-auth.sh cli-seed
|
||||
export LOBE_API_KEY=$SEED_API_KEY
|
||||
export LOBEHUB_CLI_API_KEY="\${LOBE_API_KEY}"
|
||||
export LOBEHUB_SERVER=$SERVER_URL
|
||||
export LOBEHUB_CLI_HOME=.lobehub-dev
|
||||
EOF
|
||||
}
|
||||
|
||||
write_cli_settings() {
|
||||
mkdir -p "$CLI_HOME"
|
||||
python3 - "$CLI_HOME/settings.json" "$SERVER_URL" << 'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
path, server_url = sys.argv[1], sys.argv[2]
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
json.dump({"serverUrl": server_url}, f, indent=2)
|
||||
f.write("\n")
|
||||
os.chmod(path, 0o600)
|
||||
PY
|
||||
}
|
||||
|
||||
cmd_cli_seed() {
|
||||
check_server || return 1
|
||||
write_cli_seed_env
|
||||
write_cli_settings
|
||||
ok "wrote CLI seed env: $CLI_ENV_FILE"
|
||||
note "source it before CLI commands: source $CLI_ENV_FILE"
|
||||
note "settings saved at: $CLI_HOME/settings.json"
|
||||
LOBE_API_KEY="$SEED_API_KEY" LOBEHUB_CLI_API_KEY="$SEED_API_KEY" check_cli
|
||||
}
|
||||
|
||||
cmd_open_chrome() {
|
||||
local mode="${1:-}"
|
||||
if [[ "$mode" != "" && "$mode" != "--dry-run" ]]; then
|
||||
echo "unknown open-chrome option: $mode" >&2
|
||||
usage >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "--dry-run" ]]; then
|
||||
echo "would open Google Chrome at $SERVER_URL/"
|
||||
echo "would press Cmd+Option+I to open DevTools"
|
||||
echo "would open DevTools command menu and run 'Show Network'"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
bad "open-chrome is macOS-only"
|
||||
note "open $SERVER_URL/ in your browser and open DevTools manually"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! command -v osascript > /dev/null 2>&1; then
|
||||
bad "osascript not found"
|
||||
note "open $SERVER_URL/ in Chrome and press Cmd+Option+I manually"
|
||||
return 1
|
||||
fi
|
||||
|
||||
SERVER_URL="$SERVER_URL" osascript << 'OSA'
|
||||
set targetUrl to (system attribute "SERVER_URL") & "/"
|
||||
|
||||
tell application "Google Chrome"
|
||||
activate
|
||||
if (count of windows) = 0 then
|
||||
make new window
|
||||
end if
|
||||
tell front window to make new tab with properties {URL:targetUrl}
|
||||
end tell
|
||||
|
||||
delay 1
|
||||
|
||||
tell application "System Events"
|
||||
tell process "Google Chrome"
|
||||
set frontmost to true
|
||||
keystroke "i" using {command down, option down}
|
||||
delay 1
|
||||
keystroke "p" using {command down, shift down}
|
||||
delay 0.2
|
||||
keystroke "Show Network"
|
||||
key code 36
|
||||
end tell
|
||||
end tell
|
||||
OSA
|
||||
ok "opened Chrome at $SERVER_URL/ and requested DevTools Network panel"
|
||||
}
|
||||
|
||||
cookie_header_from_jar() {
|
||||
local jar="$1"
|
||||
awk '
|
||||
BEGIN { first = 1 }
|
||||
/^$/ { next }
|
||||
/^#/ {
|
||||
if ($0 !~ /^#HttpOnly_/) next
|
||||
sub(/^#HttpOnly_/, "")
|
||||
}
|
||||
NF >= 7 {
|
||||
if (!first) printf "; "
|
||||
printf "%s=%s", $6, $7
|
||||
first = 0
|
||||
}
|
||||
END {
|
||||
if (!first) printf "\n"
|
||||
}
|
||||
' "$jar"
|
||||
}
|
||||
|
||||
# Build a Playwright storageState file from a raw Cookie header on stdin,
|
||||
# keeping only the better-auth cookies. See references/auth.md for why the
|
||||
# header must come from a Network request (HttpOnly) and why httpOnly=false.
|
||||
cmd_web() {
|
||||
mkdir -p "$AUTH_DIR"
|
||||
python3 - "$STATE_FILE" << 'PY'
|
||||
import json, sys, time
|
||||
local raw
|
||||
raw="$(cat)"
|
||||
COOKIE_INPUT="$raw" python3 - "$STATE_FILE" << 'PY'
|
||||
import json, os, sys, time
|
||||
|
||||
raw = sys.stdin.read().strip()
|
||||
if raw.lower().startswith("cookie:"):
|
||||
raw = raw.split(":", 1)[1].strip()
|
||||
raw = os.environ.get("COOKIE_INPUT", "").strip()
|
||||
cookie_lines = []
|
||||
for line in raw.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.lower().startswith("cookie:"):
|
||||
cookie_lines.append(stripped.split(":", 1)[1].strip())
|
||||
else:
|
||||
cookie_lines.append(stripped)
|
||||
|
||||
WANTED = {"better-auth.session_token", "better-auth.state"}
|
||||
raw = "; ".join(cookie_lines)
|
||||
|
||||
WANTED = {"better-auth.session_token", "better-auth.session_data", "better-auth.state"}
|
||||
exp = int(time.time()) + 30 * 24 * 3600 # 30 days
|
||||
|
||||
cookies = []
|
||||
for pair in raw.split("; "):
|
||||
for pair in raw.split(";"):
|
||||
pair = pair.strip()
|
||||
if "=" not in pair:
|
||||
continue
|
||||
name, _, value = pair.partition("=")
|
||||
@@ -146,14 +450,79 @@ with open(sys.argv[1], "w") as f:
|
||||
json.dump({"cookies": cookies, "origins": []}, f, indent=2)
|
||||
print(f"wrote {len(cookies)} cookie(s) to {sys.argv[1]}")
|
||||
PY
|
||||
agent-browser --session "$SESSION" state load "$STATE_FILE"
|
||||
cmd_web_verify
|
||||
}
|
||||
|
||||
cmd_web_seed() {
|
||||
check_server || return 1
|
||||
mkdir -p "$AUTH_DIR"
|
||||
|
||||
local cookie_jar="$AUTH_DIR/web-seed-cookie.jar"
|
||||
local response_body="$AUTH_DIR/web-seed-response.json"
|
||||
local payload code
|
||||
payload="$(
|
||||
SEED_EMAIL="$SEED_EMAIL" SEED_PASSWORD="$SEED_PASSWORD" python3 - << 'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"callbackURL": "/",
|
||||
"email": os.environ["SEED_EMAIL"],
|
||||
"password": os.environ["SEED_PASSWORD"],
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
|
||||
code=$(curl -sS -o "$response_body" -w '%{http_code}' \
|
||||
-c "$cookie_jar" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST "$SERVER_URL/api/auth/sign-in/email" \
|
||||
--data "$payload" 2> /dev/null || true)
|
||||
|
||||
if [[ ! "$code" =~ ^[23] ]]; then
|
||||
bad "seed user sign-in failed at $SERVER_URL/api/auth/sign-in/email (http_code='$code')"
|
||||
note "make sure the seed user exists:"
|
||||
note "./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local cookie_header
|
||||
cookie_header="$(cookie_header_from_jar "$cookie_jar")"
|
||||
if [[ -z "$cookie_header" ]]; then
|
||||
bad "seed sign-in succeeded but no cookies were written to $cookie_jar"
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$cookie_header" | cmd_web
|
||||
}
|
||||
|
||||
cmd_web_verify() {
|
||||
agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null
|
||||
local skip_server_check="${1:-}"
|
||||
if [[ "$skip_server_check" != "--skip-server-check" ]]; then
|
||||
check_server || return 1
|
||||
fi
|
||||
if [[ ! -f "$STATE_FILE" ]]; then
|
||||
bad "no web auth state for agent-browser"
|
||||
note "for the seeded local user, run: $0 web-seed"
|
||||
note "or copy the Cookie header from Chrome DevTools (Network tab), then:"
|
||||
note "pbpaste | $0 web"
|
||||
return 1
|
||||
fi
|
||||
check_agent_browser || return 1
|
||||
if ! agent-browser --session "$SESSION" state load "$STATE_FILE" > /dev/null; then
|
||||
bad "failed to load web auth state into agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
if ! agent-browser --session "$SESSION" open "$SERVER_URL/" > /dev/null; then
|
||||
bad "failed to open $SERVER_URL in agent-browser session '$SESSION'"
|
||||
return 1
|
||||
fi
|
||||
local url
|
||||
url=$(agent-browser --session "$SESSION" get url)
|
||||
url=$(agent-browser --session "$SESSION" get url 2> /dev/null || true)
|
||||
if [[ -z "$url" ]]; then
|
||||
bad "agent-browser session '$SESSION' did not report a current URL"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$url" == *"/signin"* || "$url" == *"/login"* ]]; then
|
||||
bad "agent-browser session '$SESSION' NOT authenticated (landed on $url)"
|
||||
note "re-copy the Cookie header and re-run: pbpaste | $0 web"
|
||||
@@ -163,12 +532,22 @@ cmd_web_verify() {
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
status) cmd_status ;;
|
||||
status)
|
||||
shift || true
|
||||
cmd_status "$@"
|
||||
;;
|
||||
cli-seed) cmd_cli_seed ;;
|
||||
cli) cmd_cli ;;
|
||||
open-chrome)
|
||||
shift || true
|
||||
cmd_open_chrome "$@"
|
||||
;;
|
||||
web-seed) cmd_web_seed ;;
|
||||
web) cmd_web ;;
|
||||
web-verify) cmd_web_verify ;;
|
||||
-h|--help) usage ;;
|
||||
*)
|
||||
echo "Usage: $0 {status|cli|web|web-verify}" >&2
|
||||
echo "Usage: $0 {status|cli-seed|cli|open-chrome|web-seed|web|web-verify}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke tests for setup-auth.sh. Uses a temporary agent-browser stub and local
|
||||
# HTTP server, so it does not need real browser auth.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPT="$SCRIPT_DIR/setup-auth.sh"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local text="$2"
|
||||
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
|
||||
}
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
server_pid=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$server_pid" ]]; then
|
||||
kill "$server_pid" > /dev/null 2>&1 || true
|
||||
wait "$server_pid" > /dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
export HOME="$tmp_dir/home"
|
||||
|
||||
port="$(python3 - << 'PY'
|
||||
import socket
|
||||
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
print(sock.getsockname()[1])
|
||||
sock.close()
|
||||
PY
|
||||
)"
|
||||
|
||||
python3 - "$port" << 'PY' > "$tmp_dir/http.log" 2>&1 &
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import sys
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/v1/users/me"):
|
||||
if self.headers.get("authorization") != "Bearer sk-lh-agenttesting0001":
|
||||
self.send_response(401)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"success":false}')
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"success":true,"data":{"id":"user_agent_testing_001"}}')
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok")
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length") or "0")
|
||||
if length:
|
||||
self.rfile.read(length)
|
||||
|
||||
if self.path != "/api/auth/sign-in/email":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
"better-auth.session_token=seed.token; Path=/; HttpOnly; SameSite=Lax",
|
||||
)
|
||||
self.send_header(
|
||||
"Set-Cookie",
|
||||
"better-auth.session_data=seed.data; Path=/; HttpOnly; SameSite=Lax",
|
||||
)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"ok":true}')
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
|
||||
ThreadingHTTPServer(("localhost", int(sys.argv[1])), Handler).serve_forever()
|
||||
PY
|
||||
server_pid="$!"
|
||||
|
||||
server_url="http://localhost:$port"
|
||||
for _ in {1..50}; do
|
||||
if curl -s -o /dev/null "$server_url/"; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
curl -s -o /dev/null "$server_url/" || fail "test HTTP server did not start"
|
||||
|
||||
mkdir -p "$tmp_dir/bin" "$tmp_dir/auth"
|
||||
cat > "$tmp_dir/bin/agent-browser" << 'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "--session" ]]; then
|
||||
shift 2
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
state)
|
||||
[[ "${2:-}" == "load" ]] || exit 2
|
||||
[[ -f "${3:-}" ]] || exit 1
|
||||
;;
|
||||
open)
|
||||
printf '%s\n' "${2:-}" > "${AGENT_BROWSER_URL_FILE:?}"
|
||||
;;
|
||||
get)
|
||||
[[ "${2:-}" == "url" ]] || exit 2
|
||||
cat "${AGENT_BROWSER_URL_FILE:?}"
|
||||
;;
|
||||
*)
|
||||
echo "unexpected agent-browser command: $*" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$tmp_dir/bin/agent-browser"
|
||||
|
||||
export PATH="$tmp_dir/bin:$PATH"
|
||||
export AUTH_DIR="$tmp_dir/auth"
|
||||
export SESSION="setup-auth-test"
|
||||
export SERVER_URL="$server_url"
|
||||
export AGENT_BROWSER_URL_FILE="$tmp_dir/current-url"
|
||||
|
||||
cookie_header="Cookie: foo=bar; better-auth.session_token=test.token; better-auth.session_data=encoded%3D; theme=dark"
|
||||
printf '%s\n' "$cookie_header" | "$SCRIPT" web > "$tmp_dir/web.out"
|
||||
|
||||
python3 - "$AUTH_DIR/web-state.json" << 'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
state = json.load(f)
|
||||
|
||||
names = {cookie["name"] for cookie in state["cookies"]}
|
||||
expected = {"better-auth.session_token", "better-auth.session_data"}
|
||||
if names != expected:
|
||||
raise SystemExit(f"unexpected cookies: {sorted(names)}")
|
||||
PY
|
||||
|
||||
"$SCRIPT" web-seed > "$tmp_dir/web-seed.out"
|
||||
|
||||
python3 - "$AUTH_DIR/web-state.json" << 'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1]) as f:
|
||||
state = json.load(f)
|
||||
|
||||
values = {cookie["name"]: cookie["value"] for cookie in state["cookies"]}
|
||||
expected = {
|
||||
"better-auth.session_token": "seed.token",
|
||||
"better-auth.session_data": "seed.data",
|
||||
}
|
||||
if values != expected:
|
||||
raise SystemExit(f"unexpected seeded cookies: {values}")
|
||||
PY
|
||||
|
||||
"$SCRIPT" status --surface web > "$tmp_dir/status.out"
|
||||
assert_contains "$tmp_dir/status.out" "surface=web"
|
||||
assert_contains "$tmp_dir/status.out" "web auth green"
|
||||
|
||||
"$SCRIPT" cli-seed > "$tmp_dir/cli-seed.out"
|
||||
assert_contains "$tmp_dir/cli-seed.out" "CLI API-key auth valid"
|
||||
assert_contains "$tmp_dir/cli-seed.out" "settings saved at: $HOME/.lobehub-dev/settings.json"
|
||||
|
||||
if "$SCRIPT" status --surface cli > "$tmp_dir/cli-no-env.out"; then
|
||||
fail "cli status without API key unexpectedly passed"
|
||||
fi
|
||||
assert_contains "$tmp_dir/cli-no-env.out" "CLI not logged in"
|
||||
|
||||
LOBEHUB_CLI_API_KEY=sk-lh-agenttesting0001 "$SCRIPT" status --surface cli > "$tmp_dir/cli-status.out"
|
||||
assert_contains "$tmp_dir/cli-status.out" "CLI API-key auth valid"
|
||||
assert_contains "$tmp_dir/cli-status.out" "cli auth green"
|
||||
|
||||
if printf 'foo=bar\n' | "$SCRIPT" web > "$tmp_dir/invalid.out" 2> "$tmp_dir/invalid.err"; then
|
||||
fail "invalid cookie unexpectedly passed"
|
||||
fi
|
||||
assert_contains "$tmp_dir/invalid.err" "no better-auth cookies found"
|
||||
|
||||
echo "setup-auth tests passed"
|
||||
+377
@@ -0,0 +1,377 @@
|
||||
#!/usr/bin/env bash
|
||||
# Print the resolved local test environment for agent-testing.
|
||||
#
|
||||
# This is intentionally read-only. It mirrors scripts/runWithEnv.mts precedence:
|
||||
# .env -> .env.$NODE_ENV -> .env.local -> .env.$NODE_ENV.local, then shell env.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
NODE_ENV="${NODE_ENV:-development}"
|
||||
|
||||
VALUE_APP_URL=""
|
||||
VALUE_PORT=""
|
||||
VALUE_SERVER_URL=""
|
||||
VALUE_AUTH_TRUSTED_ORIGINS=""
|
||||
VALUE_SPA_PORT=""
|
||||
VALUE_MOBILE_SPA_PORT=""
|
||||
VALUE_DESKTOP_PORT=""
|
||||
|
||||
SOURCE_APP_URL=""
|
||||
SOURCE_PORT=""
|
||||
SOURCE_SERVER_URL=""
|
||||
SOURCE_AUTH_TRUSTED_ORIGINS=""
|
||||
SOURCE_SPA_PORT=""
|
||||
SOURCE_MOBILE_SPA_PORT=""
|
||||
SOURCE_DESKTOP_PORT=""
|
||||
|
||||
LOADED_ENV_FILES=""
|
||||
|
||||
keys() {
|
||||
printf '%s\n' \
|
||||
APP_URL \
|
||||
PORT \
|
||||
SERVER_URL \
|
||||
AUTH_TRUSTED_ORIGINS \
|
||||
SPA_PORT \
|
||||
MOBILE_SPA_PORT \
|
||||
DESKTOP_PORT
|
||||
}
|
||||
|
||||
trim() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
workspace_root() {
|
||||
local root="$REPO_ROOT"
|
||||
local name
|
||||
name="$(basename "$root")"
|
||||
|
||||
if [[ "$name" == "lobehub" ]]; then
|
||||
local parent parent_name
|
||||
parent="$(cd "$root/.." && pwd)"
|
||||
parent_name="$(basename "$parent")"
|
||||
if [[ "$parent_name" == lobehub-cloud* ]]; then
|
||||
root="$parent"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf '%s\n' "$root"
|
||||
}
|
||||
|
||||
workspace_offset() {
|
||||
local name="$1"
|
||||
|
||||
case "$name" in
|
||||
lobehub-cloud)
|
||||
printf '0\n'
|
||||
;;
|
||||
lobehub-cloud-*)
|
||||
local suffix="${name#lobehub-cloud-}"
|
||||
if [[ "$suffix" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s\n' "$((10#$suffix))"
|
||||
else
|
||||
printf '\n'
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf '\n'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
default_port() {
|
||||
local base="$1"
|
||||
local fallback="$2"
|
||||
local root name offset
|
||||
root="$(workspace_root)"
|
||||
name="$(basename "$root")"
|
||||
offset="$(workspace_offset "$name")"
|
||||
|
||||
if [[ -n "$offset" ]]; then
|
||||
printf '%s\n' "$((base + offset))"
|
||||
else
|
||||
printf '%s\n' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
url_port() {
|
||||
local url="$1"
|
||||
local hostport
|
||||
hostport="${url#*://}"
|
||||
hostport="${hostport%%/*}"
|
||||
|
||||
if [[ "$hostport" == *:* ]]; then
|
||||
local port="${hostport##*:}"
|
||||
if [[ "$port" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s\n' "$port"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
url_origin() {
|
||||
local url="$1"
|
||||
local scheme rest hostport
|
||||
if [[ "$url" == *"://"* ]]; then
|
||||
scheme="${url%%://*}"
|
||||
rest="${url#*://}"
|
||||
hostport="${rest%%/*}"
|
||||
printf '%s://%s\n' "$scheme" "$hostport"
|
||||
else
|
||||
printf '%s\n' "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
set_value() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local source="$3"
|
||||
|
||||
case "$key" in
|
||||
APP_URL) VALUE_APP_URL="$value"; SOURCE_APP_URL="$source" ;;
|
||||
PORT) VALUE_PORT="$value"; SOURCE_PORT="$source" ;;
|
||||
SERVER_URL) VALUE_SERVER_URL="$value"; SOURCE_SERVER_URL="$source" ;;
|
||||
AUTH_TRUSTED_ORIGINS) VALUE_AUTH_TRUSTED_ORIGINS="$value"; SOURCE_AUTH_TRUSTED_ORIGINS="$source" ;;
|
||||
SPA_PORT) VALUE_SPA_PORT="$value"; SOURCE_SPA_PORT="$source" ;;
|
||||
MOBILE_SPA_PORT) VALUE_MOBILE_SPA_PORT="$value"; SOURCE_MOBILE_SPA_PORT="$source" ;;
|
||||
DESKTOP_PORT) VALUE_DESKTOP_PORT="$value"; SOURCE_DESKTOP_PORT="$source" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
value_for() {
|
||||
case "$1" in
|
||||
APP_URL) printf '%s\n' "$VALUE_APP_URL" ;;
|
||||
PORT) printf '%s\n' "$VALUE_PORT" ;;
|
||||
SERVER_URL) printf '%s\n' "$VALUE_SERVER_URL" ;;
|
||||
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$VALUE_AUTH_TRUSTED_ORIGINS" ;;
|
||||
SPA_PORT) printf '%s\n' "$VALUE_SPA_PORT" ;;
|
||||
MOBILE_SPA_PORT) printf '%s\n' "$VALUE_MOBILE_SPA_PORT" ;;
|
||||
DESKTOP_PORT) printf '%s\n' "$VALUE_DESKTOP_PORT" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
source_for() {
|
||||
case "$1" in
|
||||
APP_URL) printf '%s\n' "$SOURCE_APP_URL" ;;
|
||||
PORT) printf '%s\n' "$SOURCE_PORT" ;;
|
||||
SERVER_URL) printf '%s\n' "$SOURCE_SERVER_URL" ;;
|
||||
AUTH_TRUSTED_ORIGINS) printf '%s\n' "$SOURCE_AUTH_TRUSTED_ORIGINS" ;;
|
||||
SPA_PORT) printf '%s\n' "$SOURCE_SPA_PORT" ;;
|
||||
MOBILE_SPA_PORT) printf '%s\n' "$SOURCE_MOBILE_SPA_PORT" ;;
|
||||
DESKTOP_PORT) printf '%s\n' "$SOURCE_DESKTOP_PORT" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_tracked_key() {
|
||||
case "$1" in
|
||||
APP_URL|PORT|SERVER_URL|AUTH_TRUSTED_ORIGINS|SPA_PORT|MOBILE_SPA_PORT|DESKTOP_PORT) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
parse_env_file() {
|
||||
local file="$1"
|
||||
local root="$2"
|
||||
local label="${file#$root/}"
|
||||
local line key value
|
||||
|
||||
[[ -f "$file" ]] || return 0
|
||||
if [[ -z "$LOADED_ENV_FILES" ]]; then
|
||||
LOADED_ENV_FILES="$label"
|
||||
else
|
||||
LOADED_ENV_FILES="$LOADED_ENV_FILES, $label"
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
line="$(trim "$line")"
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
if [[ "$line" == export[[:space:]]* ]]; then
|
||||
line="$(trim "${line#export}")"
|
||||
fi
|
||||
|
||||
[[ "$line" == *=* ]] || continue
|
||||
key="$(trim "${line%%=*}")"
|
||||
value="$(trim "${line#*=}")"
|
||||
is_tracked_key "$key" || continue
|
||||
|
||||
if [[ "$value" == \"*\" && "$value" == *\" && ${#value} -ge 2 ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
elif [[ "$value" == \'* && "$value" == *\' && ${#value} -ge 2 ]]; then
|
||||
value="${value:1:${#value}-2}"
|
||||
fi
|
||||
|
||||
set_value "$key" "$value" "$label"
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
apply_env_files() {
|
||||
local root="$1"
|
||||
parse_env_file "$root/.env" "$root"
|
||||
parse_env_file "$root/.env.$NODE_ENV" "$root"
|
||||
parse_env_file "$root/.env.local" "$root"
|
||||
parse_env_file "$root/.env.$NODE_ENV.local" "$root"
|
||||
}
|
||||
|
||||
apply_shell_overrides() {
|
||||
local key value
|
||||
while IFS= read -r key; do
|
||||
if [[ -n "${!key+x}" ]]; then
|
||||
value="${!key}"
|
||||
set_value "$key" "$value" "shell"
|
||||
fi
|
||||
done < <(keys)
|
||||
}
|
||||
|
||||
resolve_defaults() {
|
||||
local app_port spa_port mobile_spa_port desktop_port
|
||||
app_port="$(default_port 3020 3010)"
|
||||
spa_port="$(default_port 9800 9876)"
|
||||
mobile_spa_port="$(default_port 3810 3012)"
|
||||
desktop_port="$(default_port 3030 3015)"
|
||||
|
||||
if [[ -z "$VALUE_APP_URL" ]]; then
|
||||
set_value APP_URL "http://localhost:$app_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_PORT" ]]; then
|
||||
if app_port="$(url_port "$VALUE_APP_URL")"; then
|
||||
set_value PORT "$app_port" "inferred from APP_URL"
|
||||
else
|
||||
set_value PORT "$(default_port 3020 3010)" "inferred"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_SERVER_URL" ]]; then
|
||||
set_value SERVER_URL "$VALUE_APP_URL" "from APP_URL"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_SPA_PORT" ]]; then
|
||||
set_value SPA_PORT "$spa_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_MOBILE_SPA_PORT" ]]; then
|
||||
set_value MOBILE_SPA_PORT "$mobile_spa_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_DESKTOP_PORT" ]]; then
|
||||
set_value DESKTOP_PORT "$desktop_port" "inferred"
|
||||
fi
|
||||
|
||||
if [[ -z "$VALUE_AUTH_TRUSTED_ORIGINS" ]]; then
|
||||
set_value AUTH_TRUSTED_ORIGINS "$(url_origin "$VALUE_APP_URL"),http://localhost:$VALUE_SPA_PORT" "inferred"
|
||||
fi
|
||||
}
|
||||
|
||||
contains_origin() {
|
||||
local list="$1"
|
||||
local expected="$2"
|
||||
local item
|
||||
IFS=',' read -r -a items <<< "$list"
|
||||
for item in "${items[@]}"; do
|
||||
item="$(trim "$item")"
|
||||
[[ "$item" == "$expected" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_exports() {
|
||||
local key value
|
||||
while IFS= read -r key; do
|
||||
value="$(value_for "$key")"
|
||||
printf 'export %s=%q\n' "$key" "$value"
|
||||
done < <(keys)
|
||||
}
|
||||
|
||||
print_value() {
|
||||
local key="$1"
|
||||
if ! is_tracked_key "$key"; then
|
||||
echo "unknown key: $key" >&2
|
||||
exit 2
|
||||
fi
|
||||
value_for "$key"
|
||||
}
|
||||
|
||||
print_human() {
|
||||
local root="$1"
|
||||
local key value source
|
||||
|
||||
echo "agent-testing test env:"
|
||||
printf ' workspace: %s\n' "$root"
|
||||
printf ' NODE_ENV: %s\n' "$NODE_ENV"
|
||||
printf ' env files: %s\n' "${LOADED_ENV_FILES:-none}"
|
||||
echo
|
||||
echo "resolved values:"
|
||||
while IFS= read -r key; do
|
||||
value="$(value_for "$key")"
|
||||
source="$(source_for "$key")"
|
||||
printf ' %-22s %s (%s)\n' "$key=$value" "" "$source"
|
||||
done < <(keys)
|
||||
echo
|
||||
echo "checks:"
|
||||
|
||||
local app_origin spa_origin app_port
|
||||
app_origin="$(url_origin "$VALUE_APP_URL")"
|
||||
spa_origin="http://localhost:$VALUE_SPA_PORT"
|
||||
if app_port="$(url_port "$VALUE_APP_URL")" && [[ "$app_port" == "$VALUE_PORT" ]]; then
|
||||
printf ' OK PORT matches APP_URL (%s)\n' "$VALUE_PORT"
|
||||
else
|
||||
printf ' WARN PORT (%s) does not match APP_URL (%s)\n' "$VALUE_PORT" "$VALUE_APP_URL"
|
||||
fi
|
||||
|
||||
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$app_origin"; then
|
||||
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$app_origin"
|
||||
else
|
||||
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$app_origin"
|
||||
fi
|
||||
|
||||
if contains_origin "$VALUE_AUTH_TRUSTED_ORIGINS" "$spa_origin"; then
|
||||
printf ' OK AUTH_TRUSTED_ORIGINS includes %s\n' "$spa_origin"
|
||||
else
|
||||
printf ' WARN AUTH_TRUSTED_ORIGINS is missing %s\n' "$spa_origin"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat << EOF
|
||||
Usage:
|
||||
$0 # print resolved test environment
|
||||
$0 --exports # print source-able export lines
|
||||
$0 --value KEY # print one resolved value
|
||||
|
||||
Tracked keys:
|
||||
APP_URL PORT SERVER_URL AUTH_TRUSTED_ORIGINS SPA_PORT MOBILE_SPA_PORT DESKTOP_PORT
|
||||
EOF
|
||||
}
|
||||
|
||||
ROOT="$(workspace_root)"
|
||||
apply_env_files "$ROOT"
|
||||
apply_shell_overrides
|
||||
resolve_defaults
|
||||
|
||||
case "${1:-}" in
|
||||
"")
|
||||
print_human "$ROOT"
|
||||
;;
|
||||
--exports)
|
||||
print_exports
|
||||
;;
|
||||
--value)
|
||||
print_value "${2:-}"
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke tests for test-env.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
assert_eq() {
|
||||
local actual="$1"
|
||||
local expected="$2"
|
||||
[[ "$actual" == "$expected" ]] || fail "expected '$expected', got '$actual'"
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local file="$1"
|
||||
local text="$2"
|
||||
grep -Fq "$text" "$file" || fail "expected '$text' in $file"
|
||||
}
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
mkdir -p "$tmp_dir/lobehub-cloud-1/.agents/skills" "$tmp_dir/lobehub/.agents/skills"
|
||||
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing"
|
||||
ln -s "$SCRIPT_DIR/.." "$tmp_dir/lobehub/.agents/skills/agent-testing"
|
||||
|
||||
cloud_script="$tmp_dir/lobehub-cloud-1/.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
oss_script="$tmp_dir/lobehub/.agents/skills/agent-testing/scripts/test-env.sh"
|
||||
|
||||
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:3021"
|
||||
assert_eq "$("$cloud_script" --value SPA_PORT)" "9801"
|
||||
assert_eq "$("$cloud_script" --value MOBILE_SPA_PORT)" "3811"
|
||||
assert_eq "$("$cloud_script" --value DESKTOP_PORT)" "3031"
|
||||
assert_eq "$("$oss_script" --value SERVER_URL)" "http://localhost:3010"
|
||||
|
||||
cat > "$tmp_dir/lobehub-cloud-1/.env" << 'EOF'
|
||||
APP_URL=http://localhost:4123
|
||||
PORT=4123
|
||||
AUTH_TRUSTED_ORIGINS=http://localhost:4123,http://localhost:9823
|
||||
SPA_PORT=9823
|
||||
MOBILE_SPA_PORT=3823
|
||||
DESKTOP_PORT=3043
|
||||
EOF
|
||||
|
||||
assert_eq "$("$cloud_script" --value SERVER_URL)" "http://localhost:4123"
|
||||
assert_eq "$("$cloud_script" --value SPA_PORT)" "9823"
|
||||
"$cloud_script" --exports > "$tmp_dir/exports.out"
|
||||
assert_contains "$tmp_dir/exports.out" "export APP_URL=http://localhost:4123"
|
||||
assert_contains "$tmp_dir/exports.out" "export SERVER_URL=http://localhost:4123"
|
||||
assert_contains "$tmp_dir/exports.out" "export AUTH_TRUSTED_ORIGINS=http://localhost:4123\\,http://localhost:9823"
|
||||
|
||||
echo "test-env tests passed"
|
||||
@@ -10,23 +10,32 @@ backend-only changes prefer [../cli/index.md](../cli/index.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Complete [Step 0.0](../SKILL.md#00-resolve-the-current-test-environment) (resolve ports) and [Step -1](../SKILL.md#step--1--plan-approval-for-non-trivial-tests) (plan approval) first.
|
||||
- Local dev server running — [../references/dev-server.md](../references/dev-server.md)
|
||||
- Web auth injected into agent-browser — [../references/auth.md](../references/auth.md):
|
||||
- Web auth verified in agent-browser — prefer `setup-auth.sh web-seed`, see [auth decision flow](../references/auth.md#web--decision-flow).
|
||||
|
||||
## Option A — agent-browser with seeded auth (recommended)
|
||||
|
||||
```bash
|
||||
pbpaste | ./.agents/skills/agent-testing/scripts/setup-auth.sh web # after copying the Cookie header
|
||||
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
|
||||
./.agents/skills/agent-testing/scripts/setup-auth.sh web-seed
|
||||
```
|
||||
|
||||
## Option A — agent-browser with injected auth (recommended)
|
||||
Then drive the verified session:
|
||||
|
||||
```bash
|
||||
SESSION=lobehub-dev
|
||||
|
||||
agent-browser --session $SESSION open "http://localhost:3010/"
|
||||
agent-browser --session $SESSION open "$SERVER_URL/"
|
||||
agent-browser --session $SESSION snapshot -i
|
||||
# interact via refs — full command reference: ../references/agent-browser.md
|
||||
```
|
||||
|
||||
Use this session as the evidence source. Do not use ordinary Chrome screenshots
|
||||
or Chrome Network records as proof for Web tests; ordinary Chrome is only a
|
||||
fallback source for copying cookies into agent-browser when the seeded login is
|
||||
not available.
|
||||
|
||||
### Watch the API while driving the UI
|
||||
|
||||
```bash
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lobechat/agent-gateway-client": "workspace:*",
|
||||
"@lobechat/device-control": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/heterogeneous-agents": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
packages:
|
||||
- '../../packages/agent-gateway-client'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/heterogeneous-agents'
|
||||
|
||||
@@ -2,9 +2,16 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
defaultGetLocalFilePreview,
|
||||
defaultGetProjectFileIndex,
|
||||
type DeviceControlDeps,
|
||||
executeDeviceRpc,
|
||||
} from '@lobechat/device-control';
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
DeviceSystemInfo,
|
||||
RpcRequestMessage,
|
||||
SystemInfoRequestMessage,
|
||||
ToolCallRequestMessage,
|
||||
} from '@lobechat/device-gateway-client';
|
||||
@@ -292,6 +299,31 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle generic server-internal device RPCs (git / workspace / file ops).
|
||||
// Shares the `@lobechat/device-control` dispatcher with the desktop app so the
|
||||
// CLI exposes the same remote-device control surface. File preview / index use
|
||||
// the package's portable defaults (no preview-protocol approval on the CLI).
|
||||
const deviceControlDeps: DeviceControlDeps = {
|
||||
getLocalFilePreview: defaultGetLocalFilePreview,
|
||||
getProjectFileIndex: defaultGetProjectFileIndex,
|
||||
};
|
||||
|
||||
client.on('rpc_request', async (request: RpcRequestMessage) => {
|
||||
const { method, params, requestId } = request;
|
||||
if (isDaemonChild) appendLog(`[RPC] ${method} (${requestId})`);
|
||||
else info(`Received rpc_request: method=${method} (${requestId})`);
|
||||
|
||||
try {
|
||||
const data = await executeDeviceRpc(method, params, deviceControlDeps);
|
||||
client.sendRpcResponse({ requestId, result: { data, success: true } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (isDaemonChild) appendLog(`[RPC ERROR] ${method}: ${message} (${requestId})`);
|
||||
else error(`rpc_request method=${method} failed: ${message}`);
|
||||
client.sendRpcResponse({ requestId, result: { error: message, success: false } });
|
||||
}
|
||||
});
|
||||
|
||||
// Handle gateway-dispatched agent runs (heterogeneous agents, e.g. Claude
|
||||
// Code). Mirrors the desktop app: spawn `lh hetero exec`, which owns the full
|
||||
// execution + server-ingest pipeline. Ack with the spawn outcome — `accepted`
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@lobechat/chat-adapter-imessage": "workspace:*",
|
||||
"@lobechat/desktop-bridge": "workspace:*",
|
||||
"@lobechat/device-control": "workspace:*",
|
||||
"@lobechat/device-gateway-client": "workspace:*",
|
||||
"@lobechat/device-identity": "workspace:*",
|
||||
"@lobechat/electron-client-ipc": "workspace:*",
|
||||
|
||||
@@ -8,6 +8,7 @@ packages:
|
||||
- '../../packages/electron-client-ipc'
|
||||
- '../../packages/file-loaders'
|
||||
- '../../packages/desktop-bridge'
|
||||
- '../../packages/device-control'
|
||||
- '../../packages/device-gateway-client'
|
||||
- '../../packages/device-identity'
|
||||
- '../../packages/local-file-shell'
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type DeviceControlDeps, executeDeviceRpc as runDeviceRpc } from '@lobechat/device-control';
|
||||
import type {
|
||||
AgentRunRequestMessage,
|
||||
GatewayMcpStdioParams,
|
||||
@@ -13,11 +14,8 @@ import type {
|
||||
GetCommandOutputParams,
|
||||
GlobFilesParams,
|
||||
GrepContentParams,
|
||||
InitWorkspaceParams,
|
||||
KillCommandParams,
|
||||
ListLocalFileParams,
|
||||
ListProjectSkillsParams,
|
||||
LocalFilePreviewUrlParams,
|
||||
LocalReadFileParams,
|
||||
LocalReadFilesParams,
|
||||
LocalSearchFilesParams,
|
||||
@@ -30,15 +28,16 @@ import { type ILocalSystemService, LocalSystemExecutionRuntime } from '@lobechat
|
||||
|
||||
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
|
||||
import ImessageBridgeService from '@/services/imessageBridgeSrv';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import GitCtr from './GitCtr';
|
||||
import HeterogeneousAgentCtr from './HeterogeneousAgentCtr';
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
import LocalFileCtr from './LocalFileCtr';
|
||||
import McpCtr from './McpCtr';
|
||||
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
|
||||
import ShellCommandCtr from './ShellCommandCtr';
|
||||
import WorkspaceCtr from './WorkspaceCtr';
|
||||
|
||||
const logger = createLogger('controllers:GatewayConnectionCtr');
|
||||
|
||||
/**
|
||||
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
|
||||
@@ -167,14 +166,6 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.app.getController(LocalFileCtr);
|
||||
}
|
||||
|
||||
private get workspaceCtr() {
|
||||
return this.app.getController(WorkspaceCtr);
|
||||
}
|
||||
|
||||
private get gitCtr() {
|
||||
return this.app.getController(GitCtr);
|
||||
}
|
||||
|
||||
private get shellCommandCtr() {
|
||||
return this.app.getController(ShellCommandCtr);
|
||||
}
|
||||
@@ -353,99 +344,33 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
return this.localSystemRuntime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-specific handlers the shared `@lobechat/device-control` dispatcher
|
||||
* delegates to. Git + workspace-scan methods run inside device-control over
|
||||
* `@lobechat/local-file-shell`; only file preview / index (and preview
|
||||
* approval) are desktop-specific and routed back to the controllers here.
|
||||
*/
|
||||
private get deviceControlDeps(): DeviceControlDeps {
|
||||
return {
|
||||
approveProjectRoot: async (root) => {
|
||||
try {
|
||||
await this.app.localFileProtocolManager.approveIndexedProjectRoot(root);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to approve project preview root ${root}:`, error);
|
||||
}
|
||||
},
|
||||
getLocalFilePreview: (params) => this.localFileCtr.getLocalFilePreview(params),
|
||||
getProjectFileIndex: (params) => this.localFileCtr.getProjectFileIndex(params),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a generic server-internal device RPC (not an agent tool call) by
|
||||
* method name. Currently only `initWorkspace` (scan the bound project root for
|
||||
* skills + AGENTS.md); add new server-only device methods here.
|
||||
* method name. The dispatch logic lives in `@lobechat/device-control` so the
|
||||
* desktop main process and the CLI daemon share one device RPC surface.
|
||||
*/
|
||||
private async executeDeviceRpc(method: string, params: unknown): Promise<unknown> {
|
||||
switch (method) {
|
||||
case 'initWorkspace': {
|
||||
return this.workspaceCtr.initWorkspace(params as InitWorkspaceParams);
|
||||
}
|
||||
|
||||
case 'getGitBranch': {
|
||||
return this.gitCtr.getGitBranch((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getLinkedPullRequest': {
|
||||
return this.gitCtr.getLinkedPullRequest(params as { branch: string; path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeStatus': {
|
||||
return this.gitCtr.getGitWorkingTreeStatus((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitAheadBehind': {
|
||||
return this.gitCtr.getGitAheadBehind((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'listGitBranches': {
|
||||
return this.gitCtr.listGitBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'checkoutGitBranch': {
|
||||
return this.gitCtr.checkoutGitBranch(
|
||||
params as { branch: string; create?: boolean; path: string },
|
||||
);
|
||||
}
|
||||
|
||||
case 'renameGitBranch': {
|
||||
return this.gitCtr.renameGitBranch(params as { from: string; path: string; to: string });
|
||||
}
|
||||
|
||||
case 'deleteGitBranch': {
|
||||
return this.gitCtr.deleteGitBranch(params as { branch: string; path: string });
|
||||
}
|
||||
|
||||
case 'pullGitBranch': {
|
||||
return this.gitCtr.pullGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'pushGitBranch': {
|
||||
return this.gitCtr.pushGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreePatches': {
|
||||
return this.gitCtr.getGitWorkingTreePatches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getGitWorkingTreeFiles': {
|
||||
return this.gitCtr.getGitWorkingTreeFiles((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'getProjectFileIndex': {
|
||||
return this.localFileCtr.getProjectFileIndex(params as { scope?: string });
|
||||
}
|
||||
|
||||
case 'getLocalFilePreview': {
|
||||
return this.localFileCtr.getLocalFilePreview(params as LocalFilePreviewUrlParams);
|
||||
}
|
||||
|
||||
case 'listProjectSkills': {
|
||||
return this.workspaceCtr.listProjectSkills(params as ListProjectSkillsParams);
|
||||
}
|
||||
|
||||
case 'getGitBranchDiff': {
|
||||
return this.gitCtr.getGitBranchDiff(params as { baseRef?: string; path: string });
|
||||
}
|
||||
|
||||
case 'listGitRemoteBranches': {
|
||||
return this.gitCtr.listGitRemoteBranches((params as { path: string }).path);
|
||||
}
|
||||
|
||||
case 'revertGitFile': {
|
||||
return this.gitCtr.revertGitFile(params as { filePath: string; path: string });
|
||||
}
|
||||
|
||||
case 'statPath': {
|
||||
return this.workspaceCtr.statPath(params as { path: string });
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown device RPC method: ${method}`);
|
||||
}
|
||||
}
|
||||
return runDeviceRpc(method, params, this.deviceControlDeps);
|
||||
}
|
||||
|
||||
private async executeToolCall(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,244 +1,53 @@
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
initWorkspace as runInitWorkspace,
|
||||
listProjectSkills as runListProjectSkills,
|
||||
statPath as runStatPath,
|
||||
type WorkspaceScanDeps,
|
||||
} from '@lobechat/device-control';
|
||||
import {
|
||||
type InitWorkspaceParams,
|
||||
type InitWorkspaceResult,
|
||||
type ListProjectSkillsParams,
|
||||
type ListProjectSkillsResult,
|
||||
type ProjectSkillItem,
|
||||
} from '@lobechat/electron-client-ipc';
|
||||
|
||||
import { detectRepoType } from '@/utils/git';
|
||||
import { createLogger } from '@/utils/logger';
|
||||
|
||||
import { ControllerModule, IpcMethod } from './index';
|
||||
|
||||
const logger = createLogger('controllers:WorkspaceCtr');
|
||||
|
||||
const SKILL_FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/;
|
||||
|
||||
// Cap recursion to guard against pathological directory trees.
|
||||
const MAX_SKILL_FILE_COUNT = 1000;
|
||||
|
||||
const toPosixRelativePath = (filePath: string) => filePath.split(path.sep).join('/');
|
||||
|
||||
const listSkillFilesRecursive = async (dir: string): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
const stack: string[] = [dir];
|
||||
|
||||
while (stack.length > 0 && results.length < MAX_SKILL_FILE_COUNT) {
|
||||
const current = stack.pop()!;
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(full);
|
||||
} else if (entry.isFile()) {
|
||||
results.push(toPosixRelativePath(path.relative(dir, full)));
|
||||
if (results.length >= MAX_SKILL_FILE_COUNT) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return results.sort();
|
||||
};
|
||||
|
||||
// Parse a minimal YAML frontmatter block for SKILL.md files.
|
||||
// Only handles `key: value` lines; multi-line block scalars fall back to the first line.
|
||||
const parseSkillFrontmatter = (raw: string): Record<string, string> => {
|
||||
const match = raw.match(SKILL_FRONTMATTER_RE);
|
||||
if (!match) return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of match[1].split(/\r?\n/)) {
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
if (!key || key.startsWith('#')) continue;
|
||||
let value = line.slice(colonIdx + 1).trim();
|
||||
if (value.startsWith('|') || value.startsWith('>')) continue;
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
fields[key] = value;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
/**
|
||||
* WorkspaceCtr
|
||||
*
|
||||
* Owns "project workspace" scanning: discovering agent skills (`.agents/skills`
|
||||
* / `.claude/skills`) and project-root instructions (`AGENTS.md` / `CLAUDE.md`)
|
||||
* under a bound project directory. Split out of LocalFileCtr so the
|
||||
* workspace/agent-config concern is distinct from generic local file ops.
|
||||
* Thin IPC layer over `@lobechat/device-control`'s workspace-scan helpers
|
||||
* (skills discovery under `.agents/skills` / `.claude/skills` + project-root
|
||||
* instructions). The scan logic is shared with the device-control RPC dispatch
|
||||
* so the local desktop IPC path, the remote device RPC, and the CLI all run
|
||||
* identical scans; the desktop-only preview-protocol approval is injected here.
|
||||
*/
|
||||
export default class WorkspaceCtr extends ControllerModule {
|
||||
static override readonly groupName = 'workspace';
|
||||
|
||||
/**
|
||||
* Scan one skill source directory (e.g. `.agents/skills`) under `root` and
|
||||
* return parsed frontmatter for each `SKILL.md`. Returns `[]` when the source
|
||||
* directory is absent or unreadable. Unsorted — callers sort/merge.
|
||||
*/
|
||||
private async scanSkillsInSource(
|
||||
root: string,
|
||||
source: ProjectSkillItem['source'],
|
||||
): Promise<ProjectSkillItem[]> {
|
||||
const dir = path.join(root, source);
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
// Directory does not exist or is not readable.
|
||||
return [];
|
||||
}
|
||||
|
||||
const skills = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map(async (entry) => {
|
||||
const skillDir = path.join(dir, entry.name);
|
||||
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||
try {
|
||||
const raw = await readFile(skillFile, 'utf8');
|
||||
const fields = parseSkillFrontmatter(raw);
|
||||
const files = await listSkillFilesRecursive(skillDir);
|
||||
return {
|
||||
description: fields.description || undefined,
|
||||
fileCount: files.length,
|
||||
files,
|
||||
name: fields.name || entry.name,
|
||||
path: skillFile,
|
||||
skillDir,
|
||||
source,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return skills.filter((skill): skill is ProjectSkillItem => skill !== null);
|
||||
private get scanDeps(): WorkspaceScanDeps {
|
||||
return { approveProjectRoot: (root) => this.approveProjectRootForPreview(root) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan agent skill directories under the project root and return parsed
|
||||
* frontmatter for each SKILL.md. Used by the hetero agent's working sidebar
|
||||
* to surface skills available in the current project. Returns the first
|
||||
* source directory that yields any skills (`.agents/skills` wins).
|
||||
*/
|
||||
@IpcMethod()
|
||||
async listProjectSkills(params: ListProjectSkillsParams): Promise<ListProjectSkillsResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
for (const source of sources) {
|
||||
const skills = (await this.scanSkillsInSource(root, source)).sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
if (skills.length > 0) {
|
||||
await this.approveProjectRootForPreview(root);
|
||||
return { root, skills, source };
|
||||
}
|
||||
}
|
||||
|
||||
return { root, skills: [], source: null };
|
||||
return runListProjectSkills(params, this.scanDeps);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-call "workspace init" scan of a bound project directory: merge the
|
||||
* project skills from BOTH `.agents/skills` and `.claude/skills` (deduped by
|
||||
* name, `.agents/skills` winning) and read the project-root agent
|
||||
* instructions file (`AGENTS.md`, else `CLAUDE.md`). Driven server-side at run
|
||||
* start via the generic device RPC (not an LLM-visible tool) and cached onto
|
||||
* `devices.workingDirs[].workspace`.
|
||||
*
|
||||
* Approves the root for the `lobe-file://` preview protocol (same as
|
||||
* `listProjectSkills`) so the user can later click through to the scanned
|
||||
* skills / instructions in the UI.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async initWorkspace(params: InitWorkspaceParams): Promise<InitWorkspaceResult> {
|
||||
const root = params.scope;
|
||||
const sources = ['.agents/skills', '.claude/skills'] as const;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const skills: ProjectSkillItem[] = [];
|
||||
for (const source of sources) {
|
||||
for (const skill of await this.scanSkillsInSource(root, source)) {
|
||||
if (seen.has(skill.name)) continue;
|
||||
seen.add(skill.name);
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const instructions = await this.readWorkspaceInstructions(root);
|
||||
|
||||
// Approve regardless of what was found — the run is now bound to this root,
|
||||
// so any later click-through to it should resolve through the preview
|
||||
// protocol even if the project carries neither skills nor instructions.
|
||||
await this.approveProjectRootForPreview(root);
|
||||
|
||||
return { instructions, root, skills };
|
||||
return runInitWorkspace(params, this.scanDeps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a path exists on this device and is a directory, plus its git
|
||||
* repo type (`git` / `github` / none). Used to validate a manually-entered
|
||||
* working directory from a web / remote client (which can't browse this
|
||||
* device's filesystem) before binding it, and to render the right dir icon.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async statPath(params: {
|
||||
path: string;
|
||||
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' }> {
|
||||
try {
|
||||
const stats = await stat(params.path);
|
||||
if (!stats.isDirectory()) return { exists: true, isDirectory: false };
|
||||
const repoType = await detectRepoType(params.path);
|
||||
return { exists: true, isDirectory: true, repoType };
|
||||
} catch {
|
||||
return { exists: false, isDirectory: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the project-root agent instructions files. Collects every present
|
||||
* candidate (`AGENTS.md`, then `CLAUDE.md`) rather than first-match, since both
|
||||
* can coexist. Each body is capped so a pathologically large file can't bloat
|
||||
* the cached `workingDirs` payload or the injected system role.
|
||||
*/
|
||||
private async readWorkspaceInstructions(
|
||||
root: string,
|
||||
): Promise<InitWorkspaceResult['instructions']> {
|
||||
const MAX_INSTRUCTIONS_BYTES = 64 * 1024;
|
||||
const candidates = ['AGENTS.md', 'CLAUDE.md'] as const;
|
||||
|
||||
const instructions: InitWorkspaceResult['instructions'] = [];
|
||||
for (const source of candidates) {
|
||||
try {
|
||||
const raw = await readFile(path.join(root, source), 'utf8');
|
||||
const content =
|
||||
raw.length > MAX_INSTRUCTIONS_BYTES ? raw.slice(0, MAX_INSTRUCTIONS_BYTES) : raw;
|
||||
instructions.push({ content, source });
|
||||
} catch {
|
||||
// File absent or unreadable; skip it.
|
||||
}
|
||||
}
|
||||
|
||||
return instructions;
|
||||
return runStatPath(params);
|
||||
}
|
||||
|
||||
private async approveProjectRootForPreview(root: string) {
|
||||
|
||||
@@ -9,10 +9,16 @@ import { KnowledgeBaseModel } from '@/database/models/knowledgeBase';
|
||||
import { SessionModel } from '@/database/models/session';
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import { AgentService } from '@/server/services/agent';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { KnowledgeType } from '@/types/knowledgeBase';
|
||||
|
||||
import { agentRouter } from '../agent';
|
||||
|
||||
vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
|
||||
vi.mock('@/database/models/user', () => ({
|
||||
UserModel: {
|
||||
findById: vi.fn(),
|
||||
@@ -329,4 +335,122 @@ describe('agentRouter', () => {
|
||||
expect(agentModelMock.update).toHaveBeenCalledWith(mockInput.id, { pinned: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit lock', () => {
|
||||
const wsCtx = () => ({ ...mockCtx, workspaceId: 'ws-1' });
|
||||
|
||||
describe('updateAgentConfig write guard', () => {
|
||||
it('rejects the update when another member holds the lock', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
|
||||
await expect(
|
||||
caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(agentServiceMock.updateAgentConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the update when no other member holds the lock', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
|
||||
|
||||
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalledWith('agent-1', {
|
||||
systemRole: 'x',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not check the lock for personal (non-workspace) agents', async () => {
|
||||
agentServiceMock.updateAgentConfig = vi.fn().mockResolvedValue({ id: 'agent-1' });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
const caller = agentRouter.createCaller(mockCtx);
|
||||
await caller.updateAgentConfig({ agentId: 'agent-1', value: { systemRole: 'x' } });
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(agentServiceMock.updateAgentConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireAgentLock', () => {
|
||||
it('returns unlocked without touching the lock service for personal agents', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const caller = agentRouter.createCaller(mockCtx);
|
||||
const result = await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'agent-1', type: 'agent' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.acquireAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentLock', () => {
|
||||
it('reports another member as the holder', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
const result = await caller.getAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseAgentLock', () => {
|
||||
it('broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.releaseAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'agent-1', type: 'agent' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
const caller = agentRouter.createCaller(wsCtx());
|
||||
await caller.releaseAgentLock({ agentId: 'agent-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,15 @@ import * as ChatGroupModelModule from '@/database/models/chatGroup';
|
||||
import * as UserModelModule from '@/database/models/user';
|
||||
import * as AgentGroupRepoModule from '@/database/repositories/agentGroup';
|
||||
import * as ChatGroupServiceModule from '@/server/services/agentGroup';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
|
||||
import { agentGroupRouter } from '../agentGroup';
|
||||
|
||||
vi.mock('@/server/services/resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
|
||||
describe('agentGroupRouter', () => {
|
||||
const userId = 'testUserId';
|
||||
let mockCtx: any;
|
||||
@@ -439,4 +445,126 @@ describe('agentGroupRouter', () => {
|
||||
expect(result).toEqual(mockUpdatedGroup);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit lock', () => {
|
||||
const wsCtx = () => ({ serverDB: {}, userId, workspaceId: 'ws-1' });
|
||||
|
||||
describe('updateGroup write guard', () => {
|
||||
it('rejects the update when another member holds the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
|
||||
await expect(
|
||||
caller.updateGroup({ id: 'group-1', value: { title: 'New' } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(chatGroupModelMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows the update when no other member holds the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
|
||||
|
||||
expect(chatGroupModelMock.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not check the lock for personal (non-workspace) groups', async () => {
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
chatGroupModelMock.update.mockResolvedValue({ id: 'group-1' });
|
||||
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
await caller.updateGroup({ id: 'group-1', value: { title: 'New' } });
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(chatGroupModelMock.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireGroupLock', () => {
|
||||
it('returns unlocked without touching the lock service for personal groups', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
const result = await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'group-1', type: 'chatGroup' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.acquireGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGroupLock', () => {
|
||||
it('reports another member as the holder', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue('other-user');
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
const result = await caller.getGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('returns unlocked for personal groups', async () => {
|
||||
const caller = agentGroupRouter.createCaller(mockCtx);
|
||||
const result = await caller.getGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseGroupLock', () => {
|
||||
it('broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.releaseGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'group-1', type: 'chatGroup' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
const caller = agentGroupRouter.createCaller(wsCtx());
|
||||
await caller.releaseGroupLock({ id: 'group-1' });
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@ import { workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AgentService } from '@/server/services/agent';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
|
||||
@@ -28,6 +30,7 @@ const agentProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
agentService: new AgentService(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
fileModel: new FileModel(ctx.serverDB, ctx.userId, wsId),
|
||||
knowledgeBaseModel: new KnowledgeBaseModel(ctx.serverDB, ctx.userId, wsId),
|
||||
sessionModel: new SessionModel(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -440,6 +443,19 @@ export const agentRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Collaborative edit lock: reject writes to a workspace agent another
|
||||
// member is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('agent', input.agentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Agent is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Use AgentService to update and return the updated agent data
|
||||
return ctx.agentService.updateAgentConfig(input.agentId, input.value);
|
||||
}),
|
||||
@@ -458,4 +474,48 @@ export const agentRouter = router({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
return ctx.agentModel.update(input.id, { pinned: input.pinned });
|
||||
}),
|
||||
|
||||
acquireAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
const result = await ctx.editLockService.acquire('agent', input.agentId);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('agent', input.agentId);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseAgentLock: agentProcedure
|
||||
.use(withScopedPermission('agent:update'))
|
||||
.input(z.object({ agentId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('agent', input.agentId);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.agentId, type: 'agent' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import { type ChatGroupConfig } from '@/database/types/chatGroup';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AgentGroupService } from '@/server/services/agentGroup';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TransferErrorCode } from '@/types/transferError';
|
||||
|
||||
/**
|
||||
@@ -55,6 +57,7 @@ const agentGroupProcedure = wsCompatProcedure.use(serverDatabase).use(async (opt
|
||||
agentGroupService: new AgentGroupService(ctx.serverDB, ctx.userId, wsId),
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
chatGroupModel: new ChatGroupModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
userModel: new UserModel(ctx.serverDB, ctx.userId),
|
||||
},
|
||||
});
|
||||
@@ -402,6 +405,19 @@ export const agentGroupRouter = router({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
// Collaborative edit lock: reject writes to a workspace group another
|
||||
// member is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('chatGroup', input.id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Group is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.chatGroupModel.update(input.id, {
|
||||
...input.value,
|
||||
config: ctx.agentGroupService.normalizeGroupConfig(
|
||||
@@ -409,6 +425,47 @@ export const agentGroupRouter = router({
|
||||
),
|
||||
});
|
||||
}),
|
||||
|
||||
acquireGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const prev = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
|
||||
const result = await ctx.editLockService.acquire('chatGroup', input.id);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: input.id, type: 'chatGroup' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await ctx.editLockService.getActiveHolder('chatGroup', input.id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseGroupLock: agentGroupProcedureWrite
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('chatGroup', input.id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: input.id, type: 'chatGroup' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
export type AgentGroupRouter = typeof agentGroupRouter;
|
||||
|
||||
@@ -253,6 +253,27 @@ export const documentRouter = router({
|
||||
return ctx.documentService.queryDocuments(input);
|
||||
}),
|
||||
|
||||
acquireDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return ctx.documentService.acquireDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
getDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.documentService.getDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
releaseDocumentLock: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.documentService.releaseDocumentLock(input.id);
|
||||
}),
|
||||
|
||||
updateDocument: documentProcedure
|
||||
.use(withScopedPermission('document:update'))
|
||||
.input(updateDocumentInputSchema)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { TopicModel } from '@/database/models/topic';
|
||||
import { workspaceMembers } from '@/database/schemas';
|
||||
import { router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { EditLockService } from '@/server/services/editLock';
|
||||
import { publishResourceEvent } from '@/server/services/resourceEvents';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
import { TaskLifecycleService } from '@/server/services/taskLifecycle';
|
||||
import { TaskRunnerService } from '@/server/services/taskRunner';
|
||||
@@ -26,6 +28,7 @@ const taskProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) =>
|
||||
ctx: {
|
||||
agentModel: new AgentModel(ctx.serverDB, ctx.userId, wsId),
|
||||
briefModel: new BriefModel(ctx.serverDB, ctx.userId, wsId),
|
||||
editLockService: new EditLockService(ctx.userId),
|
||||
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId, wsId),
|
||||
taskModel: new TaskModel(ctx.serverDB, ctx.userId, wsId),
|
||||
taskService: new TaskService(ctx.serverDB, ctx.userId, wsId),
|
||||
@@ -927,6 +930,20 @@ export const taskRouter = router({
|
||||
const model = ctx.taskModel;
|
||||
await assertAssigneeAgentBelongsToUser(ctx.agentModel, data.assigneeAgentId);
|
||||
const resolved = await resolveOrThrow(model, id);
|
||||
|
||||
// Collaborative edit lock: reject writes to a workspace task another member
|
||||
// is actively editing. Inert until a client acquires the lock.
|
||||
if (ctx.workspaceId) {
|
||||
const blockedBy = await ctx.editLockService.getBlockingHolder('task', resolved.id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Task is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedParentTaskId =
|
||||
parentTaskId === undefined
|
||||
? undefined
|
||||
@@ -947,6 +964,44 @@ export const taskRouter = router({
|
||||
}
|
||||
}),
|
||||
|
||||
acquireTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
const prev = await ctx.editLockService.getActiveHolder('task', resolved.id);
|
||||
const result = await ctx.editLockService.acquire('task', resolved.id);
|
||||
if ((result.holderId ?? null) !== (prev ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id: resolved.id, type: 'task' },
|
||||
{ actorId: ctx.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
|
||||
getTaskLock: taskProcedureWrite.input(idInput).query(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
const holder = await ctx.editLockService.getActiveHolder('task', resolved.id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== ctx.userId,
|
||||
};
|
||||
}),
|
||||
|
||||
releaseTaskLock: taskProcedureWrite.input(idInput).mutation(async ({ ctx, input }) => {
|
||||
if (!ctx.workspaceId) return;
|
||||
const resolved = await resolveOrThrow(ctx.taskModel, input.id);
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease expired and another member took over, the lock is still held.
|
||||
const released = await ctx.editLockService.release('task', resolved.id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id: resolved.id, type: 'task' },
|
||||
{ actorId: ctx.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}),
|
||||
|
||||
updateConfig: taskProcedureWrite
|
||||
.input(idInput.merge(z.object({ config: z.record(z.unknown()) })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
||||
@@ -5,14 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DocumentModel } from '@/database/models/document';
|
||||
import { FileModel } from '@/database/models/file';
|
||||
|
||||
import { EditLockService } from '../../editLock';
|
||||
import { FileService } from '../../file';
|
||||
import { publishResourceEvent } from '../../resourceEvents';
|
||||
import { DocumentHistoryService } from '../history';
|
||||
import { DocumentService } from '../index';
|
||||
|
||||
vi.mock('@/server/modules/AgentRuntime/redis', () => ({ getAgentRuntimeRedisClient: () => null }));
|
||||
vi.mock('@/database/models/document');
|
||||
vi.mock('@/database/models/file');
|
||||
vi.mock('../../file');
|
||||
vi.mock('../history');
|
||||
// Spy on the realtime broadcast so we can assert lock.changed is published only
|
||||
// on a genuine state change (holder edge / actual release).
|
||||
vi.mock('../../resourceEvents', () => ({ publishResourceEvent: vi.fn() }));
|
||||
|
||||
const publishResourceEventMock = vi.mocked(publishResourceEvent);
|
||||
vi.mock('@lobechat/file-loaders', () => ({
|
||||
loadFile: vi.fn(),
|
||||
UnsupportedFileTypeError: class UnsupportedFileTypeError extends Error {
|
||||
@@ -794,6 +802,168 @@ describe('DocumentService', () => {
|
||||
'Document not found: missing-doc',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject a workspace save when another member holds the edit lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(wsService.updateDocument('doc-1', { content: 'x' })).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
});
|
||||
expect(mockDocumentModel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a workspace save when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
mockDocumentModel.findById.mockResolvedValue(createCurrentDocument({ workspaceId: 'ws-1' }));
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.updateDocument('doc-1', { content: 'x' });
|
||||
|
||||
expect(mockDocumentModel.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a metadata-only save while another member holds the lock (only the body is locked)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.update.mockResolvedValue({ id: 'doc-1' });
|
||||
// Current body matches what the autosave re-sends — only title changes.
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ content: 'body', editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await wsService.updateDocument('doc-1', {
|
||||
content: 'body',
|
||||
editorData: { blocks: [] },
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
// Content unchanged → the lock guard never runs and the meta save lands.
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(mockDocumentModel.update).toHaveBeenCalledWith(
|
||||
'doc-1',
|
||||
expect.objectContaining({ title: 'New Title' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a body change while locked even when the content string is unchanged', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue(
|
||||
createCurrentDocument({ editorData: { blocks: [] }, workspaceId: 'ws-1' }),
|
||||
);
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
// editorData changed (historyAppended) → guard runs even with no `content`.
|
||||
await expect(
|
||||
wsService.updateDocument('doc-1', { editorData: { blocks: [{ type: 'paragraph' }] } }),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(mockDocumentModel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('document edit lock', () => {
|
||||
it('reports unlocked for personal documents without touching the lock service', async () => {
|
||||
const acquireSpy = vi.spyOn(EditLockService.prototype, 'acquire');
|
||||
|
||||
const result = await service.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: null, lockedByOther: false });
|
||||
expect(acquireSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates to the edit lock service in workspace mode', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
const acquireSpy = vi
|
||||
.spyOn(EditLockService.prototype, 'acquire')
|
||||
.mockResolvedValue({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
expect(result).toEqual({ expiresAt, holderId: userId, lockedByOther: false });
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is taken', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt,
|
||||
holderId: 'other-user',
|
||||
lockedByOther: true,
|
||||
});
|
||||
|
||||
const result = await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt, holderId: 'other-user', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('releaseDocumentLock is a no-op for personal documents', async () => {
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release');
|
||||
await service.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releaseDocumentLock delegates to the lock service in workspace mode', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
const releaseSpy = vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
expect(releaseSpy).toHaveBeenCalledWith('document', 'doc-1');
|
||||
});
|
||||
|
||||
it('acquireDocumentLock broadcasts lock.changed on a holder edge (first claim)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(undefined);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({ data: { holderId: userId }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('acquireDocumentLock does NOT broadcast on a steady-state heartbeat (same holder)', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'getActiveHolder').mockResolvedValue(userId);
|
||||
vi.spyOn(EditLockService.prototype, 'acquire').mockResolvedValue({
|
||||
expiresAt: new Date(),
|
||||
holderId: userId,
|
||||
lockedByOther: false,
|
||||
});
|
||||
|
||||
await wsService.acquireDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releaseDocumentLock broadcasts unlocked only when it actually freed the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(true);
|
||||
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).toHaveBeenCalledWith(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
expect.objectContaining({ data: { holderId: null }, type: 'lock.changed' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('releaseDocumentLock does NOT broadcast when the lease expired / was taken over', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
vi.spyOn(EditLockService.prototype, 'release').mockResolvedValue(false);
|
||||
|
||||
await wsService.releaseDocumentLock('doc-1');
|
||||
|
||||
expect(publishResourceEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveDocumentHistory', () => {
|
||||
@@ -837,6 +1007,37 @@ describe('DocumentService', () => {
|
||||
);
|
||||
expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not check the lock for personal documents', async () => {
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
const guardSpy = vi.spyOn(EditLockService.prototype, 'getBlockingHolder');
|
||||
|
||||
await service.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(guardSpy).not.toHaveBeenCalled();
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a workspace history snapshot when another member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue('other-user');
|
||||
|
||||
await expect(
|
||||
wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call'),
|
||||
).rejects.toMatchObject({ code: 'CONFLICT' });
|
||||
expect(mockDocumentHistoryService.createHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a workspace history snapshot when no other member holds the lock', async () => {
|
||||
const wsService = new DocumentService(mockDb, userId, 'ws-1');
|
||||
mockDocumentModel.findById.mockResolvedValue({ id: 'doc-1', editorData: { blocks: [] } });
|
||||
vi.spyOn(EditLockService.prototype, 'getBlockingHolder').mockResolvedValue(null);
|
||||
|
||||
await wsService.saveDocumentHistory('doc-1', { blocks: [] }, 'llm_call');
|
||||
|
||||
expect(mockDocumentHistoryService.createHistory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('trySaveCurrentDocumentHistory', () => {
|
||||
|
||||
@@ -15,13 +15,16 @@ import { isValidEditorData } from '@/libs/editor/isValidEditorData';
|
||||
import { normalizeEditorDataDiffNodes } from '@/libs/editor/normalizeDiffNodes';
|
||||
import { type LobeDocument } from '@/types/document';
|
||||
|
||||
import { EditLockService } from '../editLock';
|
||||
import { FileService } from '../file';
|
||||
import { publishResourceEvent } from '../resourceEvents';
|
||||
import { DocumentHistoryService } from './history';
|
||||
import type {
|
||||
CompareDocumentHistoryItemsParams,
|
||||
CompareDocumentHistoryItemsResult,
|
||||
DocumentHistoryAccessOptions,
|
||||
DocumentHistorySaveSource,
|
||||
DocumentLockResult,
|
||||
GetDocumentHistoryItemParams,
|
||||
ListDocumentHistoryParams,
|
||||
ListDocumentHistoryResult,
|
||||
@@ -50,6 +53,7 @@ export class DocumentService {
|
||||
private documentModel: DocumentModel;
|
||||
private documentHistoryServiceInstance?: DocumentHistoryService;
|
||||
private fileServiceInstance?: FileService;
|
||||
private editLockService: EditLockService;
|
||||
private db: LobeChatDatabase;
|
||||
|
||||
private workspaceId?: string;
|
||||
@@ -60,6 +64,7 @@ export class DocumentService {
|
||||
this.workspaceId = workspaceId;
|
||||
this.fileModel = new FileModel(db, userId, workspaceId);
|
||||
this.documentModel = new DocumentModel(db, userId, workspaceId);
|
||||
this.editLockService = new EditLockService(userId);
|
||||
}
|
||||
|
||||
private get fileService() {
|
||||
@@ -202,6 +207,63 @@ export class DocumentService {
|
||||
return this.documentModel.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire (or refresh) the collaborative edit lock for a workspace document.
|
||||
*
|
||||
* Doubles as the heartbeat: an active editor calls this on an interval to keep
|
||||
* the lease alive, and a locked-out member calls it to take the lock over once
|
||||
* it frees up. Locking only applies in workspace context — personal documents
|
||||
* always report as unlocked.
|
||||
*/
|
||||
async acquireDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const prevHolder = await this.editLockService.getActiveHolder('document', id);
|
||||
const result = await this.editLockService.acquire('document', id);
|
||||
|
||||
// Broadcast only on a holder edge (first claim / takeover). This method also
|
||||
// serves the periodic heartbeat, so a steady-state refresh (same holder)
|
||||
// must not emit an event.
|
||||
if ((result.holderId ?? null) !== (prevHolder ?? null)) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, data: { holderId: result.holderId }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only peek of the current edit lock (does not acquire). Lets a client
|
||||
* render a workspace page read-only on open when another member holds it.
|
||||
*/
|
||||
async getDocumentLock(id: string): Promise<DocumentLockResult> {
|
||||
if (!this.workspaceId) return { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
const holder = await this.editLockService.getActiveHolder('document', id);
|
||||
return {
|
||||
expiresAt: null,
|
||||
holderId: holder ?? null,
|
||||
lockedByOther: Boolean(holder) && holder !== this.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the edit lock if the current user holds it. No-op in personal mode.
|
||||
*/
|
||||
async releaseDocumentLock(id: string): Promise<void> {
|
||||
if (!this.workspaceId) return;
|
||||
// Only broadcast "unlocked" when we actually released our own lock — if the
|
||||
// lease had expired and another member took over, the lock is still held and
|
||||
// a bogus holderId:null would wrongly flip their viewers to editable.
|
||||
const released = await this.editLockService.release('document', id);
|
||||
if (!released) return;
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, data: { holderId: null }, type: 'lock.changed' },
|
||||
);
|
||||
}
|
||||
|
||||
async listDocumentHistory(
|
||||
params: ListDocumentHistoryParams,
|
||||
options?: DocumentHistoryAccessOptions,
|
||||
@@ -236,6 +298,21 @@ export class DocumentService {
|
||||
throw new Error(`Document not found: ${documentId}`);
|
||||
}
|
||||
|
||||
// Same collaborative edit-lock guard as updateDocument: don't record a
|
||||
// history snapshot for a workspace document another member is editing, so a
|
||||
// locked-out actor (e.g. a Copilot mutation that will itself be rejected)
|
||||
// can't pollute the version timeline.
|
||||
if (this.workspaceId) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', documentId);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedEditorData = normalizeEditorDataDiffNodes(editorData);
|
||||
const savedAt = new Date();
|
||||
await this.documentHistoryService.createHistory({
|
||||
@@ -331,7 +408,8 @@ export class DocumentService {
|
||||
* Update document
|
||||
*/
|
||||
async updateDocument(id: string, params: UpdateDocumentParams): Promise<UpdateDocumentResult> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
let changed = false;
|
||||
const result = await this.db.transaction(async (tx) => {
|
||||
const transactionDb = tx as unknown as LobeChatDatabase;
|
||||
const documentModel = new DocumentModel(transactionDb, this.userId, this.workspaceId);
|
||||
const fileModel = new FileModel(transactionDb, this.userId, this.workspaceId);
|
||||
@@ -361,6 +439,26 @@ export class DocumentService {
|
||||
nextEditorDataAccepted !== undefined &&
|
||||
!isEqual(nextEditorDataAccepted, currentEditorDataAccepted);
|
||||
|
||||
// Collaborative edit lock guard: reject writes to a workspace document that
|
||||
// another member is actively editing, so concurrent edits can't clobber
|
||||
// each other. Only the rich-text BODY is locked — metadata-only saves
|
||||
// (title/emoji) pass through, since the autosave always re-sends the
|
||||
// unchanged body. The lease auto-expires in Redis; when Redis is down this
|
||||
// returns null (fail-open) so the lock can't block saving.
|
||||
const contentChanged =
|
||||
historyAppended ||
|
||||
(params.content !== undefined && params.content !== currentDocument.content);
|
||||
if (this.workspaceId && contentChanged) {
|
||||
const blockedBy = await this.editLockService.getBlockingHolder('document', id);
|
||||
if (blockedBy) {
|
||||
throw new TRPCError({
|
||||
cause: { data: { code: 'DocumentLocked' } },
|
||||
code: 'CONFLICT',
|
||||
message: 'Document is being edited by another user',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (params.content !== undefined) {
|
||||
@@ -390,6 +488,9 @@ export class DocumentService {
|
||||
updates.parentId = params.parentId;
|
||||
}
|
||||
|
||||
// The lock lease is refreshed by the client heartbeat (acquireDocumentLock),
|
||||
// so a save does not need to touch it.
|
||||
|
||||
let savedAt: Date | undefined;
|
||||
|
||||
if (historyAppended) {
|
||||
@@ -414,12 +515,25 @@ export class DocumentService {
|
||||
await fileModel.update(currentDocument.fileId, fileUpdates);
|
||||
}
|
||||
|
||||
changed = Object.keys(updates).length > 0 || historyAppended;
|
||||
|
||||
return {
|
||||
historyAppended,
|
||||
id,
|
||||
savedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Notify other workspace members that the document changed so their open
|
||||
// editor refreshes immediately (best-effort; the heartbeat is the fallback).
|
||||
if (this.workspaceId && changed) {
|
||||
void publishResourceEvent(
|
||||
{ id, type: 'document' },
|
||||
{ actorId: this.userId, type: 'doc.updated' },
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,3 +75,12 @@ export interface UpdateDocumentResult {
|
||||
export interface SaveDocumentHistoryResult {
|
||||
savedAt: Date;
|
||||
}
|
||||
|
||||
export interface DocumentLockResult {
|
||||
/** Lease expiry of the active lock, if any. */
|
||||
expiresAt: Date | null;
|
||||
/** The user id currently holding the lock, or null when unlocked. */
|
||||
holderId: string | null;
|
||||
/** True when another active user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditLockService } from '../index';
|
||||
|
||||
/**
|
||||
* Minimal in-memory fake of the ioredis calls EditLockService uses:
|
||||
* `set(k, v, 'EX', ttl[, 'NX'])`, `get(k)`, and the compare-and-delete `eval`.
|
||||
*/
|
||||
const makeFakeRedis = () => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
eval: vi.fn(async (_script: string, _numKeys: number, key: string, arg: string) => {
|
||||
if (store.get(key) === arg) {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string, ...args: unknown[]) => {
|
||||
if (args.includes('NX') && store.has(key)) return null;
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}),
|
||||
store,
|
||||
};
|
||||
};
|
||||
|
||||
describe('EditLockService', () => {
|
||||
it('acquires a free lock and reports the caller as holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(result.expiresAt).toBeInstanceOf(Date);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
});
|
||||
|
||||
it('reports another member as holder when the lock is already taken', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(result).toEqual({ expiresAt: null, holderId: 'user-1', lockedByOther: true });
|
||||
});
|
||||
|
||||
it('lets the holder refresh their own lease', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
await svc.acquire('document', 'doc-1');
|
||||
|
||||
const result = await svc.acquire('document', 'doc-1');
|
||||
|
||||
expect(result.holderId).toBe('user-1');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
});
|
||||
|
||||
it('getActiveHolder reports the current holder, or undefined when free', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBeUndefined();
|
||||
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getActiveHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
});
|
||||
|
||||
it('keys locks per resource type, so the same id does not collide across types', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'shared-id');
|
||||
|
||||
// A different resource family with the same id is independently lockable.
|
||||
const result = await new EditLockService('user-2', redis as any).acquire('agent', 'shared-id');
|
||||
|
||||
expect(result.holderId).toBe('user-2');
|
||||
expect(result.lockedByOther).toBe(false);
|
||||
expect(redis.store.get('editlock:document:shared-id')).toBe('user-1');
|
||||
expect(redis.store.get('editlock:agent:shared-id')).toBe('user-2');
|
||||
});
|
||||
|
||||
it('getBlockingHolder returns the holder only when it is someone else', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
expect(
|
||||
await new EditLockService('user-2', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBe('user-1');
|
||||
expect(
|
||||
await new EditLockService('user-1', redis as any).getBlockingHolder('document', 'doc-1'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('only releases the lock for the current holder', async () => {
|
||||
const redis = makeFakeRedis();
|
||||
await new EditLockService('user-1', redis as any).acquire('document', 'doc-1');
|
||||
|
||||
// A non-holder release is a no-op and reports it did not release.
|
||||
expect(await new EditLockService('user-2', redis as any).release('document', 'doc-1')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(redis.store.get('editlock:document:doc-1')).toBe('user-1');
|
||||
|
||||
// The holder can release, and reports the lock was actually freed.
|
||||
expect(await new EditLockService('user-1', redis as any).release('document', 'doc-1')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(redis.store.has('editlock:document:doc-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to unlocked / no-op when Redis is unavailable', async () => {
|
||||
const svc = new EditLockService('user-1', null);
|
||||
|
||||
expect(await svc.acquire('document', 'doc-1')).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
});
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('fails open when Redis is configured but commands reject (unreachable)', async () => {
|
||||
// ioredis is non-null but every command rejects after retries — the write
|
||||
// guards must not turn this into a 500; treat the resource as unlocked.
|
||||
const down = new Error('Connection is closed.');
|
||||
const redis = {
|
||||
eval: vi.fn().mockRejectedValue(down),
|
||||
get: vi.fn().mockRejectedValue(down),
|
||||
set: vi.fn().mockRejectedValue(down),
|
||||
};
|
||||
const svc = new EditLockService('user-1', redis as any);
|
||||
|
||||
expect(await svc.acquire('document', 'doc-1')).toEqual({
|
||||
expiresAt: null,
|
||||
holderId: null,
|
||||
lockedByOther: false,
|
||||
});
|
||||
expect(await svc.getActiveHolder('document', 'doc-1')).toBeUndefined();
|
||||
expect(await svc.getBlockingHolder('document', 'doc-1')).toBeNull();
|
||||
await expect(svc.release('document', 'doc-1')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import debug from 'debug';
|
||||
import type { Redis } from 'ioredis';
|
||||
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
|
||||
const log = debug('lobe-server:edit-lock');
|
||||
|
||||
/** Lease lifetime in seconds; clients heartbeat well within this to keep it alive. */
|
||||
export const EDIT_LOCK_TTL_SECONDS = 30;
|
||||
|
||||
/** Editable resource families that can take a collaborative edit lock. */
|
||||
export type EditLockResourceType = 'agent' | 'chatGroup' | 'document' | 'task';
|
||||
|
||||
export interface EditLockResult {
|
||||
/** Lease expiry of the active lock, if the caller now holds it. */
|
||||
expiresAt: Date | null;
|
||||
/** The user id currently holding the lock, or null when unlocked. */
|
||||
holderId: string | null;
|
||||
/** True when another user holds the lock (caller is locked out). */
|
||||
lockedByOther: boolean;
|
||||
}
|
||||
|
||||
const UNLOCKED: EditLockResult = { expiresAt: null, holderId: null, lockedByOther: false };
|
||||
|
||||
const lockKey = (type: EditLockResourceType, id: string) => `editlock:${type}:${id}`;
|
||||
|
||||
// Release only if the caller still holds the lock (compare-and-delete), so a
|
||||
// stale releaser can't drop a lease another member has since taken over.
|
||||
const RELEASE_SCRIPT = `
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
/**
|
||||
* Redis-backed collaborative edit lock, keyed by (resourceType, resourceId).
|
||||
*
|
||||
* Intentionally a thin, table-agnostic lease: there is no DB schema, so it
|
||||
* applies uniformly to any editable resource (documents, briefs, …) and can be
|
||||
* removed wholesale once real-time co-editing lands — the keys simply expire.
|
||||
*
|
||||
* The lock is advisory: when Redis is unavailable every method degrades to
|
||||
* "unlocked" so the lock infrastructure can never block editing or saving.
|
||||
*/
|
||||
export class EditLockService {
|
||||
private userId: string;
|
||||
private explicitRedis: Redis | null | undefined;
|
||||
private lazyRedis: Redis | null = null;
|
||||
private lazyResolved = false;
|
||||
|
||||
constructor(userId: string, redis?: Redis | null) {
|
||||
this.userId = userId;
|
||||
this.explicitRedis = redis;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Redis client, resolved lazily on first use. Resolving eagerly in the
|
||||
* constructor would read server-only env (`getAgentRuntimeRedisClient`) the
|
||||
* moment any owning service is built — which throws in client/test contexts
|
||||
* that construct the service but never take a lock.
|
||||
*/
|
||||
private get redis(): Redis | null {
|
||||
if (this.explicitRedis !== undefined) return this.explicitRedis;
|
||||
if (!this.lazyResolved) {
|
||||
this.lazyRedis = getAgentRuntimeRedisClient();
|
||||
this.lazyResolved = true;
|
||||
}
|
||||
return this.lazyRedis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the lock when it is free (or already mine), refreshing the lease;
|
||||
* otherwise report whoever currently holds it. Doubles as the heartbeat.
|
||||
*/
|
||||
async acquire(type: EditLockResourceType, id: string): Promise<EditLockResult> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return UNLOCKED;
|
||||
const key = lockKey(type, id);
|
||||
|
||||
try {
|
||||
// Claim only when the key is absent (NX). The TTL gives automatic expiry, so
|
||||
// a hard-closed tab frees the lock without any cleanup job.
|
||||
const claimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
if (claimed) return this.held();
|
||||
|
||||
const holder = await redis.get(key);
|
||||
if (holder === this.userId) {
|
||||
// Already mine — refresh the lease (heartbeat).
|
||||
await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS);
|
||||
return this.held();
|
||||
}
|
||||
if (holder) return { expiresAt: null, holderId: holder, lockedByOther: true };
|
||||
|
||||
// Freed between the NX and the GET — try once more.
|
||||
const reclaimed = await redis.set(key, this.userId, 'EX', EDIT_LOCK_TTL_SECONDS, 'NX');
|
||||
return reclaimed ? this.held() : UNLOCKED;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage (configured but unreachable) must never block
|
||||
// editing — report unlocked rather than surfacing the command rejection.
|
||||
log('acquire failed for %s:%s %O', type, id, error);
|
||||
return UNLOCKED;
|
||||
}
|
||||
}
|
||||
|
||||
/** Current holder of the lock, or undefined when unlocked / Redis is down. */
|
||||
async getActiveHolder(type: EditLockResourceType, id: string): Promise<string | undefined> {
|
||||
const redis = this.redis;
|
||||
if (!redis) return undefined;
|
||||
try {
|
||||
const holder = await redis.get(lockKey(type, id));
|
||||
return holder ?? undefined;
|
||||
} catch (error) {
|
||||
// Fail-open: a Redis outage must not turn the write guards into 500s.
|
||||
log('getActiveHolder failed for %s:%s %O', type, id, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The holder when someone *other* than the caller holds the lock, else null.
|
||||
* Used by write guards; returns null when Redis is down (fail-open).
|
||||
*/
|
||||
async getBlockingHolder(type: EditLockResourceType, id: string): Promise<string | null> {
|
||||
const holder = await this.getActiveHolder(type, id);
|
||||
return holder && holder !== this.userId ? holder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the lock, but only if the caller still holds it (compare-and-delete).
|
||||
* Returns true only when the caller's lock was actually deleted — false when
|
||||
* the lease had already expired or another member has since taken it over, so
|
||||
* callers can avoid broadcasting a bogus "unlocked" event.
|
||||
*/
|
||||
async release(type: EditLockResourceType, id: string): Promise<boolean> {
|
||||
if (!this.redis) return false;
|
||||
try {
|
||||
const deleted = await this.redis.eval(RELEASE_SCRIPT, 1, lockKey(type, id), this.userId);
|
||||
return deleted === 1;
|
||||
} catch (error) {
|
||||
log('release failed for %s:%s %O', type, id, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private held(): EditLockResult {
|
||||
return {
|
||||
expiresAt: new Date(Date.now() + EDIT_LOCK_TTL_SECONDS * 1000),
|
||||
holderId: this.userId,
|
||||
lockedByOther: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,14 @@ import type {
|
||||
MainAgentRunState,
|
||||
MainAgentTurnToolState,
|
||||
SubagentIntent,
|
||||
SubagentRunSnapshot,
|
||||
ToolCallPayload,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import { createMainAgentRunState, reduceMainAgent } from '@lobechat/heterogeneous-agents';
|
||||
import {
|
||||
createMainAgentRunState,
|
||||
reduceMainAgent,
|
||||
rehydrateSubagentRunsState,
|
||||
} from '@lobechat/heterogeneous-agents';
|
||||
import {
|
||||
AgentRuntimeErrorType,
|
||||
type ChatMessageError,
|
||||
@@ -219,6 +224,7 @@ export class HeterogeneousPersistenceHandler {
|
||||
|
||||
await this.refreshToolMessageIndex(state);
|
||||
await this.refreshMainStateFromDb(state);
|
||||
await this.refreshSubagentRunsFromDb(state);
|
||||
|
||||
for (const event of params.events) {
|
||||
const key = eventKey(event);
|
||||
@@ -549,6 +555,96 @@ export class HeterogeneousPersistenceHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the in-flight subagent runs (`state.main.subagents`) from DB.
|
||||
*
|
||||
* The shared reducer keys runs by `parentToolCallId` and only lazy-creates a
|
||||
* thread when the run is ABSENT from this map. On a cold serverless replica
|
||||
* `createMainAgentRunState` seeds an empty map, so a subagent event whose
|
||||
* thread already exists (created by an earlier batch / another replica) would
|
||||
* fork a brand-new thread — the "大量无意义的 Subagent" bug. `refreshMainStateFromDb`
|
||||
* rebuilds the main-agent half; this rebuilds the subagent half the same way.
|
||||
*
|
||||
* Merge semantics: only runs MISSING from the in-memory map are rehydrated, so
|
||||
* a warm replica's live per-turn accumulators (`accContent`, current
|
||||
* `toolState`) are never clobbered by the DB projection. Finalized runs are
|
||||
* excluded (their thread is `Active`, not `Processing`), so a completed spawn
|
||||
* is never resurrected.
|
||||
*
|
||||
* Best-effort: any DB hiccup (or a partial test mock without the query
|
||||
* methods) leaves `state.main.subagents` untouched rather than aborting the
|
||||
* whole ingest.
|
||||
*/
|
||||
private async refreshSubagentRunsFromDb(state: OperationState): Promise<void> {
|
||||
try {
|
||||
const threads = await this.deps.threadModel.queryByTopicId(state.topicId);
|
||||
const existing = state.main.subagents.runs;
|
||||
const snapshots: SubagentRunSnapshot[] = [];
|
||||
|
||||
for (const thread of threads ?? []) {
|
||||
if (thread.type !== ThreadType.Isolation) continue;
|
||||
if (thread.status !== ThreadStatus.Processing) continue;
|
||||
const meta = thread.metadata as { operationId?: string; sourceToolCallId?: string } | null;
|
||||
// Operation-scoped: only rehydrate threads THIS operation created.
|
||||
// Topics are reused across turns, so a prior run that crashed / was
|
||||
// cancelled without an ingested terminal event can leave its subagent
|
||||
// thread stuck in `Processing`. Without this guard the next operation
|
||||
// would merge that unrelated thread into its reducer state and then
|
||||
// finalize/mutate it on its own terminal drain. Threads written before
|
||||
// this field existed have no `operationId` and are skipped (safe — we
|
||||
// can't attribute them, and the live run re-creates what it needs).
|
||||
if (meta?.operationId !== state.operationId) continue;
|
||||
const parentToolCallId = meta?.sourceToolCallId;
|
||||
if (!parentToolCallId || existing.has(parentToolCallId)) continue;
|
||||
|
||||
const messages = await this.deps.messageModel.query({
|
||||
threadId: thread.id,
|
||||
topicId: state.topicId,
|
||||
});
|
||||
const snapshot = this.buildSubagentSnapshot(parentToolCallId, thread.id, messages);
|
||||
if (snapshot) snapshots.push(snapshot);
|
||||
}
|
||||
|
||||
if (snapshots.length === 0) return;
|
||||
|
||||
// Union: rehydrated (missing) runs + the in-memory ones (which win, since
|
||||
// they carry live accumulators the DB hasn't caught up to yet).
|
||||
const merged = rehydrateSubagentRunsState(snapshots);
|
||||
for (const [parentToolCallId, run] of existing) merged.runs.set(parentToolCallId, run);
|
||||
state.main = { ...state.main, subagents: merged };
|
||||
} catch (err) {
|
||||
log('refreshSubagentRunsFromDb failed op=%s err=%O', state.operationId, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct one {@link SubagentRunSnapshot} from a thread's persisted
|
||||
* messages (ordered `createdAt` asc by the query). Returns undefined when the
|
||||
* thread has no assistant yet — without one there is nothing to attach a
|
||||
* continuation turn to, and the first-event path will (correctly) seed it.
|
||||
*/
|
||||
private buildSubagentSnapshot(
|
||||
parentToolCallId: string,
|
||||
threadId: string,
|
||||
messages: Array<{ id: string; parentId?: string | null; role: string; tool_call_id?: string }>,
|
||||
): SubagentRunSnapshot | undefined {
|
||||
const assistants = messages.filter((m) => m.role === 'assistant');
|
||||
const currentAssistant = assistants.at(-1);
|
||||
if (!currentAssistant) return undefined;
|
||||
|
||||
const toolRows = messages.filter((m) => m.role === 'tool' && m.tool_call_id);
|
||||
const childTools = toolRows.filter((m) => m.parentId === currentAssistant.id);
|
||||
const lastChainParentId = childTools.at(-1)?.id ?? currentAssistant.id;
|
||||
|
||||
return {
|
||||
currentAssistantId: currentAssistant.id,
|
||||
lastChainParentId,
|
||||
lifetimeToolCallIds: toolRows.map((m) => m.tool_call_id!),
|
||||
parentToolCallId,
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncAssistantPointerForAdvancedStep(state: OperationState): Promise<void> {
|
||||
const topic = await this.deps.topicModel.findById(state.topicId);
|
||||
const running = topic?.metadata?.runningOperation;
|
||||
@@ -844,6 +940,10 @@ export class HeterogeneousPersistenceHandler {
|
||||
await this.deps.threadModel.create({
|
||||
id: intent.threadId,
|
||||
metadata: {
|
||||
// Stamp the owning hetero operation so `refreshSubagentRunsFromDb`
|
||||
// only rehydrates threads from THIS run — never a stale Processing
|
||||
// thread a prior crashed/cancelled run left on the same topic.
|
||||
operationId: state.operationId,
|
||||
sourceToolCallId: intent.sourceToolCallId,
|
||||
startedAt: new Date().toISOString(),
|
||||
subagentType: intent.subagentType,
|
||||
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
// @vitest-environment node
|
||||
import type { AgentStreamEvent } from '@lobechat/agent-gateway-client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
__resetOperationStatesForTesting,
|
||||
HeterogeneousPersistenceHandler,
|
||||
} from '../HeterogeneousPersistenceHandler';
|
||||
|
||||
/**
|
||||
* Regression for the SERVER-ONLY "大量无意义的 SubAgent" bug.
|
||||
*
|
||||
* Root cause: `HeterogeneousPersistenceHandler` keeps per-operation state in a
|
||||
* module-level `operationStates` map. On Vercel serverless, consecutive ingest
|
||||
* batches for one operation can land on DIFFERENT (cold) replicas, so that map
|
||||
* is empty on the next batch. `loadOrCreateState` rehydrates the MAIN-agent
|
||||
* state from DB (accumulatedContent, toolState, toolMsgIdByCallId,
|
||||
* currentAssistantMessageId) — but initializes `subagentState` with an empty
|
||||
* `createSubagentRunsState()` and NEVER reconstructs the in-flight subagent
|
||||
* runs from DB.
|
||||
*
|
||||
* Consequence: when a subagent run spans multiple batches, the first subagent
|
||||
* event seen by each fresh replica hits the `!existing` branch of `ensureRun`
|
||||
* and creates a BRAND-NEW thread for a `parentToolCallId` that already has one.
|
||||
* The duplicates get the generic "Subagent" title because spawnMetadata only
|
||||
* rides the first subagent event per parent (adapter `announcedSpawns`).
|
||||
*
|
||||
* The desktop client never hits this — it has a single long-lived
|
||||
* `subagentState` closure for the whole run.
|
||||
*
|
||||
* This test simulates a cold replica between batches via
|
||||
* `__resetOperationStatesForTesting()` (the in-memory map is dropped while the
|
||||
* mock DB — `threads` / `messages` — persists, exactly like a fresh Lambda).
|
||||
*/
|
||||
|
||||
interface FakeMessage {
|
||||
agentId: string | null;
|
||||
content: string;
|
||||
id: string;
|
||||
metadata?: any;
|
||||
model?: string;
|
||||
parentId?: string | null;
|
||||
plugin?: any;
|
||||
reasoning?: any;
|
||||
role: 'user' | 'assistant' | 'tool' | 'task' | 'system';
|
||||
threadId?: string | null;
|
||||
tool_call_id?: string;
|
||||
tools?: any[];
|
||||
topicId: string | null;
|
||||
}
|
||||
|
||||
interface FakeThread {
|
||||
id: string;
|
||||
metadata?: any;
|
||||
sourceMessageId?: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
topicId: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const createHarness = (params: {
|
||||
assistantMessageId: string;
|
||||
operationId: string;
|
||||
topicId: string;
|
||||
}) => {
|
||||
let nextMsgIdSeq = 0;
|
||||
const messages = new Map<string, FakeMessage>();
|
||||
const threads = new Map<string, FakeThread>();
|
||||
|
||||
messages.set(params.assistantMessageId, {
|
||||
agentId: null,
|
||||
content: '',
|
||||
id: params.assistantMessageId,
|
||||
role: 'assistant',
|
||||
topicId: params.topicId,
|
||||
});
|
||||
|
||||
const messageModel = {
|
||||
create: vi.fn(async (input: Partial<FakeMessage>, id?: string) => {
|
||||
nextMsgIdSeq += 1;
|
||||
const msgId = id ?? `msg_${nextMsgIdSeq}`;
|
||||
const msg: FakeMessage = {
|
||||
agentId: input.agentId ?? null,
|
||||
content: input.content ?? '',
|
||||
id: msgId,
|
||||
metadata: input.metadata,
|
||||
model: input.model,
|
||||
parentId: input.parentId ?? null,
|
||||
plugin: input.plugin,
|
||||
provider: undefined,
|
||||
reasoning: input.reasoning,
|
||||
role: input.role!,
|
||||
threadId: input.threadId ?? null,
|
||||
tool_call_id: input.tool_call_id,
|
||||
topicId: input.topicId ?? null,
|
||||
} as FakeMessage;
|
||||
messages.set(msgId, msg);
|
||||
return msg;
|
||||
}),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeMessage>) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, ...patch });
|
||||
return { success: true };
|
||||
}),
|
||||
updateToolMessage: vi.fn(async (id: string, patch: any) => {
|
||||
const existing = messages.get(id);
|
||||
if (!existing) return { success: false };
|
||||
messages.set(id, { ...existing, content: patch.content ?? existing.content });
|
||||
return { success: true };
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => messages.get(id) ?? null),
|
||||
query: vi.fn(async (params: { threadId?: string; topicId?: string }) => {
|
||||
if (params?.threadId) {
|
||||
return [...messages.values()].filter((m) => m.threadId === params.threadId);
|
||||
}
|
||||
return [...messages.values()].filter((m) => !m.threadId && m.topicId === params?.topicId);
|
||||
}),
|
||||
getLastChildToolMessageId: vi.fn(async (assistantMessageId: string) => {
|
||||
const match = [...messages.values()].findLast(
|
||||
(m) => m.role === 'tool' && m.parentId === assistantMessageId && !m.threadId,
|
||||
);
|
||||
return match?.id;
|
||||
}),
|
||||
listMessagePluginsByTopic: vi.fn(async (_topicId: string) => {
|
||||
// Mirror the real query: every persisted tool row's (toolCallId → id).
|
||||
return [...messages.values()]
|
||||
.filter((m) => m.role === 'tool' && m.tool_call_id)
|
||||
.map((m) => ({ id: m.id, toolCallId: m.tool_call_id! }));
|
||||
}),
|
||||
};
|
||||
|
||||
const threadModel = {
|
||||
create: vi.fn(async (input: Partial<FakeThread>) => {
|
||||
const thread: FakeThread = {
|
||||
id: input.id!,
|
||||
metadata: input.metadata,
|
||||
sourceMessageId: input.sourceMessageId,
|
||||
status: input.status ?? 'active',
|
||||
title: input.title ?? '',
|
||||
topicId: input.topicId ?? params.topicId,
|
||||
type: input.type ?? 'isolation',
|
||||
};
|
||||
threads.set(thread.id, thread);
|
||||
return thread;
|
||||
}),
|
||||
findById: vi.fn(async (id: string) => threads.get(id) ?? null),
|
||||
queryByTopicId: vi.fn(async (topicId: string) =>
|
||||
[...threads.values()].filter((t) => t.topicId === topicId),
|
||||
),
|
||||
update: vi.fn(async (id: string, patch: Partial<FakeThread>) => {
|
||||
const existing = threads.get(id);
|
||||
if (!existing) return;
|
||||
threads.set(id, { ...existing, ...patch });
|
||||
}),
|
||||
};
|
||||
|
||||
const topicModel = {
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (id !== params.topicId) return null;
|
||||
return {
|
||||
agentId: null,
|
||||
id,
|
||||
metadata: {
|
||||
runningOperation: {
|
||||
assistantMessageId: params.assistantMessageId,
|
||||
operationId: params.operationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
updateMetadata: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
const handler = new HeterogeneousPersistenceHandler({
|
||||
messageModel: messageModel as any,
|
||||
threadModel: threadModel as any,
|
||||
topicModel: topicModel as any,
|
||||
});
|
||||
|
||||
return { handler, messages, threadModel, threads };
|
||||
};
|
||||
|
||||
const buildEvent = (
|
||||
type: AgentStreamEvent['type'],
|
||||
stepIndex: number,
|
||||
data: Record<string, unknown>,
|
||||
): AgentStreamEvent => ({
|
||||
data,
|
||||
operationId: 'op-1',
|
||||
stepIndex,
|
||||
timestamp: 1_700_000_000_000 + stepIndex,
|
||||
type,
|
||||
});
|
||||
|
||||
const innerTool = (id: string) => ({
|
||||
apiName: 'Bash',
|
||||
arguments: '{}',
|
||||
id,
|
||||
identifier: 'bash',
|
||||
type: 'default',
|
||||
});
|
||||
|
||||
describe('HeterogeneousPersistenceHandler — subagent run survives a cold replica', () => {
|
||||
beforeEach(() => __resetOperationStatesForTesting());
|
||||
afterEach(() => __resetOperationStatesForTesting());
|
||||
|
||||
it('does NOT spawn a duplicate thread when a later batch of the SAME subagent run lands on a fresh replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// ── Batch 1 (replica A): first subagent turn. Carries spawnMetadata, so the
|
||||
// thread is created with a real title. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: {
|
||||
description: 'Explore session/agent topic data model',
|
||||
prompt: 'investigate',
|
||||
subagentType: 'Explore',
|
||||
},
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
expect(h.threads.size).toBe(1);
|
||||
|
||||
// ── Cold replica: the warm in-memory operation state is gone, but the DB
|
||||
// (threads + messages) persists. ──
|
||||
__resetOperationStatesForTesting();
|
||||
|
||||
// ── Batch 2 (replica B): the SAME subagent run continues with a new turn.
|
||||
// Mirroring the adapter, this later event carries NO spawnMetadata. ──
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
subagentMessageId: 'sub-msg-2',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// The continuation must attach to the EXISTING thread, not fork a new one.
|
||||
expect(h.threads.size).toBe(1);
|
||||
// And we must never produce a generic-titled "Subagent" duplicate.
|
||||
expect([...h.threads.values()].some((t) => t.title === 'Subagent')).toBe(false);
|
||||
});
|
||||
|
||||
// P1: a tools_calling batch reprocessed on a cold replica (BatchIngester
|
||||
// retry, or a turn split across a cold boundary so the cumulative array is
|
||||
// re-seen) must NOT mint a second tool message for an inner tool the run
|
||||
// already persisted. Rehydration restores `lifetimeToolCallIds`, and the
|
||||
// reducer de-dupes against it.
|
||||
it('does NOT re-create an already-persisted inner tool row after a cold replica', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
const PARENT = 'tc-spawn-1';
|
||||
|
||||
// Batch 1: turn sub-msg-1 persists inner-1.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: {
|
||||
parentToolCallId: PARENT,
|
||||
spawnMetadata: { prompt: 'go', subagentType: 'Explore' },
|
||||
subagentMessageId: 'sub-msg-1',
|
||||
},
|
||||
toolsCalling: [innerTool('inner-1')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
__resetOperationStatesForTesting(); // cold replica
|
||||
|
||||
// Batch 2 (replica B): the SAME turn's cumulative array is re-seen (inner-1
|
||||
// again) plus a new inner-2.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 1, {
|
||||
chunkType: 'tools_calling',
|
||||
subagent: { parentToolCallId: PARENT, subagentMessageId: 'sub-msg-1' },
|
||||
toolsCalling: [innerTool('inner-1'), innerTool('inner-2')],
|
||||
}),
|
||||
],
|
||||
operationId: 'op-1',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
const toolRows = (callId: string) =>
|
||||
[...h.messages.values()].filter((m) => m.role === 'tool' && m.tool_call_id === callId);
|
||||
// inner-1 persisted exactly once (no duplicate row), inner-2 once.
|
||||
expect(toolRows('inner-1')).toHaveLength(1);
|
||||
expect(toolRows('inner-2')).toHaveLength(1);
|
||||
expect(h.threads.size).toBe(1);
|
||||
});
|
||||
|
||||
// P2: a stale `Processing` isolation thread left by a PRIOR operation on the
|
||||
// same topic must not be rehydrated into — or finalized by — the current
|
||||
// operation. The rehydration is scoped by `metadata.operationId`.
|
||||
it('ignores a stale Processing thread from a different operation on the same topic', async () => {
|
||||
const h = createHarness({
|
||||
assistantMessageId: 'asst-1',
|
||||
operationId: 'op-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// Seed a thread (+ its in-thread assistant) left Processing by op-1.
|
||||
h.threads.set('thd-stale', {
|
||||
id: 'thd-stale',
|
||||
metadata: { operationId: 'op-1', sourceToolCallId: 'tc-old' },
|
||||
sourceMessageId: 'asst-old',
|
||||
status: 'processing',
|
||||
title: 'Old Subagent',
|
||||
topicId: 'topic-1',
|
||||
type: 'isolation',
|
||||
});
|
||||
h.messages.set('stale-asst', {
|
||||
agentId: null,
|
||||
content: '',
|
||||
id: 'stale-asst',
|
||||
parentId: 'asst-old',
|
||||
role: 'assistant',
|
||||
threadId: 'thd-stale',
|
||||
topicId: 'topic-1',
|
||||
} as any);
|
||||
|
||||
// op-2 runs and terminates. The terminal orphan-drain would finalize every
|
||||
// run in the reducer state — so if the stale thread were merged in, it would
|
||||
// be flipped to Active here.
|
||||
await h.handler.ingest({
|
||||
assistantMessageId: 'asst-1',
|
||||
events: [
|
||||
buildEvent('stream_chunk', 0, { chunkType: 'text', content: 'working' }),
|
||||
buildEvent('agent_runtime_end', 1, {}),
|
||||
],
|
||||
operationId: 'op-2',
|
||||
topicId: 'topic-1',
|
||||
});
|
||||
|
||||
// The unrelated thread is untouched: still Processing, never updated.
|
||||
expect(h.threads.get('thd-stale')!.status).toBe('processing');
|
||||
expect(h.threadModel.update).not.toHaveBeenCalledWith('thd-stale', expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { publishResourceEvent, resourceChannelId } from '../index';
|
||||
|
||||
describe('resourceEvents', () => {
|
||||
it('formats a stable channel id per resource', () => {
|
||||
expect(resourceChannelId({ id: 'doc-1', type: 'document' })).toBe('resource:document:doc-1');
|
||||
});
|
||||
|
||||
it('publish is best-effort and never throws (no Redis → in-memory)', async () => {
|
||||
await expect(
|
||||
publishResourceEvent(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
{ actorId: 'u1', type: 'doc.updated' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await expect(
|
||||
publishResourceEvent(
|
||||
{ id: 'doc-1', type: 'document' },
|
||||
{ actorId: 'u1', data: { holderId: null }, type: 'lock.changed' },
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import debug from 'debug';
|
||||
|
||||
// Import the transport pieces from their concrete modules rather than the
|
||||
// `@/server/modules/AgentRuntime` barrel: the barrel re-exports RuntimeExecutors,
|
||||
// which eagerly constructs the ModelRuntime ApiKeyManager at module load and
|
||||
// throws in client/test contexts. These leaf modules pull no ModelRuntime.
|
||||
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
|
||||
import { getAgentRuntimeRedisClient } from '@/server/modules/AgentRuntime/redis';
|
||||
import { StreamEventManager } from '@/server/modules/AgentRuntime/StreamEventManager';
|
||||
import type { IStreamEventManager } from '@/server/modules/AgentRuntime/types';
|
||||
|
||||
import type { ReceivedResourceEvent, ResourceEvent, ResourceRef } from './types';
|
||||
|
||||
export type { ReceivedResourceEvent, ResourceEvent, ResourceRef, ResourceType } from './types';
|
||||
|
||||
const log = debug('lobe-server:resource-events');
|
||||
|
||||
/** Redis Stream / in-memory channel key for a resource. */
|
||||
export const resourceChannelId = (ref: ResourceRef): string => `resource:${ref.type}:${ref.id}`;
|
||||
|
||||
/**
|
||||
* Select the underlying transport. We deliberately bypass
|
||||
* `createStreamEventManager()` — its `GatewayStreamNotifier` wrapper POSTs every
|
||||
* published event to the agent gateway, which must not see resource events.
|
||||
* Evaluated per call so it picks up Redis becoming (un)available.
|
||||
*/
|
||||
const getManager = (): IStreamEventManager =>
|
||||
getAgentRuntimeRedisClient() !== null ? new StreamEventManager() : inMemoryStreamEventManager;
|
||||
|
||||
/**
|
||||
* Realtime event fan-out for editable resources, keyed by (resourceType, id).
|
||||
*
|
||||
* A thin, table-agnostic wrapper over the existing Redis-Streams transport so
|
||||
* presence and (eventually) real-time co-editing can reuse the same channel.
|
||||
* The lease/lock is advisory and this channel is best-effort: publishing never
|
||||
* throws, and with no Redis the in-memory manager keeps single-instance dev
|
||||
* working while clients fall back to their polling heartbeat.
|
||||
*/
|
||||
export const publishResourceEvent = async (
|
||||
ref: ResourceRef,
|
||||
event: ResourceEvent,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await getManager().publishStreamEvent(resourceChannelId(ref), {
|
||||
// The agent StreamEvent shape (stepIndex + closed `type` union) is an
|
||||
// implementation detail of the transport; cast at this single boundary.
|
||||
data: { actorId: event.actorId, ...event.data },
|
||||
stepIndex: 0,
|
||||
type: event.type,
|
||||
} as unknown as Parameters<IStreamEventManager['publishStreamEvent']>[1]);
|
||||
} catch (error) {
|
||||
// Best-effort: a transport hiccup must never break the caller's save/lock op.
|
||||
log('publishResourceEvent failed for %s:%s %O', ref.type, ref.id, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to a resource's events until `signal` aborts. Only events published
|
||||
* after subscription are delivered (no history replay).
|
||||
*/
|
||||
export const subscribeResourceEvents = async (
|
||||
ref: ResourceRef,
|
||||
onEvent: (event: ReceivedResourceEvent) => void,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> => {
|
||||
await getManager().subscribeStreamEvents(
|
||||
resourceChannelId(ref),
|
||||
'$',
|
||||
(events) => {
|
||||
for (const e of events) {
|
||||
const { actorId, ...rest } = (e.data ?? {}) as Record<string, unknown>;
|
||||
onEvent({
|
||||
actorId: typeof actorId === 'string' ? actorId : '',
|
||||
data: rest,
|
||||
timestamp: e.timestamp,
|
||||
type: e.type as unknown as ResourceEvent['type'],
|
||||
});
|
||||
}
|
||||
},
|
||||
signal,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Editable resource families that can broadcast realtime events. */
|
||||
export type ResourceType = 'agent' | 'chatGroup' | 'document' | 'task';
|
||||
|
||||
export interface ResourceRef {
|
||||
id: string;
|
||||
type: ResourceType;
|
||||
}
|
||||
|
||||
export type ResourceEventType = 'doc.updated' | 'lock.changed';
|
||||
|
||||
export interface ResourceEvent {
|
||||
/** User id that triggered the event; lets subscribers ignore self-originated events. */
|
||||
actorId: string;
|
||||
/** Event-specific payload (e.g. `{ holderId }` for `lock.changed`). */
|
||||
data?: Record<string, unknown>;
|
||||
type: ResourceEventType;
|
||||
}
|
||||
|
||||
export interface ReceivedResourceEvent extends ResourceEvent {
|
||||
timestamp: number;
|
||||
}
|
||||
@@ -706,6 +706,7 @@ export class TaskService {
|
||||
activities: activities.length > 0 ? activities : undefined,
|
||||
topicCount: topics.length > 0 ? topics.length : undefined,
|
||||
workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined,
|
||||
workspaceId: task.workspaceId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "لم يتم العثور على أعضاء مطابقين",
|
||||
"noMembersYet": "لا تحتوي هذه المجموعة على أي أعضاء بعد. انقر على زر + لدعوة وكلاء.",
|
||||
"noSelectedAgents": "لم يتم تحديد أي أعضاء بعد",
|
||||
"opStatusTray.cost": "التكلفة",
|
||||
"opStatusTray.status.compressing": "ضغط السياق",
|
||||
"opStatusTray.status.generating": "جاري التوليد",
|
||||
"opStatusTray.status.reasoning": "التفكير",
|
||||
"opStatusTray.status.searching": "جاري البحث",
|
||||
"opStatusTray.status.toolCalling": "استدعاء الأدوات",
|
||||
"opStatusTray.steps": "الخطوات",
|
||||
"opStatusTray.tokens": "الرموز",
|
||||
"openInNewWindow": "فتح في نافذة جديدة",
|
||||
"operation.contextCompression": "السياق طويل جدًا، يتم ضغط السجل...",
|
||||
"operation.execAgentRuntime": "جارٍ تحضير الرد",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "تبديل إلى فرع جديد…",
|
||||
"workingDirectory.createBranchTitle": "إنشاء فرع جديد",
|
||||
"workingDirectory.current": "دليل العمل الحالي",
|
||||
"workingDirectory.deleteBranchAction": "حذف الفرع",
|
||||
"workingDirectory.deleteBranchConfirm": "هل تريد حذف الفرع “{{name}}”؟ سيؤدي ذلك إلى إزالته نهائيًا، بما في ذلك أي عمليات دمج غير مكتملة.",
|
||||
"workingDirectory.deleteBranchTitle": "حذف الفرع",
|
||||
"workingDirectory.deleteFailed": "فشل الحذف",
|
||||
"workingDirectory.detachedHead": "رأس منفصل عند {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "تمت الإضافة {{added}} · تم التعديل {{modified}} · تم الحذف {{deleted}}",
|
||||
"workingDirectory.filesAdded": "تمت الإضافة",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "حديث",
|
||||
"workingDirectory.refreshGitStatus": "تحديث حالة الفرع وطلبات السحب",
|
||||
"workingDirectory.removeRecent": "إزالة من الحديث",
|
||||
"workingDirectory.renameBranchAction": "إعادة تسمية الفرع",
|
||||
"workingDirectory.renameBranchTitle": "إعادة تسمية الفرع",
|
||||
"workingDirectory.renameFailed": "فشل إعادة التسمية",
|
||||
"workingDirectory.selectFolder": "اختر مجلدًا",
|
||||
"workingDirectory.title": "دليل العمل",
|
||||
"workingDirectory.topicDescription": "تجاوز الإعداد الافتراضي للوكيل لهذه المحادثة فقط",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "تم حذف الصفحة بنجاح",
|
||||
"pageEditor.duplicateError": "فشل في تكرار الصفحة",
|
||||
"pageEditor.duplicateSuccess": "تم تكرار الصفحة بنجاح",
|
||||
"pageEditor.editMode.checking": "جارٍ التحقق من توفر التعديل…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editMode.lockedBySomeone": "شخص آخر يقوم بتعديل هذا المستند",
|
||||
"pageEditor.editedAt": "آخر تعديل في {{time}}",
|
||||
"pageEditor.editedBy": "آخر تعديل بواسطة {{name}}",
|
||||
"pageEditor.editorPlaceholder": "اضغط \"/\" للوصول إلى الذكاء الاصطناعي والأوامر",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "نسخة واحدة {{count}}",
|
||||
"pageEditor.history.versionCount_other": "{{count}} نسخ",
|
||||
"pageEditor.linkCopied": "تم نسخ الرابط",
|
||||
"pageEditor.lock.editingByOther": "{{name}} يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.",
|
||||
"pageEditor.lock.editingBySomeone": "شخص آخر يقوم بتعديل هذه الصفحة. لا يمكن حفظ تغييراتك الآن.",
|
||||
"pageEditor.menu.copyLink": "نسخ الرابط",
|
||||
"pageEditor.menu.export": "تصدير",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "ترتيب النماذج المخصصة",
|
||||
"providerModels.list.enabledEmpty": "لا توجد نماذج مفعلة. يرجى تفعيل النماذج المفضلة من القائمة أدناه~",
|
||||
"providerModels.list.fetcher.clear": "مسح النماذج المسحوبة",
|
||||
"providerModels.list.fetcher.error": "فشل في جلب النماذج: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "خطأ غير معروف",
|
||||
"providerModels.list.fetcher.fetch": "جلب النماذج",
|
||||
"providerModels.list.fetcher.fetching": "جارٍ جلب قائمة النماذج...",
|
||||
"providerModels.list.fetcher.latestTime": "آخر تحديث: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى زيادة حصة المفتاح أو إعادة المحاولة لاحقًا.",
|
||||
"RateLimitExceeded": "عذرًا، تم الوصول إلى الحد الأقصى لاستخدام الرموز أو عدد الطلبات لهذه المفتاح. يرجى إعادة المحاولة لاحقًا أو زيادة حصة المفتاح.",
|
||||
"StateStorePersistError": "تسبب مشكلة مؤقتة في تخزين حالة المحادثة في تعطيل هذه العملية. يرجى إعادة المحاولة؛ إذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StateStoreReadError": "تعذر استئناف هذه العملية لأن حالة الجلسة غير متوفرة. يرجى إعادة فتح المحادثة للمتابعة؛ وإذا استمرت المشكلة، يرجى الاتصال بالدعم.",
|
||||
"StreamChunkError": "حدث خطأ أثناء تحليل جزء الرسالة من الطلب المتدفق. يرجى التحقق مما إذا كانت واجهة API الحالية تتوافق مع المواصفات القياسية، أو الاتصال بمزود API للحصول على المساعدة.",
|
||||
"UpstreamGatewayError": "عاد البوابة أو الوكيل المصدر بخطأ. يرجى إعادة المحاولة قريبًا؛ إذا استمرت المشكلة، تحقق من إعدادات الوكيل / نقطة النهاية.",
|
||||
"UpstreamHttpError": "عاد المزود بخطأ HTTP دون تفاصيل إضافية. يرجى إعادة المحاولة، أو التحقق من الطلب وإعدادات النموذج.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"يعمل",
|
||||
"يُصمم",
|
||||
"يفكر",
|
||||
"يحسب",
|
||||
"يُخمّر",
|
||||
"يُركّب",
|
||||
"يُحلل",
|
||||
"يُهندس",
|
||||
"يؤلف",
|
||||
"يُنسق",
|
||||
"يرسم",
|
||||
"يُبدع",
|
||||
"يتأمل",
|
||||
"يصنع",
|
||||
"يُشعل",
|
||||
"يُغلي ببطء",
|
||||
"يُدور",
|
||||
"يُسيطر",
|
||||
"يُلمع",
|
||||
"يُجهز الإجابة",
|
||||
"يخبز",
|
||||
"يُوجه",
|
||||
"يُدمج",
|
||||
"يُفك الشيفرة",
|
||||
"يُصنع",
|
||||
"يُوائم",
|
||||
"يُرتجل",
|
||||
"يستنتج",
|
||||
"يُجرب",
|
||||
"يتعرج"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "خدمة Klavis غير مفعلة",
|
||||
"tools.klavis.oauthRequired": "يرجى إكمال التحقق من OAuth في النافذة الجديدة",
|
||||
"tools.klavis.pendingAuth": "في انتظار التحقق",
|
||||
"tools.klavis.remove": "إزالة",
|
||||
"tools.klavis.removeConfirm.desc": "سيتم إزالة {{name}} نهائيًا من خدماتك المتصلة. لا يمكن التراجع عن هذا الإجراء.",
|
||||
"tools.klavis.removeConfirm.title": "إزالة {{name}}؟",
|
||||
"tools.klavis.serverCreated": "تم إنشاء الخادم بنجاح",
|
||||
"tools.klavis.serverCreatedFailed": "فشل في إنشاء الخادم",
|
||||
"tools.klavis.serverRemoved": "تمت إزالة الخادم",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "بطاقة",
|
||||
"management.view.list": "قائمة",
|
||||
"newTopic": "موضوع جديد",
|
||||
"projectStatus.failed_one": "{{count}} موضوع فشل",
|
||||
"projectStatus.failed_other": "{{count}} مواضيع فشلت",
|
||||
"projectStatus.loading_one": "{{count}} موضوع قيد التحميل",
|
||||
"projectStatus.loading_other": "{{count}} مواضيع قيد التحميل",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع ينتظر الإدخال",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} مواضيع تنتظر الإدخال",
|
||||
"renameModal.description": "يُفضَّل أن يكون قصيرًا وسهل التعرّف.",
|
||||
"renameModal.title": "إعادة تسمية الموضوع",
|
||||
"searchPlaceholder": "ابحث في المواضيع...",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "Няма съвпадащи членове",
|
||||
"noMembersYet": "Тази група все още няма членове. Натиснете бутона +, за да поканите агенти.",
|
||||
"noSelectedAgents": "Все още няма избрани членове",
|
||||
"opStatusTray.cost": "разход",
|
||||
"opStatusTray.status.compressing": "Компресиране на контекста",
|
||||
"opStatusTray.status.generating": "Генериране",
|
||||
"opStatusTray.status.reasoning": "Разсъждаване",
|
||||
"opStatusTray.status.searching": "Търсене",
|
||||
"opStatusTray.status.toolCalling": "Извикване на инструменти",
|
||||
"opStatusTray.steps": "стъпки",
|
||||
"opStatusTray.tokens": "токени",
|
||||
"openInNewWindow": "Отвори в нов прозорец",
|
||||
"operation.contextCompression": "Контекстът е твърде дълъг, компресиране на историята...",
|
||||
"operation.execAgentRuntime": "Подготвяне на отговор",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Превключване към нов клон…",
|
||||
"workingDirectory.createBranchTitle": "Създаване на нов клон",
|
||||
"workingDirectory.current": "Текуща работна директория",
|
||||
"workingDirectory.deleteBranchAction": "Изтриване на клон",
|
||||
"workingDirectory.deleteBranchConfirm": "Да изтрия ли клона „{{name}}“? Това ще го премахне окончателно, включително всички несляти комити.",
|
||||
"workingDirectory.deleteBranchTitle": "Изтриване на клон",
|
||||
"workingDirectory.deleteFailed": "Неуспешно изтриване",
|
||||
"workingDirectory.detachedHead": "Отделен HEAD на {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Добавени {{added}} · Модифицирани {{modified}} · Изтрити {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Добавени",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Скорошни",
|
||||
"workingDirectory.refreshGitStatus": "Обновяване на статус на клона и PR",
|
||||
"workingDirectory.removeRecent": "Премахване от скорошни",
|
||||
"workingDirectory.renameBranchAction": "Преименуване на клон",
|
||||
"workingDirectory.renameBranchTitle": "Преименуване на клон",
|
||||
"workingDirectory.renameFailed": "Неуспешно преименуване",
|
||||
"workingDirectory.selectFolder": "Изберете папка",
|
||||
"workingDirectory.title": "Работна директория",
|
||||
"workingDirectory.topicDescription": "Замяна на настройката по подразбиране на агента само за този разговор",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "Страницата е изтрита успешно",
|
||||
"pageEditor.duplicateError": "Неуспешно дублиране на страницата",
|
||||
"pageEditor.duplicateSuccess": "Страницата е дублирана успешно",
|
||||
"pageEditor.editMode.checking": "Проверка на наличността за редактиране…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} редактира този документ",
|
||||
"pageEditor.editMode.lockedBySomeone": "Някой друг редактира този документ",
|
||||
"pageEditor.editedAt": "Последна редакция на {{time}}",
|
||||
"pageEditor.editedBy": "Последна редакция от {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Натиснете \"/\" за ИИ и команди",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} версия",
|
||||
"pageEditor.history.versionCount_other": "{{count}} версии",
|
||||
"pageEditor.linkCopied": "Връзката е копирана",
|
||||
"pageEditor.lock.editingByOther": "{{name}} редактира тази страница. Вашите промени не могат да бъдат запазени в момента.",
|
||||
"pageEditor.lock.editingBySomeone": "Някой друг редактира тази страница. Вашите промени не могат да бъдат запазени в момента.",
|
||||
"pageEditor.menu.copyLink": "Копирай връзка",
|
||||
"pageEditor.menu.export": "Експортирай",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "Сортиране на персонализирани модели",
|
||||
"providerModels.list.enabledEmpty": "Няма активирани модели. Моля, активирайте предпочитаните модели от списъка по-долу~",
|
||||
"providerModels.list.fetcher.clear": "Изчисти изтеглените модели",
|
||||
"providerModels.list.fetcher.error": "Неуспешно извличане на модели: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Неизвестна грешка",
|
||||
"providerModels.list.fetcher.fetch": "Изтегли модели",
|
||||
"providerModels.list.fetcher.fetching": "Изтегляне на списък с модели...",
|
||||
"providerModels.list.fetcher.latestTime": "Последна актуализация: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "Съжаляваме, използването на токени или броят на заявките достигна лимита на квотата за този ключ. Моля, увеличете квотата на ключа или опитайте отново по-късно.",
|
||||
"RateLimitExceeded": "Съжаляваме, използването на токени или броят на заявките достигна лимита на скоростта за този ключ. Моля, опитайте отново по-късно или увеличете квотата на ключа.",
|
||||
"StateStorePersistError": "Временен проблем с хранилището на състоянието на разговора прекъсна тази операция. Моля, опитайте отново; ако проблемът продължи, свържете се с поддръжката.",
|
||||
"StateStoreReadError": "Тази операция не може да бъде възобновена, защото състоянието на сесията не е налично. Моля, отворете разговора отново, за да продължите; ако проблемът продължава, свържете се с поддръжката.",
|
||||
"StreamChunkError": "Грешка при анализиране на част от съобщението в заявката за стрийминг. Моля, проверете дали текущият API интерфейс отговаря на стандартните спецификации или се свържете с вашия доставчик на API за помощ.",
|
||||
"UpstreamGatewayError": "Горният шлюз или прокси върна грешка. Моля, опитайте отново след малко; ако проблемът продължи, проверете конфигурацията на вашето прокси/крайна точка.",
|
||||
"UpstreamHttpError": "Доставчикът върна HTTP грешка без допълнителни подробности. Моля, опитайте отново или проверете вашата заявка и конфигурацията на модела.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Работя",
|
||||
"Скицирам",
|
||||
"Мисля",
|
||||
"Изчислявам",
|
||||
"Приготвям",
|
||||
"Синтезирам",
|
||||
"Смятам",
|
||||
"Проектирам",
|
||||
"Съставям",
|
||||
"Оркестрирам",
|
||||
"Рисувам",
|
||||
"Импровизирам",
|
||||
"Размишлявам",
|
||||
"Създавам",
|
||||
"Фламбирам",
|
||||
"Задушавам",
|
||||
"Въртя",
|
||||
"Овладявам",
|
||||
"Полиране",
|
||||
"Подготвям отговора",
|
||||
"Пека",
|
||||
"Канализирам",
|
||||
"Обединявам",
|
||||
"Разшифровам",
|
||||
"Изковавам",
|
||||
"Хармонизирам",
|
||||
"Импровизирам",
|
||||
"Извеждам",
|
||||
"Пипам",
|
||||
"Зигзагообразно"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "Услугата Klavis не е активирана",
|
||||
"tools.klavis.oauthRequired": "Моля, завършете OAuth удостоверяването в нов прозорец",
|
||||
"tools.klavis.pendingAuth": "Изчаква удостоверяване",
|
||||
"tools.klavis.remove": "Премахни",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} ще бъде окончателно премахнат от вашите свързани услуги. Това действие не може да бъде отменено.",
|
||||
"tools.klavis.removeConfirm.title": "Премахване на {{name}}?",
|
||||
"tools.klavis.serverCreated": "Сървърът е създаден успешно",
|
||||
"tools.klavis.serverCreatedFailed": "Неуспешно създаване на сървър",
|
||||
"tools.klavis.serverRemoved": "Сървърът е премахнат",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "Карти",
|
||||
"management.view.list": "Списък",
|
||||
"newTopic": "Нова тема",
|
||||
"projectStatus.failed_one": "{{count}} неуспешна тема",
|
||||
"projectStatus.failed_other": "{{count}} неуспешни теми",
|
||||
"projectStatus.loading_one": "{{count}} зареждаща се тема",
|
||||
"projectStatus.loading_other": "{{count}} зареждащи се теми",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} тема, очакваща въвеждане",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} теми, очакващи въвеждане",
|
||||
"renameModal.description": "Поддържайте го кратко и лесно за разпознаване.",
|
||||
"renameModal.title": "Преименуване на тема",
|
||||
"searchPlaceholder": "Търсене в темите...",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "Keine passenden Mitglieder gefunden",
|
||||
"noMembersYet": "Diese Gruppe hat noch keine Mitglieder. Klicke auf +, um Agenten einzuladen.",
|
||||
"noSelectedAgents": "Noch keine Mitglieder ausgewählt",
|
||||
"opStatusTray.cost": "Kosten",
|
||||
"opStatusTray.status.compressing": "Kontext komprimieren",
|
||||
"opStatusTray.status.generating": "Generieren",
|
||||
"opStatusTray.status.reasoning": "Nachdenken",
|
||||
"opStatusTray.status.searching": "Suchen",
|
||||
"opStatusTray.status.toolCalling": "Werkzeuge aufrufen",
|
||||
"opStatusTray.steps": "Schritte",
|
||||
"opStatusTray.tokens": "Token",
|
||||
"openInNewWindow": "In neuem Fenster öffnen",
|
||||
"operation.contextCompression": "Kontext zu lang, komprimiere Verlauf...",
|
||||
"operation.execAgentRuntime": "Antwort wird vorbereitet",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Neuen Zweig auschecken…",
|
||||
"workingDirectory.createBranchTitle": "Neuen Zweig erstellen",
|
||||
"workingDirectory.current": "Aktuelles Arbeitsverzeichnis",
|
||||
"workingDirectory.deleteBranchAction": "Branch löschen",
|
||||
"workingDirectory.deleteBranchConfirm": "Branch „{{name}}“ löschen? Dies entfernt ihn dauerhaft, einschließlich aller nicht zusammengeführten Commits.",
|
||||
"workingDirectory.deleteBranchTitle": "Branch löschen",
|
||||
"workingDirectory.deleteFailed": "Löschen fehlgeschlagen",
|
||||
"workingDirectory.detachedHead": "Losgelöster HEAD bei {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Hinzugefügt {{added}} · Geändert {{modified}} · Gelöscht {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Hinzugefügt",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Kürzlich",
|
||||
"workingDirectory.refreshGitStatus": "Zweig- und PR-Status aktualisieren",
|
||||
"workingDirectory.removeRecent": "Aus Kürzlich entfernen",
|
||||
"workingDirectory.renameBranchAction": "Branch umbenennen",
|
||||
"workingDirectory.renameBranchTitle": "Branch umbenennen",
|
||||
"workingDirectory.renameFailed": "Umbenennen fehlgeschlagen",
|
||||
"workingDirectory.selectFolder": "Ordner auswählen",
|
||||
"workingDirectory.title": "Arbeitsverzeichnis",
|
||||
"workingDirectory.topicDescription": "Überschreibt den Standard-Agenten nur für diese Unterhaltung",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "Seite erfolgreich gelöscht",
|
||||
"pageEditor.duplicateError": "Fehler beim Duplizieren der Seite",
|
||||
"pageEditor.duplicateSuccess": "Seite erfolgreich dupliziert",
|
||||
"pageEditor.editMode.checking": "Bearbeitsverfügbarkeit wird überprüft…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} bearbeitet dieses Dokument",
|
||||
"pageEditor.editMode.lockedBySomeone": "Jemand anderes bearbeitet dieses Dokument",
|
||||
"pageEditor.editedAt": "Zuletzt bearbeitet am {{time}}",
|
||||
"pageEditor.editedBy": "Zuletzt bearbeitet von {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Drücken Sie \"/\" für KI und Befehle",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} Version",
|
||||
"pageEditor.history.versionCount_other": "{{count}} Versionen",
|
||||
"pageEditor.linkCopied": "Link kopiert",
|
||||
"pageEditor.lock.editingByOther": "{{name}} bearbeitet diese Seite. Ihre Änderungen können momentan nicht gespeichert werden.",
|
||||
"pageEditor.lock.editingBySomeone": "Jemand anderes bearbeitet diese Seite. Ihre Änderungen können momentan nicht gespeichert werden.",
|
||||
"pageEditor.menu.copyLink": "Link kopieren",
|
||||
"pageEditor.menu.export": "Exportieren",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "Benutzerdefinierte Modellsortierung",
|
||||
"providerModels.list.enabledEmpty": "Keine aktivierten Modelle verfügbar. Bitte aktivieren Sie Ihre bevorzugten Modelle aus der Liste unten~",
|
||||
"providerModels.list.fetcher.clear": "Abgerufene Modelle löschen",
|
||||
"providerModels.list.fetcher.error": "Fehler beim Abrufen der Modelle: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Unbekannter Fehler",
|
||||
"providerModels.list.fetcher.fetch": "Modelle abrufen",
|
||||
"providerModels.list.fetcher.fetching": "Modellliste wird abgerufen...",
|
||||
"providerModels.list.fetcher.latestTime": "Zuletzt aktualisiert: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "Entschuldigung, die Token-Nutzung oder die Anzahl der Anfragen hat das Kontingentlimit für diesen Schlüssel erreicht. Bitte erhöhen Sie das Kontingent des Schlüssels oder versuchen Sie es später erneut.",
|
||||
"RateLimitExceeded": "Entschuldigung, die Token-Nutzung oder die Anzahl der Anfragen hat das Ratenlimit für diesen Schlüssel erreicht. Bitte versuchen Sie es später erneut oder erhöhen Sie das Kontingent des Schlüssels.",
|
||||
"StateStorePersistError": "Ein vorübergehendes Problem mit dem Konversationsstatusspeicher hat diesen Vorgang unterbrochen. Bitte versuchen Sie es erneut; falls das Problem weiterhin besteht, wenden Sie sich an den Support.",
|
||||
"StateStoreReadError": "Dieser Vorgang konnte nicht fortgesetzt werden, da der Sitzungsstatus nicht verfügbar war. Bitte öffnen Sie das Gespräch erneut, um fortzufahren; wenn das Problem weiterhin besteht, wenden Sie sich an den Support.",
|
||||
"StreamChunkError": "Fehler beim Parsen des Nachrichtenchunks der Streaming-Anfrage. Bitte überprüfen Sie, ob die aktuelle API-Schnittstelle den Standardspezifikationen entspricht, oder wenden Sie sich an Ihren API-Anbieter, um Unterstützung zu erhalten.",
|
||||
"UpstreamGatewayError": "Das Upstream-Gateway oder der Proxy hat einen Fehler zurückgegeben. Bitte versuchen Sie es in Kürze erneut; falls das Problem weiterhin besteht, überprüfen Sie Ihre Proxy-/Endpunktkonfiguration.",
|
||||
"UpstreamHttpError": "Der Anbieter hat einen HTTP-Fehler ohne weitere Details zurückgegeben. Bitte versuchen Sie es erneut oder überprüfen Sie Ihre Anfrage- und Modellkonfiguration.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Arbeiten",
|
||||
"Entwerfen",
|
||||
"Nachdenken",
|
||||
"Berechnen",
|
||||
"Brauen",
|
||||
"Synthesieren",
|
||||
"Knacken",
|
||||
"Gestalten",
|
||||
"Komponieren",
|
||||
"Orchestrieren",
|
||||
"Skizzieren",
|
||||
"Herumprobieren",
|
||||
"Überlegen",
|
||||
"Gestalten",
|
||||
"Flambieren",
|
||||
"Simmern",
|
||||
"Surren",
|
||||
"Zähmen",
|
||||
"Polieren",
|
||||
"Antwort vorbereiten",
|
||||
"Backen",
|
||||
"Kanalisieren",
|
||||
"Verschmelzen",
|
||||
"Entschlüsseln",
|
||||
"Schmieden",
|
||||
"Harmonisieren",
|
||||
"Improvisieren",
|
||||
"Schlussfolgern",
|
||||
"Tüfteln",
|
||||
"Zickzack bewegen"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "Klavis-Dienst nicht aktiviert",
|
||||
"tools.klavis.oauthRequired": "Bitte schließen Sie die OAuth-Authentifizierung im neuen Fenster ab",
|
||||
"tools.klavis.pendingAuth": "Authentifizierung ausstehend",
|
||||
"tools.klavis.remove": "Entfernen",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} wird dauerhaft aus Ihren verbundenen Diensten entfernt. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"tools.klavis.removeConfirm.title": "{{name}} entfernen?",
|
||||
"tools.klavis.serverCreated": "Server erfolgreich erstellt",
|
||||
"tools.klavis.serverCreatedFailed": "Servererstellung fehlgeschlagen",
|
||||
"tools.klavis.serverRemoved": "Server entfernt",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "Karte",
|
||||
"management.view.list": "Liste",
|
||||
"newTopic": "Neues Thema",
|
||||
"projectStatus.failed_one": "{{count}} fehlgeschlagenes Thema",
|
||||
"projectStatus.failed_other": "{{count}} fehlgeschlagene Themen",
|
||||
"projectStatus.loading_one": "{{count}} ladendes Thema",
|
||||
"projectStatus.loading_other": "{{count}} ladende Themen",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} Thema wartet auf Eingabe",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} Themen warten auf Eingabe",
|
||||
"renameModal.description": "Kurz und leicht erkennbar halten.",
|
||||
"renameModal.title": "Thema umbenennen",
|
||||
"searchPlaceholder": "Themen suchen...",
|
||||
|
||||
@@ -370,12 +370,12 @@
|
||||
"noMatchingAgents": "No matching members found",
|
||||
"noMembersYet": "This group doesn't have any members yet. Click the + button to invite agents.",
|
||||
"noSelectedAgents": "No members selected yet",
|
||||
"opStatusTray.cost": "cost",
|
||||
"opStatusTray.status.compressing": "Compressing context",
|
||||
"opStatusTray.status.generating": "Generating",
|
||||
"opStatusTray.status.reasoning": "Thinking",
|
||||
"opStatusTray.status.searching": "Searching",
|
||||
"opStatusTray.status.toolCalling": "Calling tools",
|
||||
"opStatusTray.cost": "cost",
|
||||
"opStatusTray.steps": "steps",
|
||||
"opStatusTray.tokens": "tokens",
|
||||
"openInNewWindow": "Open in New Window",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "Page deleted successfully",
|
||||
"pageEditor.duplicateError": "Failed to duplicate the page",
|
||||
"pageEditor.duplicateSuccess": "Page duplicated successfully",
|
||||
"pageEditor.editMode.checking": "Checking edit availability…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} is editing this document",
|
||||
"pageEditor.editMode.lockedBySomeone": "Someone else is editing this document",
|
||||
"pageEditor.editedAt": "Last edited on {{time}}",
|
||||
"pageEditor.editedBy": "Last edited by {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Press \"/\" for AI and commands.",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} version",
|
||||
"pageEditor.history.versionCount_other": "{{count}} versions",
|
||||
"pageEditor.linkCopied": "Link copied",
|
||||
"pageEditor.lock.editingByOther": "{{name}} is editing this page. Your changes can’t be saved right now.",
|
||||
"pageEditor.lock.editingBySomeone": "Someone else is editing this page. Your changes can’t be saved right now.",
|
||||
"pageEditor.menu.copyLink": "Copy Link",
|
||||
"pageEditor.menu.export": "Export",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "Custom Model Sorting",
|
||||
"providerModels.list.enabledEmpty": "No enabled models available. Please enable your preferred models from the list below~",
|
||||
"providerModels.list.fetcher.clear": "Clear fetched models",
|
||||
"providerModels.list.fetcher.error": "Failed to fetch models: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Unknown error",
|
||||
"providerModels.list.fetcher.fetch": "Fetch models",
|
||||
"providerModels.list.fetcher.fetching": "Fetching model list...",
|
||||
"providerModels.list.fetcher.latestTime": "Last updated: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
|
||||
"RateLimitExceeded": "Sorry, the token usage or request count has reached the rate limit for this key. Please try again later or increase the key's quota.",
|
||||
"StateStorePersistError": "A temporary issue with the conversation state store interrupted this operation. Please try again; if it persists, contact support.",
|
||||
"StateStoreReadError": "This operation could not be resumed because its session state was unavailable. Please reopen the conversation to continue; if it persists, contact support.",
|
||||
"StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
|
||||
"UpstreamGatewayError": "The upstream gateway or proxy returned an error. Please try again shortly; if it persists, check your proxy / endpoint configuration.",
|
||||
"UpstreamHttpError": "The provider returned an HTTP error without further detail. Please try again, or check your request and model configuration.",
|
||||
|
||||
@@ -1179,9 +1179,6 @@
|
||||
"tools.klavis.disconnect": "Disconnect",
|
||||
"tools.klavis.disconnected": "Disconnected",
|
||||
"tools.klavis.error": "Error",
|
||||
"tools.klavis.remove": "Remove",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.klavis.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.klavis.groupName": "Klavis Tools",
|
||||
"tools.klavis.manage": "Manage Klavis",
|
||||
"tools.klavis.manageTitle": "Manage Klavis Integration",
|
||||
@@ -1189,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "Klavis service not enabled",
|
||||
"tools.klavis.oauthRequired": "Please complete OAuth authentication in the new window",
|
||||
"tools.klavis.pendingAuth": "Pending Authentication",
|
||||
"tools.klavis.remove": "Remove",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
|
||||
"tools.klavis.removeConfirm.title": "Remove {{name}}?",
|
||||
"tools.klavis.serverCreated": "Server created successfully",
|
||||
"tools.klavis.serverCreatedFailed": "Failed to create server",
|
||||
"tools.klavis.serverRemoved": "Server removed",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "No se encontraron miembros coincidentes",
|
||||
"noMembersYet": "Este grupo aún no tiene miembros. Haz clic en el botón + para invitar agentes.",
|
||||
"noSelectedAgents": "Aún no se han seleccionado miembros",
|
||||
"opStatusTray.cost": "costo",
|
||||
"opStatusTray.status.compressing": "Comprimiendo contexto",
|
||||
"opStatusTray.status.generating": "Generando",
|
||||
"opStatusTray.status.reasoning": "Pensando",
|
||||
"opStatusTray.status.searching": "Buscando",
|
||||
"opStatusTray.status.toolCalling": "Llamando herramientas",
|
||||
"opStatusTray.steps": "pasos",
|
||||
"opStatusTray.tokens": "fichas",
|
||||
"openInNewWindow": "Abrir en una nueva ventana",
|
||||
"operation.contextCompression": "Contexto demasiado largo, comprimiendo historial...",
|
||||
"operation.execAgentRuntime": "Preparando respuesta",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Cambiar a nueva rama…",
|
||||
"workingDirectory.createBranchTitle": "Crear nueva rama",
|
||||
"workingDirectory.current": "Directorio de trabajo actual",
|
||||
"workingDirectory.deleteBranchAction": "Eliminar rama",
|
||||
"workingDirectory.deleteBranchConfirm": "¿Eliminar la rama “{{name}}”? Esto la eliminará permanentemente, incluyendo cualquier commit no fusionado.",
|
||||
"workingDirectory.deleteBranchTitle": "Eliminar rama",
|
||||
"workingDirectory.deleteFailed": "Error al eliminar",
|
||||
"workingDirectory.detachedHead": "HEAD separado en {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Añadido {{added}} · Modificado {{modified}} · Eliminado {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Añadido",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Recientes",
|
||||
"workingDirectory.refreshGitStatus": "Actualizar estado de rama y PR",
|
||||
"workingDirectory.removeRecent": "Eliminar de recientes",
|
||||
"workingDirectory.renameBranchAction": "Renombrar rama",
|
||||
"workingDirectory.renameBranchTitle": "Renombrar rama",
|
||||
"workingDirectory.renameFailed": "Error al renombrar",
|
||||
"workingDirectory.selectFolder": "Seleccionar carpeta",
|
||||
"workingDirectory.title": "Directorio de Trabajo",
|
||||
"workingDirectory.topicDescription": "Sobrescribir el valor predeterminado del Agente solo para esta conversación",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "Página eliminada correctamente",
|
||||
"pageEditor.duplicateError": "Error al duplicar la página",
|
||||
"pageEditor.duplicateSuccess": "Página duplicada correctamente",
|
||||
"pageEditor.editMode.checking": "Comprobando la disponibilidad de edición…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} está editando este documento",
|
||||
"pageEditor.editMode.lockedBySomeone": "Alguien más está editando este documento",
|
||||
"pageEditor.editedAt": "Última edición el {{time}}",
|
||||
"pageEditor.editedBy": "Última edición por {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Presiona \"/\" para IA y comandos",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} versión",
|
||||
"pageEditor.history.versionCount_other": "{{count}} versiones",
|
||||
"pageEditor.linkCopied": "Enlace copiado",
|
||||
"pageEditor.lock.editingByOther": "{{name}} está editando esta página. Tus cambios no se pueden guardar en este momento.",
|
||||
"pageEditor.lock.editingBySomeone": "Alguien más está editando esta página. Tus cambios no se pueden guardar en este momento.",
|
||||
"pageEditor.menu.copyLink": "Copiar enlace",
|
||||
"pageEditor.menu.export": "Exportar",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "Orden personalizado de modelos",
|
||||
"providerModels.list.enabledEmpty": "No hay modelos habilitados disponibles. Habilita tus modelos preferidos de la lista a continuación~",
|
||||
"providerModels.list.fetcher.clear": "Borrar modelos obtenidos",
|
||||
"providerModels.list.fetcher.error": "Error al obtener los modelos: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Error desconocido",
|
||||
"providerModels.list.fetcher.fetch": "Obtener modelos",
|
||||
"providerModels.list.fetcher.fetching": "Obteniendo lista de modelos...",
|
||||
"providerModels.list.fetcher.latestTime": "Última actualización: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "Lo sentimos, el uso de tokens o el número de solicitudes ha alcanzado el límite de cuota para esta clave. Por favor, aumenta la cuota de la clave o vuelve a intentarlo más tarde.",
|
||||
"RateLimitExceeded": "Lo sentimos, el uso de tokens o el número de solicitudes ha alcanzado el límite de velocidad para esta clave. Por favor, vuelve a intentarlo más tarde o aumenta la cuota de la clave.",
|
||||
"StateStorePersistError": "Un problema temporal con el almacenamiento del estado de la conversación interrumpió esta operación. Por favor, vuelve a intentarlo; si el problema persiste, contacta al soporte.",
|
||||
"StateStoreReadError": "Esta operación no se pudo reanudar porque el estado de su sesión no estaba disponible. Por favor, vuelva a abrir la conversación para continuar; si el problema persiste, contacte con el soporte.",
|
||||
"StreamChunkError": "Error al analizar el fragmento de mensaje de la solicitud de transmisión. Por favor, verifica si la interfaz API actual cumple con las especificaciones estándar, o contacta a tu proveedor de API para obtener ayuda.",
|
||||
"UpstreamGatewayError": "El gateway o proxy del proveedor devolvió un error. Por favor, vuelve a intentarlo en breve; si el problema persiste, verifica tu configuración de proxy/punto final.",
|
||||
"UpstreamHttpError": "El proveedor devolvió un error HTTP sin más detalles. Por favor, vuelve a intentarlo, o verifica tu solicitud y configuración del modelo.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Trabajando",
|
||||
"Redactando",
|
||||
"Pensando",
|
||||
"Calculando",
|
||||
"Preparando",
|
||||
"Sintetizando",
|
||||
"Procesando",
|
||||
"Arquitectando",
|
||||
"Componiendo",
|
||||
"Orquestando",
|
||||
"Esbozando",
|
||||
"Improvisando",
|
||||
"Reflexionando",
|
||||
"Elaborando",
|
||||
"Flambeando",
|
||||
"Cocinando a fuego lento",
|
||||
"Zumbando",
|
||||
"Domando",
|
||||
"Puliendo",
|
||||
"Preparando la respuesta",
|
||||
"Horneando",
|
||||
"Canalizando",
|
||||
"Fusionando",
|
||||
"Descifrando",
|
||||
"Forjando",
|
||||
"Armonizando",
|
||||
"Improvisando",
|
||||
"Deduciendo",
|
||||
"Trasteando",
|
||||
"Zigzagueando"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "Servicio Klavis no habilitado",
|
||||
"tools.klavis.oauthRequired": "Por favor, completa la autenticación OAuth en la nueva ventana",
|
||||
"tools.klavis.pendingAuth": "Autenticación Pendiente",
|
||||
"tools.klavis.remove": "Eliminar",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} se eliminará permanentemente de tus servicios conectados. Esta acción no se puede deshacer.",
|
||||
"tools.klavis.removeConfirm.title": "¿Eliminar {{name}}?",
|
||||
"tools.klavis.serverCreated": "Servidor creado con éxito",
|
||||
"tools.klavis.serverCreatedFailed": "Error al crear el servidor",
|
||||
"tools.klavis.serverRemoved": "Servidor eliminado",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "Tarjeta",
|
||||
"management.view.list": "Lista",
|
||||
"newTopic": "Nuevo tema",
|
||||
"projectStatus.failed_one": "{{count}} tema fallido",
|
||||
"projectStatus.failed_other": "{{count}} temas fallidos",
|
||||
"projectStatus.loading_one": "{{count}} tema cargando",
|
||||
"projectStatus.loading_other": "{{count}} temas cargando",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} tema esperando entrada",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} temas esperando entrada",
|
||||
"renameModal.description": "Mantenlo breve y fácil de reconocer.",
|
||||
"renameModal.title": "Renombrar tema",
|
||||
"searchPlaceholder": "Buscar temas...",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "هیچ عضوی مطابق یافت نشد",
|
||||
"noMembersYet": "این گروه هنوز عضوی ندارد. برای دعوت نمایندهها روی دکمه + کلیک کنید.",
|
||||
"noSelectedAgents": "هنوز عضوی انتخاب نشده است",
|
||||
"opStatusTray.cost": "هزینه",
|
||||
"opStatusTray.status.compressing": "فشردهسازی محتوا",
|
||||
"opStatusTray.status.generating": "تولید",
|
||||
"opStatusTray.status.reasoning": "تفکر",
|
||||
"opStatusTray.status.searching": "جستجو",
|
||||
"opStatusTray.status.toolCalling": "فراخوانی ابزارها",
|
||||
"opStatusTray.steps": "مراحل",
|
||||
"opStatusTray.tokens": "توکنها",
|
||||
"openInNewWindow": "باز کردن در پنجره جدید",
|
||||
"operation.contextCompression": "متن بیش از حد طولانی است، در حال فشردهسازی تاریخچه...",
|
||||
"operation.execAgentRuntime": "در حال آمادهسازی پاسخ",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "ایجاد شاخه جدید…",
|
||||
"workingDirectory.createBranchTitle": "ایجاد شاخه جدید",
|
||||
"workingDirectory.current": "دایرکتوری کاری فعلی",
|
||||
"workingDirectory.deleteBranchAction": "حذف شاخه",
|
||||
"workingDirectory.deleteBranchConfirm": "آیا میخواهید شاخه «{{name}}» را حذف کنید؟ این عمل به طور دائمی آن را حذف میکند، شامل هرگونه کامیت ادغامنشده.",
|
||||
"workingDirectory.deleteBranchTitle": "حذف شاخه",
|
||||
"workingDirectory.deleteFailed": "حذف ناموفق بود",
|
||||
"workingDirectory.detachedHead": "HEAD جدا شده در {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "افزوده شده {{added}} · تغییر یافته {{modified}} · حذف شده {{deleted}}",
|
||||
"workingDirectory.filesAdded": "افزوده شده",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "اخیر",
|
||||
"workingDirectory.refreshGitStatus": "وضعیت شاخه و درخواستها را تازهسازی کنید",
|
||||
"workingDirectory.removeRecent": "حذف از موارد اخیر",
|
||||
"workingDirectory.renameBranchAction": "تغییر نام شاخه",
|
||||
"workingDirectory.renameBranchTitle": "تغییر نام شاخه",
|
||||
"workingDirectory.renameFailed": "تغییر نام ناموفق بود",
|
||||
"workingDirectory.selectFolder": "انتخاب پوشه",
|
||||
"workingDirectory.title": "دایرکتوری کاری",
|
||||
"workingDirectory.topicDescription": "جایگزینی پیشفرض عامل فقط برای این مکالمه",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "صفحه با موفقیت حذف شد",
|
||||
"pageEditor.duplicateError": "تکثیر صفحه ناموفق بود",
|
||||
"pageEditor.duplicateSuccess": "صفحه با موفقیت تکثیر شد",
|
||||
"pageEditor.editMode.checking": "در حال بررسی امکان ویرایش...",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} در حال ویرایش این سند است",
|
||||
"pageEditor.editMode.lockedBySomeone": "شخص دیگری در حال ویرایش این سند است",
|
||||
"pageEditor.editedAt": "آخرین ویرایش در {{time}}",
|
||||
"pageEditor.editedBy": "آخرین ویرایش توسط {{name}}",
|
||||
"pageEditor.editorPlaceholder": "برای هوش مصنوعی و دستورات \"/\" را فشار دهید",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} نسخه",
|
||||
"pageEditor.history.versionCount_other": "{{count}} نسخه",
|
||||
"pageEditor.linkCopied": "لینک کپی شد",
|
||||
"pageEditor.lock.editingByOther": "{{name}} در حال ویرایش این صفحه است. تغییرات شما در حال حاضر ذخیره نمیشود.",
|
||||
"pageEditor.lock.editingBySomeone": "شخص دیگری در حال ویرایش این صفحه است. تغییرات شما در حال حاضر ذخیره نمیشود.",
|
||||
"pageEditor.menu.copyLink": "کپی لینک",
|
||||
"pageEditor.menu.export": "صادرات",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "مرتبسازی مدلهای سفارشی",
|
||||
"providerModels.list.enabledEmpty": "هیچ مدل فعالی در دسترس نیست. لطفاً مدلهای دلخواه خود را از لیست زیر فعال کنید~",
|
||||
"providerModels.list.fetcher.clear": "پاکسازی مدلهای دریافتشده",
|
||||
"providerModels.list.fetcher.error": "دریافت مدلها با شکست مواجه شد: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "خطای ناشناخته",
|
||||
"providerModels.list.fetcher.fetch": "دریافت مدلها",
|
||||
"providerModels.list.fetcher.fetching": "در حال دریافت لیست مدلها...",
|
||||
"providerModels.list.fetcher.latestTime": "آخرین بهروزرسانی: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "متأسفیم، استفاده از توکن یا تعداد درخواستها به حد سهمیه این کلید رسیده است. لطفاً سهمیه کلید را افزایش داده یا بعداً دوباره تلاش کنید.",
|
||||
"RateLimitExceeded": "متأسفیم، استفاده از توکن یا تعداد درخواستها به حد نرخ این کلید رسیده است. لطفاً بعداً دوباره تلاش کنید یا سهمیه کلید را افزایش دهید.",
|
||||
"StateStorePersistError": "یک مشکل موقت در ذخیرهسازی وضعیت مکالمه این عملیات را مختل کرد. لطفاً دوباره تلاش کنید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.",
|
||||
"StateStoreReadError": "این عملیات نمیتواند ادامه یابد زیرا وضعیت جلسه در دسترس نیست. لطفاً مکالمه را دوباره باز کنید تا ادامه دهید؛ اگر مشکل ادامه داشت، با پشتیبانی تماس بگیرید.",
|
||||
"StreamChunkError": "خطا در تجزیه بخش پیام درخواست جریان. لطفاً بررسی کنید که آیا رابط API فعلی با مشخصات استاندارد مطابقت دارد یا با ارائهدهنده API خود تماس بگیرید.",
|
||||
"UpstreamGatewayError": "دروازه یا پراکسی بالادستی خطایی بازگرداند. لطفاً به زودی دوباره تلاش کنید؛ اگر مشکل ادامه داشت، پیکربندی پراکسی/نقطه پایانی خود را بررسی کنید.",
|
||||
"UpstreamHttpError": "ارائهدهنده یک خطای HTTP بدون جزئیات بیشتر بازگرداند. لطفاً دوباره تلاش کنید یا درخواست و پیکربندی مدل خود را بررسی کنید.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"در حال کار",
|
||||
"در حال پیشنویس",
|
||||
"در حال تفکر",
|
||||
"در حال محاسبه",
|
||||
"در حال دم کردن",
|
||||
"در حال ترکیب",
|
||||
"در حال پردازش",
|
||||
"در حال معماری",
|
||||
"در حال ترکیببندی",
|
||||
"در حال هماهنگی",
|
||||
"در حال طراحی",
|
||||
"در حال آزمودن",
|
||||
"در حال اندیشیدن",
|
||||
"در حال ساختن",
|
||||
"در حال شعلهور کردن",
|
||||
"در حال جوشاندن",
|
||||
"در حال چرخیدن",
|
||||
"در حال مدیریت",
|
||||
"در حال پرداخت",
|
||||
"در حال آمادهسازی پاسخ",
|
||||
"در حال پختن",
|
||||
"در حال هدایت",
|
||||
"در حال همگرایی",
|
||||
"در حال رمزگشایی",
|
||||
"در حال شکلدهی",
|
||||
"در حال هماهنگسازی",
|
||||
"در حال بداههپردازی",
|
||||
"در حال استنتاج",
|
||||
"در حال دستکاری",
|
||||
"در حال زیگزاگ رفتن"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "سرویس Klavis فعال نیست",
|
||||
"tools.klavis.oauthRequired": "لطفاً احراز هویت OAuth را در پنجره جدید کامل کنید",
|
||||
"tools.klavis.pendingAuth": "در انتظار احراز هویت",
|
||||
"tools.klavis.remove": "حذف",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} به طور دائمی از خدمات متصل شما حذف خواهد شد. این اقدام قابل بازگشت نیست.",
|
||||
"tools.klavis.removeConfirm.title": "حذف {{name}}؟",
|
||||
"tools.klavis.serverCreated": "سرور با موفقیت ایجاد شد",
|
||||
"tools.klavis.serverCreatedFailed": "ایجاد سرور ناموفق بود",
|
||||
"tools.klavis.serverRemoved": "سرور حذف شد",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "کارت",
|
||||
"management.view.list": "فهرست",
|
||||
"newTopic": "موضوع جدید",
|
||||
"projectStatus.failed_one": "{{count}} موضوع ناموفق",
|
||||
"projectStatus.failed_other": "{{count}} موضوعات ناموفق",
|
||||
"projectStatus.loading_one": "{{count}} موضوع در حال بارگذاری",
|
||||
"projectStatus.loading_other": "{{count}} موضوعات در حال بارگذاری",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} موضوع منتظر ورودی",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} موضوعات منتظر ورودی",
|
||||
"renameModal.description": "کوتاه و قابل تشخیص نگه دارید.",
|
||||
"renameModal.title": "تغییر نام موضوع",
|
||||
"searchPlaceholder": "جستجوی گفتوگوها...",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "Aucun membre correspondant trouvé",
|
||||
"noMembersYet": "Ce groupe n'a pas encore de membres. Cliquez sur le bouton + pour inviter des agents.",
|
||||
"noSelectedAgents": "Aucun membre sélectionné pour le moment",
|
||||
"opStatusTray.cost": "coût",
|
||||
"opStatusTray.status.compressing": "Compression du contexte",
|
||||
"opStatusTray.status.generating": "Génération",
|
||||
"opStatusTray.status.reasoning": "Réflexion",
|
||||
"opStatusTray.status.searching": "Recherche",
|
||||
"opStatusTray.status.toolCalling": "Appel des outils",
|
||||
"opStatusTray.steps": "étapes",
|
||||
"opStatusTray.tokens": "jetons",
|
||||
"openInNewWindow": "Ouvrir dans une nouvelle fenêtre",
|
||||
"operation.contextCompression": "Contexte trop long, compression de l'historique...",
|
||||
"operation.execAgentRuntime": "Préparation de la réponse",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Basculer vers une nouvelle branche…",
|
||||
"workingDirectory.createBranchTitle": "Créer une nouvelle branche",
|
||||
"workingDirectory.current": "Répertoire de travail actuel",
|
||||
"workingDirectory.deleteBranchAction": "Supprimer la branche",
|
||||
"workingDirectory.deleteBranchConfirm": "Supprimer la branche « {{name}} » ? Cela la supprime définitivement, y compris tous les commits non fusionnés.",
|
||||
"workingDirectory.deleteBranchTitle": "Supprimer la branche",
|
||||
"workingDirectory.deleteFailed": "Échec de la suppression",
|
||||
"workingDirectory.detachedHead": "HEAD détaché à {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Ajouté {{added}} · Modifié {{modified}} · Supprimé {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Ajouté",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Récents",
|
||||
"workingDirectory.refreshGitStatus": "Actualiser l'état des branches et des PR",
|
||||
"workingDirectory.removeRecent": "Supprimer des récents",
|
||||
"workingDirectory.renameBranchAction": "Renommer la branche",
|
||||
"workingDirectory.renameBranchTitle": "Renommer la branche",
|
||||
"workingDirectory.renameFailed": "Échec du renommage",
|
||||
"workingDirectory.selectFolder": "Sélectionner un dossier",
|
||||
"workingDirectory.title": "Répertoire de travail",
|
||||
"workingDirectory.topicDescription": "Remplacer le répertoire par défaut de l'Agent uniquement pour cette conversation",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "Page supprimée avec succès",
|
||||
"pageEditor.duplicateError": "Échec de la duplication de la page",
|
||||
"pageEditor.duplicateSuccess": "Page dupliquée avec succès",
|
||||
"pageEditor.editMode.checking": "Vérification de la disponibilité de l'édition…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} est en train de modifier ce document",
|
||||
"pageEditor.editMode.lockedBySomeone": "Quelqu'un d'autre est en train de modifier ce document",
|
||||
"pageEditor.editedAt": "Dernière modification le {{time}}",
|
||||
"pageEditor.editedBy": "Dernière modification par {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Appuyez sur \"/\" pour l'IA et les commandes",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} version",
|
||||
"pageEditor.history.versionCount_other": "{{count}} versions",
|
||||
"pageEditor.linkCopied": "Lien copié",
|
||||
"pageEditor.lock.editingByOther": "{{name}} est en train de modifier cette page. Vos modifications ne peuvent pas être enregistrées pour le moment.",
|
||||
"pageEditor.lock.editingBySomeone": "Quelqu'un d'autre est en train de modifier cette page. Vos modifications ne peuvent pas être enregistrées pour le moment.",
|
||||
"pageEditor.menu.copyLink": "Copier le lien",
|
||||
"pageEditor.menu.export": "Exporter",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "Tri personnalisé des modèles",
|
||||
"providerModels.list.enabledEmpty": "Aucun modèle activé disponible. Veuillez activer vos modèles préférés ci-dessous~",
|
||||
"providerModels.list.fetcher.clear": "Effacer les modèles récupérés",
|
||||
"providerModels.list.fetcher.error": "Échec de la récupération des modèles : {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Erreur inconnue",
|
||||
"providerModels.list.fetcher.fetch": "Récupérer les modèles",
|
||||
"providerModels.list.fetcher.fetching": "Récupération de la liste des modèles...",
|
||||
"providerModels.list.fetcher.latestTime": "Dernière mise à jour : {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "Désolé, l'utilisation des jetons ou le nombre de requêtes a atteint la limite de quota pour cette clé. Veuillez augmenter le quota de la clé ou réessayer plus tard.",
|
||||
"RateLimitExceeded": "Désolé, l'utilisation des jetons ou le nombre de requêtes a atteint la limite de débit pour cette clé. Veuillez réessayer plus tard ou augmenter le quota de la clé.",
|
||||
"StateStorePersistError": "Un problème temporaire avec le stockage de l'état de la conversation a interrompu cette opération. Veuillez réessayer ; si le problème persiste, contactez le support.",
|
||||
"StateStoreReadError": "Cette opération n'a pas pu être reprise car l'état de la session était indisponible. Veuillez rouvrir la conversation pour continuer ; si le problème persiste, contactez le support.",
|
||||
"StreamChunkError": "Erreur lors de l'analyse du fragment de message de la requête en streaming. Veuillez vérifier si l'interface API actuelle est conforme aux spécifications standard ou contactez votre fournisseur d'API pour obtenir de l'aide.",
|
||||
"UpstreamGatewayError": "La passerelle ou le proxy en amont a renvoyé une erreur. Veuillez réessayer sous peu ; si le problème persiste, vérifiez la configuration de votre proxy / point de terminaison.",
|
||||
"UpstreamHttpError": "Le fournisseur a renvoyé une erreur HTTP sans plus de détails. Veuillez réessayer ou vérifier votre requête et la configuration du modèle.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"En cours",
|
||||
"Rédaction",
|
||||
"Réflexion",
|
||||
"Calcul",
|
||||
"Infusion",
|
||||
"Synthèse",
|
||||
"Analyse",
|
||||
"Architecture",
|
||||
"Composition",
|
||||
"Orchestration",
|
||||
"Esquisse",
|
||||
"Gribouillage",
|
||||
"Méditation",
|
||||
"Artisanat",
|
||||
"Flambage",
|
||||
"Mijotage",
|
||||
"Vrombissement",
|
||||
"Domptage",
|
||||
"Polissage",
|
||||
"Préparation de la réponse",
|
||||
"Cuisson",
|
||||
"Canalisation",
|
||||
"Fusion",
|
||||
"Déchiffrage",
|
||||
"Forge",
|
||||
"Harmonisation",
|
||||
"Improvisation",
|
||||
"Inférence",
|
||||
"Bricolage",
|
||||
"Zigzag"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "Service Klavis non activé",
|
||||
"tools.klavis.oauthRequired": "Veuillez compléter l’authentification OAuth dans la nouvelle fenêtre",
|
||||
"tools.klavis.pendingAuth": "Authentification en attente",
|
||||
"tools.klavis.remove": "Supprimer",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} sera définitivement supprimé de vos services connectés. Cette action est irréversible.",
|
||||
"tools.klavis.removeConfirm.title": "Supprimer {{name}} ?",
|
||||
"tools.klavis.serverCreated": "Serveur créé avec succès",
|
||||
"tools.klavis.serverCreatedFailed": "Échec de la création du serveur",
|
||||
"tools.klavis.serverRemoved": "Serveur supprimé",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "Carte",
|
||||
"management.view.list": "Liste",
|
||||
"newTopic": "Nouveau sujet",
|
||||
"projectStatus.failed_one": "{{count}} sujet échoué",
|
||||
"projectStatus.failed_other": "{{count}} sujets échoués",
|
||||
"projectStatus.loading_one": "{{count}} sujet en cours de chargement",
|
||||
"projectStatus.loading_other": "{{count}} sujets en cours de chargement",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} sujet en attente d'entrée",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} sujets en attente d'entrée",
|
||||
"renameModal.description": "Gardez-le court et facile à reconnaître.",
|
||||
"renameModal.title": "Renommer le sujet",
|
||||
"searchPlaceholder": "Rechercher des sujets...",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "Nessun membro corrispondente trovato",
|
||||
"noMembersYet": "Questo gruppo non ha ancora membri. Clicca sul pulsante + per invitare agenti.",
|
||||
"noSelectedAgents": "Nessun membro selezionato",
|
||||
"opStatusTray.cost": "costo",
|
||||
"opStatusTray.status.compressing": "Compressione del contesto",
|
||||
"opStatusTray.status.generating": "Generazione",
|
||||
"opStatusTray.status.reasoning": "Riflessione",
|
||||
"opStatusTray.status.searching": "Ricerca",
|
||||
"opStatusTray.status.toolCalling": "Chiamata degli strumenti",
|
||||
"opStatusTray.steps": "passaggi",
|
||||
"opStatusTray.tokens": "token",
|
||||
"openInNewWindow": "Apri in una nuova finestra",
|
||||
"operation.contextCompression": "Contesto troppo lungo, compressione della cronologia in corso...",
|
||||
"operation.execAgentRuntime": "Preparazione della risposta",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Passa a un nuovo ramo…",
|
||||
"workingDirectory.createBranchTitle": "Crea nuovo ramo",
|
||||
"workingDirectory.current": "Directory di lavoro corrente",
|
||||
"workingDirectory.deleteBranchAction": "Elimina ramo",
|
||||
"workingDirectory.deleteBranchConfirm": "Eliminare il ramo “{{name}}”? Questo lo rimuove permanentemente, inclusi eventuali commit non uniti.",
|
||||
"workingDirectory.deleteBranchTitle": "Elimina ramo",
|
||||
"workingDirectory.deleteFailed": "Eliminazione fallita",
|
||||
"workingDirectory.detachedHead": "HEAD scollegato a {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Aggiunti {{added}} · Modificati {{modified}} · Eliminati {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Aggiunti",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Recenti",
|
||||
"workingDirectory.refreshGitStatus": "Aggiorna stato ramo e PR",
|
||||
"workingDirectory.removeRecent": "Rimuovi dai recenti",
|
||||
"workingDirectory.renameBranchAction": "Rinomina ramo",
|
||||
"workingDirectory.renameBranchTitle": "Rinomina ramo",
|
||||
"workingDirectory.renameFailed": "Rinomina fallita",
|
||||
"workingDirectory.selectFolder": "Seleziona cartella",
|
||||
"workingDirectory.title": "Directory di lavoro",
|
||||
"workingDirectory.topicDescription": "Sostituisci il valore predefinito dell'Agente solo per questa conversazione",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "Pagina eliminata con successo",
|
||||
"pageEditor.duplicateError": "Duplicazione della pagina non riuscita",
|
||||
"pageEditor.duplicateSuccess": "Pagina duplicata con successo",
|
||||
"pageEditor.editMode.checking": "Verifica della disponibilità di modifica…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}} sta modificando questo documento",
|
||||
"pageEditor.editMode.lockedBySomeone": "Qualcun altro sta modificando questo documento",
|
||||
"pageEditor.editedAt": "Ultima modifica il {{time}}",
|
||||
"pageEditor.editedBy": "Ultima modifica di {{name}}",
|
||||
"pageEditor.editorPlaceholder": "Premi \"/\" per AI e comandi",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} versione",
|
||||
"pageEditor.history.versionCount_other": "{{count}} versioni",
|
||||
"pageEditor.linkCopied": "Link copiato",
|
||||
"pageEditor.lock.editingByOther": "{{name}} sta modificando questa pagina. Le tue modifiche non possono essere salvate in questo momento.",
|
||||
"pageEditor.lock.editingBySomeone": "Qualcun altro sta modificando questa pagina. Le tue modifiche non possono essere salvate in questo momento.",
|
||||
"pageEditor.menu.copyLink": "Copia link",
|
||||
"pageEditor.menu.export": "Esporta",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
@@ -307,6 +307,8 @@
|
||||
"providerModels.list.enabledActions.sort": "Ordinamento modelli personalizzati",
|
||||
"providerModels.list.enabledEmpty": "Nessun modello abilitato disponibile. Abilita i tuoi modelli preferiti dall'elenco qui sotto~",
|
||||
"providerModels.list.fetcher.clear": "Cancella modelli recuperati",
|
||||
"providerModels.list.fetcher.error": "Errore durante il recupero dei modelli: {{message}}",
|
||||
"providerModels.list.fetcher.errorFallback": "Errore sconosciuto",
|
||||
"providerModels.list.fetcher.fetch": "Recupera modelli",
|
||||
"providerModels.list.fetcher.fetching": "Recupero elenco modelli in corso...",
|
||||
"providerModels.list.fetcher.latestTime": "Ultimo aggiornamento: {{time}}",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"QuotaLimitReached": "Spiacenti, l'utilizzo dei token o il numero di richieste ha raggiunto il limite di quota per questa chiave. Aumenta la quota della chiave o riprova più tardi.",
|
||||
"RateLimitExceeded": "Spiacenti, l'utilizzo dei token o il numero di richieste ha raggiunto il limite di velocità per questa chiave. Riprova più tardi o aumenta la quota della chiave.",
|
||||
"StateStorePersistError": "Un problema temporaneo con l'archiviazione dello stato della conversazione ha interrotto questa operazione. Riprova; se il problema persiste, contatta l'assistenza.",
|
||||
"StateStoreReadError": "Questa operazione non può essere ripresa perché lo stato della sessione non è disponibile. Riapri la conversazione per continuare; se il problema persiste, contatta il supporto.",
|
||||
"StreamChunkError": "Errore durante l'analisi del frammento di messaggio della richiesta in streaming. Controlla se l'interfaccia API corrente è conforme alle specifiche standard o contatta il tuo provider API per ricevere assistenza.",
|
||||
"UpstreamGatewayError": "Il gateway o proxy a monte ha restituito un errore. Riprova tra poco; se il problema persiste, controlla la configurazione del proxy / endpoint.",
|
||||
"UpstreamHttpError": "Il provider ha restituito un errore HTTP senza ulteriori dettagli. Riprova o controlla la tua richiesta e la configurazione del modello.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"generatingPhrases": [
|
||||
"Lavorando",
|
||||
"Redigendo",
|
||||
"Pensando",
|
||||
"Calcolando",
|
||||
"Preparando",
|
||||
"Sintetizzando",
|
||||
"Elaborando",
|
||||
"Progettando",
|
||||
"Componendo",
|
||||
"Orchestrando",
|
||||
"Schizzando",
|
||||
"Sperimentando",
|
||||
"Riflettendo",
|
||||
"Creando",
|
||||
"Fiammando",
|
||||
"Sobollendo",
|
||||
"Frullando",
|
||||
"Gestendo",
|
||||
"Lucidando",
|
||||
"Preparando la risposta",
|
||||
"Cucinando",
|
||||
"Canalizzando",
|
||||
"Coagulando",
|
||||
"Decifrando",
|
||||
"Forjando",
|
||||
"Armonizzando",
|
||||
"Improvvisando",
|
||||
"Deducendo",
|
||||
"Trafficando",
|
||||
"Zigzagando"
|
||||
]
|
||||
}
|
||||
@@ -1186,6 +1186,9 @@
|
||||
"tools.klavis.notEnabled": "Servizio Klavis non abilitato",
|
||||
"tools.klavis.oauthRequired": "Completa l'autenticazione OAuth nella nuova finestra",
|
||||
"tools.klavis.pendingAuth": "Autenticazione in Attesa",
|
||||
"tools.klavis.remove": "Rimuovi",
|
||||
"tools.klavis.removeConfirm.desc": "{{name}} sarà rimosso definitivamente dai tuoi servizi collegati. Questa azione non può essere annullata.",
|
||||
"tools.klavis.removeConfirm.title": "Rimuovere {{name}}?",
|
||||
"tools.klavis.serverCreated": "Server creato con successo",
|
||||
"tools.klavis.serverCreatedFailed": "Creazione server fallita",
|
||||
"tools.klavis.serverRemoved": "Server rimosso",
|
||||
|
||||
@@ -135,6 +135,12 @@
|
||||
"management.view.card": "Scheda",
|
||||
"management.view.list": "Elenco",
|
||||
"newTopic": "Nuovo argomento",
|
||||
"projectStatus.failed_one": "{{count}} argomento fallito",
|
||||
"projectStatus.failed_other": "{{count}} argomenti falliti",
|
||||
"projectStatus.loading_one": "{{count}} argomento in caricamento",
|
||||
"projectStatus.loading_other": "{{count}} argomenti in caricamento",
|
||||
"projectStatus.waitingForHuman_one": "{{count}} argomento in attesa di input",
|
||||
"projectStatus.waitingForHuman_other": "{{count}} argomenti in attesa di input",
|
||||
"renameModal.description": "Mantienilo breve e facile da riconoscere.",
|
||||
"renameModal.title": "Rinomina argomento",
|
||||
"searchPlaceholder": "Cerca Argomenti...",
|
||||
|
||||
@@ -370,6 +370,14 @@
|
||||
"noMatchingAgents": "一致するメンバーが見つかりませんでした",
|
||||
"noMembersYet": "このグループにはまだメンバーがいません。「+」をクリックしてアシスタントを招待してください",
|
||||
"noSelectedAgents": "メンバーが選択されていません",
|
||||
"opStatusTray.cost": "コスト",
|
||||
"opStatusTray.status.compressing": "コンテキストを圧縮中",
|
||||
"opStatusTray.status.generating": "生成中",
|
||||
"opStatusTray.status.reasoning": "考え中",
|
||||
"opStatusTray.status.searching": "検索中",
|
||||
"opStatusTray.status.toolCalling": "ツールを呼び出し中",
|
||||
"opStatusTray.steps": "ステップ",
|
||||
"opStatusTray.tokens": "トークン",
|
||||
"openInNewWindow": "新しいウィンドウで開く",
|
||||
"operation.contextCompression": "コンテキストが長すぎるため、履歴を圧縮しています...",
|
||||
"operation.execAgentRuntime": "応答を準備中",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "新しいブランチをチェックアウト…",
|
||||
"workingDirectory.createBranchTitle": "新しいブランチを作成",
|
||||
"workingDirectory.current": "現在の作業ディレクトリ",
|
||||
"workingDirectory.deleteBranchAction": "ブランチを削除",
|
||||
"workingDirectory.deleteBranchConfirm": "ブランチ「{{name}}」を削除しますか?これにより、未マージのコミットを含めて永久に削除されます。",
|
||||
"workingDirectory.deleteBranchTitle": "ブランチを削除",
|
||||
"workingDirectory.deleteFailed": "削除に失敗しました",
|
||||
"workingDirectory.detachedHead": "デタッチされたHEAD: {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "追加: {{added}} · 修正: {{modified}} · 削除: {{deleted}}",
|
||||
"workingDirectory.filesAdded": "追加済み",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "最近使用したもの",
|
||||
"workingDirectory.refreshGitStatus": "ブランチとPRのステータスを更新",
|
||||
"workingDirectory.removeRecent": "最近使用したものから削除",
|
||||
"workingDirectory.renameBranchAction": "ブランチ名を変更",
|
||||
"workingDirectory.renameBranchTitle": "ブランチ名を変更",
|
||||
"workingDirectory.renameFailed": "名前の変更に失敗しました",
|
||||
"workingDirectory.selectFolder": "フォルダーを選択",
|
||||
"workingDirectory.title": "作業ディレクトリ",
|
||||
"workingDirectory.topicDescription": "この会話のみでエージェントのデフォルトを上書き",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"pageEditor.deleteSuccess": "ドキュメントを削除しました",
|
||||
"pageEditor.duplicateError": "ページの複製に失敗しました",
|
||||
"pageEditor.duplicateSuccess": "ページを正常に複製しました",
|
||||
"pageEditor.editMode.checking": "編集可能か確認しています…",
|
||||
"pageEditor.editMode.lockedByOther": "{{name}}がこのドキュメントを編集しています",
|
||||
"pageEditor.editMode.lockedBySomeone": "他の誰かがこのドキュメントを編集しています",
|
||||
"pageEditor.editedAt": "最終編集:{{time}}",
|
||||
"pageEditor.editedBy": "最終編集者:{{name}}",
|
||||
"pageEditor.editorPlaceholder": "「/」で AI とコマンドを呼び出し",
|
||||
@@ -131,6 +134,8 @@
|
||||
"pageEditor.history.versionCount_one": "{{count}} 件のバージョン",
|
||||
"pageEditor.history.versionCount_other": "{{count}} 件のバージョン",
|
||||
"pageEditor.linkCopied": "リンクをコピーしました",
|
||||
"pageEditor.lock.editingByOther": "{{name}}がこのページを編集しています。現在、変更を保存することはできません。",
|
||||
"pageEditor.lock.editingBySomeone": "他の誰かがこのページを編集しています。現在、変更を保存することはできません。",
|
||||
"pageEditor.menu.copyLink": "リンクをコピー",
|
||||
"pageEditor.menu.export": "エクスポート",
|
||||
"pageEditor.menu.export.markdown": "Markdown",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user