Compare commits

...

32 Commits

Author SHA1 Message Date
Arvin Xu 17fd96ca5a 🐛 fix(desktop): re-throw non-transient unhandled rejections
Installing an unhandledRejection listener overrides Node's default
--unhandled-rejections=throw, so the prior log-and-return swallowed
genuine main-process rejections (e.g. an unawaited app.bootstrap()),
leaving the app partially booted instead of crashing. Re-throw the
non-transient branch to restore the fatal behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:44:59 +08:00
Arvin Xu 58a44e5ed3 🐛 fix(desktop): swallow transient net errors in main process
Electron's net stack (SimpleURLLoaderWrapper, used internally by
electron-updater's net.request) emits transient connectivity errors
(ERR_NETWORK_CHANGED, ERR_NETWORK_IO_SUSPENDED) on Wi-Fi/VPN switch and
system sleep. With no global guard they bubble up as an uncaughtException
and pop the "A JavaScript error occurred in the main process" dialog.

Add a process-level handler that swallows known transient net-stack
errors and re-throws everything else, so genuine crashes stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:41:59 +08:00
Rdmclin2 e5adb393b3 feat: workspace device support (#16134)
* feat: workspace device support

Squashed commits:
- chore: update device constraint
- chore: local system support remote device workspace
- fix: reconnect time
- fix: workspace connect daemon parameter
- fix: device list refresh
- chore: device admin
- feat: support workspace device
- feat: device list support workspace
- feat: add workspaceId to device gateway

* chore: update i18n rc

* 🐛 fix(workspace): narrow wsOwnerProcedure ctx.workspaceId to string

The OSS stub left `wsOwnerProcedure = authedProcedure`, so consumers
saw `ctx.workspaceId: string | null | undefined` and broke type-check
in `lambda/device.ts` (signWorkspaceDeviceToken / DeviceModel).
Add a guard middleware that throws + narrows the type, matching the
cloud override semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update i18n files

* fix: remote device test case

* fix: device workspaceId request

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-21 00:41:06 +08:00
Arvin Xu 434c16c49d 🔖 chore(cli): bump @lobehub/cli to 0.0.34 (#16139) 2026-06-21 00:35:51 +08:00
Arvin Xu 52d2306793 feat(verify): ingest path for standalone verify run (#16132)
*  feat(verify): ingest path for standalone sessions (createRun / ingestResult / uploadEvidence / upsertReport + CLI)

Add the server + CLI surface to persist a verification session that isn't a live
Agent Run — e.g. the agent-testing harness pushing its local report into the
verify tables so it can be reviewed in-app.

- model: VerifyCheckResultModel.upsertByCheckItem (insert-or-update on the stable
  (verifyRunId, checkItemId) key, for direct verdict ingest); VerifyRunModel.query
  (recent sessions).
- router (verify): createRun / getRun / listRuns; ingestResult (verdict +
  toulmin/suggestion, derives status); uploadEvidence + listEvidence (anchored on
  checkResultId, accepts an already-uploaded fileId or inline content);
  upsertReport / getReport. All keyed by verifyRunId, no operation required.
- CLI: `lh verify ingest-report <dir>` reads result.json + report.md + assets,
  creates a session, ingests each case as a check result, uploads its evidence
  files, and writes the report (`--open` prints the in-app URL). Plus
  `lh verify upload-evidence` (LOBE-10618) for one-off artifact attach.
- tests: upsertByCheckItem insert-then-overwrite.

Stacked on the verify_runs schema branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(verify): standalone report viewer page (/verify-report?id=<verifyRunId>)

Make an ingested verification session viewable in-app without an Agent Run / chat
context — the missing half of the agent-testing ingest flow.

- server: verify.getReportBundle({verifyRunId}) returns { run, report, results }
  in one call, with each result's evidence and a resolved (signed) file URL per
  file-backed artifact (via FileService.getFullFileUrl).
- client: verifyService.getReportBundle + useVerifyReportBundle SWR hook + key.
- ui: Verify/ReportViewer renders the verdict/stats header, each check result with
  its inline evidence (image / text), and the full markdown report.
- route: /verify-report registered in desktop + desktop.desktop + mobile router
  configs (sync test green).
- cli: `lh verify ingest-report --open` now prints /verify-report?id=… (the prior
  /verify-im hint was wrong — that route is the messenger account-linking page).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(verify): use path param /verify/:runId for report viewer

Switch the standalone verification-report route from the query-based
/verify-report?id=<id> to a path param /verify/:runId, so the URL is
cleaner and shareable. ReportViewer now reads runId via useParams, the
CLI --open output prints the new path, and the route file moves to
src/routes/verify/[runId]/index.tsx across all router configs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(verify): use path-param route /verify/:runId + allowlist it in the SPA matcher

Verified the ingest→viewer flow end-to-end against a local full-stack env and
fixed what that surfaced:

- route: /verify-report?id= → /verify/:runId (path param); ReportViewer reads
  useParams; registered in all three router configs.
- proxy: add `/verify/(.*)` to the SPA route matcher (src/proxy.ts) — without it
  the server 307-redirects unknown paths to `/`, so the viewer never loaded.
- cli: ingest-report no longer double-writes the case observation (was set on both
  `suggestion` and `toulmin.evidence`, rendering twice); observation → toulmin,
  `suggestion` reserved for an actual remediation hint. --open prints /verify/<id>.

E2E confirmed: lh verify ingest-report created a standalone session
(operation_id=NULL) + 3 results + report; /verify/:runId renders it in-app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(verify): atomize CLI into per-entity commands (run / result / evidence / report)

Per review: break the verify CLI into atomic, per-entity capabilities so each can
be called directly, with the report ingest kept as an aggregate convenience.

- cli: new command groups —
  - `verify run create | list | get`
  - `verify result ingest | list (--run | --operation)`
  - `verify evidence upload (--file | --content) | list`
  - `verify report upsert | get`
  Renamed the agent-path executor `verify run <op>` → `verify execute <op>` to free
  the `run` noun for the session entity; `verify ingest-report` stays as the
  aggregate (it just composes the atomic procedures).
- server: add verify.listResultsByRun (run-keyed result list) backing `result list --run`.
- model: VerifyRunModel guards operation_id reservation with assertOperationOwned
  so a run can't hijack another owner's (globally-unique) operation link; test added.

Verified end-to-end against a local full-stack env with real R2 storage: both the
aggregate `ingest-report` and a fully atomic create→ingest→upload(file)→report
chain produce a standalone session that renders at /verify/:runId, with
file-backed evidence served from R2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix: validate verify ingestion ownership

* 🗃️ chore: remove verify migration backfill

*  test: cover verify ingest run ownership

*  allow public verify report viewing

* 🐛 fix verify report evidence rendering

* 🐛 tolerate missing verify evidence file URLs

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:29:40 +08:00
Arvin Xu 330694fe49 🐛 fix(conversation): surface terminal errors on assistant turns that already streamed content (#16086)
* 🐛 fix(conversation): surface terminal errors on assistant turns that already streamed content

A turn that streamed content + a successful tool call before hitting a
terminal error (e.g. upstream 529 overload) had its `error` silently
dropped on the read side: both the AssistantGroup `ContentBlock` and the
normal `Assistant` message gated the error UI behind empty content, so
the message rendered as if it had completed cleanly — no error banner, no
retry.

Now the error is shown below the content when a turn errors after
producing output, while keeping the existing "error replaces the whole
block" behavior when nothing was streamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(conversation): keep streamed content when dismissing an error alert

The fallback ErrorContent alert is closable and its afterClose unconditionally
deleted the whole message. Now that errors render below already-streamed
content, dismissing the alert would wipe the content it was meant to preserve.

Dismiss now clears only the error (updateMessageError(id, null)) when the
message still has content, and keeps the existing delete behavior for
messages that are nothing but an error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:26:03 +08:00
Arvin Xu 0230cd0e7f feat(chat): add Manage button to execution device switcher (#16126)
* 💄 feat(linear): render builtin Linear tool cards and drop the Result label

Register the shared LinearRender for the `linear` builtin identifier so
native LobeHub Linear tool calls render the same entity card as the
heterogeneous CC/codex path (previously only inspectors were wired up).

Also remove the redundant "Result" label above the self-explanatory
entity cards / result text in the Linear render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(chat): add Manage button to execution device switcher header

Add a "Manage" button in the empty right slot of the execution-device
dropdown header that navigates to the device settings page
(/settings/devices). Shown on desktop and on web when no devices exist
(where the small download link isn't rendered).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:39:52 +08:00
YuTengjing e1ff69bfbe feat(runtime): map provider model IDs (#16128) 2026-06-20 23:16:44 +08:00
Arvin Xu 3432707dc9 🐛 fix(hetero): split post-tool answer into its own step when CC reuses the tool message.id (#16127)
* 🐛 fix(hetero): split post-tool answer into its own step when CC reuses the tool message.id

On device/batch (`lh hetero exec`) Claude Code runs, the model's post-tool answer
can arrive under the SAME `message.id` as the preceding `tool_use`. `openMainMessage`
short-circuited on the matching id and emitted no `newStep`, so the answer text
coalesced onto the tool-issuing assistant: text + `tool_use` ended up on one message
and the renderer dropped the tool block below the answer (leaving a trailing empty
assistant shell as the post-tool turn).

Track whether the in-flight turn already emitted a `tool_use`; a later text-only
event that reuses the same id now forces a step boundary so the answer anchors to
its own assistant, chained after the tool results. A normal preamble-text+tool_use
event stays on one step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(hetero): distinct replay-stable key + clear user-input flag on forced post-tool split

Addresses review of the same-id post-tool split:

- P1: reusing the tool turn's message.id as the newStep id made the main-agent
  reducer drop it as a REPLAY (id === currentMainMessageId) for any tool turn
  opened by a prior newStep — the split was dropped and the text coalesced
  anyway (the seed-turn case only escaped because the seed has no mainMessageId).
  Stamp a DISTINCT `${id}:s${stepIndex}` key — unique per split, deterministic
  across cold-replica reprocessing — so a fresh assistant is actually opened.

- P2: the forced branch never cleared `hasUnhandledUserInput` (armed by the
  preceding tool_result). In a Monitor flow whose post-tool confirmation reuses
  the tool id, the stale flag survived, so the next stdout callback opened while
  the task was active failed the `!hasUnhandledUserInput` signal check and went
  untagged. Clear it exactly like the normal turn boundary does.

Adds regressions for both (verified RED before the fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:03:17 +08:00
Arvin Xu 2cf7b0a824 🗃️ refactor(verify): decouple verify chain from agent_operations via verify_runs (#16129)
The verify chain hung off agent_operations: the plan lived on
`agent_operations.verify_plan` and results/reports keyed on `operation_id`. That
forced every verification — including standalone ones (e.g. the agent-testing
harness ingesting results) — to mint a fake Agent Run, polluting the operation
analytics with rows that carry no real execution trace.

Introduce `verify_runs` as the verification-session entity and make it the
grouping key, via a fully ADDITIVE migration:

- schema: new `verify_runs` (plan snapshot + rollup status + source) with an
  OPTIONAL `operation_id` link to an Agent Run (null for standalone sessions, set
  null on run delete). verify_check_results / verify_reports gain a nullable
  `verifyRunId` (FK → verify_runs, cascade) as the new grouping key; the existing
  `operation_id` is KEPT (relaxed to nullable + ON DELETE set null) as a
  denormalized direct link to the run — no column dropped, no data moved.
  agent_operations.verify_plan / verify_status / verify_plan_confirmed_at marked
  deprecated (kept physically; no ALTER on the analytics table).
- models: new VerifyRunModel owns the plan/status + the operation link
  (ensureForOperation / findByOperation / getStateByOperation keep the legacy
  {verifyPlan,verifyPlanConfirmedAt,verifyStatus} shape). Repointed
  VerifyCheckResultModel (listByRun / updateByCheckItem / backfillTracingId) and
  VerifyReportModel (upsertByRun / findByRun / markReviewed); dropped the dead
  verify methods from AgentOperationModel.
- services + router + runtime resolve operationId→run at the boundary; all
  client-facing contracts (router inputs, getVerifyState response shape) are
  unchanged, so the UI / CLI / client service need no changes. The executor also
  keeps populating the denormalized operation_id on agent-path result rows.
- migration 0113: additive + idempotent — create verify_runs, add nullable
  verify_run_id, relax operation_id (drop NOT NULL + FK → set null). No drop, no
  backfill, no NOT NULL enforcement.
- tests: repointed the three db model tests; deleting an Agent Run now KEEPS the
  report alive (only nulls the links) — asserted.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:42:00 +08:00
Arvin Xu d389e4fe9d ️ perf: dedupe message:list switch-back revalidate with a 30s window (#16131)
The Conversation store is recreated on every topic/session switch and remounts
`useFetchMessages`. At the `useClientDataSWR` default (`dedupingInterval: 0`)
that fires a network revalidate on every single switch — the "正在获取最新消息…"
flash on switch-back.

Now that message mutations write through to the `message:list` cache (#15927),
a switch-back within the dedupe window hydrates from a FRESH cache, so the
refetch is pure redundancy. Set a 30s window: it covers the typical
switch-away-and-back loop while keeping cross-device / server-agent updates
within an acceptable staleness bound (`revalidateOnFocus`'s 5min throttle and
`revalidateOnReconnect` remain the longer-tail backstop; a running conversation
keeps its live gateway stream regardless of this window).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:38:31 +08:00
Arvin Xu 02d1bb307c ️ perf: write message mutations through to the message:list SWR cache (#15927)
* ️ perf: write message mutations through to the message:list SWR cache

Message mutations only touched the in-memory store, so the message:list
SWR/IndexedDB cache stayed stale until a network refetch. Because the
Conversation store is recreated on every topic/session switch and
re-hydrates from that cache, the stale cache is what forced a refetch on
every switch.

replaceMessages now seeds the message:list cache for the exact bucket via
mutate(matcher, messages, { revalidate: false }). Skips the
useFetchMessages onData sync path (SWR already holds it) and skips while
the context is streaming to avoid per-token IndexedDB thrash; the
agent_runtime_end snapshot still writes through since it clears the
running flag first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix: seed message:list cache even when replaceMessages store-set is a no-op

Optimistic flows (optimisticUpdateMessageContent / optimisticDeleteMessage[s])
dispatch the mutation into dbMessagesMap first, then call
replaceMessages(server). When the server echo equals the already-applied
optimistic state, the isEqual early-return skipped the store-set AND the
write-through, leaving message:list at the pre-mutation snapshot — a later
remount could hydrate stale content / deleted rows.

Move the write-through ahead of the equality early-return so the cache is
seeded even on a store no-op. Streaming / fetch-sync guards stay inside
#writeThroughMessageCache, so per-token thrash is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:07:44 +08:00
Arvin Xu b7579279ab 💄 feat(linear): render builtin Linear tool cards and drop the Result label (#16125)
Register the shared LinearRender for the `linear` builtin identifier so
native LobeHub Linear tool calls render the same entity card as the
heterogeneous CC/codex path (previously only inspectors were wired up).

Also remove the redundant "Result" label above the self-explanatory
entity cards / result text in the Linear render.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:50:08 +08:00
Arvin Xu 3464bba25e feat(chat): auto-generate topic title for gateway & hetero via shared lifecycle (#16119)
 feat(chat): generate topic title for gateway & hetero via shared lifecycle

Fold topic-title auto-generation into the shared `afterUserMessagePersisted`
hook so all three runtimes title new topics consistently (LOBE-10379 "补齐缺列
title"). Previously only the client summarized titles inline in sendMessage;
gateway topics stayed untitled and hetero showed only a sliced placeholder.

- buildRunLifecycle: implement afterUserMessagePersisted (was NOOP) — top_level
  only (sub_agent / compact excluded). For a brand-new topic it awaits
  refreshTopic when the topic isn't in the store yet, because the gateway path
  inserts it via a fire-and-forget refreshTopic and summaryTopicTitle bails on a
  missing topic.
- conversationLifecycle: call the hook from all three branches after the user
  message is persisted — client passes its freshly-created `data.messages`
  (preserving behavior), gateway/hetero let the hook read from the store.
- types: UserMessagePersistedEvent carries optional messages / assistantMessageId.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:59:53 +08:00
Arvin Xu 6c0649e8a0 🐛 fix(bot): keep command-disconnect copy and use provider-neutral system fallback (#16122)
* 🐛 fix(bot): keep command-disconnect copy and use provider-neutral system fallback

Follow-up to the IM agent-failure error rendering change. Now that the lifecycle
event always populates errorType, two regressions surfaced for state-store
(Upstash/ioredis) failures:

- `Command aborted due to connection close` is pattern-refined to
  StateStorePersistError / StateStoreReadError, so renderAgentError's
  command-disconnect special case (which only accepted undefined/`500`) was
  skipped and users got the generic internal-error copy. Widen the type gate to
  accept the refined state-store codes while keeping the message as the signal.
- The `system` attribution fallback rendered errorTransientNetwork, whose copy
  blames the model provider and suggests switching models — misleading for
  state-store reads where no provider/model is involved. Add a provider-neutral
  `errorSystemInfra` string and point the `system` fallback at it; ProviderNetworkError
  (the one provider-related system code) keeps its precise network copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 💄 style(bot): collapse hetero device guard into a thin single-line notice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:13:46 +08:00
Arvin Xu 1ed0ca305b 🗃️ feat(verify): add verify_evidence + verify_reports tables, probe verifier type (#16114)
🗃️ feat(verify): add verify_evidence + verify_reports tables

Phase-1 data layer for delivery verification (LOBE-10615). Pure data layer —
no business/service code changes; existing `verifierTypes` and `ToulminVerdict`
are untouched.

- schema `verify_evidence` (flat, no recursion): payload is either inline
  `content` (small text: dom snapshot / console / transcript) or `file_id`
  (FK → `files`, which already owns mime / size / hash / url — so no
  mime/size/checksum columns here). Both nullable; file_id set null on file
  delete. `description` sits right under `id`.
- schema `verify_reports`: loosely coupled — `operation_id` is nullable (not a
  hard binding), as are `verdict` / `reviewed_by_user` / the stats counts. A
  unique index still keeps at most one report per operation (regenerate
  overwrites in place).
- types: new `VerifyEvidence` / `VerifyReport` domain types + evidence enums;
  schema files export no drizzle-inferred row types — the models consume the
  `@lobechat/types` domain types directly.
- models `VerifyEvidenceModel` (CRUD + flat `listByCheckResult`) and
  `VerifyReportModel` (`upsertByOperation`, `findByOperation`, `markReviewed`),
  with unit tests. Migration 0112 (idempotent).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:49:24 +08:00
Arvin Xu aff3760ec4 refactor(verify): make the verify agent selectable per task (#16121)
*  feat(verify): make the verify agent selectable per task

Replace the hardcoded builtin verify agent with a task-pinned, selectable
agent (TaskVerifyConfig.verifierAgentId). A pinned agent runs by agentId under
its own agency config (executionTarget / device / provider) — so picking a
heterogeneous agent (e.g. Codex) gives the verifier device + browser access —
and is never overridden by the parent run's model/provider. Falls back to the
builtin verify agent (inheriting the parent run's model/provider) when unset or
when the pinned agent no longer exists.

The pinned id is sourced at completion from the operation's task verify config
(resolveVerifyConfig, with subtask inheritance); non-task runs keep the builtin
fallback.

LOBE-10617

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(verify): inject the verify writeback tool for pinned agents

A pinned verify agent runs with only its own configured plugins, so it lacks
the lobe-verify (submitVerifyResult) tool that the builtin verify agent
declares. Without it the agent can't write its verdict back and the check
result is stuck `running`. Inject VerifyToolIdentifier via additionalPluginIds
for the pinned branch only (the builtin already has it, so it isn't re-added).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:42:47 +08:00
Arvin Xu d378f525a5 🐛 fix(bot): surface perceivable agent-failure causes in IM instead of a bare Operation ID (#16120)
When an agent run failed in an IM/bot channel, users only saw an opaque
"Agent Execution Failed / Operation ID: op_..." line — never the root cause.

Two gaps caused this:
- buildLifecycleEvent never populated errorType on the lifecycle event, so the
  bot renderer always hit the opaque fallback (the existing friendly-error map
  was effectively dead code on the bot path).
- The friendly map only covered 8 user-fixable config codes; the highest-volume
  transient failures (network, rate limit, provider outage, timeout) had no copy.

Fix:
- buildLifecycleEvent normalizes state.error via formatErrorForState and emits
  errorType + errorAttribution on the event (AgentHookEvent gains errorAttribution).
- renderAgentError renders in three tiers: precise code copy → attribution-based
  fallback (network / provider / harness / user) → legacy Operation ID. Adds copy
  for ProviderNetworkError, ProviderServiceUnavailable / NoAvailableChannel,
  RateLimitExceeded, ModelEmptyCompletion, ContentModeration, and
  OperationInactivityTimeout (en-US + zh-CN). Operation ID is demoted to a footer.
- Both consumers (AgentBridgeService in-process hook, BotCallbackService webhook)
  forward errorAttribution to the renderer.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:15:45 +08:00
Arvin Xu fd39bbdc1b 🐛 fix(agent): stop hiding agent-documents tool when an agent has zero documents (#16107)
The server tool engine gated `lobe-agent-documents` behind a
`hasAgentDocuments` system rule that sat AFTER the plugin spread in
`agentModeRules`, so it overrode the agent's own plugin selection down to
`false` whenever the agent had no existing (non-deleted) documents.

This created a dead state: once a user deleted every document on an agent,
`hasByAgent` returned false, the tool was dropped from the LLM payload, and
the agent could never create its first document again — it silently fell
back to dumping content as plain chat text. A meeting-memo agent hit exactly
this after the user bulk-deleted its 16 documents.

Drop the override so the tool is governed by the normal plugin-enable layer
(the inbox agent injects it into default plugins, and users keep it in
`agentConfig.plugins`). Also remove the now-unused `hasAgentDocuments` param
from `ServerCreateAgentToolsEngineParams` and its call site; the
`hasDocuments()` DB lookup stays for operation telemetry only.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:11:52 +08:00
Arvin Xu 45d75dbaad feat(task): add task-level verify config storage layer (#16113)
*  feat(task): add task-level verify config storage layer

Introduce the Phase 1 storage layer for task-bound delivery-acceptance
(verify) config (LOBE-10616 / LOBE-10614 §2 §6):

- types: add `TaskVerifyConfig`, expose `verify` on `TaskDetailData`, mark
  the legacy `review` field deprecated
- model: `getVerifyConfig` (with migration-period fallback to the legacy
  `review` key), `resolveVerifyConfig` (subtask inheritance with whole-config
  override), `updateVerifyConfig`
- router: `task.getVerifyConfig` / `task.updateVerifyConfig` procedures

No DB migration — config lands in the `tasks.config` JSONB. Legacy
`getReview`/`updateReview` are kept until taskReview is retired (Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 🐛 fix(task): let updateVerifyConfig clear saved verify ids

Deep-merge + JSON's inability to send `undefined` meant a saved
`verifyRubricId` / `verifierAgentId` could never be removed — omitting a
field preserved the stale value, blocking a switch back to the default or a
criteria-only config.

`updateVerifyConfig` now does a controlled per-key patch over `config.verify`:
`null` clears the key, omission leaves it untouched, a value sets it (arrays
replace wholesale). Router input accepts `.nullish()` so callers can send
`null` to clear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ♻️ refactor(task): surface verify in task detail and drop legacy review field

Address PR review:

- getTaskDetail now populates `TaskDetailData.verify` via getVerifyConfig, so
  clients see saved verify config after updateVerifyConfig (was left undefined,
  unlike the old review field).
- Remove the deprecated `TaskDetailData.review` field and its readers: the
  dead `activeTaskReview` selector, the `updateReview` store action's stale
  optimistic dispatch, and the review block in the viewTask / task-run prompt
  formatters (review is retired; verify is the converged gate). The separate
  TaskRunPromptInput.review surface is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:43:00 +08:00
Arvin Xu 3ed3ac02e5 feat(agent-tasks): add agent-scoped task list and refine task UI (#16111)
*  feat(agent-tasks): add agent-scoped task list and refine task UI

- add /agent/:aid/tasks route showing one agent's tasks (both router configs)
- breadcrumb: show "All tasks › agent › task" with agent crumb linking to its task list
- sidebar tasks group: surface "view all" action + show for heterogeneous agents
- hide model select for heterogeneous-agent tasks (external runtime owns the model)
- lock assignee to the scoped agent in inline/modal/empty-state create entries
- reset shared list state on scope change so the all-tasks view no longer bleeds a single agent's tasks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(agent-management): describe hetero agent runtime in getAgentDetail

getAgentDetail now surfaces a runtime descriptor for heterogeneous agents
(Claude Code, Codex, etc.) so an orchestrator can judge their real
capabilities. Such agents bring their own toolset and ignore the chat
model/plugins, so reporting only model/provider was misleading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(agent-tasks): show running-op status + collapse toggle in topic drawer reply

Mirror Fleet's ReplyArea in the task topic drawer FeedbackInput:
- surface the live OpStatusTray (seamless) above the reply affordance so the
  running agent is visible without expanding the composer
- add a collapse toggle (taskDetail.collapseReply) when the composer is expanded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(agent-tasks): render task detail in chat Portal + fix createTask state

- Add PortalViewType.TaskDetail with openTaskDetail/closeTaskDetail + selectors
- New Portal/TaskDetail view (Title/Body) reusing TaskDetailSections
- Extract TaskDetailSections + shared useActiveTaskDetail hook (page + Portal)
- CreateTask card: auto-open Portal on creation + click to open/toggle detail
- Fix server createTask runtime dropping the task identifier from tool state,
  so the inline card and Portal can link to the created task
- Front-load the assignee agent config as a hard loading gate; TaskModelConfig
  now resolves model / heterogeneous runtime from the assignee, not whatever
  agent is active in the surrounding chat
- Fix first-paint 404 flash by using the fetch error as the not-found signal

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(agent-tasks): show heterogeneous runtime tag on task assignee

- Extract reusable HeterogeneousTag (single source for the runtime pill)
- Render it on the assignee picker rows and the task-detail assignee chip,
  matching the home agent list
- Refactor home AgentItem to use the shared tag (no behavior change)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(agent-tasks): propagate agent scope into kanban + sync locked inline assignee

- KanbanBoard takes an agentId prop: scoped group-list fetch + scoped/locked create modal, so /agent/:aid/tasks kanban shows only that agent (and stops listAgentId flip-flopping between the agent id and __all__)
- CreateTaskInlineEntry syncs its hidden assignee to agentId when locked, so reusing the route subtree across /agent/A/tasks -> /agent/B/tasks no longer submits to A

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  feat(agent-tasks): remember sidebar task/topic section expand state per agent

Make the agent sidebar's Tasks/Topic accordion controlled and persist its
expanded state per agentId in systemStatus (localStorage), so switching agents
keeps each one's own collapse/expand layout instead of sharing a single
accordion. Stored as nested booleans (not a key array) so updateSystemStatus's
lodash merge replaces scalars cleanly instead of index-merging arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

*  test(agent-tasks): fix Breadcrumb react-router mock after refactor

Breadcrumb now uses useParams + WorkspaceLink + useAgentDisplayMeta; add the
missing react-router useParams export and mock WorkspaceLink / useAgentDisplayMeta
so the breadcrumb-link assertions render predictable hrefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:42:41 +08:00
Arvin Xu de51d13d58 ♻️ refactor(chat): wire gateway and hetero into the shared run lifecycle (#16102)
* ♻️ refactor(chat): wire gateway and hetero into the shared run lifecycle

Route the gateway and hetero sendMessage runtimes through the shared
`buildRunLifecycle` so all three runtimes settle terminal side effects in a
consistent order (LOBE-10379 [4b]/[4c]).

- gateway: agent_runtime_end → completeRun({status}); error → fail; add the
  queue drain + desktop notification; drop the duplicate completeOperation in
  onSessionComplete (now a terminal-missing fallback via terminalReceived).
- hetero: completion notification moves to afterRunComplete; heteroSessionId
  save is best-effort (no longer blocks the queue drain); the reused gateway
  handler runs as runtimeType:'hetero' (reconciliation only).
- lifecycle: cancelled completes the op for gateway/hetero; afterRunComplete is
  the single notification home for all runtimes; drop the chat-store value
  import so gateway can statically import buildRunLifecycle without a cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(chat): gate sub-agent gateway terminals from top-level run effects

A nested Agent/Task sub-agent dispatched via gateway runs with
context.scope: 'sub_agent', but completeRun/afterRunComplete did not inspect
runScope — so a sub-agent completion could drain the parent's input queue or
fire a desktop completion notification before the parent run finished.

Honor the RunScope contract centrally (fixes client + gateway uniformly): skip
the input-queue drain in completeRun and the notification in afterRunComplete
when runScope === 'sub_agent'. The op still completes so the sub-agent loading
clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:57:47 +08:00
Arvin Xu 88a7e82ab8 ⬆️ fix(model-runtime): upgrade openai SDK to v6 to drop node-fetch@2 (#16090)
OpenAI SDK v4 bundles node-fetch@2, whose gzip response-stream handling
breaks under the Node.js June 2026 security release and throws
ERR_STREAM_PREMATURE_CLOSE (surfaced as "LLM stream error: Premature
close" across all providers). SDK v6 uses native fetch, removing the
node-fetch@2 dependency from the LLM call path.

Migration touch-ups for v6's type changes:
- narrow `apiKey` back to `string` (v6 widened it to `string | ApiKeySetter`)
- access `.function` via the function-tool variant (`ChatCompletionTool` /
  `ChatCompletionMessageToolCall` are now function|custom unions)
- pass real `Headers` instead of plain objects to `APIError` in tests
  (v6 calls `headers.get()`)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:57:56 +08:00
Sui_Feng 2731937feb 🔨 chore(docker-compose): Explicitly enable pg_search in the Paradedb container (#16096)
Enable pg_search in Postgres containers

Start Postgres with shared_preload_libraries=pg_search in three docker-compose files (deploy, dev, production/grafana). This adds a command to the Postgres service so the pg_search library is preloaded on startup, ensuring the pg_search extension is available for full-text search features.
Related Links: https://github.com/paradedb/paradedb/pull/4914
2026-06-20 14:44:14 +08:00
AmAzing- 25d582827c feat(analytics): track home footer menu clicks and exposure (#16088)
*  feat(analytics): track home footer menu clicks and exposure

* 🐛 fix(analytics): align footer menu-open keys with click-tracked items

Billboard menu items were included in home_footer_menu_opened keys but
never emit home_footer_menu_clicked, producing per-key CTR denominators
that can never have matching clicks. Report exposure only for the items
wrapped by injectMenuTracking (own items); billboard entries keep their
own billboard_* events.
2026-06-20 12:50:39 +08:00
Arvin Xu bd034c3aef 🐛 fix(bot): warn when a WeChat file attachment can't be retrieved instead of silently dropping it (#16087)
WeChat (iLink/ClawBot) relays oversized files as metadata only — the inbound
FILE item carries no downloadable CDN media descriptor (no encrypt_query_param),
so `downloadMediaFromRawMessage` silently breaks and produces no attachment.
`extractFiles` then returned nothing, no file was ingested, and the only thing
the model saw was the bare `[file: name]` text placeholder that the adapter's
`extractText` always emits. The model then hallucinated around the filename —
e.g. for a 132 MB .mp3 it replied that it "couldn't hear" the audio it never
actually received. Nothing surfaced this: the package helper's own warn is a
no-op here (no logger passed), so there was no log and no user-facing signal.

extractFiles now walks the raw item_list and, for any FILE item that wasn't
downloaded, returns a warning (with size hint) via the existing
ExtractFilesResult.warnings channel — already injected into the context engine
and surfaced to the user (same mechanism Telegram uses for its 20 MB cap).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 12:27:31 +08:00
lobehubbot 80a06e047c Merge remote-tracking branch 'origin/main' into canary 2026-06-20 04:23:58 +00:00
Arvin Xu bb3e689053 🐛 fix(conversation-flow): fold toolless turn-head into the tool chain group (#16084)
* 🐛 fix(conversation-flow): fold toolless turn-head into the tool chain group

A hetero-agent turn often opens with a toolless narration step streamed in
reply to the user before the first tool call, with the tool-using step as its
direct child. The flat-list dispatcher only opened an AssistantGroup when the
message itself carried tools, so the toolless head fell through to the regular
message path and rendered as its own standalone bubble while the tool step
opened a separate group — the UI showed two disconnected assistant cards (a
visually broken chain).

Add MessageCollector.isToolChainHead to detect a toolless turn-head (parent is
a user message, single same-agent continuation that calls tools) and let it
seed the AssistantGroup. Scoped narrowly to turn heads so mid-chain toolless
steps and branch handling are untouched; walks via childrenMap to stay
O(chain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(conversation-flow): only fold a toolless head whose next step calls tools

A multi-step toolless prelude (>1 prose step before the first tool call) was
mis-classified as a tool-chain head: the walk advanced through the intermediate
prose and returned true, but collectAssistantChain stops at the first toolless
continuation (it only recurses through tool-using steps), yielding a tools-less
assistantGroup with the tool step still split off.

Restrict isToolChainHead to heads whose IMMEDIATE same-agent continuation
already carries tools; a multi-prose prelude now falls back to standalone
bubbles instead of a malformed group. Add a regression asserting no tools-less
assistantGroup is produced for that shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 12:16:14 +08:00
Arvin Xu 54ea8f62a2 chore: clean up LOBE-XXX code markers (2026-06-20) (#16079)
chore: clean up LOBE-XXX markers from source code

- packages/database/src/schemas/aiInfra.ts: replace LOBE-10056 comment
  markers with descriptive migration context (0110 surrogate PK)
- packages/builtin-tools/src/codex/mcpToolUtils.test.ts: replace
  LOBE-10205 test fixture IDs with generic TEST-0000
- src/features/DevPanel/RenderGallery/fixtures/claude-code.ts: same
- src/features/DevPanel/RenderGallery/fixtures/codex.ts: same

Co-authored-by: Arvin Xu <arvinxx@lobehub.com>
2026-06-20 11:52:28 +08:00
Arvin Xu d37ebb2922 ♻️ refactor(chat): make run-lifecycle completeRun transport-driven (#16083)
Generalize the shared `buildRunLifecycle.completeRun` so gateway/hetero can drive
it, without changing client behavior — the enabling step before wiring the other
two transports onto the shared lifecycle.

- Add `resolveTerminalDisposition`: derive the effective terminal disposition
  (`success` / `failed` / `cancelled`) from EITHER the client's raw
  `runtimeStatus` (`AgentState['status']`) OR the normalized cross-runtime
  `status` ('completed' / 'failed' / 'cancelled') that gateway/hetero supply.
  `completeRun` now branches on the disposition, so the same side effects
  (afterCompletion → queue drain → completeOperation/failOperation → markUnread)
  fire regardless of which transport reached the boundary.
- Gate the client-only `client.runtime.complete` source event to
  `runtimeType === 'client'` — gateway/hetero emit their own `client.gateway.*`
  events at their transport boundaries and must not double-emit here.

Client path is byte-equivalent (done→success, error→failed, interrupted→cancelled
map to the previous branches). No caller change yet; gateway/hetero are wired in
the follow-up commits. Part of LOBE-10379.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:40:26 +08:00
Arvin Xu 2487407192 feat(cli): add lh update command to self-upgrade the CLI (#16052)
*  feat(cli): add `lh update` command to self-upgrade the CLI

Add a `lh update` command that checks the npm registry for the latest
published version of `@lobehub/cli`, compares it against the installed
version, and runs the matching global-install command. The package
manager is auto-detected (npm/pnpm/yarn/bun) from the running binary
path, with `--package-manager` to override. Supports `--check` for a
dry run and `--tag` to target a non-latest dist-tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): resolve update command package metadata from the bundled entry

`update.ts` loaded `require('../../package.json')`, a path relative to its
source location. tsdown bundles everything into `dist/index.js`, where that
runtime require resolves to `node_modules/@lobehub/package.json` instead of
`@lobehub/cli/package.json` — so a published build threw at startup the moment
the update module was registered, breaking every `lh` invocation.

Reuse the single package-metadata load already done by the bundled entry
(`program.ts`, at the depth where `../package.json` is correct both in source
and in dist). Export the package name alongside the version and consume both
in the update command. Verified against a real `tsdown` build: `lh --version`
starts cleanly and `lh update --check` resolves `@lobehub/cli` correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ♻️ refactor(cli): extract package metadata into a shared src/pkg.ts const

Importing `cliVersion`/`cliPackageName` from `program.ts` into `update.ts`
created a `program ↔ update` import cycle (program registers the update
command). Move the single `require('../package.json')` load into a dedicated
`src/pkg.ts` const module that both `program.ts` and `update.ts` import,
breaking the cycle. The module sits directly under `src/` — the same depth as
the bundled `dist/index.js` — so `../package.json` resolves to `@lobehub/cli`'s
own package.json in both source and bundled runs.

Verified against a real tsdown build: one `createRequire("../package.json")`
remains, `lh --version` starts cleanly, `lh update --check` resolves the
package, and `man:generate` still reads the version via the re-export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 🐛 fix(cli): use the semver package for update version comparison

The hand-rolled comparator ordered prerelease identifiers lexicographically,
so `1.0.0-beta.10` sorted below `1.0.0-beta.9` and `lh update --tag beta`
reported "already current" instead of offering the newer prerelease.

Replace `compareSemver` with `isNewerVersion`, delegating to the `semver`
package (already a workspace dependency) so numeric prerelease parts compare
numerically. Add `semver` / `@types/semver` to the CLI package and a
regression test for the beta.9 → beta.10 case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:22:38 +08:00
AmAzing- 6de1e14a4d feat(subscription): add referral invite entry (#16073)
*  feat(subscription): add referral invite entry

* 💬 chore(subscription): refine referral code copy
2026-06-20 10:52:52 +08:00
382 changed files with 54543 additions and 3050 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ module.exports = defineConfig({
markdown: {
reference:
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
fs.readFileSync(path.join(__dirname, 'docs/glossary.mdx'), 'utf8'),
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
entryLocale: 'en-US',
outputLocales: ['zh-CN'],
+7 -1
View File
@@ -1,6 +1,6 @@
.\" Code generated by `npm run man:generate`; DO NOT EDIT.
.\" Manual command details come from the Commander command tree.
.TH LH 1 "" "@lobehub/cli 0.0.32" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.34" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -41,6 +41,9 @@ Show a manual page for the CLI or a subcommand
.B connect
Connect to the device gateway and listen for tool calls
.TP
.B disconnect
Disconnect from the device gateway (alias for `connect stop`)
.TP
.B device
Manage connected devices
.TP
@@ -127,6 +130,9 @@ Manage evaluation workflows
.TP
.B migrate
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
.TP
.B update
Update the LobeHub CLI to the latest published version
.SH OPTIONS
.TP
.B \-V, \-\-version
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.32",
"version": "0.0.34",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -37,6 +37,7 @@
"@lobechat/tool-runtime": "workspace:*",
"@trpc/client": "^11.8.1",
"@types/node": "^24.13.2",
"@types/semver": "^7.7.1",
"@types/ws": "^8.18.1",
"commander": "^13.1.0",
"dayjs": "^1.11.19",
@@ -45,6 +46,7 @@
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"picocolors": "^1.1.1",
"semver": "^7.7.3",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^6.0.3",
+39 -13
View File
@@ -12,7 +12,8 @@ import { log } from '../utils/logger';
export type TrpcClient = ReturnType<typeof createTRPCClient<LambdaRouter>>;
export type ToolsTrpcClient = ReturnType<typeof createTRPCClient<ToolsRouter>>;
let _client: TrpcClient | undefined;
const PERSONAL_KEY = '__personal__';
const _clients = new Map<string, TrpcClient>();
let _toolsClient: ToolsTrpcClient | undefined;
async function getAuthAndServer() {
@@ -53,21 +54,40 @@ async function getAuthAndServer() {
};
}
export async function getTrpcClient(): Promise<TrpcClient> {
if (_client) return _client;
/**
* Resolve the workspace scope for outbound tRPC calls.
*
* Precedence: explicit caller arg → `LOBEHUB_WORKSPACE_ID` env (inherited
* from a workspace-dispatched parent process, e.g. openclaw spawned by the
* device's `runHeteroTask`) → personal mode. Without this, agentNotify
* callbacks on workspace topics would resolve through personal-mode
* TopicModel and 404.
*/
function resolveWorkspaceId(explicit?: string): string | undefined {
if (explicit) return explicit;
const fromEnv = process.env.LOBEHUB_WORKSPACE_ID;
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
}
export async function getTrpcClient(workspaceId?: string): Promise<TrpcClient> {
const wsId = resolveWorkspaceId(workspaceId);
const cacheKey = wsId ?? PERSONAL_KEY;
const cached = _clients.get(cacheKey);
if (cached) return cached;
const { headers, serverUrl } = await getAuthAndServer();
_client = createTRPCClient<LambdaRouter>({
const client = createTRPCClient<LambdaRouter>({
links: [
httpLink({
headers,
headers: wsId ? { ...headers, 'X-Workspace-Id': wsId } : headers,
transformer: superjson,
url: `${serverUrl}/trpc/lambda`,
}),
],
});
_clients.set(cacheKey, client);
return _client;
return client;
}
/**
@@ -77,13 +97,19 @@ export async function getTrpcClient(): Promise<TrpcClient> {
* via env/stored creds and `process.exit(1)` when none exist, which would
* abort an otherwise-valid explicit-token session.
*/
export function createLambdaClient(auth: {
serverUrl: string;
token: string;
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
}): TrpcClient {
const headers =
auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token };
export function createLambdaClient(
auth: {
serverUrl: string;
token: string;
tokenType: 'apiKey' | 'jwt' | 'serviceToken';
},
/** When set, scopes the request to a workspace (e.g. workspace-device enrollment). */
workspaceId?: string,
): TrpcClient {
const headers: Record<string, string> = {
...(auth.tokenType === 'apiKey' ? { 'X-API-Key': auth.token } : { 'Oidc-Auth': auth.token }),
...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}),
};
return createTRPCClient<LambdaRouter>({
links: [httpLink({ headers, transformer: superjson, url: `${auth.serverUrl}/trpc/lambda` })],
+98 -47
View File
@@ -18,7 +18,6 @@ import type {
import { GatewayClient } from '@lobechat/device-gateway-client';
import type { Command } from 'commander';
import { getValidToken } from '../auth/refresh';
import { resolveToken } from '../auth/resolveToken';
import { CLI_API_KEY_ENV } from '../constants/auth';
import { OFFICIAL_GATEWAY_URL } from '../constants/urls';
@@ -34,7 +33,13 @@ import {
writeStatus,
} from '../daemon/manager';
import { spawnHeteroAgentRun } from '../device/agentRun';
import { registerDevice, resolveDeviceIdentity } from '../device/register';
import {
mintWorkspaceConnectToken,
registerDevice,
registerWorkspaceDevice,
resolveDeviceIdentity,
resolveWorkspaceDeviceIdentity,
} from '../device/register';
import { loadOrCreateConnectionId, loadSettings, normalizeUrl, saveSettings } from '../settings';
import { executeToolCall } from '../tools';
import { cleanupAllProcesses } from '../tools/shell';
@@ -47,6 +52,8 @@ interface ConnectOptions {
gateway?: string;
token?: string;
verbose?: boolean;
/** Enroll this machine as a device of the given workspace (admin only). */
workspace?: string;
}
export function registerConnectCommand(program: Command) {
@@ -56,6 +63,7 @@ export function registerConnectCommand(program: Command) {
.option('--token <jwt>', 'JWT access token')
.option('--gateway <url>', 'Device gateway URL')
.option('--device-id <id>', 'Device ID (auto-generated if not provided)')
.option('--workspace <id>', 'Enroll as a device of this workspace (admin only)')
.option('-v, --verbose', 'Enable verbose logging')
.option('-d, --daemon', 'Run as a background daemon process')
.option('--daemon-child', 'Internal: runs as the daemon child process')
@@ -185,6 +193,7 @@ function buildDaemonArgs(options: ConnectOptions): string[] {
if (options.token) args.push('--token', options.token);
if (options.gateway) args.push('--gateway', options.gateway);
if (options.deviceId) args.push('--device-id', options.deviceId);
if (options.workspace) args.push('--workspace', options.workspace);
if (options.verbose) args.push('--verbose');
return args;
@@ -209,10 +218,43 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
const resolvedGatewayUrl = gatewayUrl || OFFICIAL_GATEWAY_URL;
// Workspace enrollment: the device joins a workspace pool (reachable by all
// members) instead of the personal pool. It authenticates with a minted
// workspace-device token (carrying the `workspace_id` claim) and uses a
// workspace-derived deviceId. `auth` stays the admin's identity — used only to
// (re-)mint the connect token and register the row.
const workspaceId = options.workspace;
// Resolve a stable device identity. An explicit `--device-id` wins (lets a
// user pin a VM to a fixed identity); otherwise derive from the machine id so
// the same machine + user maps to one device across reconnects.
const identity = resolveDeviceIdentity(auth.userId, options.deviceId);
// the same machine maps to one device across reconnects.
const identity = workspaceId
? resolveWorkspaceDeviceIdentity(workspaceId, options.deviceId)
: resolveDeviceIdentity(auth.userId, options.deviceId);
// The token the gateway socket authenticates with. Re-minted on refresh for
// workspace devices (see `refreshConnectToken`).
let connectToken = auth.token;
let connectTokenType: 'apiKey' | 'jwt' | 'serviceToken' = auth.tokenType;
if (workspaceId) {
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
connectToken = minted.token;
connectTokenType = 'jwt';
}
// Re-resolve the admin auth and, for workspace mode, re-mint the connect token.
const refreshConnectToken = async (): Promise<string | undefined> => {
const refreshed = await resolveToken({});
if (!refreshed) return undefined;
auth = refreshed;
if (workspaceId) {
const minted = await mintWorkspaceConnectToken(auth, workspaceId);
connectToken = minted.token;
return connectToken;
}
connectToken = refreshed.token;
return connectToken;
};
// Freeform channel label (`cli` by default); `LOBEHUB_CLI_CHANNEL` lets a
// dev build tag itself `cli-dev` so the gateway can prioritise / display it.
@@ -225,9 +267,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
gatewayUrl: resolvedGatewayUrl,
logger: isDaemonChild ? createDaemonLogger() : log,
serverUrl: auth.serverUrl,
token: auth.token,
tokenType: auth.tokenType,
userId: auth.userId,
token: connectToken,
tokenType: connectTokenType,
userId: workspaceId ? undefined : auth.userId,
workspaceId,
});
const info = (msg: string) => {
@@ -376,15 +419,21 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
updateStatus('reconnecting');
});
// Proactive token refresh — schedule before JWT expires
const startProactiveRefresh = () =>
// Proactive token refresh — schedule before the connect token expires. For a
// workspace device `refreshConnectToken` re-mints the workspace token; for a
// personal device it refreshes the user token. Scheduling watches the actual
// connect token, so the workspace token's shorter life is respected.
const startProactiveRefresh = (): (() => void) | null =>
scheduleProactiveRefresh(
auth,
(refreshed) => {
client.updateToken(refreshed.token);
auth = refreshed;
// Schedule next refresh based on the new token
cancelRefreshTimer = startProactiveRefresh();
connectToken,
connectTokenType,
async () => {
const newToken = await refreshConnectToken();
if (newToken) {
client.updateToken(newToken);
cancelRefreshTimer = startProactiveRefresh();
}
return newToken;
},
info,
error,
@@ -395,15 +444,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// (e.g., auto-reconnect may send an expired JWT before proactive refresh fires)
let authFailedRefreshAttempted = false;
client.on('auth_failed', async (reason) => {
if (auth.tokenType === 'jwt' && !authFailedRefreshAttempted) {
if (connectTokenType === 'jwt' && !authFailedRefreshAttempted) {
authFailedRefreshAttempted = true;
info(`Authentication failed (${reason}). Attempting token refresh...`);
try {
const refreshed = await resolveToken({});
if (refreshed && refreshed.token !== auth.token) {
const prev = connectToken;
const newToken = await refreshConnectToken();
if (newToken && newToken !== prev) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(refreshed.token);
auth = refreshed;
client.updateToken(newToken);
authFailedRefreshAttempted = false;
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
@@ -424,7 +473,7 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Handle auth expired — refresh token and reconnect automatically
client.on('auth_expired', async () => {
if (auth.tokenType === 'apiKey') {
if (connectTokenType === 'apiKey') {
// API keys don't expire; ignore stale auth_expired signals
return;
}
@@ -432,11 +481,10 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
info('Authentication expired. Attempting to refresh token...');
try {
const refreshed = await resolveToken({});
if (refreshed) {
const newToken = await refreshConnectToken();
if (newToken) {
info('Token refreshed successfully. Reconnecting...');
client.updateToken(refreshed.token);
auth = refreshed;
client.updateToken(newToken);
cancelRefreshTimer = startProactiveRefresh();
await client.reconnect();
return;
@@ -486,7 +534,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
try {
// Reuse the already-resolved auth (respects `--token` mode) so we don't
// re-discover creds and exit when none are found.
await registerDevice(auth, identity);
if (workspaceId) await registerWorkspaceDevice(auth, identity, workspaceId);
else await registerDevice(auth, identity);
} catch (err) {
error(`Device registration failed (non-fatal): ${(err as Error).message}`);
}
@@ -534,47 +583,49 @@ function parseJwtExp(token: string): number | undefined {
}
/**
* Schedule a proactive token refresh before the JWT expires.
* Returns a cleanup function that cancels the scheduled timer.
* Schedule a proactive token refresh before the (connect) token expires.
* `refresh` performs the actual refresh — re-minting a workspace token or
* refreshing the user token — and returns the new token. Returns a cleanup
* function that cancels the scheduled timer.
*/
function scheduleProactiveRefresh(
auth: { token: string; tokenType: string },
onRefreshed: (newAuth: Awaited<ReturnType<typeof resolveToken>>) => void,
token: string,
tokenType: string,
refresh: () => Promise<string | undefined>,
info: (msg: string) => void,
error: (msg: string) => void,
): (() => void) | null {
if (auth.tokenType !== 'jwt') return null;
if (tokenType !== 'jwt') return null;
const exp = parseJwtExp(auth.token);
const exp = parseJwtExp(token);
if (!exp) return null;
const refreshAt = (exp - PROACTIVE_REFRESH_BUFFER) * 1000;
const delay = refreshAt - Date.now();
if (delay < 0) {
// Already past the refresh window — refresh immediately on next tick
const lifetimeMs = exp * 1000 - Date.now();
if (lifetimeMs <= 0) {
// Token already expired — refresh once on next tick.
void doRefresh();
return null;
}
// Refresh ahead of expiry, but never let the buffer meet or exceed the token's
// remaining lifetime: a buffer >= lifetime collapses the refresh window to <=0
// and busy-loops re-minting (e.g. a 1h token with a 1h buffer). Cap the buffer
// at half the remaining lifetime so a short-lived token refreshes about once per
// half-life instead of spinning.
const bufferMs = Math.min(PROACTIVE_REFRESH_BUFFER * 1000, lifetimeMs / 2);
const delay = lifetimeMs - bufferMs;
const timer = setTimeout(() => void doRefresh(), delay);
return () => clearTimeout(timer);
async function doRefresh() {
try {
// Use the same buffer so getValidToken actually triggers a refresh
const result = await getValidToken(PROACTIVE_REFRESH_BUFFER);
if (!result) {
const newToken = await refresh();
if (!newToken) {
error('Proactive token refresh failed — no valid credentials.');
return;
}
const refreshed = await resolveToken({});
// Only notify if the token actually changed to avoid reschedule loops
if (refreshed.token !== auth.token) {
info('Proactively refreshed token.');
onRefreshed(refreshed);
}
if (newToken !== token) info('Proactively refreshed token.');
} catch {
error('Proactive token refresh failed.');
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildInstallCommand, isNewerVersion } from './update';
describe('isNewerVersion', () => {
it('compares core versions', () => {
expect(isNewerVersion('1.2.3', '1.2.2')).toBe(true);
expect(isNewerVersion('1.2.2', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3', '1.2.3')).toBe(false);
expect(isNewerVersion('2.0.0', '1.9.9')).toBe(true);
});
it('tolerates a leading v and missing segments', () => {
expect(isNewerVersion('v1.2.0', '1.2.0')).toBe(false);
expect(isNewerVersion('1.2', '1.2.0')).toBe(false);
expect(isNewerVersion('1.3', '1.2.9')).toBe(true);
});
it('ranks a stable release above a prerelease of the same core', () => {
expect(isNewerVersion('1.2.3', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3')).toBe(false);
expect(isNewerVersion('1.2.3-beta.2', '1.2.3-beta.1')).toBe(true);
expect(isNewerVersion('1.2.3-beta.1', '1.2.3-beta.1')).toBe(false);
});
it('orders numeric prerelease identifiers numerically, not lexicographically', () => {
// The bug a raw string compare gets wrong: beta.10 must outrank beta.9.
expect(isNewerVersion('1.0.0-beta.10', '1.0.0-beta.9')).toBe(true);
expect(isNewerVersion('1.0.0-beta.9', '1.0.0-beta.10')).toBe(false);
expect(isNewerVersion('1.0.0-beta.2', '1.0.0-beta.10')).toBe(false);
});
it('returns false for an unparseable latest version', () => {
expect(isNewerVersion('not-a-version', '1.0.0')).toBe(false);
});
});
describe('buildInstallCommand', () => {
it('builds the global install command per package manager', () => {
expect(buildInstallCommand('npm', '@lobehub/cli@1.0.0')).toEqual({
args: ['install', '-g', '@lobehub/cli@1.0.0'],
command: 'npm',
});
expect(buildInstallCommand('pnpm', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'pnpm',
});
expect(buildInstallCommand('bun', '@lobehub/cli@1.0.0')).toEqual({
args: ['add', '-g', '@lobehub/cli@1.0.0'],
command: 'bun',
});
expect(buildInstallCommand('yarn', '@lobehub/cli@1.0.0')).toEqual({
args: ['global', 'add', '@lobehub/cli@1.0.0'],
command: 'yarn',
});
});
});
+179
View File
@@ -0,0 +1,179 @@
import { spawn } from 'node:child_process';
import { realpathSync } from 'node:fs';
import type { Command } from 'commander';
import pc from 'picocolors';
import semver from 'semver';
// Pull package metadata from the shared `src/pkg.ts` module (resolved at the
// bundled entry's depth) rather than a local `require('../../package.json')`,
// which would point outside the package once bundled into dist/index.js.
import { cliPackageName, cliVersion } from '../pkg';
import { log } from '../utils/logger';
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
const PACKAGE_MANAGERS: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'];
interface UpdateOptions {
check?: boolean;
packageManager?: PackageManager;
tag?: string;
}
/**
* Detect which package manager installed the CLI so we run the matching global
* upgrade command. We first trust an explicit `npm_config_user_agent` (set when
* invoked through a package-manager script) and otherwise infer from the path of
* the running binary. Falls back to npm.
*/
export function detectPackageManager(): PackageManager {
const ua = process.env.npm_config_user_agent;
if (ua) {
if (ua.startsWith('pnpm')) return 'pnpm';
if (ua.startsWith('yarn')) return 'yarn';
if (ua.startsWith('bun')) return 'bun';
if (ua.startsWith('npm')) return 'npm';
}
try {
const binPath = realpathSync(process.argv[1] ?? '').replaceAll('\\', '/');
if (binPath.includes('/pnpm/')) return 'pnpm';
if (binPath.includes('/.bun/') || binPath.includes('/bun/')) return 'bun';
if (binPath.includes('/yarn/') || binPath.includes('/.yarn/')) return 'yarn';
} catch {
// ignore fall back to npm
}
return 'npm';
}
/** Build the global-install command for the detected package manager. */
export function buildInstallCommand(
pm: PackageManager,
spec: string,
): { args: string[]; command: string } {
switch (pm) {
case 'pnpm': {
return { args: ['add', '-g', spec], command: 'pnpm' };
}
case 'yarn': {
return { args: ['global', 'add', spec], command: 'yarn' };
}
case 'bun': {
return { args: ['add', '-g', spec], command: 'bun' };
}
default: {
return { args: ['install', '-g', spec], command: 'npm' };
}
}
}
/**
* Whether `latest` is a newer version than `current`. Delegates to `semver` so
* prerelease identifiers order correctly (e.g. `1.0.0-beta.10` > `1.0.0-beta.9`,
* which a lexicographic compare gets wrong). Tolerates a leading `v` and missing
* segments via coercion; an unparseable `latest` is treated as "not newer".
*/
export function isNewerVersion(latest: string, current: string): boolean {
const latestParsed = semver.coerce(latest, { includePrerelease: true }) ?? semver.parse(latest);
const currentParsed =
semver.coerce(current, { includePrerelease: true }) ?? semver.parse(current);
if (!latestParsed || !currentParsed) return false;
return semver.gt(latestParsed, currentParsed);
}
async function fetchLatestVersion(name: string, tag: string): Promise<string> {
const url = `https://registry.npmjs.org/${name}/${encodeURIComponent(tag)}`;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) {
throw new Error(`npm registry returned status ${res.status} for tag "${tag}"`);
}
const data = (await res.json()) as { version?: string };
if (!data.version) {
throw new Error('npm registry response is missing the "version" field');
}
return data.version;
}
function runInstall(command: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
shell: process.platform === 'win32',
stdio: 'inherit',
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`${command} exited with code ${code ?? 'null'}`));
});
});
}
export function registerUpdateCommand(program: Command) {
program
.command('update')
.description('Update the LobeHub CLI to the latest published version')
.option('--check', 'Only check for a newer version without installing')
.option('--tag <tag>', 'npm dist-tag to update to', 'latest')
.option(
'--package-manager <pm>',
`Force a package manager (${PACKAGE_MANAGERS.join(', ')}) instead of auto-detecting`,
)
.action(async (options: UpdateOptions) => {
if (options.packageManager && !PACKAGE_MANAGERS.includes(options.packageManager)) {
log.error(
`Unsupported package manager "${options.packageManager}". Use one of: ${PACKAGE_MANAGERS.join(', ')}.`,
);
process.exit(1);
return;
}
const current = cliVersion;
const tag = options.tag || 'latest';
log.info(`Current version: ${pc.bold(current)}`);
let latest: string;
try {
latest = await fetchLatestVersion(cliPackageName, tag);
} catch (error) {
log.error(`Unable to check for updates: ${(error as Error).message}`);
process.exit(1);
return;
}
log.info(`Latest version: ${pc.bold(latest)} ${pc.dim(`(${tag})`)}`);
if (!isNewerVersion(latest, current)) {
log.info(pc.green('Already on the latest version.'));
return;
}
if (options.check) {
log.info(
`Update available: ${current}${pc.green(latest)}. Run ${pc.cyan('lh update')} to upgrade.`,
);
return;
}
const pm = options.packageManager || detectPackageManager();
const spec = `${cliPackageName}@${latest}`;
const { args, command } = buildInstallCommand(pm, spec);
log.info(`Upgrading via ${pc.bold(pm)}: ${pc.dim([command, ...args].join(' '))}`);
try {
await runInstall(command, args);
log.info(pc.green(`Successfully updated to ${latest}. Restart any running sessions.`));
} catch (error) {
log.error(`Update failed: ${(error as Error).message}`);
log.error(`You can upgrade manually: ${[command, ...args].join(' ')}`);
process.exit(1);
}
});
}
+42
View File
@@ -88,3 +88,45 @@ describe('verify rubric config commands', () => {
expect(printed).toContain('4');
});
});
describe('verify evidence upload command', () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockGetTrpcClient.mockReset();
exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new Error(`process.exit ${code}`);
}) as any);
});
afterEach(() => {
exitSpy.mockRestore();
});
const run = async (args: string[]) => {
const program = new Command();
program.exitOverride();
registerVerifyCommand(program);
await program.parseAsync(['node', 'lh', 'verify', ...args]);
};
it('rejects evidence with both file and inline content', async () => {
await expect(
run([
'evidence',
'upload',
'--check',
'result-1',
'--type',
'text',
'--file',
'artifact.txt',
'--content',
'inline payload',
]),
).rejects.toThrow('process.exit 1');
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockGetTrpcClient).not.toHaveBeenCalled();
});
});
+434 -7
View File
@@ -1,9 +1,13 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { confirm, outputJson, printTable, timeAgo, truncate } from '../utils/format';
import { log } from '../utils/logger';
import { uploadLocalFile } from '../utils/uploadLocalFile';
// ── Helpers ────────────────────────────────────────────────
@@ -32,6 +36,36 @@ function assertEnum<T extends string>(value: T | undefined, allowed: T[], flag:
}
}
type Verdict = 'failed' | 'passed' | 'uncertain';
type EvidenceType = 'dom_snapshot' | 'gif' | 'screenshot' | 'text' | 'transcript' | 'video';
/** Map a free-form case/summary result token onto the verify verdict vocabulary. */
function toVerdict(raw: unknown): Verdict {
const s = String(raw ?? '').toLowerCase();
if (['pass', 'passed', 'ok', 'success'].includes(s)) return 'passed';
if (['fail', 'failed', 'error'].includes(s)) return 'failed';
return 'uncertain'; // partial / blocked / skipped / pending / unknown
}
/** Pick an evidence medium from a file extension. */
function evidenceTypeForFile(file: string): EvidenceType {
const ext = path.extname(file).toLowerCase().slice(1);
if (ext === 'gif') return 'gif';
if (['png', 'jpg', 'jpeg', 'webp', 'svg', 'bmp'].includes(ext)) return 'screenshot';
if (['mp4', 'webm', 'mov', 'm4v'].includes(ext)) return 'video';
if (['html', 'htm'].includes(ext)) return 'dom_snapshot';
return 'text';
}
/** Normalize a case's `evidence` field (string | string[] | {path}[]) to path strings. */
function evidencePaths(evidence: unknown): string[] {
if (!evidence) return [];
const arr = Array.isArray(evidence) ? evidence : [evidence];
return arr
.map((e) => (typeof e === 'string' ? e : (e?.path ?? e?.file)))
.filter((p): p is string => typeof p === 'string' && p.length > 0);
}
// ── Command Registration ───────────────────────────────────
export function registerVerifyCommand(program: Command) {
@@ -368,9 +402,9 @@ export function registerVerifyCommand(program: Command) {
console.log(`${pc.green('✓')} Skipped verification for run ${pc.bold(operationId)}`);
});
// ════════════ run / results ════════════
// ════════════ execute (agent path) ════════════
verify
.command('run <operationId>')
.command('execute <operationId>')
.description('Execute the confirmed plan against a deliverable (LLM judge)')
.requiredOption('--goal <goal>', "The run's task")
.requiredOption('--deliverable <text>', 'The output to judge')
@@ -406,13 +440,147 @@ export function registerVerifyCommand(program: Command) {
},
);
verify
.command('results <operationId>')
.description('List check results for a run')
// ════════════ run (verification session entity) ════════════
const run = verify.command('run').description('Verification sessions (verify_runs)');
run
.command('create')
.description('Create a standalone verification session')
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
.option('--operation <id>', 'Link to an existing Agent Run')
.option('--title <title>', 'Session title')
.option('--goal <goal>', 'Goal/task being verified')
.option('--json [fields]', 'Output JSON')
.action(async (operationId: string, options: { json?: boolean | string }) => {
.action(
async (options: {
goal?: string;
json?: boolean | string;
operation?: string;
source?: string;
title?: string;
}) => {
const client = await getTrpcClient();
const created = await client.verify.createRun.mutate({
goal: options.goal,
operationId: options.operation,
source: options.source as any,
title: options.title,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Created run ${pc.bold(created.id)}`);
},
);
run
.command('list')
.description('List recent verification sessions')
.option('--json [fields]', 'Output JSON')
.action(async (options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const results = await client.verify.listResults.query({ operationId });
const runs = await client.verify.listRuns.query();
if (options.json !== undefined) {
outputJson(runs, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (runs.length === 0) return void console.log('No runs found.');
printTable(
runs.map((r: any) => [
r.id,
truncate(r.title || '', 40),
r.source,
r.status ?? '',
r.operationId ? 'agent' : 'standalone',
r.createdAt ? timeAgo(r.createdAt) : '',
]),
['ID', 'TITLE', 'SOURCE', 'STATUS', 'KIND', 'CREATED'],
);
});
run
.command('get <runId>')
.description('Show a verification session')
.option('--json [fields]', 'Output JSON')
.action(async (runId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getRun.query({ verifyRunId: runId });
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!item) return void console.log('Run not found.');
console.log(JSON.stringify(item, null, 2));
});
// ════════════ result (check result entity) ════════════
const result = verify.command('result').description('Check results (verify_check_results)');
result
.command('ingest')
.description('Upsert one check result by (run, checkItemId) from a supplied verdict')
.requiredOption('--run <verifyRunId>', 'Target session id')
.requiredOption('--check <checkItemId>', 'Stable check item id within the session')
.requiredOption('--verdict <verdict>', 'passed|failed|uncertain')
.option('--title <title>', 'Check title')
.option('--index <n>', 'Display index')
.option('--confidence <n>', '0-1 confidence')
.option('--status <status>', 'pending|running|passed|failed|skipped (derived from verdict)')
.option('--evidence <text>', 'Key observation (stored as Toulmin evidence)')
.option('--suggestion <text>', 'Remediation hint')
.option('--soft', 'Non-blocking (required=false); defaults to blocking')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
check: string;
confidence?: string;
evidence?: string;
index?: string;
json?: boolean | string;
run: string;
soft?: boolean;
status?: string;
suggestion?: string;
title?: string;
verdict: string;
}) => {
const client = await getTrpcClient();
const created = await client.verify.ingestResult.mutate({
checkItemId: options.check,
checkItemIndex: options.index ? Number.parseInt(options.index, 10) : undefined,
checkItemTitle: options.title,
confidence: options.confidence ? Number.parseFloat(options.confidence) : undefined,
required: options.soft ? false : undefined,
status: options.status as any,
suggestion: options.suggestion,
toulmin: options.evidence ? { evidence: options.evidence } : undefined,
verdict: options.verdict as any,
verifyRunId: options.run,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Result ${pc.bold(created.id)} (${created.verdict})`);
},
);
result
.command('list')
.description('List check results — by session (--run) or by Agent Run (--operation)')
.option('--run <verifyRunId>', 'List by verification session')
.option('--operation <operationId>', 'List by Agent Run')
.option('--json [fields]', 'Output JSON')
.action(async (options: { json?: boolean | string; operation?: string; run?: string }) => {
if (!options.run && !options.operation) {
log.error('Provide either --run or --operation');
process.exit(1);
}
const client = await getTrpcClient();
const results = options.run
? await client.verify.listResultsByRun.query({ verifyRunId: options.run })
: await client.verify.listResults.query({ operationId: options.operation! });
if (options.json !== undefined) {
outputJson(results, typeof options.json === 'string' ? options.json : undefined);
return;
@@ -421,6 +589,143 @@ export function registerVerifyCommand(program: Command) {
printResults(results);
});
// ════════════ evidence (artifact entity) ════════════
const evidence = verify.command('evidence').description('Evidence artifacts (verify_evidence)');
evidence
.command('upload')
.description('Attach an evidence artifact (file or inline text) to a check result')
.requiredOption('--check <checkResultId>', 'Target check result id')
.requiredOption('--type <type>', 'screenshot|gif|video|text|dom_snapshot|transcript')
.option('--file <path>', 'Local file to upload as the artifact')
.option('--content <text>', 'Inline text payload (instead of a file)')
.option('--by <capturedBy>', 'agent-browser|cdp|cli|program|llm_judge', 'cli')
.option('--desc <text>', 'Human-readable caption')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
by?: string;
check: string;
content?: string;
desc?: string;
file?: string;
json?: boolean | string;
type: string;
}) => {
if (Boolean(options.file) === Boolean(options.content)) {
log.error('Provide exactly one of --file or --content');
process.exit(1);
}
const client = await getTrpcClient();
let fileId: string | undefined;
if (options.file) {
const uploaded = await uploadLocalFile(client, options.file);
fileId = uploaded.id;
}
const ev = await client.verify.uploadEvidence.mutate({
capturedBy: options.by as any,
checkResultId: options.check,
content: options.content,
description: options.desc,
fileId,
type: options.type as any,
});
if (options.json !== undefined) {
outputJson(ev, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(
`${pc.green('✓')} Evidence ${pc.bold(ev.id)}${fileId ? ` (file ${fileId})` : ''}`,
);
},
);
evidence
.command('list <checkResultId>')
.description('List evidence for a check result')
.option('--json [fields]', 'Output JSON')
.action(async (checkResultId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const rows = await client.verify.listEvidence.query({ checkResultId });
if (options.json !== undefined) {
outputJson(rows, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (rows.length === 0) return void console.log('No evidence.');
printTable(
rows.map((e: any) => [
e.id,
e.type,
e.capturedBy ?? '',
e.fileId ? 'file' : 'inline',
truncate(e.description || '', 40),
]),
['ID', 'TYPE', 'BY', 'PAYLOAD', 'DESC'],
);
});
// ════════════ report (narrative entity) ════════════
const report = verify.command('report').description('Verification reports (verify_reports)');
report
.command('upsert')
.description('Write (overwrite) the report for a session')
.requiredOption('--run <verifyRunId>', 'Target session id')
.option('--verdict <verdict>', 'passed|failed|uncertain')
.option('--summary <text>', 'Short summary')
.option('--content <markdown>', 'Full markdown body')
.option('--total <n>', 'Total checks')
.option('--passed <n>', 'Passed checks')
.option('--failed <n>', 'Failed checks')
.option('--uncertain <n>', 'Uncertain checks')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
content?: string;
failed?: string;
json?: boolean | string;
passed?: string;
run: string;
summary?: string;
total?: string;
uncertain?: string;
verdict?: string;
}) => {
const num = (s?: string) => (s === undefined ? undefined : Number.parseInt(s, 10));
const client = await getTrpcClient();
const created = await client.verify.upsertReport.mutate({
content: options.content,
failedChecks: num(options.failed),
passedChecks: num(options.passed),
summary: options.summary,
totalChecks: num(options.total),
uncertainChecks: num(options.uncertain),
verdict: options.verdict as any,
verifyRunId: options.run,
});
if (options.json !== undefined) {
outputJson(created, typeof options.json === 'string' ? options.json : undefined);
return;
}
console.log(`${pc.green('✓')} Report ${pc.bold(created.id)} (${created.verdict ?? '—'})`);
},
);
report
.command('get <runId>')
.description('Show the report for a session')
.option('--json [fields]', 'Output JSON')
.action(async (runId: string, options: { json?: boolean | string }) => {
const client = await getTrpcClient();
const item = await client.verify.getReport.query({ verifyRunId: runId });
if (options.json !== undefined) {
outputJson(item, typeof options.json === 'string' ? options.json : undefined);
return;
}
if (!item) return void console.log('No report.');
console.log(JSON.stringify(item, null, 2));
});
// ════════════ feedback ════════════
verify
.command('decision <resultId> <decision>')
@@ -431,6 +736,128 @@ export function registerVerifyCommand(program: Command) {
await client.verify.submitDecision.mutate({ decision, resultId });
console.log(`${pc.green('✓')} Recorded ${pc.bold(decision)} on result ${pc.bold(resultId)}`);
});
// ════════════ ingest (aggregate convenience over the atomic commands) ════════════
verify
.command('ingest-report <reportDir>')
.description(
'Ingest a local agent-testing report (result.json + report.md + assets) as a verify session',
)
.option('--source <source>', 'agent | agent-testing', 'agent-testing')
.option('--operation <id>', 'Link the session to an existing Agent Run')
.option('--title <title>', 'Override the session title')
.option('--goal <goal>', 'The goal/task being verified')
.option('--open', 'Print the in-app URL to open the report')
.option('--json [fields]', 'Output JSON')
.action(
async (
reportDir: string,
options: {
goal?: string;
json?: boolean | string;
open?: boolean;
operation?: string;
source?: string;
title?: string;
},
) => {
const dir = path.resolve(reportDir);
const resultPath = path.join(dir, 'result.json');
if (!existsSync(resultPath)) {
log.error(`result.json not found in ${dir}`);
process.exit(1);
}
let result: any;
try {
result = JSON.parse(readFileSync(resultPath, 'utf8'));
} catch {
log.error('result.json is not valid JSON');
process.exit(1);
}
const cases: any[] = Array.isArray(result.cases) ? result.cases : [];
const summary = result.summary ?? {};
const reportMdPath = path.join(dir, 'report.md');
const content = existsSync(reportMdPath) ? readFileSync(reportMdPath, 'utf8') : undefined;
const client = await getTrpcClient();
// 1. Create the verification session.
const run = await client.verify.createRun.mutate({
goal: options.goal,
operationId: options.operation,
source: options.source as any,
title: options.title ?? result.title,
});
// 2. Ingest each case as a check result + its evidence.
let uploaded = 0;
for (const [index, c] of cases.entries()) {
const checkItemId = String(c.id ?? c.checkItemId ?? `case-${index + 1}`);
const verdict = toVerdict(c.result ?? c.status ?? c.verdict);
const observation = c.keyObservation ?? c.observation ?? c.note;
const checkResult = await client.verify.ingestResult.mutate({
checkItemId,
checkItemIndex: index,
checkItemTitle: c.name ?? c.case ?? c.title ?? checkItemId,
required: c.required ?? true,
// The case's key observation is recorded as Toulmin evidence; a real
// remediation hint (if the report provides one) goes to `suggestion`.
suggestion: typeof c.suggestion === 'string' ? c.suggestion : undefined,
toulmin: typeof observation === 'string' ? { evidence: observation } : undefined,
verdict,
verifierType: 'agent',
verifyRunId: run.id,
});
for (const rel of evidencePaths(c.evidence)) {
const abs = path.isAbsolute(rel) ? rel : path.join(dir, rel);
if (!existsSync(abs)) {
log.warn(`evidence not found, skipping: ${rel}`);
continue;
}
const file = await uploadLocalFile(client, abs);
await client.verify.uploadEvidence.mutate({
capturedBy: 'cli',
checkResultId: checkResult.id,
description: c.name ?? path.basename(abs),
fileId: file.id,
type: evidenceTypeForFile(abs),
});
uploaded += 1;
}
}
// 3. Write the report (full markdown + stats snapshot).
await client.verify.upsertReport.mutate({
content,
failedChecks: summary.failed,
passedChecks: summary.passed,
summary: typeof summary.note === 'string' ? summary.note : undefined,
totalChecks: summary.total ?? cases.length,
uncertainChecks: (summary.blocked ?? 0) + (summary.uncertain ?? 0) || undefined,
verdict: summary.verdict ? toVerdict(summary.verdict) : undefined,
verifyRunId: run.id,
});
if (options.json !== undefined) {
outputJson(
{ cases: cases.length, evidence: uploaded, verifyRunId: run.id },
typeof options.json === 'string' ? options.json : undefined,
);
return;
}
console.log(
`${pc.green('✓')} Ingested ${pc.bold(String(cases.length))} case(s), ${pc.bold(String(uploaded))} evidence file(s)`,
);
console.log(`${pc.bold('verifyRunId')}: ${run.id}`);
if (options.open) {
console.log(`${pc.bold('open')}: /verify/${run.id}`);
}
},
);
}
function printResults(results: any[]): void {
+5
View File
@@ -10,6 +10,11 @@ export interface TaskEntry {
startedAt: string;
taskId: string;
topicId: string;
/**
* Workspace that owns the dispatched topic. Persisted so the cancel-time
* notify still scopes to the right workspace after the daemon restarts.
*/
workspaceId?: string;
}
function getRegistryPath(): string {
+42
View File
@@ -38,3 +38,45 @@ export async function registerDevice(
platform: process.platform,
});
}
type Auth = { serverUrl: string; token: string; tokenType: 'apiKey' | 'jwt' | 'serviceToken' };
/**
* Identity for a WORKSPACE device: derived from the workspaceId (namespaced) so
* the same physical machine enrolled into a workspace is a distinct device from
* its personal identity, and stable across reconnects.
*/
export function resolveWorkspaceDeviceIdentity(
workspaceId: string,
explicitDeviceId?: string,
): DeviceIdentity {
if (explicitDeviceId) return { deviceId: explicitDeviceId, identitySource: 'fallback' };
return deriveDeviceId(`workspace:${workspaceId}`);
}
/**
* Mint a workspace-device connect token (owner-only on the server). The returned
* token carries the `workspace_id` claim the gateway routes by.
*/
export async function mintWorkspaceConnectToken(
auth: Auth,
workspaceId: string,
): Promise<{ token: string; workspaceId: string }> {
const trpc = createLambdaClient(auth, workspaceId);
return trpc.device.mintWorkspaceConnectToken.mutate();
}
/** Register this machine as a device of the given workspace (owner-only). */
export async function registerWorkspaceDevice(
auth: Auth,
identity: DeviceIdentity,
workspaceId: string,
): Promise<void> {
const trpc = createLambdaClient(auth, workspaceId);
await trpc.device.registerWorkspaceDevice.mutate({
deviceId: identity.deviceId,
hostname: os.hostname(),
identitySource: identity.identitySource,
platform: process.platform,
});
}
+16
View File
@@ -0,0 +1,16 @@
import { createRequire } from 'node:module';
/**
* Single source of truth for this package's own metadata.
*
* Must live directly under `src/` (depth 1), the same depth as the bundled
* entry `dist/index.js`, so `../package.json` resolves to `@lobehub/cli`'s own
* package.json both when running from source (`bun src/index.ts`) and from the
* tsdown bundle (`dist/index.js`). A module one directory deeper would resolve
* the path outside the package once everything is bundled into a single file.
*/
const require = createRequire(import.meta.url);
const pkg = require('../package.json') as { name: string; version: string };
export const cliPackageName = pkg.name;
export const cliVersion = pkg.version;
+5 -7
View File
@@ -1,5 +1,3 @@
import { createRequire } from 'node:module';
import { Command } from 'commander';
import { registerAgentCommand } from './commands/agent';
@@ -33,11 +31,10 @@ import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUpdateCommand } from './commands/update';
import { registerUserCommand } from './commands/user';
import { registerVerifyCommand } from './commands/verify';
const require = createRequire(import.meta.url);
const { version } = require('../package.json');
import { cliVersion } from './pkg';
export function createProgram() {
const program = new Command();
@@ -45,7 +42,7 @@ export function createProgram() {
program
.name('lh')
.description('LobeHub CLI - manage and connect to LobeHub services')
.version(version);
.version(cliVersion);
registerLoginCommand(program);
registerLogoutCommand(program);
@@ -80,8 +77,9 @@ export function createProgram() {
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
registerUpdateCommand(program);
return program;
}
export { version as cliVersion };
export { cliPackageName, cliVersion } from './pkg';
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { getTrpcClient } from '../../api/client';
import { removeTask, saveTask } from '../../daemon/taskRegistry';
import { runHeteroTask } from '../heteroTask';
@@ -34,6 +35,8 @@ vi.mock('../../api/client', () => ({
}),
}));
const getTrpcClientMock = vi.mocked(getTrpcClient);
vi.mock('../../utils/logger', () => ({
log: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
@@ -248,4 +251,56 @@ describe('runHeteroTask (openclaw)', () => {
expect(removeTask).toHaveBeenCalledWith('task-1');
killSpy.mockRestore();
});
it('threads workspaceId into the saved task entry and the spawned child env', async () => {
const child = makeMockChild(6666);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-ws',
agentType: 'openclaw',
operationId: 'op-ws',
prompt: 'workspace dispatch',
taskId: 'task-ws',
topicId: 'topic-ws',
workspaceId: 'ws-42',
});
expect(saveTask).toHaveBeenCalledWith(expect.objectContaining({ workspaceId: 'ws-42' }));
const [, , spawnOpts] = spawnMock.mock.calls[0] as [
string,
string[],
{ env: NodeJS.ProcessEnv },
];
expect(spawnOpts.env.LOBEHUB_WORKSPACE_ID).toBe('ws-42');
});
it('passes workspaceId to getTrpcClient when the close handler auto-notifies', async () => {
const child = makeMockChild(7777);
spawnMock.mockReturnValue(child);
await runHeteroTask({
agentId: 'agent-ws',
agentType: 'openclaw',
operationId: 'op-ws-2',
prompt: 'ws prompt',
taskId: 'task-ws-2',
topicId: 'topic-ws-2',
workspaceId: 'ws-99',
});
getTrpcClientMock.mockClear();
// Abnormal exit triggers sendAutoNotify + sendDoneSignal — both must scope
// to the dispatching workspace or agentNotify resolves the topic in
// personal mode and 404s.
child._emit('close', 1, null);
// Await microtask drain so the close-handler promise chain settles.
await new Promise((r) => setImmediate(r));
expect(getTrpcClientMock.mock.calls.length).toBeGreaterThan(0);
for (const call of getTrpcClientMock.mock.calls) {
expect(call[0]).toBe('ws-99');
}
});
});
+35 -14
View File
@@ -57,6 +57,13 @@ export interface RunHeteroTaskParams {
prompt: string;
taskId: string;
topicId: string;
/**
* Workspace id seeded by the server when the dispatched topic lives in a
* workspace. Threaded into auto-notify calls (as `X-Workspace-Id`) and into
* the spawned child's `LOBEHUB_WORKSPACE_ID` env so its own `lh notify`
* shells inherit the same scope.
*/
workspaceId?: string;
}
export interface CancelHeteroTaskParams {
@@ -69,9 +76,10 @@ async function sendAutoNotify(
taskId: string,
text: string,
agentId?: string,
workspaceId?: string,
): Promise<void> {
try {
const client = await getTrpcClient();
const client = await getTrpcClient(workspaceId);
await client.agentNotify.notify.mutate({
agentId,
content: text,
@@ -90,9 +98,13 @@ async function sendAutoNotify(
* `sendAutoNotify` which writes an error message AND triggers completion via
* the `done` flag.
*/
async function sendDoneSignal(topicId: string, agentId?: string): Promise<void> {
async function sendDoneSignal(
topicId: string,
agentId?: string,
workspaceId?: string,
): Promise<void> {
try {
const client = await getTrpcClient();
const client = await getTrpcClient(workspaceId);
await client.agentNotify.notify.mutate({
agentId,
content: '',
@@ -138,9 +150,15 @@ function buildNotifyProtocol(lhPath: string, topicId: string): string {
}
export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = params;
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = params;
const workDir = cwd || process.cwd();
const lhPath = resolveLhPath();
// Propagate workspace scope into the spawned child so its own `lh notify`
// invocations (and any grandchildren it shells out) inherit the same scope
// via getTrpcClient → resolveWorkspaceId.
const childEnv: NodeJS.ProcessEnv = workspaceId
? { ...process.env, LOBEHUB_WORKSPACE_ID: workspaceId }
: { ...process.env };
if (agentType === 'openclaw') {
// openclaw agent --local is one-shot: each invocation processes one message and exits.
@@ -182,7 +200,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
{
cwd: workDir,
detached: true,
env: { ...process.env },
env: childEnv,
stdio: 'ignore',
},
);
@@ -201,6 +219,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
startedAt: new Date().toISOString(),
taskId,
topicId,
workspaceId,
});
log.info(`OpenClaw task started: taskId=${taskId} pid=${pid} agent=${openclawAgent}`);
@@ -216,12 +235,12 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
: `Task failed (exit code: ${code})`;
// Send error message first, THEN signal done (sequential).
// Fire-and-forget both, but ensure done is always sent even if notify fails.
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
);
} else {
// Clean exit — openclaw already sent its final message; just signal done.
void sendDoneSignal(topicId, agentId);
void sendDoneSignal(topicId, agentId, workspaceId);
}
});
@@ -253,7 +272,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
const child = spawn('hermes', hermesArgs, {
cwd: workDir,
detached: true,
env: { ...process.env },
env: childEnv,
stdio: ['ignore', 'pipe', 'ignore'],
});
@@ -269,6 +288,7 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
startedAt: new Date().toISOString(),
taskId,
topicId,
workspaceId,
});
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
@@ -284,8 +304,8 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
void sendAutoNotify(topicId, taskId, text, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
);
return;
}
@@ -298,11 +318,11 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
if (sessionId) saveHermesSessionId(topicId, sessionId);
if (response) {
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
void sendAutoNotify(topicId, taskId, response, agentId, workspaceId).finally(() =>
sendDoneSignal(topicId, agentId, workspaceId),
);
} else {
void sendDoneSignal(topicId, agentId);
void sendDoneSignal(topicId, agentId, workspaceId);
}
});
@@ -334,6 +354,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
taskId,
'Task already completed or cancelled',
entry.agentId,
entry.workspaceId,
);
}
@@ -77,6 +77,12 @@ interface PlatformTaskEntry {
operationId: string;
pid: number;
topicId: string;
/**
* Workspace that owns the dispatched topic — used at exit time so the
* cleanup notify still scopes to the workspace agentNotify resolves the
* topic in (the server seeds this via the `runHeteroTask` args).
*/
workspaceId?: string;
}
/**
@@ -524,6 +530,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: string;
taskId: string;
topicId: string;
workspaceId?: string;
},
);
return { content: json, state: safeJsonParse(json), success: true };
@@ -765,8 +772,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
prompt: string;
taskId: string;
topicId: string;
workspaceId?: string;
}): Promise<string> {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId, workspaceId } = args;
const workDir = cwd || process.cwd();
const [serverUrl, accessToken] = await Promise.all([
@@ -774,11 +782,15 @@ export default class GatewayConnectionCtr extends ControllerModule {
this.remoteServerConfigCtr.getAccessToken(),
]);
// Inject auth into child env so `lh notify` can authenticate without CLI config.
// Inject auth + workspace scope into child env so `lh notify` can
// authenticate AND target the same workspace as the dispatched topic
// (without LOBEHUB_WORKSPACE_ID, the CLI's notify falls back to personal
// mode and the workspace topic 404s).
const childEnv: NodeJS.ProcessEnv = {
...process.env,
...(accessToken && { LOBEHUB_JWT: accessToken }),
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
...(workspaceId && { LOBEHUB_WORKSPACE_ID: workspaceId }),
};
if (agentType === 'openclaw') {
@@ -823,7 +835,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (pid === undefined) throw new Error('Failed to get PID for openclaw process');
child.unref();
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
this.platformTasks.set(taskId, {
agentId,
agentType,
operationId,
pid,
topicId,
workspaceId,
});
child.on('close', (code, signal) => {
this.platformTasks.delete(taskId);
@@ -831,11 +850,31 @@ export default class GatewayConnectionCtr extends ControllerModule {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
void this.sendNotify({
agentId,
content: text,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
);
} else {
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
void this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
});
}
});
@@ -874,7 +913,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
child.unref();
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
this.platformTasks.set(taskId, {
agentId,
agentType,
operationId,
pid,
topicId,
workspaceId,
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
@@ -888,8 +934,21 @@ export default class GatewayConnectionCtr extends ControllerModule {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void this.sendNotify({ agentId, content: text, role: 'assistant', topicId }).finally(() =>
this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
void this.sendNotify({
agentId,
content: text,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
);
return;
}
@@ -902,11 +961,31 @@ export default class GatewayConnectionCtr extends ControllerModule {
if (sessionId) this.hermesSessionMap.set(topicId, sessionId);
if (response) {
void this.sendNotify({ agentId, content: response, role: 'assistant', topicId }).finally(
() => this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId }),
void this.sendNotify({
agentId,
content: response,
role: 'assistant',
topicId,
workspaceId,
}).finally(() =>
this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
}),
);
} else {
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
void this.sendNotify({
agentId,
content: '',
done: true,
role: 'assistant',
topicId,
workspaceId,
});
}
});
@@ -934,6 +1013,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
content: 'Task already completed or cancelled',
role: 'assistant',
topicId: entry.topicId,
workspaceId: entry.workspaceId,
});
}
@@ -951,6 +1031,12 @@ export default class GatewayConnectionCtr extends ControllerModule {
done?: boolean;
role: string;
topicId: string;
/**
* Workspace scope for the notify. When set, attaches `X-Workspace-Id` so
* agentNotify resolves the workspace-owned topic instead of falling back
* to personal mode (which would 404 the lookup).
*/
workspaceId?: string;
}): Promise<void> {
try {
const [serverUrl, token] = await Promise.all([
@@ -959,12 +1045,16 @@ export default class GatewayConnectionCtr extends ControllerModule {
]);
if (!serverUrl || !token) return;
const { workspaceId, ...body } = params;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Oidc-Auth': token,
};
if (workspaceId) headers['X-Workspace-Id'] = workspaceId;
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
body: JSON.stringify({ json: params }),
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': token,
},
body: JSON.stringify({ json: body }),
headers,
method: 'POST',
});
} catch {
+5
View File
@@ -3,6 +3,11 @@ import './pre-app-init';
import fixPath from 'fix-path';
import { App } from './core/App';
import { installProcessErrorHandlers } from './process-error-handlers';
// Guard the main process against transient network blips (Wi-Fi/VPN switch,
// system sleep) emitted by Electron's net stack as uncaught exceptions.
installProcessErrorHandlers();
const app = new App();
@@ -0,0 +1,83 @@
import { createLogger } from '@/utils/logger';
const logger = createLogger('main:process-error-handlers');
/**
* Transient Chromium network errors emitted by Electron's `net` stack
* (`SimpleURLLoaderWrapper`). These happen during normal operation — switching
* Wi-Fi / VPN, the machine sleeping, the network interface dropping — and are
* NOT application bugs. Electron emits them as an `error` event on the internal
* loader; when nothing is listening they bubble up as an `uncaughtException`
* and pop the "A JavaScript error occurred in the main process" dialog, even
* though the request layer already handles the failure via promise rejection.
*
* We swallow these specific cases so transient connectivity blips never crash
* the main process. Everything else is re-thrown to preserve normal crash
* visibility.
*
* @see https://github.com/electron/electron/issues/24948
*/
const TRANSIENT_NET_ERROR_CODES = new Set([
'ERR_NETWORK_CHANGED',
'ERR_NETWORK_IO_SUSPENDED',
'ERR_INTERNET_DISCONNECTED',
'ERR_NETWORK_ACCESS_DENIED',
'ERR_CONNECTION_RESET',
'ERR_CONNECTION_ABORTED',
'ERR_CONNECTION_CLOSED',
'ERR_NAME_NOT_RESOLVED',
'ERR_TIMED_OUT',
]);
const isTransientNetError = (error: unknown): boolean => {
if (!error) return false;
const message = error instanceof Error ? error.message : String(error);
// Electron net errors are formatted as `net::ERR_XXX`.
const match = message.match(/net::(ERR_[A-Z_]+)/);
if (match && TRANSIENT_NET_ERROR_CODES.has(match[1])) return true;
// Belt-and-suspenders: these only ever originate from the net loader.
const stack = error instanceof Error ? (error.stack ?? '') : '';
return /net::ERR_/.test(message) && stack.includes('SimpleURLLoaderWrapper');
};
/**
* Install global guards for the Electron main process. Must be called as early
* as possible (before the rest of the app boots) so it catches errors from any
* module's top-level / async work.
*/
export const installProcessErrorHandlers = () => {
process.on('uncaughtException', (error) => {
if (isTransientNetError(error)) {
logger.warn('Ignoring transient network error in main process:', error.message);
return;
}
// Re-throw so genuine bugs still surface as a crash instead of being
// silently swallowed by this handler.
logger.error('Uncaught exception in main process:', error);
throw error;
});
process.on('unhandledRejection', (reason) => {
if (isTransientNetError(reason)) {
logger.warn(
'Ignoring transient network rejection in main process:',
reason instanceof Error ? reason.message : String(reason),
);
return;
}
// Installing this listener overrides Node's default
// `--unhandled-rejections=throw`, so we must re-throw to preserve the fatal
// behavior. Throwing here surfaces as an uncaughtException (handled above,
// which also re-throws non-transient errors), instead of leaving the app
// partially booted on a genuine failure (e.g. an unawaited app.bootstrap()).
logger.error('Unhandled rejection in main process:', reason);
throw reason;
});
logger.info('Process error handlers installed');
};
@@ -9,7 +9,6 @@
* - Gets model capabilities from provided function
* - No dependency on frontend stores (useToolStore, useAgentStore, etc.)
*/
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
import { KnowledgeBaseManifest } from '@lobechat/builtin-tool-knowledge-base';
import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system';
@@ -135,7 +134,6 @@ export const createServerAgentToolsEngine = (
disableLocalSystem = false,
executionPlan,
globalMemoryEnabled = false,
hasAgentDocuments = false,
hasEnabledKnowledgeBases = false,
isBotConversation = false,
model,
@@ -247,7 +245,6 @@ export const createServerAgentToolsEngine = (
hasDeviceProxy &&
!deviceContext?.autoActivated &&
!deviceContext?.boundDeviceId,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
[WebBrowsingManifest.identifier]: isSearchEnabled,
};
@@ -101,8 +101,6 @@ export interface ServerCreateAgentToolsEngineParams {
executionPlan?: ExecutionPlan;
/** Whether the user's global memory setting is enabled */
globalMemoryEnabled?: boolean;
/** Whether agent has agent documents */
hasAgentDocuments?: boolean;
/** Whether agent has enabled knowledge bases */
hasEnabledKnowledgeBases?: boolean;
/** Whether the request originates from a bot conversation (auto-enables message tool) */
@@ -348,6 +348,72 @@ describe('Task Router Integration', () => {
});
});
describe('verify config', () => {
it('should set and retrieve verify config (round-trip)', async () => {
const task = await caller.create({ instruction: 'Test' });
await caller.updateVerifyConfig({
id: task.data.id,
verify: {
enabled: true,
maxIterations: 3,
verifierAgentId: 'agt_codex',
verifyCriteriaIds: ['c1', 'c2'],
verifyRubricId: 'rub_1',
},
});
const verify = await caller.getVerifyConfig({ id: task.data.id });
expect(verify.data).toEqual({
enabled: true,
maxIterations: 3,
verifierAgentId: 'agt_codex',
verifyCriteriaIds: ['c1', 'c2'],
verifyRubricId: 'rub_1',
});
// task.detail must surface the saved verify config (not leave it undefined).
const detail = await caller.detail({ id: task.data.id });
expect(detail.data!.verify).toEqual({
enabled: true,
maxIterations: 3,
verifierAgentId: 'agt_codex',
verifyCriteriaIds: ['c1', 'c2'],
verifyRubricId: 'rub_1',
});
});
it('should clear a saved field when passed null', async () => {
const task = await caller.create({ instruction: 'Test' });
await caller.updateVerifyConfig({
id: task.data.id,
verify: { enabled: true, verifierAgentId: 'agt_codex', verifyRubricId: 'rub_1' },
});
// Switch the verifier back to default + drop the rubric.
await caller.updateVerifyConfig({
id: task.data.id,
verify: { verifierAgentId: null, verifyRubricId: null },
});
const verify = await caller.getVerifyConfig({ id: task.data.id });
expect(verify.data).toEqual({ enabled: true });
});
it('getVerifyConfig falls back to the legacy review key', async () => {
const task = await caller.create({ instruction: 'Test' });
await caller.updateReview({
id: task.data.id,
review: { autoRetry: true, enabled: true, maxIterations: 4, rubrics: [] },
});
const verify = await caller.getVerifyConfig({ id: task.data.id });
expect(verify.data).toEqual({ enabled: true, maxIterations: 4 });
});
});
describe('run idempotency', () => {
it('should reject run when a topic is already running', async () => {
const task = await caller.create({
@@ -0,0 +1,252 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { verifyRouter } from '@/server/routers/lambda/verify';
import { FileService } from '@/server/services/file';
const modelMocks = vi.hoisted(() => ({
findRunById: vi.fn(),
findResultById: vi.fn(),
getFullFileUrl: vi.fn(),
getServerDB: vi.fn(async () => ({})),
upsertByCheckItem: vi.fn(),
}));
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: modelMocks.getServerDB,
}));
vi.mock('@/database/models/verifyCheckResult', () => ({
VerifyCheckResultModel: vi.fn(() => ({
findById: modelMocks.findResultById,
upsertByCheckItem: modelMocks.upsertByCheckItem,
})),
}));
vi.mock('@/database/models/verifyRun', () => ({
VerifyRunModel: vi.fn(() => ({
findById: modelMocks.findRunById,
})),
}));
vi.mock('@/server/services/verify', () => ({
VerifyExecutorService: class VerifyExecutorService {},
VerifyFeedbackService: class VerifyFeedbackService {},
VerifyPlanGeneratorService: class VerifyPlanGeneratorService {},
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn(() => ({
getFullFileUrl: modelMocks.getFullFileUrl,
})),
}));
const createCaller = () => verifyRouter.createCaller({ userId: 'verify-router-test-user' } as any);
const createPublicCaller = () => verifyRouter.createCaller({} as any);
const selectRows = <T>(rows: T[]) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(async () => rows),
})),
})),
});
describe('verifyRouter', () => {
beforeEach(() => {
vi.clearAllMocks();
modelMocks.getServerDB.mockResolvedValue({});
vi.mocked(FileService).mockImplementation(
() =>
({
getFullFileUrl: modelMocks.getFullFileUrl,
}) as any,
);
});
describe('ingestResult', () => {
it("rejects a run outside the caller's scope before upserting the result", async () => {
modelMocks.findRunById.mockResolvedValueOnce(undefined);
await expect(
createCaller().ingestResult({
checkItemId: 'shared-check',
checkItemTitle: 'attacker update',
status: 'passed',
verdict: 'passed',
verifyRunId: 'other-user-run',
}),
).rejects.toThrow('Verification run not found');
expect(modelMocks.findRunById).toHaveBeenCalledWith('other-user-run');
expect(modelMocks.upsertByCheckItem).not.toHaveBeenCalled();
});
});
describe('uploadEvidence', () => {
it('rejects evidence with both inline content and fileId', async () => {
await expect(
createCaller().uploadEvidence({
checkResultId: 'result-1',
content: 'inline payload',
fileId: 'files-1',
type: 'text',
}),
).rejects.toThrow('Provide exactly one of `content` or `fileId`.');
});
it('rejects evidence without inline content or fileId', async () => {
await expect(
createCaller().uploadEvidence({
checkResultId: 'result-1',
type: 'text',
}),
).rejects.toThrow('Provide exactly one of `content` or `fileId`.');
});
});
describe('getReportBundle', () => {
it('reads a standalone report without a logged-in user', async () => {
const run = {
goal: 'Ship a working page',
id: 'run-1',
title: 'Run report',
userId: 'owner-user',
workspaceId: null,
};
const report = {
id: 'report-1',
totalChecks: 1,
verdict: 'passed',
verifyRunId: 'run-1',
};
const result = {
checkItemId: 'check-1',
checkItemIndex: 0,
checkItemTitle: 'Page renders',
id: 'result-1',
required: true,
status: 'passed',
verdict: 'passed',
verifyRunId: 'run-1',
};
const evidence = {
checkResultId: 'result-1',
content: null,
description: 'Homepage screenshot',
fileId: 'file-1',
id: 'evidence-1',
type: 'screenshot',
};
const serverDB = {
query: {
files: {
findFirst: vi.fn(async () => ({ id: 'file-1', url: 'verify/evidence.png' })),
},
verifyReports: {
findFirst: vi.fn(async () => report),
},
verifyRuns: {
findFirst: vi.fn(async () => run),
},
},
select: vi
.fn()
.mockReturnValueOnce(selectRows([result]))
.mockReturnValueOnce(selectRows([evidence])),
};
modelMocks.getServerDB.mockResolvedValue(serverDB);
modelMocks.getFullFileUrl.mockResolvedValue('https://cdn.example.com/verify/evidence.png');
const bundle = await createPublicCaller().getReportBundle({ verifyRunId: 'run-1' });
expect(bundle).toMatchObject({
report,
results: [
{
checkItemId: 'check-1',
evidence: [
{
fileId: 'file-1',
fileUrl: 'https://cdn.example.com/verify/evidence.png',
},
],
},
],
run,
});
expect(modelMocks.findRunById).not.toHaveBeenCalled();
});
it('keeps returning the bundle when file URL resolution is unavailable', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
vi.mocked(FileService).mockImplementation(() => {
throw new Error('S3 env missing');
});
const run = {
goal: 'Ship a working page',
id: 'run-1',
title: 'Run report',
userId: 'owner-user',
workspaceId: null,
};
const result = {
checkItemId: 'check-1',
checkItemIndex: 0,
checkItemTitle: 'Page renders',
id: 'result-1',
required: true,
status: 'passed',
verdict: 'passed',
verifyRunId: 'run-1',
};
const evidence = {
checkResultId: 'result-1',
content: null,
description: 'Homepage screenshot',
fileId: 'file-1',
id: 'evidence-1',
type: 'screenshot',
};
const serverDB = {
query: {
files: {
findFirst: vi.fn(async () => ({ id: 'file-1', url: 'verify/evidence.png' })),
},
verifyReports: {
findFirst: vi.fn(async () => null),
},
verifyRuns: {
findFirst: vi.fn(async () => run),
},
},
select: vi
.fn()
.mockReturnValueOnce(selectRows([result]))
.mockReturnValueOnce(selectRows([evidence])),
};
modelMocks.getServerDB.mockResolvedValue(serverDB);
const bundle = await createPublicCaller().getReportBundle({ verifyRunId: 'run-1' });
expect(bundle).toMatchObject({
results: [
{
evidence: [
{
fileId: 'file-1',
fileUrl: null,
},
],
},
],
run,
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[verify:getReportBundle:resolveFileUrl]',
expect.any(Error),
);
consoleErrorSpy.mockRestore();
});
});
});
+198 -67
View File
@@ -1,11 +1,16 @@
import { REMOTE_HETEROGENEOUS_AGENT_CONFIGS } from '@lobechat/heterogeneous-agents';
import type { DeviceChannel, DeviceListItem, WorkingDirEntry } from '@lobechat/types';
import type { DeviceChannel, DeviceListItem, DeviceScope, WorkingDirEntry } from '@lobechat/types';
import { z } from 'zod';
import {
wsCompatProcedure,
wsOwnerProcedure,
} from '@/business/server/trpc-middlewares/workspaceAuth';
import { DeviceModel } from '@/database/models/device';
import { authedProcedure, router } from '@/libs/trpc/lambda';
import { router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { deviceGateway } from '@/server/services/deviceGateway';
import { signWorkspaceDeviceToken } from '@/libs/trpc/utils/internalJwt';
import { type DeviceAttachment, deviceGateway } from '@/server/services/deviceGateway';
import { preserveWorkspaceCache } from './deviceWorkingDirs';
import { assertWorkspaceRootApproved } from './deviceWorkspaceGuard';
@@ -22,11 +27,19 @@ const remotePlatformEnum = z.enum(
const CAPABILITY_TIMEOUT_MS = 5_000;
const PROFILE_TIMEOUT_MS = 5_000;
const deviceProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
// Workspace-aware (compat): with an `X-Workspace-Id` header the device list also
// surfaces the workspace's shared devices; without it, the personal path is
// unchanged (`ctx.workspaceId === undefined`).
const deviceProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const wsId = ctx.workspaceId ?? undefined;
return opts.next({
ctx: { deviceModel: new DeviceModel(ctx.serverDB, ctx.userId), userId: ctx.userId },
ctx: {
deviceModel: new DeviceModel(ctx.serverDB, ctx.userId, wsId),
userId: ctx.userId,
workspaceId: wsId,
},
});
});
@@ -62,7 +75,7 @@ export const deviceRouter = router({
)
.query(async ({ ctx, input }) => {
const result = await deviceGateway.executeToolCall(
{ deviceId: input.deviceId, userId: ctx.userId },
{ deviceId: input.deviceId, userId: ctx.userId, workspaceId: ctx.workspaceId },
{
apiName: 'checkPlatformCapability',
arguments: JSON.stringify({ platform: input.platform }),
@@ -99,6 +112,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -111,6 +125,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -122,6 +137,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -133,6 +149,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -149,6 +166,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? [];
}),
@@ -170,6 +188,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? [];
}),
@@ -194,6 +213,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -217,6 +237,7 @@ export const deviceRouter = router({
path: input.path,
to: input.to,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -238,6 +259,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -252,6 +274,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -266,6 +289,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -281,6 +305,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -297,6 +322,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -312,6 +338,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? [];
}),
@@ -328,6 +355,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -344,6 +372,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
scope: input.scope,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -365,6 +394,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
@@ -381,6 +411,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
scope: input.scope,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -397,6 +428,7 @@ export const deviceRouter = router({
filePath: input.filePath,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
}),
),
@@ -415,6 +447,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
items: input.items,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
@@ -436,6 +469,7 @@ export const deviceRouter = router({
newName: input.newName,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
@@ -457,6 +491,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workingDirectory: input.workingDirectory,
});
}),
@@ -474,6 +509,7 @@ export const deviceRouter = router({
deviceId: input.deviceId,
path: input.path,
userId: ctx.userId,
workspaceId: ctx.workspaceId,
});
return result ?? null;
}),
@@ -492,7 +528,7 @@ export const deviceRouter = router({
)
.query(async ({ ctx, input }) => {
const result = await deviceGateway.executeToolCall(
{ deviceId: input.deviceId, userId: ctx.userId },
{ deviceId: input.deviceId, userId: ctx.userId, workspaceId: ctx.workspaceId },
{
apiName: 'getAgentProfile',
arguments: JSON.stringify({ platform: input.platform }),
@@ -517,7 +553,7 @@ export const deviceRouter = router({
getDeviceSystemInfo: deviceProcedure
.input(z.object({ deviceId: z.string() }))
.query(async ({ ctx, input }) => {
return deviceGateway.queryDeviceSystemInfo(ctx.userId, input.deviceId);
return deviceGateway.queryDeviceSystemInfo(ctx.userId, input.deviceId, ctx.workspaceId);
}),
/**
@@ -533,76 +569,171 @@ export const deviceRouter = router({
* a currently-reachable device during rollout.
*/
listDevices: deviceProcedure.query(async ({ ctx }): Promise<DeviceListItem[]> => {
const [registered, onlineList] = await Promise.all([
ctx.deviceModel.query(),
const wsId = ctx.workspaceId;
// Personal devices resolve under the user principal; workspace devices under
// the `workspace:<id>` principal (a separate gateway pool). Fetch both.
const [personalRows, workspaceRows, personalOnline, workspaceOnline] = await Promise.all([
ctx.deviceModel.queryPersonal(),
wsId ? ctx.deviceModel.queryWorkspaceDevices() : Promise.resolve([]),
deviceGateway.queryDeviceList(ctx.userId),
wsId ? deviceGateway.queryDeviceList(ctx.userId, wsId) : Promise.resolve([]),
]);
// The gateway already groups by device, exposing live sessions as nested
// `channels`. Flatten them into the UI-facing channel shape; fall back to a
// single synthetic channel for a legacy gateway that omits the field.
const channelsByDevice = new Map<string, DeviceChannel[]>();
for (const conn of onlineList) {
const channels: DeviceChannel[] =
conn.channels && conn.channels.length > 0
? conn.channels.map((c) => ({
channel: c.channel ?? null,
connectedAt: c.connectedAt,
// `channels`. Flatten one connection into the UI-facing channel shape; fall
// back to a single synthetic channel for a legacy gateway that omits the field.
const toChannels = (conn: DeviceAttachment): DeviceChannel[] =>
conn.channels && conn.channels.length > 0
? conn.channels.map((c) => ({
channel: c.channel ?? null,
connectedAt: c.connectedAt,
hostname: conn.hostname ?? null,
platform: conn.platform ?? null,
}))
: [
{
channel: null,
connectedAt: conn.lastSeen,
hostname: conn.hostname ?? null,
platform: conn.platform ?? null,
}))
: [
{
channel: null,
connectedAt: conn.lastSeen,
hostname: conn.hostname ?? null,
platform: conn.platform ?? null,
},
];
channelsByDevice.set(conn.deviceId, channels);
}
},
];
const seen = new Set<string>();
// Merge a DB-registered set with its live gateway pool into the UI shape.
// `scope` tags the group; deviceIds never collide across pools (a personal id
// is derived from userId, a workspace id from workspaceId).
const buildItems = (
rows: Awaited<ReturnType<typeof ctx.deviceModel.queryPersonal>>,
onlineList: DeviceAttachment[],
scope: DeviceScope,
): DeviceListItem[] => {
const channelsByDevice = new Map<string, DeviceChannel[]>();
for (const conn of onlineList) channelsByDevice.set(conn.deviceId, toChannels(conn));
const fromDb = registered.map((d) => {
seen.add(d.deviceId);
const channels = channelsByDevice.get(d.deviceId) ?? [];
const live = channels[0];
return {
channels,
defaultCwd: d.defaultCwd,
deviceId: d.deviceId,
friendlyName: d.friendlyName,
hostname: d.hostname ?? live?.hostname ?? null,
identitySource: d.identitySource,
lastSeen: d.lastSeenAt.toISOString(),
online: channels.length > 0,
platform: d.platform ?? live?.platform ?? null,
registered: true,
workingDirs: d.workingDirs ?? [],
};
});
const seen = new Set<string>();
const fromDb = rows.map((d): DeviceListItem => {
seen.add(d.deviceId);
const channels = channelsByDevice.get(d.deviceId) ?? [];
const live = channels[0];
return {
channels,
defaultCwd: d.defaultCwd,
deviceId: d.deviceId,
friendlyName: d.friendlyName,
hostname: d.hostname ?? live?.hostname ?? null,
identitySource: d.identitySource,
lastSeen: d.lastSeenAt.toISOString(),
online: channels.length > 0,
platform: d.platform ?? live?.platform ?? null,
registered: true,
scope,
workingDirs: d.workingDirs ?? [],
};
});
// Online but not yet persisted — transient until the client auto-registers.
const ghosts = [...channelsByDevice.entries()]
.filter(([deviceId]) => !seen.has(deviceId))
.map(([deviceId, channels]) => ({
channels,
defaultCwd: null,
deviceId,
friendlyName: null,
hostname: channels[0]?.hostname ?? null,
identitySource: null,
lastSeen: channels[0]?.connectedAt ?? new Date().toISOString(),
online: true,
platform: channels[0]?.platform ?? null,
registered: false,
workingDirs: [] as WorkingDirEntry[],
}));
// Online but not yet persisted — transient until the client auto-registers.
const ghosts = [...channelsByDevice.entries()]
.filter(([deviceId]) => !seen.has(deviceId))
.map(
([deviceId, channels]): DeviceListItem => ({
channels,
defaultCwd: null,
deviceId,
friendlyName: null,
hostname: channels[0]?.hostname ?? null,
identitySource: null,
lastSeen: channels[0]?.connectedAt ?? new Date().toISOString(),
online: true,
platform: channels[0]?.platform ?? null,
registered: false,
scope,
workingDirs: [] as WorkingDirEntry[],
}),
);
return [...fromDb, ...ghosts];
return [...fromDb, ...ghosts];
};
return [
...buildItems(personalRows, personalOnline, 'personal'),
...buildItems(workspaceRows, workspaceOnline, 'workspace'),
];
}),
/**
* Mint a short-lived connect token for enrolling a WORKSPACE-owned device.
* Owner-only (`wsOwnerProcedure`) — the server verifies the caller is an admin
* of the workspace, then signs a token carrying the `workspace_id` claim that
* the device gateway trusts to route the device to the `workspace:<id>`
* principal. The CLI (`lh connect --workspace`) / settings page use this.
*/
mintWorkspaceConnectToken: wsOwnerProcedure.mutation(async ({ ctx }) => {
const token = await signWorkspaceDeviceToken(ctx.workspaceId);
return { token, workspaceId: ctx.workspaceId };
}),
/**
* Enroll the calling machine as a device of the current workspace. Owner-only;
* stamps `workspace_id` so the row belongs to the workspace. Used by
* `lh connect --workspace` after minting the connect token.
*/
registerWorkspaceDevice: wsOwnerProcedure
.use(serverDatabase)
.input(
z.object({
deviceId: z.string().min(1).max(64),
hostname: z.string().nullable().optional(),
identitySource: z.enum(['machine-id', 'fallback']),
platform: z.string().max(20).nullable().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
return model.registerWorkspaceDevice({ ...input, workspaceId: ctx.workspaceId });
}),
/**
* Rename / set working dirs of a WORKSPACE device — scoped by `workspace_id`,
* owner-gated, so any workspace owner can manage it (not just the enroller).
* Mirrors {@link deviceRouter.updateDevice} but for the workspace pool.
*/
updateWorkspaceDevice: wsOwnerProcedure
.use(serverDatabase)
.input(
z.object({
defaultCwd: z.string().nullable().optional(),
deviceId: z.string(),
friendlyName: z.string().max(100).nullable().optional(),
workingDirs: z
.array(z.object({ path: z.string(), repoType: z.enum(['git', 'github']).optional() }))
.max(20)
.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
const { deviceId, workingDirs, ...value } = input;
const nextWorkingDirs = workingDirs
? preserveWorkspaceCache(
workingDirs,
(await model.findWorkspaceDeviceById(deviceId))?.workingDirs ?? [],
)
: undefined;
await model.updateWorkspaceDevice(deviceId, { ...value, workingDirs: nextWorkingDirs });
return { success: true };
}),
/** Remove a WORKSPACE device — scoped by `workspace_id`, owner-gated. */
removeWorkspaceDevice: wsOwnerProcedure
.use(serverDatabase)
.input(z.object({ deviceId: z.string() }))
.mutation(async ({ ctx, input }) => {
const model = new DeviceModel(ctx.serverDB, ctx.userId, ctx.workspaceId);
await model.deleteWorkspaceDevice(input.deviceId);
return { success: true };
}),
/**
* Auto-register the calling device (desktop after OIDC login / CLI on first
* `lh connect`). Upserts on (userId, deviceId); user-owned fields are
@@ -629,7 +760,7 @@ export const deviceRouter = router({
}),
status: deviceProcedure.query(async ({ ctx }) => {
return deviceGateway.queryDeviceStatus(ctx.userId);
return deviceGateway.queryDeviceStatus(ctx.userId, ctx.workspaceId);
}),
/** User-editable fields only — never the machine-reported identity columns. */
+56
View File
@@ -900,6 +900,62 @@ export const taskRouter = router({
}
}),
getVerifyConfig: taskProcedure.input(idInput).query(async ({ input, ctx }) => {
try {
const model = ctx.taskModel;
const task = await resolveOrThrow(model, input.id);
return { data: model.getVerifyConfig(task) || null, success: true };
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error('[task:getVerifyConfig]', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to get verify config',
});
}
}),
updateVerifyConfig: taskProcedureWrite
.input(
idInput.merge(
z.object({
// `.nullish()` lets callers clear a saved field: `null` removes it
// (JSON can't send `undefined`), omission leaves it untouched. See
// TaskModel.updateVerifyConfig.
verify: z.object({
enabled: z.boolean().nullish(),
maxIterations: z.number().min(1).max(10).nullish(),
verifierAgentId: z.string().nullish(),
verifyCriteriaIds: z.array(z.string()).nullish(),
verifyRubricId: z.string().nullish(),
}),
}),
),
)
.mutation(async ({ input, ctx }) => {
const { id, verify } = input;
try {
const model = ctx.taskModel;
const resolved = await resolveOrThrow(model, id);
const task = await model.updateVerifyConfig(resolved.id, verify);
if (!task) throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
return {
data: model.getVerifyConfig(task),
message: 'Verify config updated',
success: true,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
console.error('[task:updateVerifyConfig]', error);
throw new TRPCError({
cause: error,
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to update verify config',
});
}
}),
runReview: taskProcedureWrite
.input(
idInput.merge(
+297 -11
View File
@@ -1,13 +1,26 @@
import { TRPCError } from '@trpc/server';
import { asc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { wsCompatProcedure } from '@/business/server/trpc-middlewares/workspaceAuth';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { FileModel } from '@/database/models/file';
import { LlmGenerationTracingModel } from '@/database/models/llmGenerationTracing';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
import { VerifyEvidenceModel } from '@/database/models/verifyEvidence';
import { VerifyReportModel } from '@/database/models/verifyReport';
import { VerifyRubricModel } from '@/database/models/verifyRubric';
import { router } from '@/libs/trpc/lambda';
import { VerifyRunModel } from '@/database/models/verifyRun';
import {
verifyCheckResults,
verifyEvidence,
verifyReports,
verifyRuns,
} from '@/database/schemas/verify';
import { publicProcedure, router } from '@/libs/trpc/lambda';
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
import { FileService } from '@/server/services/file';
import {
VerifyExecutorService,
VerifyFeedbackService,
@@ -18,6 +31,28 @@ const verifierTypeSchema = z.enum(['program', 'agent', 'llm']);
const onFailSchema = z.enum(['manual', 'auto_repair']);
const decisionSchema = z.enum(['accepted', 'rejected', 'overridden']);
const modelConfigSchema = z.object({ model: z.string(), provider: z.string() });
const verdictSchema = z.enum(['passed', 'failed', 'uncertain']);
const checkStatusSchema = z.enum(['pending', 'running', 'passed', 'failed', 'skipped']);
const runSourceSchema = z.enum(['agent', 'agent-testing']);
const evidenceTypeSchema = z.enum([
'screenshot',
'gif',
'video',
'text',
'dom_snapshot',
'transcript',
]);
const evidenceCapturedBySchema = z.enum(['agent-browser', 'cdp', 'cli', 'program', 'llm_judge']);
const toulminSchema = z.object({
counterEvidence: z.string().optional(),
evidence: z.string().optional(),
limitation: z.string().optional(),
reasoning: z.string().optional(),
});
/** Derive the lifecycle status from a verdict when the caller doesn't pin one. */
const statusForVerdict = (verdict: 'passed' | 'failed' | 'uncertain') =>
verdict === 'passed' ? ('passed' as const) : ('failed' as const);
/** Run-policy knobs persisted on a rubric (see VerifyRubricConfig). */
const rubricConfigSchema = z.object({
@@ -36,23 +71,67 @@ const checkItemSchema = z.object({
verifierType: verifierTypeSchema,
});
const verifyRunIdInputSchema = z.object({ verifyRunId: z.string() });
const uploadEvidenceInputSchema = z
.object({
capturedBy: evidenceCapturedBySchema.optional(),
// Exactly one of `content` (inline text) or `fileId` (already-uploaded artifact).
checkResultId: z.string(),
content: z.string().min(1).optional(),
description: z.string().optional(),
fileId: z.string().min(1).optional(),
type: evidenceTypeSchema,
})
.refine((data) => Boolean(data.content) !== Boolean(data.fileId), {
message: 'Provide exactly one of `content` or `fileId`.',
});
const verifyProcedure = wsCompatProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
const workspaceId = ctx.workspaceId ?? undefined;
return opts.next({
ctx: {
criterionModel: new VerifyCriterionModel(ctx.serverDB, ctx.userId, workspaceId),
evidenceModel: new VerifyEvidenceModel(ctx.serverDB, ctx.userId, workspaceId),
executorService: new VerifyExecutorService(ctx.serverDB, ctx.userId, workspaceId),
tracingModel: new LlmGenerationTracingModel(ctx.serverDB, ctx.userId, workspaceId),
feedbackService: new VerifyFeedbackService(ctx.serverDB, ctx.userId, workspaceId),
operationModel: new AgentOperationModel(ctx.serverDB, ctx.userId, workspaceId),
planGenerator: new VerifyPlanGeneratorService(ctx.serverDB, ctx.userId, workspaceId),
reportModel: new VerifyReportModel(ctx.serverDB, ctx.userId, workspaceId),
resultModel: new VerifyCheckResultModel(ctx.serverDB, ctx.userId, workspaceId),
rubricModel: new VerifyRubricModel(ctx.serverDB, ctx.userId, workspaceId),
runModel: new VerifyRunModel(ctx.serverDB, ctx.userId, workspaceId),
},
});
});
const publicVerifyReportProcedure = publicProcedure.use(serverDatabase);
const resolveVerifyRun = async (ctx: { runModel: VerifyRunModel }, verifyRunId: string) => {
const run = await ctx.runModel.findById(verifyRunId);
if (!run) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Verification run not found' });
}
return run;
};
const resolveCheckResult = async (
ctx: { resultModel: VerifyCheckResultModel },
checkResultId: string,
) => {
const result = await ctx.resultModel.findById(checkResultId);
if (!result) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Verification check result not found' });
}
return result;
};
export const verifyRouter = router({
// ---- criteria (reusable atomic standards) ----
createCriterion: verifyProcedure
@@ -143,7 +222,10 @@ export const verifyRouter = router({
// ---- per-run plan ----
confirmPlan: verifyProcedure
.input(z.object({ operationId: z.string() }))
.mutation(async ({ ctx, input }) => ctx.operationModel.confirmVerifyPlan(input.operationId)),
.mutation(async ({ ctx, input }) => {
const run = await ctx.runModel.ensureForOperation(input.operationId);
return ctx.runModel.confirmPlan(run.id);
}),
generateDraftPlan: verifyProcedure
.input(
@@ -188,19 +270,21 @@ export const verifyRouter = router({
getVerifyState: verifyProcedure
.input(z.object({ operationId: z.string() }))
.query(async ({ ctx, input }) => ctx.operationModel.getVerifyState(input.operationId)),
.query(async ({ ctx, input }) => ctx.runModel.getStateByOperation(input.operationId)),
skipPlan: verifyProcedure
.input(z.object({ operationId: z.string() }))
.mutation(async ({ ctx, input }) =>
ctx.operationModel.updateVerifyStatus(input.operationId, null),
),
.mutation(async ({ ctx, input }) => {
const run = await ctx.runModel.findByOperation(input.operationId);
if (run) await ctx.runModel.updateStatus(run.id, null);
}),
updateDraftItems: verifyProcedure
.input(z.object({ items: z.array(checkItemSchema), operationId: z.string() }))
.mutation(async ({ ctx, input }) =>
ctx.operationModel.replaceVerifyPlanItems(input.operationId, input.items),
),
.mutation(async ({ ctx, input }) => {
const run = await ctx.runModel.ensureForOperation(input.operationId);
return ctx.runModel.replacePlanItems(run.id, input.items);
}),
// ---- results / execution ----
executeVerify: verifyProcedure
@@ -215,12 +299,16 @@ export const verifyRouter = router({
)
.mutation(async ({ ctx, input }) => {
await ctx.executorService.execute(input);
return ctx.resultModel.listByOperation(input.operationId);
const run = await ctx.runModel.findByOperation(input.operationId);
return run ? ctx.resultModel.listByRun(run.id) : [];
}),
listResults: verifyProcedure
.input(z.object({ operationId: z.string() }))
.query(async ({ ctx, input }) => ctx.resultModel.listByOperation(input.operationId)),
.query(async ({ ctx, input }) => {
const run = await ctx.runModel.findByOperation(input.operationId);
return run ? ctx.resultModel.listByRun(run.id) : [];
}),
// ---- feedback (data flywheel) ----
submitDecision: verifyProcedure
@@ -228,4 +316,202 @@ export const verifyRouter = router({
.mutation(async ({ ctx, input }) =>
ctx.feedbackService.submitDecision(input.resultId, input.decision),
),
// ---- ingest (standalone sessions: results / evidence / report, e.g. agent-testing) ----
// A verification session that isn't a live Agent Run (no executor): an external
// harness creates the run, ingests each check's verdict + evidence, and writes a
// report — all keyed by verifyRunId.
createRun: verifyProcedure
.input(
z.object({
goal: z.string().optional(),
operationId: z.string().optional(),
source: runSourceSchema.optional(),
title: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.runModel.create({
goal: input.goal,
operationId: input.operationId,
source: input.source ?? 'agent-testing',
title: input.title,
}),
),
getRun: verifyProcedure
.input(verifyRunIdInputSchema)
.query(async ({ ctx, input }) => ctx.runModel.findById(input.verifyRunId)),
listRuns: verifyProcedure.query(async ({ ctx }) => ctx.runModel.query()),
listResultsByRun: verifyProcedure.input(verifyRunIdInputSchema).query(async ({ ctx, input }) => {
const run = await resolveVerifyRun(ctx, input.verifyRunId);
return ctx.resultModel.listByRun(run.id);
}),
ingestResult: verifyProcedure
.input(
z.object({
checkItemId: z.string(),
checkItemIndex: z.number().optional(),
checkItemTitle: z.string().optional(),
confidence: z.number().min(0).max(1).optional(),
required: z.boolean().optional(),
status: checkStatusSchema.optional(),
suggestion: z.string().optional(),
toulmin: toulminSchema.optional(),
verdict: verdictSchema,
verifierType: verifierTypeSchema.optional(),
verifyRunId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const run = await resolveVerifyRun(ctx, input.verifyRunId);
return ctx.resultModel.upsertByCheckItem({
checkItemId: input.checkItemId,
checkItemIndex: input.checkItemIndex,
checkItemTitle: input.checkItemTitle,
completedAt: new Date(),
confidence: input.confidence,
required: input.required ?? true,
status: input.status ?? statusForVerdict(input.verdict),
suggestion: input.suggestion,
toulmin: input.toulmin,
verdict: input.verdict,
verifierType: input.verifierType ?? 'agent',
verifyRunId: run.id,
});
}),
uploadEvidence: verifyProcedure
.input(uploadEvidenceInputSchema)
.mutation(async ({ ctx, input }) => {
const result = await resolveCheckResult(ctx, input.checkResultId);
return ctx.evidenceModel.create({
capturedAt: new Date(),
capturedBy: input.capturedBy ?? null,
checkResultId: result.id,
content: input.content ?? null,
description: input.description ?? null,
fileId: input.fileId ?? null,
type: input.type,
});
}),
listEvidence: verifyProcedure
.input(z.object({ checkResultId: z.string() }))
.query(async ({ ctx, input }) => {
const result = await resolveCheckResult(ctx, input.checkResultId);
return ctx.evidenceModel.listByCheckResult(result.id);
}),
upsertReport: verifyProcedure
.input(
z.object({
content: z.string().optional(),
failedChecks: z.number().optional(),
generatedBy: z.string().optional(),
overallConfidence: z.number().min(0).max(1).optional(),
passedChecks: z.number().optional(),
summary: z.string().optional(),
totalChecks: z.number().optional(),
uncertainChecks: z.number().optional(),
verdict: verdictSchema.optional(),
verifyRunId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const run = await resolveVerifyRun(ctx, input.verifyRunId);
return ctx.reportModel.upsertByRun({
content: input.content ?? null,
failedChecks: input.failedChecks ?? null,
generatedBy: input.generatedBy ?? 'agent-testing',
overallConfidence: input.overallConfidence ?? null,
passedChecks: input.passedChecks ?? null,
summary: input.summary ?? null,
totalChecks: input.totalChecks ?? null,
uncertainChecks: input.uncertainChecks ?? null,
verdict: input.verdict ?? null,
verifyRunId: run.id,
});
}),
getReport: verifyProcedure.input(verifyRunIdInputSchema).query(async ({ ctx, input }) => {
const run = await resolveVerifyRun(ctx, input.verifyRunId);
return ctx.reportModel.findByRun(run.id);
}),
/**
* One-shot payload for the standalone report viewer: the session, its report,
* and every check result with its evidence — addressed purely by verifyRunId
* (no operation / chat context required).
*/
getReportBundle: publicVerifyReportProcedure
.input(verifyRunIdInputSchema)
.query(async ({ ctx, input }) => {
const run = await ctx.serverDB.query.verifyRuns.findFirst({
where: eq(verifyRuns.id, input.verifyRunId),
});
if (!run) return null;
const [report, results] = await Promise.all([
ctx.serverDB.query.verifyReports.findFirst({
where: eq(verifyReports.verifyRunId, input.verifyRunId),
}),
ctx.serverDB
.select()
.from(verifyCheckResults)
.where(eq(verifyCheckResults.verifyRunId, input.verifyRunId))
.orderBy(asc(verifyCheckResults.checkItemIndex)),
]);
// Resolve a displayable URL for each file-backed evidence artifact.
let fileService: FileService | null | undefined;
const getFileService = () => {
if (fileService !== undefined) return fileService;
try {
fileService = new FileService(ctx.serverDB, run.userId, run.workspaceId ?? undefined);
} catch (error) {
console.error('[verify:getReportBundle:resolveFileUrl]', error);
fileService = null;
}
return fileService;
};
const resolveFileUrl = async (fileId: string | null) => {
if (!fileId) return null;
try {
const file = await FileModel.getFileById(ctx.serverDB, fileId);
if (!file?.url) return null;
const service = getFileService();
return service ? await service.getFullFileUrl(file.url) : null;
} catch (error) {
console.error('[verify:getReportBundle:resolveFileUrl]', error);
return null;
}
};
const resultsWithEvidence = await Promise.all(
results.map(async (r) => {
const evidence = await ctx.serverDB
.select()
.from(verifyEvidence)
.where(eq(verifyEvidence.checkResultId, r.id))
.orderBy(asc(verifyEvidence.createdAt));
return {
...r,
evidence: await Promise.all(
evidence.map(async (e) => ({ ...e, fileUrl: await resolveFileUrl(e.fileId) })),
),
};
}),
);
return { report: report ?? null, results: resultsWithEvidence, run };
}),
});
@@ -6,6 +6,7 @@ import {
type RecordOperationStartParams,
} from '@/database/models/agentOperation';
import { MessageModel } from '@/database/models/message';
import { VerifyRunModel } from '@/database/models/verifyRun';
import { type LobeChatDatabase } from '@/database/type';
import { formatErrorForState } from '@/server/modules/AgentRuntime/formatErrorForState';
import { buildFinalSnapshotKey } from '@/server/modules/AgentTracing';
@@ -278,11 +279,10 @@ export class CompletionLifecycle {
userId: string,
): Promise<void> {
try {
const operationModel = new AgentOperationModel(this.serverDB, userId);
const state = await operationModel.getVerifyState(operationId);
if (!state?.verifyPlan?.length) return;
const run = await new VerifyRunModel(this.serverDB, userId).findByOperation(operationId);
if (!run?.plan?.length) return;
const op = await operationModel.findById(operationId);
const op = await new AgentOperationModel(this.serverDB, userId).findById(operationId);
if (!op?.topicId) return;
const messageModel = new MessageModel(this.serverDB, userId);
@@ -422,14 +422,24 @@ export class CompletionLifecycle {
? Date.now() - new Date(state.createdAt).getTime()
: undefined;
// On the error path, normalize the runtime error once so the lifecycle
// event carries the stable taxonomy fields (errorType + attribution). Bot
// reply renderers switch on these to surface a perceivable cause (network /
// quota / provider outage …) instead of an opaque Operation ID. Mirrors the
// same normalization dispatchHooks runs before writing the error onto the
// assistant message row.
const formattedError = state?.error ? formatErrorForState(state.error) : undefined;
return {
event: {
agentId: metadata?.agentId || '',
attachments: attachments.length > 0 ? attachments : undefined,
cost: state?.cost?.total,
duration,
errorAttribution: formattedError?.attribution,
errorDetail: state?.error,
errorMessage: this.extractErrorMessage(state?.error) || String(state?.error || ''),
errorType: formattedError?.type === undefined ? undefined : String(formattedError.type),
finalState: state,
lastAssistantContent,
llmCalls: state?.usage?.llm?.apiCalls,
@@ -196,6 +196,30 @@ describe('CompletionLifecycle.buildLifecycleEvent', () => {
expect(event.attachments).toBeUndefined();
expect(event.agentId).toBe('a');
});
it('populates errorType + attribution from the normalized error on the error path', () => {
// Regression: the event previously carried only errorDetail/errorMessage, so
// bot reply renderers never saw the stable code/attribution and always fell
// back to the opaque Operation ID. buildLifecycleEvent must normalize the
// runtime error via formatErrorForState and surface these taxonomy fields.
const state = {
error: { error: { message: 'fetch failed' }, errorType: 'ProviderNetworkError' },
metadata: { agentId: 'agent-1', userId: 'user-1' },
};
const { event } = callBuild(state, 'error');
expect(event.errorType).toBe('ProviderNetworkError');
expect(event.errorAttribution).toBe('system');
expect(event.errorMessage).toBe('fetch failed');
});
it('leaves errorType + attribution undefined when there is no error', () => {
const { event } = callBuild({ messages: [], metadata: {} }, 'done');
expect(event.errorType).toBeUndefined();
expect(event.errorAttribution).toBeUndefined();
});
});
describe('CompletionLifecycle.dispatchHooks — error persistence', () => {
+76 -11
View File
@@ -371,6 +371,24 @@ export class AiAgentService {
return task?.id;
}
/**
* If `deviceId` is a device enrolled into the caller's current workspace,
* return that workspaceId so device-gateway calls route to the
* `workspace:<id>` principal. Returns undefined for a personal device (or no
* workspace context), keeping the personal path byte-identical.
*/
private async resolveDeviceWorkspaceId(
deviceId: string | undefined,
): Promise<string | undefined> {
if (!deviceId || !this.workspaceId) return undefined;
const row = await new DeviceModel(
this.db,
this.userId,
this.workspaceId,
).findWorkspaceDeviceById(deviceId);
return row ? this.workspaceId : undefined;
}
/**
* Resolve the "workspace init" scan (project skills + AGENTS.md) for a run
* bound to a device's project directory. Reads the cache on
@@ -392,10 +410,24 @@ export class AiAgentService {
if (!activeDeviceId) return { workspace: empty };
try {
const deviceModel = new DeviceModel(this.db, this.userId);
const device = await deviceModel.findByDeviceId(activeDeviceId);
// The active device may be personal (userId-scoped) or workspace-owned
// (workspace-scoped) — look up both pools so the bound cwd, project
// skills, and AGENTS/CLAUDE instructions still resolve for a workspace
// device. Mirrors the dispatch-side lookup (see `deviceModelForCwd`).
const deviceModel = new DeviceModel(this.db, this.userId, this.workspaceId);
const personalDevice = await deviceModel.findByDeviceId(activeDeviceId);
const workspaceDevice = personalDevice
? undefined
: await deviceModel.findWorkspaceDeviceById(activeDeviceId);
const device = personalDevice ?? workspaceDevice;
if (!device) return { workspace: empty };
// For a workspace-owned device, route the gateway RPC to the
// `workspace:<id>` principal and persist the scan via the workspace
// update path — otherwise the scan goes through the personal pool
// (empty result) and the writeback misses the row.
const deviceWorkspaceId = workspaceDevice ? this.workspaceId : undefined;
// The bound project root we scan — resolved via the shared precedence
// helper so it cannot drift from hetero dispatch / topic backfill. Read
// from the persisted `device.defaultCwd` (not a live device query, which
@@ -423,6 +455,7 @@ export class AiAgentService {
deviceId: activeDeviceId,
scope: boundCwd,
userId: this.userId,
workspaceId: deviceWorkspaceId,
});
if (!scanned) {
// Scan failed (offline mid-run / parse error). Fall back to a stale
@@ -435,9 +468,15 @@ export class AiAgentService {
}
// Persist the fresh scan back onto `workingDirs` (update in place or prepend
// a new MRU entry), keeping the JSONB payload bounded.
// a new MRU entry), keeping the JSONB payload bounded. Workspace devices
// are owned by the workspace, not a userId — use the workspace-scoped
// update path so the writeback actually lands.
const updated = upsertWorkspaceScan(workingDirs, boundCwd, scanned, Date.now());
await deviceModel.update(activeDeviceId, { workingDirs: updated });
if (deviceWorkspaceId) {
await deviceModel.updateWorkspaceDevice(activeDeviceId, { workingDirs: updated });
} else {
await deviceModel.update(activeDeviceId, { workingDirs: updated });
}
log('execAgent: scanned and cached workspace init for %s', boundCwd);
return { boundCwd, workspace: scanned };
@@ -1401,8 +1440,9 @@ export class AiAgentService {
// lh connect only handles tool_call_request (not agent_run_request),
// so we use executeToolCall with the runHeteroTask tool instead of dispatchAgentRun.
const remoteDeviceWorkspaceId = await this.resolveDeviceWorkspaceId(remoteDeviceId);
const result = await deviceGateway.executeToolCall(
{ deviceId: remoteDeviceId, userId: this.userId },
{ deviceId: remoteDeviceId, userId: this.userId, workspaceId: remoteDeviceWorkspaceId },
{
apiName: 'runHeteroTask',
arguments: JSON.stringify({
@@ -1413,6 +1453,11 @@ export class AiAgentService {
prompt,
taskId: operationId,
topicId,
// Scope notify callbacks to the same workspace as the dispatched
// topic so agentNotify can resolve the workspace-owned topic.
// Without this the device's notify call falls back to personal
// mode and TopicModel.findById returns NOT_FOUND.
workspaceId: remoteDeviceWorkspaceId,
}),
identifier: 'runHeteroTask',
},
@@ -1510,9 +1555,13 @@ export class AiAgentService {
// wins, else the device's user-configured defaultCwd. The device row
// lives in the DB (the gateway only knows live connections), so read
// it directly rather than via deviceGateway.
const boundDevice = await new DeviceModel(this.db, this.userId).findByDeviceId(
dispatchDeviceId,
);
// The bound device may be personal (userId-scoped) or a workspace
// device (workspace-scoped) — look up both so its defaultCwd resolves.
const deviceModelForCwd = new DeviceModel(this.db, this.userId, this.workspaceId);
const boundDevice =
(await deviceModelForCwd.findByDeviceId(dispatchDeviceId)) ??
(await deviceModelForCwd.findWorkspaceDeviceById(dispatchDeviceId));
const dispatchWorkspaceId = await this.resolveDeviceWorkspaceId(dispatchDeviceId);
// Resolve via the shared precedence helper so dispatch, workspace-init,
// and the new-topic backfill below all agree on the cwd.
const deviceCwd = resolveDeviceWorkingDirectory({
@@ -1548,6 +1597,9 @@ export class AiAgentService {
cwd: deviceCwd,
deviceId: dispatchDeviceId,
systemContext: deviceSystemContext,
// Route to the workspace pool when this is a workspace device; the
// operation JWT stays member-scoped (the run belongs to the member).
workspaceId: dispatchWorkspaceId,
});
if (!result.success) {
log('execAgent: hetero device dispatch failed: %s', result.error);
@@ -1871,7 +1923,16 @@ export class AiAgentService {
const boundDeviceId = topicBoundDeviceId || agentBoundDeviceId;
if (gatewayConfigured) {
try {
onlineDevices = await deviceGateway.queryDeviceList(this.userId);
// Personal pool (user principal) the current workspace's shared pool
// (workspace principal). Workspace devices are absent for non-workspace
// runs, so this is identical to the personal-only fetch there.
const [personalOnline, workspaceOnline] = await Promise.all([
deviceGateway.queryDeviceList(this.userId),
this.workspaceId
? deviceGateway.queryDeviceList(this.userId, this.workspaceId)
: Promise.resolve([]),
]);
onlineDevices = [...personalOnline, ...workspaceOnline];
log('execAgent: found %d online device(s)', onlineDevices.length);
} catch (error) {
log('execAgent: failed to query device list: %O', error);
@@ -1986,7 +2047,6 @@ export class AiAgentService {
disableLocalSystem,
executionPlan,
globalMemoryEnabled,
hasAgentDocuments,
hasEnabledKnowledgeBases,
isBotConversation,
model,
@@ -3812,9 +3872,14 @@ export class AiAgentService {
runningOp.deviceId,
taskId,
);
const cancelWorkspaceId = await this.resolveDeviceWorkspaceId(runningOp.deviceId);
await deviceGateway
.executeToolCall(
{ deviceId: runningOp.deviceId, userId: this.userId },
{
deviceId: runningOp.deviceId,
userId: this.userId,
workspaceId: cancelWorkspaceId,
},
{
apiName: 'cancelHeteroTask',
arguments: JSON.stringify({ signal: 'SIGINT', taskId }),
@@ -1229,6 +1229,7 @@ export class AgentBridgeService {
errorMsg,
event.operationId,
replyLocale,
event.errorAttribution,
);
// Wrap in `{ markdown }` so the Chat SDK adapter sets the
// platform's markdown parse_mode (e.g. Telegram `Markdown`,
@@ -55,6 +55,13 @@ export interface BotCallbackBody {
cost?: number;
duration?: number;
elapsedMs?: number;
/**
* Error ownership from the model-runtime error taxonomy (`user` | `provider`
* | `harness` | `system`). Drives the user-facing error message tier when the
* exact `errorType` has no precise copy. Forwarded verbatim from the agent
* lifecycle event.
*/
errorAttribution?: string;
errorMessage?: string;
errorType?: string;
executionTimeMs?: number;
@@ -379,8 +386,15 @@ export class BotCallbackService {
charLimit?: number,
canEdit = true,
): Promise<void> {
const { reason, lastAssistantContent, errorMessage, errorType, operationId, attachments } =
body;
const {
reason,
lastAssistantContent,
errorAttribution,
errorMessage,
errorType,
operationId,
attachments,
} = body;
if (reason === 'error') {
log(
@@ -389,7 +403,13 @@ export class BotCallbackService {
errorType,
errorMessage,
);
const errorBody = renderAgentError(errorType, errorMessage, operationId, replyLocale);
const errorBody = renderAgentError(
errorType,
errorMessage,
operationId,
replyLocale,
errorAttribution,
);
const errorText = client.formatMarkdown?.(errorBody) ?? errorBody;
await this.deliverFirstChunk(messenger, progressMessageId, errorText, canEdit);
return;
@@ -417,7 +417,70 @@ describe('replyTemplate', () => {
expect(zh).toContain('命令会话已断开');
});
it('falls back to the generic op-id template for unknown error codes', () => {
it('keeps command-disconnect copy after the error is refined to a StateStore code', () => {
// formatErrorForState pattern-refines "Command aborted due to connection
// close" to StateStorePersistError (write) / StateStoreReadError (read).
// The specific disconnect guidance must still win over the harness/system
// tiers now that errorType is always populated.
const persist = renderAgentError(
'StateStorePersistError',
'Command aborted due to connection close',
'op-1',
'en-US',
'harness',
);
expect(persist).toContain('Command session disconnected');
const read = renderAgentError(
'StateStoreReadError',
'Command aborted due to connection close',
'op-1',
'en-US',
'system',
);
expect(read).toContain('Command session disconnected');
});
it('uses provider-neutral copy for system infra errors (no model-provider blame)', () => {
// StateStoreReadError is system-attributed but the LLM provider is not
// involved — the fallback must not suggest switching models / blame the
// provider the way the network copy does.
const en = renderAgentError(
'StateStoreReadError',
'Agent state not found for operation',
'op-1',
'en-US',
'system',
);
expect(en).toContain('temporary system error');
expect(en).not.toContain('model provider');
expect(en).not.toMatch(/switch to a different model/i);
expect(en).toContain('op-1');
const zh = renderAgentError(
'StateStoreReadError',
'Agent state not found for operation',
'op-1',
'zh-CN',
'system',
);
expect(zh).toContain('临时系统错误');
});
it('still gives ProviderNetworkError the provider-specific network copy', () => {
// Regression guard: the provider-neutral system fallback must not swallow
// the one system-attributed code that IS about the model provider.
const out = renderAgentError(
'ProviderNetworkError',
'fetch failed',
'op-1',
'en-US',
'system',
);
expect(out).toContain('Network error talking to the model provider');
});
it('falls back to the generic op-id template for unknown error codes without attribution', () => {
expect(renderAgentError('SomeNewErrorCode', undefined, 'op-1')).toBe(
'**Agent Execution Failed**\nOperation ID: `op-1`',
);
@@ -426,6 +489,63 @@ describe('replyTemplate', () => {
it('falls back to the generic header when neither errorType nor operationId is known', () => {
expect(renderAgentError(undefined, undefined, undefined)).toBe('**Agent Execution Failed**');
});
it('surfaces a network message for ProviderNetworkError instead of a bare op id', () => {
const en = renderAgentError('ProviderNetworkError', 'fetch failed', 'op-net');
expect(en).toContain('Network error talking to the model provider');
expect(en).toContain('op-net');
expect(en).not.toContain('**Agent Execution Failed**\nOperation ID');
const zh = renderAgentError('ProviderNetworkError', 'fetch failed', 'op-net', 'zh-CN');
expect(zh).toContain('网络连接异常');
});
it('maps provider-capacity codes to the temporarily-unavailable copy', () => {
const unavailable = renderAgentError('ProviderServiceUnavailable', undefined, 'op-1');
const noChannel = renderAgentError('NoAvailableChannel', undefined, 'op-1');
expect(unavailable).toContain('temporarily unavailable');
expect(unavailable).toBe(noChannel);
expect(renderAgentError('RateLimitExceeded', undefined, 'op-1')).toContain(
'Too many requests',
);
expect(renderAgentError('ModelEmptyCompletion', undefined, 'op-1')).toContain(
'empty response',
);
});
it('gives OperationInactivityTimeout retry-oriented copy', () => {
expect(renderAgentError('OperationInactivityTimeout', undefined, 'op-1')).toContain(
'timed out',
);
});
it('falls back by attribution when the exact code is unknown', () => {
// system → provider-neutral infra copy (must not blame the model provider)
expect(renderAgentError('SomeNewInfraCode', undefined, 'op-1', 'en-US', 'system')).toContain(
'temporary system error',
);
// provider → temporarily-unavailable copy
expect(renderAgentError('SomeNewCode', undefined, 'op-1', 'en-US', 'provider')).toContain(
'temporarily unavailable',
);
// harness → internal-error copy, op id kept for support
const harness = renderAgentError('SomeNewCode', undefined, 'op-h', 'en-US', 'harness');
expect(harness).toContain('Something went wrong on our side');
expect(harness).toContain('op-h');
// user → generic check-your-settings copy
expect(renderAgentError('SomeNewCode', undefined, 'op-1', 'en-US', 'user')).toContain(
"couldn't be completed",
);
});
it('prefers the precise code copy over the attribution fallback', () => {
// ProviderNetworkError has precise copy even though attribution=system
// would also resolve; the precise tier must win.
const out = renderAgentError('InvalidProviderAPIKey', undefined, 'op-1', 'en-US', 'system');
expect(out).toContain('Invalid or missing API key');
expect(out).not.toContain('Network error');
});
});
// ==================== renderStopped ====================
@@ -263,9 +263,10 @@ describe('WechatGatewayClient', () => {
expect.anything(), // WechatApiClient instance
raw,
);
expect(result).toEqual([
{ buffer, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined },
]);
expect(result).toEqual({
files: [{ buffer, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined }],
warnings: undefined,
});
});
it('returns undefined when downloadMediaFromRawMessage resolves to an empty array', async () => {
@@ -276,6 +277,34 @@ describe('WechatGatewayClient', () => {
expect(result).toBeUndefined();
});
it('warns when a FILE item has no downloadable media (e.g. oversized) and was dropped', async () => {
// WeChat relays oversized files as metadata only — no CDN media handle —
// so downloadMediaFromRawMessage returns nothing for them. We must surface
// a warning instead of silently passing only the `[file: name]` text.
mockDownloadMediaFromRawMessage.mockResolvedValue([]);
const client = createClient();
const result = (await client.extractFiles!(
makeMessage({
item_list: [
{
file_item: {
file_name: 'October 11, 2023 Alta Town Council Meeting Audio.mp3',
len: '132800970',
},
type: 4, // MessageItemType.FILE
},
],
}),
)) as { files?: unknown[]; warnings?: string[] } | undefined;
expect(result?.files).toBeUndefined();
expect(result?.warnings).toHaveLength(1);
expect(result?.warnings?.[0]).toContain(
'October 11, 2023 Alta Town Council Meeting Audio.mp3',
);
expect(result?.warnings?.[0]).toContain('126.6 MB');
expect(result?.warnings?.[0]).toContain('could not be retrieved');
});
it('maps file attachments preserving name + size', async () => {
const buffer = Buffer.from('pdf-bytes');
mockDownloadMediaFromRawMessage.mockResolvedValue([
@@ -303,9 +332,10 @@ describe('WechatGatewayClient', () => {
],
}),
);
expect(result).toEqual([
{ buffer, mimeType: 'application/pdf', name: 'report.pdf', size: 4096 },
]);
expect(result).toEqual({
files: [{ buffer, mimeType: 'application/pdf', name: 'report.pdf', size: 4096 }],
warnings: undefined,
});
});
it('maps multiple attachments in a single message', async () => {
@@ -324,10 +354,13 @@ describe('WechatGatewayClient', () => {
],
}),
);
expect(result).toEqual([
{ buffer: imageBuf, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined },
{ buffer: voiceBuf, mimeType: 'audio/silk', name: undefined, size: undefined },
]);
expect(result).toEqual({
files: [
{ buffer: imageBuf, mimeType: 'image/jpeg', name: 'image.jpg', size: undefined },
{ buffer: voiceBuf, mimeType: 'audio/silk', name: undefined, size: undefined },
],
warnings: undefined,
});
});
it('propagates errors from downloadMediaFromRawMessage as undefined gracefully', async () => {
@@ -2,6 +2,7 @@ import type { WechatRawMessage } from '@lobechat/chat-adapter-wechat';
import {
createWechatAdapter,
downloadMediaFromRawMessage,
MessageItemType,
MessageState,
MessageType,
WechatApiClient,
@@ -20,6 +21,7 @@ import {
type BotPlatformRuntimeContext,
type BotProviderConfig,
ClientFactory,
type ExtractFilesResult,
type MessengerContent,
messengerContentText,
type PlatformClient,
@@ -347,30 +349,63 @@ class WechatGatewayClient implements PlatformClient {
* `parseRawEvent` runs at adapter parse time including the cascading
* image fallback (CDN main thumb direct URL).
*/
async extractFiles(message: Message): Promise<AttachmentSource[] | undefined> {
async extractFiles(message: Message): Promise<ExtractFilesResult | undefined> {
const raw = (message as any).raw as WechatRawMessage | undefined;
if (!raw?.item_list?.length) return undefined;
log('extractFiles: msgId=%s, items=%d', (message as any).id, raw.item_list.length);
const attachments = await downloadMediaFromRawMessage(this.api, raw);
if (attachments.length === 0) {
// Detect FILE items that arrived as metadata only. WeChat does not relay a
// downloadable CDN media descriptor for oversized files, so
// downloadMediaFromRawMessage silently drops them. Without a warning the
// agent only sees the bare `[file: name]` text placeholder (from the
// adapter's extractText) and hallucinates that it received the file — e.g.
// claiming it can't "hear" an audio it never actually got. Surface a
// warning so the model can tell the user the file couldn't be retrieved.
const downloadedNames = new Set(
attachments.map((att: any) => att.name).filter(Boolean) as string[],
);
const warnings: string[] = [];
for (const item of raw.item_list) {
if (item.type !== MessageItemType.FILE || !item.file_item) continue;
const fileName = item.file_item.file_name;
if (fileName && downloadedNames.has(fileName)) continue;
const sizeBytes = Number(item.file_item.len);
const sizeHint =
Number.isFinite(sizeBytes) && sizeBytes > 0
? ` (${(sizeBytes / (1024 * 1024)).toFixed(1)} MB)`
: '';
warnings.push(
`File "${fileName || 'unknown'}"${sizeHint} could not be retrieved from WeChat ` +
`(it may be too large) and was not processed.`,
);
}
if (attachments.length === 0 && warnings.length === 0) {
log('extractFiles: no media items resolved for msgId=%s', (message as any).id);
return undefined;
}
log(
'extractFiles: resolved %d media item(s) for msgId=%s',
'extractFiles: resolved %d media item(s), %d warning(s) for msgId=%s',
attachments.length,
warnings.length,
(message as any).id,
);
return attachments.map((att: any) => ({
const files: AttachmentSource[] = attachments.map((att: any) => ({
buffer: att.buffer,
mimeType: att.mimeType,
name: att.name,
size: att.size,
}));
return {
files: files.length > 0 ? files : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
};
}
getMessenger(platformThreadId: string): PlatformMessenger {
+121 -16
View File
@@ -237,11 +237,20 @@ type SystemStrings = {
errorExceededContextWindow: string;
errorInvalidProviderAPIKey: string;
errorCommandConnectionClosed: string;
errorContentModeration: string;
errorEmptyCompletion: string;
errorHarnessInternal: string;
errorLocationNotSupported: string;
errorModelNotFound: string;
errorNoAvailableProvider: string;
errorPermissionDenied: string;
errorProviderUnavailable: string;
errorQuotaLimitReached: string;
errorRateLimited: string;
errorSystemInfra: string;
errorTimeout: string;
errorTransientNetwork: string;
errorUserGeneric: string;
errorWithDetails: (details: string, operationId?: string) => string;
errorWithId: (operationId: string) => string;
groupRejectedAllowlist: string;
@@ -293,6 +302,12 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
"**Context window exceeded.**\nThe conversation is too long for this model. Send `/new` to start a fresh topic, or switch to a model with a larger context window in the agent's settings.",
errorCommandConnectionClosed:
'**Command session disconnected.**\nThe agent lost its command connection before finishing. Please retry. If this keeps happening, check the sandbox or device connection and review the server logs for the operation.',
errorContentModeration:
"**Blocked by the content-safety filter.**\nThe model provider's safety filter rejected the request or response. Please rephrase and try again.",
errorEmptyCompletion:
"**The model returned an empty response.**\nThe model finished without producing any output. Please try again, or switch to a different model in the agent's settings.",
errorHarnessInternal:
'**Something went wrong on our side.**\nThe agent run hit an internal error, which has been logged. Please try again — if it keeps happening, share the Operation ID below with support.',
errorInvalidProviderAPIKey:
"**Invalid or missing API key.**\nThe configured model provider rejected its API key. Please verify the key in the agent's provider settings (it may be expired, revoked, or mistyped) and try again.",
errorLocationNotSupported:
@@ -303,8 +318,20 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
"**No model provider configured.**\nThis bot's agent has no available model provider — please add an API key and enable a provider in the agent's settings, then try again.",
errorPermissionDenied:
"**Permission denied by the model provider.**\nThe API key doesn't have access to the requested model or operation. Please check the key's permissions, or switch to a model your account is authorized to use.",
errorProviderUnavailable:
"**Model provider temporarily unavailable.**\nThe model provider is overloaded or unavailable right now. Please wait a moment and try again, or switch to a different model in the agent's settings.",
errorQuotaLimitReached:
"**Provider quota exhausted.**\nThe configured model provider is out of quota or rate-limited. Please wait a moment and try again, top up the account, or switch to a different provider in the agent's settings.",
errorRateLimited:
"**Too many requests.**\nThe model provider is rate-limiting requests right now. Please wait a moment before trying again, or switch to a different model in the agent's settings.",
errorSystemInfra:
'**A temporary system error occurred.**\nThe request hit a transient infrastructure issue on our side and could not be completed. Please try again in a moment.',
errorTimeout:
'**The agent run timed out.**\nThe operation ran too long without progress and was stopped. Please try again; if the task is large, try breaking it into smaller steps.',
errorTransientNetwork:
"**Network error talking to the model provider.**\nThe connection to the model provider timed out or dropped. This is usually temporary — please try again in a moment. If it keeps happening, try a different model in the agent's settings.",
errorUserGeneric:
"**The agent run couldn't be completed.**\nPlease check your input or the agent's settings and try again.",
errorWithDetails: (details, operationId) =>
operationId
? `**Agent Execution Failed**\nOperation ID: \`${operationId}\`\nDetails:\n\`\`\`\n${details}\n\`\`\``
@@ -350,6 +377,12 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
'**上下文已超出模型上限**\n当前对话长度超过了该模型的上下文窗口。可以发送 `/new` 开启新话题,或在 Agent 设置中切换到上下文更大的模型后重试。',
errorCommandConnectionClosed:
'**命令会话已断开**\nAgent 在完成前丢失了命令连接。请重试;如果该问题持续出现,请检查 sandbox 或设备连接,并结合 Operation ID 查看服务端日志。',
errorContentModeration:
'**被内容安全策略拦截**\n模型 Provider 的安全策略拒绝了本次请求或回复。请调整内容后重试。',
errorEmptyCompletion:
'**模型未返回任何内容**\n模型执行结束但没有产生输出。请重试,或在 Agent 设置中切换到其他模型。',
errorHarnessInternal:
'**我们这边出了点问题**\nAgent 执行遇到内部错误,已记录。请重试;如果持续出现,请把下方 Operation ID 提供给支持人员。',
errorInvalidProviderAPIKey:
'**API Key 无效或缺失**\n所配置的模型 Provider 拒绝了 API Key,可能已过期、被吊销或填写错误。请到 Agent 的 Provider 设置中检查并更新 API Key 后重试。',
errorLocationNotSupported:
@@ -360,8 +393,19 @@ const SYSTEM_STRINGS: Partial<Record<BotReplyLocale, SystemStrings>> = {
'**未配置可用的模型 Provider**\n该机器人的 Agent 当前没有可用的模型 Provider,请在 Agent 设置中添加 API Key 并启用一个 Provider 后重试。',
errorPermissionDenied:
'**模型 Provider 拒绝访问**\nAPI Key 没有访问该模型或操作的权限。请检查 Key 的权限范围,或在 Agent 设置中切换到当前账户已授权的模型。',
errorProviderUnavailable:
'**模型 Provider 暂时不可用**\n模型 Provider 当前过载或不可用。请稍后重试,或在 Agent 设置中切换到其他模型。',
errorQuotaLimitReached:
'**Provider 配额已用尽**\n所配置的模型 Provider 已达到配额上限或被限流。请稍后重试、为账户充值,或在 Agent 设置中切换到其他 Provider。',
errorRateLimited:
'**请求过于频繁**\n模型 Provider 正在限流。请稍后再试,或在 Agent 设置中切换到其他模型。',
errorSystemInfra:
'**发生了临时系统错误**\n由于我们这边的临时基础设施问题,本次请求未能完成。请稍后重试。',
errorTimeout:
'**Agent 执行超时**\n操作长时间无进展,已被中止。请重试;如果任务较大,可尝试拆分成更小的步骤。',
errorTransientNetwork:
'**与模型 Provider 的网络连接异常**\n连接模型 Provider 时超时或中断。通常是临时问题,请稍后重试;如果反复出现,可在 Agent 设置中换一个模型。',
errorUserGeneric: '**Agent 执行未能完成**\n请检查你的输入或 Agent 设置后重试。',
errorWithDetails: (details, operationId) =>
operationId
? `**Agent 执行失败**\nOperation ID: \`${operationId}\`\n详细信息:\n\`\`\`\n${details}\n\`\`\``
@@ -389,14 +433,19 @@ export function renderError(operationId?: string, lng?: BotReplyLocale): string
/**
* Map known `AgentRuntimeError` codes to the `SystemStrings` field that
* carries the friendly, actionable copy for that failure mode. Codes not in
* this map fall back to the generic `Operation ID` template opaque enough
* not to leak internal error strings, but still traceable in logs.
* carries the friendly, actionable copy for that failure mode. This is the
* precise tier: when we recognize the exact code we show copy tailored to it.
*
* Codes not in this map fall back to {@link FALLBACK_ERROR_BY_ATTRIBUTION}
* (a per-`attribution` tier), and only then to the generic `Operation ID`
* template.
*
* When adding a new code: extend `SystemStrings`, drop the copy into both the
* `en-US` and `zh-CN` dictionaries, then add the mapping here.
*/
const FRIENDLY_ERROR_BY_TYPE: Record<string, keyof SystemStrings> = {
// ── user-fixable config / input (attribution: user) ──
ContentModeration: 'errorContentModeration',
ExceededContextWindow: 'errorExceededContextWindow',
InsufficientQuota: 'errorQuotaLimitReached',
InvalidProviderAPIKey: 'errorInvalidProviderAPIKey',
@@ -405,47 +454,103 @@ const FRIENDLY_ERROR_BY_TYPE: Record<string, keyof SystemStrings> = {
NoAvailableProvider: 'errorNoAvailableProvider',
PermissionDenied: 'errorPermissionDenied',
QuotaLimitReached: 'errorQuotaLimitReached',
// ── transient provider / capacity (attribution: provider) ──
ModelEmptyCompletion: 'errorEmptyCompletion',
NoAvailableChannel: 'errorProviderUnavailable',
ProviderServiceUnavailable: 'errorProviderUnavailable',
RateLimitExceeded: 'errorRateLimited',
// ── network / infra (attribution: system) ──
// ProviderNetworkError is the one system-attributed code that *is* about the
// model provider, so it keeps the provider-specific "switch model" copy. Other
// system codes (state-store reads) hit the provider-neutral `system` fallback.
ProviderNetworkError: 'errorTransientNetwork',
// ── harness watchdog: harness-owned but retry-friendly, so it gets its
// own retry-oriented copy rather than the generic internal-error tier ──
OperationInactivityTimeout: 'errorTimeout',
};
/**
* When a specific error code has no precise copy above, fall back to a message
* keyed on the error's `attribution` (from the model-runtime error taxonomy) so
* the user still learns *who owns the failure* and whether to retry instead of
* a bare Operation ID. Unknown / absent attribution falls through to the legacy
* template.
*/
const FALLBACK_ERROR_BY_ATTRIBUTION: Record<string, keyof SystemStrings> = {
harness: 'errorHarnessInternal',
provider: 'errorProviderUnavailable',
// Provider-neutral: `system` covers infra failures (state-store reads, etc.)
// where the LLM provider/model is not involved, so the copy must NOT blame the
// provider or suggest switching models. ProviderNetworkError, the one
// provider-related system code, is mapped precisely above.
system: 'errorSystemInfra',
user: 'errorUserGeneric',
};
/**
* Append the Operation ID as a traceable footer so operators can still grep
* logs for the failure even when the user-facing copy is a friendly, actionable
* message rather than the raw "Operation ID" line.
*/
const appendOperationId = (value: string, operationId: string | undefined): string =>
operationId ? `${value}\nOperation ID: \`${operationId}\`` : value;
// `command aborted due to connection close` reaches us under a few stable
// codes: the raw `500` fallback, or — once `formatErrorForState` pattern-refines
// the Upstash/ioredis disconnect — `StateStorePersistError` (write path) /
// `StateStoreReadError` (blocking-read path). The message stays the precise
// signal; the type gate just has to let these through so the specific
// "command session disconnected" guidance still wins over the generic tiers.
const COMMAND_CONNECTION_CLOSED_TYPES = new Set([
'500',
'StateStorePersistError',
'StateStoreReadError',
]);
const isCommandConnectionClosedError = (
errorType: string | undefined,
errorMessage: string | undefined,
) => {
if (errorType && errorType !== '500') return false;
if (errorType && !COMMAND_CONNECTION_CLOSED_TYPES.has(errorType)) return false;
if (!errorMessage) return false;
return /command aborted due to connection close/i.test(errorMessage);
};
/**
* Render an agent-execution failure for the user. Switches on the stable
* `errorType` code (from `AgentRuntimeError.chat`) to surface a friendly,
* actionable message for known failure modes.
* Render an agent-execution failure for the user, in three tiers:
*
* For unknown error codes or when `errorType` is missing falls back to
* the legacy `Operation ID` template.
* 1. **Precise** switch on the stable `errorType` code (from
* `AgentRuntimeError.chat`) for copy tailored to that exact failure mode.
* 2. **Attribution** when the code is unknown, fall back to a message keyed
* on `attribution` (network / provider / harness / user) so the user still
* learns who owns the failure and whether to retry.
* 3. **Legacy** when neither is known, the opaque `Operation ID` template.
*
* The Operation ID is appended as a footer to every tier (not the whole
* message) so it stays traceable in logs without being the only thing the
* user sees.
*/
export function renderAgentError(
errorType: string | undefined,
errorMessage: string | undefined,
operationId: string | undefined,
lng?: BotReplyLocale,
attribution?: string,
): string {
const strings = getSystemStrings(lng);
if (isCommandConnectionClosedError(errorType, errorMessage)) {
const value = strings.errorCommandConnectionClosed;
return operationId ? `${value}\nOperation ID: \`${operationId}\`` : value;
return appendOperationId(strings.errorCommandConnectionClosed, operationId);
}
const stringKey = errorType ? FRIENDLY_ERROR_BY_TYPE[errorType] : undefined;
const stringKey =
(errorType ? FRIENDLY_ERROR_BY_TYPE[errorType] : undefined) ??
(attribution ? FALLBACK_ERROR_BY_ATTRIBUTION[attribution] : undefined);
if (stringKey) {
const value = strings[stringKey];
if (typeof value === 'string') {
// Append the operationId as a traceable footer so operators can still
// grep logs for the failure even when the user-facing copy is a
// friendly, actionable message rather than the raw "Operation ID" line.
return operationId ? `${value}\nOperation ID: \`${operationId}\`` : value;
return appendOperationId(value, operationId);
}
}
@@ -65,7 +65,7 @@ describe('DeviceGateway', () => {
const result = await proxy.queryDeviceStatus('user-1');
expect(result).toEqual(expected);
expect(mockClient.queryDeviceStatus).toHaveBeenCalledWith('user-1');
expect(mockClient.queryDeviceStatus).toHaveBeenCalledWith('user-1', undefined);
});
it('should return offline status on error', async () => {
@@ -136,7 +136,7 @@ describe('DeviceGateway', () => {
platform: 'win32',
},
]);
expect(mockClient.queryDeviceList).toHaveBeenCalledWith('user-1');
expect(mockClient.queryDeviceList).toHaveBeenCalledWith('user-1', undefined);
});
it('tolerates a legacy gateway response without channels', async () => {
@@ -191,7 +191,7 @@ describe('DeviceGateway', () => {
const result = await proxy.queryDeviceSystemInfo('user-1', 'dev-1');
expect(result).toEqual(systemInfo);
expect(mockClient.getDeviceSystemInfo).toHaveBeenCalledWith('user-1', 'dev-1');
expect(mockClient.getDeviceSystemInfo).toHaveBeenCalledWith('user-1', 'dev-1', undefined);
});
it('should return undefined when result is not successful', async () => {
+127 -54
View File
@@ -87,23 +87,25 @@ export class DeviceGateway {
return !!gatewayEnv.DEVICE_GATEWAY_URL;
}
async queryDeviceStatus(userId: string): Promise<DeviceStatusResult> {
async queryDeviceStatus(userId: string, workspaceId?: string): Promise<DeviceStatusResult> {
const client = this.getClient();
if (!client) return { deviceCount: 0, online: false };
try {
return await client.queryDeviceStatus(userId);
return await client.queryDeviceStatus(userId, workspaceId);
} catch {
return { deviceCount: 0, online: false };
}
}
async queryDeviceList(userId: string): Promise<DeviceAttachment[]> {
// Pass a `workspaceId` to address a workspace-owned device pool (the gateway
// routes to the `workspace:<id>` principal); omit it for the personal pool.
async queryDeviceList(userId: string, workspaceId?: string): Promise<DeviceAttachment[]> {
const client = this.getClient();
if (!client) return [];
try {
const devices = await client.queryDeviceList(userId);
const devices = await client.queryDeviceList(userId, workspaceId);
// The gateway already dedupes to one entry per physical device, with its
// live connections nested as `channels`. Map to the runtime shape; every
// returned device has at least one channel, so it's online.
@@ -129,12 +131,13 @@ export class DeviceGateway {
async queryDeviceSystemInfo(
userId: string,
deviceId: string,
workspaceId?: string,
): Promise<DeviceSystemInfo | undefined> {
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.getDeviceSystemInfo(userId, deviceId);
const result = await client.getDeviceSystemInfo(userId, deviceId, workspaceId);
return result.success ? result.systemInfo : undefined;
} catch {
log('queryDeviceSystemInfo: failed for userId=%s, deviceId=%s', userId, deviceId);
@@ -157,8 +160,9 @@ export class DeviceGateway {
scope: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<WorkspaceInitResult | undefined> {
const { userId, deviceId, scope, timeout = 30_000 } = params;
const { userId, deviceId, scope, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
@@ -169,7 +173,10 @@ export class DeviceGateway {
const result = await client.invokeRpc<{
instructions?: WorkspaceInitResult['instructions'];
skills?: (ProjectSkillMeta & Record<string, unknown>)[];
}>({ deviceId, timeout, userId }, { method: 'initWorkspace', params: { scope } });
}>(
{ deviceId, timeout, userId, workspaceId },
{ method: 'initWorkspace', params: { scope } },
);
if (!result.success || !result.data) {
log('initWorkspace: failed for deviceId=%s — %s', deviceId, result.error);
@@ -198,16 +205,16 @@ export class DeviceGateway {
*/
private async invokeGitRead<T>(
method: string,
params: { deviceId: string; timeout?: number; userId: string },
params: { deviceId: string; timeout?: number; userId: string; workspaceId?: string },
rpcParams: Record<string, unknown>,
): Promise<T | undefined> {
const { userId, deviceId, timeout = 15_000 } = params;
const { userId, deviceId, timeout = 15_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<T>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method, params: rpcParams },
);
@@ -224,12 +231,18 @@ export class DeviceGateway {
}
/** Branch name + detached flag for a directory on a remote device. */
gitBranch(params: { deviceId: string; path: string; userId: string }) {
gitBranch(params: { deviceId: string; path: string; userId: string; workspaceId?: string }) {
return this.invokeGitRead<DeviceGitBranchInfo>('getGitBranch', params, { path: params.path });
}
/** The GitHub PR linked to a branch in a directory on a remote device. */
gitLinkedPullRequest(params: { branch: string; deviceId: string; path: string; userId: string }) {
gitLinkedPullRequest(params: {
branch: string;
deviceId: string;
path: string;
userId: string;
workspaceId?: string;
}) {
return this.invokeGitRead<DeviceGitLinkedPullRequestResult>('getLinkedPullRequest', params, {
branch: params.branch,
path: params.path,
@@ -237,21 +250,31 @@ export class DeviceGateway {
}
/** Working-tree dirty-file counts for a directory on a remote device. */
gitWorkingTreeStatus(params: { deviceId: string; path: string; userId: string }) {
gitWorkingTreeStatus(params: {
deviceId: string;
path: string;
userId: string;
workspaceId?: string;
}) {
return this.invokeGitRead<DeviceGitWorkingTreeStatus>('getGitWorkingTreeStatus', params, {
path: params.path,
});
}
/** Ahead/behind commit counts for a directory on a remote device. */
gitAheadBehind(params: { deviceId: string; path: string; userId: string }) {
gitAheadBehind(params: { deviceId: string; path: string; userId: string; workspaceId?: string }) {
return this.invokeGitRead<DeviceGitAheadBehind>('getGitAheadBehind', params, {
path: params.path,
});
}
/** Git worktrees attached to the same repository as a directory on a remote device. */
listGitWorktrees(params: { deviceId: string; path: string; userId: string }) {
listGitWorktrees(params: {
deviceId: string;
path: string;
userId: string;
workspaceId?: string;
}) {
return this.invokeGitRead<DeviceGitWorktreeListItem[]>('listGitWorktrees', params, {
path: params.path,
});
@@ -267,14 +290,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitBranchListItem[] | undefined> {
const { userId, deviceId, path, timeout = 15_000 } = params;
const { userId, deviceId, path, timeout = 15_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitBranchListItem[]>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'listGitBranches', params: { path } },
);
@@ -301,14 +325,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitCheckoutResult> {
const { userId, deviceId, branch, create, path, timeout = 30_000 } = params;
const { userId, deviceId, branch, create, path, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitCheckoutResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'checkoutGitBranch', params: { branch, create, path } },
);
@@ -335,14 +360,15 @@ export class DeviceGateway {
timeout?: number;
to: string;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitRenameBranchResult> {
const { userId, deviceId, from, to, path, timeout = 30_000 } = params;
const { userId, deviceId, from, to, path, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitRenameBranchResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'renameGitBranch', params: { from, path, to } },
);
@@ -368,14 +394,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitDeleteBranchResult> {
const { userId, deviceId, branch, path, timeout = 30_000 } = params;
const { userId, deviceId, branch, path, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitDeleteBranchResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'deleteGitBranch', params: { branch, path } },
);
@@ -400,14 +427,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitSyncResult> {
const { userId, deviceId, path, timeout = 65_000 } = params;
const { userId, deviceId, path, timeout = 65_000, workspaceId } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitSyncResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'pullGitBranch', params: { path } },
);
@@ -432,14 +460,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitSyncResult> {
const { userId, deviceId, path, timeout = 65_000 } = params;
const { userId, deviceId, path, timeout = 65_000, workspaceId } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitSyncResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'pushGitBranch', params: { path } },
);
@@ -465,14 +494,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitWorkingTreePatches | undefined> {
const { userId, deviceId, path, timeout = 30_000 } = params;
const { userId, deviceId, path, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitWorkingTreePatches>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'getGitWorkingTreePatches', params: { path } },
);
@@ -498,14 +528,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitBranchDiffPatches | undefined> {
const { userId, deviceId, baseRef, path, timeout = 30_000 } = params;
const { userId, deviceId, baseRef, path, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitBranchDiffPatches>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'getGitBranchDiff', params: { baseRef, path } },
);
@@ -531,14 +562,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitWorkingTreeFiles | undefined> {
const { userId, deviceId, path, timeout = 15_000 } = params;
const { userId, deviceId, path, timeout = 15_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitWorkingTreeFiles>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'getGitWorkingTreeFiles', params: { path } },
);
@@ -563,14 +595,15 @@ export class DeviceGateway {
scope: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceProjectFileIndexResult | undefined> {
const { userId, deviceId, scope, timeout = 30_000 } = params;
const { userId, deviceId, scope, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceProjectFileIndexResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'getProjectFileIndex', params: { scope } },
);
@@ -598,14 +631,23 @@ export class DeviceGateway {
timeout?: number;
userId: string;
workingDirectory: string;
workspaceId?: string;
}): Promise<DeviceLocalFilePreviewResult> {
const { accept, userId, deviceId, path, workingDirectory, timeout = 30_000 } = params;
const {
accept,
userId,
deviceId,
path,
workingDirectory,
timeout = 30_000,
workspaceId,
} = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceLocalFilePreviewResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{
method: 'getLocalFilePreview',
params: { accept, path, workingDirectory },
@@ -636,14 +678,15 @@ export class DeviceGateway {
scope: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceListProjectSkillsResult | undefined> {
const { userId, deviceId, scope, timeout = 30_000 } = params;
const { userId, deviceId, scope, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceListProjectSkillsResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'listProjectSkills', params: { scope } },
);
@@ -669,14 +712,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitRemoteBranchListItem[] | undefined> {
const { userId, deviceId, path, timeout = 15_000 } = params;
const { userId, deviceId, path, timeout = 15_000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
try {
const result = await client.invokeRpc<DeviceGitRemoteBranchListItem[]>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'listGitRemoteBranches', params: { path } },
);
@@ -702,14 +746,15 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<DeviceGitFileRevertResult> {
const { userId, deviceId, filePath, path, timeout = 15_000 } = params;
const { userId, deviceId, filePath, path, timeout = 15_000, workspaceId } = params;
const client = this.getClient();
if (!client) return { error: 'Device gateway not configured', success: false };
try {
const result = await client.invokeRpc<DeviceGitFileRevertResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'revertGitFile', params: { filePath, path } },
);
@@ -738,8 +783,9 @@ export class DeviceGateway {
timeout?: number;
userId: string;
workingDirectory: string;
workspaceId?: string;
}): Promise<DeviceMoveProjectFileResultItem[]> {
const { userId, deviceId, items, workingDirectory, timeout = 30_000 } = params;
const { userId, deviceId, items, workingDirectory, timeout = 30_000, workspaceId } = params;
const client = this.getClient();
if (!client) throw new Error('Device gateway not configured');
@@ -749,7 +795,7 @@ export class DeviceGateway {
);
const result = await client.invokeRpc<DeviceMoveProjectFileResultItem[]>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'moveLocalFiles', params: { items } },
);
@@ -773,8 +819,17 @@ export class DeviceGateway {
timeout?: number;
userId: string;
workingDirectory: string;
workspaceId?: string;
}): Promise<DeviceRenameProjectFileResult> {
const { userId, deviceId, path, newName, workingDirectory, timeout = 30_000 } = params;
const {
userId,
deviceId,
path,
newName,
workingDirectory,
timeout = 30_000,
workspaceId,
} = params;
const client = this.getClient();
if (!client) throw new Error('Device gateway not configured');
@@ -783,7 +838,7 @@ export class DeviceGateway {
assertPathsWithinWorkspace(workingDirectory, [path]);
const result = await client.invokeRpc<DeviceRenameProjectFileResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'renameLocalFile', params: { newName, path } },
);
@@ -807,15 +862,24 @@ export class DeviceGateway {
timeout?: number;
userId: string;
workingDirectory: string;
workspaceId?: string;
}): Promise<DeviceWriteProjectFileResult> {
const { userId, deviceId, path, content, workingDirectory, timeout = 30_000 } = params;
const {
userId,
deviceId,
path,
content,
workingDirectory,
timeout = 30_000,
workspaceId,
} = params;
const client = this.getClient();
if (!client) throw new Error('Device gateway not configured');
assertPathsWithinWorkspace(workingDirectory, [path]);
const result = await client.invokeRpc<DeviceWriteProjectFileResult>(
{ deviceId, timeout, userId },
{ deviceId, timeout, userId, workspaceId },
{ method: 'writeLocalFile', params: { content, path } },
);
@@ -839,8 +903,9 @@ export class DeviceGateway {
path: string;
timeout?: number;
userId: string;
workspaceId?: string;
}): Promise<{ exists: boolean; isDirectory: boolean; repoType?: 'git' | 'github' } | undefined> {
const { userId, deviceId, path, timeout = 8000 } = params;
const { userId, deviceId, path, timeout = 8000, workspaceId } = params;
const client = this.getClient();
if (!client) return undefined;
@@ -849,7 +914,7 @@ export class DeviceGateway {
exists: boolean;
isDirectory: boolean;
repoType?: 'git' | 'github';
}>({ deviceId, timeout, userId }, { method: 'statPath', params: { path } });
}>({ deviceId, timeout, userId, workspaceId }, { method: 'statPath', params: { path } });
if (!result.success || !result.data) {
log('statPath: failed for deviceId=%s — %s', deviceId, result.error);
@@ -876,6 +941,7 @@ export class DeviceGateway {
systemContext?: string;
topicId: string;
userId: string;
workspaceId?: string;
}): Promise<{ error?: string; success: boolean }> {
const client = this.getClient();
if (!client) return { error: 'GATEWAY_NOT_CONFIGURED', success: false };
@@ -890,7 +956,7 @@ export class DeviceGateway {
}
async executeToolCall(
params: { deviceId: string; operationId?: string; userId: string },
params: { deviceId: string; operationId?: string; userId: string; workspaceId?: string },
toolCall: { apiName: string; arguments: string; identifier: string },
timeout = 30_000,
): Promise<DeviceToolCallResult> {
@@ -919,6 +985,7 @@ export class DeviceGateway {
operationId: params.operationId,
timeout,
userId: params.userId,
workspaceId: params.workspaceId,
},
toolCall,
);
@@ -942,6 +1009,7 @@ export class DeviceGateway {
identifier: string;
params: GatewayMcpStdioParams;
userId: string;
workspaceId?: string;
},
timeout = 30_000,
): Promise<DeviceToolCallResult> {
@@ -972,7 +1040,7 @@ export class DeviceGateway {
}
async executeMessageApi(
params: { deviceId: string; userId: string },
params: { deviceId: string; userId: string; workspaceId?: string },
api: { apiName: string; payload: Record<string, unknown>; platform: string },
timeout = 30_000,
): Promise<DeviceMessageApiResult> {
@@ -995,7 +1063,12 @@ export class DeviceGateway {
try {
return await client.executeMessageApi(
{ deviceId: params.deviceId, timeout, userId: params.userId },
{
deviceId: params.deviceId,
timeout,
userId: params.userId,
workspaceId: params.workspaceId,
},
api,
);
} catch (error) {
+22 -21
View File
@@ -63,6 +63,7 @@ describe('TaskService', () => {
getDependencies: vi.fn(),
getDependenciesByTaskIds: vi.fn(),
getReviewConfig: vi.fn(),
getVerifyConfig: vi.fn(),
getTaskFileIds: vi.fn().mockResolvedValue([]),
getTreeAgentIdsForTaskIds: vi.fn().mockResolvedValue({}),
getTreePinnedDocuments: vi.fn(),
@@ -131,7 +132,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -189,7 +190,7 @@ describe('TaskService', () => {
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.findById.mockResolvedValue(parentTask);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-2');
@@ -232,7 +233,7 @@ describe('TaskService', () => {
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.findById.mockResolvedValue(null);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-2');
@@ -292,7 +293,7 @@ describe('TaskService', () => {
mockTaskModel.getDependenciesByTaskIds.mockResolvedValue(subtaskDeps);
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -374,7 +375,7 @@ describe('TaskService', () => {
mockTaskModel.getDependenciesByTaskIds.mockResolvedValue([]);
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -433,7 +434,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue(depTasks);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-3');
@@ -474,7 +475,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-3');
@@ -544,7 +545,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -604,7 +605,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
// Mock model methods to return agent and user data
mockAgentModel.getAgentAvatarsByIds.mockResolvedValue([
@@ -670,7 +671,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -726,7 +727,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -767,7 +768,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -831,7 +832,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue(workspace);
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -878,7 +879,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -919,7 +920,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -957,7 +958,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockRejectedValue(new Error('DB error'));
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -1020,7 +1021,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
mockAgentModel.getAgentAvatarsByIds.mockResolvedValue([
{ avatar: 'avatar.png', backgroundColor: '#fff', id: 'agent-1', title: 'Agent One' },
]);
@@ -1091,7 +1092,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -1147,7 +1148,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
// Force the brief enrichment path to reject without breaking the
// sibling resolveAuthors call (which shares the agent model mock).
const enrichSpy = vi
@@ -1213,7 +1214,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
@@ -1273,7 +1274,7 @@ describe('TaskService', () => {
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
mockTaskModel.getVerifyConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
+1 -1
View File
@@ -691,7 +691,6 @@ export class TaskService {
name: task.name,
parent,
priority: task.priority,
review: this.taskModel.getReviewConfig(task),
schedule:
task.schedulePattern || task.scheduleTimezone || scheduleConfig.maxExecutions != null
? {
@@ -702,6 +701,7 @@ export class TaskService {
: undefined,
status: task.status,
userId: task.assigneeUserId,
verify: this.taskModel.getVerifyConfig(task),
subtasks,
activities: activities.length > 0 ? activities : undefined,
topicCount: topics.length > 0 ? topics.length : undefined,
@@ -83,7 +83,7 @@ describe('localSystemRuntime', () => {
const result = await proxy[apiName](args);
expect(mockExecuteToolCall).toHaveBeenCalledWith(
{ deviceId: 'device-1', operationId: 'op-1', userId: 'user-1' },
{ deviceId: 'device-1', operationId: 'op-1', userId: 'user-1', workspaceId: undefined },
{
apiName,
arguments: JSON.stringify(args),
@@ -110,13 +110,38 @@ describe('localSystemRuntime', () => {
await proxy[apiName](complexArgs);
expect(mockExecuteToolCall).toHaveBeenCalledWith(
{ deviceId: 'device-2', userId: 'user-2' },
{ deviceId: 'device-2', userId: 'user-2', workspaceId: undefined },
expect.objectContaining({
arguments: JSON.stringify(complexArgs),
}),
undefined,
);
});
it('should forward workspaceId so workspace-owned devices route to the correct gateway pool', async () => {
const context: ToolExecutionContext = {
activeDeviceId: 'device-ws',
toolManifestMap: {},
userId: 'user-1',
workspaceId: 'ws-42',
};
mockExecuteToolCall.mockResolvedValue({ content: '', success: true });
const proxy = localSystemRuntime.factory(context);
const apiName = LocalSystemManifest.api[0].name;
await proxy[apiName]({ path: '/tmp' });
expect(mockExecuteToolCall).toHaveBeenCalledWith(
{ deviceId: 'device-ws', userId: 'user-1', workspaceId: 'ws-42' },
expect.objectContaining({
apiName,
identifier: LocalSystemIdentifier,
}),
undefined,
);
});
});
describe('working directory injection', () => {
@@ -2,7 +2,7 @@ import {
RemoteDeviceExecutionRuntime,
RemoteDeviceIdentifier,
} from '@lobechat/builtin-tool-remote-device';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type ToolExecutionContext } from '../../types';
@@ -17,6 +17,10 @@ vi.mock('@/server/services/deviceGateway', () => ({
// Import after mock setup
const { remoteDeviceRuntime } = await import('../remoteDevice');
beforeEach(() => {
mockQueryDeviceList.mockReset();
});
describe('remoteDeviceRuntime', () => {
it('should have the correct identifier', () => {
expect(remoteDeviceRuntime.identifier).toBe(RemoteDeviceIdentifier);
@@ -44,7 +48,7 @@ describe('remoteDeviceRuntime', () => {
expect(runtime).toBeInstanceOf(RemoteDeviceExecutionRuntime);
});
it('should pass queryDeviceList that calls deviceGateway with the userId', async () => {
it('should query only the personal pool when no workspaceId is in context', async () => {
const context: ToolExecutionContext = {
toolManifestMap: {},
userId: 'user-1',
@@ -63,11 +67,54 @@ describe('remoteDeviceRuntime', () => {
const runtime = remoteDeviceRuntime.factory(context) as RemoteDeviceExecutionRuntime;
// Call listOnlineDevices which internally calls queryDeviceList
const result = await runtime.listOnlineDevices();
expect(mockQueryDeviceList).toHaveBeenCalledTimes(1);
expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1');
expect(result.success).toBe(true);
});
it('should merge personal + workspace pools when workspaceId is in context', async () => {
const context: ToolExecutionContext = {
toolManifestMap: {},
userId: 'user-1',
workspaceId: 'ws-1',
};
const personalDevice = {
deviceId: 'd-personal',
hostname: 'laptop',
lastSeen: '2024-01-01',
online: true,
platform: 'darwin',
};
const workspaceDevice = {
deviceId: 'd-workspace',
hostname: 'shared-mac',
lastSeen: '2024-01-01',
online: true,
platform: 'darwin',
};
mockQueryDeviceList.mockImplementation((_userId: string, wsId?: string) =>
Promise.resolve(wsId ? [workspaceDevice] : [personalDevice]),
);
const runtime = remoteDeviceRuntime.factory(context) as RemoteDeviceExecutionRuntime;
const result = await runtime.listOnlineDevices();
expect(mockQueryDeviceList).toHaveBeenCalledTimes(2);
expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1');
expect(mockQueryDeviceList).toHaveBeenCalledWith('user-1', 'ws-1');
expect(result.success).toBe(true);
const parsed = JSON.parse(result.content);
expect(parsed).toEqual(
expect.arrayContaining([
expect.objectContaining({ deviceId: 'd-personal' }),
expect.objectContaining({ deviceId: 'd-workspace' }),
]),
);
});
});
});
@@ -65,6 +65,10 @@ export const localSystemRuntime: ServerRuntimeRegistration = {
deviceId: context.activeDeviceId!,
operationId: context.operationId,
userId: context.userId!,
// Workspace devices live under the `workspace:<id>` principal in
// the gateway, so the relay needs the workspaceId to address the
// right DO pool. Personal device runs leave it undefined.
workspaceId: context.workspaceId,
},
{
apiName: api.name,
@@ -14,9 +14,21 @@ export const remoteDeviceRuntime: ServerRuntimeRegistration = {
}
const userId = context.userId;
const workspaceId = context.workspaceId;
return new RemoteDeviceExecutionRuntime({
queryDeviceList: () => deviceGateway.queryDeviceList(userId),
// Personal pool (user principal) the current workspace's shared pool
// (workspace principal). Mirrors execAgent's onlineDevices fetch so the
// tool refresh stays consistent with the systemRole snapshot — otherwise
// a workspace-bound chat would see its workspace device in the system
// prompt but lose it the moment the model calls listOnlineDevices.
queryDeviceList: async () => {
const [personal, workspace] = await Promise.all([
deviceGateway.queryDeviceList(userId),
workspaceId ? deviceGateway.queryDeviceList(userId, workspaceId) : Promise.resolve([]),
]);
return [...personal, ...workspace];
},
});
},
identifier: RemoteDeviceIdentifier,
@@ -150,9 +150,12 @@ export const createTaskRuntime = (deps: TaskRuntimeDeps) => {
},
createTask: async (args: CreateTaskArgs) => {
const result = await createTaskImpl(args);
const { identifier: _identifier, ...rest } = result;
return rest;
const { identifier, ...rest } = await createTaskImpl(args);
// Surface the created task identifier as plugin state (mirrors the client
// executor's `{ identifier, success }`) so the inline render can link to
// the task detail. Without this the tool message persists no state and the
// card has nothing to open.
return identifier ? { ...rest, state: { identifier, success: rest.success } } : rest;
},
createTasks: async (args: { tasks: CreateTaskArgs[] }) => {
@@ -4,6 +4,7 @@ import debug from 'debug';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyRunModel } from '@/database/models/verifyRun';
import type { LobeChatDatabase } from '@/database/type';
import { maybeAutoRepair, VerifyStatusService } from '@/server/services/verify';
@@ -55,9 +56,17 @@ class VerifyResultExecutionRuntime {
);
const targetOperationId = op?.parentOperationId ?? this.operationId;
// The result row is keyed by the parent run's verification session.
const run = await new VerifyRunModel(this.db, this.userId, this.workspaceId).findByOperation(
targetOperationId,
);
if (!run) {
return { content: 'No verification session for this run.', error: 'NO_RUN', success: false };
}
const status = params.verdict === 'passed' ? 'passed' : 'failed';
await new VerifyCheckResultModel(this.db, this.userId, this.workspaceId).updateByCheckItem(
targetOperationId,
run.id,
params.checkItemId,
{
completedAt: new Date(),
@@ -0,0 +1,120 @@
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { VerifyToolIdentifier } from '@lobechat/builtin-tool-verify';
import type { VerifyCheckItem } from '@lobechat/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createVerifierAgentRunner } from '../agentVerifier';
// AgentModel/ThreadModel expose their methods as arrow-function class fields
// (instance props, not on the prototype), so they can't be spied via the
// prototype — mock the modules instead. Hoisted so the factories can close over them.
const { existsByIdMock, getBuiltinAgentMock, threadCreateMock, execAgentMock } = vi.hoisted(() => ({
execAgentMock: vi.fn(async (_params: any) => ({ operationId: 'verifier-op-1' })),
existsByIdMock: vi.fn(),
getBuiltinAgentMock: vi.fn(),
threadCreateMock: vi.fn(async () => ({ id: 'thread-1' })),
}));
/** The single execAgent param object, asserted to exist. */
const execParams = (): any => {
const call = execAgentMock.mock.calls[0];
expect(call).toBeDefined();
return call![0];
};
vi.mock('@/database/models/agent', () => ({
AgentModel: vi.fn().mockImplementation(() => ({
existsById: existsByIdMock,
getBuiltinAgent: getBuiltinAgentMock,
})),
}));
vi.mock('@/database/models/thread', () => ({
ThreadModel: vi.fn().mockImplementation(() => ({ create: threadCreateMock })),
}));
// The runner dynamically imports AiAgentService to break a static cycle.
vi.mock('@/server/services/aiAgent', () => ({
AiAgentService: vi.fn().mockImplementation(() => ({ execAgent: execAgentMock })),
}));
const checkItem: VerifyCheckItem = {
id: 'check-1',
index: 0,
onFail: 'manual',
required: true,
title: 'Toolbar renders',
verifierConfig: {},
verifierType: 'agent',
};
const runnerArgs = { checkItem, goal: 'ship the toolbar', operationId: 'parent-op-1' };
const db = {} as any;
const baseParams = {
db,
deliverable: 'the toolbar',
model: 'gpt-parent',
provider: 'openai',
topicId: 'topic-1',
userId: 'u',
};
describe('createVerifierAgentRunner', () => {
beforeEach(() => {
vi.clearAllMocks();
threadCreateMock.mockResolvedValue({ id: 'thread-1' });
execAgentMock.mockResolvedValue({ operationId: 'verifier-op-1' });
});
it('returns undefined without a topicId (no thread to host the verifier)', () => {
const runner = createVerifierAgentRunner({ db, deliverable: 'x', topicId: null, userId: 'u' });
expect(runner).toBeUndefined();
});
it('runs a pinned agent by agentId, keeping its own model/provider', async () => {
existsByIdMock.mockResolvedValue(true);
const runner = createVerifierAgentRunner({ ...baseParams, verifierAgentId: 'agent-codex' })!;
const result = await runner(runnerArgs);
expect(result).toEqual({ verifierOperationId: 'verifier-op-1' });
expect(existsByIdMock).toHaveBeenCalledWith('agent-codex');
expect(getBuiltinAgentMock).not.toHaveBeenCalled();
const params = execParams();
expect(params.agentId).toBe('agent-codex');
// A pinned agent keeps its own agency — never overridden by the parent run.
expect(params.slug).toBeUndefined();
expect(params.model).toBeUndefined();
expect(params.provider).toBeUndefined();
// A pinned agent lacks the writeback tool, so it must be injected.
expect(params.additionalPluginIds).toEqual([VerifyToolIdentifier]);
});
it('falls back to the builtin verify agent (by slug) inheriting parent model/provider', async () => {
existsByIdMock.mockResolvedValue(false); // pinned id no longer exists
getBuiltinAgentMock.mockResolvedValue({ id: 'builtin-verify' });
const runner = createVerifierAgentRunner({ ...baseParams, verifierAgentId: 'agent-deleted' })!;
await runner(runnerArgs);
expect(getBuiltinAgentMock).toHaveBeenCalledWith(BUILTIN_AGENT_SLUGS.verifyAgent);
const params = execParams();
expect(params.slug).toBe(BUILTIN_AGENT_SLUGS.verifyAgent);
expect(params.agentId).toBeUndefined();
expect(params.model).toBe('gpt-parent');
expect(params.provider).toBe('openai');
// The builtin verify agent already declares the tool — not re-injected.
expect(params.additionalPluginIds).toBeUndefined();
});
it('uses the builtin agent when no verifierAgentId is pinned', async () => {
getBuiltinAgentMock.mockResolvedValue({ id: 'builtin-verify' });
const runner = createVerifierAgentRunner({ ...baseParams })!;
await runner(runnerArgs);
// No pinned id → never probes existsById, goes straight to the builtin.
expect(existsByIdMock).not.toHaveBeenCalled();
expect(execParams().slug).toBe(BUILTIN_AGENT_SLUGS.verifyAgent);
});
});
@@ -1,4 +1,5 @@
import { BUILTIN_AGENT_SLUGS } from '@lobechat/builtin-agents';
import { VerifyToolIdentifier } from '@lobechat/builtin-tool-verify';
import type { VerifyCheckItem } from '@lobechat/types';
import { ThreadType } from '@lobechat/types';
import debug from 'debug';
@@ -37,24 +38,35 @@ export const buildVerifierPrompt = (params: {
};
/**
* Build a {@link VerifierAgentRunner} that runs each `agent`-type check as the
* dedicated builtin **verify agent**: it materializes the verify agent, opens an
* isolated thread, and `execAgent`s (headless) with the check context (incl.
* `checkItemId`) injected into the prompt. The verify agent investigates and
* writes its verdict back via the `submitVerifyResult` tool during its run no
* document creation, no output parsing, no external completion hook.
* Build a {@link VerifierAgentRunner} that runs each `agent`-type check as a
* **verify agent**: it opens an isolated thread and `execAgent`s (headless) with
* the check context (incl. `checkItemId`) injected into the prompt. The verify
* agent investigates and writes its verdict back via the `submitVerifyResult`
* tool during its run no document creation, no output parsing, no external
* completion hook.
*
* Which agent runs is selectable: when the task pins a `verifierAgentId`
* (`TaskVerifyConfig.verifierAgentId`) that agent runs under its OWN agency
* config (executionTarget / device / provider) so picking a heterogeneous
* agent (e.g. Codex) naturally gives the verifier device + browser access. When
* unset (or the pinned agent no longer exists) it falls back to the builtin
* verify agent, which inherits the parent run's model/provider (its own default
* may not point at a configured provider).
*/
export const createVerifierAgentRunner = (params: {
db: LobeChatDatabase;
deliverable: string;
/** Inherit the parent run's model so the verifier uses a configured provider. */
/** Inherit the parent run's model so the builtin fallback uses a configured provider. */
model?: string | null;
provider?: string | null;
topicId?: string | null;
userId: string;
/** Task-pinned verify agent. Falls back to the builtin verify agent when unset/missing. */
verifierAgentId?: string | null;
workspaceId?: string;
}): VerifierAgentRunner | undefined => {
const { db, deliverable, model, provider, topicId, userId, workspaceId } = params;
const { db, deliverable, model, provider, topicId, userId, verifierAgentId, workspaceId } =
params;
if (!topicId) return undefined;
return async ({ checkItem, goal, operationId }) => {
@@ -64,17 +76,42 @@ export const createVerifierAgentRunner = (params: {
?.content ?? undefined)
: undefined;
// Materialize the builtin verify agent (idempotent) to get an id for the thread.
const verifyAgent = await new AgentModel(db, userId, workspaceId).getBuiltinAgent(
BUILTIN_AGENT_SLUGS.verifyAgent,
);
if (!verifyAgent) {
log('verify agent unavailable, cannot run agent verifier for check %s', checkItem.id);
return null;
const agentModel = new AgentModel(db, userId, workspaceId);
// Resolve which agent verifies. A pinned agent runs as itself (`agentId`) so
// its own agency config drives execution target/provider — we don't override
// its model/provider. The builtin fallback runs by `slug` and inherits the
// parent run's model/provider.
let threadAgentId: string;
let agentRef: { agentId: string } | { slug: string };
let inheritModel = false;
// A pinned agent (selected for its runtime/device) carries only its own
// configured plugins, so it lacks the verify writeback tool — inject it, else
// the verdict never lands and the check result is stuck `running`. The builtin
// verify agent already declares this tool in its plugins, so it isn't re-added.
let extraPluginIds: string[] = [];
if (verifierAgentId && (await agentModel.existsById(verifierAgentId))) {
threadAgentId = verifierAgentId;
agentRef = { agentId: verifierAgentId };
extraPluginIds = [VerifyToolIdentifier];
} else {
if (verifierAgentId) {
log('pinned verify agent %s not found, falling back to builtin', verifierAgentId);
}
// Materialize the builtin verify agent (idempotent) to get an id for the thread.
const builtin = await agentModel.getBuiltinAgent(BUILTIN_AGENT_SLUGS.verifyAgent);
if (!builtin) {
log('verify agent unavailable, cannot run agent verifier for check %s', checkItem.id);
return null;
}
threadAgentId = builtin.id;
agentRef = { slug: BUILTIN_AGENT_SLUGS.verifyAgent };
inheritModel = true;
}
const thread = await new ThreadModel(db, userId, workspaceId).create({
agentId: verifyAgent.id,
agentId: threadAgentId,
title: `Verify: ${checkItem.title}`,
topicId,
type: ThreadType.Isolation,
@@ -88,15 +125,17 @@ export const createVerifierAgentRunner = (params: {
// → verify lifecycle → this runner → aiAgent.
const { AiAgentService } = await import('@/server/services/aiAgent');
const result = await new AiAgentService(db, userId, { workspaceId }).execAgent({
// Inject the verify writeback tool for pinned agents (no-op list otherwise).
...(extraPluginIds.length ? { additionalPluginIds: extraPluginIds } : {}),
appContext: { threadId: thread.id, topicId },
autoStart: true,
// Inherit the parent run's model/provider so the verifier uses a provider
// that's actually configured (the builtin agent's default may not be).
...(model ? { model } : {}),
// Only the builtin fallback inherits the parent run's model/provider; a
// pinned agent keeps its own (critical for heterogeneous runtimes).
...(inheritModel && model ? { model } : {}),
parentOperationId: operationId,
prompt: buildVerifierPrompt({ checkItem, deliverable, goal, instruction }),
...(provider ? { provider } : {}),
slug: BUILTIN_AGENT_SLUGS.verifyAgent,
...(inheritModel && provider ? { provider } : {}),
...agentRef,
userInterventionConfig: { approvalMode: 'headless' },
});
+52 -32
View File
@@ -10,9 +10,9 @@ import type {
} from '@lobechat/types';
import debug from 'debug';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { DocumentModel } from '@/database/models/document';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyRunModel } from '@/database/models/verifyRun';
import type { NewVerifyCheckResult } from '@/database/schemas/verify';
import type { LobeChatDatabase } from '@/database/type';
import { AiGenerationService } from '@/server/services/aiGeneration';
@@ -75,7 +75,7 @@ const toToulmin = (v: SingleVerdict): ToulminVerdict => ({
export class VerifyExecutorService {
private readonly db: LobeChatDatabase;
private readonly userId: string;
private readonly operationModel: AgentOperationModel;
private readonly runModel: VerifyRunModel;
private readonly resultModel: VerifyCheckResultModel;
private readonly statusService: VerifyStatusService;
private readonly documentModel: DocumentModel;
@@ -83,7 +83,7 @@ export class VerifyExecutorService {
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.db = db;
this.userId = userId;
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.runModel = new VerifyRunModel(db, userId, workspaceId);
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
this.statusService = new VerifyStatusService(db, userId, workspaceId);
this.documentModel = new DocumentModel(db, userId, workspaceId);
@@ -105,20 +105,22 @@ export class VerifyExecutorService {
* spawner (results land asynchronously). Recomputes the rollup at the end.
*/
async execute(params: ExecuteVerifyParams): Promise<void> {
const state = await this.operationModel.getVerifyState(params.operationId);
if (!state?.verifyPlan?.length) {
// Resolve (or lazily create) the verification session bound to this Agent Run.
const run = await this.runModel.ensureForOperation(params.operationId);
if (!run.plan?.length) {
log('execute: no plan for op %s, skipping', params.operationId);
return;
}
if (!state.verifyPlanConfirmedAt) {
if (!run.planConfirmedAt) {
log('execute: plan for op %s not confirmed, skipping', params.operationId);
return;
}
const items = state.verifyPlan as VerifyCheckItem[];
const verifyRunId = run.id;
const items = run.plan as VerifyCheckItem[];
// Idempotently create the pending result rows (skip ones already present).
const existing = await this.resultModel.listByOperation(params.operationId);
const existing = await this.resultModel.listByRun(verifyRunId);
const existingIds = new Set(existing.map((r) => r.checkItemId));
const toCreate: Omit<NewVerifyCheckResult, 'userId'>[] = items
.filter((i) => !existingIds.has(i.id))
@@ -126,11 +128,13 @@ export class VerifyExecutorService {
checkItemId: item.id,
checkItemIndex: item.index,
checkItemTitle: item.title,
// Denormalized direct link to the Agent Run (canonical link is verifyRunId).
operationId: params.operationId,
required: item.required,
status: 'pending' as const,
verifierConfigHash: hashConfig(item.verifierConfig),
verifierType: item.verifierType,
verifyRunId,
}));
if (toCreate.length > 0) await this.resultModel.createMany(toCreate);
@@ -143,18 +147,18 @@ export class VerifyExecutorService {
// The three verifier kinds are independent — run them concurrently. LLM items
// are judged in one batched call; each agent item spawns its own sub-agent.
await Promise.all([
this.runProgramItems(params.operationId, programItems),
this.runLlmItems(params, llmItems),
...agentItems.map((item) => this.runAgentItem(params, item)),
this.runProgramItems(verifyRunId, programItems),
this.runLlmItems(params, verifyRunId, llmItems),
...agentItems.map((item) => this.runAgentItem(params, verifyRunId, item)),
]);
await this.statusService.recompute(params.operationId);
}
/** Program verifiers are a v1 placeholder (no shell environment) — mark skipped. */
private async runProgramItems(operationId: string, items: VerifyCheckItem[]): Promise<void> {
private async runProgramItems(verifyRunId: string, items: VerifyCheckItem[]): Promise<void> {
for (const item of items) {
await this.resultModel.updateByCheckItem(operationId, item.id, {
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
completedAt: new Date(),
status: 'skipped',
toulmin: { limitation: 'Program verifier is not executed in v1.' },
@@ -163,13 +167,17 @@ export class VerifyExecutorService {
}
/** Judge all LLM items via the Toulmin judge (one batched call by default). */
private async runLlmItems(params: ExecuteVerifyParams, items: VerifyCheckItem[]): Promise<void> {
private async runLlmItems(
params: ExecuteVerifyParams,
verifyRunId: string,
items: VerifyCheckItem[],
): Promise<void> {
if (items.length === 0) return;
try {
if (params.batchLlm ?? true) {
await this.judgeBatch(params, items);
await this.judgeBatch(params, verifyRunId, items);
} else {
for (const item of items) await this.judgeSingle(params, item);
for (const item of items) await this.judgeSingle(params, verifyRunId, item);
}
} catch (error) {
log('llm judge failed for op %s: %O', params.operationId, error);
@@ -178,9 +186,13 @@ export class VerifyExecutorService {
}
/** Run one agent check as a verifier sub-agent (verdict lands async via its hook) or skip. */
private async runAgentItem(params: ExecuteVerifyParams, item: VerifyCheckItem): Promise<void> {
private async runAgentItem(
params: ExecuteVerifyParams,
verifyRunId: string,
item: VerifyCheckItem,
): Promise<void> {
if (!params.runVerifierAgent) {
await this.resultModel.updateByCheckItem(params.operationId, item.id, {
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
completedAt: new Date(),
status: 'skipped',
toulmin: { limitation: 'Agent verifier requires runtime context; not run here.' },
@@ -193,14 +205,14 @@ export class VerifyExecutorService {
goal: params.goal,
operationId: params.operationId,
});
await this.resultModel.updateByCheckItem(params.operationId, item.id, {
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
startedAt: new Date(),
status: 'running',
verifierOperationId: spawned?.verifierOperationId ?? null,
});
} catch (error) {
log('agent verifier spawn failed for item %s: %O', item.id, error);
await this.resultModel.updateByCheckItem(params.operationId, item.id, {
await this.resultModel.updateByCheckItem(verifyRunId, item.id, {
completedAt: new Date(),
status: 'failed',
toulmin: { limitation: 'Agent verifier failed to start.' },
@@ -209,7 +221,11 @@ export class VerifyExecutorService {
}
}
private async judgeBatch(params: ExecuteVerifyParams, items: VerifyCheckItem[]): Promise<void> {
private async judgeBatch(
params: ExecuteVerifyParams,
verifyRunId: string,
items: VerifyCheckItem[],
): Promise<void> {
// Batch: N verdicts share ONE tracing row (N:1).
const tracingId = randomUUID();
const promptItems = await Promise.all(
@@ -248,7 +264,7 @@ export class VerifyExecutorService {
// Backfill the tracing FK only after the (async, best-effort) tracing
// row is persisted — verdicts are written with a null link below.
onPersisted: this.backfillTracing(
params.operationId,
verifyRunId,
items.map((i) => i.id),
),
},
@@ -266,13 +282,17 @@ export class VerifyExecutorService {
if (!validIds.has(v.checkItemId)) continue;
await this.writeVerdict({
checkItemId: v.checkItemId,
operationId: params.operationId,
verdict: v,
verifyRunId,
});
}
}
private async judgeSingle(params: ExecuteVerifyParams, item: VerifyCheckItem): Promise<void> {
private async judgeSingle(
params: ExecuteVerifyParams,
verifyRunId: string,
item: VerifyCheckItem,
): Promise<void> {
// Per-criterion: each result gets its own tracing row (1:1).
const tracingId = randomUUID();
const { system, user } = buildJudgePrompt({
@@ -301,7 +321,7 @@ export class VerifyExecutorService {
schemaName: SINGLE_VERDICT_JSON_SCHEMA.name,
tracingId,
} satisfies TracingOptions),
onPersisted: this.backfillTracing(params.operationId, [item.id]),
onPersisted: this.backfillTracing(verifyRunId, [item.id]),
},
},
);
@@ -313,21 +333,21 @@ export class VerifyExecutorService {
}
await this.writeVerdict({
checkItemId: item.id,
operationId: params.operationId,
verdict: parsed.data,
verifyRunId,
});
}
private async writeVerdict(params: {
checkItemId: string;
operationId: string;
verdict: SingleVerdict;
verifyRunId: string;
}): Promise<void> {
const { operationId, checkItemId, verdict } = params;
const { verifyRunId, checkItemId, verdict } = params;
// `verifier_tracing_id` is intentionally left null here — the tracing row is
// written asynchronously (best-effort, after the response), so linking it now
// would violate the FK. It is backfilled by `backfillTracing` once the row exists.
await this.resultModel.updateByCheckItem(operationId, checkItemId, {
await this.resultModel.updateByCheckItem(verifyRunId, checkItemId, {
completedAt: new Date(),
confidence: verdict.confidence,
status: verdictToStatus(verdict.verdict),
@@ -344,13 +364,13 @@ export class VerifyExecutorService {
* FK link. Receives the persisted tracing id (or null if tracing was disabled
* or the record failed), so a missing tracing row simply leaves the link null.
*/
private backfillTracing(operationId: string, checkItemIds: string[]) {
private backfillTracing(verifyRunId: string, checkItemIds: string[]) {
return async (tracingId: string | null): Promise<void> => {
if (!tracingId) return;
try {
await this.resultModel.backfillTracingId(operationId, checkItemIds, tracingId);
await this.resultModel.backfillTracingId(verifyRunId, checkItemIds, tracingId);
} catch (error) {
log('tracing-id backfill failed for op %s (non-fatal): %O', operationId, error);
log('tracing-id backfill failed for run %s (non-fatal): %O', verifyRunId, error);
}
};
}
+21 -7
View File
@@ -1,6 +1,8 @@
import debug from 'debug';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { TaskModel } from '@/database/models/task';
import { VerifyRunModel } from '@/database/models/verifyRun';
import type { LobeChatDatabase } from '@/database/type';
import { createVerifierAgentRunner } from './agentVerifier';
@@ -36,27 +38,38 @@ export const runVerifyOnCompletion = async (
workspaceId?: string,
): Promise<void> => {
try {
const operationModel = new AgentOperationModel(db, userId, workspaceId);
const state = await operationModel.getVerifyState(params.operationId);
const run = await new VerifyRunModel(db, userId, workspaceId).findByOperation(
params.operationId,
);
// Opt-in gate: only runs with a confirmed plan that hasn't been verified yet.
if (!state?.verifyPlan?.length || !state.verifyPlanConfirmedAt) return;
if (state.verifyStatus !== 'planned') return;
if (!run?.plan?.length || !run.planConfirmedAt) return;
if (run.status !== 'planned') return;
const op = await operationModel.findById(params.operationId);
const op = await new AgentOperationModel(db, userId, workspaceId).findById(params.operationId);
if (!op?.model || !op?.provider) {
log('op %s missing model/provider, cannot run verify', params.operationId);
return;
}
// Task-bound runs may pin which agent verifies (TaskVerifyConfig.verifierAgentId,
// with subtask inheritance). Non-task runs leave it undefined → builtin fallback.
let verifierAgentId: string | undefined;
if (op.taskId) {
const verifyConfig = await new TaskModel(db, userId, workspaceId).resolveVerifyConfig(
op.taskId,
);
verifierAgentId = verifyConfig?.verifierAgentId ?? undefined;
}
const executor = new VerifyExecutorService(db, userId, workspaceId);
await executor.execute({
deliverable: params.deliverable,
goal: params.goal,
modelConfig: { model: op.model, provider: op.provider },
operationId: params.operationId,
// `agent`-type checks run as the dedicated builtin verify agent, which
// writes its verdict back via the submitVerifyResult tool during its run.
// `agent`-type checks run as the task-pinned verify agent (or the builtin
// one), which writes its verdict back via the submitVerifyResult tool.
runVerifierAgent: createVerifierAgentRunner({
db,
deliverable: params.deliverable,
@@ -64,6 +77,7 @@ export const runVerifyOnCompletion = async (
provider: op.provider,
topicId: op.topicId,
userId,
verifierAgentId,
workspaceId,
}),
});
@@ -5,10 +5,10 @@ import type { TracingOptions } from '@lobechat/llm-generation-tracing';
import type { VerifyCheckItem } from '@lobechat/types';
import debug from 'debug';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { DocumentModel } from '@/database/models/document';
import { VerifyCriterionModel } from '@/database/models/verifyCriterion';
import { VerifyRubricModel } from '@/database/models/verifyRubric';
import { VerifyRunModel } from '@/database/models/verifyRun';
import type { VerifyCriterionItem } from '@/database/schemas/verify';
import type { LobeChatDatabase } from '@/database/type';
import { AiGenerationService } from '@/server/services/aiGeneration';
@@ -72,7 +72,7 @@ export class VerifyPlanGeneratorService {
private readonly userId: string;
private readonly criterionModel: VerifyCriterionModel;
private readonly rubricModel: VerifyRubricModel;
private readonly operationModel: AgentOperationModel;
private readonly runModel: VerifyRunModel;
private readonly documentModel: DocumentModel;
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
@@ -80,7 +80,7 @@ export class VerifyPlanGeneratorService {
this.userId = userId;
this.criterionModel = new VerifyCriterionModel(db, userId, workspaceId);
this.rubricModel = new VerifyRubricModel(db, userId, workspaceId);
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.runModel = new VerifyRunModel(db, userId, workspaceId);
this.documentModel = new DocumentModel(db, userId, workspaceId);
}
@@ -155,9 +155,10 @@ export class VerifyPlanGeneratorService {
// 3. Aggregate the criteria under the rubric (criteria reusable across rubrics).
await this.rubricModel.setCriteria(rubric.id, links);
// 4. Snapshot onto the operation + confirm so it runs when the op completes.
await this.operationModel.setVerifyPlan(params.operationId, items);
await this.operationModel.confirmVerifyPlan(params.operationId);
// 4. Snapshot onto the run + confirm so it runs when the op completes.
const run = await this.runModel.ensureForOperation(params.operationId, { title: params.title });
await this.runModel.setPlan(run.id, items);
await this.runModel.confirmPlan(run.id);
log(
'created rubric %s with %d criteria for op %s',
@@ -215,7 +216,8 @@ export class VerifyPlanGeneratorService {
}
}
await this.operationModel.setVerifyPlan(params.operationId, items);
const run = await this.runModel.ensureForOperation(params.operationId, { goal: params.goal });
await this.runModel.setPlan(run.id, items);
log('generated draft plan for op %s with %d items', params.operationId, items.length);
return items;
@@ -6,6 +6,7 @@ import { AgentOperationModel } from '@/database/models/agentOperation';
import { MessageModel } from '@/database/models/message';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyRubricModel } from '@/database/models/verifyRubric';
import { VerifyRunModel } from '@/database/models/verifyRun';
import type { VerifyCheckResultItem } from '@/database/schemas/verify';
import type { LobeChatDatabase } from '@/database/type';
@@ -114,13 +115,15 @@ export const createRepairRunner = (params: {
});
const repairOperationId = result.operationId;
// Re-snapshot the same plan onto the repair op + confirm, so the repair run
// re-verifies (round N+1) against its corrected deliverable on completion.
const state = await operationModel.getVerifyState(operationId);
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
// Re-snapshot the same plan onto the repair op's session + confirm, so the
// repair run re-verifies (round N+1) against its corrected deliverable.
const runModel = new VerifyRunModel(db, userId, workspaceId);
const sourceRun = await runModel.findByOperation(operationId);
const plan = (sourceRun?.plan ?? []) as VerifyCheckItem[];
if (plan.length > 0) {
await operationModel.setVerifyPlan(repairOperationId, plan);
await operationModel.confirmVerifyPlan(repairOperationId);
const repairRun = await runModel.ensureForOperation(repairOperationId);
await runModel.setPlan(repairRun.id, plan);
await runModel.confirmPlan(repairRun.id);
}
log('repair op %s → %s (round %d)', operationId, repairOperationId, round + 1);
@@ -143,13 +146,11 @@ export const maybeAutoRepair = async (
workspaceId?: string,
): Promise<void> => {
const operationModel = new AgentOperationModel(db, userId, workspaceId);
const state = await operationModel.getVerifyState(operationId);
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
if (plan.length === 0) return;
const run = await new VerifyRunModel(db, userId, workspaceId).findByOperation(operationId);
const plan = (run?.plan ?? []) as VerifyCheckItem[];
if (!run || plan.length === 0) return;
const results = await new VerifyCheckResultModel(db, userId, workspaceId).listByOperation(
operationId,
);
const results = await new VerifyCheckResultModel(db, userId, workspaceId).listByRun(run.id);
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
// Wait until every required check has a terminal result (don't repair early).
@@ -193,22 +194,23 @@ const buildInstruction = (
export class VerifyRepairService {
private readonly messageModel: MessageModel;
private readonly operationModel: AgentOperationModel;
private readonly runModel: VerifyRunModel;
private readonly resultModel: VerifyCheckResultModel;
private readonly statusService: VerifyStatusService;
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.messageModel = new MessageModel(db, userId, workspaceId);
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.runModel = new VerifyRunModel(db, userId, workspaceId);
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
this.statusService = new VerifyStatusService(db, userId, workspaceId);
}
/** Collect the auto-repairable failures for a run. */
async collectRepairable(operationId: string) {
const state = await this.operationModel.getVerifyState(operationId);
const plan = (state?.verifyPlan ?? []) as VerifyCheckItem[];
const results = await this.resultModel.listByOperation(operationId);
const run = await this.runModel.findByOperation(operationId);
if (!run) return [];
const plan = (run.plan ?? []) as VerifyCheckItem[];
const results = await this.resultModel.listByRun(run.id);
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
return plan
@@ -254,10 +256,13 @@ export class VerifyRepairService {
if (!spawned) return null;
// Link the repair operation onto each failed result and flip the rollup.
for (const { item } of failures) {
await this.resultModel.updateByCheckItem(operationId, item.id, {
repairOperationId: spawned.repairOperationId,
});
const run = await this.runModel.findByOperation(operationId);
if (run) {
for (const { item } of failures) {
await this.resultModel.updateByCheckItem(run.id, item.id, {
repairOperationId: spawned.repairOperationId,
});
}
}
await this.statusService.markRepairing(operationId);
log('triggered auto-repair op %s → %s', operationId, spawned.repairOperationId);
@@ -1,24 +1,24 @@
import type { VerifyCheckItem } from '@lobechat/types';
import type { VerifyCheckItem, VerifyRunStatus } from '@lobechat/types';
import debug from 'debug';
import type { VerifyStatus } from '@/database/models/agentOperation';
import { AgentOperationModel } from '@/database/models/agentOperation';
import { VerifyCheckResultModel } from '@/database/models/verifyCheckResult';
import { VerifyRunModel } from '@/database/models/verifyRun';
import type { LobeChatDatabase } from '@/database/type';
const log = debug('lobe-server:verify-status');
/**
* Service-layer chokepoint for the denormalized `agent_operations.verify_status`
* rollup. MUST be the only writer of that column (besides explicit repair /
* deliver transitions) so the badge never drifts from the underlying results.
* Service-layer chokepoint for the denormalized `verify_runs.status` rollup. MUST
* be the only writer of that column (besides explicit repair / deliver
* transitions) so the badge never drifts from the underlying results. Addresses
* sessions by their bound Agent Run (`operationId`) for the agent pipeline.
*/
export class VerifyStatusService {
private readonly operationModel: AgentOperationModel;
private readonly runModel: VerifyRunModel;
private readonly resultModel: VerifyCheckResultModel;
constructor(db: LobeChatDatabase, userId: string, workspaceId?: string) {
this.operationModel = new AgentOperationModel(db, userId, workspaceId);
this.runModel = new VerifyRunModel(db, userId, workspaceId);
this.resultModel = new VerifyCheckResultModel(db, userId, workspaceId);
}
@@ -30,18 +30,18 @@ export class VerifyStatusService {
* - otherwise `passed`
* `skipped` results (e.g. v1 program placeholders) are pass-through.
*/
async recompute(operationId: string): Promise<VerifyStatus | null> {
const state = await this.operationModel.getVerifyState(operationId);
if (!state) return null;
async recompute(operationId: string): Promise<VerifyRunStatus | null> {
const run = await this.runModel.findByOperation(operationId);
if (!run) return null;
const plan = (state.verifyPlan ?? []) as VerifyCheckItem[];
const plan = (run.plan ?? []) as VerifyCheckItem[];
if (plan.length === 0) {
// No plan → nothing to verify. Leave as-is (unverified / skipped).
return state.verifyStatus ?? null;
return (run.status ?? null) as VerifyRunStatus | null;
}
if (!state.verifyPlanConfirmedAt) return 'planned';
if (!run.planConfirmedAt) return 'planned';
const results = await this.resultModel.listByOperation(operationId);
const results = await this.resultModel.listByRun(run.id);
const byItem = new Map(results.map((r) => [r.checkItemId, r]));
const requiredItems = plan.filter((i) => i.required);
@@ -58,11 +58,11 @@ export class VerifyStatusService {
if (result.status === 'failed' || result.verdict === 'failed') anyFailed = true;
}
const status: VerifyStatus = anyPending ? 'verifying' : anyFailed ? 'failed' : 'passed';
const status: VerifyRunStatus = anyPending ? 'verifying' : anyFailed ? 'failed' : 'passed';
if (status !== state.verifyStatus) {
await this.operationModel.updateVerifyStatus(operationId, status);
log('rollup op %s → %s', operationId, status);
if (status !== run.status) {
await this.runModel.updateStatus(run.id, status);
log('rollup op %s (run %s) → %s', operationId, run.id, status);
}
return status;
@@ -70,14 +70,24 @@ export class VerifyStatusService {
/** Explicit transitions that aren't derivable from results alone. */
async markVerifying(operationId: string) {
await this.operationModel.updateVerifyStatus(operationId, 'verifying');
await this.setStatus(operationId, 'verifying');
}
async markRepairing(operationId: string) {
await this.operationModel.updateVerifyStatus(operationId, 'repairing');
await this.setStatus(operationId, 'repairing');
}
async markDelivered(operationId: string) {
await this.operationModel.updateVerifyStatus(operationId, 'delivered');
await this.setStatus(operationId, 'delivered');
}
/** Resolve the session for an Agent Run and write its rollup status. */
private async setStatus(operationId: string, status: VerifyRunStatus): Promise<void> {
const run = await this.runModel.findByOperation(operationId);
if (!run) {
log('setStatus: no verify run for op %s, skipping %s', operationId, status);
return;
}
await this.runModel.updateStatus(run.id, status);
}
}
+1
View File
@@ -47,6 +47,7 @@ services:
- '5432:5432'
volumes:
- './data:/var/lib/postgresql/data'
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
environment:
- 'POSTGRES_DB=${LOBE_DB_NAME}'
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
+1
View File
@@ -21,6 +21,7 @@ services:
- '5432:5432'
volumes:
- './data:/var/lib/postgresql/data'
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
environment:
- 'POSTGRES_DB=${LOBE_DB_NAME}'
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
@@ -26,6 +26,7 @@ services:
environment:
- 'POSTGRES_DB=${LOBE_DB_NAME}'
- 'POSTGRES_PASSWORD=${POSTGRES_PASSWORD}'
command: ['postgres', '-c', 'shared_preload_libraries=pg_search']
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
+77 -6
View File
@@ -461,8 +461,7 @@ table ai_models {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(id, provider_id, user_id) [name: 'ai_models_id_provider_id_user_id_unique', unique]
(id, provider_id, user_id, workspace_id) [name: 'ai_models_id_provider_id_user_id_workspace_id_unique', unique]
(id, provider_id, user_id) [pk]
user_id [name: 'ai_models_user_id_idx']
workspace_id [name: 'ai_models_workspace_id_idx']
}
@@ -489,8 +488,7 @@ table ai_providers {
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
(id, user_id) [name: 'ai_providers_id_user_id_unique', unique]
(id, user_id, workspace_id) [name: 'ai_providers_id_user_id_workspace_id_unique', unique]
(id, user_id) [pk]
user_id [name: 'ai_providers_user_id_idx']
workspace_id [name: 'ai_providers_workspace_id_idx']
}
@@ -2451,7 +2449,8 @@ table user_memory_persona_documents {
table verify_check_results {
id uuid [pk, not null, default: `gen_random_uuid()`]
operation_id text [not null]
verify_run_id uuid
operation_id text
user_id text [not null]
workspace_id text
check_item_id text [not null]
@@ -2476,9 +2475,10 @@ table verify_check_results {
created_at "timestamp with time zone" [not null, default: `now()`]
indexes {
verify_run_id [name: 'verify_check_results_verify_run_id_idx']
operation_id [name: 'verify_check_results_operation_id_idx']
user_id [name: 'verify_check_results_user_id_idx']
(operation_id, check_item_id) [name: 'verify_check_results_operation_id_check_item_id_unique', unique]
(verify_run_id, check_item_id) [name: 'verify_check_results_verify_run_id_check_item_id_unique', unique]
verifier_type [name: 'verify_check_results_verifier_type_idx']
verifier_operation_id [name: 'verify_check_results_verifier_operation_id_idx']
verifier_tracing_id [name: 'verify_check_results_verifier_tracing_id_idx']
@@ -2512,6 +2512,54 @@ table verify_criteria {
}
}
table verify_evidence {
id uuid [pk, not null, default: `gen_random_uuid()`]
description text
check_result_id uuid [not null]
type text [not null]
content text
file_id text
captured_by text
captured_at "timestamp with time zone"
user_id text [not null]
workspace_id text
created_at "timestamp with time zone" [not null, default: `now()`]
indexes {
check_result_id [name: 'verify_evidence_check_result_id_idx']
file_id [name: 'verify_evidence_file_id_idx']
user_id [name: 'verify_evidence_user_id_idx']
workspace_id [name: 'verify_evidence_workspace_id_idx']
}
}
table verify_reports {
id uuid [pk, not null, default: `gen_random_uuid()`]
verify_run_id uuid
operation_id text
user_id text [not null]
workspace_id text
verdict text
overall_confidence "numeric(3, 2)"
total_checks integer
passed_checks integer
failed_checks integer
uncertain_checks integer
summary text
content text
reviewed_by_user boolean [default: false]
generated_by text [default: 'system']
generated_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
indexes {
verify_run_id [name: 'verify_reports_verify_run_id_unique', unique]
operation_id [name: 'verify_reports_operation_id_idx']
user_id [name: 'verify_reports_user_id_idx']
workspace_id [name: 'verify_reports_workspace_id_idx']
}
}
table verify_rubric_criteria {
rubric_id uuid [not null]
criterion_id uuid [not null]
@@ -2545,6 +2593,29 @@ table verify_rubrics {
}
}
table verify_runs {
id uuid [pk, not null, default: `gen_random_uuid()`]
user_id text [not null]
workspace_id text
operation_id text
source text [not null, default: 'agent']
title text
goal text
plan jsonb
plan_confirmed_at "timestamp with time zone"
status text
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
user_id [name: 'verify_runs_user_id_idx']
workspace_id [name: 'verify_runs_workspace_id_idx']
operation_id [name: 'verify_runs_operation_id_unique', unique]
source [name: 'verify_runs_source_idx']
}
}
table workspace_audit_logs {
id text [pk, not null]
workspace_id text [not null]
+28 -2
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "يفكر",
"artifact.thought": "عملية التفكير",
"artifact.unknownTitle": "عمل بدون عنوان",
"audioPlayer.pause": "إيقاف تشغيل الصوت",
"audioPlayer.play": "تشغيل الصوت",
"availableAgents": "الوكلاء المتاحون",
"backToBottom": "الانتقال إلى الأحدث",
"beforeUnload.confirmLeave": "لا يزال هناك طلب قيد التشغيل. هل تريد المغادرة؟",
@@ -120,6 +122,18 @@
"createModal.groupPlaceholder": "صف ما ينبغي أن يقوم به هذا الفريق...",
"createModal.groupTitle": "ما الذي ينبغي أن يقوم به فريقك؟",
"createModal.placeholder": "صف ما ينبغي أن يقوم به وكيلك...",
"createModal.skillSuggestion.actions.createAnyway": "إنشاء الوكيل على أي حال",
"createModal.skillSuggestion.actions.createAnywayHint": "المهارة غير مناسبة؟",
"createModal.skillSuggestion.actions.install": "تثبيت المهارة",
"createModal.skillSuggestion.actions.installing": "جارٍ التثبيت…",
"createModal.skillSuggestion.actions.openSkills": "عرض في المهارات",
"createModal.skillSuggestion.actions.tryInLobeAI": "استخدام في {{name}}",
"createModal.skillSuggestion.description": "يبدو أن هذا سير عمل قابل لإعادة الاستخدام. قم بتثبيت المهارة مرة واحدة، ثم استخدمها عبر الوكلاء.",
"createModal.skillSuggestion.installError": "لم يتم تثبيت المهارة. حاول مرة أخرى، أو قم بإنشاء وكيل على أي حال.",
"createModal.skillSuggestion.installed.description": "يمكنك استخدام هذه المهارة في {{name}}، أو تمكينها لأي وكيل.",
"createModal.skillSuggestion.installed.ready": "جاهز في {{name}}",
"createModal.skillSuggestion.installed.title": "تم تثبيت المهارة",
"createModal.skillSuggestion.title": "قد تكون المهارة مناسبة أكثر",
"createModal.title": "ما الذي ينبغي أن يقوم به وكيلك؟",
"createTask.assignee": "المكلّف",
"createTask.collapse": "إخفاء الإدخال",
@@ -166,6 +180,8 @@
"extendParams.title": "ميزات توسيع النموذج",
"extendParams.urlContext.desc": "عند التفعيل، سيتم تحليل الروابط تلقائيًا لاستخراج محتوى صفحة الويب الفعلي",
"extendParams.urlContext.title": "استخراج محتوى رابط الويب",
"floatingChatPanel.collapse": "إخفاء الدردشة",
"floatingChatPanel.expand": "توسيع الدردشة",
"followUpPlaceholder": "متابعة. @ لإسناد مهام لوكلاء آخرين.",
"followUpPlaceholderHeterogeneous": "تابع.",
"gatewayMode.beta": "تجريبي",
@@ -219,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "لم يتم تكوين أي مستودعات. أضفها في إعدادات الوكيل.",
"heteroAgent.cloudRepo.notSet": "لم يتم تحديد أي مستودع",
"heteroAgent.cloudRepo.sectionTitle": "المستودعات",
"heteroAgent.executionTarget.auto": "تلقائي",
"heteroAgent.executionTarget.autoDesc": "استخدام جهاز متصل تلقائيًا، واختيار واحد عند توفر عدة أجهزة",
"heteroAgent.executionTarget.downloadDesktop": "احصل على تطبيق سطح المكتب",
"heteroAgent.executionTarget.downloadDesktopDesc": "قم بتشغيل الوكلاء مع الوصول إلى جهاز الكمبيوتر الخاص بك",
"heteroAgent.executionTarget.downloadDesktopTitle": "احصل على تطبيق سطح المكتب",
"heteroAgent.executionTarget.gateway": "البوابة",
"heteroAgent.executionTarget.gatewayDesc": "التشغيل عبر بوابة الجهاز بحيث يمكن للعملاء الآخرين متابعة التقدم",
"heteroAgent.executionTarget.infoTooltip": "اختر جهازًا بعيدًا لتشغيل هذا الجهاز من الويب. \"هذا الجهاز\" يشغل الوكيل محليًا وهو متاح فقط داخل تطبيق سطح المكتب.",
"heteroAgent.executionTarget.loading": "جارٍ تحميل الأجهزة...",
"heteroAgent.executionTarget.local": "هذا الجهاز",
@@ -231,10 +251,12 @@
"heteroAgent.executionTarget.noneDesc": "لم يتم تمكين أي جهاز",
"heteroAgent.executionTarget.offline": "غير متصل",
"heteroAgent.executionTarget.online": "متصل",
"heteroAgent.executionTarget.personalGroup": "شخصي",
"heteroAgent.executionTarget.sandbox": "بيئة سحابية مؤقتة",
"heteroAgent.executionTarget.sandboxDesc": "تشغيل في بيئة سحابية مؤقتة",
"heteroAgent.executionTarget.title": "جهاز التنفيذ",
"heteroAgent.executionTarget.unknownDevice": "جهاز غير معروف",
"heteroAgent.executionTarget.workspaceGroup": "مساحة العمل",
"heteroAgent.fullAccess.label": "وصول كامل",
"heteroAgent.fullAccess.tooltip": "يعمل Claude Code محليًا مع صلاحية قراءة/كتابة كاملة في دليل العمل. تبديل أوضاع الصلاحيات غير متاح بعد.",
"heteroAgent.resumeReset.cwdChanged": "تم تغيير دليل العمل. لا يمكن استئناف جلسة Claude Code السابقة إلا من دليلها الأصلي، لذا بدأت محادثة جديدة.",
@@ -631,6 +653,8 @@
"taskDetail.artifacts": "المنتجات",
"taskDetail.blockedBy": "محجوب بواسطة {{id}}",
"taskDetail.cancelSchedule": "إلغاء الجدولة",
"taskDetail.closeDetail": "إغلاق التفاصيل",
"taskDetail.collapseReply": "إخفاء الرد",
"taskDetail.comment.cancel": "إلغاء",
"taskDetail.comment.delete": "حذف",
"taskDetail.comment.deleteConfirm.content": "سيتم حذف هذا التعليق بشكل دائم.",
@@ -657,6 +681,7 @@
"taskDetail.notFound.backToTasks": "العودة إلى جميع المهام",
"taskDetail.notFound.desc": "قد تكون هذه المهمة قد حُذفت، أو ليس لديك إذن لعرضها.",
"taskDetail.notFound.title": "المهمة غير موجودة",
"taskDetail.openDetail": "فتح التفاصيل",
"taskDetail.pauseTask": "إيقاف المهمة مؤقتًا",
"taskDetail.priority.high": "عالية",
"taskDetail.priority.low": "منخفضة",
@@ -925,9 +950,9 @@
"workflow.collapse": "طي",
"workflow.expandFull": "توسيع كامل",
"workflow.failedSuffix": "(فشل)",
"workflow.summaryAcrossTools": "عبر {{count}} أدوات",
"workflow.summaryCallsLead": "{{count}} مكالمات: {{tools}}",
"workflow.summaryFailed": "{{count}} فشلت",
"workflow.summaryMoreTools": "{{count}} أنواع أدوات",
"workflow.summaryTotalCalls": "{{count}} مكالمات إجمالية",
"workflow.thoughtForDuration": "تفكير لمدة {{duration}}",
"workflow.toolDisplayName.activateDevice": "تم تفعيل الجهاز",
"workflow.toolDisplayName.activateSkill": "تم تفعيل مهارة",
@@ -1043,6 +1068,7 @@
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.deleteTitleMulti": "حذف {{count}} عنصرًا؟",
"workingPanel.resources.empty": "لا توجد مستندات بعد. ستظهر المستندات المرتبطة بهذا الوكيل هنا.",
"workingPanel.resources.emptyDocuments": "لا توجد مستندات حتى الآن. قم بإنشاء واحد باستخدام + أعلاه.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "مستندات",
"workingPanel.resources.filter.skills": "المهارات",
+18 -2
View File
@@ -444,6 +444,23 @@
"tab.setting": "الإعدادات",
"tab.tasks": "المهام",
"tab.video": "الفيديو",
"taskTemplate.action.connect.button": "اتصل بـ {{provider}}",
"taskTemplate.action.connect.error": "فشل الاتصال، يرجى المحاولة مرة أخرى.",
"taskTemplate.action.connect.popupBlocked": "تم حظر نافذة الاتصال المنبثقة. اسمح بالنوافذ المنبثقة في متصفحك للمتابعة.",
"taskTemplate.action.connect.short": "اتصل",
"taskTemplate.action.connecting": "في انتظار التفويض...",
"taskTemplate.action.create.error": "فشل إنشاء المهمة. يرجى المحاولة مرة أخرى.",
"taskTemplate.action.create.success": "تمت إضافة المهمة المجدولة. يمكنك العثور عليها في Lobe AI.",
"taskTemplate.action.createButton": "إضافة مهمة",
"taskTemplate.action.creating": "جاري الإنشاء...",
"taskTemplate.action.dismiss.error": "فشل الإلغاء. يرجى المحاولة مرة أخرى.",
"taskTemplate.action.dismiss.tooltip": "غير مهتم",
"taskTemplate.action.refresh.button": "تحديث",
"taskTemplate.card.templateTag": "قالب",
"taskTemplate.schedule.daily": "كل يوم في {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "يمكنك تعديل الجدول الزمني بعد إنشاء المهمة.",
"taskTemplate.schedule.weekly": "كل {{weekday}} في {{time}}",
"taskTemplate.section.title": "جرب هذه المهام المجدولة",
"telemetry.allow": "السماح",
"telemetry.deny": "رفض",
"telemetry.desc": "نود جمع معلومات الاستخدام بشكل مجهول لمساعدتنا في تحسين {{appName}} وتقديم تجربة أفضل لك. يمكنك تعطيل هذا في أي وقت من خلال الإعدادات - حول.",
@@ -474,15 +491,14 @@
"userPanel.email": "دعم البريد الإلكتروني",
"userPanel.feedback": "اتصل بنا",
"userPanel.help": "مركز المساعدة",
"userPanel.inviteFriend": "ادعُ صديقًا",
"userPanel.moveGuide": "تم نقل زر الإعدادات إلى هنا",
"userPanel.plans": "خطط الاشتراك",
"userPanel.profile": "الحساب",
"userPanel.setting": "الإعدادات",
"userPanel.upgradePlan": "ترقية الخطة",
"userPanel.usages": "إحصائيات الاستخدام",
"userPanel.workspaceCredits": "أرصدة مساحة العمل",
"userPanel.workspaceSetting": "إعدادات مساحة العمل",
"userPanel.workspaceUsages": "استخدامات مساحة العمل",
"version": "الإصدار",
"zoom": "تكبير"
}
+2
View File
@@ -101,6 +101,7 @@
"LocalFile.action.open": "فتح",
"LocalFile.action.showInFolder": "عرض في المجلد",
"MaxTokenSlider.unlimited": "غير محدود",
"ModelSelect.featureTag.audio": "يدعم هذا النموذج التعرف على إدخال الصوت.",
"ModelSelect.featureTag.custom": "نموذج مخصص، يدعم افتراضيًا استدعاء الوظائف والتعرف البصري. يرجى التحقق من توفر هذه القدرات حسب الحالة الفعلية.",
"ModelSelect.featureTag.file": "يدعم هذا النموذج تحميل الملفات للقراءة والتعرف.",
"ModelSelect.featureTag.functionCall": "يدعم هذا النموذج استدعاء الوظائف.",
@@ -114,6 +115,7 @@
"ModelSwitchPanel.byModel": "حسب النموذج",
"ModelSwitchPanel.byProvider": "حسب المزوّد",
"ModelSwitchPanel.detail.abilities": "القدرات",
"ModelSwitchPanel.detail.abilities.audio": "الصوت",
"ModelSwitchPanel.detail.abilities.files": "الملفات",
"ModelSwitchPanel.detail.abilities.functionCall": "استدعاء الأداة",
"ModelSwitchPanel.detail.abilities.imageOutput": "إخراج الصورة",
-3
View File
@@ -38,9 +38,6 @@
"brief.title": "الموجز اليومي",
"brief.viewAllTasks": "عرض جميع المهام",
"brief.viewRun": "عرض التشغيل",
"freeCreditBadge.cta": "ابدأ النسخة التجريبية المجانية",
"freeCreditBadge.dismiss": "تجاهل",
"freeCreditBadge.label": "أرصدة مجانية حصرية لـ {{model}}",
"project.create": "مشروع جديد",
"project.deleteConfirm": "سيتم حذف هذا المشروع ولن يمكن استعادته. أكد للمتابعة.",
"recommendations.heteroAgent.cta": "أضف الوكيل",
+4 -4
View File
@@ -5,10 +5,12 @@
"authorize.footer.agreement": "بالمتابعة، فإنك تؤكد أنك قرأت ووافقت على <terms>الشروط والأحكام</terms> و<privacy>سياسة الخصوصية</privacy>.",
"authorize.footer.privacy": "سياسة الخصوصية",
"authorize.footer.terms": "شروط الخدمة",
"authorize.scenes.connector.confirm": "متابعة إلى السوق",
"authorize.scenes.connector.description": "يُستخدم السوق فقط لبدء تفويض هذه الخدمة. يبقى حساب {{appName}} الخاص بك منفصلًا.",
"authorize.scenes.connector.subtitle": "سجّل الدخول إلى السوق للاتصال وتفويض هذه الخدمة المجتمعية.",
"authorize.scenes.connector.title": "اتصال الخدمة المجتمعية",
"authorize.scenes.mcp.subtitle": "قم بإنشاء ملف تعريف مجتمعي لتثبيت وتشغيل هذه المهارة من المجتمع.",
"authorize.scenes.mcp.title": "تثبيت مهارة المجتمع",
"authorize.scenes.publish.subtitle": "قم بإنشاء ملف تعريف مجتمعي لنشر وإدارة قائمتك داخل المجتمع.",
"authorize.scenes.publish.title": "النشر في المجتمع",
"authorize.scenes.sandbox.subtitle": "قم بإنشاء ملف تعريف مجتمعي لتشغيل هذه الأداة في صندوق المجتمع التجريبي.",
"authorize.scenes.sandbox.title": "جرب صندوق المجتمع التجريبي",
"authorize.subtitle": "أنشئ ملفًا شخصيًا في المجتمع لتتمكن من إرسال وإدارة القوائم داخل المجتمع.",
@@ -50,8 +52,6 @@
"messages.handoffTimeout": "انتهت مهلة التفويض. أكمل العملية في المتصفح ثم أعد المحاولة.",
"messages.loading": "جارٍ بدء عملية التفويض...",
"messages.success.cloudMcpInstall": "تم التفويض بنجاح! يمكنك الآن تثبيت مهارة Cloud MCP.",
"messages.success.submit": "تم التفويض بنجاح! يمكنك الآن نشر وكيلك.",
"messages.success.upload": "تم التفويض بنجاح! يمكنك الآن نشر إصدار جديد.",
"profileSetup.cancel": "إلغاء",
"profileSetup.confirmChangeUserId.cancel": "إلغاء",
"profileSetup.confirmChangeUserId.confirm": "تغيير معرف المستخدم",
+4 -2
View File
@@ -222,6 +222,7 @@
"providerModels.item.modelConfig.extendParams.options.effort.hint": "لـ Claude Opus 4.6؛ يتحكم في مستوى الجهد (منخفض/متوسط/عالٍ/أقصى).",
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "لـ Claude Opus 4.6؛ يفعّل أو يعطّل التفكير التكيفي.",
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "لنماذج Claude وDeepSeek وغيرها من نماذج الاستدلال؛ يفعّل التفكير العميق.",
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "بالنسبة لـ GLM-5.2؛ يتحكم في جهد التفكير بمستويات عالية وأقصى.",
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "لسلسلة GPT-5؛ يتحكم في شدة الاستدلال.",
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "لسلسلة GPT-5.1؛ يتحكم في شدة الاستدلال.",
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "لسلسلة GPT-5.2 Pro؛ يتحكم في شدة الاستدلال.",
@@ -256,6 +257,7 @@
"providerModels.item.modelConfig.files.title": "دعم رفع الملفات",
"providerModels.item.modelConfig.functionCall.extra": "سيمكن هذا الإعداد النموذج من استخدام الأدوات، ولكن قدرة النموذج الفعلية على استخدامها تعتمد عليه بالكامل؛ يرجى الاختبار بنفسك.",
"providerModels.item.modelConfig.functionCall.title": "دعم استخدام الأدوات",
"providerModels.item.modelConfig.id.duplicate": "يوجد بالفعل نموذج بهذا المعرف. استخدم معرف نموذج مختلف.",
"providerModels.item.modelConfig.id.extra": "لا يمكن تعديله بعد الإنشاء وسيُستخدم كمعرف النموذج عند استدعاء الذكاء الاصطناعي",
"providerModels.item.modelConfig.id.placeholder": "يرجى إدخال معرف النموذج، مثل gpt-4o أو claude-3.5-sonnet",
"providerModels.item.modelConfig.id.title": "معرف النموذج",
@@ -270,11 +272,11 @@
"providerModels.item.modelConfig.tokens.title": "أقصى نافذة سياق",
"providerModels.item.modelConfig.tokens.unlimited": "غير محدود",
"providerModels.item.modelConfig.type.extra": "أنواع النماذج المختلفة لها استخدامات وقدرات مختلفة",
"providerModels.item.modelConfig.type.options.asr": "تحويل الكلام إلى نص",
"providerModels.item.modelConfig.type.options.chat": "دردشة",
"providerModels.item.modelConfig.type.options.embedding": "تضمين",
"providerModels.item.modelConfig.type.options.image": "توليد الصور",
"providerModels.item.modelConfig.type.options.realtime": "دردشة فورية",
"providerModels.item.modelConfig.type.options.stt": "تحويل الكلام إلى نص",
"providerModels.item.modelConfig.type.options.text2music": "نص إلى موسيقى",
"providerModels.item.modelConfig.type.options.tts": "تحويل النص إلى كلام",
"providerModels.item.modelConfig.type.options.video": "توليد الفيديو",
@@ -323,10 +325,10 @@
"providerModels.list.total": "{{count}} نموذج متاح",
"providerModels.searchNotFound": "لم يتم العثور على نتائج",
"providerModels.tabs.all": "الكل",
"providerModels.tabs.asr": "تحويل الكلام إلى نص",
"providerModels.tabs.chat": "دردشة",
"providerModels.tabs.embedding": "تضمين",
"providerModels.tabs.image": "صورة",
"providerModels.tabs.stt": "تحويل الكلام إلى نص",
"providerModels.tabs.tts": "تحويل النص إلى كلام",
"providerModels.tabs.video": "فيديو",
"sortModal.success": "تم تحديث الترتيب بنجاح",
+9 -69
View File
@@ -440,60 +440,7 @@
"llm.proxyUrl.title": "عنوان وكيل API",
"llm.waitingForMore": "سيتم <1>إضافة المزيد من النماذج</1> قريبًا، ترقبوا",
"llm.waitingForMoreLinkAriaLabel": "فتح نموذج طلب المزود",
"marketPublish.forkConfirm.by": "بواسطة {{author}}",
"marketPublish.forkConfirm.confirm": "تأكيد النشر",
"marketPublish.forkConfirm.confirmGroup": "تأكيد النشر",
"marketPublish.forkConfirm.description": "أنت على وشك نشر نسخة مشتقة تستند إلى وكيل موجود من المجتمع. سيتم إنشاء وكيلك الجديد كإدخال منفصل في السوق.",
"marketPublish.forkConfirm.descriptionGroup": "أنت على وشك نشر نسخة مشتقة تستند إلى مجموعة موجودة من المجتمع. سيتم إنشاء مجموعتك الجديدة كإدخال منفصل في السوق.",
"marketPublish.forkConfirm.title": "نشر وكيل مشتق",
"marketPublish.forkConfirm.titleGroup": "نشر مجموعة مشتقة",
"marketPublish.modal.changelog.extra": "وصف التغييرات والتحسينات الرئيسية في هذا الإصدار",
"marketPublish.modal.changelog.label": "سجل التغييرات",
"marketPublish.modal.changelog.maxLengthError": "يجب ألا يتجاوز سجل التغييرات 500 حرف",
"marketPublish.modal.changelog.placeholder": "أدخل سجل التغييرات",
"marketPublish.modal.changelog.required": "يرجى إدخال سجل التغييرات",
"marketPublish.modal.comparison.local": "الإصدار المحلي الحالي",
"marketPublish.modal.comparison.remote": "الإصدار المنشور حاليًا",
"marketPublish.modal.identifier.extra": "هذا هو المعرف الفريد للوكيل. استخدم أحرفًا صغيرة وأرقامًا وشرطات.",
"marketPublish.modal.identifier.label": "معرف الوكيل",
"marketPublish.modal.identifier.lengthError": "يجب أن يتراوح طول المعرف بين 3 و50 حرفًا",
"marketPublish.modal.identifier.patternError": "يمكن أن يحتوي المعرف فقط على أحرف صغيرة وأرقام وشرطات",
"marketPublish.modal.identifier.placeholder": "أدخل معرفًا فريدًا للوكيل، مثل web-development",
"marketPublish.modal.identifier.required": "يرجى إدخال معرف الوكيل",
"marketPublish.modal.loading.fetchingRemote": "جارٍ تحميل البيانات البعيدة...",
"marketPublish.modal.loading.submit": "جارٍ إرسال الوكيل...",
"marketPublish.modal.loading.submitGroup": "جارٍ إرسال المجموعة...",
"marketPublish.modal.loading.upload": "جارٍ نشر الإصدار الجديد...",
"marketPublish.modal.loading.uploadGroup": "جارٍ نشر إصدار جديد من المجموعة...",
"marketPublish.modal.messages.createVersionFailed": "فشل في إنشاء الإصدار: {{message}}",
"marketPublish.modal.messages.fetchRemoteFailed": "فشل في جلب بيانات الوكيل البعيدة",
"marketPublish.modal.messages.missingIdentifier": "لا يحتوي هذا الوكيل على معرف المجتمع بعد.",
"marketPublish.modal.messages.noGroup": "لم يتم تحديد أي مجموعة",
"marketPublish.modal.messages.notAuthenticated": "يرجى تسجيل الدخول إلى حسابك في المجتمع أولاً.",
"marketPublish.modal.messages.publishFailed": "فشل النشر: {{message}}",
"marketPublish.modal.submitButton": "نشر",
"marketPublish.modal.title.submit": "مشاركة في مجتمع الوكلاء",
"marketPublish.modal.title.upload": "نشر إصدار جديد",
"marketPublish.resultModal.message": "تم إرسال وكيلك للمراجعة. بمجرد الموافقة عليه، سيتم نشره تلقائيًا.",
"marketPublish.resultModal.messageGroup": "تم إرسال مجموعتك للمراجعة. بمجرد الموافقة عليها، سيتم نشرها تلقائيًا.",
"marketPublish.resultModal.title": "تم الإرسال بنجاح",
"marketPublish.resultModal.view": "عرض في المجتمع",
"marketPublish.status.underReview": "قيد المراجعة",
"marketPublish.submit.button": "مشاركة في المجتمع",
"marketPublish.submit.tooltip": "شارك هذا الوكيل في المجتمع",
"marketPublish.submitGroup.tooltip": "شارك هذه المجموعة مع المجتمع",
"marketPublish.upload.button": "نشر إصدار جديد",
"marketPublish.upload.tooltip": "نشر إصدار جديد في مجتمع الوكلاء",
"marketPublish.uploadGroup.tooltip": "نشر إصدار جديد في مجتمع المجموعات",
"marketPublish.validation.communitySetupRequired.action": "إعداد الآن",
"marketPublish.validation.communitySetupRequired.desc": "لم تقم هذه مساحة العمل بإعداد ملف تعريف المجتمع الخاص بها بعد. قم بإعداده قبل النشر في المجتمع.",
"marketPublish.validation.communitySetupRequired.memberHint": "لم تقم هذه مساحة العمل بإعداد ملف تعريف المجتمع الخاص بها بعد. اطلب من مالك مساحة العمل إعدادها قبل النشر في المجتمع.",
"marketPublish.validation.communitySetupRequired.title": "قم بإعداد ملف تعريف المجتمع أولاً",
"marketPublish.validation.confirmPublish": "النشر في السوق؟",
"marketPublish.validation.confirmPublishDesc": "بمجرد النشر، سيكون هذا المحتوى مرئيًا للجمهور في السوق ومتاحًا لأي شخص لاكتشافه واستخدامه.",
"marketPublish.validation.emptyName": "لا يمكن النشر: الاسم مطلوب",
"marketPublish.validation.emptySystemRole": "لا يمكن النشر: دور النظام مطلوب",
"marketPublish.validation.underReview": "إصدارك الجديد قيد المراجعة حاليًا. يرجى انتظار الموافقة قبل نشر إصدار جديد.",
"memory.effort.desc": "تحكم في مدى شدة استرجاع وتحديث الذاكرة من قبل الذكاء الاصطناعي.",
"memory.effort.high": "عالي — استرجاع وتحديث استباقي",
"memory.effort.level.high": "عالي",
@@ -515,14 +462,6 @@
"myAgents.actions.deprecateLoading": "جارٍ إيقاف الوكيل...",
"myAgents.actions.deprecateSuccess": "تم إيقاف الوكيل",
"myAgents.actions.edit": "تعديل الوكيل",
"myAgents.actions.publish": "نشر الوكيل",
"myAgents.actions.publishError": "فشل في نشر الوكيل",
"myAgents.actions.publishLoading": "جارٍ نشر الوكيل...",
"myAgents.actions.publishSuccess": "تم نشر الوكيل",
"myAgents.actions.unpublish": "إلغاء نشر الوكيل",
"myAgents.actions.unpublishError": "فشل في إلغاء نشر الوكيل",
"myAgents.actions.unpublishLoading": "جارٍ إلغاء نشر الوكيل...",
"myAgents.actions.unpublishSuccess": "تم إلغاء نشر الوكيل",
"myAgents.actions.viewDetail": "عرض التفاصيل",
"myAgents.detail.category": "الفئة",
"myAgents.detail.description": "الوصف",
@@ -587,7 +526,6 @@
"plugin.settings.title": "إعدادات مهارة {{id}}",
"plugin.settings.tooltip": "إعدادات المهارة",
"plugin.store": "متجر المهارات",
"publishToCommunity": "النشر في المجتمع",
"serviceModel.contextLimit.placeholder": "حد السياق",
"serviceModel.memoryModels.title": "نماذج الذاكرة",
"serviceModel.modelAssignments.title": "تعيينات النموذج",
@@ -955,13 +893,6 @@
"storageOverage.usage.estimatedCharge": "الرسوم المقدرة للدورة",
"storageOverage.usage.incurredCharge": "تم تكبدها في هذه الدورة",
"storageOverage.usage.overage": "التجاوز",
"submitAgentModal.button": "إرسال الوكيل",
"submitAgentModal.identifier": "معرّف الوكيل",
"submitAgentModal.metaMiss": "يرجى إكمال معلومات الوكيل قبل الإرسال. يجب أن تتضمن الاسم والوصف والوسوم.",
"submitAgentModal.placeholder": "أدخل معرّفًا فريدًا للوكيل، مثل: web-development",
"submitAgentModal.success": "تم إرسال الوكيل بنجاح",
"submitAgentModal.tooltips": "مشاركة في مجتمع الوكلاء",
"submitGroupModal.tooltips": "المشاركة في مجتمع المجموعات",
"sync.device.deviceName.hint": "أضف اسمًا لسهولة التعرف",
"sync.device.deviceName.placeholder": "أدخل اسم الجهاز",
"sync.device.deviceName.title": "اسم الجهاز",
@@ -1086,6 +1017,7 @@
"tools.activation.auto": "تلقائي",
"tools.activation.auto.desc": "ذكي",
"tools.activation.fixed.hint": "دائمًا قيد التشغيل — يتم إدارته بواسطة التطبيق ولا يمكن إيقاف تشغيله",
"tools.activation.pin": "الرمز السري",
"tools.activation.pinned": "مثبت",
"tools.activation.pinned.desc": "دائمًا قيد التشغيل",
"tools.add": "إضافة مهارة",
@@ -2047,6 +1979,14 @@
"workspace.wizard.step3.title": "مرحبًا بك في {{name}}!",
"workspace.wizard.title": "إنشاء مساحة العمل",
"workspaceSetting.breadcrumb.settings": "الإعدادات",
"workspaceSetting.devices.desc": "الأجهزة المشتركة المسجلة في مساحة العمل هذه. يمكن للأعضاء تشغيل الوكلاء عليها.",
"workspaceSetting.devices.empty": "لا توجد أجهزة مرتبطة بمساحة العمل حتى الآن.",
"workspaceSetting.devices.enrollDesc": "قم بتشغيل هذا على الجهاز الذي تريد مشاركته (لصاحب مساحة العمل فقط):",
"workspaceSetting.devices.enrollTitle": "إضافة جهاز",
"workspaceSetting.devices.heroDesc": "قم بتسجيل جهاز مشترك — مثل خادم بناء أو جهاز Mac لفريق العمل — وسيتمكن كل عضو من تشغيل الوكلاء عليه: قراءة/كتابة الملفات، تشغيل الأوامر، واستخدام أدوات النظام.",
"workspaceSetting.devices.heroTitle": "قم بتوصيل أول جهاز لمساحة العمل",
"workspaceSetting.devices.offline": "غير متصل",
"workspaceSetting.devices.online": "متصل",
"workspaceSetting.group.admin": "الإدارة",
"workspaceSetting.group.agent": "الوكيل",
"workspaceSetting.group.general": "عام",
+10 -5
View File
@@ -147,10 +147,6 @@
"limitation.chat.topupSuccess.title": "تم الشحن بنجاح",
"limitation.expired.desc": "انتهت صلاحية أرصدة الحوسبة الخاصة بك في خطة {{plan}} بتاريخ {{expiredAt}}. قم بالترقية الآن للحصول على أرصدة جديدة.",
"limitation.expired.title": "انتهت أرصدة الحوسبة",
"limitation.fableCampaign.desc": "Claude Fable 5 هو نموذج عالي التكلفة. تم استخدام أرصدة التجربة الخاصة بالحملة. قم بترقية خطتك للاستمرار في استخدام Fable.",
"limitation.fableCampaign.title": "تم استخدام أرصدة تجربة Fable",
"limitation.fableCampaign.upgrade": "ترقية الخطة",
"limitation.fableCampaign.upgradeToPlan": "الترقية إلى {{plan}}",
"limitation.hobby.action": "تم التكوين، تابع المحادثة",
"limitation.hobby.configAPI": "تكوين API",
"limitation.hobby.desc": "تم استهلاك أرصدة الحوسبة المجانية الخاصة بك. يرجى تكوين واجهة برمجة التطبيقات المخصصة للنموذج للمتابعة.",
@@ -342,7 +338,14 @@
"plans.workspace.noSharedCredits": "لا توجد أرصدة مشتركة",
"plans.workspace.sharedCredits": "~{{count}} أرصدة / شهريًا",
"plans.workspace.solo": "فردي (عضو واحد)",
"promoBanner.fableYearly": "المشتركين السنويين يحصلون على خصم {{percent}}% على الاستخدام لفترة محدودة",
"plansModal.creditLimit.desc": "قم بترقية خطتك لفتح المزيد من الاعتمادات الشهرية ومواصلة العمل دون انقطاع.",
"plansModal.creditLimit.title": "لقد نفدت الاعتمادات",
"plansModal.default.desc": "افتح المزيد من السعة والميزات المتقدمة.",
"plansModal.default.title": "قم بترقية خطتك",
"plansModal.fileStorageLimit.desc": "مساحة تخزين الملفات ممتلئة. قم بالترقية لمواصلة تحميل وإدارة الملفات.",
"plansModal.fileStorageLimit.title": "تم الوصول إلى حد التخزين",
"plansModal.modelAccess.desc": "هذا النموذج متاح في الخطط المدفوعة. قم بالترقية لاستخدام مجموعة النماذج الكاملة.",
"plansModal.modelAccess.title": "افتح جميع النماذج",
"qa.desc": "إذا لم تجد إجابتك، تحقق من <1>وثائق المنتج</1> لمزيد من الأسئلة الشائعة، أو تواصل معنا.",
"qa.detail": "عرض التفاصيل",
"qa.list.credit.a": "أرصدة الحوسبة هي وحدة قياس يستخدمها {{cloud}} لقياس استخدام نماذج الذكاء الاصطناعي. تستهلك النماذج المختلفة كميات مختلفة من الأرصدة.",
@@ -398,6 +401,8 @@
"referral.errors.invalidFormat": "تنسيق رمز الإحالة غير صالح، يرجى إدخال 2-8 أحرف أو أرقام أو شرطات سفلية",
"referral.errors.selfReferral": "لا يمكنك استخدام رمز الدعوة الخاص بك",
"referral.errors.updateFailed": "فشل التحديث، يرجى المحاولة لاحقًا",
"referral.hero.description": "شارك رابط الإحالة الخاص بك أدناه. بعد أن يقوم صديقك بأول عملية دفع، ستحصلان كلاكما على {{reward}} مليون اعتماد.",
"referral.hero.title": "ادعُ أصدقاءك، واحصل كلاكما على <0>{{reward}} مليون اعتماد</0>",
"referral.inviteCode.description": "شارك رمز الإحالة الحصري الخاص بك لدعوة الأصدقاء للتسجيل",
"referral.inviteCode.title": "رمز الإحالة الخاص بي",
"referral.inviteLink.description": "انسخ الرابط وشاركه مع الأصدقاء. يحصل كلاكما على أرصدة بعد أن يقوم صديقك بالدفع",
+1
View File
@@ -25,6 +25,7 @@
"actions.unmarkCompleted": "وضع علامة كنشط",
"defaultTitle": "موضوع افتراضي",
"displayItems": "عرض العناصر",
"draft": "[مسودة]",
"duplicateLoading": "جارٍ نسخ الموضوع...",
"duplicateSuccess": "تم نسخ الموضوع بنجاح",
"failedStatusTip": "تعرضت هذه العملية لخطأ — افتحها لإلقاء نظرة.",
+28 -2
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "Мислене",
"artifact.thought": "Мисловен процес",
"artifact.unknownTitle": "Без заглавие",
"audioPlayer.pause": "Пауза на аудио",
"audioPlayer.play": "Пусни аудио",
"availableAgents": "Налични Агенти",
"backToBottom": "Към най-новото",
"beforeUnload.confirmLeave": "Заявка все още се изпълнява. Искате ли да напуснете въпреки това?",
@@ -120,6 +122,18 @@
"createModal.groupPlaceholder": "Опишете какво трябва да прави тази група...",
"createModal.groupTitle": "Какво трябва да прави вашата група?",
"createModal.placeholder": "Опишете какво трябва да прави вашият агент...",
"createModal.skillSuggestion.actions.createAnyway": "Създай агент въпреки това",
"createModal.skillSuggestion.actions.createAnywayHint": "Умението не е подходящо?",
"createModal.skillSuggestion.actions.install": "Инсталирай умение",
"createModal.skillSuggestion.actions.installing": "Инсталиране…",
"createModal.skillSuggestion.actions.openSkills": "Преглед в Умения",
"createModal.skillSuggestion.actions.tryInLobeAI": "Използвай в {{name}}",
"createModal.skillSuggestion.description": "Това изглежда като повторяем работен процес. Инсталирайте умението веднъж, след това го използвайте в различни агенти.",
"createModal.skillSuggestion.installError": "Умението не беше инсталирано. Опитайте отново или създайте агент въпреки това.",
"createModal.skillSuggestion.installed.description": "Можете да използвате това умение в {{name}}, или да го активирате за всеки агент.",
"createModal.skillSuggestion.installed.ready": "Готово в {{name}}",
"createModal.skillSuggestion.installed.title": "Умението е инсталирано",
"createModal.skillSuggestion.title": "Умението може да е по-подходящо",
"createModal.title": "Какво трябва да прави вашият агент?",
"createTask.assignee": "Изпълнител",
"createTask.collapse": "Скрий полето",
@@ -166,6 +180,8 @@
"extendParams.title": "Разширени функции на модела",
"extendParams.urlContext.desc": "Когато е активирано, уеб връзките ще се анализират автоматично, за да се извлече съдържанието на страницата",
"extendParams.urlContext.title": "Извличане на съдържание от уеб връзки",
"floatingChatPanel.collapse": "Свий чата",
"floatingChatPanel.expand": "Разшири чата",
"followUpPlaceholder": "Последващо действие. Използвайте @, за да възлагате задачи на други агенти.",
"followUpPlaceholderHeterogeneous": "Последващ въпрос.",
"gatewayMode.beta": "Бета",
@@ -219,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "Няма конфигурирани хранилища. Добавете ги в настройките на агента.",
"heteroAgent.cloudRepo.notSet": "Няма избрано хранилище",
"heteroAgent.cloudRepo.sectionTitle": "Хранилища",
"heteroAgent.executionTarget.auto": "Автоматично",
"heteroAgent.executionTarget.autoDesc": "Използвайте онлайн устройство автоматично, избирайки едно, когато има няколко налични",
"heteroAgent.executionTarget.downloadDesktop": "Изтеглете десктоп приложението",
"heteroAgent.executionTarget.downloadDesktopDesc": "Стартирайте агенти с достъп до вашия компютър",
"heteroAgent.executionTarget.downloadDesktopTitle": "Изтеглете десктоп приложението",
"heteroAgent.executionTarget.gateway": "Шлюз",
"heteroAgent.executionTarget.gatewayDesc": "Изпълнявайте през шлюза на устройството, за да могат други клиенти да следят напредъка",
"heteroAgent.executionTarget.infoTooltip": "Изберете отдалечено устройство, за да управлявате тази машина от уеба. \"Това устройство\" изпълнява агента локално и е достъпно само в десктоп приложението.",
"heteroAgent.executionTarget.loading": "Зареждане на устройства...",
"heteroAgent.executionTarget.local": "Това устройство",
@@ -231,10 +251,12 @@
"heteroAgent.executionTarget.noneDesc": "Няма активирано устройство",
"heteroAgent.executionTarget.offline": "Офлайн",
"heteroAgent.executionTarget.online": "Онлайн",
"heteroAgent.executionTarget.personalGroup": "Лично",
"heteroAgent.executionTarget.sandbox": "Облачен пясъчник",
"heteroAgent.executionTarget.sandboxDesc": "Изпълнява се в временен облачен пясъчник",
"heteroAgent.executionTarget.title": "Устройство за изпълнение",
"heteroAgent.executionTarget.unknownDevice": "Неизвестно устройство",
"heteroAgent.executionTarget.workspaceGroup": "Работно пространство",
"heteroAgent.fullAccess.label": "Пълен достъп",
"heteroAgent.fullAccess.tooltip": "Claude Code работи локално с пълен достъп за четене/запис в работната директория. Превключването на режимите на достъп все още не е налично.",
"heteroAgent.resumeReset.cwdChanged": "Работната директория е променена. Предишната сесия на Claude Code може да бъде продължена само от оригиналната ѝ директория, затова е започнат нов разговор.",
@@ -631,6 +653,8 @@
"taskDetail.artifacts": "Артефакти",
"taskDetail.blockedBy": "Блокирано от {{id}}",
"taskDetail.cancelSchedule": "Отмени графика",
"taskDetail.closeDetail": "Затвори детайла",
"taskDetail.collapseReply": "Свий",
"taskDetail.comment.cancel": "Отказ",
"taskDetail.comment.delete": "Изтриване",
"taskDetail.comment.deleteConfirm.content": "Този коментар ще бъде изтрит завинаги.",
@@ -657,6 +681,7 @@
"taskDetail.notFound.backToTasks": "Обратно към всички задачи",
"taskDetail.notFound.desc": "Тази задача може да е изтрита или нямате разрешение да я видите.",
"taskDetail.notFound.title": "Задачата не е намерена",
"taskDetail.openDetail": "Отвори детайла",
"taskDetail.pauseTask": "Пауза на задачата",
"taskDetail.priority.high": "Висок",
"taskDetail.priority.low": "Нисък",
@@ -925,9 +950,9 @@
"workflow.collapse": "Свий",
"workflow.expandFull": "Разгъни напълно",
"workflow.failedSuffix": "(неуспешно)",
"workflow.summaryAcrossTools": "през {{count}} инструмента",
"workflow.summaryCallsLead": "{{count}} обаждания: {{tools}}",
"workflow.summaryFailed": "{{count}} неуспешни",
"workflow.summaryMoreTools": "{{count}} вида инструменти",
"workflow.summaryTotalCalls": "{{count}} извиквания общо",
"workflow.thoughtForDuration": "Мисли в продължение на {{duration}}",
"workflow.toolDisplayName.activateDevice": "Активирано устройство",
"workflow.toolDisplayName.activateSkill": "Активира умение",
@@ -1043,6 +1068,7 @@
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.deleteTitleMulti": "Изтриване на {{count}} елемента?",
"workingPanel.resources.empty": "Все още няма документи. Документите, свързани с този агент, ще се показват тук.",
"workingPanel.resources.emptyDocuments": "Все още няма документи. Създайте един с + горе.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "Документи",
"workingPanel.resources.filter.skills": "Умения",
+18 -2
View File
@@ -444,6 +444,23 @@
"tab.setting": "Настройки",
"tab.tasks": "Задачи",
"tab.video": "Видео",
"taskTemplate.action.connect.button": "Свържете {{provider}}",
"taskTemplate.action.connect.error": "Свързването не бе успешно, моля опитайте отново.",
"taskTemplate.action.connect.popupBlocked": "Изскачащият прозорец за свързване е блокиран. Разрешете изскачащи прозорци в браузъра си, за да продължите.",
"taskTemplate.action.connect.short": "Свържете",
"taskTemplate.action.connecting": "Изчакване за оторизация…",
"taskTemplate.action.create.error": "Неуспешно създаване на задача. Моля, опитайте отново.",
"taskTemplate.action.create.success": "Добавена е планирана задача. Намерете я в Lobe AI.",
"taskTemplate.action.createButton": "Добавете задача",
"taskTemplate.action.creating": "Създаване...",
"taskTemplate.action.dismiss.error": "Неуспешно отхвърляне. Моля, опитайте отново.",
"taskTemplate.action.dismiss.tooltip": "Не се интересувам",
"taskTemplate.action.refresh.button": "Обновете",
"taskTemplate.card.templateTag": "Шаблон",
"taskTemplate.schedule.daily": "Всеки ден в {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "Можете да коригирате графика след създаването на задачата.",
"taskTemplate.schedule.weekly": "Всеки {{weekday}} в {{time}}",
"taskTemplate.section.title": "Опитайте тези планирани задачи",
"telemetry.allow": "Разреши",
"telemetry.deny": "Откажи",
"telemetry.desc": "Бихме искали анонимно да събираме информация за използването, за да подобрим {{appName}} и да ви предоставим по-добро потребителско изживяване. Можете да го изключите по всяко време в Настройки - Относно.",
@@ -474,15 +491,14 @@
"userPanel.email": "Имейл поддръжка",
"userPanel.feedback": "Свържете се с нас",
"userPanel.help": "Център за помощ",
"userPanel.inviteFriend": "Поканете приятел",
"userPanel.moveGuide": "Бутонът за настройки е преместен тук",
"userPanel.plans": "Абонаментни планове",
"userPanel.profile": "Акаунт",
"userPanel.setting": "Настройки",
"userPanel.upgradePlan": "Надграждане на плана",
"userPanel.usages": "Статистика на използване",
"userPanel.workspaceCredits": "Кредити за работно пространство",
"userPanel.workspaceSetting": "Настройки на работното пространство",
"userPanel.workspaceUsages": "Използване на работното пространство",
"version": "Версия",
"zoom": "Увеличение"
}
+2
View File
@@ -101,6 +101,7 @@
"LocalFile.action.open": "Отвори",
"LocalFile.action.showInFolder": "Покажи в папката",
"MaxTokenSlider.unlimited": "Неограничено",
"ModelSelect.featureTag.audio": "Този модел поддържа разпознаване на аудио вход.",
"ModelSelect.featureTag.custom": "Персонализиран модел, по подразбиране поддържа извикване на функции и визуално разпознаване. Моля, проверете наличността на тези възможности според конкретната ситуация.",
"ModelSelect.featureTag.file": "Този модел поддържа качване на файлове за четене и разпознаване.",
"ModelSelect.featureTag.functionCall": "Този модел поддържа извикване на функции.",
@@ -114,6 +115,7 @@
"ModelSwitchPanel.byModel": "По модел",
"ModelSwitchPanel.byProvider": "По доставчик",
"ModelSwitchPanel.detail.abilities": "Възможности",
"ModelSwitchPanel.detail.abilities.audio": "Аудио",
"ModelSwitchPanel.detail.abilities.files": "Файлове",
"ModelSwitchPanel.detail.abilities.functionCall": "Извикване на инструмент",
"ModelSwitchPanel.detail.abilities.imageOutput": "Изход на изображение",
-3
View File
@@ -38,9 +38,6 @@
"brief.title": "Дневен обзор",
"brief.viewAllTasks": "Преглед на всички задачи",
"brief.viewRun": "Преглед на изпълнението",
"freeCreditBadge.cta": "Започнете безплатен пробен период",
"freeCreditBadge.dismiss": "Отхвърли",
"freeCreditBadge.label": "Ексклузивни безплатни кредити за {{model}}",
"project.create": "Нов проект",
"project.deleteConfirm": "Този проект ще бъде изтрит и не може да бъде възстановен. Потвърдете, за да продължите.",
"recommendations.heteroAgent.cta": "Добавяне на агент",
+4 -4
View File
@@ -5,10 +5,12 @@
"authorize.footer.agreement": "Продължавайки, потвърждаваш, че си прочел и се съгласяваш с <terms>Общите условия</terms> и <privacy>Политиката за поверителност</privacy>.",
"authorize.footer.privacy": "Политика за поверителност",
"authorize.footer.terms": "Общи условия",
"authorize.scenes.connector.confirm": "Продължете към Market",
"authorize.scenes.connector.description": "Market се използва само за стартиране на тази услуга за упълномощаване. Вашият акаунт в {{appName}} остава отделен.",
"authorize.scenes.connector.subtitle": "Влезте в Market, за да свържете и упълномощите тази обществена услуга.",
"authorize.scenes.connector.title": "Свържете обществена услуга",
"authorize.scenes.mcp.subtitle": "Създайте профил в общността, за да инсталирате и използвате това умение от общността.",
"authorize.scenes.mcp.title": "Инсталиране на умение от общността",
"authorize.scenes.publish.subtitle": "Създайте профил в общността, за да публикувате и управлявате вашия списък в общността.",
"authorize.scenes.publish.title": "Публикуване в общността",
"authorize.scenes.sandbox.subtitle": "Създайте профил в общността, за да използвате този инструмент в пясъчника на общността.",
"authorize.scenes.sandbox.title": "Опитайте пясъчника на общността",
"authorize.subtitle": "Създай профил в общността, за да публикуваш и управляваш обяви в нея.",
@@ -50,8 +52,6 @@
"messages.handoffTimeout": "Времето за удостоверяване изтече. Завършете го в браузъра си и опитайте отново.",
"messages.loading": "Стартиране на процеса на удостоверяване...",
"messages.success.cloudMcpInstall": "Удостоверяването е успешно! Вече можете да инсталирате умението Cloud MCP.",
"messages.success.submit": "Удостоверяването е успешно! Вече можете да публикувате своя агент.",
"messages.success.upload": "Удостоверяването е успешно! Вече можете да публикувате нова версия.",
"profileSetup.cancel": "Отказ",
"profileSetup.confirmChangeUserId.cancel": "Отказ",
"profileSetup.confirmChangeUserId.confirm": "Промени потребителското име",
+4 -2
View File
@@ -222,6 +222,7 @@
"providerModels.item.modelConfig.extendParams.options.effort.hint": "За Claude Opus 4.6; контролира нивото на усилие (ниско/средно/високо/максимално).",
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "За Claude Opus 4.6; включва или изключва адаптивното мислене.",
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "За Claude, DeepSeek и други модели с логическо мислене; отключва по-задълбочено разсъждение.",
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "За GLM-5.2; контролира усилието за разсъждение с високи и максимални нива.",
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "За серията GPT-5; контролира интензивността на разсъждението.",
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "За серията GPT-5.1; контролира интензивността на разсъждението.",
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "За серията GPT-5.2 Pro; контролира интензивността на разсъждението.",
@@ -256,6 +257,7 @@
"providerModels.item.modelConfig.files.title": "Поддръжка на качване на файлове",
"providerModels.item.modelConfig.functionCall.extra": "Тази настройка активира възможността на модела да използва инструменти. Дали ще ги използва ефективно зависи от самия модел. Моля, тествайте.",
"providerModels.item.modelConfig.functionCall.title": "Поддръжка на използване на инструменти",
"providerModels.item.modelConfig.id.duplicate": "Модел с този ID вече съществува. Използвайте различен ID за модела.",
"providerModels.item.modelConfig.id.extra": "Не може да се променя след създаване и ще се използва като ID на модела при извикване",
"providerModels.item.modelConfig.id.placeholder": "Въведете ID на модела, напр. gpt-4o или claude-3.5-sonnet",
"providerModels.item.modelConfig.id.title": "ID на модела",
@@ -270,11 +272,11 @@
"providerModels.item.modelConfig.tokens.title": "Максимален контекстов прозорец",
"providerModels.item.modelConfig.tokens.unlimited": "Неограничен",
"providerModels.item.modelConfig.type.extra": "Различните типове модели имат различни приложения и възможности",
"providerModels.item.modelConfig.type.options.asr": "Реч към текст",
"providerModels.item.modelConfig.type.options.chat": "Чат",
"providerModels.item.modelConfig.type.options.embedding": "Вграждане",
"providerModels.item.modelConfig.type.options.image": "Генериране на изображения",
"providerModels.item.modelConfig.type.options.realtime": "Чат в реално време",
"providerModels.item.modelConfig.type.options.stt": "Реч към текст",
"providerModels.item.modelConfig.type.options.text2music": "Текст към музика",
"providerModels.item.modelConfig.type.options.tts": "Текст към реч",
"providerModels.item.modelConfig.type.options.video": "Генериране на видео",
@@ -323,10 +325,10 @@
"providerModels.list.total": "Налични {{count}} модела",
"providerModels.searchNotFound": "Няма намерени резултати от търсенето",
"providerModels.tabs.all": "Всички",
"providerModels.tabs.asr": "ASR",
"providerModels.tabs.chat": "Чат",
"providerModels.tabs.embedding": "Вграждане",
"providerModels.tabs.image": "Изображение",
"providerModels.tabs.stt": "ASR",
"providerModels.tabs.tts": "TTS",
"providerModels.tabs.video": "Видео",
"sortModal.success": "Сортирането е успешно обновено",
+9 -69
View File
@@ -440,60 +440,7 @@
"llm.proxyUrl.title": "API прокси URL",
"llm.waitingForMore": "Очакват се <1>още модели</1>, следете за новини",
"llm.waitingForMoreLinkAriaLabel": "Отвори формуляр за заявка към доставчик",
"marketPublish.forkConfirm.by": "от {{author}}",
"marketPublish.forkConfirm.confirm": "Потвърди публикуване",
"marketPublish.forkConfirm.confirmGroup": "Потвърди публикуване",
"marketPublish.forkConfirm.description": "Вие сте на път да публикувате производна версия, базирана на съществуващ агент от общността. Вашият нов агент ще бъде създаден като отделен запис в пазара.",
"marketPublish.forkConfirm.descriptionGroup": "Вие сте на път да публикувате производна версия, базирана на съществуваща група от общността. Вашата нова група ще бъде създадена като отделен запис в пазара.",
"marketPublish.forkConfirm.title": "Публикуване на производен агент",
"marketPublish.forkConfirm.titleGroup": "Публикуване на производна група",
"marketPublish.modal.changelog.extra": "Опишете основните промени и подобрения в тази версия",
"marketPublish.modal.changelog.label": "Дневник на промените",
"marketPublish.modal.changelog.maxLengthError": "Дневникът на промените не трябва да надвишава 500 знака",
"marketPublish.modal.changelog.placeholder": "Въведете дневника на промените",
"marketPublish.modal.changelog.required": "Моля, въведете дневника на промените",
"marketPublish.modal.comparison.local": "Текуща локална версия",
"marketPublish.modal.comparison.remote": "Публикувана версия",
"marketPublish.modal.identifier.extra": "Това е уникалният идентификатор на агента. Използвайте малки букви, цифри и тирета.",
"marketPublish.modal.identifier.label": "Идентификатор на агента",
"marketPublish.modal.identifier.lengthError": "Идентификаторът трябва да е между 3 и 50 знака",
"marketPublish.modal.identifier.patternError": "Идентификаторът може да съдържа само малки букви, цифри и тирета",
"marketPublish.modal.identifier.placeholder": "Въведете уникален идентификатор за агента, напр. web-development",
"marketPublish.modal.identifier.required": "Моля, въведете идентификатор на агента",
"marketPublish.modal.loading.fetchingRemote": "Зареждане на отдалечени данни...",
"marketPublish.modal.loading.submit": "Изпращане на агента...",
"marketPublish.modal.loading.submitGroup": "Изпращане на групата...",
"marketPublish.modal.loading.upload": "Публикуване на нова версия...",
"marketPublish.modal.loading.uploadGroup": "Публикуване на нова версия на групата...",
"marketPublish.modal.messages.createVersionFailed": "Неуспешно създаване на версия: {{message}}",
"marketPublish.modal.messages.fetchRemoteFailed": "Неуспешно извличане на данни за отдалечения агент",
"marketPublish.modal.messages.missingIdentifier": "Този агент все още няма идентификатор в Общността.",
"marketPublish.modal.messages.noGroup": "Няма избрана група",
"marketPublish.modal.messages.notAuthenticated": "Първо влезте в профила си в Общността.",
"marketPublish.modal.messages.publishFailed": "Публикуването не бе успешно: {{message}}",
"marketPublish.modal.submitButton": "Публикувай",
"marketPublish.modal.title.submit": "Сподели в Общността на агентите",
"marketPublish.modal.title.upload": "Публикувай нова версия",
"marketPublish.resultModal.message": "Вашият агент е изпратен за преглед. След одобрение ще бъде автоматично публикуван.",
"marketPublish.resultModal.messageGroup": "Вашата група е изпратена за преглед. След одобрение ще бъде автоматично публикувана.",
"marketPublish.resultModal.title": "Успешно изпращане",
"marketPublish.resultModal.view": "Виж в Общността",
"marketPublish.status.underReview": "В процес на преглед",
"marketPublish.submit.button": "Сподели в Общността",
"marketPublish.submit.tooltip": "Споделете този агент в Общността",
"marketPublish.submitGroup.tooltip": "Споделете тази група с общността",
"marketPublish.upload.button": "Публикувай нова версия",
"marketPublish.upload.tooltip": "Публикувайте нова версия в Общността на агентите",
"marketPublish.uploadGroup.tooltip": "Публикувайте нова версия в общността на групите",
"marketPublish.validation.communitySetupRequired.action": "Настройте сега",
"marketPublish.validation.communitySetupRequired.desc": "Това работно пространство все още не е настроило своя профил в Общността. Настройте го преди публикуване в Общността.",
"marketPublish.validation.communitySetupRequired.memberHint": "Това работно пространство все още не е настроило своя профил в Общността. Помолете собственик на работното пространство да го настрои преди публикуване в Общността.",
"marketPublish.validation.communitySetupRequired.title": "Първо настройте профила в Общността",
"marketPublish.validation.confirmPublish": "Публикуване в Маркета?",
"marketPublish.validation.confirmPublishDesc": "След публикуване, това съдържание ще бъде публично видимо в маркета и достъпно за всеки да го открие и използва.",
"marketPublish.validation.emptyName": "Не може да се публикува: Името е задължително",
"marketPublish.validation.emptySystemRole": "Не може да се публикува: Системната роля е задължителна",
"marketPublish.validation.underReview": "Вашата нова версия в момента се преглежда. Моля, изчакайте одобрение преди да публикувате нова версия.",
"memory.effort.desc": "Контролирайте колко агресивно AI извлича и актуализира паметта.",
"memory.effort.high": "Високо — Проактивно извличане и актуализации",
"memory.effort.level.high": "Високо",
@@ -515,14 +462,6 @@
"myAgents.actions.deprecateLoading": "Оттегляне на агента...",
"myAgents.actions.deprecateSuccess": "Агентът е оттеглен",
"myAgents.actions.edit": "Редактирай агент",
"myAgents.actions.publish": "Публикувай агент",
"myAgents.actions.publishError": "Неуспешно публикуване на агента",
"myAgents.actions.publishLoading": "Публикуване на агента...",
"myAgents.actions.publishSuccess": "Агентът е публикуван",
"myAgents.actions.unpublish": "Скрий агента",
"myAgents.actions.unpublishError": "Неуспешно скриване на агента",
"myAgents.actions.unpublishLoading": "Скриване на агента...",
"myAgents.actions.unpublishSuccess": "Агентът е скрит",
"myAgents.actions.viewDetail": "Виж подробности",
"myAgents.detail.category": "Категория",
"myAgents.detail.description": "Описание",
@@ -587,7 +526,6 @@
"plugin.settings.title": "Конфигурация на умение {{id}}",
"plugin.settings.tooltip": "Конфигурация на умение",
"plugin.store": "Магазин за умения",
"publishToCommunity": "Публикувай в общността",
"serviceModel.contextLimit.placeholder": "Ограничение на контекста",
"serviceModel.memoryModels.title": "Модели на паметта",
"serviceModel.modelAssignments.title": "Назначения на модели",
@@ -955,13 +893,6 @@
"storageOverage.usage.estimatedCharge": "Очаквана такса за цикъл",
"storageOverage.usage.incurredCharge": "Начислено за този цикъл",
"storageOverage.usage.overage": "Превишение",
"submitAgentModal.button": "Изпрати агент",
"submitAgentModal.identifier": "Идентификатор на агент",
"submitAgentModal.metaMiss": "Моля, попълнете информацията за агента преди изпращане. Тя трябва да включва име, описание и тагове",
"submitAgentModal.placeholder": "Въведете уникален идентификатор за агента, напр. web-development",
"submitAgentModal.success": "Агентът е изпратен успешно",
"submitAgentModal.tooltips": "Сподели в общността на агентите",
"submitGroupModal.tooltips": "Сподели в общността на групите",
"sync.device.deviceName.hint": "Добавете име за по-лесно разпознаване",
"sync.device.deviceName.placeholder": "Въведете име на устройство",
"sync.device.deviceName.title": "Име на устройство",
@@ -1086,6 +1017,7 @@
"tools.activation.auto": "Автоматично",
"tools.activation.auto.desc": "Интелигентно",
"tools.activation.fixed.hint": "Винаги включено — управлява се от приложението и не може да бъде изключено",
"tools.activation.pin": "Пин",
"tools.activation.pinned": "Закрепено",
"tools.activation.pinned.desc": "Винаги включено",
"tools.add": "Добави умение",
@@ -2047,6 +1979,14 @@
"workspace.wizard.step3.title": "Добре дошли в {{name}}!",
"workspace.wizard.title": "Създайте работно пространство",
"workspaceSetting.breadcrumb.settings": "Настройки",
"workspaceSetting.devices.desc": "Споделени машини, записани в това работно пространство. Членовете могат да изпълняват агенти на тях.",
"workspaceSetting.devices.empty": "Все още няма устройства в работното пространство.",
"workspaceSetting.devices.enrollDesc": "Изпълнете това на машината, която искате да споделите (само за собственика на работното пространство):",
"workspaceSetting.devices.enrollTitle": "Добавете устройство",
"workspaceSetting.devices.heroDesc": "Запишете споделена машина — сървър за изграждане или екипен Mac — и всеки член може да изпълнява агенти на нея: четене/запис на файлове, изпълнение на команди и използване на системни инструменти.",
"workspaceSetting.devices.heroTitle": "Свържете първото устройство към работното пространство",
"workspaceSetting.devices.offline": "Офлайн",
"workspaceSetting.devices.online": "Онлайн",
"workspaceSetting.group.admin": "Администратор",
"workspaceSetting.group.agent": "Агент",
"workspaceSetting.group.general": "Общи",
+10 -5
View File
@@ -147,10 +147,6 @@
"limitation.chat.topupSuccess.title": "Успешно зареждане",
"limitation.expired.desc": "Вашите {{plan}} изчислителни кредити изтекоха на {{expiredAt}}. Надградете плана си сега, за да получите нови кредити.",
"limitation.expired.title": "Изчислителните кредити са изтекли",
"limitation.fableCampaign.desc": "Claude Fable 5 е модел с висока цена. Кредитите за пробната кампания са изчерпани. Надградете плана си, за да продължите да използвате Fable.",
"limitation.fableCampaign.title": "Изчерпани кредити за пробната версия на Fable",
"limitation.fableCampaign.upgrade": "Надградете плана",
"limitation.fableCampaign.upgradeToPlan": "Надградете до {{plan}}",
"limitation.hobby.action": "Конфигурирано, продължи разговора",
"limitation.hobby.configAPI": "Конфигурирай API",
"limitation.hobby.desc": "Вашите безплатни изчислителни кредити са изчерпани. Моля, конфигурирайте персонализиран API на модел, за да продължите.",
@@ -342,7 +338,14 @@
"plans.workspace.noSharedCredits": "Без споделени кредити",
"plans.workspace.sharedCredits": "~{{count}} кредити / месец",
"plans.workspace.solo": "Соло (1 член)",
"promoBanner.fableYearly": "Годишните абонати получават {{percent}}% отстъпка за ограничено време",
"plansModal.creditLimit.desc": "Надградете плана си, за да отключите повече месечни кредити и да продължите работа без прекъсване.",
"plansModal.creditLimit.title": "Нямате кредити",
"plansModal.default.desc": "Отключете повече капацитет и разширени функции.",
"plansModal.default.title": "Надградете плана си",
"plansModal.fileStorageLimit.desc": "Вашето файлово хранилище е пълно. Надградете, за да продължите да качвате и управлявате файлове.",
"plansModal.fileStorageLimit.title": "Достигнат лимит за хранилище",
"plansModal.modelAccess.desc": "Този модел е достъпен в платените планове. Надградете, за да използвате пълния набор от модели.",
"plansModal.modelAccess.title": "Отключете всички модели",
"qa.desc": "Ако въпросът ви не е отговорен, проверете <1>Документацията на продукта</1> за още често задавани въпроси или се свържете с нас.",
"qa.detail": "Виж подробности",
"qa.list.credit.a": "Изчислителните кредити са мярка, използвана от {{cloud}} за измерване на използването на AI модели при извикване на модели. Различните AI модели консумират различно количество изчислителни кредити.",
@@ -398,6 +401,8 @@
"referral.errors.invalidFormat": "Невалиден формат на кода, въведете 2–8 букви, цифри или долни черти",
"referral.errors.selfReferral": "Не можете да използвате собствения си код за покана",
"referral.errors.updateFailed": "Неуспешна актуализация, моля опитайте отново по-късно",
"referral.hero.description": "Споделете вашия реферален линк по-долу. След като вашият приятел направи първото си плащане, и двамата ще получите {{reward}}M кредита.",
"referral.hero.title": "Поканете приятели, и двамата печелите <0>{{reward}}M кредита</0>",
"referral.inviteCode.description": "Споделете своя уникален код за покана, за да поканите приятели да се регистрират",
"referral.inviteCode.title": "Моят код за покана",
"referral.inviteLink.description": "Копирайте линка и го споделете с приятели. И двамата получавате кредити, след като вашият приятел направи плащане.",
+1
View File
@@ -25,6 +25,7 @@
"actions.unmarkCompleted": "Отбележи като активна",
"defaultTitle": "Тема по подразбиране",
"displayItems": "Показване на елементи",
"draft": "[Чернова]",
"duplicateLoading": "Копиране на тема...",
"duplicateSuccess": "Темата беше успешно копирана",
"failedStatusTip": "Този процес срещна грешка — отворете го, за да разгледате.",
+28 -2
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "Denkt nach",
"artifact.thought": "Denkprozess",
"artifact.unknownTitle": "Unbenannte Arbeit",
"audioPlayer.pause": "Audio pausieren",
"audioPlayer.play": "Audio abspielen",
"availableAgents": "Verfügbare Agenten",
"backToBottom": "Zum neuesten Beitrag springen",
"beforeUnload.confirmLeave": "Eine Anfrage läuft noch. Trotzdem verlassen?",
@@ -120,6 +122,18 @@
"createModal.groupPlaceholder": "Beschreiben Sie, was diese Gruppe tun soll...",
"createModal.groupTitle": "Was soll Ihre Gruppe tun?",
"createModal.placeholder": "Beschreiben Sie, was Ihr Agent tun soll...",
"createModal.skillSuggestion.actions.createAnyway": "Agent trotzdem erstellen",
"createModal.skillSuggestion.actions.createAnywayHint": "Skill passt nicht?",
"createModal.skillSuggestion.actions.install": "Skill installieren",
"createModal.skillSuggestion.actions.installing": "Wird installiert…",
"createModal.skillSuggestion.actions.openSkills": "In Skills anzeigen",
"createModal.skillSuggestion.actions.tryInLobeAI": "In {{name}} verwenden",
"createModal.skillSuggestion.description": "Das scheint ein wiederverwendbarer Workflow zu sein. Installieren Sie den Skill einmal und nutzen Sie ihn dann in verschiedenen Agents.",
"createModal.skillSuggestion.installError": "Skill wurde nicht installiert. Versuchen Sie es erneut oder erstellen Sie trotzdem einen Agent.",
"createModal.skillSuggestion.installed.description": "Sie können diesen Skill in {{name}} verwenden oder ihn für jeden Agent aktivieren.",
"createModal.skillSuggestion.installed.ready": "Bereit in {{name}}",
"createModal.skillSuggestion.installed.title": "Skill installiert",
"createModal.skillSuggestion.title": "Ein Skill könnte besser passen",
"createModal.title": "Was soll Ihr Agent tun?",
"createTask.assignee": "Zuständige Person",
"createTask.collapse": "Eingabe ausblenden",
@@ -166,6 +180,8 @@
"extendParams.title": "Modellerweiterungsfunktionen",
"extendParams.urlContext.desc": "Wenn aktiviert, werden Weblinks automatisch analysiert, um den tatsächlichen Seiteninhalt abzurufen",
"extendParams.urlContext.title": "Webseiteninhalte extrahieren",
"floatingChatPanel.collapse": "Chat minimieren",
"floatingChatPanel.expand": "Chat maximieren",
"followUpPlaceholder": "Folgen Sie nach. @, um Aufgaben anderen Agenten zuzuweisen.",
"followUpPlaceholderHeterogeneous": "Weiter ausführen.",
"gatewayMode.beta": "Beta",
@@ -219,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "Keine Repositories konfiguriert. Fügen Sie diese in den Agenteneinstellungen hinzu.",
"heteroAgent.cloudRepo.notSet": "Kein Repository ausgewählt",
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
"heteroAgent.executionTarget.auto": "Automatisch",
"heteroAgent.executionTarget.autoDesc": "Automatisch ein Online-Gerät verwenden, wobei eines ausgewählt wird, wenn mehrere verfügbar sind",
"heteroAgent.executionTarget.downloadDesktop": "Desktop-App herunterladen",
"heteroAgent.executionTarget.downloadDesktopDesc": "Führen Sie Agenten mit Zugriff auf Ihren Computer aus",
"heteroAgent.executionTarget.downloadDesktopTitle": "Desktop-App herunterladen",
"heteroAgent.executionTarget.gateway": "Gateway",
"heteroAgent.executionTarget.gatewayDesc": "Über das Geräte-Gateway ausführen, damit andere Clients den Fortschritt verfolgen können",
"heteroAgent.executionTarget.infoTooltip": "Wählen Sie ein Remote-Gerät aus, um diese Maschine über das Web zu steuern. \"Dieses Gerät\" führt den Agenten lokal aus und ist nur in der Desktop-App verfügbar.",
"heteroAgent.executionTarget.loading": "Geräte werden geladen…",
"heteroAgent.executionTarget.local": "Dieses Gerät",
@@ -231,10 +251,12 @@
"heteroAgent.executionTarget.noneDesc": "Kein Gerät aktiviert",
"heteroAgent.executionTarget.offline": "Offline",
"heteroAgent.executionTarget.online": "Online",
"heteroAgent.executionTarget.personalGroup": "Persönlich",
"heteroAgent.executionTarget.sandbox": "Cloud-Sandbox",
"heteroAgent.executionTarget.sandboxDesc": "In einer temporären Cloud-Sandbox ausführen",
"heteroAgent.executionTarget.title": "Ausführungsgerät",
"heteroAgent.executionTarget.unknownDevice": "Unbekanntes Gerät",
"heteroAgent.executionTarget.workspaceGroup": "Arbeitsbereich",
"heteroAgent.fullAccess.label": "Vollzugriff",
"heteroAgent.fullAccess.tooltip": "Claude Code läuft lokal mit vollständigem Lese-/Schreibzugriff auf das Arbeitsverzeichnis. Das Umschalten von Berechtigungsmodi ist derzeit nicht verfügbar.",
"heteroAgent.resumeReset.cwdChanged": "Arbeitsverzeichnis geändert. Die vorherige Claude-Code-Sitzung kann nur aus dem ursprünglichen Verzeichnis fortgesetzt werden, daher wurde eine neue Unterhaltung gestartet.",
@@ -631,6 +653,8 @@
"taskDetail.artifacts": "Artefakte",
"taskDetail.blockedBy": "Blockiert durch {{id}}",
"taskDetail.cancelSchedule": "Planung abbrechen",
"taskDetail.closeDetail": "Details schließen",
"taskDetail.collapseReply": "Einklappen",
"taskDetail.comment.cancel": "Abbrechen",
"taskDetail.comment.delete": "Löschen",
"taskDetail.comment.deleteConfirm.content": "Dieser Kommentar wird dauerhaft entfernt.",
@@ -657,6 +681,7 @@
"taskDetail.notFound.backToTasks": "Zurück zu allen Aufgaben",
"taskDetail.notFound.desc": "Diese Aufgabe wurde möglicherweise gelöscht oder Sie haben keine Berechtigung, sie anzusehen.",
"taskDetail.notFound.title": "Aufgabe nicht gefunden",
"taskDetail.openDetail": "Details öffnen",
"taskDetail.pauseTask": "Aufgabe pausieren",
"taskDetail.priority.high": "Hoch",
"taskDetail.priority.low": "Niedrig",
@@ -925,9 +950,9 @@
"workflow.collapse": "Einklappen",
"workflow.expandFull": "Vollständig ausklappen",
"workflow.failedSuffix": "(fehlgeschlagen)",
"workflow.summaryAcrossTools": "über {{count}} Tools",
"workflow.summaryCallsLead": "{{count}} Anrufe: {{tools}}",
"workflow.summaryFailed": "{{count}} fehlgeschlagen",
"workflow.summaryMoreTools": "{{count}} Werkzeugarten",
"workflow.summaryTotalCalls": "{{count}} Aufrufe insgesamt",
"workflow.thoughtForDuration": "Nachgedacht für {{duration}}",
"workflow.toolDisplayName.activateDevice": "Gerät aktiviert",
"workflow.toolDisplayName.activateSkill": "Eine Fähigkeit wurde aktiviert",
@@ -1043,6 +1068,7 @@
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.deleteTitleMulti": "{{count}} Elemente löschen?",
"workingPanel.resources.empty": "Noch keine Dokumente. Dokumente, die diesem Agenten zugeordnet sind, werden hier angezeigt.",
"workingPanel.resources.emptyDocuments": "Noch keine Dokumente. Erstellen Sie eines mit dem + oben.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "Dokumente",
"workingPanel.resources.filter.skills": "Fähigkeiten",
+18 -2
View File
@@ -444,6 +444,23 @@
"tab.setting": "Einstellungen",
"tab.tasks": "Aufgaben",
"tab.video": "Video",
"taskTemplate.action.connect.button": "Verbinden mit {{provider}}",
"taskTemplate.action.connect.error": "Verbindung fehlgeschlagen, bitte versuchen Sie es erneut.",
"taskTemplate.action.connect.popupBlocked": "Verbindungspopup blockiert. Erlauben Sie Popups in Ihrem Browser, um fortzufahren.",
"taskTemplate.action.connect.short": "Verbinden",
"taskTemplate.action.connecting": "Warten auf Autorisierung…",
"taskTemplate.action.create.error": "Aufgabe konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"taskTemplate.action.create.success": "Geplante Aufgabe hinzugefügt. Finden Sie sie in Lobe AI.",
"taskTemplate.action.createButton": "Aufgabe hinzufügen",
"taskTemplate.action.creating": "Wird erstellt...",
"taskTemplate.action.dismiss.error": "Abweisung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"taskTemplate.action.dismiss.tooltip": "Nicht interessiert",
"taskTemplate.action.refresh.button": "Aktualisieren",
"taskTemplate.card.templateTag": "Vorlage",
"taskTemplate.schedule.daily": "Jeden Tag um {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "Sie können den Zeitplan nach der Erstellung der Aufgabe anpassen.",
"taskTemplate.schedule.weekly": "Jeden {{weekday}} um {{time}}",
"taskTemplate.section.title": "Probieren Sie diese geplanten Aufgaben aus",
"telemetry.allow": "Zulassen",
"telemetry.deny": "Ablehnen",
"telemetry.desc": "Wir möchten anonym Nutzungsdaten erfassen, um {{appName}} zu verbessern und Ihnen ein besseres Produkterlebnis zu bieten. Sie können dies jederzeit unter Einstellungen Über deaktivieren.",
@@ -474,15 +491,14 @@
"userPanel.email": "E-Mail-Support",
"userPanel.feedback": "Kontaktieren Sie uns",
"userPanel.help": "Hilfezentrum",
"userPanel.inviteFriend": "Einen Freund einladen",
"userPanel.moveGuide": "Die Schaltfläche für Einstellungen wurde hierher verschoben",
"userPanel.plans": "Abonnementpläne",
"userPanel.profile": "Konto",
"userPanel.setting": "Einstellungen",
"userPanel.upgradePlan": "Plan upgraden",
"userPanel.usages": "Nutzungsstatistiken",
"userPanel.workspaceCredits": "Arbeitsbereich-Guthaben",
"userPanel.workspaceSetting": "Arbeitsbereich-Einstellungen",
"userPanel.workspaceUsages": "Arbeitsbereich-Nutzung",
"version": "Version",
"zoom": "Zoom"
}
+2
View File
@@ -101,6 +101,7 @@
"LocalFile.action.open": "Öffnen",
"LocalFile.action.showInFolder": "Im Ordner anzeigen",
"MaxTokenSlider.unlimited": "Unbegrenzt",
"ModelSelect.featureTag.audio": "Dieses Modell unterstützt die Erkennung von Audioeingaben.",
"ModelSelect.featureTag.custom": "Benutzerdefiniertes Modell, unterstützt standardmäßig Funktionsaufrufe und visuelle Erkennung. Bitte prüfen Sie die tatsächliche Verfügbarkeit dieser Funktionen.",
"ModelSelect.featureTag.file": "Dieses Modell unterstützt das Hochladen von Dateien zur Analyse und Erkennung.",
"ModelSelect.featureTag.functionCall": "Dieses Modell unterstützt Funktionsaufrufe.",
@@ -114,6 +115,7 @@
"ModelSwitchPanel.byModel": "Nach Modell",
"ModelSwitchPanel.byProvider": "Nach Anbieter",
"ModelSwitchPanel.detail.abilities": "Fähigkeiten",
"ModelSwitchPanel.detail.abilities.audio": "Audio",
"ModelSwitchPanel.detail.abilities.files": "Dateien",
"ModelSwitchPanel.detail.abilities.functionCall": "Werkzeugaufruf",
"ModelSwitchPanel.detail.abilities.imageOutput": "Bildausgabe",
-3
View File
@@ -38,9 +38,6 @@
"brief.title": "Tagesübersicht",
"brief.viewAllTasks": "Alle Aufgaben anzeigen",
"brief.viewRun": "Lauf anzeigen",
"freeCreditBadge.cta": "Kostenlose Testversion starten",
"freeCreditBadge.dismiss": "Schließen",
"freeCreditBadge.label": "Exklusive Gratisguthaben für {{model}}",
"project.create": "Neues Projekt",
"project.deleteConfirm": "Dieses Projekt wird gelöscht und kann nicht wiederhergestellt werden. Bestätigen Sie, um fortzufahren.",
"recommendations.heteroAgent.cta": "Agent hinzufügen",
+4 -4
View File
@@ -5,10 +5,12 @@
"authorize.footer.agreement": "Indem du fortfährst, bestätigst du, dass du die <terms>Nutzungsbedingungen</terms> und die <privacy>Datenschutzerklärung</privacy> gelesen hast und ihnen zustimmst.",
"authorize.footer.privacy": "Datenschutzerklärung",
"authorize.footer.terms": "Nutzungsbedingungen",
"authorize.scenes.connector.confirm": "Weiter zum Markt",
"authorize.scenes.connector.description": "Der Markt wird nur verwendet, um diese Dienstautorisierung zu starten. Ihr {{appName}}-Konto bleibt getrennt.",
"authorize.scenes.connector.subtitle": "Melden Sie sich beim Markt an, um diesen Gemeinschaftsdienst zu verbinden und zu autorisieren.",
"authorize.scenes.connector.title": "Gemeinschaftsdienst verbinden",
"authorize.scenes.mcp.subtitle": "Erstellen Sie ein Community-Profil, um diese Fähigkeit aus der Community zu installieren und auszuführen.",
"authorize.scenes.mcp.title": "Community-Fähigkeit installieren",
"authorize.scenes.publish.subtitle": "Erstellen Sie ein Community-Profil, um Ihr Angebot in der Community zu veröffentlichen und zu verwalten.",
"authorize.scenes.publish.title": "In der Community veröffentlichen",
"authorize.scenes.sandbox.subtitle": "Erstellen Sie ein Community-Profil, um dieses Tool im Community-Sandbox-Modus auszuführen.",
"authorize.scenes.sandbox.title": "Community-Sandbox ausprobieren",
"authorize.subtitle": "Erstelle ein Community-Profil, um Einträge innerhalb der Community zu verwalten und einzureichen.",
@@ -50,8 +52,6 @@
"messages.handoffTimeout": "Autorisierung abgelaufen. Schließe sie im Browser ab und versuche es erneut.",
"messages.loading": "Autorisierungsvorgang wird gestartet...",
"messages.success.cloudMcpInstall": "Autorisierung erfolgreich! Du kannst jetzt die Cloud MCP-Funktion installieren.",
"messages.success.submit": "Autorisierung erfolgreich! Du kannst jetzt deinen Agenten veröffentlichen.",
"messages.success.upload": "Autorisierung erfolgreich! Du kannst jetzt eine neue Version veröffentlichen.",
"profileSetup.cancel": "Abbrechen",
"profileSetup.confirmChangeUserId.cancel": "Abbrechen",
"profileSetup.confirmChangeUserId.confirm": "Benutzer-ID ändern",
+4 -2
View File
@@ -222,6 +222,7 @@
"providerModels.item.modelConfig.extendParams.options.effort.hint": "Für Claude Opus 4.6; steuert das Anstrengungsniveau (niedrig/mittel/hoch/maximal).",
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "Für Claude Opus 4.6; schaltet adaptives Denken ein oder aus.",
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "Für Claude-, DeepSeek- und andere Modelle mit logischem Denken; ermöglicht tiefere Überlegungen.",
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "Für GLM-5.2; steuert den Aufwand für logisches Denken mit den Stufen Hoch und Maximal.",
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "Für die GPT-5-Serie; steuert die Intensität des logischen Denkens.",
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "Für die GPT-5.1-Serie; steuert die Intensität des logischen Denkens.",
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "Für die GPT-5.2 Pro-Serie; steuert die Intensität des logischen Denkens.",
@@ -256,6 +257,7 @@
"providerModels.item.modelConfig.files.title": "Datei-Upload-Unterstützung",
"providerModels.item.modelConfig.functionCall.extra": "Diese Konfiguration aktiviert nur die Fähigkeit des Modells, Werkzeuge zu verwenden. Ob das Modell diese tatsächlich nutzen kann, hängt vom Modell selbst ab. Bitte testen Sie die Nutzbarkeit selbst.",
"providerModels.item.modelConfig.functionCall.title": "Werkzeugnutzung unterstützen",
"providerModels.item.modelConfig.id.duplicate": "Ein Modell mit dieser ID existiert bereits. Verwenden Sie eine andere Modell-ID.",
"providerModels.item.modelConfig.id.extra": "Kann nach Erstellung nicht mehr geändert werden und wird als Modell-ID bei KI-Aufrufen verwendet",
"providerModels.item.modelConfig.id.placeholder": "Bitte geben Sie die Modell-ID ein, z.B. gpt-4o oder claude-3.5-sonnet",
"providerModels.item.modelConfig.id.title": "Modell-ID",
@@ -270,11 +272,11 @@
"providerModels.item.modelConfig.tokens.title": "Maximales Kontextfenster",
"providerModels.item.modelConfig.tokens.unlimited": "Unbegrenzt",
"providerModels.item.modelConfig.type.extra": "Verschiedene Modelltypen haben unterschiedliche Anwendungsfälle und Fähigkeiten",
"providerModels.item.modelConfig.type.options.asr": "Sprache-zu-Text",
"providerModels.item.modelConfig.type.options.chat": "Chat",
"providerModels.item.modelConfig.type.options.embedding": "Embedding",
"providerModels.item.modelConfig.type.options.image": "Bildgenerierung",
"providerModels.item.modelConfig.type.options.realtime": "Echtzeit-Chat",
"providerModels.item.modelConfig.type.options.stt": "Sprache-zu-Text",
"providerModels.item.modelConfig.type.options.text2music": "Text-zu-Musik",
"providerModels.item.modelConfig.type.options.tts": "Text-zu-Sprache",
"providerModels.item.modelConfig.type.options.video": "Videoerstellung",
@@ -323,10 +325,10 @@
"providerModels.list.total": "{{count}} Modelle verfügbar",
"providerModels.searchNotFound": "Keine Suchergebnisse gefunden",
"providerModels.tabs.all": "Alle",
"providerModels.tabs.asr": "ASR",
"providerModels.tabs.chat": "Chat",
"providerModels.tabs.embedding": "Embedding",
"providerModels.tabs.image": "Bild",
"providerModels.tabs.stt": "ASR",
"providerModels.tabs.tts": "TTS",
"providerModels.tabs.video": "Video",
"sortModal.success": "Sortierung erfolgreich aktualisiert",
+9 -69
View File
@@ -440,60 +440,7 @@
"llm.proxyUrl.title": "API-Proxy-URL",
"llm.waitingForMore": "Weitere Modelle sind <1>in Planung</1>, bleib dran",
"llm.waitingForMoreLinkAriaLabel": "Anbieter-Anfrageformular öffnen",
"marketPublish.forkConfirm.by": "von {{author}}",
"marketPublish.forkConfirm.confirm": "Veröffentlichung bestätigen",
"marketPublish.forkConfirm.confirmGroup": "Veröffentlichung bestätigen",
"marketPublish.forkConfirm.description": "Sie sind dabei, eine abgeleitete Version basierend auf einem bestehenden Agenten aus der Community zu veröffentlichen. Ihr neuer Agent wird als separater Eintrag im Marktplatz erstellt.",
"marketPublish.forkConfirm.descriptionGroup": "Sie sind dabei, eine abgeleitete Version basierend auf einer bestehenden Gruppe aus der Community zu veröffentlichen. Ihre neue Gruppe wird als separater Eintrag im Marktplatz erstellt.",
"marketPublish.forkConfirm.title": "Abgeleiteten Agenten veröffentlichen",
"marketPublish.forkConfirm.titleGroup": "Abgeleitete Gruppe veröffentlichen",
"marketPublish.modal.changelog.extra": "Beschreiben Sie die wichtigsten Änderungen und Verbesserungen in dieser Version",
"marketPublish.modal.changelog.label": "Änderungsprotokoll",
"marketPublish.modal.changelog.maxLengthError": "Das Änderungsprotokoll darf 500 Zeichen nicht überschreiten",
"marketPublish.modal.changelog.placeholder": "Änderungsprotokoll eingeben",
"marketPublish.modal.changelog.required": "Bitte geben Sie das Änderungsprotokoll ein",
"marketPublish.modal.comparison.local": "Aktuelle lokale Version",
"marketPublish.modal.comparison.remote": "Derzeit veröffentlichte Version",
"marketPublish.modal.identifier.extra": "Dies ist die eindeutige Kennung des Agenten. Verwenden Sie Kleinbuchstaben, Zahlen und Bindestriche.",
"marketPublish.modal.identifier.label": "Agentenkennung",
"marketPublish.modal.identifier.lengthError": "Die Kennung muss zwischen 3 und 50 Zeichen lang sein",
"marketPublish.modal.identifier.patternError": "Die Kennung darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten",
"marketPublish.modal.identifier.placeholder": "Eindeutige Kennung für den Agenten eingeben, z.B. web-entwicklung",
"marketPublish.modal.identifier.required": "Bitte geben Sie die Agentenkennung ein",
"marketPublish.modal.loading.fetchingRemote": "Remote-Daten werden geladen...",
"marketPublish.modal.loading.submit": "Agent wird übermittelt...",
"marketPublish.modal.loading.submitGroup": "Gruppe wird übermittelt...",
"marketPublish.modal.loading.upload": "Neue Version wird veröffentlicht...",
"marketPublish.modal.loading.uploadGroup": "Neue Gruppenversion wird veröffentlicht...",
"marketPublish.modal.messages.createVersionFailed": "Version konnte nicht erstellt werden: {{message}}",
"marketPublish.modal.messages.fetchRemoteFailed": "Remote-Agentendaten konnten nicht abgerufen werden",
"marketPublish.modal.messages.missingIdentifier": "Dieser Agent hat noch keine Community-Kennung.",
"marketPublish.modal.messages.noGroup": "Keine Gruppe ausgewählt",
"marketPublish.modal.messages.notAuthenticated": "Melden Sie sich zuerst bei Ihrem Community-Konto an.",
"marketPublish.modal.messages.publishFailed": "Veröffentlichung fehlgeschlagen: {{message}}",
"marketPublish.modal.submitButton": "Veröffentlichen",
"marketPublish.modal.title.submit": "In der Agenten-Community teilen",
"marketPublish.modal.title.upload": "Neue Version veröffentlichen",
"marketPublish.resultModal.message": "Ihr Agent wurde zur Überprüfung eingereicht. Nach Freigabe wird er automatisch veröffentlicht.",
"marketPublish.resultModal.messageGroup": "Ihre Gruppe wurde zur Überprüfung eingereicht. Nach der Freigabe wird sie automatisch veröffentlicht.",
"marketPublish.resultModal.title": "Erfolgreich eingereicht",
"marketPublish.resultModal.view": "In der Community anzeigen",
"marketPublish.status.underReview": "In Prüfung",
"marketPublish.submit.button": "In der Community teilen",
"marketPublish.submit.tooltip": "Diesen Agenten in der Community teilen",
"marketPublish.submitGroup.tooltip": "Diese Gruppe mit der Community teilen",
"marketPublish.upload.button": "Neue Version veröffentlichen",
"marketPublish.upload.tooltip": "Eine neue Version in der Agenten-Community veröffentlichen",
"marketPublish.uploadGroup.tooltip": "Neue Version in der Gruppen-Community veröffentlichen",
"marketPublish.validation.communitySetupRequired.action": "Jetzt einrichten",
"marketPublish.validation.communitySetupRequired.desc": "Dieser Arbeitsbereich hat sein Community-Profil noch nicht eingerichtet. Richten Sie es ein, bevor Sie es in der Community veröffentlichen.",
"marketPublish.validation.communitySetupRequired.memberHint": "Dieser Arbeitsbereich hat sein Community-Profil noch nicht eingerichtet. Bitten Sie einen Arbeitsbereichsbesitzer, es einzurichten, bevor Sie es in der Community veröffentlichen.",
"marketPublish.validation.communitySetupRequired.title": "Community-Profil zuerst einrichten",
"marketPublish.validation.confirmPublish": "Im Markt veröffentlichen?",
"marketPublish.validation.confirmPublishDesc": "Nach der Veröffentlichung ist dieser Inhalt öffentlich im Markt sichtbar und für jeden zugänglich.",
"marketPublish.validation.emptyName": "Veröffentlichung nicht möglich: Name ist erforderlich",
"marketPublish.validation.emptySystemRole": "Veröffentlichung nicht möglich: Systemrolle ist erforderlich",
"marketPublish.validation.underReview": "Ihre neue Version wird derzeit geprüft. Bitte warten Sie auf die Genehmigung, bevor Sie eine neue Version veröffentlichen.",
"memory.effort.desc": "Steuern Sie, wie aggressiv die KI Speicher abruft und aktualisiert.",
"memory.effort.high": "Hoch — Proaktives Abrufen und Aktualisieren",
"memory.effort.level.high": "Hoch",
@@ -515,14 +462,6 @@
"myAgents.actions.deprecateLoading": "Agent wird veraltet...",
"myAgents.actions.deprecateSuccess": "Agent veraltet",
"myAgents.actions.edit": "Agent bearbeiten",
"myAgents.actions.publish": "Agent veröffentlichen",
"myAgents.actions.publishError": "Agent konnte nicht veröffentlicht werden",
"myAgents.actions.publishLoading": "Agent wird veröffentlicht...",
"myAgents.actions.publishSuccess": "Agent veröffentlicht",
"myAgents.actions.unpublish": "Agent zurückziehen",
"myAgents.actions.unpublishError": "Agent konnte nicht zurückgezogen werden",
"myAgents.actions.unpublishLoading": "Agent wird zurückgezogen...",
"myAgents.actions.unpublishSuccess": "Agent zurückgezogen",
"myAgents.actions.viewDetail": "Details anzeigen",
"myAgents.detail.category": "Kategorie",
"myAgents.detail.description": "Beschreibung",
@@ -587,7 +526,6 @@
"plugin.settings.title": "{{id}} Fähigkeitenkonfiguration",
"plugin.settings.tooltip": "Fähigkeitenkonfiguration",
"plugin.store": "Fähigkeiten-Store",
"publishToCommunity": "In der Community veröffentlichen",
"serviceModel.contextLimit.placeholder": "Kontextlimit",
"serviceModel.memoryModels.title": "Speichermodelle",
"serviceModel.modelAssignments.title": "Modellzuweisungen",
@@ -955,13 +893,6 @@
"storageOverage.usage.estimatedCharge": "Geschätzte Zyklusgebühr",
"storageOverage.usage.incurredCharge": "In diesem Zyklus angefallen",
"storageOverage.usage.overage": "Überlastung",
"submitAgentModal.button": "Agent einreichen",
"submitAgentModal.identifier": "Agentenkennung",
"submitAgentModal.metaMiss": "Bitte vervollständigen Sie die Agenteninformationen vor dem Einreichen. Name, Beschreibung und Tags sind erforderlich.",
"submitAgentModal.placeholder": "Geben Sie eine eindeutige Kennung für den Agenten ein, z.B. web-entwicklung",
"submitAgentModal.success": "Agent erfolgreich eingereicht",
"submitAgentModal.tooltips": "In der Agenten-Community teilen",
"submitGroupModal.tooltips": "In der Gruppen-Community teilen",
"sync.device.deviceName.hint": "Fügen Sie einen Namen zur leichteren Identifizierung hinzu",
"sync.device.deviceName.placeholder": "Gerätenamen eingeben",
"sync.device.deviceName.title": "Gerätename",
@@ -1086,6 +1017,7 @@
"tools.activation.auto": "Automatisch",
"tools.activation.auto.desc": "Intelligent",
"tools.activation.fixed.hint": "Immer aktiv — wird von der App verwaltet und kann nicht deaktiviert werden",
"tools.activation.pin": "Pin",
"tools.activation.pinned": "Angeheftet",
"tools.activation.pinned.desc": "Immer an",
"tools.add": "Fähigkeit hinzufügen",
@@ -2047,6 +1979,14 @@
"workspace.wizard.step3.title": "Willkommen bei {{name}}!",
"workspace.wizard.title": "Arbeitsbereich erstellen",
"workspaceSetting.breadcrumb.settings": "Einstellungen",
"workspaceSetting.devices.desc": "Gemeinsam genutzte Geräte, die in diesem Arbeitsbereich registriert sind. Mitglieder können darauf Agenten ausführen.",
"workspaceSetting.devices.empty": "Noch keine Geräte im Arbeitsbereich.",
"workspaceSetting.devices.enrollDesc": "Führen Sie dies auf dem Gerät aus, das Sie teilen möchten (nur für Arbeitsbereichsinhaber):",
"workspaceSetting.devices.enrollTitle": "Ein Gerät hinzufügen",
"workspaceSetting.devices.heroDesc": "Registrieren Sie ein gemeinsam genutztes Gerät einen Build-Server oder einen Team-Mac und jedes Mitglied kann darauf Agenten ausführen: Dateien lesen/schreiben, Befehle ausführen und Systemtools aufrufen.",
"workspaceSetting.devices.heroTitle": "Verbinden Sie Ihr erstes Arbeitsbereichsgerät",
"workspaceSetting.devices.offline": "Offline",
"workspaceSetting.devices.online": "Online",
"workspaceSetting.group.admin": "Admin",
"workspaceSetting.group.agent": "Agent",
"workspaceSetting.group.general": "Allgemein",
+10 -5
View File
@@ -147,10 +147,6 @@
"limitation.chat.topupSuccess.title": "Aufladung erfolgreich",
"limitation.expired.desc": "Deine {{plan}}-Rechenguthaben sind am {{expiredAt}} abgelaufen. Upgrade jetzt, um neue Guthaben zu erhalten.",
"limitation.expired.title": "Rechenguthaben abgelaufen",
"limitation.fableCampaign.desc": "Claude Fable 5 ist ein hochpreisiges Modell. Die Kampagnen-Testguthaben sind aufgebraucht. Aktualisieren Sie Ihren Plan, um Fable weiterhin zu nutzen.",
"limitation.fableCampaign.title": "Fable-Testguthaben aufgebraucht",
"limitation.fableCampaign.upgrade": "Plan aktualisieren",
"limitation.fableCampaign.upgradeToPlan": "Upgrade auf {{plan}}",
"limitation.hobby.action": "Konfiguriert, weiter chatten",
"limitation.hobby.configAPI": "API konfigurieren",
"limitation.hobby.desc": "Deine kostenlosen Rechenguthaben sind aufgebraucht. Bitte konfiguriere eine benutzerdefinierte Modell-API, um fortzufahren.",
@@ -342,7 +338,14 @@
"plans.workspace.noSharedCredits": "Keine geteilten Credits",
"plans.workspace.sharedCredits": "~{{count}} Credits / Monat",
"plans.workspace.solo": "Solo (1 Mitglied)",
"promoBanner.fableYearly": "Jahresabonnenten erhalten {{percent}}% Rabatt für eine begrenzte Zeit",
"plansModal.creditLimit.desc": "Aktualisieren Sie Ihren Plan, um mehr monatliche Credits freizuschalten und ohne Unterbrechung weiterzuarbeiten.",
"plansModal.creditLimit.title": "Ihnen sind die Credits ausgegangen",
"plansModal.default.desc": "Schalten Sie mehr Kapazität und erweiterte Funktionen frei.",
"plansModal.default.title": "Aktualisieren Sie Ihren Plan",
"plansModal.fileStorageLimit.desc": "Ihr Dateispeicher ist voll. Aktualisieren Sie, um weiterhin Dateien hochzuladen und zu verwalten.",
"plansModal.fileStorageLimit.title": "Speicherlimit erreicht",
"plansModal.modelAccess.desc": "Dieses Modell ist in kostenpflichtigen Plänen verfügbar. Aktualisieren Sie, um die vollständige Modellauswahl zu nutzen.",
"plansModal.modelAccess.title": "Alle Modelle freischalten",
"qa.desc": "Wenn Ihre Frage nicht beantwortet wurde, besuchen Sie die <1>Produktdokumentation</1> für weitere FAQs oder kontaktieren Sie uns.",
"qa.detail": "Details anzeigen",
"qa.list.credit.a": "Rechen-Credits sind eine Metrik von {{cloud}}, um die Nutzung von KI-Modellen zu messen. Verschiedene Modelle verbrauchen unterschiedlich viele Credits.",
@@ -398,6 +401,8 @@
"referral.errors.invalidFormat": "Ungültiges Format, bitte 28 Buchstaben, Zahlen oder Unterstriche eingeben",
"referral.errors.selfReferral": "Du kannst deinen eigenen Einladungscode nicht verwenden",
"referral.errors.updateFailed": "Aktualisierung fehlgeschlagen, bitte später erneut versuchen",
"referral.hero.description": "Teilen Sie Ihren Empfehlungslink unten. Nachdem Ihr Freund seine erste Zahlung geleistet hat, erhalten Sie beide jeweils {{reward}}M Credits.",
"referral.hero.title": "Laden Sie Freunde ein, Sie verdienen beide <0>{{reward}}M Credits</0>",
"referral.inviteCode.description": "Teilen Sie Ihren exklusiven Empfehlungscode, um Freunde einzuladen",
"referral.inviteCode.title": "Mein Empfehlungscode",
"referral.inviteLink.description": "Kopieren Sie den Link und teilen Sie ihn mit Freunden. Beide erhalten Credits, nachdem Ihr Freund eine Zahlung getätigt hat.",
+1
View File
@@ -25,6 +25,7 @@
"actions.unmarkCompleted": "Als aktiv markieren",
"defaultTitle": "Standardthema",
"displayItems": "Elemente anzeigen",
"draft": "[Entwurf]",
"duplicateLoading": "Thema wird kopiert...",
"duplicateSuccess": "Thema erfolgreich kopiert",
"failedStatusTip": "Dieser Durchlauf hat einen Fehler — öffnen Sie ihn, um nachzusehen.",
+6
View File
@@ -246,15 +246,18 @@
"heteroAgent.executionTarget.loading": "Loading devices…",
"heteroAgent.executionTarget.local": "This device",
"heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app",
"heteroAgent.executionTarget.manage": "Manage",
"heteroAgent.executionTarget.noDevices": "No remote devices yet. Run `lh connect` on another machine to add one.",
"heteroAgent.executionTarget.none": "No device",
"heteroAgent.executionTarget.noneDesc": "No device enabled",
"heteroAgent.executionTarget.offline": "Offline",
"heteroAgent.executionTarget.online": "Online",
"heteroAgent.executionTarget.personalGroup": "Personal",
"heteroAgent.executionTarget.sandbox": "Cloud Sandbox",
"heteroAgent.executionTarget.sandboxDesc": "Run in an ephemeral cloud sandbox",
"heteroAgent.executionTarget.title": "Execution Device",
"heteroAgent.executionTarget.unknownDevice": "Unknown device",
"heteroAgent.executionTarget.workspaceGroup": "Workspace",
"heteroAgent.fullAccess.label": "Full access",
"heteroAgent.fullAccess.tooltip": "Claude Code runs locally with full read/write access to the working directory. Switching permission modes is not available yet.",
"heteroAgent.resumeReset.cwdChanged": "Working directory changed. Previous Claude Code session can only be resumed from its original directory, so a new conversation has started.",
@@ -651,6 +654,8 @@
"taskDetail.artifacts": "Artifacts",
"taskDetail.blockedBy": "Blocked by {{id}}",
"taskDetail.cancelSchedule": "Cancel schedule",
"taskDetail.closeDetail": "Close detail",
"taskDetail.collapseReply": "Collapse",
"taskDetail.comment.cancel": "Cancel",
"taskDetail.comment.delete": "Delete",
"taskDetail.comment.deleteConfirm.content": "This comment will be permanently removed.",
@@ -677,6 +682,7 @@
"taskDetail.notFound.backToTasks": "Back to all tasks",
"taskDetail.notFound.desc": "This task may have been deleted, or you don't have permission to view it.",
"taskDetail.notFound.title": "Task not found",
"taskDetail.openDetail": "Open detail",
"taskDetail.pauseTask": "Pause task",
"taskDetail.priority.high": "High",
"taskDetail.priority.low": "Low",
+2
View File
@@ -491,12 +491,14 @@
"userPanel.email": "Email Support",
"userPanel.feedback": "Contact Us",
"userPanel.help": "Help Center",
"userPanel.inviteFriend": "Invite a friend",
"userPanel.moveGuide": "The settings button has been moved here",
"userPanel.plans": "Subscription Plans",
"userPanel.profile": "Account",
"userPanel.setting": "Settings",
"userPanel.upgradePlan": "Upgrade Plan",
"userPanel.usages": "Usage",
"userPanel.workspaceSetting": "Workspace Settings",
"version": "Version",
"zoom": "Zoom"
}
-3
View File
@@ -38,9 +38,6 @@
"brief.title": "Brief",
"brief.viewAllTasks": "View all tasks",
"brief.viewRun": "View run",
"freeCreditBadge.cta": "Start free trial",
"freeCreditBadge.dismiss": "Dismiss",
"freeCreditBadge.label": "Exclusive free credits for {{model}}",
"project.create": "New project",
"project.deleteConfirm": "This project will be deleted and can't be recovered. Confirm to continue.",
"recommendations.heteroAgent.cta": "Add Agent",
+8
View File
@@ -1979,6 +1979,14 @@
"workspace.wizard.step3.title": "Welcome to {{name}}!",
"workspace.wizard.title": "Create Workspace",
"workspaceSetting.breadcrumb.settings": "Settings",
"workspaceSetting.devices.desc": "Shared machines enrolled into this workspace. Members can run agents on them.",
"workspaceSetting.devices.empty": "No workspace devices yet.",
"workspaceSetting.devices.enrollDesc": "Run this on the machine you want to share (workspace owner only):",
"workspaceSetting.devices.enrollTitle": "Add a device",
"workspaceSetting.devices.heroDesc": "Enroll a shared machine — a build server or a team Mac — and every member can run agents on it: read/write files, run commands, and call system tools.",
"workspaceSetting.devices.heroTitle": "Connect your first workspace device",
"workspaceSetting.devices.offline": "Offline",
"workspaceSetting.devices.online": "Online",
"workspaceSetting.group.admin": "Admin",
"workspaceSetting.group.agent": "Agent",
"workspaceSetting.group.general": "General",
+3 -6
View File
@@ -147,10 +147,6 @@
"limitation.chat.topupSuccess.title": "Top-up Successful",
"limitation.expired.desc": "Your {{plan}} credits expired on {{expiredAt}}. Upgrade your plan now to get credits.",
"limitation.expired.title": "Credits Expired",
"limitation.fableCampaign.desc": "Claude Fable 5 is a high-cost model. The campaign trial credits have been used up. Upgrade your plan to keep using Fable.",
"limitation.fableCampaign.title": "Fable Trial Credits Used Up",
"limitation.fableCampaign.upgrade": "Upgrade Plan",
"limitation.fableCampaign.upgradeToPlan": "Upgrade to {{plan}}",
"limitation.hobby.action": "Configured, continue chatting",
"limitation.hobby.configAPI": "Configure API",
"limitation.hobby.desc": "Your free credits have been exhausted. Please configure a custom model API to continue.",
@@ -350,7 +346,6 @@
"plansModal.fileStorageLimit.title": "Storage limit reached",
"plansModal.modelAccess.desc": "This model is available on paid plans. Upgrade to use the full model lineup.",
"plansModal.modelAccess.title": "Unlock all models",
"promoBanner.fableYearly": "Annual subscribers get {{percent}}% usage off for a limited time",
"qa.desc": "If your question is not answered, check <1>Product Documentation</1> for more FAQs, or contact us.",
"qa.detail": "View Details",
"qa.list.credit.a": "Credits are how {{cloud}} measures AI model usage. Different AI models consume different amounts of credits.",
@@ -406,8 +401,10 @@
"referral.errors.invalidFormat": "Invalid referral code format, please enter 2-8 letters, numbers or underscores",
"referral.errors.selfReferral": "You cannot use your own invite code",
"referral.errors.updateFailed": "Update failed, please try again later",
"referral.hero.description": "Share your referral link below. After your friend makes their first payment, you each earn {{reward}}M credits.",
"referral.hero.title": "Invite friends, you both earn <0>{{reward}}M credits</0>",
"referral.inviteCode.description": "Share your exclusive referral code to invite friends to register",
"referral.inviteCode.title": "My Referral Code",
"referral.inviteCode.title": "My Exclusive Referral Code",
"referral.inviteLink.description": "Copy the link and share with friends. Both of you earn credits after your friend makes a payment",
"referral.inviteLink.title": "Referral Link",
"referral.rules.antiAbuse": "If fraudulent activity is detected (e.g., mass registration of disposable email accounts), the associated accounts will be permanently banned",
+28 -2
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "Pensando",
"artifact.thought": "Proceso de pensamiento",
"artifact.unknownTitle": "Trabajo sin título",
"audioPlayer.pause": "Pausar audio",
"audioPlayer.play": "Reproducir audio",
"availableAgents": "Agentes disponibles",
"backToBottom": "Ir al último mensaje",
"beforeUnload.confirmLeave": "Una solicitud aún está en curso. ¿Salir de todos modos?",
@@ -120,6 +122,18 @@
"createModal.groupPlaceholder": "Describe lo que debería hacer este grupo...",
"createModal.groupTitle": "¿Qué debería hacer tu grupo?",
"createModal.placeholder": "Describe lo que debería hacer tu agente...",
"createModal.skillSuggestion.actions.createAnyway": "Crear agente de todos modos",
"createModal.skillSuggestion.actions.createAnywayHint": "¿Habilidad no adecuada?",
"createModal.skillSuggestion.actions.install": "Instalar habilidad",
"createModal.skillSuggestion.actions.installing": "Instalando…",
"createModal.skillSuggestion.actions.openSkills": "Ver en Habilidades",
"createModal.skillSuggestion.actions.tryInLobeAI": "Usar en {{name}}",
"createModal.skillSuggestion.description": "Esto parece un flujo de trabajo reutilizable. Instala la habilidad una vez y luego úsala en todos los agentes.",
"createModal.skillSuggestion.installError": "La habilidad no se instaló. Inténtalo de nuevo o crea un agente de todos modos.",
"createModal.skillSuggestion.installed.description": "Puedes usar esta habilidad en {{name}} o habilitarla para cualquier agente.",
"createModal.skillSuggestion.installed.ready": "Listo en {{name}}",
"createModal.skillSuggestion.installed.title": "Habilidad instalada",
"createModal.skillSuggestion.title": "Una habilidad podría encajar mejor",
"createModal.title": "¿Qué debería hacer tu agente?",
"createTask.assignee": "Asignado a",
"createTask.collapse": "Ocultar entrada",
@@ -166,6 +180,8 @@
"extendParams.title": "Funciones de Extensión del Modelo",
"extendParams.urlContext.desc": "Cuando está habilitado, los enlaces web se analizarán automáticamente para recuperar el contenido real de la página",
"extendParams.urlContext.title": "Extraer contenido de enlaces web",
"floatingChatPanel.collapse": "Colapsar chat",
"floatingChatPanel.expand": "Expandir chat",
"followUpPlaceholder": "Seguimiento. Usa @ para asignar tareas a otros agentes.",
"followUpPlaceholderHeterogeneous": "Continuar.",
"gatewayMode.beta": "Beta",
@@ -219,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "No hay repositorios configurados. Agrégalos en la configuración del agente.",
"heteroAgent.cloudRepo.notSet": "Ningún repositorio seleccionado",
"heteroAgent.cloudRepo.sectionTitle": "Repositorios",
"heteroAgent.executionTarget.auto": "Automático",
"heteroAgent.executionTarget.autoDesc": "Usar un dispositivo en línea automáticamente, eligiendo uno cuando haya varios disponibles",
"heteroAgent.executionTarget.downloadDesktop": "Obtener la aplicación de escritorio",
"heteroAgent.executionTarget.downloadDesktopDesc": "Ejecuta agentes con acceso a tu computadora",
"heteroAgent.executionTarget.downloadDesktopTitle": "Obtener la aplicación de escritorio",
"heteroAgent.executionTarget.gateway": "Puerta de enlace",
"heteroAgent.executionTarget.gatewayDesc": "Ejecutar a través de la puerta de enlace del dispositivo para que otros clientes puedan seguir el progreso",
"heteroAgent.executionTarget.infoTooltip": "Elige un dispositivo remoto para controlar esa máquina desde la web. \"Este dispositivo\" ejecuta el agente localmente y solo está disponible dentro de la aplicación de escritorio.",
"heteroAgent.executionTarget.loading": "Cargando dispositivos…",
"heteroAgent.executionTarget.local": "Este dispositivo",
@@ -231,10 +251,12 @@
"heteroAgent.executionTarget.noneDesc": "No hay dispositivos habilitados",
"heteroAgent.executionTarget.offline": "Desconectado",
"heteroAgent.executionTarget.online": "Conectado",
"heteroAgent.executionTarget.personalGroup": "Personal",
"heteroAgent.executionTarget.sandbox": "Sandbox en la nube",
"heteroAgent.executionTarget.sandboxDesc": "Ejecutar en un sandbox efímero en la nube",
"heteroAgent.executionTarget.title": "Dispositivo de Ejecución",
"heteroAgent.executionTarget.unknownDevice": "Dispositivo desconocido",
"heteroAgent.executionTarget.workspaceGroup": "Espacio de trabajo",
"heteroAgent.fullAccess.label": "Acceso completo",
"heteroAgent.fullAccess.tooltip": "Claude Code se ejecuta localmente con acceso completo de lectura y escritura al directorio de trabajo. Cambiar los modos de permiso aún no está disponible.",
"heteroAgent.resumeReset.cwdChanged": "El directorio de trabajo ha cambiado. La sesión anterior de Claude Code solo puede reanudarse desde su directorio original, por lo que se ha iniciado una nueva conversación.",
@@ -631,6 +653,8 @@
"taskDetail.artifacts": "Artefactos",
"taskDetail.blockedBy": "Bloqueado por {{id}}",
"taskDetail.cancelSchedule": "Cancelar programación",
"taskDetail.closeDetail": "Cerrar detalle",
"taskDetail.collapseReply": "Colapsar",
"taskDetail.comment.cancel": "Cancelar",
"taskDetail.comment.delete": "Eliminar",
"taskDetail.comment.deleteConfirm.content": "Este comentario se eliminará permanentemente.",
@@ -657,6 +681,7 @@
"taskDetail.notFound.backToTasks": "Volver a todas las tareas",
"taskDetail.notFound.desc": "Es posible que esta tarea haya sido eliminada o que no tengas permiso para verla.",
"taskDetail.notFound.title": "Tarea no encontrada",
"taskDetail.openDetail": "Abrir detalle",
"taskDetail.pauseTask": "Pausar tarea",
"taskDetail.priority.high": "Alta",
"taskDetail.priority.low": "Baja",
@@ -925,9 +950,9 @@
"workflow.collapse": "Contraer",
"workflow.expandFull": "Expandir completamente",
"workflow.failedSuffix": "(fallido)",
"workflow.summaryAcrossTools": "a través de {{count}} herramientas",
"workflow.summaryCallsLead": "{{count}} llamadas: {{tools}}",
"workflow.summaryFailed": "{{count}} fallos",
"workflow.summaryMoreTools": "{{count}} tipos de herramientas",
"workflow.summaryTotalCalls": "{{count}} llamadas en total",
"workflow.thoughtForDuration": "Reflexionó durante {{duration}}",
"workflow.toolDisplayName.activateDevice": "Dispositivo activado",
"workflow.toolDisplayName.activateSkill": "Activó una habilidad",
@@ -1043,6 +1068,7 @@
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.deleteTitleMulti": "¿Eliminar {{count}} elementos?",
"workingPanel.resources.empty": "Aún no hay documentos. Los documentos asociados con este agente aparecerán aquí.",
"workingPanel.resources.emptyDocuments": "Aún no hay documentos. Crea uno con el + arriba.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "Documentos",
"workingPanel.resources.filter.skills": "Habilidades",
+18 -2
View File
@@ -444,6 +444,23 @@
"tab.setting": "Configuración",
"tab.tasks": "Tareas",
"tab.video": "Vídeo",
"taskTemplate.action.connect.button": "Conectar {{provider}}",
"taskTemplate.action.connect.error": "Conexión fallida, por favor inténtalo de nuevo.",
"taskTemplate.action.connect.popupBlocked": "Ventana emergente de conexión bloqueada. Permite ventanas emergentes en tu navegador para continuar.",
"taskTemplate.action.connect.short": "Conectar",
"taskTemplate.action.connecting": "Esperando autorización…",
"taskTemplate.action.create.error": "No se pudo crear la tarea. Por favor, inténtalo de nuevo.",
"taskTemplate.action.create.success": "Tarea programada añadida. Encuéntrala en Lobe AI.",
"taskTemplate.action.createButton": "Añadir tarea",
"taskTemplate.action.creating": "Creando...",
"taskTemplate.action.dismiss.error": "No se pudo descartar. Por favor, inténtalo de nuevo.",
"taskTemplate.action.dismiss.tooltip": "No me interesa",
"taskTemplate.action.refresh.button": "Actualizar",
"taskTemplate.card.templateTag": "Plantilla",
"taskTemplate.schedule.daily": "Todos los días a las {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "Puedes ajustar el horario después de crear la tarea.",
"taskTemplate.schedule.weekly": "Todos los {{weekday}} a las {{time}}",
"taskTemplate.section.title": "Prueba estas tareas programadas",
"telemetry.allow": "Permitir",
"telemetry.deny": "Denegar",
"telemetry.desc": "Nos gustaría recopilar información de uso de forma anónima para ayudarnos a mejorar {{appName}} y ofrecerte una mejor experiencia. Puedes desactivar esta opción en cualquier momento en Configuración - Acerca de.",
@@ -474,15 +491,14 @@
"userPanel.email": "Soporte por correo",
"userPanel.feedback": "Contáctanos",
"userPanel.help": "Centro de ayuda",
"userPanel.inviteFriend": "Invitar a un amigo",
"userPanel.moveGuide": "El botón de configuración se ha movido aquí",
"userPanel.plans": "Planes de suscripción",
"userPanel.profile": "Cuenta",
"userPanel.setting": "Configuración",
"userPanel.upgradePlan": "Actualizar Plan",
"userPanel.usages": "Estadísticas de uso",
"userPanel.workspaceCredits": "Créditos del Espacio de Trabajo",
"userPanel.workspaceSetting": "Configuración del Espacio de Trabajo",
"userPanel.workspaceUsages": "Uso del Espacio de Trabajo",
"version": "Versión",
"zoom": "Zoom"
}
+2
View File
@@ -101,6 +101,7 @@
"LocalFile.action.open": "Abrir",
"LocalFile.action.showInFolder": "Mostrar en carpeta",
"MaxTokenSlider.unlimited": "Ilimitado",
"ModelSelect.featureTag.audio": "Este modelo admite el reconocimiento de entrada de audio.",
"ModelSelect.featureTag.custom": "Modelo personalizado, por defecto, admite llamadas a funciones y reconocimiento visual. Verifica la disponibilidad de estas capacidades según el caso.",
"ModelSelect.featureTag.file": "Este modelo admite la carga de archivos para lectura y reconocimiento.",
"ModelSelect.featureTag.functionCall": "Este modelo admite llamadas a funciones.",
@@ -114,6 +115,7 @@
"ModelSwitchPanel.byModel": "Por modelo",
"ModelSwitchPanel.byProvider": "Por proveedor",
"ModelSwitchPanel.detail.abilities": "Capacidades",
"ModelSwitchPanel.detail.abilities.audio": "Audio",
"ModelSwitchPanel.detail.abilities.files": "Archivos",
"ModelSwitchPanel.detail.abilities.functionCall": "Llamada a herramienta",
"ModelSwitchPanel.detail.abilities.imageOutput": "Salida de imagen",
-3
View File
@@ -38,9 +38,6 @@
"brief.title": "Informe diario",
"brief.viewAllTasks": "Ver todas las tareas",
"brief.viewRun": "Ver ejecución",
"freeCreditBadge.cta": "Comienza la prueba gratuita",
"freeCreditBadge.dismiss": "Descartar",
"freeCreditBadge.label": "Créditos gratis exclusivos para {{model}}",
"project.create": "Nuevo proyecto",
"project.deleteConfirm": "Este proyecto se eliminará y no se podrá recuperar. Confirma para continuar.",
"recommendations.heteroAgent.cta": "Agregar agente",
+4 -4
View File
@@ -5,10 +5,12 @@
"authorize.footer.agreement": "Al continuar, confirmas que has leído y aceptas los <terms>Términos y Condiciones</terms> y la <privacy>Política de Privacidad</privacy>.",
"authorize.footer.privacy": "Política de Privacidad",
"authorize.footer.terms": "Términos del Servicio",
"authorize.scenes.connector.confirm": "Continuar al Mercado",
"authorize.scenes.connector.description": "El Mercado solo se utiliza para iniciar esta autorización de servicio. Tu cuenta de {{appName}} permanece separada.",
"authorize.scenes.connector.subtitle": "Inicia sesión en el Mercado para conectar y autorizar este servicio comunitario.",
"authorize.scenes.connector.title": "Conectar Servicio Comunitario",
"authorize.scenes.mcp.subtitle": "Crea un perfil comunitario para instalar y ejecutar esta habilidad desde la comunidad.",
"authorize.scenes.mcp.title": "Instalar Habilidad Comunitaria",
"authorize.scenes.publish.subtitle": "Crea un perfil comunitario para publicar y gestionar tu listado dentro de la comunidad.",
"authorize.scenes.publish.title": "Publicar en la Comunidad",
"authorize.scenes.sandbox.subtitle": "Crea un perfil comunitario para ejecutar esta herramienta en el sandbox de la comunidad.",
"authorize.scenes.sandbox.title": "Probar el Sandbox de la Comunidad",
"authorize.subtitle": "Crea un perfil de comunidad para enviar y gestionar publicaciones dentro de la comunidad.",
@@ -50,8 +52,6 @@
"messages.handoffTimeout": "La autorización ha expirado. Complétala en tu navegador y vuelve a intentarlo.",
"messages.loading": "Iniciando proceso de autorización...",
"messages.success.cloudMcpInstall": "¡Autorización exitosa! Ahora puedes instalar la habilidad Cloud MCP.",
"messages.success.submit": "¡Autorización exitosa! Ahora puedes publicar tu agente.",
"messages.success.upload": "¡Autorización exitosa! Ahora puedes publicar una nueva versión.",
"profileSetup.cancel": "Cancelar",
"profileSetup.confirmChangeUserId.cancel": "Cancelar",
"profileSetup.confirmChangeUserId.confirm": "Cambiar ID de usuario",
+4 -2
View File
@@ -222,6 +222,7 @@
"providerModels.item.modelConfig.extendParams.options.effort.hint": "Para Claude Opus 4.6; controla el nivel de esfuerzo (bajo/medio/alto/máximo).",
"providerModels.item.modelConfig.extendParams.options.enableAdaptiveThinking.hint": "Para Claude Opus 4.6; activa o desactiva el pensamiento adaptativo.",
"providerModels.item.modelConfig.extendParams.options.enableReasoning.hint": "Para Claude, DeepSeek y otros modelos de razonamiento; permite un pensamiento más profundo.",
"providerModels.item.modelConfig.extendParams.options.glm5_2ReasoningEffort.hint": "Para GLM-5.2; controla el esfuerzo de razonamiento con niveles Alto y Máximo.",
"providerModels.item.modelConfig.extendParams.options.gpt5ReasoningEffort.hint": "Para la serie GPT-5; controla la intensidad del razonamiento.",
"providerModels.item.modelConfig.extendParams.options.gpt5_1ReasoningEffort.hint": "Para la serie GPT-5.1; controla la intensidad del razonamiento.",
"providerModels.item.modelConfig.extendParams.options.gpt5_2ProReasoningEffort.hint": "Para la serie GPT-5.2 Pro; controla la intensidad del razonamiento.",
@@ -256,6 +257,7 @@
"providerModels.item.modelConfig.files.title": "Soporte de carga de archivos",
"providerModels.item.modelConfig.functionCall.extra": "Esta configuración solo habilita la capacidad del modelo para usar herramientas, permitiendo agregar habilidades tipo herramienta. Sin embargo, si el modelo puede usarlas depende completamente de él; por favor, prueba su funcionalidad.",
"providerModels.item.modelConfig.functionCall.title": "Soporte para uso de herramientas",
"providerModels.item.modelConfig.id.duplicate": "Ya existe un modelo con este ID. Utiliza un ID de modelo diferente.",
"providerModels.item.modelConfig.id.extra": "No se puede modificar después de la creación y se usará como ID del modelo al llamar a la IA",
"providerModels.item.modelConfig.id.placeholder": "Introduce el ID del modelo, por ejemplo, gpt-4o o claude-3.5-sonnet",
"providerModels.item.modelConfig.id.title": "ID del modelo",
@@ -270,11 +272,11 @@
"providerModels.item.modelConfig.tokens.title": "Ventana de contexto máxima",
"providerModels.item.modelConfig.tokens.unlimited": "Ilimitado",
"providerModels.item.modelConfig.type.extra": "Los diferentes tipos de modelos tienen distintos casos de uso y capacidades",
"providerModels.item.modelConfig.type.options.asr": "Texto a voz",
"providerModels.item.modelConfig.type.options.chat": "Chat",
"providerModels.item.modelConfig.type.options.embedding": "Embedding",
"providerModels.item.modelConfig.type.options.image": "Generación de imágenes",
"providerModels.item.modelConfig.type.options.realtime": "Chat en tiempo real",
"providerModels.item.modelConfig.type.options.stt": "Voz a texto",
"providerModels.item.modelConfig.type.options.text2music": "Texto a música",
"providerModels.item.modelConfig.type.options.tts": "Texto a voz",
"providerModels.item.modelConfig.type.options.video": "Generación de video",
@@ -323,10 +325,10 @@
"providerModels.list.total": "{{count}} modelos disponibles",
"providerModels.searchNotFound": "No se encontraron resultados de búsqueda",
"providerModels.tabs.all": "Todos",
"providerModels.tabs.asr": "ASR",
"providerModels.tabs.chat": "Chat",
"providerModels.tabs.embedding": "Embedding",
"providerModels.tabs.image": "Imagen",
"providerModels.tabs.stt": "ASR",
"providerModels.tabs.tts": "TTS",
"providerModels.tabs.video": "Vídeo",
"sortModal.success": "Orden actualizado con éxito",
+9 -69
View File
@@ -440,60 +440,7 @@
"llm.proxyUrl.title": "URL del Proxy de API",
"llm.waitingForMore": "Se <1>planea añadir más modelos</1>, mantente atento",
"llm.waitingForMoreLinkAriaLabel": "Abrir formulario de solicitud de proveedor",
"marketPublish.forkConfirm.by": "por {{author}}",
"marketPublish.forkConfirm.confirm": "Confirmar publicación",
"marketPublish.forkConfirm.confirmGroup": "Confirmar publicación",
"marketPublish.forkConfirm.description": "Estás a punto de publicar una versión derivada basada en un agente existente de la comunidad. Tu nuevo agente se creará como una entrada separada en el mercado.",
"marketPublish.forkConfirm.descriptionGroup": "Estás a punto de publicar una versión derivada basada en un grupo existente de la comunidad. Tu nuevo grupo se creará como una entrada separada en el mercado.",
"marketPublish.forkConfirm.title": "Publicar agente derivado",
"marketPublish.forkConfirm.titleGroup": "Publicar grupo derivado",
"marketPublish.modal.changelog.extra": "Describe los cambios clave y mejoras en esta versión",
"marketPublish.modal.changelog.label": "Registro de cambios",
"marketPublish.modal.changelog.maxLengthError": "El registro de cambios no debe exceder los 500 caracteres",
"marketPublish.modal.changelog.placeholder": "Introduce el registro de cambios",
"marketPublish.modal.changelog.required": "Por favor, introduce el registro de cambios",
"marketPublish.modal.comparison.local": "Versión local actual",
"marketPublish.modal.comparison.remote": "Versión publicada actualmente",
"marketPublish.modal.identifier.extra": "Este es el identificador único del Agente. Usa letras minúsculas, números y guiones.",
"marketPublish.modal.identifier.label": "Identificador del Agente",
"marketPublish.modal.identifier.lengthError": "El identificador debe tener entre 3 y 50 caracteres",
"marketPublish.modal.identifier.patternError": "El identificador solo puede contener letras minúsculas, números y guiones",
"marketPublish.modal.identifier.placeholder": "Introduce un identificador único para el agente, por ejemplo, desarrollo-web",
"marketPublish.modal.identifier.required": "Por favor, introduce el identificador del agente",
"marketPublish.modal.loading.fetchingRemote": "Cargando datos remotos...",
"marketPublish.modal.loading.submit": "Enviando Agente...",
"marketPublish.modal.loading.submitGroup": "Enviando grupo...",
"marketPublish.modal.loading.upload": "Publicando nueva versión...",
"marketPublish.modal.loading.uploadGroup": "Publicando nueva versión del grupo...",
"marketPublish.modal.messages.createVersionFailed": "Error al crear la versión: {{message}}",
"marketPublish.modal.messages.fetchRemoteFailed": "Error al obtener los datos del agente remoto",
"marketPublish.modal.messages.missingIdentifier": "Este Agente aún no tiene un identificador de la Comunidad.",
"marketPublish.modal.messages.noGroup": "Ningún grupo seleccionado",
"marketPublish.modal.messages.notAuthenticated": "Inicia sesión en tu cuenta de la Comunidad primero.",
"marketPublish.modal.messages.publishFailed": "Error al publicar: {{message}}",
"marketPublish.modal.submitButton": "Publicar",
"marketPublish.modal.title.submit": "Compartir con la Comunidad de Agentes",
"marketPublish.modal.title.upload": "Publicar Nueva Versión",
"marketPublish.resultModal.message": "Tu Agente ha sido enviado para revisión. Una vez aprobado, se publicará automáticamente.",
"marketPublish.resultModal.messageGroup": "Tu grupo ha sido enviado para revisión. Una vez aprobado, se publicará automáticamente.",
"marketPublish.resultModal.title": "Envío Exitoso",
"marketPublish.resultModal.view": "Ver en la Comunidad",
"marketPublish.status.underReview": "En revisión",
"marketPublish.submit.button": "Compartir con la Comunidad",
"marketPublish.submit.tooltip": "Comparte este Agente con la Comunidad",
"marketPublish.submitGroup.tooltip": "Comparte este grupo con la comunidad",
"marketPublish.upload.button": "Publicar Nueva Versión",
"marketPublish.upload.tooltip": "Publica una nueva versión en la Comunidad de Agentes",
"marketPublish.uploadGroup.tooltip": "Publica una nueva versión en la comunidad de grupos",
"marketPublish.validation.communitySetupRequired.action": "Configurar ahora",
"marketPublish.validation.communitySetupRequired.desc": "Este espacio de trabajo aún no ha configurado su perfil de Comunidad. Configúralo antes de publicar en la Comunidad.",
"marketPublish.validation.communitySetupRequired.memberHint": "Este espacio de trabajo aún no ha configurado su perfil de Comunidad. Pide a un propietario del espacio de trabajo que lo configure antes de publicar en la Comunidad.",
"marketPublish.validation.communitySetupRequired.title": "Configura primero el perfil de Comunidad",
"marketPublish.validation.confirmPublish": "¿Publicar en el Mercado?",
"marketPublish.validation.confirmPublishDesc": "Una vez publicado, este contenido será visible públicamente en el mercado y estará disponible para que cualquiera lo descubra y utilice.",
"marketPublish.validation.emptyName": "No se puede publicar: El nombre es obligatorio",
"marketPublish.validation.emptySystemRole": "No se puede publicar: El rol del sistema es obligatorio",
"marketPublish.validation.underReview": "Tu nueva versión está actualmente en revisión. Por favor, espera la aprobación antes de publicar una nueva versión.",
"memory.effort.desc": "Controla cuán agresivamente la IA recupera y actualiza la memoria.",
"memory.effort.high": "Alto — Recuperación y actualizaciones proactivas",
"memory.effort.level.high": "Alto",
@@ -515,14 +462,6 @@
"myAgents.actions.deprecateLoading": "Retirando agente...",
"myAgents.actions.deprecateSuccess": "Agente retirado",
"myAgents.actions.edit": "Editar Agente",
"myAgents.actions.publish": "Publicar Agente",
"myAgents.actions.publishError": "Error al publicar el agente",
"myAgents.actions.publishLoading": "Publicando agente...",
"myAgents.actions.publishSuccess": "Agente publicado",
"myAgents.actions.unpublish": "Despublicar Agente",
"myAgents.actions.unpublishError": "Error al despublicar el agente",
"myAgents.actions.unpublishLoading": "Despublicando agente...",
"myAgents.actions.unpublishSuccess": "Agente despublicado",
"myAgents.actions.viewDetail": "Ver Detalles",
"myAgents.detail.category": "Categoría",
"myAgents.detail.description": "Descripción",
@@ -587,7 +526,6 @@
"plugin.settings.title": "Configuración de Habilidad {{id}}",
"plugin.settings.tooltip": "Configuración de Habilidad",
"plugin.store": "Tienda de Habilidades",
"publishToCommunity": "Publicar en la comunidad",
"serviceModel.contextLimit.placeholder": "Límite de contexto",
"serviceModel.memoryModels.title": "Modelos de memoria",
"serviceModel.modelAssignments.title": "Asignaciones de modelo",
@@ -955,13 +893,6 @@
"storageOverage.usage.estimatedCharge": "Cargo estimado del ciclo",
"storageOverage.usage.incurredCharge": "Incurrido en este ciclo",
"storageOverage.usage.overage": "Exceso",
"submitAgentModal.button": "Enviar Agente",
"submitAgentModal.identifier": "Identificador del Agente",
"submitAgentModal.metaMiss": "Por favor, completa la información del agente antes de enviarlo. Debe incluir nombre, descripción y etiquetas",
"submitAgentModal.placeholder": "Introduce un identificador único para el agente, por ejemplo: desarrollo-web",
"submitAgentModal.success": "Agente enviado con éxito",
"submitAgentModal.tooltips": "Compartir con la Comunidad de Agentes",
"submitGroupModal.tooltips": "Compartir con la comunidad de grupos",
"sync.device.deviceName.hint": "Agrega un nombre para facilitar la identificación",
"sync.device.deviceName.placeholder": "Introduce el nombre del dispositivo",
"sync.device.deviceName.title": "Nombre del Dispositivo",
@@ -1086,6 +1017,7 @@
"tools.activation.auto": "Automático",
"tools.activation.auto.desc": "Inteligente",
"tools.activation.fixed.hint": "Siempre activado — gestionado por la aplicación y no se puede desactivar",
"tools.activation.pin": "Pin",
"tools.activation.pinned": "Fijado",
"tools.activation.pinned.desc": "Siempre Activado",
"tools.add": "Agregar Habilidad",
@@ -2047,6 +1979,14 @@
"workspace.wizard.step3.title": "¡Bienvenido a {{name}}!",
"workspace.wizard.title": "Crear espacio de trabajo",
"workspaceSetting.breadcrumb.settings": "Configuración",
"workspaceSetting.devices.desc": "Máquinas compartidas inscritas en este espacio de trabajo. Los miembros pueden ejecutar agentes en ellas.",
"workspaceSetting.devices.empty": "Aún no hay dispositivos en el espacio de trabajo.",
"workspaceSetting.devices.enrollDesc": "Ejecuta esto en la máquina que deseas compartir (solo el propietario del espacio de trabajo):",
"workspaceSetting.devices.enrollTitle": "Agregar un dispositivo",
"workspaceSetting.devices.heroDesc": "Inscribe una máquina compartida — un servidor de compilación o un Mac de equipo — y cada miembro podrá ejecutar agentes en ella: leer/escribir archivos, ejecutar comandos y utilizar herramientas del sistema.",
"workspaceSetting.devices.heroTitle": "Conecta tu primer dispositivo del espacio de trabajo",
"workspaceSetting.devices.offline": "Desconectado",
"workspaceSetting.devices.online": "Conectado",
"workspaceSetting.group.admin": "Administrador",
"workspaceSetting.group.agent": "Agente",
"workspaceSetting.group.general": "General",
+10 -5
View File
@@ -147,10 +147,6 @@
"limitation.chat.topupSuccess.title": "Recarga Exitosa",
"limitation.expired.desc": "Tus créditos de cómputo del plan {{plan}} expiraron el {{expiredAt}}. Mejora tu plan ahora para obtener más créditos.",
"limitation.expired.title": "Créditos de Cómputo Expirados",
"limitation.fableCampaign.desc": "Claude Fable 5 es un modelo de alto costo. Los créditos de prueba de la campaña se han agotado. Mejora tu plan para seguir usando Fable.",
"limitation.fableCampaign.title": "Créditos de Prueba de Fable Agotados",
"limitation.fableCampaign.upgrade": "Mejorar Plan",
"limitation.fableCampaign.upgradeToPlan": "Mejorar a {{plan}}",
"limitation.hobby.action": "Configurado, continuar chateando",
"limitation.hobby.configAPI": "Configurar API",
"limitation.hobby.desc": "Tus créditos gratuitos de cómputo se han agotado. Por favor configura una API de modelo personalizada para continuar.",
@@ -342,7 +338,14 @@
"plans.workspace.noSharedCredits": "Sin créditos compartidos",
"plans.workspace.sharedCredits": "~{{count}} Créditos / mes",
"plans.workspace.solo": "Solo (1 miembro)",
"promoBanner.fableYearly": "Los suscriptores anuales obtienen un {{percent}}% de descuento en el uso por tiempo limitado",
"plansModal.creditLimit.desc": "Mejora tu plan para desbloquear más créditos mensuales y seguir trabajando sin interrupciones.",
"plansModal.creditLimit.title": "Te has quedado sin créditos",
"plansModal.default.desc": "Desbloquea más capacidad y funciones avanzadas.",
"plansModal.default.title": "Mejora tu plan",
"plansModal.fileStorageLimit.desc": "Tu almacenamiento de archivos está lleno. Mejora tu plan para seguir subiendo y gestionando archivos.",
"plansModal.fileStorageLimit.title": "Límite de almacenamiento alcanzado",
"plansModal.modelAccess.desc": "Este modelo está disponible en planes de pago. Mejora tu plan para acceder a toda la gama de modelos.",
"plansModal.modelAccess.title": "Desbloquea todos los modelos",
"qa.desc": "Si tu pregunta no está respondida, revisa la <1>Documentación del Producto</1> para más preguntas frecuentes, o contáctanos.",
"qa.detail": "Ver Detalles",
"qa.list.credit.a": "Los créditos de cómputo son una métrica utilizada por {{cloud}} para medir el uso de modelos de IA al llamarlos. Diferentes modelos consumen diferentes cantidades de créditos.",
@@ -398,6 +401,8 @@
"referral.errors.invalidFormat": "Formato inválido, introduce 2-8 letras, números o guiones bajos",
"referral.errors.selfReferral": "No puedes usar tu propio código de invitación",
"referral.errors.updateFailed": "Error al actualizar, por favor intenta más tarde",
"referral.hero.description": "Comparte tu enlace de referencia a continuación. Después de que tu amigo realice su primer pago, ambos ganarán {{reward}}M de créditos.",
"referral.hero.title": "Invita a tus amigos, ambos ganan <0>{{reward}}M de créditos</0>",
"referral.inviteCode.description": "Comparte tu código exclusivo para invitar amigos a registrarse",
"referral.inviteCode.title": "Mi Código de Referido",
"referral.inviteLink.description": "Copia el enlace y compártelo con amigos. Ambos ganan créditos después de que tu amigo realice un pago",
+1
View File
@@ -25,6 +25,7 @@
"actions.unmarkCompleted": "Marcar como activa",
"defaultTitle": "Tema predeterminado",
"displayItems": "Mostrar elementos",
"draft": "[Borrador]",
"duplicateLoading": "Copiando tema...",
"duplicateSuccess": "Tema copiado con éxito",
"failedStatusTip": "Esta ejecución tuvo un error — ábrela para echar un vistazo.",
+28 -2
View File
@@ -41,6 +41,8 @@
"artifact.thinking": "در حال تفکر",
"artifact.thought": "فرآیند تفکر",
"artifact.unknownTitle": "کار بدون عنوان",
"audioPlayer.pause": "توقف صدا",
"audioPlayer.play": "پخش صدا",
"availableAgents": "عوامل در دسترس",
"backToBottom": "پرش به آخرین پیام",
"beforeUnload.confirmLeave": "درخواستی هنوز در حال اجراست. آیا می‌خواهید خارج شوید؟",
@@ -120,6 +122,18 @@
"createModal.groupPlaceholder": "توضیح دهید این گروه باید چه کاری انجام دهد...",
"createModal.groupTitle": "گروه شما باید چه کاری انجام دهد؟",
"createModal.placeholder": "توضیح دهید عامل شما باید چه کاری انجام دهد...",
"createModal.skillSuggestion.actions.createAnyway": "ایجاد عامل به هر حال",
"createModal.skillSuggestion.actions.createAnywayHint": "مهارت مناسب نیست؟",
"createModal.skillSuggestion.actions.install": "نصب مهارت",
"createModal.skillSuggestion.actions.installing": "در حال نصب...",
"createModal.skillSuggestion.actions.openSkills": "مشاهده در مهارت‌ها",
"createModal.skillSuggestion.actions.tryInLobeAI": "استفاده در {{name}}",
"createModal.skillSuggestion.description": "این به نظر یک جریان کاری قابل استفاده مجدد است. مهارت را یک بار نصب کنید، سپس آن را در عوامل مختلف استفاده کنید.",
"createModal.skillSuggestion.installError": "مهارت نصب نشد. دوباره تلاش کنید یا به هر حال یک عامل ایجاد کنید.",
"createModal.skillSuggestion.installed.description": "شما می‌توانید این مهارت را در {{name}} استفاده کنید یا آن را برای هر عاملی فعال کنید.",
"createModal.skillSuggestion.installed.ready": "آماده در {{name}}",
"createModal.skillSuggestion.installed.title": "مهارت نصب شد",
"createModal.skillSuggestion.title": "ممکن است یک مهارت بهتر باشد",
"createModal.title": "عامل شما باید چه کاری انجام دهد؟",
"createTask.assignee": "مسئول",
"createTask.collapse": "پنهان کردن ورودی",
@@ -166,6 +180,8 @@
"extendParams.title": "ویژگی‌های توسعه مدل",
"extendParams.urlContext.desc": "در صورت فعال بودن، پیوندهای وب به‌طور خودکار تجزیه شده و محتوای صفحه بازیابی می‌شود",
"extendParams.urlContext.title": "استخراج محتوای پیوند وب",
"floatingChatPanel.collapse": "بستن چت",
"floatingChatPanel.expand": "باز کردن چت",
"followUpPlaceholder": "پیگیری. برای واگذاری وظیفه به عامل‌های دیگر از @ استفاده کنید.",
"followUpPlaceholderHeterogeneous": "پیگیری.",
"gatewayMode.beta": "بتا",
@@ -219,9 +235,13 @@
"heteroAgent.cloudRepo.noRepos": "هیچ مخزنی تنظیم نشده است. آنها را در تنظیمات عامل اضافه کنید.",
"heteroAgent.cloudRepo.notSet": "هیچ مخزنی انتخاب نشده است",
"heteroAgent.cloudRepo.sectionTitle": "مخازن",
"heteroAgent.executionTarget.auto": "خودکار",
"heteroAgent.executionTarget.autoDesc": "به طور خودکار از یک دستگاه آنلاین استفاده کنید و یکی را در صورت موجود بودن چندین دستگاه انتخاب کنید",
"heteroAgent.executionTarget.downloadDesktop": "دریافت اپلیکیشن دسکتاپ",
"heteroAgent.executionTarget.downloadDesktopDesc": "اجرای عوامل با دسترسی به کامپیوتر شما",
"heteroAgent.executionTarget.downloadDesktopTitle": "دریافت اپلیکیشن دسکتاپ",
"heteroAgent.executionTarget.gateway": "دروازه",
"heteroAgent.executionTarget.gatewayDesc": "از طریق دروازه دستگاه اجرا کنید تا سایر مشتریان بتوانند پیشرفت را دنبال کنند",
"heteroAgent.executionTarget.infoTooltip": "یک دستگاه از راه دور را انتخاب کنید تا آن ماشین را از طریق وب کنترل کنید. \"این دستگاه\" عامل را به صورت محلی اجرا می‌کند و فقط در داخل برنامه دسکتاپ در دسترس است.",
"heteroAgent.executionTarget.loading": "در حال بارگذاری دستگاه‌ها...",
"heteroAgent.executionTarget.local": "این دستگاه",
@@ -231,10 +251,12 @@
"heteroAgent.executionTarget.noneDesc": "هیچ دستگاهی فعال نشده است",
"heteroAgent.executionTarget.offline": "آفلاین",
"heteroAgent.executionTarget.online": "آنلاین",
"heteroAgent.executionTarget.personalGroup": "شخصی",
"heteroAgent.executionTarget.sandbox": "محیط آزمایشی ابری",
"heteroAgent.executionTarget.sandboxDesc": "در یک محیط آزمایشی ابری موقت اجرا شود",
"heteroAgent.executionTarget.title": "دستگاه اجرا",
"heteroAgent.executionTarget.unknownDevice": "دستگاه ناشناخته",
"heteroAgent.executionTarget.workspaceGroup": "محیط کاری",
"heteroAgent.fullAccess.label": "دسترسی کامل",
"heteroAgent.fullAccess.tooltip": "Claude Code به‌صورت محلی با دسترسی کامل خواندن/نوشتن در پوشه کاری اجرا می‌شود. تغییر حالت‌های دسترسی فعلاً امکان‌پذیر نیست.",
"heteroAgent.resumeReset.cwdChanged": "پوشه کاری تغییر کرده است. نشست قبلی Claude Code فقط از پوشه اصلی خود قابل ادامه است، بنابراین یک مکالمه جدید آغاز شد.",
@@ -631,6 +653,8 @@
"taskDetail.artifacts": "آیتم‌ها",
"taskDetail.blockedBy": "مسدود شده توسط {{id}}",
"taskDetail.cancelSchedule": "لغو زمان‌بندی",
"taskDetail.closeDetail": "بستن جزئیات",
"taskDetail.collapseReply": "بستن پاسخ",
"taskDetail.comment.cancel": "انصراف",
"taskDetail.comment.delete": "حذف",
"taskDetail.comment.deleteConfirm.content": "این نظر به‌طور دائمی حذف خواهد شد.",
@@ -657,6 +681,7 @@
"taskDetail.notFound.backToTasks": "بازگشت به همه وظایف",
"taskDetail.notFound.desc": "این وظیفه ممکن است حذف شده باشد، یا شما اجازه مشاهده آن را ندارید.",
"taskDetail.notFound.title": "وظیفه پیدا نشد",
"taskDetail.openDetail": "باز کردن جزئیات",
"taskDetail.pauseTask": "توقف وظیفه",
"taskDetail.priority.high": "زیاد",
"taskDetail.priority.low": "کم",
@@ -925,9 +950,9 @@
"workflow.collapse": "جمع کردن",
"workflow.expandFull": "نمایش کامل",
"workflow.failedSuffix": "(ناموفق)",
"workflow.summaryAcrossTools": "در میان {{count}} ابزار",
"workflow.summaryCallsLead": "{{count}} تماس‌ها: {{tools}}",
"workflow.summaryFailed": "{{count}} مورد ناموفق",
"workflow.summaryMoreTools": "{{count}} نوع ابزار",
"workflow.summaryTotalCalls": "{{count}} فراخوانی در مجموع",
"workflow.thoughtForDuration": "تفکر به مدت {{duration}}",
"workflow.toolDisplayName.activateDevice": "دستگاه فعال‌شده",
"workflow.toolDisplayName.activateSkill": "یک مهارت فعال شد",
@@ -1043,6 +1068,7 @@
"workingPanel.resources.deleteTitle": "Delete document?",
"workingPanel.resources.deleteTitleMulti": "حذف {{count}} مورد؟",
"workingPanel.resources.empty": "هنوز سندی وجود ندارد. اسناد مرتبط با این عامل در اینجا نمایش داده می‌شوند.",
"workingPanel.resources.emptyDocuments": "هنوز هیچ سندی وجود ندارد. یکی را با + بالا ایجاد کنید.",
"workingPanel.resources.error": "Failed to load resources",
"workingPanel.resources.filter.documents": "اسناد",
"workingPanel.resources.filter.skills": "مهارت‌ها",
+18 -2
View File
@@ -444,6 +444,23 @@
"tab.setting": "تنظیمات",
"tab.tasks": "کارها",
"tab.video": "ویدیو",
"taskTemplate.action.connect.button": "اتصال به {{provider}}",
"taskTemplate.action.connect.error": "اتصال ناموفق بود، لطفاً دوباره تلاش کنید.",
"taskTemplate.action.connect.popupBlocked": "پنجره اتصال مسدود شده است. برای ادامه، پاپ‌آپ‌ها را در مرورگر خود فعال کنید.",
"taskTemplate.action.connect.short": "اتصال",
"taskTemplate.action.connecting": "در انتظار تأیید...",
"taskTemplate.action.create.error": "ایجاد وظیفه ناموفق بود. لطفاً دوباره تلاش کنید.",
"taskTemplate.action.create.success": "وظیفه زمان‌بندی‌شده اضافه شد. آن را در Lobe AI پیدا کنید.",
"taskTemplate.action.createButton": "اضافه کردن وظیفه",
"taskTemplate.action.creating": "در حال ایجاد...",
"taskTemplate.action.dismiss.error": "رد کردن ناموفق بود. لطفاً دوباره تلاش کنید.",
"taskTemplate.action.dismiss.tooltip": "علاقه‌ای ندارم",
"taskTemplate.action.refresh.button": "تازه‌سازی",
"taskTemplate.card.templateTag": "الگو",
"taskTemplate.schedule.daily": "هر روز در ساعت {{time}}",
"taskTemplate.schedule.editableAfterCreateTooltip": "می‌توانید زمان‌بندی را پس از ایجاد وظیفه تنظیم کنید.",
"taskTemplate.schedule.weekly": "هر {{weekday}} در ساعت {{time}}",
"taskTemplate.section.title": "این وظایف زمان‌بندی‌شده را امتحان کنید",
"telemetry.allow": "اجازه بده",
"telemetry.deny": "رد کن",
"telemetry.desc": "ما مایلیم اطلاعات استفاده را به‌صورت ناشناس جمع‌آوری کنیم تا {{appName}} را بهبود دهیم و تجربه بهتری ارائه دهیم. می‌توانید این گزینه را در تنظیمات - درباره غیرفعال کنید.",
@@ -474,15 +491,14 @@
"userPanel.email": "پشتیبانی ایمیلی",
"userPanel.feedback": "تماس با ما",
"userPanel.help": "مرکز راهنما",
"userPanel.inviteFriend": "دعوت از یک دوست",
"userPanel.moveGuide": "دکمه تنظیمات به اینجا منتقل شده است",
"userPanel.plans": "طرح‌های اشتراک",
"userPanel.profile": "حساب کاربری",
"userPanel.setting": "تنظیمات",
"userPanel.upgradePlan": "ارتقاء طرح",
"userPanel.usages": "آمار استفاده",
"userPanel.workspaceCredits": "اعتبارات فضای کاری",
"userPanel.workspaceSetting": "تنظیمات فضای کاری",
"userPanel.workspaceUsages": "استفاده از فضای کاری",
"version": "نسخه",
"zoom": "بزرگنمایی"
}
+2
View File
@@ -101,6 +101,7 @@
"LocalFile.action.open": "باز کردن",
"LocalFile.action.showInFolder": "نمایش در پوشه",
"MaxTokenSlider.unlimited": "نامحدود",
"ModelSelect.featureTag.audio": "این مدل از شناسایی ورودی صوتی پشتیبانی می‌کند.",
"ModelSelect.featureTag.custom": "مدل سفارشی که به‌طور پیش‌فرض از تماس‌های تابع و تشخیص بصری پشتیبانی می‌کند. لطفاً بر اساس شرایط واقعی، قابلیت‌های فوق را بررسی کنید.",
"ModelSelect.featureTag.file": "این مدل از بارگذاری فایل برای خواندن و تشخیص پشتیبانی می‌کند.",
"ModelSelect.featureTag.functionCall": "این مدل از تماس‌های تابع پشتیبانی می‌کند.",
@@ -114,6 +115,7 @@
"ModelSwitchPanel.byModel": "بر اساس مدل",
"ModelSwitchPanel.byProvider": "بر اساس ارائه‌دهنده",
"ModelSwitchPanel.detail.abilities": "قابلیت‌ها",
"ModelSwitchPanel.detail.abilities.audio": "صوت",
"ModelSwitchPanel.detail.abilities.files": "فایل‌ها",
"ModelSwitchPanel.detail.abilities.functionCall": "فراخوانی ابزار",
"ModelSwitchPanel.detail.abilities.imageOutput": "خروجی تصویر",
-3
View File
@@ -38,9 +38,6 @@
"brief.title": "گزارش روزانه",
"brief.viewAllTasks": "مشاهدهٔ همهٔ وظایف",
"brief.viewRun": "مشاهده اجرا",
"freeCreditBadge.cta": "شروع آزمایش رایگان",
"freeCreditBadge.dismiss": "رد کردن",
"freeCreditBadge.label": "اعتبار رایگان انحصاری برای {{model}}",
"project.create": "پروژه جدید",
"project.deleteConfirm": "این پروژه حذف خواهد شد و امکان بازیابی آن وجود ندارد. برای ادامه تأیید کنید.",
"recommendations.heteroAgent.cta": "افزودن عامل",

Some files were not shown because too many files have changed in this diff Show More