Compare commits

...

89 Commits

Author SHA1 Message Date
Arvin Xu d450efd3b2 feat(agent-runtime): park call_tools_batch on deferred tools
Mirror the call_tool deferred-park on the parallel path: deferred (async)
tools are collected during the concurrent batch and, once server tools
settle, the operation parks (waiting_for_async_tool + pendingToolsCalling)
alongside any client tools — so K parallel sub-agents in one round all
resolve before the parent resumes.

Part of the server sub-agent suspend/resume mechanism (LOBE-9763).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:37:03 +08:00
Arvin Xu ab2e95ad7d feat(agent-runtime): add deferred-tool park infrastructure
Introduce a generic `deferred` result flag (BuiltinServerRuntimeOutput /
ToolExecutionResult). When a tool returns deferred, call_tool parks the
operation (waiting_for_async_tool + pendingToolsCalling) without writing a
tool_result — mirroring the client-tool pause — so the result can be
delivered out-of-band later by a completion bridge. Thread the existing
execSubAgentTask DI seam into ToolExecutionContext so async tools can spawn
a child op without a circular import.

Part of the server sub-agent suspend/resume mechanism (LOBE-9763).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:33:20 +08:00
Arvin Xu 9ffd9d39e3 Revert "♻️ refactor(aiAgent): rename execSubAgentTask -> execSubAgent"
This reverts commit f1ea407d74.
2026-05-28 20:26:28 +08:00
Arvin Xu f1ea407d74 ♻️ refactor(aiAgent): rename execSubAgentTask -> execSubAgent
Full rename of the service method, its `ExecSubAgentTaskParams`/`ExecSubAgentTaskResult`
types, the tRPC endpoint, the injected `RuntimeExecutorContext`/`AgentRuntimeServiceOptions`
callback, and tests. Group-mode `execGroupSubAgent*` identifiers are intentionally left
untouched. Prep for the server sub-agent suspend/resume work (LOBE-9763).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:11:59 +08:00
Arvin Xu 98d77166cc ♻️ refactor(agent-runtime): extract isParkedStatus / isBlockedStatus predicates
Replace the repeated `status === 'waiting_for_human' || ... === 'waiting_for_async_tool' || ... === 'interrupted'`
chains with named predicates so the parked/blocked semantics live in one place
(runtime step-loop break, completion lifecycle completedAt, executeSync pause,
operation isActive).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:01:56 +08:00
Arvin Xu 9821328d43 feat(agent-runtime): add waiting_for_async_tool parked state for deferred tools
Add a dedicated `waiting_for_async_tool` operation status that mirrors
`waiting_for_human` as a non-terminal, resumable pause, and migrate the
client-tool execution pause off `interrupted` onto it — so `interrupted`
once again means only user-initiated cancellation.

Also add the AgentOperationModel primitives the upcoming server sub-agent
bridge needs: queryByParentOperationId (reconcile child ops) and
tryResumeFromAsyncTool (atomic single-fire CAS).

Foundation for the server sub-agent suspend/resume mechanism (LOBE-9763).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:22:47 +08:00
Innei 980c2e74d8 🐛 fix(desktop): expand ~ when opening local files and folders (#15284)
shell.openPath() does not perform tilde expansion, so paths like
~/git/work failed silently. Run expandTilde() (shared with the rest
of LocalFileCtr) on the incoming path before handing it to the OS.
2026-05-28 16:05:48 +08:00
Innei 84598524df 🐛 fix(chat-input): keep input mounted while intervention panel is shown (#15283)
* 🐛 fix(chat-input): keep input mounted while intervention panel is shown

Conditional render swapped <DesktopChatInput> with <InterventionBar>,
unmounting the Lexical editor and wiping any unsent draft. Wrap the
input area in a display: contents | none container so the editor's
React subtree stays mounted and its in-memory document survives.

* 🐛 fix: hide expanded chat input during interventions
2026-05-28 16:05:39 +08:00
Arvin Xu 5e2ef88c13 🐛 fix(conversation-flow): prevent infinite recursion in assistant chain (#15288)
* 🐛 fix(conversation-flow): guard collectAssistantChain against cyclic chains

collectAssistantChain checked `processedIds` for loop protection but never
populated it, so when a topic contains duplicated tool_call_ids (the same
tool result reachable from multiple assistant messages) the assistant→tool→
assistant walk revisited already-seen assistants and recursed without bound,
crashing the conversation view with "Maximum call stack size exceeded".
Mark each assistant visited up front.

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

*  test(conversation-flow): cover collectAssistantChain cycle guard

Regression test for the duplicate-tool_call_id cycle that previously
overflowed the stack: two assistant turns declaring the same tool_call_id
make one turn's tool result resolvable from the other, so the
assistant→tool→assistant walk revisits an already-collected assistant.
Asserts the walk terminates and collects each assistant once, plus a
control case for a normal acyclic chain.

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

* 🐛 fix(conversation-flow): skip already-visited followers in collectAssistantChain

The cycle guard stopped the infinite recursion but, with a duplicated
tool_call_id, collectToolMessages can surface an earlier turn's tool result
before the current assistant's own. Its child is an already-visited assistant,
so the recursive call is a no-op — yet the unconditional return after it made
the walk stop there and silently drop the current turn's real continuation
under a later tool. Skip already-processed followers so the loop advances to
the current assistant's own tool result.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:53:05 +08:00
YuTengjing 403de538d6 🐛 fix: improve Google image error handling (#15285) 2026-05-28 15:40:15 +08:00
Arvin Xu 8949e89535 ♻️ refactor(agent): run client sub-agent as a normal tool call (#15281)
* ♻️ refactor(agent): run client sub-agent as a normal tool call

Make lobe-agent callSubAgent/callSubAgents execute the sub-agent in an
isolated thread via the current client runtime (executeClientAgent +
threadId + isSubAgent) and return a normal tool result, instead of the
stop:true + exec_sub_agent instruction + polling detour. UI now mirrors
the Claude Code Agent tool: a collapsed tool row that opens the sub-agent
thread in the portal. No more role='task' messages on the lobe-agent path.

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

* 💄 style(agent): refine sub-agent tool UI and unify subagent thread display

- Inspector mirrors the Claude Code Agent tool: leading bot icon, "Call SubAgent" / "Call SubAgents" label, description as a chip, and a compact run-stats tail (model · tools · tokens)
- callSubAgents collapses to the first description + "等 X 个" beyond 2, with per-row stats
- rename the open-thread action to "View Detail"
- unify subagent-thread detection on ThreadType.Isolation so lobe-agent sub-agent threads indent in the sidebar and render read-only like CC subagents
- fix: refresh threads right after creating the client sub-agent thread so the "View Detail" button and sidebar entry appear immediately instead of only after a topic switch

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

* 💄 style(agent): unify sub-agent workflow group label to "Call SubAgent"

Align the collapsed workflow group summary (workflow.toolDisplayName) with the
inspector copy so callSubAgent / callSubAgents read "Call SubAgent" / "Call
SubAgents" instead of "Dispatched a sub-agent".

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:14:08 +08:00
Arvin Xu 8aa075cd80 feat(model-runtime): add DatabasePersistError code for failed DB queries (#15279)
*  feat(model-runtime): add DatabasePersistError code for failed DB queries

Drizzle stringifies a failed query/transaction as `Failed query: <sql>
params: <values>`. These are harness-side persistence failures, but they
were landing in the operation dashboards as `unknown` — and worse, the
embedded SQL/parameter text (model names, error_log rows, user messages)
contains substrings that trip unrelated provider patterns, so naive
message-matching misclassified them as CapabilityNotSupported /
InsufficientQuota / ModelNotFound.

- `agentRuntime.ts` — new `DatabasePersistError` code.
- `specs.ts` — E7004 under the 7xxx Stream/Runtime (harness) bucket,
  `attribution: harness`, `countAsFailure: true`, httpStatus 500.
- `patterns.ts` — `Failed query:` substring pattern placed **first** in the
  registry. matchErrorPattern is first-match-wins, so claiming it up front
  both classifies these correctly and stops the embedded blob from matching
  anything below.
- `match.test.ts` — assert the wrap classifies as DatabasePersistError and
  that a blob embedding `InsufficientQuota` / `context length exceeded` still
  resolves to DatabasePersistError.
- `modelRuntime.ts` — en-US `DatabasePersistError` copy (others auto-translate).

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

*  feat(model-runtime): add StateStorePersistError; stop classifying Redis aborts as provider-network

`Command aborted due to connection close` is an ioredis error — the
Redis/Upstash agent-state store dropping a queued command, not the LLM
provider's network. It was mapped to `ProviderNetworkError`, which
misattributed our own infra failures to upstream providers.

- `agentRuntime.ts` — new `StateStorePersistError` (sibling of
  `DatabasePersistError`: DB layer vs state-store layer).
- `specs.ts` — E7005 under 7xxx Stream/Runtime (harness), countAsFailure true.
- `patterns.ts` — repoint `Command aborted due to connection close` to
  StateStorePersistError, and add the other Upstash state-store signatures
  (`max request size exceeded`, `database has been suspended`).
- `match.test.ts` + `modelRuntime.ts` — test + en-US locale.

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

*  feat(model-runtime): add ContextEnginePipelineError + harness JS-crash patterns

Classify the harness-side crashes that were landing as `unknown`:

- `ContextEnginePipelineError` (E7006, 7xxx Stream/Runtime, harness) — the
  context-engine pipeline processor crash, surfaced as "Processor [<name>]
  execution failed". The context-engine throws `PipelineError` (its
  `error.name`), so a CODE_ALIASES entry resolves `PipelineError` →
  ContextEnginePipelineError for stored / live records.
- patterns: `Processor [` → ContextEnginePipelineError, placed before the
  generic JS-crash fallbacks so a processor crash with a nested TypeError is
  attributed to the pipeline, not the bare `Cannot read properties` rule.
- patterns: bare V8 crashes (`is not a function`, `Cannot read properties of`,
  `Maximum call stack size exceeded`) → AgentRuntimeError, kept LAST so
  specific provider/harness patterns win first.
- test + en-US locale.

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

* ♻️ refactor(model-runtime): reattribute ConversationParentMissing to user

The broken conversation chain (`parent_id` no longer exists) is usually the
user deleting the topic / parent message mid-operation — an expected race,
not a harness bug. Flip attribution harness → user, countAsFailure
true → false (so it drops out of failure metrics), severity error → warning.

numericId 7003 / category `stream` stay put (append-only); attribution and
category are orthogonal, so a stream-bucket code can be user-attributed.

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

*  feat(model-runtime): classify "[object Object]" messages as AgentRuntimeError

A message of literally "[object Object]" means the harness stringified an
error object instead of extracting its message — a harness serialization bug.
Add it to the JS-crash fallbacks (last, lowest priority) so it resolves to
AgentRuntimeError instead of staying unknown.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:21:07 +08:00
Arvin Xu 9cc5f9e1a0 feat(model-runtime): classify Cloud-only error codes via numericId tier digit (#15278)
The three Cloud-only `ChatErrorType` codes (`FreePlanLimit`,
`InsufficientBudgetForModel`, `LobeHubModelDeprecated`) were emitted by the
managed gateway but had no spec, so they showed up unclassified on the
operation dashboards.

Rather than add a 10th `ErrorCategory` (the single-digit category prefix
1-9 is exhausted, and a 10th would break the 4-digit numericId scheme +
its validation tests), encode the OSS-vs-Cloud distinction in the
**second digit** of `numericId`: `0` = open-source runtime, `9` = Cloud-only.
Every existing code already has tier digit 0, so this is purely additive —
the category leading-digit invariant, 4-digit range, and `E####` regex all
hold unchanged.

- `taxonomy.ts` — document the tier digit, add `CLOUD_TIER_DIGIT = 9`.
- `specs.ts` — widen the spec key/`code` type to `SpecErrorCode`
  (`ILobeAgentRuntimeErrorType | CloudErrorCode`); add the three entries
  under their semantic categories with tier-9 ids: `FreePlanLimit` E2901 &
  `InsufficientBudgetForModel` E2902 (quota), `LobeHubModelDeprecated` E4901
  (request). All `attribution: user`, `countAsFailure: false`.
- `match.test.ts` — assert every spec's tier digit is 0 or 9, and the three
  Cloud codes resolve under the cloud tier.

Locale keys (`response.<code>`) for all three already exist. The
agent-gateway mirror is updated separately.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:28:50 +08:00
AnotiaWang bcf97d9487 💄 style: add DeepSeek V4 Pro to SiliconCloud model list (#15267)
*  feat(model-bank): add DeepSeek V4 Pro to SiliconCloud model list

Co-authored-by: AnotiaWang <AnotiaWang@users.noreply.github.com>

* 💰 pricing(siliconcloud): add cache hit price for DeepSeek V4 Flash

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: AnotiaWang <AnotiaWang@users.noreply.github.com>
2026-05-28 11:07:20 +08:00
Arvin Xu 3e4b81d2cc chore(channel): register iMessage platform with coming-soon UI gate (#15276)
 feat(channel): register iMessage platform with coming-soon UI gate

Activate the server-side iMessage registration that was previously
landed but un-registered, and let coming-soon entries take precedence
over server platforms with the same id so the platform stays hidden
until the desktop bridge UI ships.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:54:20 +08:00
Arvin Xu 651d1a203a Revert " feat(desktop): support cloud desktop builds (#14498)"
This reverts commit 0c5ccc8770.
2026-05-28 10:40:59 +08:00
Arvin Xu 4c29515e4c ♻️ refactor(locales): split model-runtime errors into modelRuntime namespace (#15269)
Until now, every runtime error code (InvalidProviderAPIKey, ProviderBizError,
ExceededContextWindow, …) lived under `error.response.<X>` — mixed in the
same file with HTTP statuses, Plugin*, Cloud business errors, and
GoogleAIBlockReason subkeys. The `response.` prefix is a lobehub-specific
convention that has nothing to do with the underlying ErrorCode, which
made it awkward for external consumers and noisy for maintainers.

This change carves out a dedicated `modelRuntime` i18next namespace:

- `src/locales/default/modelRuntime.ts` — 34 keys, one per
  `AgentRuntimeErrorType` (or deprecated alias `QuotaLimitReached`).
  Key = the bare ErrorCode (no `response.` prefix).
- `src/locales/default/error.ts` — runtime keys removed. The file keeps
  HTTP statuses (response.400 - response.524), Plugin*, Cloud-only
  business errors (FreePlanLimit, SubscriptionPlanLimit, etc.),
  GoogleAIBlockReason.*, and the various UI-flow strings.
- Registered `modelRuntime` in `src/locales/default/index.ts` so the
  namespace appears in the typed resources map.
- Generated `locales/en-US/modelRuntime.json` + updated
  `locales/en-US/error.json` — other languages need `pnpm i18n`.

New helper `src/utils/locale/runtimeErrorMessage.ts`:

```ts
getRuntimeErrorMessage(t, code, vars)
```

Routes via `getErrorCodeSpec(code)`: returns `t('modelRuntime:<code>')`
when the code is in `ERROR_CODE_SPECS`, otherwise falls back to
`t('response.<code>')`. Callers add `'modelRuntime'` to their
`useTranslation()` namespace list.

UI consumer migrations (5 dynamic lookup sites):

- `features/Conversation/Messages/AssistantGroup/Tool/Detail/ErrorResponse.tsx`
- `features/Conversation/Error/index.tsx`
- `routes/(main)/settings/provider/features/ProviderConfig/Checker.tsx`
  (incl. the static `t('response.ConnectionCheckFailed')` call)
- `routes/(main)/(create)/video/features/GenerationFeed/VideoErrorItem.tsx`
- `routes/(main)/(create)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx`

`Description.tsx` (HTTP status renderer) stays on `response.<X>` since
its inputs are always HTTP status numbers, never runtime ErrorCodes.

Stacks on top of #15262 (the unified errors PR introduces
`getErrorCodeSpec` / `ERROR_CODE_SPECS`); base this PR there until
#15262 merges, then it auto-rebases onto canary.

Tests: lobehub type-check clean; model-runtime 3908 pass / 1 skip / 164 files.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:04:07 +08:00
Innei b4b1205ee9 ♻️ refactor(modal): migrate confirm modals to @lobehub/ui/base-ui (Phase 1) (#15259)
* ♻️ refactor(modal): migrate confirm modals to @lobehub/ui/base-ui

Replace all `App.useApp().modal.confirm`, `Modal.confirm` and `AntModal.confirm`
call sites with the headless `confirmModal` from `@lobehub/ui/base-ui`, dropping
antd-only props (`centered`, `type`, `width`, `okButtonProps.type='primary'`,
`okButtonProps.loading`, `classNames.root`) that the base-ui imperative API does
not accept.

- 82 files touched; `modal.confirm`/`Modal.confirm` call sites now zero
- `PageEditor/store/action.ts`: drop `modal` arg from `handleDelete`
- `ResourceManager/useUploadFolder`: replace dynamic `import('antd').Modal`
- `Eval/DatasetsTab`: migrate `modal.success` to `confirmModal`

Part of LOBE-9645 Phase 1.

* ♻️ refactor(ui): migrate select/modal call sites to @lobehub/ui/base-ui

- Convert imperative-modal factories (createXxxModal + Content split) for apikey,
  creds (Create/Edit/View), provider (CreateNewProvider), and messenger LinkModal.
- Switch Select usages to base-ui Select (Messenger AgentSelect, provider sdkType).
- Restructure CreateNewProvider form to vertical layout with manual section titles
  for tighter spacing; drop FormModal/Form group nesting.
- Standardize small ActionIcon sizing via DESKTOP_HEADER_ICON_SMALL_SIZE
  (WideScreenButton, ToggleRightPanelButton, ContextDropdown, AddNewProvider).
- Fix missing title on ResourceManager delete confirm modal so the header
  (title + close X) renders.
- Update react skill and AGENTS.md to require base-ui priority over root @lobehub/ui
  / antd; expand component table and Common Mistakes with explicit base-ui rules.

* ♻️ refactor(ui): swap antd Select to base-ui Select and migrate createStyles to createStaticStyles

*  test: update test mocks for base-ui confirmModal migration

*  test(e2e): switch delete confirm selector to base-ui dialog role
2026-05-28 02:46:27 +08:00
Arvin Xu 8c0e66b633 feat(agent-runtime): persist ERROR_CODE_SPECS classification on operation errors (#15273)
*  feat(agent-runtime): persist ERROR_CODE_SPECS classification on operation errors

Look up the runtime error's spec in `ERROR_CODE_SPECS` at the single catch
chokepoint and merge `attribution` / `category` / `severity` / `httpStatus`
/ `retryable` / `countAsFailure` / `numericId` onto the normalized
`ChatMessageError`. The enriched object flows through to all three
downstream sinks — `agent_operations.error` JSONB, S3 trace snapshot,
and the agent-gateway WS push — without each consumer having to re-run
pattern matching.

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

*  feat(agent-runtime): enrich inner-step error path too

Model-runtime failures caught inside `runtime.step()` resolve normally with
`newState.status = 'error'` instead of throwing, so the prior commit's outer
`executeStep` catch never sees common provider errors like
`InvalidProviderAPIKey` / `InsufficientQuota`. Those were reaching
`agent_operations.error` JSONB and the success-path trace snapshot raw —
without `attribution` / `category` / `severity` / …

Run `formatErrorForState` on `stepResult.newState.error` immediately after
`runtime.step()` returns, before the state is saved to Redis, hooks are
dispatched, or the trace is finalized. Made the helper idempotent (recognizes
already-normalized `ChatMessageError` shape) so a second pass through the
outer catch can't collapse it back to `AgentRuntimeError`. Success-path
`traceRecorder.finalize` now forwards the classification fields too.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 02:25:16 +08:00
Innei 1ae8498fc7 feat(agent-document): render non-markdown docs as readonly highlight (#15272)
* 🐛 fix(tool-archive): use .txt extension for archived tool results

Tool result content is raw output (logs, JSON, stack traces) rather than
structured markdown. Saving as .md misrepresents the format and triggers
markdown parsing downstream. Switch the archive filename to .txt to match
the actual content type.

*  feat(agent-document): render non-markdown docs as readonly highlight

Agent documents whose filename does not resolve to markdown (e.g. archived
tool results saved as .txt, future .json / .yaml entries) are now rendered
through @lobehub/ui Highlighter with the inferred language, replacing the
markdown editor surface that misinterpreted raw text as syntax.

- Extract the filename→language map from FileViewer Code renderer into a
  shared util so the document viewer reuses the same mapping.
- Introduce getDocumentRenderMode: SKILL.md and .md keep the editor; all
  other extensions resolve to a Highlighter, which is naturally readonly.
- Hide the auto-save hint in Header when the document is rendered as a
  Highlighter (no editor, nothing to save).

* 🐛 fix(agent-document): render notebook documents as editor when filename is absent

Notebook documents store the markdown signal in `fileType` + `title` and never set a
`filename`. `getDocumentRenderMode` was falling back to `title` for language
inference, which resolved free-form titles like "Meeting notes" to `txt` and routed
them into the readonly Highlighter (also hiding the autosave hint).

Treat filename-absent documents as editor mode directly; filename remains the only
source for code-language inference.
2026-05-28 01:37:58 +08:00
Arvin Xu c4b147554b ♻️ refactor(model-runtime): unify error codes into spec + pattern registry (#15262)
*  feat(model-runtime): unify error codes into spec + pattern registry

Add a single source of truth for runtime error classification under
`packages/model-runtime/src/errors/`:

- `taxonomy.ts` — category / severity / attribution dimensions
- `specs.ts` — ERROR_CODE_SPECS: per-code httpStatus / retryable /
  countAsFailure / attribution (user | provider | harness | system)
- `patterns.ts` — ERROR_PATTERNS: substring/regex registry consolidating
  the 5 separate isXxxError lists and the upstream provider message
  patterns previously kept only in agent-gateway
- `match.ts` — matchErrorPattern() + isUserSideError()

Wire-up:
- Add 8 codes to AgentRuntimeErrorType (ProviderServiceUnavailable,
  ProviderNetworkError, NoAvailableChannel, ContentModeration,
  CapabilityNotSupported, InvalidRequestFormat, UserConfigError,
  OperationInactivityTimeout) plus their en-US locale keys
- Rewrite isExceededContextWindow / isQuotaLimit / isInsufficientQuota /
  isAccountDeactivated as one-line wrappers around matchErrorPattern
- errorResponse.ts getStatus() now reads ERROR_CODE_SPECS, removing the
  hardcoded switch

Tests: 167 model-runtime test files (3916 pass / 1 skip) including 13
new match.test.ts cases and all 42 isXxxError snapshots unchanged.

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

*  feat(model-runtime): add numericId (E1001) + ErrorClassifier namespace

Numeric reference codes for external surfaces (open-source consumers, docs
anchors, support tickets):

- `ErrorCodeSpec.numericId` (required, 4-digit). Append-only contract: once
  assigned, a (code, numericId) pair never changes even if the string `code`
  is renamed.
- Format: `E<numericId>` (e.g. `E1001` InvalidProviderAPIKey, `E3001`
  QuotaLimitReached, `E7002` OperationInactivityTimeout).
- First digit encodes category via `CATEGORY_NUMERIC_PREFIX`:
  1=auth, 2=quota, 3=capacity, 4=request, 5=safety, 6=network, 7=stream,
  8=provider, 9=config.
- Helpers: `formatErrorRef(code) → 'E1001'`, `parseErrorRef('E1001') → code`.
- Test guards: numericId is unique across specs; leading digit matches the
  declared category for every entry.

Consolidate classification predicates:

- New `ErrorClassifier` namespace bundles `isExceededContextWindow` /
  `isInsufficientQuota` / `isQuotaLimitReached` / `isAccountDeactivated`
  behind a single discoverable import.
- The 4 scattered `is*Error.ts` utilities are now `@deprecated`; kept as
  shims for callers that aren't migrated yet.
- Parity test asserts ErrorClassifier and the legacy utils return the same
  boolean on a curated sample set.

Tests: 168 files / 3928 pass / 1 skip. +12 new tests for numericId contract,
ref formatting, and classifier parity.

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

* ♻️ refactor(model-runtime): rename QuotaLimitReached → RateLimitExceeded

The legacy name conflated two distinct semantics: short-window rate limit
(429-class, transient, retryable, provider-attributed) vs. long-term
account-level quota exhaustion (`InsufficientQuota`, user-attributed).
Surface code readers hit this confusion the moment they look at the spec
table — the name reads like a 2xxx quota code but the spec sits in 3xxx
capacity.

- Add `AgentRuntimeErrorType.RateLimitExceeded` as the canonical name.
- Keep `AgentRuntimeErrorType.QuotaLimitReached` as a `@deprecated` alias
  (same string value preserved for legacy stored data on the dashboard
  side) — `CODE_ALIASES` map in `specs.ts` ensures `getErrorCodeSpec` /
  `isUserSideError` resolve both old and new strings to the canonical
  E3001 spec.
- `ErrorClassifier`: new `isRateLimitExceeded` is canonical;
  `isQuotaLimitReached` kept as deprecated alias.
- Refresh patterns.ts (~24 entries) + isQuotaLimitError util.
- Locale: add `response.RateLimitExceeded` next to the kept legacy
  `response.QuotaLimitReached`.
- Match.ts now reads via `getErrorCodeSpec` so alias resolution flows
  through one place.

Tests: 3930 model-runtime tests pass (+2 explicit alias-resolution cases).

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

* 🐛 fix(AgentRuntime): wire classifyLLMError to ERROR_CODE_SPECS

The runtime retry loop's STOP_ERROR_TYPES was a hardcoded set that didn't
move with the unified error scheme. New codes added in #15262
(ContentModeration, InvalidRequestFormat, UserConfigError, NoAvailableChannel,
OperationInactivityTimeout, CapabilityNotSupported, LocationNotSupportError,
ExceededToolLimit, …) all carry `retryable: false` in the spec, but an
error arriving with one of these `errorType` values **and no HTTP status**
(e.g. a gateway-classified moderation message like "Content Exists Risk")
fell through to the classifier's default `retry` branch, producing pointless
retry storms for requests the spec says should stop.

Fix:

- Derive `STOP_ERROR_TYPES` / `RETRY_ERROR_TYPES` from `ERROR_CODE_SPECS` at
  module load. Future codes added to the spec table now classify
  automatically — no second source of truth.
- Keep a tight `RETRY_OVERRIDES` set for the 4 legacy codes
  (`AgentRuntimeError` / `OllamaServiceUnavailable` / `ProviderBizError` /
  `StreamChunkError`) that the runtime intentionally retries even though
  the spec marks them non-retryable; these are catch-all / harness-level
  failures often transient in practice.
- Resolve through `getErrorCodeSpec` before set lookup so the deprecated
  `QuotaLimitReached` alias classifies the same as its canonical
  `RateLimitExceeded`.
- Export the `errors/` module from `@lobechat/model-runtime` root barrel.

Tests: 31 cases (+12) including `it.each` coverage of all 8 newly-stop
codes and 3 newly-retry codes, plus explicit guards for the legacy retry
overrides and the QuotaLimitReached → RateLimitExceeded alias.

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

* ♻️ refactor(model-runtime): consolidate isXxxError utils into ErrorClassifier

Three structural cleanups on top of the unified error scheme:

1. **Reorder `ERROR_CODE_SPECS` strictly by `numericId`.** Previously the
   spec table followed the original loose category groupings, which left
   stragglers like `InvalidOllamaArgs` (E9001, config) wedged into the
   1xxx auth section. Now entries appear in 1001 → 9005 order with
   numeric-prefix section dividers. Added `it('spec entries appear in
   source order sorted by numericId')` as a lint guard so future
   additions stay sorted (JS preserves object-literal insertion order).

2. **Migrate all production callers from `isXxxError` utils to
   `ErrorClassifier` namespace.** Touched 4 files, 13 call sites:
   - `core/anthropicCompatibleFactory/index.ts` (6)
   - `core/openaiCompatibleFactory/index.ts` (4)
   - `providers/bedrock/index.ts` (1)
   - `utils/googleErrorParser.ts` (2)

3. **Delete the 4 deprecated util files + their tests.** With no
   production callers left, the shim layer is dead code. Classifier
   tests now stand on their own (no parity comparison against the
   deleted utils).

Also mirror the spec ordering to `agent-gateway/src/errors/specs.ts`
(separate commit on that repo).

Tests: 164 files / 3908 pass / 1 skip (was 168 / 3930 — the delta is the
4 removed `isXxxError.test.ts` files, ~42 tests, net of new classifier
coverage).

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

* 🐛 fix(AgentRuntime): stub ERROR_CODE_SPECS in test mocks of @lobechat/model-runtime

`classifyLLMError` now reads `ERROR_CODE_SPECS` + `getErrorCodeSpec` at
module-load time to derive the STOP / RETRY sets. Two test suites mock
`@lobechat/model-runtime` sparsely (only `consumeStreamUntilDone` or
`getModelPropertyWithFallback`), so those new exports were undefined and
the module-eval crashed with `No "ERROR_CODE_SPECS" export is defined on
the "@lobechat/model-runtime" mock`.

Fix: add the two symbols to the mocks. Used empty stubs rather than
`importOriginal` so the mocks stay small and don't transitively pull
the entire model-runtime package (which would then expect every other
mocked package — e.g. `model-bank.AiModelTypeSchema` — to be complete).

Neither suite exercises the runtime retry classifier, so empty
`ERROR_CODE_SPECS` and `getErrorCodeSpec` returning `undefined` are
behaviorally equivalent to the pre-PR baseline.

Verified locally:
- `bunx vitest run src/server/modules/AgentRuntime/__tests__/RuntimeExecutors.test.ts` — 102 tests pass
- `bunx vitest run src/server/services/agentRuntime/AgentRuntimeService.test.ts` — 60 tests pass

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 00:54:59 +08:00
Hardy 5fb1f339a7 feat(opencode-go): align model configs with models.dev API, add DeepSeek V4 Flash/Pro, improve reasoning runtime (#15031) 2026-05-28 00:52:27 +08:00
Rdmclin2 81fc1aaf7f 🐛 fix: telegram messager attachments (#15268)
* fix: telegram messager installation

* fix: lint error

* fix: telegram resolve Credentials first
2026-05-27 23:31:47 +07:00
LobeHub Bot b14f1dba5c 🌐 chore: translate non-English comments to English in openapi-types-common (#15255)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:19:31 +08:00
Arvin Xu 1d2b32bafc 🔖 chore(cli): bump @lobehub/cli to 0.0.22 (#15254)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:18:37 +08:00
Innei 347e2eec0c 💄 style(desktop/overlay): replace native select with @base-ui/react primitives (#15266)
* 💄 style(desktop/overlay): replace native select with @base-ui/react primitives

The overlay window's agent/model pickers use native `<select>` elements,
which render poorly on Windows. Switch to `@base-ui/react/select` primitives
directly, styled with the existing overlay vanilla-extract tokens.

The overlay is a bare-React tree (no SPA provider stack) intentionally
optimised for cold-start, so we cannot afford to mount `@lobehub/ui`'s
`ThemeProvider` just to use its `Select` wrapper — that path adds ~250ms
of bundle parse + ~117ms of React mount in dev mode. Using the underlying
primitive instead keeps the increase to ~119ms over native.

Mirror the overlay theme CSS variables onto `document.documentElement` so
the portaled popup (rendered outside the panel subtree) inherits them.

Also add a small gated benchmark utility (`perfMark.ts`, enabled via
`localStorage.lobe-overlay-bench=1` or `?bench`, zero overhead otherwise)
for measuring overlay cold-start segments. Call `__OVERLAY_BENCH__()`
in DevTools to dump the timeline.

* 🔥 chore(desktop/overlay): drop bench instrumentation, lower popup z-index

- Remove perfMark utility and its call sites — benchmarking is done, no
  need to ship the bench harness.
- Drop popup z-index from int32-max to 114514 (sufficient on its own
  stacking context; saner number).
2026-05-27 22:01:11 +08:00
Arvin Xu e8275a93ff 🐛 fix(hetero-agent): hide device switcher in regular agent chat input (#15257)
The HeteroDeviceSwitcher is meant for heterogeneous agents only and is
already rendered by HeterogeneousChatInput/WorkingDirectoryBar. Remove
it from the regular RuntimeConfig so it no longer appears for normal
agents.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:54:42 +08:00
Innei 49d191d2a7 🐛 fix: unify TypeScript peer resolution on 6.x (#15263) 2026-05-27 19:22:35 +08:00
Arvin Xu 35052416cc chore: clean up LOBE-XXX code annotations (#15249) 2026-05-27 18:09:06 +08:00
Innei 0c5ccc8770 feat(desktop): support cloud desktop builds (#14498)
*  feat(desktop): support cloud desktop builds

* 🐛 fix: open payment navigations externally in desktop
2026-05-27 16:22:48 +08:00
Innei c8ff3ac43d feat: gate agent document floating chat panel (#15260) 2026-05-27 14:02:14 +08:00
Innei 718096e306 💄 style(settings): unify select width and migrate to base-ui Select on service-model (#15248)
* 💄 style(settings): unify select width and migrate to base-ui Select on service-model

- Migrate `Select` from deprecated `@lobehub/ui` (antd-based) to `@lobehub/ui/base-ui` on STT/OpenAI/const
- Fix inconsistent select widths on service-model page: all selects now fixed at 448px
- Pull Switch out of width-constrained Flexbox in optional features row so the inner ModelSelect stays at 448px
- Drop per-item `minWidth: undefined` overrides and let Form-level `itemMinWidth={undefined}` make control col fit-content

* 💄 style(settings): move enable Switch before Select in optional features

Putting Switch in front of the Select aligns all selects on the page at the
same right edge — previously Switch trailing the Select pushed its right edge
56px to the left of other rows.
2026-05-27 12:44:35 +08:00
LiJian f0eded2941 feat(onboarding): skip redirect when landing on agent/inbox with message param (#15256)
*  feat(onboarding): skip redirect when landing on agent inbox with message param

New users arriving via /agent/inbox?message=... (e.g. Skills Marketplace
"Try in LobeHub" links) were being redirected to /onboarding before their
message could be sent, breaking the intended flow.

When the user lands on /agent/inbox with a message param, skip the onboarding
redirect so MessageFromUrl can immediately deliver the message. The user will
be prompted to complete onboarding on their next regular visit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(onboarding): broaden agent inbox guard to cover AgentIdSync slug rewrite

The previous guard matched only /agent/inbox, but AgentIdSync rewrites the
builtin slug to the resolved real agent ID (/agent/{uuid}) before the
useInitUserState callback fires — so pathname.startsWith('/agent/inbox')
was false by the time the check ran.

Widen the guard to any /agent/* path with a message param. The message
query param is the "send immediately" signal so the guard remains narrow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:58:59 +08:00
LiJian 1f6d350dca 🐛 fix(copy): unescape markdown escapes when copying user messages (#15253)
* 🐛 fix(copy): unescape markdown escapes when copying user messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🔨 refactor(copy): extract unescapeMarkdown util and skip code spans

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:09:51 +08:00
LiJian 5eee6d21e3 🐛 fix(hetero-agent): hide sandbox selector when device switcher is visible and sync runtimeMode (#15252)
* 🐛 fix(hetero-agent): hide runtimeMode selector when device switcher is visible and sync runtimeMode on target change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(hetero-agent): persist executionTarget and runtimeMode atomically to avoid abort-signal race

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:05:06 +08:00
Arvin Xu bcc31ca331 feat(bot): add hidden iMessage backend foundation (#15227)
*  feat(bot): add hidden iMessage backend foundation

* 🐛 fix(bot): align iMessage search totals and attachment timeout

* ♻️ refactor(bot): derive gateway runtime user from provider

*  feat(device): add message API calls
2026-05-27 02:21:43 +08:00
Innei 72d34046c0 🐛 fix(tabbar): debounce route meta publish to avoid tab item flicker (#15238)
* 🐛 fix(tabbar): debounce route meta publish to avoid tab item flicker

Desktop tab title and avatar could switch multiple times during page
navigation as agent/chat store data settled. Two coordinated fixes:

- Short-circuit `setCurrentRouteMeta` on shallow-equal meta + url so
  repeated identical writes never trigger downstream re-renders.
- Wrap the publish in a trailing 80ms debounce inside `RouteMetaBridge`
  and cancel it on route change/unmount so stale resolves from the
  previous route cannot overwrite the new one. Local `setDynamic`
  (driving document.title) stays synchronous.

* 🐛 fix(route-meta): keep previous dynamic meta during in-route navigation to stop title flicker

Dynamic state was keyed by `routeId + currentUrl`, so switching between
two topics (same route, different params) immediately invalidated the
previously resolved meta. The title fell back to the static `titleKey`
for one render before the new resolution arrived, producing an "A →
Chat → B" flash.

Key the cached meta by `routeId` alone. While navigating within the
same route family, the previous title persists until the new dynamic
resolution overwrites it; navigating to a different route still clears
correctly via the existing reset effect.
2026-05-26 22:09:23 +08:00
Innei 60f08f58e4 🐛 fix(electron-tab): update inactive tab title when topic is auto-named (#15244)
Run DynamicMetaRunner for every tab via TabCacheBridges so background
tabs receive auto-named topic titles instead of staying on "Default Topic".

Fixes LOBE-9492
2026-05-26 21:42:23 +08:00
Innei 202f062a0d feat(portal): embed thread chat in document preview portal (#15216)
*  feat(portal): embed thread chat in document preview portal

Embed FloatingChatPanel at the bottom of the Document preview portal so
users can converse with the agent about the document they are viewing
without leaving the portal.

Key changes:

- Remove the unused `/agent/:aid/:topicId/page/:docId` route and its
  supporting modules (TopicCanvas, Page, PageRedirect, topicPageRouteMeta,
  `agent/page` redirect). The route had no remaining UI entry point.
- Revive FloatingChatPanel as a thread-scoped side chat. Replace the
  hardcoded MainChatInput with `@/features/Conversation`'s ChatInput so
  the embedded composer no longer fights the main-page input for the
  global `mainInputEditor` slot.
- Default the panel's context to `scope='thread' + isNew: true` so a
  fresh ephemeral thread can be created on first send.
- Thread an `agentDocumentId` field through ConversationContext,
  ExecAgentAppContext, the Document portal payload, `openDocument` and
  callers (AgentDocumentsGroup, DocumentExplorerTree,
  AgentSignalReceiptList) so the in-portal chat always knows the
  agent_documents row id for the document in view.
- Rewrite the server `activeTopicDocument` resolver to use a single
  indexed `findRowByDocumentId(agentId, documentId)` lookup. This
  validates any caller-supplied row id and recovers the row when one
  was not provided, fixing cross-topic documents (skills, web docs)
  whose row id was previously missing — preventing the LLM from passing
  a `documents.id` into `readDocument({ id })` and triggering a failed
  query against `agent_documents.id`.

*  feat(portal): persist document portal chats as real threads

Anchor the in-portal `FloatingChatPanel` on the topic's last main-scope
message so the first send goes through `conversationLifecycle.ts`'s
`newThread` branch and the server actually creates a thread row. The
resulting thread now shows up in the left sidebar's `ThreadList` under
the parent topic.

- Read `sourceMessageId` from the latest non-thread message in
  `dbMessagesMap[messageMapKey({ agentId, topicId })]`; pair it with
  `ThreadType.Standalone` in the conversation context when `isNew`.
- Track the active thread in panel-local state. On
  `onAfterMessageCreate({ createdThreadId })` we refresh threads /
  messages and pivot the context from `isNew` to the persisted
  `threadId` in place — without calling `openThreadInPortal`, which
  would push a Thread view onto the portal stack and cover the document
  the user is reading.
- When the topic has no messages yet (no anchor), fall back to the
  previous ephemeral behavior (still leaks to main on first send;
  needed for empty-topic scenarios).

*  feat(portal): isolate document portal thread chat from main topic

Make the Document portal's `FloatingChatPanel` a truly doc-anchored side
conversation — independent of the main topic history and surviving the
mid-send pivot from `_new` → persisted thread key without the AI stream
disappearing.

- Subscribe to `chatStore.portalThreadId` instead of a panel-local
  `internalThreadId`. `lifecycle.ts:syncThreadInPortal` writes the new
  thread id into the portal slice *before* stream chunks arrive, so this
  panel's chatKey pivots in time to render the streaming response — the
  old `onAfterMessageCreate` hook only fired after the stream resolved,
  leaving the panel blank for the whole turn.
- Clear any stale `portalThreadId` left by a sibling portal on mount so a
  fresh `(agentId, topicId, documentId)` opens in `isNew` state.
- Pass `skipFetch` + a filtered `messages` prop to ConversationProvider.
  Without `skipFetch` the provider's own `useFetchMessages` pulled the
  main-topic history into this panel; with the doc-anchored A-mode we
  show only rows whose `threadId` matches the active thread (or nothing
  before the first send).
- Split `openThreadInPortal` into two actions: keep the original (push
  Thread view + sync state) for the main-page "create subtopic" flow,
  and add `syncThreadInPortal` that only mutates the portal slice.
  `lifecycle.ts` now picks one based on the current portal view type so a
  panel-hosted ConversationProvider in the Document portal no longer
  triggers a Thread view that covers the document.
- Add `key={agentId:topicId:documentId}` on `FloatingChatPanel` inside
  `Portal/Document/Body.tsx` so panel-local state (snap point, open,
  etc.) resets when conversation coordinates change.
- Anchor new threads on the topic's last main-scope message, paired with
  `ThreadType.Standalone`, so first send actually creates a thread row
  rather than leaking into the main topic.
2026-05-26 20:58:23 +08:00
LiJian be81c35e94 🐛 fix(exec-agent): gate CREDS_LIST/KLAVIS substitution on manifestMap instead of enabledToolIds (#15240)
* 🐛 fix(exec-agent): gate CREDS_LIST fetch on manifestMap instead of enabledToolIds

In execAgent mode, lobe-creds is added to toolManifestMap for activator
discovery but never into enabledToolIds, so the previous check
`resolved.enabledToolIds.includes(CredsIdentifier)` was always false
while the system role (containing {{CREDS_LIST}}) was already injected.
Gating on manifestMap presence aligns the variable substitution with the
actual system-role injection condition.

Also applies the same fix to {{KLAVIS_SERVICES_LIST}} which shares the
same isCredsEnabled gate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(exec-agent): gate KLAVIS_SERVICES_LIST substitution on KLAVIS_API_KEY presence

When KLAVIS_API_KEY is not configured the Klavis API client throws and
none of the advertised services are actually usable. Populate
{{KLAVIS_SERVICES_LIST}} only when the key is present, mirroring the
client-side enableKlavis check.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:11:24 +08:00
LiJian 14357a3f51 🐛 fix(create-platform-agent): wrap long version string in capability status (#15237)
🐛 fix(create-platform-agent): wrap long version string in capability status tag

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:17:19 +08:00
LiJian 0561a1d7eb 🐛 fix(hetero-agent): skip LOADING_FLAT placeholder when restoring accumulatedContent (#15236)
* 🐛 fix(hetero-agent): skip LOADING_FLAT placeholder when restoring accumulatedContent

When the cloud/IM Claude Code path cold-starts (Vercel serverless), it reads
the pre-created assistant message from DB to restore accumulatedContent.  That
message initially holds LOADING_FLAT ('...'), which was being treated as real
text — causing every first-turn response to start with '...'.

Subsequent turns were unaffected because handleStepStart (triggered by
--resume's newStep:true) always resets accumulatedContent to '' and creates a
fresh message with empty content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(hetero-agent): also strip LOADING_FLAT in ingest() DB refresh path

The previous commit guarded loadOrCreateState but the ingest() method
re-reads the assistant row from DB immediately after and adopts the DB
value when it is longer than in-memory.  On a cold-start first turn the
DB still holds LOADING_FLAT ('...', length 3) while in-memory was just
reset to '' (length 0), so the "adopt if longer" branch overwrote the
fix and put '...' back into accumulatedContent.

Apply the same LOADING_FLAT → '' normalisation to the refresh read so
both paths are consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:01:21 +08:00
Rylan Cai 3e0a396968 ♻️ refactor: + run command observation timeout (#15195)
*  add command observation timeout handling

*  hide shell observation timeout from model

* 🐛 restore shell observation compatibility

* 🐛 clear device proxy caller timeout timer

* 🐛 separate shell observation and caller timeouts

* 📝 clarify shell output wait semantics

* 🐛 align shell manifest timeout semantics

* 🐛 restore incremental shell output semantics

* 🐛 restore get command output manifest wording

* 🐛 restore get command output prompt wording

* 🐛 remove running state from command output

* 🐛 restore local system server runtime passthrough

* 🐛 restore device proxy timeout passthrough

* ♻️ tighten shell observation implementation

* ♻️ defer completed shell cleanup policy

* ♻️ simplify shell observation wait

* ♻️ read shell exit code from child process

* ♻️ tighten shell output exit code handling

* ♻️ clarify shell observation wait race

* 🐛 add device gateway HTTP call timeout

* 📝 clarify shell command session prompts

*  use incremental shell session ids

*  pass execution timeout through local system tool chain

* 🚑 fix local system timeout CI coverage

*  fix desktop shell controller tests
2026-05-26 14:53:34 +08:00
Arvin Xu 5f27cd8f26 💄 polish(agent-topic-manager): lighter bulk-bar shadow, transparent tool-auth alert, preserve sub-route (#15224)
* 💄 polish(agent-topic-manager): lighter bulk-bar shadow, transparent tool-auth alert, preserve sub-route on agent switch

- BulkActionBar: tone down the floating pill shadow from a heavy 24%/16%
  stack to a softer 8%/6% pair so it stops competing with the list rows.
- ToolAuthAlert: drop the secondary-tint fill (`background: transparent`)
  so the panel reads as a calm hint, not a warning. Reword the hint copy
  to "技能未授权或未配置时,相关技能无法使用,可能导致助理能力受限或报错" /
  matching EN.
- Sidebar agent switcher: clicking Lobe AI (Inbox) from `/agent/X/topics`
  now lands on `/agent/inbox/topics` instead of dropping back to the
  default chat URL. Extracts the existing `AgentItem` preservation logic
  into a `usePreservedAgentUrl` hook so both items share it.

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

* 💄 polish(bulk-bar): use cssVar.boxShadowSecondary token

Replace the hand-tuned `box-shadow` stack with the existing
`boxShadowSecondary` design token — matches the floating-overlay
pattern used by Notification, CommandMenu, etc.

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

*  feat(inspector): add X (Twitter) inspector

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

* 💄 polish(linear-inspector): use secondary text color in chips

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

* 💄 polish(linear-inspector): only dim the Linear wordmark, keep chip text primary

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

* 💄 polish(twitter-inspector): only dim the X (Twitter) wordmark, keep chip text primary

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:36:43 +08:00
YuTengjing 1c80146a07 🐛 fix(auth): prompt account selection for Google OAuth (#15234) 2026-05-26 12:54:47 +08:00
Innei 1d4d5c1c73 🐛 fix(library): add CTA in folder hierarchy empty state (#15220)
Previously the sidebar tree showed a blank panel when a library had no files or folders, leaving users with no entry point. Now an empty state surfaces an icon, title, hint, and the existing AddButton dropdown (new page / new folder / upload / Notion import).
2026-05-26 12:50:48 +08:00
Innei d45257615a 🐛 fix(sidebar): respect customize sidebar order across the bottom spacer (#15222)
The home sidebar previously split items into hard-coded top/bottom buckets,
so reordering an item across the bottom spacer in the Customize Sidebar
modal had no visible effect. Introduce a sentinel spacer slot in
`sidebarItems` (draggable in the modal as a divider row, rendered as a
flex:1 occupant in the sidebar) and remove the hard split — the sidebar
now follows the persisted order verbatim.
2026-05-26 12:50:40 +08:00
Arvin Xu b3cbc9a710 🐛 fix(prompts): keep input_completion system prompt stable across invocations (#15230)
* 🐛 fix(prompts): keep input_completion system prompt stable across invocations

Move the per-conversation context block out of the system message and into
a dedicated user message. The tracing `promptHash` is computed over the
system prompt, so embedding the rolling conversation window in it produced
a fresh hash on nearly every keystroke (1000+ unique hashes observed),
defeating per-prompt grouping.

Bumps `INPUT_COMPLETION_PROMPT_VERSION` to v1.1 so tracing can distinguish
the two message layouts.

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

* ♻️ refactor(prompts): build inputCompletion messages array declaratively

Replace successive `messages.push(...)` mutations with a single array
literal using a conditional spread for the optional context message.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:50:00 +08:00
LiJian e295f80235 🐛 fix: restore userId in gateway dispatch, gate local-system by executionTarget, add device switcher for regular agents (#15232)
- Fix GatewayHttpClient.dispatchAgentRun stripping userId from request body,
  causing 'Missing userId' error when routing Claude Code to desktop device
- Gate activeDeviceId=undefined when executionTarget='sandbox' so local-system
  tools are not injected in sandbox mode
- Add HeteroDeviceSwitcher to RuntimeConfig for regular agents (lab flag gated)
  so users can select a desktop device for local-system tool execution

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:00:05 +08:00
Arvin Xu 5cd02b937b feat(topics): add per-agent topic management page (#15207)
*  feat(agent-topics): add per-agent topic management page

Add /agent/:aid/topics — a dedicated management surface for browsing,
filtering, and bulk-operating on an agent's topics. Card grid view by
default with list view toggle, status / project / trigger / time filters,
keyword search, and multi-select bulk favorite / archive / delete.

A new "All Topics" entry in the agent sidebar (above the Topic accordion)
opens the page.

Frontend-only — no new TRPC procedures. Wires to the existing
useFetchTopics / useSearchTopics / favoriteTopic / updateTopicStatus /
removeTopic actions. Filters that the existing backend doesn't natively
support (project, time range, multi-sort) apply client-side on the loaded
page (default pageSize 100). Bulk favorite / archive loops single-action
calls; a proper batchUpdate procedure is left as a follow-up.

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

* 💄 style(agent-topics): UX iteration — sidebar entry, breadcrumb, byProject grouping, floating bulk bar

Major refinements after design review on PR #15207:

- Sidebar entry: moved from in-accordion to top nav between Profile and
  Channels, renamed "All Topics" → "Topics", uses MessagesSquare icon
- Header: breadcrumb (Agent / Topics) replaces standalone title; search
  bar moves into the NavHeader center slot; "New chat" + "Select" header
  buttons removed (selection enters via card hover-checkbox)
- Card refresh: compact layout (no fixed min-height, removed "No preview"
  fallback), favorite star moves to title prefix, hover reveals
  top-right checkbox, status renders as subtle StatusDot instead of
  saturated Tag, time uses platform `useActivityTime` (relative <24h,
  absolute date otherwise)
- Grouping: defaults to byTime; adds byProject + flat options matching
  the sidebar accordion modes; section titles in normal case
- Toolbar: status chips become a single Segmented control; Trigger
  dropdown items get icons (Chat/API/Scheduled/Eval); default trigger
  filter = ['chat'] so cron/api/eval noise hides by default
- List view: grid-template `minmax(0, 1fr)` + per-cell `min-width: 0`
  so long titles ellipsize instead of pushing other columns
- Layout: content max-width 1440, centered; grid `minmax(min(280px,
  100%), 1fr)` wraps cleanly when the agent sidebar expands
- Infinite scroll: IntersectionObserver sentinel + `loadMoreTopics`,
  PAGE_SIZE 30, shimmer text via `shinyTextStyles`
- BulkActionBar: floating pill at bottom-center (position: fixed,
  pointer-events isolated), ActionIcon buttons instead of full Buttons
- i18n: `management.*` namespace fleshed out across en/zh; zh "活跃"
  for active status (not "进行中")
- Backend: `topic.getTopics` SELECT now includes `description`;
  `ChatTopic` type adds `description?: string | null`

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

*  feat(agent-topics): bulk actions, stats columns, sticky header, list polish

Second iteration on PR #15207:

Backend (`topic.getTopics`)
- SELECT now returns `firstUserMessage` (correlated subquery, indexed via
  `messages_topic_id_idx`), `messageCount`, and `trigger`
- Mock `tokenUsage` / `cost` via `hashtext(topic.id)` so values are stable
  across refetches but look varied; will be replaced once real aggregation
  lands
- `ChatTopic` type adds matching optional fields

Page
- `ToolbarActions` (⋯ menu next to Sort): one-click "Archive topics
  inactive for 3+ months" (client-side iterate → `updateTopicStatus →
  completed`, with confirm and noneFound/done toasts), and an
  "Auto-generate summaries" entry stubbed to a Coming Soon toast until a
  topic-summary endpoint exists
- Status Segmented: drop `archived` and `favorite` (favorite isn't a
  status — keep the star indicator on the card/list instead); add
  `running` as its own slot
- `matchesTrigger` detects cron-spawned topics via `metadata.cronJobId`
  when `trigger` is null, so Daily Brief style data doesn't leak into the
  default Chat filter
- `clearFilters` resets to All instead of Active so users can confirm an
  empty result really is empty across the whole dataset
- Infinite-scroll: `IntersectionObserver` now uses the scroll container
  as `root` (was viewport — broken inside a nested scroller); sentinel +
  shimmer text rendered only when topics are actually present

Card
- Preview fallback chain `description → historySummary → firstUserMessage`
- Footer shows `messageCount` / `tokenUsage` (formatTokenNumber) / `cost`
  (formatPrice) alongside the activity time

List view
- Sticky header (`position: sticky; inset-block-start: 0`) with opaque
  `colorBgElevated` so scrolled rows don't bleed through
- "Select all" checkbox in header with indeterminate state; auto-enters
  selectMode on first activation
- Trigger column localized via `t('management.filters.trigger.*')`;
  Updated column right-aligned
- Grid template back to 6 columns (favorite star is now inline before
  the title)

Sidebar
- The Topic accordion's "Load more" entry (`FlatMode` + `GroupedAccordion`)
  now navigates to `/agent/:aid/topics` instead of opening the legacy
  `AllTopicsDrawer`

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

*  feat(agent-topics): infinite scroll, status counts, task trigger filter

- Per-agent paged fetch via new agentTopicsViewMap (action + selectors + initial state) with `withDetails` opt-in for card columns
- Toolbar status segmented control surfaces live counts; trigger filter switches `cron` → `task` (matches TaskRunnerService output) with ListTodo icon

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

* ♻️ refactor(agent-topic-manager): rename folder, swap to LobeUI Checkbox

- Rename `AgentTopics` → `AgentTopicManager` (folder, displayNames, route import)
- Replace hand-rolled card checkbox with `@lobehub/ui` Checkbox (size 18, lighter border via colorBorder); list view also uses `@lobehub/ui` instead of antd
- Fix topic.query withDetails correlated subqueries: qualify column refs so `topic_id = topics.id` resolves correctly (drizzle `${table.col}` renders unqualified — previously matched against messages.id). Add covering tests.

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

* 🔧 chore(topic-query): drop mock cost/tokenUsage from withDetails, polish card

- topic.ts: stop emitting hashtext-mocked `cost` / `tokenUsage` in the
  `withDetails` branch — they need a real schema migration before they
  can be backed by actual numbers. Real aggregates (firstUserMessage,
  messageCount) and existing columns (description, trigger) still come
  back as before.
- Update test + JSDoc to match. The card already gracefully drops the
  cost row via `cost > 0` since the field is now undefined.
- TopicCard: drop the redundant `$` text before `formatPrice` — the
  CircleDollarSign icon already conveys the currency.

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

* 🙈 hide(agent-topic-manager): hide auto-summarize entry until migration lands

The auto-summarize menu item depends on the same schema migration that
gates cost / tokenUsage in the topic.query withDetails path. Drop it
from the ToolbarActions dropdown for now; i18n keys stay in place so
re-enabling is just adding the item back.

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

*  test(agent-sidebar-nav): add MessagesSquareIcon to lucide-react mock

Nav.tsx now renders the agent-topic-manager entry via `MessagesSquareIcon`;
the test mock listed only the previous three icons, so the component
threw on render.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 10:07:28 +08:00
Innei cce2741de3 🐛 fix(chat-input): disable automatic URL highlighting (#15219) 2026-05-26 01:54:09 +08:00
YuTengjing 362d137a2b 🐛 fix(model-runtime): preserve usage cost in custom streams (#15218) 2026-05-26 01:13:39 +08:00
Innei 6859ee2374 feat(page-agent): preview initPage streaming arguments (#15039) 2026-05-26 01:08:08 +08:00
Arvin Xu d6e641b790 🐛 fix(model-runtime): capture useful errorCode from generateObject failures (#15209)
The catch in ModelRuntime.generateObject only read `error.code`, but
neither lobehub's structured ChatCompletionErrorPayload nor Vercel
AI SDK errors expose that field — provider wrappers set `errorType`
(InvalidProviderAPIKey / ModelNotFound / ExceededContextWindow / …)
and AI SDK errors set `name` (AI_TypeValidationError /
AI_NoObjectGeneratedError / AI_RateLimitError / …). As a result every
tracing row landed with `error_code = null`, displayed downstream as
"unknown" and defeating the error-type classifier in dashboards.

Walk the chain `errorType → code → name → constructor.name` so the
most descriptive identifier wins. Add three test cases covering each
branch.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 21:28:49 +08:00
Innei 2ee53bcd60 ⬆️ chore(deps): bump @lobehub/ui to 5.15.1 (#15214)
5.15.1 adds `&[data-has-header] { padding-block-start: 0 }` and
`&[data-has-footer] { padding-block-end: 0 }` on the menu popup, so the
4px block padding the slot content used to bleed into no longer exists.
Drop the `margin-block-*: -4px` compensations on the Plus menu's tools
search box, stats footer, and knowledge "view more" button to avoid
content being clipped by the popup's `overflow: hidden`.
2026-05-25 21:11:48 +08:00
Innei 8b96d14347 💄 style(explorer-tree): align file icons with folder icons (#15205)
Drop the `compact` density override on the two PierreFileTree consumers
(DocumentExplorerTree, WorkingSidebar Files) so rows breathe like the
SkillsList. Reserve a chevron-sized slot on file rows when the tree
contains any folder so file icons line up with the folder glyph, mirroring
SkillsList's `reserveChevronSlot`.

Pierre's `unsafeCSS` is captured at FileTree construction with no public
setter, so the offset is driven by a CSS custom property the wrapper sets
inline. Custom properties cascade through the shadow DOM, so toggling the
flag when the last folder is deleted reflows the offset live.
2026-05-25 19:58:29 +08:00
Arvin Xu 248d6ecf76 feat(observability): instrument Agent Runtime with OTel GenAI semantic conventions (#15123)
*  feat(observability): add Agent Runtime OTel spans per GenAI semantic conventions

Introduces a new `@lobechat/observability-otel/modules/agent-runtime` module
with `gen_ai.*` attribute helpers (aligned with OTel GenAI semconv v1.41) and
LobeHub-specific `lobehub.*` extensions, then instruments the core execution
path with four span types:

- `invoke_agent {agent.name}` around `AgentRuntimeService.executeStep`,
  carrying `gen_ai.agent.*`, `gen_ai.conversation.id`, accumulated token
  usage and `lobehub.agent.completion_reason`.
- `chat {model}` around the LLM call in `RuntimeExecutors.call_llm`,
  including `gen_ai.response.time_to_first_chunk` captured on the first
  text/reasoning chunk, finish reasons, and per-call token breakdown.
- `execute_tool {tool.name}` per tool call in both `call_tool` and the
  concurrent `call_tools_batch`, with `gen_ai.tool.type` mapped from
  LobeHub `ToolSource` and `lobehub.tool.success` / `lobehub.tool.attempts`.
- `context_engineering` around `serverMessagesEngine` invocations, with
  message/token/knowledge/memory/tool-count metadata.

Spans are no-ops when OTEL is not initialized (the `@opentelemetry/api`
default provider), so runs without `ENABLE_TELEMETRY` keep their previous
cost profile.

Refs LOBE-5594.

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

* fix(observability): align agent runtime GenAI attributes

* test(agent-runtime): stabilize agent signal hook integration

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:43:46 +08:00
LiJian d4e8d6df6e 🐛 fix: desktop device hetero task — correct notify URL, auth header, child env (#15206)
🐛 fix: desktop hetero task notify — correct URL, auth header, and child env

Three bugs prevented openclaw results from reaching the UI when dispatched
via the desktop device (vs. the CLI which worked):

1. `sendNotify` posted to `/trpc/agentNotify.notify` — missing `/lambda/`
   segment, causing every done/error signal to hit a 404.
2. `sendNotify` sent `Authorization: Bearer <token>`; the lambda tRPC context
   only recognises `Oidc-Auth` (and `X-API-Key`), so every call was UNAUTHORIZED.
3. Spawned openclaw/hermes processes inherited bare `process.env` with no
   credentials, so `lh notify` inside the child had no auth to call back.

Fix: inject `LOBEHUB_JWT` + `LOBEHUB_SERVER` into child env from desktop's
stored credentials, and use the correct `/trpc/lambda/` URL + `Oidc-Auth`
header (matching what the CLI does).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:34:42 +08:00
Innei 4c6a3999c1 🐛 fix(agent): hold per-agent working directory in store (#15204)
Previously `getAgentWorkingDirectoryById` read directly from localStorage
and `updateAgentRuntimeEnvConfigById` wrote via `setLocalAgentWorkingDirectory`
without going through zustand's `set`. With no store mutation, subscribers
were never notified, so components that re-render only via store updates
(e.g. AgentWorkingSidebar's Files tab) kept showing stale data while the
picker itself appeared to work — the popover close re-rendered the bar,
masking the bug.

Hold the per-agent working directory in `localAgentWorkingDirectoryMap`
on the agent store (hydrated from localStorage at init). Writes now go
through `#set` in addition to localStorage, so all subscribers see the
change. Selectors read from the store map.
2026-05-25 18:19:43 +08:00
Arvin Xu 506b96af64 🐛 fix(agent-runtime): strip heavy fields off finalState in stream events (#15190)
🐛 fix(agent-runtime): strip heavy fields off finalState in stream events (LOBE-9544)

Long topics with `compressedGroup` envelopes can serialize a full
`AgentState.messages` array that exceeds Upstash Redis's 10 MB single-
request limit on xadd, crashing `agent_runtime_stream:<opId>` writes
and surfacing as a misleading watchdog "Operation idle" timeout on
the gateway side.

LOBE-9110 already removed `contextEngine.input` + `toolsetBaseline`
from the state blob. `messages` (especially compressedGroup envelopes
that preserve full original-message arrays alongside the LLM summary)
is the remaining size driver. A diagnosed case (op_177967426) was
20 MB, of which 15 MB lived in 3 compressedGroup envelopes holding
752 raw messages.

Approach: centralize the strip at the `publishStreamEvent` chokepoint.
Every stream-event publish in the runtime — `publishAgentRuntimeEnd`,
the per-step `step_complete` in `AgentRuntimeService.executeStep`, the
two terminal `step_complete` sites in `RuntimeExecutors` — flows
through this single method. Putting the strip there means call sites
stay dumb and any future direct user of `publishStreamEvent` gets the
size protection automatically.

The same strip is mirrored in `InMemoryStreamEventManager.publishStreamEvent`
(test-mode parity) and `GatewayStreamNotifier.pushEvent` (gateway WS
push channel — separate HTTP POST that would otherwise re-introduce
the same multi-megabyte serialization).

Fields stripped (mirrors OperationTraceRecorder's `done`-event strip
from LOBE-9110, kept in sync intentionally):

- `messages` — canonical copy lives in DB rows / in-memory state;
  in-process consumers (e.g. `execSubAgentTask.onComplete`) receive
  the full state via the local `HookContext` channel, not via the
  stream
- `operationToolSet`, `toolManifestMap`, `toolSourceMap`, `tools`
  — operation-level snapshot already covered by LOBE-9110

`finalState` itself stays in the payload so existing consumers that
read lightweight fields (`status`, `cost`, `usage`, `error`, …) keep
working. Verified no consumer reads the stripped fields off the
wire — `gatewayEventHandler` only reads `reason` + `uiMessages`,
`runAgent.ts` reads `finalState.status` which survives the strip,
CLI / agent-gateway-client / hetero adapters / agent-mock have no
`finalState` references at all.

Tests:
- New `publishAgentRuntimeEnd` integration test with a fat finalState
  asserts heavy fields stripped + lightweight fields preserved +
  `reasonDetail` derivation still sees the un-stripped error message
- New `stripFinalStateInEventData` unit tests cover the helper
  contract (no-op when absent / falsy, strips correctly, defensive
  on non-object input)
- Existing tests pass unchanged — their mock `finalState` objects
  don't carry `messages`, so the strip is a no-op for them, which
  is exactly the chokepoint contract: invisible to callers that
  don't pass heavy state

306 tests pass (StreamEventManager / InMemoryStreamEventManager /
GatewayStreamNotifier / RuntimeExecutors / AgentRuntimeService /
AgentRuntimeCoordinator / runAgent / gatewayEventHandler).

Follow-up (out of scope): catch the xadd 500 inside the DO and
publish an `op_crashed_redis_overflow` event so the gateway surfaces
"state payload exceeded" instead of the misleading watchdog idle
timeout.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 18:00:47 +08:00
LiJian 924ae8bf1f 🐛 fix: pass assistantMessageId through sandbox env to eliminate heteroIngest race (#15197)
* 🐛 fix: pass assistantMessageId through sandbox env to eliminate heteroIngest race

Before this change, `HeterogeneousPersistenceHandler.loadOrCreateState` always
read `topic.metadata.runningOperation` from the DB to obtain `assistantMessageId`.
On Vercel serverless, the first `heteroIngest` batch could arrive on a cold Lambda
that read from a replica before the orchestrator's `updateMetadata` write was
visible, causing a hard throw and BatchIngester exhausting all 5 retries — leaving
the assistant message stuck as LOADING_FLAT with no user feedback.

Fix: orchestrator passes `assistantMessageId` via `LOBEHUB_ASSISTANT_MESSAGE_ID`
env var → CLI → `TrpcIngestSink` → `heteroIngest` payload → `loadOrCreateState`.
When present, the DB lookup is skipped entirely for state initialisation, matching
the frontend `createGatewayEventHandler` pattern which always receives
`assistantMessageId` in-memory before any events are processed.

The `topic.metadata` DB read is kept as a fallback for desktop/old-CLI callers
that do not send the field, and is still needed to restore `heteroCurrentMsgId`
for mid-conversation cold-start reconstruction on step boundaries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(hetero-agent): snapshot text ingests and ignore stale batches

* chore: publish the cli to 0.0.21

* 🐛 fix(hetero-agent): validate seeded assistant binding

* fix: fixed the little types error

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 17:43:02 +08:00
Innei 302755057e ️ perf(vite): prewarm more route chunks (#15142) 2026-05-25 16:48:55 +08:00
Innei eea9464b04 🌐 fix(locale): add missing follow-up i18n keys (#15201)
🌐 fix(locale): add missing follow-up i18n keys for zh-CN and en-US
2026-05-25 16:45:02 +08:00
Arvin Xu 82cc885394 🐛 fix(llm-generation-tracing): backfill task_brief / task_brief_judge scenario (#15198)
🐛 fix(llm-generation-tracing): backfill task_brief/task_brief_judge scenario

Brief generation and judge call sites only set `metadata.trigger`, so the
tracing hook fell back to `scenario='unknown'` for every row. Surfaced via
the unknown-scenario cleanup pass: 433 task-brief + 26 task-brief-judge
rows landed in unknown, alongside 434 task-handoff rows that still used
the dashed trigger string.

- Add `task_brief` and `task_brief_judge` to `TRACING_SCENARIOS`
- Add `_PROMPT_VERSION` + `_SCHEMA_NAME` constants for both brief chains,
  matching the existing `TASK_TOPIC_HANDOFF_*` convention
- Wire explicit `tracing: { promptVersion, scenario, schemaName }` at all
  three task-lifecycle generateObject call sites
- Normalize `metadata.trigger` to underscored ids
  (`task_handoff` / `task_brief` / `task_brief_judge`) to match the
  `RequestTrigger` enum convention
2026-05-25 16:40:37 +08:00
Arvin Xu e4ad195df9 🐛 fix: silence Turbopack project-wide glob warning (#15194)
`path.join(this.root, sub)` still tripped Turbopack's static file-pattern
analyzer because `safeSegment`'s `|| 'unknown'` fallback gave the analyzer
a finite alternation, fanning out into a project-wide glob that matched
11k+ files at build time. Hand-roll the join with `path.sep` so the
analyzer can't see it as a path pattern; output is byte-identical to
`path.join` on both Unix and Windows.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:36:40 +08:00
LiJian 47b6f3503a feat(hermes): implement hermes agent chain logic (#15189)
*  feat(hermes): implement hermes agent chain logic

Replace the broken HTTP gateway approach with direct CLI spawn (matching
openclaw's architecture). Hermes chat -q --quiet outputs session_id +
response to stdout — we capture it and relay via sendAutoNotify/sendDoneSignal,
no buildNotifyProtocol injection needed.

- heteroTask (CLI): spawn hermes chat -q --quiet [--resume <id>], capture
  stdout, persist session_id to ~/.lobehub/hermes-sessions.json per topicId,
  kill concurrent same-topic tasks by PID before spawning
- GatewayConnectionCtr (desktop): mirror CLI logic, store hermes session IDs
  in-memory hermesSessionMap, remove unused HTTP gateway helpers
- getAgentProfile: implement hermes profile fetch via `hermes profile list`
  + SOUL.md description parsing
- checkPlatformCapability: fix hermes check to use `hermes --version`
  instead of non-existent HTTP /health endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(hermes): fix CodeQL incomplete sanitization and tilde path expansion

- readHermesSoulDescription: loop comment-stripping regex until stable to
  prevent residual `<!--` from malformed/nested sequences (CodeQL High)
- getHermesProfilePath: expand leading `~` via os.homedir() before fs.join
  in case hermes profile show returns a tilde-prefixed path (CLI + desktop)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(hermes): strip residual angle brackets to satisfy CodeQL sanitization check

After stripping HTML comments, add .replaceAll(/[<>]/g, '') inside the
loop to ensure no partial `<!--` delimiters survive, resolving the CodeQL
'Incomplete multi-character sanitization' High warning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

*  feat(platform-agent): enable Hermes + add Amp/OpenCode as coming-soon platforms

- Enable Hermes in CreatePlatformAgent (remove from COMING_SOON_PLATFORMS)
- Add Amp and OpenCode to REMOTE_HETEROGENEOUS_AGENT_CONFIGS (coming-soon disabled)
- Extend RemoteHeterogeneousAgentType and HeterogeneousProviderConfig.type
- Use isRemoteHeterogeneousType() in HeterogeneousAgentStatusCard to future-proof
  remote agent detection guard (removes hardcoded openclaw/hermes check)
- Export isRemoteHeterogeneousType from heterogeneous-agents/client entrypoint
- Broaden agentType to string in device-gateway-client (AgentRunRequestMessage,
  dispatchAgentRun) so new remote types pass without package updates
- Add i18n keys for amp/opencode platform descriptions (en-US, zh-CN)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 🐛 fix(test): add isRemoteHeterogeneousType to heterogeneous-agents/client mock

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 14:09:21 +08:00
YuTengjing bb4924fc5b 🐛 fix(image): explain text-only image responses (#15181) 2026-05-25 14:05:43 +08:00
Arvin Xu 46f884d5ed chore(llm-generation-tracing): pre-allocate tracingId + recordFeedback router (#15146)
*  feat(llm-generation-tracing): pre-allocate tracingId + recordFeedback router

Wire up the per-call feedback loop foundation.

1. **Pre-allocate tracingId (plan A2)**
   - `TracingOptions.tracingId?: string` — optional caller-supplied UUID.
   - `LLMGenerationTracingService.record` generates one via `randomUUID()`
     when the caller doesn't supply one, so the id is always known
     before DB insert.
   - `LlmGenerationTracingModel.record` accepts an optional `id` and
     forwards it to the insert (Drizzle still autogens when omitted).
   - `aiChat.outputJSON` allocates the id up-front, threads it through
     `tracing.tracingId`, and returns `{ data, tracingId }` so the
     client can wire feedback against the id even though
     `service.record` runs inside Next's `after()`.
   - `aiChatService.generateJSON` consumers (InputEditor, supervisor)
     unwrap the envelope.

2. **New `llmGenerationTracingRouter.recordFeedback`**
   - Scenario-agnostic feedback endpoint at `lambda.llmGenerationTracing`.
   - Validates `{ tracingId (uuid), signal (positive|negative|neutral),
     source, score?, data? }` and forwards to
     `LLMGenerationTracingService.recordFeedback`.

Follow-up issues already filed:
- LOBE-9488 — `@lobehub/editor` AutoCompletePlugin needs
  `onAccept`/`onReject`/`onCancel` callbacks before the client side can
  capture Tab/Esc/keep-typing signals against the returned tracingId.
- LOBE-9489 — session-level signal modeling (multi-suggestion typing
  sessions) — deferred until per-row feedback data lands.

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

* 🐛 fix(llm-generation-tracing): surface feedback write failures instead of silent ok

The recordFeedback mutation used to always return `{ ok: true }` even when
the underlying write was silently dropped — `LLMGenerationTracingService`
swallowed both DB-init/update throws and the no-op case where the WHERE
clause (id + userId) matched zero rows. Callers couldn't tell
"persisted" from "lost", which would skew tracing-feedback metrics and
prevent reasoned retry/error handling.

Fix:

- `LlmGenerationTracingModel.updateFeedback` now returns
  `{ updated: boolean }` (via `.returning({ id })`), so the caller knows
  whether the WHERE clause actually matched a row.
- `LLMGenerationTracingService.recordFeedback` throws a typed
  `LLMGenerationFeedbackError` with `kind: 'not_found' | 'db_failure'`
  instead of swallowing — stops logging-only behaviour for DB errors and
  promotes the 0-rows case to an explicit signal.
- `llmGenerationTracingRouter.recordFeedback` catches that error and
  translates to `TRPCError({ code: 'NOT_FOUND' })` for stale-id and
  `INTERNAL_SERVER_ERROR` for DB outages — `{ ok: true }` only flows
  back when a row was actually patched.

Tests:
- Model: assert `{ updated: true/false }` for happy / cross-user / missing-id
- Service: assert throws on both not_found scenarios
- Router: assert TRPCError code translation for both error kinds

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

*  feat(input-completion): wire Tab/Esc/typing feedback to recordFeedback

- bump @lobehub/editor to ^4.12.0 for AutoComplete onSuggestion{Accepted,Rejected}
- add llmGenerationTracingService wrapping lambda.llmGenerationTracing.recordFeedback
- InputEditor: map suggestionId→tracingId, fire positive on accept, negative on
  esc, neutral on typing/cursor-move/blur/other; recode IME-driven escape as
  neutral/autocomplete_ime so CJK input doesn't poison the signal

Closes LOBE-9488

* ♻️ refactor(input-completion): fold recordTracingFeedback into aiChatService

Single trpc mutation didn't warrant a dedicated service file; aiChatService
already owns the paired `outputJSON` call that mints the tracingId, so
recordTracingFeedback belongs alongside it.

* 💄 style(llm-generation-tracing): tag task-handoff scenario + prompt version (#15191)

* 💄 style(QueueTray): use borderless variant for queued file preview

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

*  feat(llm-generation-tracing): tag task-handoff scenario + prompt version

Task topic handoff was tracing as scenario=unknown / promptVersion=v0 because the
generateObject call only set metadata.trigger and that trigger isn't in the
registry. Add a TaskHandoff scenario const, version the prompt next to its
definition, and pass tracing options explicitly at the call site (mirroring
followUpAction).

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

---------

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

* 🐛 fix(llm-generation-tracing): validate caller-supplied tracingId as UUID

The `outputJSON` route echoed `tracing.tracingId` back to clients without
checking the shape. Because the surrounding `tracing` record is free-form,
a malformed value passed request validation, then failed DB insertion on
the uuid PK and was later rejected by `recordFeedback` (`z.string().uuid()`),
so callers could receive a tracingId unusable for the feedback flow.

Tighten `StructureOutputSchema.tracing` to a `z.object({ tracingId: uuid }).catchall(unknown)`
so the validation happens at the request boundary; the route can then drop
the redundant `typeof === 'string'` guard.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:59:28 +08:00
Arvin Xu 0fcc21895e 🧹 chore(skills): audit pass — normalize, dedupe, and fix project-overview (#15193)
* 🧹 chore(skills): consolidate, normalize, and add audit skill

Findings from the first skills audit on the 36 project-local skills:

- `source-command-dedupe` was a verbatim duplicate of the global `dedupe` skill (same description, same procedure). Deleted.
- `data-fetching` only covered the pipeline (Service + Zustand Store + SWR),
  not Zustand itself. Renamed to `data-fetching-architecture` so the scope
  is clear next to the standalone `zustand` skill. Cross-ref in
  `store-data-structures` updated.
- 9 skills had inconsistent description format (numbered lists, missing
  `Triggers on`, `MUST use when` opener, `Triggers:` colon vs `Triggers on`,
  etc). Normalized to the template:
  `{Topic + key conventions}. Use when {scenarios}. Triggers on {symbols, phrases, 中文}.`
  Skills touched: docs-changelog, pr, project-overview, react, review-checklist,
  spa-routes, chat-sdk, upstash-workflow, store-data-structures.
  User-invoked-only skills (`disable-model-invocation: true`) intentionally
  skipped — they don't need trigger keywords.

Adds a new `skills-audit` skill that codifies the weekly check (inventory,
overlap detection, description-template validation, stale-skill check,
cross-reference integrity) so future audits don't have to re-derive the
process.

Skill count: 36 → 36 (-1 deleted, +1 added).

* 📝 docs(skills): rewrite project-overview from open-source repo perspective

The skill previously described the private cloud repo (cloud root + `lobehub/`
submodule + override mechanism), which doesn't apply here — this is the
open-source root. Rewrite the directory map and description for the flat
`apps/` + `packages/@lobechat/*` + `src/` layout, and append a Cloud Repo
note explaining how the cloud SaaS repo mounts this as a submodule.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 13:32:02 +08:00
Arvin Xu 3c52998157 feat(hetero-agent): execution target switcher in composer (#15179)
*  feat(hetero-agent): add execution target switcher in composer

Add a chip in the chat composer toolbar that lets users pick where a
heterogeneous agent (claude-code / codex) executes: on this desktop, in
a cloud sandbox, or on an `lh connect` remote device. Persists the
choice via a new `agencyConfig.executionTarget` field paired with the
existing `boundDeviceId`. Server dispatch wiring will land separately.

* 🐛 fix(hetero-agent): mount execution target switcher in hetero composer

The hetero `ChatInput` replaces `RuntimeConfig` with `WorkingDirectoryBar`
via `runtimeConfigSlot`, so the new chip added in the previous commit
was never reached for hetero agents. Mount `HeteroDeviceSwitcher` in
`WorkingDirectoryBar` directly (both desktop and web branches).

* 💄 style(hetero-agent): polish execution target popover

- Drop uppercase + letter-spacing from section titles for normal sentence case
- Add a green status dot next to "Online" on device rows
- Rename "Remote devices (lh connect)" to "Other devices" with a clarifying
  subtitle so it covers both desktop-app and `lh connect` machines

* 💄 style(hetero-agent): use OS-specific icons for devices

Replace the generic bot avatar in device rows (and the chip) with the
machine's actual OS icon — Apple for darwin, Linux for linux, Microsoft
for win32, generic monitor as fallback. Matches the same icon set
already used in MCP plugin deployment.

* 💄 style(hetero-agent): unify execution targets into a single list

- Flatten This device / Cloud sandbox / remote devices into one list
- Add an info ⓘ icon in the popover header explaining when to pick a
  remote device vs This device; drop the inline section description
- Remove the "Other devices" rename and keep the original "Remote
  devices" terminology in the empty hint

* 💄 style(hetero-agent): rename popover title to Execution Device

* 💄 style(agent-signal): refine skill receipt card with self-evolution copy

- Render SkillsIcon for skill receipts and let PortalResourceCard accept a ReactNode icon
- Square 64x64 avatar, 12px corner radius, larger icon, drop the RadioTower marker
- Move the receipt card below the Usage row so it reads as metadata, not body content
- Reword the skill receipt to convey self-evolution ("Auto-learned a new skill" / "已自动习得新技能")

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

* 🐛 fix(hetero-agent): keep working-directory controls in RuntimeConfig

Revert the early-return I added in `RuntimeConfig.rightContent` for
heterogeneous agents. Hetero agents are mounted via `HeterogeneousChatInput`
which already replaces `RuntimeConfig` with `WorkingDirectoryBar` (where
the `HeteroDeviceSwitcher` lives), so the branch here was dead code — but
it dropped the `!isDesktop` gate and would have skipped the desktop
working-directory picker for any edge case that still falls through this
path (popup/share/embed). Restore the original web-only condition.

*  feat(hetero-agent): fork dispatch by executionTarget for local CLI hetero

Local CLI hetero (claude-code / codex) used to dispatch to a device only
when topic.metadata.boundDeviceId was set, otherwise always spawned a
cloud sandbox — ignoring agencyConfig.executionTarget entirely.

Now resolve in this order:
  1. requestedDeviceId (topic-level override) → device dispatch, always wins
  2. agencyConfig.executionTarget = 'device' → dispatch to boundDeviceId;
     error out if no device is bound (no silent sandbox fallback, since
     the user explicitly chose this mode)
  3. otherwise (sandbox / local / unset) → cloud sandbox

'local' mode falls back to sandbox on the server since in-process spawn
only makes sense inside the Electron client; that path is owned by the
desktop and doesn't reach this code today.

*  feat(hetero-agent): route runtime by executionTarget for local CLI hetero

Frontend complement to the previous server-side dispatch fork. Without
this change the chip's choice on desktop was a no-op: selectRuntimeType
hard-routed local CLI hetero to 'hetero' (desktop IPC) whenever
isDesktop, bypassing the server entirely — so 'device' / 'sandbox' picks
never reached the new server-side fork.

Now selectRuntimeType reads agencyConfig.executionTarget:
  - 'device'  → 'gateway' (server dispatches to bound lh connect device)
  - 'sandbox' → 'gateway' (server spawns cloud sandbox)
  - 'local'   → 'hetero' on desktop, 'gateway' on web (fallback)
  - unset     → legacy default (desktop = hetero, web = gateway)

All four runtime-selection call sites pass executionTarget through; the
non-hetero sub-agent dispatcher is unaffected since heteroProvider is
always undefined there.

*  feat(chat-input): add Advanced Parameters entry to Plus menu

- New menu item toggles the right working sidebar's params tab, mirroring the agent header's ParamsPanelToggle
- Simplify the format-toolbar item label to a fixed "Show formatting toolbar" with a checkmark indicating active state
- Widen the active-label gap so the checkmark sits comfortably away from the text

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

* 🚩 feat(hetero-agent): gate execution-device switcher behind a lab flag

Add `enableExecutionDeviceSwitcher` to UserLabSchema (default off) and gate the heterogeneous WorkingDirectoryBar's HeteroDeviceSwitcher on it, so the new switcher can ship to canary without exposing it to all users until ready. Expose the toggle in Settings → Advanced → Labs.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:50:41 +08:00
Arvin Xu 8d4c48749f feat(agent-signal): add execAgent plumbing for self-iteration migration (#15187)
Phase 1 of LOBE-9434: introduces dormant plumbing for converging
agent execution onto execAgent. No behavior changes for any existing
caller — every piece is a no-op until later phases wire it up.

- Add `ExecAgentAppContext.suppressSignal` flag and `sourceMessageId`
- Add `shouldSuppressSignal` helper; gate the `agent.user.message`
  re-emission in `aiAgent.execAgent` so future builtin/background runs
  cannot recurse into the analyzeIntent pipeline
- Register `self-iteration` builtin agent + `SELF_ITERATION_AGENT_SLUGS`
- Add `finalStateExtractor` (`extractFromFinalState` /
  `extractMutations` / `extractArtifacts`) for reading tool-result kind
  partitions off a persisted AgentState snapshot
- Register a no-op `completionPolicy` listener on
  `agent.execution.completed` with an optional
  `onSelfIterationCompleted` callback (undefined by default)

Tests: 17 new unit tests across suppressSignal, finalStateExtractor,
and completionPolicy.
2026-05-25 11:40:23 +08:00
Arvin Xu 26aa28c263 chore: clean up LOBE-XXX code annotations (2026-05-25) (#15182)
chore: clean up LOBE-XXX code annotations

- Removed LOBE-9501 markers (assistantGroup clobber fix — gateway
  pushes UIChatMessage snapshot as SoT at step boundaries)
- Removed LOBE-9523 markers (mid-stream cancel fix — skip uiMessages
  for interrupted status; partial-finalize accumulated content in
  executor catch block)
- Removed LOBE-9378 markers (local-system template variable injection
  — unified activeDeviceId resolution for regular chat)
- Preserved all descriptive comments; only removed issue ID tokens
- No behavior changes

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
2026-05-25 10:43:43 +08:00
Neko f3d5d03cf5 ♻️ refactor(userMemories): support resolving agent config from ServiceModel (#15138)
* ♻️ refactor(userMemories): support resolving agent config from ServiceModel

* ♻️ refactor(userMemories): share memory analysis service model
2026-05-25 04:06:50 +08:00
YuTengjing d71686ba88 🐛 fix: normalize image MIME from bytes (#15172) 2026-05-25 00:32:55 +08:00
Arvin Xu f16c280e93 🐛 fix(agent): surface projectSkills regardless of activeDeviceId (#15177)
The merge gate in execAgent silently dropped client-provided
projectSkills whenever activeDeviceId couldn't be resolved
(multi-device-no-bind, bound-device-offline, disableTools=true, no
DEVICE_GATEWAY_URL). The client having scanned `.agents/skills` /
`.claude/skills` and sent them up is itself proof that a device is
reachable now — gating availability on a multi-device-routing decision
conflated two concerns and produced "I sent skills but the model never
sees them" with no log to diagnose.

Drop the activeDeviceId precondition so projectSkills always populate
`<available_skills>`. Whether the readFile can actually resolve at
activation time stays gated at `serverRuntimes/skills.ts`, where a
missing `deviceFileAccess` naturally fails `activateSkill` instead of
silently hiding the option.

Also add a one-line merge log so future "why didn't my skill show up"
investigations land on the answer immediately.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:20:59 +08:00
YuTengjing be62847e00 🐛 fix: support Bedrock structured generation (#15174) 2026-05-25 00:15:50 +08:00
Arvin Xu a8faccff66 🐛 fix(agent-runtime): preserve streamed content across mid-stream cancel (#15173)
* 🐛 fix(agent-runtime): preserve streamed content across mid-stream cancel

LOBE-9523

Mid-stream STOP currently collapses the in-memory streamed assistant
content back to the LOADING_FLAT placeholder (cLen 5182 → 3 observed in
the agent-gateway probe dump at `.agent-gateway/caseD-prerefresh-…json`),
and a subsequent reload returns the same placeholder from DB so the
content is **permanently lost**.

Root cause (matrix-tested via Electron + probe, see updated LOBE-9523
description): when the user clicks STOP, `interruptOperation` flips
state.status to 'interrupted' and `coordinator.saveAgentState` publishes
`agent_runtime_end` carrying the `uiMessages` snapshot. The executor's
post-stream finalize at `RuntimeExecutors.call_llm:1078` hasn't run yet,
so the assistant row is still the empty placeholder — that placeholder
gets pushed to the client as SoT and clobbers the streamed content.

Three coordinated fixes:

1. **Executor partial-finalize on interrupt** (`RuntimeExecutors.ts`
   inner catch). When `isOperationInterrupted` is true AND the
   `onText`/`onThinking`/`onToolsCalling` callbacks accumulated partial
   content, do an extra `messageModel.update` before rethrowing. This
   makes the DB row carry the real partial content, so a later reload
   shows the streamed answer instead of an empty placeholder.

2. **Coordinator skips uiMessages on interrupted** (`AgentRuntimeCoordinator.ts`
   `resolveUiMessages`). Short-circuit when `state.status === 'interrupted'`
   so the agent_runtime_end payload omits `uiMessages` entirely. The
   executor's partial-finalize update from (1) is racy with this publish
   path — leaving the field undefined lets the client preserve its
   in-memory state instead of pulling whatever's in DB at publish time.

3. **Client skips DB refetch on `reason='interrupted'`** (`gatewayEventHandler.ts`
   agent_runtime_end case). The existing fallback at L540 does a
   `fetchAndReplaceMessages` whenever uiMessages is absent, which would
   defeat fix (2) by reading the still-pre-finalize DB row. Add a
   third branch: when reason='interrupted' AND no uiMessages, keep the
   in-memory state — the next explicit refresh (route change, user-driven
   mutate, page reload) will pick up the finalized partial content from
   (1).

Test matrix (5 new tests):
- `RuntimeExecutors`: persists on interrupt-with-content / skips on
  empty-interrupt / skips on non-interrupt error
- `AgentRuntimeCoordinator`: resolver not called on saveAgentState /
  saveStepResult when status='interrupted'
- `gatewayEventHandler`: no refetch + no replaceMessages when reason=
  'interrupted' and uiMessages absent / SoT still consumed when server
  did include uiMessages on an interrupted run (forward-compat)

Manual verification (probe dumps in `.agent-gateway/`):
- Case A/B/C/E (clean stream, mid-stream tab-switch, post-stream
  tab-switch, post-stream reload) all remain  — no regression
- Case D (long stream → STOP) currently shows
  `cLen[gRojDUMG] 5182→3 near-event:[agent_runtime_end]` rollback;
  with this patch the client retains 5182 chars and the DB carries the
  same partial content for reload

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

* 🐛 fix(chat-store): only skip interrupt refetch after stream progressed

Reviewer caught a regression in PR #15173's agent_runtime_end change:
unconditionally skipping the DB fallback when `reason === 'interrupted'`
leaves the optimistic `tmp_*` placeholder messages stuck in the store
when cancel arrives BEFORE any server state landed (no step_start, no
stream_start with server id, no chunks). Previously the fallback
`fetchAndReplaceMessages` cleaned those up by replacing them with the
server-side rows.

Track `hasStreamedContent` in the handler closure and flip it to true on:
- `stream_start` switching to a server-assigned assistant id
- `stream_chunk` dispatching text / reasoning / tools_calling

Gate the interrupted-skip on this flag:
- `hasStreamedContent === true`  → keep in-memory state (mid-stream cancel)
- `hasStreamedContent === false` → fall back to refetch (cancel-before-stream)

New test for the cancel-before-stream path; existing
"NOT refetch when reason=interrupted" test renamed and updated to set up
prior stream activity before sending the cancel.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:13:53 +08:00
Arvin Xu 63d8e07453 chore: clean up LOBE-xx comment (2026-05-24) (#15158)
chore: 清理 LOBE-9110 代码注释 (2026-05-24)

- 将 RuntimeExecutors / AgentRuntimeService / OperationTraceRecorder 中的 "See LOBE-9110" 引用替换为实际的架构决策说明
- 补充 context engine payload 脱离 Redis state pipeline 的上下文(Upstash 10MB 限制根因)
- 保留 WelcomeText 中的 /LOBE-\d+/ 正则(功能性代码,用于动态内容自动链接)

Co-authored-by: Arvin Xu <arvinx@lobehub.com>
2026-05-25 00:13:14 +08:00
Arvin Xu 44e69af6cc 🐛 fix(desktop): preview .cjs/.mjs/no-ext files instead of binary fallback (#15168)
* 🐛 fix(desktop): sniff unknown extensions instead of mislabeling as binary

The local file preview pipeline used a hand-maintained extension whitelist
in `apps/desktop/src/main/utils/mime.ts` and fell back to
`application/octet-stream` for anything unmapped. `.cjs`, `.mjs`,
`.editorconfig`, `.lock`, and any other extension not in the table got
classified as binary by the renderer and showed "二进制文件 — 无法预览",
even though the contents were plain text.

Add `resolveLocalFileMimeType(filePath, buffer)`: whitelist hit first for
known source/image extensions; otherwise run `sniffBinaryBuffer` (from
`@lobechat/file-loaders`, already a desktop dep) on the first 8KB.
Text → `text/plain; charset=utf-8`, binary → `application/octet-stream`.
`getExportMimeType` is left untouched for `RendererProtocolManager`
because the bundled-asset extension set there is closed.

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

* 🐛 fix(desktop): short-circuit known-binary extensions before sniff

The sniff fallback in `resolveLocalFileMimeType` only flags a buffer as
binary on a null byte or >30% non-printable chars in the first 8KB. PDF
files (and many archives/executables/media containers) start with a long
printable-ASCII prefix — header + xref + dictionary for PDF — so the sniff
returns text and the renderer hands the buffer to the text highlighter,
producing garbled output and unnecessary decode cost.

Add a `KNOWN_BINARY_EXTENSIONS` set checked before the sniff. Common
binary formats (PDF, zip/tar/gz/7z, exe/dll/dylib/so/wasm, audio/video,
sqlite, design files) short-circuit to `application/octet-stream`. The
set is intentionally narrow — uncommon binary blobs with early null bytes
still fall through to the sniff.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:02:40 +08:00
Arvin Xu eedf46a11d ♻️ refactor(agent-runtime): route desktop callers through device-gateway (#15157)
Removes the Phase 6.4 `clientRuntime === 'desktop'` short-circuit so the
desktop UI, web UI, and IM/Bot callers all converge on a single tool
dispatch path: the device-gateway proxy to a registered device. The
Agent Gateway WS-back-to-caller mechanism is deprecated.

This is the second half of LOBE-9378. PR #15087 fixed the IM/Web
single-online-device auto-activate so `deviceSystemInfo` was fetched
and the `<user_context>` Mustache template substituted (`{{hostname}}`,
`{{workingDirectory}}`, `{{homePath}}`). But on cloud canary the desktop
Electron client took the Phase 6.4 branch instead — `lobe-local-system`
was enabled via `hasClientExecutor` and `executor:'client'` was stamped
on the manifest, bypassing both `activeDeviceId` resolution AND
`fetchDeviceSystemInfoForTemplate`. So `state.metadata.deviceSystemInfo`
stayed undefined and the literal `{{workingDirectory}}` reached the LLM
even after the LOBE-9378 fix shipped. With this refactor, the desktop
client registers with device-gateway like the CLI does, gets picked up
by `queryDeviceList`, auto-activates as the single online device, and
the existing template substitution kicks in unchanged.

Changes:
- AgentToolsEngine: drop `hasClientExecutor` / `clientRuntime` param.
  `platform` is now `hasDeviceProxy ? 'desktop' : 'web'`. LocalSystem
  enable rule is the single device-gateway path; RemoteDevice no longer
  has the `!hasClientExecutor` carve-out.
- aiAgent.execAgent: drop `clientRuntime` param. `shouldDispatchToClient`
  collapses to `!gatewayConfigured`, preserving the standalone-Electron
  path where there is no gateway and tools run in-process.
- tRPC input + shared types (`packages/types/src/agentExecution`,
  `src/services/aiAgent.ts`) drop the `clientRuntime` field.
- Store: stop sending `clientRuntime: isDesktop ? 'desktop' : 'web'`.
- Tests: remove the Phase 6.4 describe blocks and the
  `clientRuntime`-forwarding tests; add coverage that local-system /
  stdio MCP `executor` stays unset when the gateway is configured so
  routing goes through Remote Device.
- `executors` doc on builtin tool manifests rewritten to describe the
  remaining standalone path (no more "client dispatched via Agent
  Gateway WS").

The unrelated `clientRuntimeStart` / `clientRuntimeComplete` agent
signal source-types are about run lifecycle events, not request runtime,
and are untouched.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 23:01:12 +08:00
YuTengjing ff61f4b3fa 💄 style: add Qwen3.7 Max locale (#15150) 2026-05-24 21:49:34 +08:00
Innei 192111840c 💄 style(workflow): normalize block spacing (#15169) 2026-05-24 20:17:30 +08:00
Arvin Xu 837a3daa58 feat(chat): consume gateway uiMessages snapshot as SoT at step boundaries (#15153)
* ♻️ refactor(chat-store): useFetchMessages accepts options object

LOBE-9501

Replace the positional `skipFetch?: boolean` second argument with an
`options?: { skipFetch?, revalidateOnFocus? }` object on both
`useChatStore.useFetchMessages` and `useConversationStore.useFetchMessages`.
Plumb `revalidateOnFocus` through to the underlying SWR config so callers
can suppress focus revalidate per-call (default behaviour unchanged).

Mechanically migrate all 7 call sites to the new shape. No behaviour
change in this commit — the streaming-aware `revalidateOnFocus: false`
follow-up lives in the next commit.

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

*  feat(chat): consume gateway uiMessages snapshot as SoT at step boundaries

LOBE-9501

Server attaches the canonical UIChatMessage[] snapshot to step_start and
agent_runtime_end events (#15152). The client now uses that pushed payload
as the source of truth instead of refetching from DB:

- step_start handler calls replaceMessages(uiMessages, { context }) when
  the snapshot is present, so the assistant tab-switch / next-step path
  no longer issues a refetch that returns a stale assistant placeholder.
- agent_runtime_end handler does the same for the terminal step — the
  last step has no later step_start to carry a fresh snapshot, so this
  branch is the only one that reconciles the final commit.
- step_complete on phase=tool_execution stops calling refreshMessages.
  That refetch was the direct cause of the assistantGroup→assistant
  clobber regression captured by the agent-gateway probe scripts.
- ChatList disables SWR revalidateOnFocus while the current topic is
  streaming (via operationSelectors.isAgentRuntimeRunningByContext) and
  automatically restores it after the run ends. Tab-focus during a run
  no longer triggers the stale DB read.

Doesn't touch streamingExecutor.ts (homogeneous runtime — parallel path).

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

* 🐛 fix(chat-store): wire gateway handler to consume server-pushed uiMessages SoT

LOBE-9501

#15152 (server) attaches the canonical UIChatMessage[] snapshot to both
the Redis SSE channel and the gateway /push-event channel. The earlier
client patch wired the consumer into `runAgent.ts`, but that file only
runs on the Group Chat SSE path. The actual gateway entry point
(`createGatewayEventHandler` in `gatewayEventHandler.ts`, used by single
agent, sub-agent, and hetero-CLI flows) ignored the field entirely and
kept refetching from DB.

Fix the gateway handler:

- step_start: consume `event.data.uiMessages` and replaceMessages with
  the pushed SoT. Skipped when absent — hetero adapters don't emit
  step_start at all (HeterogeneousEventType excludes it), so the new
  branch is invisible to hetero.

- agent_runtime_end: same SoT consumption; the existing
  `fetchAndReplaceMessages` becomes the fallback for events without the
  field. Claude Code adapter emits agent_runtime_end with empty data,
  so hetero terminal behavior is preserved by the fallback.

- stream_start: gate the DB fetch on `!newAssistantMessageId`. Native
  gateway streams carry `assistantMessage.id` (the preceding step_start
  also delivered the SoT), so the await is unnecessary — AND it was
  blocking the enqueue chain. Live chunks queued behind that await
  could not dispatch, which manifested as "streaming content never
  lands in messagesMap" during tab-switch and slow-network repros.
  Hetero CLI streams never set `assistantMessage.id`, so the fetch
  still runs for them on every stream_start.

Verified with the agent-gateway probe (separate commit): chunks now
land in real time (cLen grows 3 → 529 monotonically), and tab-switch
mid-stream no longer rolls the streamed assistantGroup back to the
LOADING placeholder (ROLLBACKS=none in the analyzer output).

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

* 🧪 chore(local-testing): rewrite agent-gateway probes in TS + add CLI

LOBE-9501

Convert the local-testing agent-gateway probes from .js/.mjs to TypeScript
and add a unified `run.ts` CLI that bundles via Bun.build (no extra
deps) and persists dumps to a gitignored `.agent-gateway/` directory for
use as streaming-replay test fixtures.

- types.ts: shared dump shape (ProbeStreamEvent / ProbeTimelineSample /
  ProbeDump) and `declare global` for the `window.__PROBE_*` surface
- probe-events.ts: WebSocket + fetch interception (gateway WS captures
  any socket with `operationId=`; fetch captures `/api/agent/stream` for
  direct SSE). Per-key timeline samples every 200ms so we can see
  which messagesMap key streaming chunks actually land in
- probe-dump.ts: stops the timeline timer and stashes JSON dump on
  `window.__PROBE_LAST_DUMP_JSON` (runner returns that global)
- analyze-events.ts: stream events (non-chunk) + chunks summary +
  action-call stacks + correlation + per-key assistant growth +
  rollback detection. Per-key growth was added specifically to
  diagnose "chunks arrive but assistant cLen never moves"
- run.ts: `install` | `dump [name]` | `analyze [path]` CLI. Bundles via
  Bun.build, wraps as IIFE with explicit return, pipes to
  `agent-browser eval --stdin`. Dumps land at
  `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`

`.agent-gateway/` is gitignored so dumps accumulate across debugging
sessions without polluting git.

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

* 🐛 fix(local-testing): repair run.ts after autofix mangled path imports

LOBE-9501

The eslint --fix run during the previous commit applied the unicorn
`import-style` rule and renamed every `join(` / `dirname(` / `resolve(`
to `path.join(` / `path.dirname(` / `path.resolve(`, but the replacement
was a naive text substitution that:

1. rewrote `array.join('\n')` to `array.path.join('\n')` — broke bundle
   error reporting (would TypeError on the build-failure path)
2. produced `const path = path.join(DUMP_DIR, filename)` inside cmdDump
   — shadowed the `path` module with itself, ReferenceError on every
   dump invocation

Rename the local `path` to `dumpPath` and drop the spurious `.path`
prefix on the array `.join`. Verified round-trip: install + dump now
write a valid capture to `.agent-gateway/`.

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

* 🧪 chore(local-testing): capture per-call message snapshot in probe

LOBE-9501

The probe's `replaceMessages` wrapper used to record only `count` and
`params` — enough to see "two messages were written" but not WHICH two.
For post-stream collapse debugging we need to see whether each call
restored streamed content (cLen=N) or wiped to LOADING_FLAT (cLen=3).

Two changes:

- Capture `snapshot` field on every replaceMessages call: last 2
  messages' id / role / cLen / rLen / updatedAt. The analyzer prints
  this inline next to each call so reviewers can see content drift /
  collapse without re-reading the dump.

- Make wrapping idempotent across re-installs. The old guard
  `chat.__probeWrapped = true` froze the first-installed wrapper across
  re-installs, so updates to the probe body had no effect without a
  page reload. Stash the originals on
  `window.__PROBE_ORIG_REFRESH_MESSAGES` /
  `window.__PROBE_ORIG_REPLACE_MESSAGES` and re-wrap from those on
  every install.

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

* 🧪 chore(local-testing): add mutation log + dispatchMessage wrap to probe

LOBE-9501

The replaceMessages-only wrap couldn't catch chunk-level writes (those go
through internal_dispatchMessage) or attribute post-stream collapses to a
specific writer. Add:

- `__PROBE_MUTATIONS` — unified ordered log of every dbMessagesMap[key]
  reference change, with `last`/`prevLast` summaries and a `delta` field
  that tags interesting transitions (`cLen↓N→M`, `rLen↓`, `id:A→B`,
  `n↓prev→cur`). Both writers — replaceMessages AND internal_dispatchMessage
  — push to the same buffer so a single timeline shows all stores writes.

- Idempotent action wrapping. Originals are stashed on
  `window.__PROBE_ORIG_*` and re-wrapped from there on every install, so
  probe edits take effect without a page reload (previous
  `chat.__probeWrapped` flag froze the first wrapper).

- Snapshot field on replaceMessages — last 2 messages'
  id/role/cLen/rLen/updatedAt — so reviewers can see WHICH content each
  call is writing instead of just the count.

- Dump file now carries the `mutations` array alongside streamEvents,
  actionCalls, timeline.

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

* 🐛 fix(chat-store): gate SWR onData by isStreaming for streaming topic

LOBE-9501

Backstop for the post-stream cLen collapse that survives even with the
gateway SoT consume in place. Reproduction (confirmed):

1. Send a stream that lands lots of WS chunks into ChatStore
2. Immediately reload the page

If the page reload races against server-side chunk fan-out into Postgres,
SWR's fresh fetch returns the assistant row in its LOADING_FLAT placeholder
state (cLen=3) and writes that to ChatStore via the conversation-store
mirror — even though the WS push at agent_runtime_end carried the
correct full content moments earlier.

`mergeFetchedMessagesWithLocalState`'s updatedAt tie-breaker handles
this for in-session repros (local message wins when its updatedAt is
newer), but it degenerates when:

- The SoT consume just wrote server's snapshot updatedAt onto the local
  message, equalising the timestamps so the next stale DB fetch wins
- The user reloads (no local state to merge against — fresh fetch wins
  outright)

Add a gate at the bottom of `ConversationStore.useFetchMessages.onData`:
while `isAgentRuntimeRunningByContext(context)` is true, drop the SWR
write entirely. SWR's own cache still updates, so once streaming ends a
normal revalidate writes through correctly.

This is layered defense — it does NOT fix the underlying server-side
fan-out lag (filed as separate Linear issue). It does prevent the
client-side flash users currently see during the lag window.

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

* 🧪 test(chat-store): align gateway handler tests with SoT contract

The previous assertions still expected `stream_start` to issue a DB refetch
on every native gateway stream — the very behaviour LOBE-9501 removes
(`acb9523a04`). Update the three failing cases to the new contract:

- `stream_start > should associate new message with operation`:
  assert `messageService.getMessages` is NOT called when
  `assistantMessage.id` is present (the SoT snapshot from the preceding
  `step_start` already pre-populated `dbMessagesMap`).
- `sequential processing`: rewrite around the surviving ordering guarantee
  — `associate` (stream_start) must precede `dispatch` (stream_chunk) so
  the chunk targets the new id. Add a sibling case for hetero CLI streams
  (no `assistantMessage.id` → DB fetch is still mandatory).
- `multi-step integration > full LLM → tools → LLM cycle`: keep the
  post-`tool_end` `replaceMessages` assertion (tool_end still refreshes
  from DB), invert the post-`stream_start` assertion for step 2.

42 tests passing (was 41 + 1 new hetero fallback test).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:05:58 +08:00
AmAzing- 5f6f053039 🐛 fix(agent): hide community publish for heterogeneous agents (#15166) 2026-05-24 18:39:05 +08:00
AmAzing- 775be47513 🐛 fix(agent): align settings defaults and locale state (#15163) 2026-05-24 16:29:22 +08:00
Arvin Xu 2f265a9307 🐛 fix(conversation): only swap model name for remote hetero agents in Usage (#15156)
* 🐛 fix(conversation): only swap model name for remote hetero agents in Usage

Local CLI hetero agents (claude-code, codex) report their actual model
id on `turn_metadata` and persist it on the assistant message, but the
Usage extra was unconditionally replacing it with the provider brand
label ("Claude Code" / "Codex") whenever `HETEROGENEOUS_TYPE_LABELS`
had an entry. Gate the swap to remote platform agents (openclaw,
hermes) — those don't expose a real model id — so CC/Codex turns show
the underlying model again.

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

*  test(desktop): update GatewayConnectionCtr tests for lh hetero exec route

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:08:21 +08:00
Arvin Xu 0fa2e2349c 🐛 fix(desktop): route gateway agent runs through lh hetero exec (#15132)
* feat(desktop): route gateway agent runs through lh hetero exec

Replace the desktop-side GatewayConnectionCtr.executeAgentRun() flow
(startSession -> sendPrompt with local AgentStreamPipeline) with a direct
lh hetero exec spawn. The lh CLI handles spawn -> adapt -> BatchIngester ->
heteroIngest/heteroFinish, matching the cloud sandbox path exactly.

Changes:
- HeterogeneousAgentCtr: add spawnLhHeteroExec() method
- GatewayConnectionCtr: executeAgentRun() now delegates to the new method

* 🐛 fix(desktop): remove duplicate lh token from hetero exec args

spawn('lh', args) already invokes the lh binary, so the leading 'lh'
in args made the effective command `lh lh hetero exec ...` and failed
before heteroIngest could run, breaking the gateway-triggered agent
run flow.

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

---------

Co-authored-by: LobeHub Agent <agent@lobehub.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 02:54:00 +08:00
Arvin Xu 930344ae23 feat(agent-runtime): push UIChatMessage snapshot at gateway step boundaries (#15152)
* 🧪 chore(local-testing): add agent-gateway probe scripts for stream SoT validation

Probe + tab-switch + analyzer scripts under .agents/skills/local-testing/scripts/agent-gateway/
to capture in-browser snapshots of the message store during gateway streaming and detect
regressions where assistantGroup messages get clobbered by stale DB refetches.

Used to verify LOBE-9501.

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

*  feat(agent-runtime): push canonical UIChatMessage snapshot at step boundaries

LOBE-9501

Gateway-mode streaming previously let the client refetch from DB on every
step_complete or tab-focus; with stream chunks landing before the DB write
fans out, the refetch returned a stale assistant placeholder that clobbered
the in-memory streamed assistantGroup (reasoning / tool calls / content).

Server now attaches the canonical UIChatMessage[] snapshot to step_start
and agent_runtime_end events so the client can use the pushed payload as
Source of Truth instead of refetching:

- step_start now loads agent state first, queries messages, and attaches
  uiMessages to the event data when topic context is known
- publishAgentRuntimeEnd signature switched to a params object (additive
  uiMessages field) and the coordinator resolves the snapshot through an
  optional uiMessagesResolver hook before publishing terminal events
- AgentRuntimeService wires the resolver through a lazily-instantiated
  MessageService so tests without S3 env still construct cleanly
- MessageService.queryMessages exposes the same read path as the
  message.getMessages trpc lambda (FileService postProcessUrl included)

Pure additive on the wire: legacy consumers see new uiMessages field, old
finalState payload unchanged. Existing call sites in agentNotify and
aiAgent migrated to the params shape. Failures in the resolver fall back
to publishing without uiMessages so streaming never fails the step.

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

* 🐛 fix(agent-runtime): forward uiMessages in gateway /push-event payload

LOBE-9501

GatewayStreamNotifier.publishAgentRuntimeEnd was delegating uiMessages to
the inner manager (Redis SSE) but reconstructing its own push-event data
object that only carried { errorType, finalState, reason, reasonDetail }.
In gateway mode, clients consume /push-event rather than Redis directly,
so the canonical UIChatMessage[] snapshot never reached them at terminal
state — and the final step has no later step_start to carry a fresh one.

Forward uiMessages via the same conditional-spread pattern used in the
inner managers; add two tests covering the present/absent branches.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:23:21 +08:00
Arvin Xu 538195dfb4 🐛 fix(agent-runtime): route context engine payload out of the events stream (#15151)
* 🐛 fix(agent-runtime): route context engine payload out of the events stream

`call_llm` previously pushed a `context_engine_result` event carrying the
full `contextEngineInput` (agentDocuments, systemRole, knowledge, …) into
the per-step events array. That array is the same one persisted into
Redis `agent_runtime_events`, so every step shipped the heavy CE payload
into the state pipeline even though the only consumer was the trace
recorder, which extracted CE into the typed `contextEngine` snapshot
field and immediately filtered the event back out.

Wire a typed `recordContextEngine` callback through
`RuntimeExecutorContext` instead. `AgentRuntimeService.executeStep`
buffers the call per step and hands it to
`OperationTraceRecorder.appendStep` via a new `contextEngine` param.
Trace snapshots are byte-identical; the events stream — and therefore
the Redis state blob — no longer carries CE.

Step toward LOBE-9110 (split state vs trace pipeline). Viewer keeps
the legacy `context_engine_result` reader for back-compat with older
on-disk snapshots.

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

* 🎨 refactor(agent-runtime): rename recordContextEngine to tracingContextEngine

The callback name now signals its role as the trace-pipeline channel,
matching the `tracing` prefix used elsewhere for non-state observability
wiring. Pure rename, no behavior change.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 01:14:12 +08:00
672 changed files with 26905 additions and 8577 deletions
+6 -5
View File
@@ -14,7 +14,7 @@ In `NODE_ENV=development`, `AgentRuntimeService.executeStep()` automatically rec
**Data flow**: executeStep loop -> build `StepPresentationData` -> write partial snapshot to disk -> on completion, finalize to `.agent-tracing/{timestamp}_{traceId}.json`
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor emits a `context_engine_result` event after `serverMessagesEngine()` processes messages. This event carries the full `contextEngineInput` (DB messages, systemRole, model, knowledge, tools, userMemory, etc.) and the processed `output` messages (the final LLM payload).
**Context engine capture**: In `RuntimeExecutors.ts`, the `call_llm` executor calls `ctx.tracingContextEngine(input, output)` after `serverMessagesEngine()` processes messages. `AgentRuntimeService.executeStep` buffers the call per step and forwards it to `OperationTraceRecorder.appendStep` as the typed `contextEngine` field. CE flows through this side channel rather than the `events` array so its heavy payload (agentDocuments, systemRole, …) never enters the Redis state pipeline (LOBE-9110).
## Package Location
@@ -199,9 +199,10 @@ interface StepSnapshot {
messages?: any[]; // DB messages before step
context?: { phase: string; payload?: unknown; stepContext?: unknown };
events?: Array<{ type: string; [key: string]: unknown }>;
// context_engine_result event contains:
// input: full contextEngineInput (messages, systemRole, model, knowledge, tools, userMemory, ...)
// output: processed messages array (final LLM payload)
contextEngine?: {
input?: unknown; // contextEngineInput minus messages + toolsConfig (reconstructible from baseline)
output?: unknown; // processed messages array (final LLM payload)
};
}
```
@@ -216,5 +217,5 @@ When using `--messages`, the output shows three sections (if context engine data
## Integration Points
- **Recording**: `src/server/services/agentRuntime/AgentRuntimeService.ts` — in the `executeStep()` method, after building `stepPresentationData`, writes partial snapshot in dev mode
- **Context engine event**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, emits `context_engine_result` event
- **Context engine capture**: `src/server/modules/AgentRuntime/RuntimeExecutors.ts` — in `call_llm` executor, after `serverMessagesEngine()` returns, calls `ctx.tracingContextEngine(input, output)`. `AgentRuntimeService.executeStep` buffers it per step and passes it to `traceRecorder.appendStep` as the typed `contextEngine` field (kept off the `events` array to stay out of Redis state).
- **Store**: `FileSnapshotStore` reads/writes to `.agent-tracing/` relative to `process.cwd()`
+1 -8
View File
@@ -1,13 +1,6 @@
---
name: chat-sdk
description: >
Build multi-platform chat bots with Chat SDK (`chat` npm package). Use when developers want to
(1) Build a Slack, Teams, Google Chat, Discord, GitHub, or Linear bot,
(2) Use the Chat SDK to handle mentions, messages, reactions, slash commands, cards, modals, or streaming,
(3) Set up webhook handlers for chat platforms,
(4) Send interactive cards or stream AI responses to chat platforms.
Triggers on "chat sdk", "chat bot", "slack bot", "teams bot", "discord bot", "@chat-adapter",
building bots that work across multiple chat platforms.
description: "Build multi-platform chat bots with the Chat SDK (`chat` npm package) — Slack, Teams, Google Chat, Discord, GitHub, Linear. Use when building a chat bot, handling mentions / messages / reactions / slash commands / cards / modals / streaming, setting up a webhook handler, or sending interactive cards / streaming AI responses to a chat platform. Triggers on `@chat-adapter`, 'chat sdk', 'chat bot', 'slack bot', 'teams bot', 'discord bot', 'webhook handler', 'cross-platform bot'."
user-invocable: false
---
@@ -1,6 +1,6 @@
---
name: data-fetching
description: Data fetching architecture guide using Service layer + Zustand Store + SWR. Use when implementing data fetching, creating services, working with store hooks, or migrating from useEffect. Triggers on data loading, API calls, service creation, or store data fetching tasks.
name: data-fetching-architecture
description: Standardized data-fetching pipeline guide Service layer + Zustand Store + SWR. Use when implementing a data-fetching feature, creating a `xxxService`, adding a `useFetchXxx` hook, wiring `useClientDataSWR`, or migrating ad-hoc `useEffect + fetch` to the standard pipeline. Triggers on `lambdaClient`, `useClientDataSWR`, `xxxService`, `useFetchXxx`, 'data fetching', 'fetch architecture', 'service layer', 'SWR hook', 'migrate useEffect'.
user-invocable: false
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: docs-changelog
description: 'Writing guide for website changelog pages under docs/changelog/*.mdx. Use when creating or editing product update posts in EN/ZH. Not for GitHub Release notes.'
description: "Writing guide for website changelog pages under `docs/changelog/*.mdx` (NOT GitHub Release notes — those live in the `version-release` skill). Use when creating or editing a product update post in EN/ZH. Triggers on `docs/changelog/*.mdx`, 'changelog post', 'product update post', 'add a changelog', '更新日志', 'changelog 文案'."
---
# Docs Changelog Writing Guide
@@ -0,0 +1,93 @@
# LobeHub gateway streaming + tab-switch test harness
Captures store + DOM state at 200ms intervals so we can prove or disprove
claims like "切回 tab 后消息回到了很早以前". Built for gateway-mode chat but
works for any LobeHub streaming session.
## Files
`scripts/agent-gateway/`
| File | Role |
| --------------- | ---------------------------------------------------------------- |
| `probe.js` | Injects a 200ms sampler + `__PROBE_EVENT` marker + `__switchTab` |
| `probe-dump.js` | Stops the sampler and returns `{events, samples}` as JSON string |
| `tab-switch.js` | Runs N round-trip switches between two tabs, marks each step |
| `analyze.mjs` | Node post-processor: timeline + regression detection |
## Standard workflow
```bash
# 1. Start Electron with CDP
./.agents/skills/local-testing/scripts/electron-dev.sh start
# 2. Navigate to a chat, switch runtime to Cloud Sandbox (gateway mode)
# 3. Install the probe + helpers
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/probe.js
# 4. Send a tool-call message — manually or via type+press
agent-browser --cdp 9222 eval "window.__PROBE_EVENT('SENT')"
# 5. Run the multi-switch driver (auto-picks active tab as BACK and the
# rightmost inactive tab as AWAY — edit ROUND_TRIPS / DWELL_MS in the
# file if you want different timing)
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/tab-switch.js
# 6. Wait for streaming to finish, then dump
agent-browser --cdp 9222 eval --stdin \
< .agents/skills/local-testing/scripts/agent-gateway/probe-dump.js \
> /tmp/probe.json
# 7. Analyze
node .agents/skills/local-testing/scripts/agent-gateway/analyze.mjs /tmp/probe.json
```
The analyzer prints three sections: EVENTS, TIMELINE, REGRESSIONS. If
REGRESSIONS is non-empty it means content/reasoning/childN dropped on the
same topic — the symptom users describe.
## What the probe tracks (and why)
`chat.messagesMap` only stores the top-level `assistantGroup` shell. The
actual streamed content, reasoning, and tool calls live in
`assistantGroup.children: AssistantContentBlock[]`. Any probe that only
reads `m.content` / `m.reasoning` will see zeros throughout streaming and
miss everything that matters. probe.js walks both levels and sums:
- `cT` total content length
- `rT` total reasoning length
- `toolT` total tool-call count
- `childN` number of content blocks
Plus DOM-side signals (`domLen`, search/crawl indicator counts) so you can
tell store-side regressions apart from render-side regressions.
## Gotchas
- **Optimistic new-topic state.** Before the first chunk lands, messages
live under the `<scope>_new` key with `tmp_*` ids and no `topicId` field.
probe.js falls back to those when `activeTopicId` is null.
- **Reasoning resets to 0 are not bugs.** When the assistant finishes
thinking and starts tool-use or text, the streaming reasoning buffer
empties and the finalised reasoning gets sealed into a completed block.
Filter these out manually if needed.
- **DOM length jitters by a handful of chars** because counters like "(10)"
in tool-call labels change as results arrive. analyze.mjs only flags
`domLen` drops greater than 100 chars to ignore that noise.
- **Never identify tabs by innerText.** The active tab's text embeds a
` · <agent name>` suffix, so a search like `'LobeHub Growth'` matches the
active tab when the active agent happens to be LobeHub Growth — and you
end up clicking the tab you're already on. probe.js uses the stable
`data-contextmenu-trigger` attribute (a React `useId()` value that's set
per-tab and survives focus changes) plus `data-active="true"` to mark
the active one. Helpers exposed:
`__listTabs()` / `__clickTabByKey(key)` / `__clickTabByIndex(i)` /
`__activeTabKey()`.
- **`tab-switch.js` fires-and-forgets.** The IIFE kicks off an async loop
and returns immediately so the agent-browser CLI eval doesn't blow past
its default 25 s timeout. Wait on the `SWITCH_LOOP_DONE` event marker
before dumping. Re-running while a loop is in flight is refused — the
chaotic data from overlapping runs is not worth debugging.
@@ -0,0 +1,243 @@
// Analyzer for probe-events dumps. Reads a JSON file produced by `run.ts dump`
// and prints a layered breakdown:
//
// 1. STREAM EVENTS — every non-chunk WS/SSE event in receipt order
// 2. CHUNKS SUMMARY — collapsed per-step chunk counts (otherwise floods)
// 3. ACTION CALLS — replaceMessages / refreshMessages / MARK:* with stack
// 4. CORRELATION — calls ↔ nearest stream event within ±300ms
// 5. PER-KEY ASSISTANT GROWTH — for each messagesMap key, when the leading
// assistant message's cLen / rLen actually moves (this is what reveals
// "chunks arrived but the message never grew" regressions)
// 6. ROLLBACKS — msgN / childN / role drops in the active-topic timeline
//
// Usage:
// bun run .agents/skills/local-testing/scripts/agent-gateway/analyze-events.ts <dump.json>
import { readFileSync } from 'node:fs';
import type {
ProbeActionCall,
ProbeDump,
ProbeMessageSummary,
ProbeStreamEvent,
ProbeTimelineSample,
} from './types';
const file = process.argv[2];
if (!file) {
console.error('usage: bun run analyze-events.ts <dump.json>');
process.exit(1);
}
const raw = readFileSync(file, 'utf8');
// agent-browser eval --stdin wraps return values in quotes when the value is
// a string — so the JSON file may be double-encoded depending on how it was
// captured. Handle both.
const parsedOnce = JSON.parse(raw) as ProbeDump | string;
const dump: ProbeDump = typeof parsedOnce === 'string' ? JSON.parse(parsedOnce) : parsedOnce;
const { streamEvents = [], actionCalls = [], timeline = [] } = dump;
const pad = (v: unknown, n: number) => String(v).padStart(n);
// ── META ───────────────────────────────────────────────────────────
console.log('=== META ===');
console.log(` events: ${streamEvents.length}`);
console.log(` calls: ${actionCalls.length}`);
console.log(` timeline: ${timeline.length}`);
// ── 1. STREAM EVENTS (non-chunk) ───────────────────────────────────
const nonChunkEvents = streamEvents.filter((e) => e.type !== 'stream_chunk');
const chunkEvents = streamEvents.filter((e) => e.type === 'stream_chunk');
console.log(
`\n=== STREAM EVENTS (${nonChunkEvents.length} non-chunk + ${chunkEvents.length} chunks elided) ===`,
);
for (const e of nonChunkEvents) {
const dataStr = e.dataKeys?.length ? ` [${e.dataKeys.join(',')}]` : '';
const data = e.data as Record<string, unknown> | undefined;
const uiHint = data?.uiMessagesPreview
? ` uiPreview=${JSON.stringify(data.uiMessagesPreview)}`
: data?.uiMessagesTotal
? ` uiTotal=${data.uiMessagesTotal}`
: '';
const phaseHint = data?.phase ? ` phase=${data.phase}` : '';
const extra = e.serverType ? ` serverType=${e.serverType}` : '';
console.log(
` t=${pad(e.t, 7)} [${(e.transport ?? '?').padEnd(3)}] step=${pad(e.stepIndex ?? '-', 2)} ` +
`type=${(e.type ?? '').padEnd(22)} op=${e.opIdTail ?? '-'}${phaseHint}${uiHint}${extra}${dataStr}`,
);
}
// ── 2. CHUNK SUMMARY ───────────────────────────────────────────────
console.log('\n=== CHUNKS SUMMARY (per step / chunkType) ===');
const chunkBuckets = new Map<string, { count: number; firstT: number; lastT: number }>();
for (const c of chunkEvents) {
const data = c.data as Record<string, unknown> | undefined;
const ct = (data?.chunkType as string | undefined) ?? '?';
const key = `step=${c.stepIndex ?? '-'} chunkType=${ct.padEnd(8)} op=${c.opIdTail}`;
const slot = chunkBuckets.get(key);
if (slot) {
slot.count += 1;
slot.lastT = c.t;
} else {
chunkBuckets.set(key, { count: 1, firstT: c.t, lastT: c.t });
}
}
for (const [k, v] of chunkBuckets) {
console.log(` ${k} count=${pad(v.count, 4)} t=${pad(v.firstT, 7)}..${pad(v.lastT, 7)}`);
}
// ── 3. ACTION CALLS ───────────────────────────────────────────────
console.log('\n=== ACTION CALLS (replace/refresh/MARK) ===');
for (const c of actionCalls) {
if (c.name?.startsWith('MARK:')) {
console.log(` t=${pad(c.t, 7)} ${c.name}`);
continue;
}
const snapshot = (c.args as any)?.snapshot as
| Array<{ id: string; role: string; cLen: number; rLen: number }>
| undefined;
const snapStr = snapshot?.length
? ' snapshot=' + snapshot.map((m) => `${m.id}:${m.role}/c${m.cLen}/r${m.rLen}`).join(' | ')
: '';
const summary =
c.name === 'replaceMessages'
? `count=${c.args?.count} action=${(c.args?.params as any)?.action ?? '-'}${snapStr}`
: c.name === 'refreshMessages'
? `ctx=${JSON.stringify(c.args?.context)}`
: c.error
? `error=${c.error}`
: '';
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(20)} ${summary}`);
if (c.stack) {
const frames = c.stack
.split(' ← ')
.filter((f) => !!f && !f.includes('Object.<anonymous>'))
.slice(0, 3);
for (const f of frames) console.log(`${f}`);
}
}
// ── 4. CORRELATION ────────────────────────────────────────────────
function nearestEventForCall(
call: ProbeActionCall,
windowMs = 300,
): { event: ProbeStreamEvent; delta: number } | null {
let best: ProbeStreamEvent | null = null;
let bestDelta = Infinity;
for (const e of streamEvents) {
const d = Math.abs(e.t - call.t);
if (d < bestDelta && d <= windowMs) {
bestDelta = d;
best = e;
}
}
return best ? { event: best, delta: bestDelta } : null;
}
console.log('\n=== CORRELATION (replace/refresh ↔ nearest event within ±300ms) ===');
for (const c of actionCalls) {
if (c.name !== 'refreshMessages' && c.name !== 'replaceMessages') continue;
const hit = nearestEventForCall(c);
if (hit) {
const phase = (hit.event.data as Record<string, unknown> | undefined)?.phase;
console.log(
` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← Δ${pad(hit.delta, 4)}ms ${hit.event.type}` +
(phase ? ` phase=${phase}` : ''),
);
} else {
console.log(` t=${pad(c.t, 7)} ${c.name.padEnd(16)} ← (no event nearby — external trigger)`);
}
}
// ── 5. PER-KEY ASSISTANT GROWTH ───────────────────────────────────
// For each messagesMap key, find the trailing assistant message and report
// the points in time where its cLen / rLen actually changed. If the timeline
// shows chunks arriving but the assistant cLen never moves, that's the
// signature of "dispatch queue blocked / messageId mismatch".
console.log('\n=== PER-KEY ASSISTANT GROWTH ===');
const keysEverSeen = new Set<string>();
for (const s of timeline) for (const k of Object.keys(s.byKey ?? {})) keysEverSeen.add(k);
for (const key of keysEverSeen) {
console.log(`\n key=${key}`);
let lastSig: string | null = null;
for (const s of timeline) {
const slot = s.byKey?.[key];
if (!slot) continue;
const last = slot.msgs.at(-1) as ProbeMessageSummary | undefined;
if (!last) continue;
const sig = `${last.id}|c${last.cLen}|r${last.rLen}|n${slot.n}`;
if (sig === lastSig) continue;
lastSig = sig;
console.log(
` t=${pad(s.t, 7)} msgN=${pad(slot.n, 3)} ` +
`lastAssistant=${last.id} cLen=${pad(last.cLen, 5)} rLen=${pad(last.rLen, 5)}` +
` runOps=${s.runOps}`,
);
}
}
// ── 6. ROLLBACKS (active-topic msgN / childN / role drops) ─────────
console.log('\n=== ROLLBACKS (active-topic msgN / childN / role drops) ===');
let prev: ProbeTimelineSample | null = null;
const rollbacks: Array<{ t: number; topic: string | null; drops: string[] }> = [];
const flatten = (s: ProbeTimelineSample) => {
if (!s.activeTopic) return [];
return Object.entries(s.byKey ?? {})
.filter(([k]) => k.includes(s.activeTopic!))
.flatMap(([, v]) => v.msgs);
};
for (const s of timeline) {
if (s.err) {
prev = null;
continue;
}
if (!prev || prev.activeTopic !== s.activeTopic) {
prev = s;
continue;
}
const prevMsgs = flatten(prev);
const curMsgs = flatten(s);
const drops: string[] = [];
if (curMsgs.length < prevMsgs.length) drops.push(`msgN ${prevMsgs.length}${curMsgs.length}`);
let prevChild = 0;
let curChild = 0;
for (const m of prevMsgs) prevChild += m.chN ?? 0;
for (const m of curMsgs) curChild += m.chN ?? 0;
if (curChild < prevChild) drops.push(`childN ${prevChild}${curChild}`);
const prevById = new Map(prevMsgs.map((m) => [m.id, m]));
for (const m of curMsgs) {
const pr = prevById.get(m.id);
if (!pr) continue;
if (m.cLen < pr.cLen) drops.push(`cLen[${m.id}] ${pr.cLen}${m.cLen}`);
if (m.rLen < pr.rLen) drops.push(`rLen[${m.id}] ${pr.rLen}${m.rLen}`);
}
if (drops.length) rollbacks.push({ t: s.t, topic: s.activeTopic, drops });
prev = s;
}
if (rollbacks.length === 0) {
console.log(' (none)');
} else {
for (const r of rollbacks) {
const nearEvent = streamEvents
.filter((e) => Math.abs(e.t - r.t) <= 300)
.map((e) => `${e.type}${(e.data as any)?.phase ? ':' + (e.data as any).phase : ''}`);
const nearCall = actionCalls
.filter((c) => Math.abs(c.t - r.t) <= 300 && !c.name?.startsWith('MARK:'))
.map((c) => c.name);
console.log(
` t=${pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}` +
(nearEvent.length ? ` near-event:[${nearEvent.join(',')}]` : '') +
(nearCall.length ? ` near-call:[${nearCall.join(',')}]` : ''),
);
}
}
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Analyze a probe dump captured by probe.js + probe-dump.js.
//
// node analyze.mjs /tmp/probe.json
//
// Prints:
// 1. EVENTS — user-action markers with their relative timestamps
// 2. TIMELINE — periodic samples (~1 per second + event-adjacent samples)
// showing every interesting field; columns:
// t(ms) | runOps | msgN | childN | content | reasoning | tools | domLen | search | crawl | topic | event
// 3. REGRESSIONS — every place a tracked counter *dropped* on the same
// topic between adjacent samples. A "true" UI rollback shows up as a
// drop in content/reasoning/tools/childN/domLen without a topic change.
//
// Whitelisted transitions (not flagged):
// - topic change → all drops expected (focus moved away)
// - reasoning length 0 after content starts → reasoning gets sealed into a
// completed sub-block; the parent's running reasoning resets to ''.
// - msgN drop when topic transitions from `_new` placeholder to a real id.
import fs from 'node:fs';
const file = process.argv[2];
if (!file) {
console.error('usage: node analyze.mjs <probe.json>');
process.exit(1);
}
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
// probe-dump.js wraps the payload in JSON.stringify so agent-browser returns
// it as a single quoted string. Unwrap.
const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
const { events, samples } = data;
const fmt = {
pad(v, n) {
return String(v).padStart(n);
},
};
console.log('=== EVENTS ===');
for (const e of events) console.log(` t=${fmt.pad(e.t, 7)} ${e.name}`);
console.log(
'\n=== TIMELINE (~1s cadence, plus event-adjacent samples) ===\n' +
' t(ms) runOps msgN childN content reasoning tools domLen search crawl topic event',
);
let lastSampledAt = -1e9;
const eventBuckets = events.map((e) => e.t);
for (let i = 0; i < samples.length; i++) {
const s = samples[i];
const nearEvent = eventBuckets.some((et) => Math.abs(et - s.t) < 110);
if (!nearEvent && s.t - lastSampledAt < 1000) continue;
lastSampledAt = s.t;
const ev = events.find((e) => Math.abs(e.t - s.t) < 110);
const evMarker = ev ? `${ev.name}` : '';
const topicSuffix = s.topicId ? s.topicId.slice(-6) : '(none)';
const search = s.ind?.search ?? 0;
const crawl = s.ind?.crawl ?? 0;
console.log(
` ${fmt.pad(s.t, 6)} ` +
`${fmt.pad(s.runOps, 6)} ` +
`${fmt.pad(s.msgN, 4)} ` +
`${fmt.pad(s.childN ?? 0, 5)} ` +
`${fmt.pad(s.cT ?? 0, 8)} ` +
`${fmt.pad(s.rT ?? 0, 9)} ` +
`${fmt.pad(s.toolT ?? 0, 5)} ` +
`${fmt.pad(s.domLen ?? 0, 7)} ` +
`${fmt.pad(search, 6)} ` +
`${fmt.pad(crawl, 5)} ` +
`${topicSuffix.padEnd(8)}${evMarker}`,
);
}
console.log('\n=== REGRESSIONS (same topic, value dropped) ===');
const regressions = [];
for (let i = 1; i < samples.length; i++) {
const prev = samples[i - 1];
const cur = samples[i];
if (!cur.topicId || prev.topicId !== cur.topicId) continue;
const drops = [];
if (cur.msgN < prev.msgN) drops.push(`msgN: ${prev.msgN}${cur.msgN}`);
if ((cur.childN ?? 0) < (prev.childN ?? 0)) drops.push(`childN: ${prev.childN}${cur.childN}`);
if ((cur.cT ?? 0) < (prev.cT ?? 0)) drops.push(`content: ${prev.cT}${cur.cT}`);
if ((cur.rT ?? 0) < (prev.rT ?? 0)) drops.push(`reasoning: ${prev.rT}${cur.rT}`);
if ((cur.toolT ?? 0) < (prev.toolT ?? 0)) drops.push(`tools: ${prev.toolT}${cur.toolT}`);
// domLen jitters by a few chars from counter labels — only flag big drops.
if ((cur.domLen ?? 0) < (prev.domLen ?? 0) - 100) {
drops.push(`domLen: ${prev.domLen}${cur.domLen}`);
}
if (drops.length === 0) continue;
const nearbyEv = events.filter((e) => Math.abs(e.t - cur.t) < 600).map((e) => e.name);
regressions.push({ t: cur.t, topic: cur.topicId.slice(-6), drops, nearbyEv });
}
if (regressions.length === 0) {
console.log(' (none)');
} else {
for (const r of regressions) {
const evStr = r.nearbyEv.length ? ` near:[${r.nearbyEv.join(',')}]` : '';
console.log(` t=${fmt.pad(r.t, 7)} topic=${r.topic} ${r.drops.join(' | ')}${evStr}`);
}
}
console.log(`\n=== SUMMARY ===`);
console.log(` samples: ${samples.length}`);
console.log(` events: ${events.length}`);
console.log(` regressions: ${regressions.length}`);
if (samples.length) {
const last = samples.at(-1);
console.log(
` final: msgN=${last.msgN} childN=${last.childN ?? 0} content=${last.cT ?? 0} ` +
`reasoning=${last.rT ?? 0} tools=${last.toolT ?? 0} runOps=${last.runOps}`,
);
}
@@ -0,0 +1,17 @@
// Stop the probe and serialize collected data.
//
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
//
// The whole thing is wrapped in a JSON.stringify so agent-browser returns it
// as a single quoted string — the analyzer double-parses to handle that.
(function () {
if (window.__PROBE_TIMER) {
clearInterval(window.__PROBE_TIMER);
window.__PROBE_TIMER = null;
}
return JSON.stringify({
events: window.__PROBE_EVENTS || [],
samples: window.__PROBE_SAMPLES || [],
});
})();
@@ -0,0 +1,37 @@
// Stops the events-probe timeline timer and stashes the full capture as a
// JSON string on `window.__PROBE_LAST_DUMP_JSON`. `run.ts` wraps the bundle
// in an IIFE that returns that global, which `agent-browser eval` prints to
// stdout — the runner then persists it under `.agent-gateway/`.
import type { ProbeDump } from './types';
declare global {
interface Window {
__PROBE_LAST_DUMP_JSON?: string;
}
}
const w = window;
if (w.__PROBE_TIMELINE_TIMER) {
clearInterval(w.__PROBE_TIMELINE_TIMER);
w.__PROBE_TIMELINE_TIMER = null;
}
const mutations = w.__PROBE_MUTATIONS ?? [];
const dump: ProbeDump & { mutations: typeof mutations } = {
meta: {
t0: w.__PROBE_T0 ?? 0,
collectedAt: Date.now(),
sampleCount: (w.__PROBE_MSG_TIMELINE ?? []).length,
eventCount: (w.__PROBE_STREAM_EVENTS ?? []).length,
callCount: (w.__PROBE_ACTION_CALLS ?? []).length,
},
streamEvents: w.__PROBE_STREAM_EVENTS ?? [],
actionCalls: w.__PROBE_ACTION_CALLS ?? [],
timeline: w.__PROBE_MSG_TIMELINE ?? [],
mutations,
};
w.__PROBE_LAST_DUMP_JSON = JSON.stringify(dump);
@@ -0,0 +1,637 @@
// LobeHub gateway raw-event-stream probe.
//
// Gateway-mode chats subscribe via WebSocket — NOT via the `/api/agent/stream`
// SSE endpoint (that one belongs to the direct/client durable-agent runtime).
// `AgentStreamClient` (`packages/agent-gateway-client/src/client.ts`) opens
// `new WebSocket('wss://.../ws?operationId=...')`, then parses JSON frames in
// its `onmessage` handler and re-emits `agent_event.event` objects to the
// chat store.
//
// To capture the RAW gateway events before the store touches them, we wrap
// `window.WebSocket` so that for any socket whose URL contains `operationId=`
// we intercept the `onmessage` handler / `addEventListener('message')` and
// log every `agent_event` frame.
//
// We *also* keep the `window.fetch` hook for `/api/agent/stream` so this
// probe still works for direct-mode runs — but gateway-mode events come
// through the WebSocket path.
//
// Buffers (read via `dump`):
// __PROBE_STREAM_EVENTS — raw events parsed off the wire
// __PROBE_ACTION_CALLS — replaceMessages / refreshMessages calls (best-effort)
// __PROBE_MSG_TIMELINE — 200ms snapshots of every messagesMap key
import type {
ProbeActionCall,
ProbeMessageSummary,
ProbeStreamEvent,
ProbeTimelineSample,
} from './types';
// Bundled by esbuild as an IIFE. Top-level code runs once on injection.
const w = window;
// ── Buffers ─────────────────────────────────────────────────────────
declare global {
interface Window {
__PROBE_MUTATIONS?: Array<{
t: number;
key: string;
n: number;
last?: { id: string; role: string; cLen: number; rLen: number; updatedAt?: unknown };
prevLast?: { id: string; role: string; cLen: number; rLen: number };
delta?: string;
}>;
__PROBE_STORE_UNSUB?: () => void;
}
}
const events: ProbeStreamEvent[] = (w.__PROBE_STREAM_EVENTS ??= []);
const calls: ProbeActionCall[] = (w.__PROBE_ACTION_CALLS ??= []);
const timeline: ProbeTimelineSample[] = (w.__PROBE_MSG_TIMELINE ??= []);
const mutations = (w.__PROBE_MUTATIONS ??= []);
events.length = 0;
calls.length = 0;
timeline.length = 0;
mutations.length = 0;
const t0 = Date.now();
w.__PROBE_T0 = t0;
const now = (): number => Date.now() - t0;
// ── Helpers ─────────────────────────────────────────────────────────
function summarizeData(data: unknown): Record<string, unknown> | unknown {
if (!data || typeof data !== 'object') return data;
const src = data as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const k of Object.keys(src)) {
const v = src[k];
if (v == null) {
out[k] = v;
} else if (Array.isArray(v)) {
out[k] = `Array(${v.length})`;
if (k === 'uiMessages') {
out.uiMessagesPreview = v.slice(0, 5).map((m: any) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
children: (m.children ?? []).length,
tools: (m.tools ?? []).length,
reasoning: (m.reasoning?.content ?? '').length,
}));
out.uiMessagesTotal = v.length;
}
} else if (typeof v === 'object') {
const obj = v as Record<string, unknown>;
out[k] =
'Object{' +
Object.keys(obj)
.slice(0, 6)
.map((kk) => kk + (typeof obj[kk] === 'string' ? `=${(obj[kk] as string).length}ch` : ''))
.join(',') +
'}';
} else if (typeof v === 'string') {
out[k] = v.length > 100 ? v.slice(0, 100) + `…(${v.length})` : v;
} else {
out[k] = v;
}
}
return out;
}
function summarizeMessages(msgs: any[]): ProbeMessageSummary[] {
return (msgs ?? []).slice(0, 80).map((m) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
rLen: (m.reasoning?.content ?? '').length,
tools: (m.tools ?? []).length,
chN: (m.children ?? []).length,
}));
}
function shortStack(): string {
const raw = new Error('probe-stack').stack ?? '';
return raw
.split('\n')
.slice(3)
.filter((l) => !l.includes('probe-events') && !l.includes('node_modules'))
.map((l) => l.trim().replace(/^at\s+/, ''))
.slice(0, 6)
.join(' ← ');
}
function recordAgentEvent(args: {
transport: 'ws' | 'sse';
opId: string | null;
agentEvent: any;
eventId?: string | null;
rawLen?: number;
}): void {
const { transport, opId, agentEvent, eventId, rawLen } = args;
if (!agentEvent || typeof agentEvent !== 'object') return;
events.push({
t: now(),
transport,
opIdTail: (opId ?? '').slice(-10),
eventId: eventId ?? null,
type: agentEvent.type,
stepIndex: agentEvent.stepIndex,
dataKeys: agentEvent.data ? Object.keys(agentEvent.data) : [],
data: summarizeData(agentEvent.data) as Record<string, unknown>,
rawLen,
});
}
// ── 1. Patch window.WebSocket for gateway WS events ────────────────
if (!w.__PROBE_ORIG_WEBSOCKET) w.__PROBE_ORIG_WEBSOCKET = w.WebSocket;
const OrigWS = w.__PROBE_ORIG_WEBSOCKET;
function extractOpIdFromWsUrl(url: string | URL): string | null {
const m = String(url ?? '').match(/operationId=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function isGatewayWs(url: string | URL): boolean {
return String(url ?? '').includes('operationId=');
}
function handleWsFrame(rawData: unknown, opId: string | null): void {
const rawLen = typeof rawData === 'string' ? rawData.length : -1;
let parsed: any;
try {
parsed = typeof rawData === 'string' ? JSON.parse(rawData) : null;
} catch {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_PARSE_ERROR_',
raw: typeof rawData === 'string' && rawData.length < 400 ? rawData : '(non-string or large)',
});
return;
}
if (!parsed) return;
if (parsed.type === 'agent_event') {
recordAgentEvent({
transport: 'ws',
opId,
agentEvent: parsed.event,
eventId: parsed.id,
rawLen,
});
} else {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_SERVER_MSG_',
serverType: parsed.type,
rawLen,
});
}
}
// Wrap the constructor. Instance `constructor` will still reflect OrigWS
// (we share prototypes), so use the `_WS_OPEN_` sentinel events to confirm
// the patch is firing.
function PatchedWebSocket(this: WebSocket, url: string | URL, protocols?: string | string[]) {
const ws: WebSocket = protocols == null ? new OrigWS(url) : new OrigWS(url, protocols);
const opId = extractOpIdFromWsUrl(url);
if (!isGatewayWs(url)) return ws;
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_WS_OPEN_',
url: String(url),
});
// One observer listener that always fires, regardless of how the consumer
// (AgentStreamClient uses `ws.onmessage = …`) subscribes.
ws.addEventListener('message', (e) => {
try {
handleWsFrame((e as MessageEvent).data, opId);
} catch {
/* swallow */
}
});
ws.addEventListener('close', () => {
events.push({
t: now(),
transport: 'ws',
opIdTail: (opId ?? '').slice(-10),
type: '_WS_CLOSE_',
});
});
return ws;
}
// Preserve prototype + static fields so `instanceof WebSocket` and
// `WebSocket.OPEN` constants still work.
(PatchedWebSocket as unknown as { prototype: WebSocket }).prototype = OrigWS.prototype;
for (const k of Object.keys(OrigWS) as Array<keyof typeof OrigWS>) {
try {
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
} catch {
/* readonly */
}
}
(['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'] as const).forEach((k) => {
(PatchedWebSocket as any)[k] = (OrigWS as any)[k];
});
w.WebSocket = PatchedWebSocket as unknown as typeof WebSocket;
// ── 2. Patch window.fetch for `/api/agent/stream` (direct-mode SSE) ─
if (!w.__PROBE_ORIG_FETCH) w.__PROBE_ORIG_FETCH = w.fetch.bind(w);
const origFetch = w.__PROBE_ORIG_FETCH;
function isAgentStreamUrl(input: RequestInfo | URL): boolean {
let url = '';
if (typeof input === 'string') url = input;
else if (input instanceof URL) url = input.toString();
else if (input && typeof (input as Request).url === 'string') url = (input as Request).url;
return url.includes('/api/agent/stream');
}
function extractOpIdFromHttpUrl(input: RequestInfo | URL): string | null {
const url = typeof input === 'string' ? input : (input as Request | URL).toString();
const m = url.match(/operationId=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function pushFromSSEFrame(rawFrame: string, opId: string | null): void {
const lines = rawFrame.split('\n');
let dataJson = '';
let evtName = 'message';
for (const line of lines) {
if (line.startsWith('event:')) evtName = line.slice(6).trim();
else if (line.startsWith('data:')) dataJson += line.slice(5).trim();
}
if (!dataJson) return;
let parsed: any;
try {
parsed = JSON.parse(dataJson);
} catch {
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_PARSE_ERROR_',
sseEvent: evtName,
raw: dataJson.length > 400 ? dataJson.slice(0, 400) + '…' : dataJson,
});
return;
}
recordAgentEvent({
transport: 'sse',
opId,
agentEvent: parsed,
eventId: null,
rawLen: dataJson.length,
});
}
async function teeAndDrain(response: Response, opId: string | null): Promise<Response> {
if (!response.body) return response;
const [a, b] = response.body.tee();
void (async () => {
const reader = b.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx);
buf = buf.slice(idx + 2);
if (frame.trim()) pushFromSSEFrame(frame, opId);
}
}
if (buf.trim()) pushFromSSEFrame(buf, opId);
} catch (e: any) {
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_TEE_ERROR_',
message: String(e?.message ?? e),
});
}
})();
return new Response(a, {
headers: response.headers,
status: response.status,
statusText: response.statusText,
});
}
w.fetch = async function patchedFetch(input: RequestInfo | URL, init?: RequestInit) {
const response = await origFetch(input as any, init);
if (!isAgentStreamUrl(input)) return response;
const opId = extractOpIdFromHttpUrl(input);
const url =
typeof input === 'string'
? input.split('?')[0]
: (input as Request | URL).toString().split('?')[0];
events.push({
t: now(),
transport: 'sse',
opIdTail: (opId ?? '').slice(-10),
type: '_CONNECTED_',
url,
status: response.status,
});
return teeAndDrain(response, opId);
} as typeof fetch;
// ── 3. Wrap store actions (best-effort for "who called replace") ────
// Side-global stash for the original chat-store actions. Re-installs ALWAYS
// rewrap from the originals so updates to the probe body take effect
// without a page reload — using only a `__probeWrapped` flag on the chat
// state object would freeze the first-installed wrapper across re-installs.
declare global {
interface Window {
__PROBE_ORIG_REFRESH_MESSAGES?: any;
__PROBE_ORIG_REPLACE_MESSAGES?: any;
}
}
try {
const chat = w.__LOBE_STORES?.chat?.();
if (chat) {
// First-time install: cache the originals. Re-install: restore from
// the cached originals before wrapping again.
if (!w.__PROBE_ORIG_REFRESH_MESSAGES) w.__PROBE_ORIG_REFRESH_MESSAGES = chat.refreshMessages;
if (!w.__PROBE_ORIG_REPLACE_MESSAGES) w.__PROBE_ORIG_REPLACE_MESSAGES = chat.replaceMessages;
const origRefresh = w.__PROBE_ORIG_REFRESH_MESSAGES;
const origReplace = w.__PROBE_ORIG_REPLACE_MESSAGES;
chat.refreshMessages = origRefresh;
chat.replaceMessages = origReplace;
chat.refreshMessages = async function probeRefresh(this: unknown, ...args: any[]) {
calls.push({
t: now(),
name: 'refreshMessages',
args: { context: args[0] ?? null },
stack: shortStack(),
});
return origRefresh.apply(this, args);
};
chat.replaceMessages = function probeReplace(this: unknown, ...args: any[]) {
const msgs = (args[0] as any[]) ?? [];
const snapshot = msgs.slice(-2).map((m) => ({
id: (m.id ?? '').slice(-8),
role: m.role,
cLen: (m.content ?? '').length,
rLen: (m.reasoning?.content ?? '').length,
updatedAt: m.updatedAt,
}));
calls.push({
t: now(),
name: 'replaceMessages',
args: { count: msgs.length, params: args[1] ?? null, snapshot } as any,
stack: shortStack(),
});
// Pair the call with a mutation row so the analyzer can build a
// single ordered timeline across replaceMessages + dispatchMessage.
const stackTop = shortStack().split(' ← ')[0]?.slice(0, 80);
const last = msgs.at(-1);
const lastSum = last
? {
id: (last.id ?? '').slice(-8),
role: last.role,
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
updatedAt: last.updatedAt,
}
: undefined;
const params: any = args[1] ?? {};
const ctxKey = params.context
? `main_${params.context.agentId ?? '?'}_${
params.context.topicId ? 'tpc_' + params.context.topicId : 'new'
}`.replace('main_tpc_', 'main_') // crude key inference
: '(no-ctx)';
mutations.push({
t: now(),
key: ctxKey,
n: msgs.length,
last: lastSum,
delta: `replaceMessages(action=${params.action ?? '-'}) src=${stackTop ?? '-'}`,
});
return origReplace.apply(this, args);
};
}
} catch (e: any) {
calls.push({ t: now(), name: '_WRAP_ERROR_', error: String(e?.message ?? e) });
}
// ── 3.5. Mutation log — wrap the TWO ChatStore writers (replaceMessages,
// internal_dispatchMessage) to record EVERY dbMessagesMap[key] reference
// change with a one-line "before/after last assistant message" delta. This
// reveals dispatchMessage-driven collapses that the replaceMessages wrap
// alone cannot see.
declare global {
interface Window {
__PROBE_ORIG_DISPATCH_MESSAGE?: any;
}
}
try {
const chat = w.__LOBE_STORES?.chat?.();
if (chat?.internal_dispatchMessage) {
if (!w.__PROBE_ORIG_DISPATCH_MESSAGE)
w.__PROBE_ORIG_DISPATCH_MESSAGE = chat.internal_dispatchMessage;
const origDispatch = w.__PROBE_ORIG_DISPATCH_MESSAGE;
chat.internal_dispatchMessage = origDispatch;
chat.internal_dispatchMessage = function probeDispatch(this: unknown, payload: any, ctx?: any) {
// Snapshot BEFORE — read the would-be target key + last message.
const before = (() => {
try {
const state = w.__LOBE_STORES?.chat?.();
if (!state) return null;
// Replicate state.internal_getConversationContext logic enough to
// resolve a key — but most callers pass operationId on ctx, and
// operationId-keyed lookup needs store internals. Easiest: snapshot
// ALL keys' last-assistant cLen and compare BEFORE vs AFTER below.
const map = state.dbMessagesMap ?? {};
const out: Record<string, any> = {};
for (const k of Object.keys(map)) {
const last = (map[k] ?? []).at(-1);
out[k] = last
? {
id: (last.id ?? '').slice(-8),
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
n: map[k].length,
}
: { n: 0 };
}
return out;
} catch {
return null;
}
})();
const result = origDispatch.apply(this, [payload, ctx]);
// Snapshot AFTER — find which key(s) actually changed.
try {
const state = w.__LOBE_STORES?.chat?.();
if (state && before) {
const map = state.dbMessagesMap ?? {};
for (const k of Object.keys(map)) {
const last = (map[k] ?? []).at(-1);
const beforeSnap = before[k];
const afterSnap = last
? {
id: (last.id ?? '').slice(-8),
cLen: (last.content ?? '').length,
rLen: (last.reasoning?.content ?? '').length,
n: map[k].length,
}
: { n: 0 };
const changed =
!beforeSnap ||
beforeSnap.n !== afterSnap.n ||
beforeSnap.id !== (afterSnap as any).id ||
beforeSnap.cLen !== (afterSnap as any).cLen ||
beforeSnap.rLen !== (afterSnap as any).rLen;
if (!changed) continue;
let delta = '';
if (beforeSnap?.id !== undefined && beforeSnap.id !== (afterSnap as any).id)
delta += `id:${beforeSnap.id}${(afterSnap as any).id};`;
if (
beforeSnap?.cLen !== undefined &&
(afterSnap as any).cLen !== undefined &&
(afterSnap as any).cLen < beforeSnap.cLen
)
delta += `cLen↓${beforeSnap.cLen}${(afterSnap as any).cLen};`;
if (
beforeSnap?.rLen !== undefined &&
(afterSnap as any).rLen !== undefined &&
(afterSnap as any).rLen < beforeSnap.rLen
)
delta += `rLen↓${beforeSnap.rLen}${(afterSnap as any).rLen};`;
if (beforeSnap?.n !== undefined && afterSnap.n < beforeSnap.n)
delta += `n↓${beforeSnap.n}${afterSnap.n};`;
mutations.push({
t: now(),
key: k,
n: afterSnap.n,
last: (afterSnap as any).id ? (afterSnap as any) : undefined,
prevLast: beforeSnap?.id ? beforeSnap : undefined,
delta: delta || `dispatch:${payload?.type}`,
});
}
}
} catch (e: any) {
mutations.push({
t: now(),
key: '_DISPATCH_PROBE_ERROR_',
n: -1,
delta: String(e?.message ?? e),
});
}
return result;
};
}
} catch (e: any) {
calls.push({ t: now(), name: '_DISPATCH_WRAP_ERROR_', error: String(e?.message ?? e) });
}
// ── 4. Periodic per-key timeline snapshots ─────────────────────────
function captureTimeline(): void {
try {
const c = w.__LOBE_STORES?.chat?.();
if (!c) return;
const msgsMap = (c.messagesMap ?? {}) as Record<string, any[]>;
const dbMap = (c.dbMessagesMap ?? {}) as Record<string, any[]>;
const byKey: ProbeTimelineSample['byKey'] = {};
for (const k of Object.keys(msgsMap)) {
const display = msgsMap[k] ?? [];
const db = dbMap[k] ?? [];
if (display.length === 0 && db.length === 0) continue;
byKey[k] = {
n: display.length,
dbN: db.length,
msgs: summarizeMessages(display),
};
}
const ops = Object.values((c.operations ?? {}) as Record<string, any>);
timeline.push({
t: now(),
activeTopic: ((c.activeTopicId as string | null) ?? '').slice(-10) || null,
keys: Object.keys(byKey),
byKey,
runOps: ops.filter((o: any) => o.status === 'running').length,
});
} catch (e: any) {
timeline.push({
t: now(),
activeTopic: null,
keys: [],
byKey: {},
runOps: 0,
err: e?.message ?? String(e),
});
}
}
captureTimeline();
if (w.__PROBE_TIMELINE_TIMER) clearInterval(w.__PROBE_TIMELINE_TIMER);
w.__PROBE_TIMELINE_TIMER = setInterval(captureTimeline, 200);
// ── 5. Tab-switch helpers ──────────────────────────────────────────
function listTopBarTabs(): HTMLElement[] {
return Array.from(
document.querySelectorAll<HTMLElement>(
'[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]',
),
).filter((t) => t.getBoundingClientRect().top < 30);
}
w.__listTabs = () =>
listTopBarTabs().map((t, i) => ({
i,
key: t.getAttribute('data-contextmenu-trigger'),
active: t.getAttribute('data-active') === 'true',
title: (t.innerText ?? '').slice(0, 60),
}));
w.__clickTabByKey = (key: string) => {
const tab = listTopBarTabs().find((t) => t.getAttribute('data-contextmenu-trigger') === key);
if (!tab) return 'not found: ' + key;
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
tab.click();
return 'clicked key=' + key;
};
w.__PROBE_EVENT = (name: string) => {
calls.push({ t: now(), name: 'MARK:' + name });
};
// `run.ts` wraps the bundle in an IIFE and appends a `return <confirmation>`
// after the bundle body — agent-browser then prints the confirmation back to
// the operator. Nothing to do here at the end of the module body.
@@ -0,0 +1,204 @@
// LobeHub chat streaming time-series probe.
//
// Inject into the renderer (via agent-browser eval) to record store + DOM
// snapshots every 200ms during a streaming session. Designed to surface
// "UI rolled back to an earlier state" symptoms — especially around
// gateway-mode tab switches that happen while the assistant is still writing.
//
// Usage:
// agent-browser --cdp 9222 eval --stdin < probe.js
// # ...do test interactions, call window.__PROBE_EVENT('LABEL') to mark moments...
// agent-browser --cdp 9222 eval --stdin < probe-dump.js > /tmp/probe.json
// node analyze.mjs /tmp/probe.json
//
// What it captures per sample:
// - activeTopicId
// - msgN: top-level messages in chat.messagesMap for this topic
// - childN: total assistantGroup.children blocks across all msgs (THIS is
// where streaming content actually lives — top-level assistantGroup stays empty)
// - cT / rT / toolT: totals across messages AND their children
// (content, reasoning, tool-call count)
// - perMsg: per-message breakdown so regressions can be located precisely
// - runOps: number of running operations (execServerAgentRuntime etc.)
// - domLen: total innerText length of the rendered chat list area
// - ind: visible UI indicators (Search pages, Crawled pages, Deeply Thought, Sending)
//
// Event markers: window.__PROBE_EVENT('NAME') records {t, name} into
// __PROBE_EVENTS, used by the analyzer to align state changes with
// user-driven actions (SENT, AWAY_1, BACK_1, ...).
(function () {
if (window.__PROBE_TIMER) clearInterval(window.__PROBE_TIMER);
window.__PROBE_SAMPLES = [];
window.__PROBE_EVENTS = [];
const t0 = Date.now();
function snapshot() {
try {
const chat = window.__LOBE_STORES.chat();
const topicId = chat.activeTopicId;
const idTail = topicId ? topicId.replace('tpc_', '') : null;
const keys = Object.keys(chat.messagesMap || {});
// Collect messages for the active topic. Before a topic is committed,
// optimistic messages live under the `<agentScope>_new` key — fall
// back to those when no topic is active yet.
let msgs = [];
if (idTail) {
keys.forEach((k) => {
if (k.includes(idTail)) msgs = msgs.concat(chat.messagesMap[k] || []);
});
} else {
keys
.filter((k) => k.endsWith('_new'))
.forEach((k) => {
msgs = msgs.concat(chat.messagesMap[k] || []);
});
}
// Walk top-level + assistantGroup.children. children carry the actual
// streamed content / reasoning / tool calls; the parent assistantGroup
// remains a placeholder (cLen=0, rLen=0) for its whole lifetime.
let totalContent = 0;
let totalReason = 0;
let totalTools = 0;
let childCount = 0;
const perMsg = msgs.map((m) => {
const cLen = (m.content || '').length;
const rLen = ((m.reasoning && m.reasoning.content) || '').length;
const tools = (m.tools || []).length;
totalContent += cLen;
totalReason += rLen;
totalTools += tools;
const children = m.children || [];
let chC = 0;
let chR = 0;
let chT = 0;
children.forEach((c) => {
chC += (c.content || '').length;
chR += ((c.reasoning && c.reasoning.content) || '').length;
chT += (c.tools || []).length;
});
totalContent += chC;
totalReason += chR;
totalTools += chT;
childCount += children.length;
return {
id: (m.id || '').slice(-8),
role: m.role,
cLen,
rLen,
tools,
chCount: children.length,
chC,
chR,
chT,
};
});
const ops = Object.values(chat.operations || {});
const runningOps = ops.filter((o) => o.status === 'running');
// DOM probe: total rendered text in the chat scroll area (proxy for
// "how much is actually visible to the user").
const convScroll =
document.querySelector(
'[data-chat-list], [class*="ChatList"], [class*="ConversationList"]',
) ||
document.querySelector('main [class*="scroll"]') ||
document.querySelector('main');
const domTxt = convScroll ? convScroll.innerText || '' : '';
const bodyTxt = document.body.innerText || '';
const searchMatches = (bodyTxt.match(/Search pages?:|Searched the web/g) || []).length;
const crawlMatches = (bodyTxt.match(/Crawl(ed|ing) pages?/g) || []).length;
window.__PROBE_SAMPLES.push({
t: Date.now() - t0,
topicId,
msgN: msgs.length,
childN: childCount,
cT: totalContent,
rT: totalReason,
toolT: totalTools,
perMsg,
runOps: runningOps.length,
runOpTypes: runningOps.map((o) => o.type),
domLen: domTxt.length,
ind: {
search: searchMatches,
crawl: crawlMatches,
sending: bodyTxt.includes('Sending message'),
deeplyThinking: bodyTxt.includes('Deeply Thinking'),
deeplyThought: bodyTxt.includes('Deeply Thought'),
},
});
} catch (e) {
window.__PROBE_SAMPLES.push({ t: Date.now() - t0, err: e.message });
}
}
snapshot();
window.__PROBE_TIMER = setInterval(snapshot, 200);
window.__PROBE_EVENT = function (name) {
window.__PROBE_EVENTS.push({ t: Date.now() - t0, name });
};
// Tab-switch helpers installed alongside the probe.
//
// The Electron tab bar mounts each tab as a div with data-insp-path
// ending in `TabItem.tsx:...`. The active tab is marked with
// data-active="true". DO NOT search by innerText — the active tab's text
// includes a ` · <agent name>` suffix that produces false matches when
// your search string happens to overlap with the agent name.
function listTabs() {
return Array.from(
document.querySelectorAll('[data-insp-path*="TabItem.tsx"][data-contextmenu-trigger]'),
).filter((t) => t.getBoundingClientRect().top < 30);
}
function tabKey(el) {
// Stable for the tab's lifetime; survives focus changes.
return el.getAttribute('data-contextmenu-trigger');
}
function findActiveTab() {
return listTabs().find((t) => t.getAttribute('data-active') === 'true') || null;
}
// Click by stable key captured earlier (preferred for round-trips).
window.__clickTabByKey = function (key) {
const tab = listTabs().find((t) => tabKey(t) === key);
if (!tab) return 'not found: key=' + key;
if (tab.getAttribute('data-active') === 'true') return 'already active: ' + key;
tab.click();
return 'clicked key=' + key;
};
// Click by index in the tab strip (0-based, left-to-right).
window.__clickTabByIndex = function (i) {
const tabs = listTabs();
if (i < 0 || i >= tabs.length) return 'index out of range: ' + i + '/' + tabs.length;
const t = tabs[i];
if (t.getAttribute('data-active') === 'true') return 'already active: i=' + i;
t.click();
return 'clicked i=' + i + ' key=' + tabKey(t);
};
// Snapshot all tabs in order: [{key, active, title (first 60 chars of innerText)}]
window.__listTabs = function () {
return listTabs().map((t, i) => ({
i,
key: tabKey(t),
active: t.getAttribute('data-active') === 'true',
title: (t.innerText || '').slice(0, 60),
}));
};
window.__activeTabKey = function () {
const a = findActiveTab();
return a ? tabKey(a) : null;
};
return 'probe installed';
})();
@@ -0,0 +1,211 @@
// CLI for the agent-gateway probe.
//
// Bundles the TS probes with esbuild, pipes them into `agent-browser eval`,
// and persists dumps under `.agent-gateway/` (gitignored) for later use as
// streaming-replay test fixtures.
//
// Commands:
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts install
// Bundle probe-events.ts and inject into the CDP-attached browser.
// Re-installing clears all buffers and re-patches WebSocket / fetch.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts dump [name]
// Stop the timeline timer, fetch the capture as JSON, write it to
// `.agent-gateway/<name>-<YYYYMMDD-HHmmss>.json`. `name` defaults to
// `dump`. Prints the absolute path written.
//
// bun run .agents/skills/local-testing/scripts/agent-gateway/run.ts analyze [path]
// Run analyze-events.ts on the dump. `path` defaults to the most
// recently modified file in `.agent-gateway/`.
//
// Optional flags:
// --cdp <port> CDP port (default 9222)
// --browser <bin> agent-browser binary (default 'agent-browser')
import { spawn } from 'node:child_process';
import { mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
// .agents/skills/local-testing/scripts/agent-gateway/ → 5 levels up
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../../../../..');
const DUMP_DIR = path.join(PROJECT_ROOT, '.agent-gateway');
interface Flags {
browser: string;
cdp: string;
positional: string[];
}
function parseFlags(argv: string[]): Flags {
const out: Flags = { cdp: '9222', browser: 'agent-browser', positional: [] };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--cdp') out.cdp = argv[++i] ?? out.cdp;
else if (a === '--browser') out.browser = argv[++i] ?? out.browser;
else out.positional.push(a);
}
return out;
}
async function bundle(entry: string): Promise<string> {
// Bun.build is built into the Bun runtime — no external dep needed.
const r = await Bun.build({
entrypoints: [path.join(SCRIPT_DIR, entry)],
target: 'browser',
format: 'esm',
minify: false,
});
if (!r.success) {
const msgs = r.logs.map((l) => `${l.level}: ${l.message}`).join('\n');
throw new Error(`bundle failed for ${entry}:\n${msgs}`);
}
return await r.outputs[0].text();
}
function wrapIife(body: string, returnExpr: string): string {
// Wrap as an IIFE that swallows the bundled top-level (top-level `const`
// declarations get scoped to the IIFE, so re-injection doesn't conflict)
// and returns the configured expression — which `agent-browser eval`
// captures and prints to stdout.
return `(() => {\n${body}\n;return ${returnExpr};\n})()`;
}
function runAgentBrowserEval(flags: Flags, script: string): Promise<string> {
return new Promise((resolveP, rejectP) => {
const child = spawn(flags.browser, ['--cdp', flags.cdp, 'eval', '--stdin'], {
stdio: ['pipe', 'pipe', 'inherit'],
});
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8');
});
child.on('error', rejectP);
child.on('close', (code) => {
if (code === 0) resolveP(stdout);
else rejectP(new Error(`agent-browser exited ${code}`));
});
child.stdin.write(script);
child.stdin.end();
});
}
// agent-browser prints eval results as JSON (string values are quoted).
function unquoteAgentBrowserResult(raw: string): string {
const trimmed = raw.trim();
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
try {
return JSON.parse(trimmed) as string;
} catch {
/* fall through */
}
}
return trimmed;
}
function isoStamp(): string {
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}-${hh}${mi}${ss}`;
}
function ensureDumpDir(): void {
mkdirSync(DUMP_DIR, { recursive: true });
}
function latestDump(): string | null {
ensureDumpDir();
const entries = readdirSync(DUMP_DIR)
.filter((f) => f.endsWith('.json'))
.map((f) => ({ f, mtime: statSync(path.join(DUMP_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
return entries[0] ? path.join(DUMP_DIR, entries[0].f) : null;
}
// ── Commands ────────────────────────────────────────────────────────
async function cmdInstall(flags: Flags): Promise<void> {
const body = await bundle('probe-events.ts');
const installMsg = JSON.stringify(
'events probe installed: WebSocket+fetch interception. ' +
'WS captures operationId= sockets (gateway), fetch captures /api/agent/stream (direct).',
);
const script = wrapIife(body, installMsg);
const out = await runAgentBrowserEval(flags, script);
console.log(unquoteAgentBrowserResult(out));
}
async function cmdDump(flags: Flags): Promise<void> {
const name = flags.positional[1] ?? 'dump';
const body = await bundle('probe-dump.ts');
const script = wrapIife(body, 'window.__PROBE_LAST_DUMP_JSON');
const raw = await runAgentBrowserEval(flags, script);
const json = unquoteAgentBrowserResult(raw);
ensureDumpDir();
const filename = `${name}-${isoStamp()}.json`;
const dumpPath = path.join(DUMP_DIR, filename);
writeFileSync(dumpPath, json, 'utf8');
// Validate by parsing the meta header so we error early on bad capture
try {
const parsed = JSON.parse(json) as {
meta?: { eventCount?: number; callCount?: number; sampleCount?: number };
};
const meta = parsed.meta ?? {};
console.log(
`wrote ${dumpPath} (${json.length} bytes events=${meta.eventCount ?? '?'} ` +
`calls=${meta.callCount ?? '?'} samples=${meta.sampleCount ?? '?'})`,
);
} catch {
console.log(`wrote ${dumpPath} (${json.length} bytes — JSON.parse failed; see file)`);
}
}
async function cmdAnalyze(flags: Flags): Promise<void> {
const target = flags.positional[1] ?? latestDump();
if (!target) {
console.error('no dump file found. run `dump` first or pass a path.');
process.exit(1);
}
const child = spawn('bun', ['run', path.join(SCRIPT_DIR, 'analyze-events.ts'), target], {
stdio: 'inherit',
});
await new Promise<void>((resolveP, rejectP) => {
child.on('error', rejectP);
child.on('close', (code) => (code === 0 ? resolveP() : rejectP(new Error(`exit ${code}`))));
});
}
// ── Entry point ─────────────────────────────────────────────────────
const flags = parseFlags(process.argv.slice(2));
const cmd = flags.positional[0];
const usage = `usage:
bun run run.ts install [--cdp 9222]
bun run run.ts dump [name] [--cdp 9222]
bun run run.ts analyze [path]
`;
if (!cmd) {
console.error(usage);
process.exit(1);
}
try {
if (cmd === 'install') await cmdInstall(flags);
else if (cmd === 'dump') await cmdDump(flags);
else if (cmd === 'analyze') await cmdAnalyze(flags);
else {
console.error(`unknown command: ${cmd}\n\n${usage}`);
process.exit(1);
}
} catch (e: any) {
console.error(e?.stack ?? e);
process.exit(1);
}
@@ -0,0 +1,72 @@
// Run N round-trip tab switches with event markers timed against the probe.
//
// agent-browser --cdp 9222 eval --stdin < tab-switch.js
//
// Captures the currently-active tab as the BACK target and the rightmost
// inactive tab as the AWAY target. Both are addressed by their stable
// data-contextmenu-trigger key (NOT by visible title — the active tab's
// innerText embeds a ` · <agent name>` suffix that breaks text matching).
//
// Fires the loop in the background and returns immediately so the
// agent-browser eval doesn't have to await the full ROUND_TRIPS × DWELL_MS
// duration. Wait on the `SWITCH_LOOP_DONE` event before dumping.
//
// Refuses to launch if a previous loop is still in flight.
//
// Requires probe.js to have been installed first (provides
// window.__PROBE_EVENT / __listTabs / __clickTabByKey / __activeTabKey).
(function () {
const ROUND_TRIPS = 4;
const DWELL_MS = 10_000;
if (!window.__PROBE_EVENT || !window.__listTabs || !window.__clickTabByKey) {
return 'probe not installed — eval probe.js first';
}
if (window.__SWITCH_LOOP_RUNNING) {
return 'switch loop already running — wait for SWITCH_LOOP_DONE first';
}
const tabs = window.__listTabs();
const activeTab = tabs.find((t) => t.active);
if (!activeTab) return 'no active tab — abort';
// Pick the first inactive tab as AWAY target. With multiple inactive tabs
// you'll usually want the one that's stable across the test — feel free
// to swap to tabs[tabs.length-1] if you want the rightmost.
const inactives = tabs.filter((t) => !t.active);
if (inactives.length === 0) return 'no inactive tab to switch to — abort';
const awayTab = inactives.at(-1); // rightmost inactive
const BACK_KEY = activeTab.key;
const AWAY_KEY = awayTab.key;
window.__SWITCH_LOOP_RUNNING = true;
window.__PROBE_EVENT('SWITCH_LOOP_CONFIG:back=' + BACK_KEY + ',away=' + AWAY_KEY);
(async function () {
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
try {
window.__PROBE_EVENT('SWITCH_LOOP_START');
for (let i = 1; i <= ROUND_TRIPS; i++) {
window.__PROBE_EVENT('AWAY_' + i);
const awayResult = window.__clickTabByKey(AWAY_KEY);
window.__PROBE_EVENT('AWAY_' + i + '_RES:' + awayResult.slice(0, 50));
await sleep(DWELL_MS);
window.__PROBE_EVENT('BACK_' + i);
const backResult = window.__clickTabByKey(BACK_KEY);
window.__PROBE_EVENT('BACK_' + i + '_RES:' + backResult.slice(0, 50));
await sleep(DWELL_MS);
}
window.__PROBE_EVENT('SWITCH_LOOP_DONE');
} finally {
window.__SWITCH_LOOP_RUNNING = false;
}
})();
return 'switch loop kicked off (BACK=' + BACK_KEY + ', AWAY=' + AWAY_KEY + ')';
})();
@@ -0,0 +1,113 @@
// Shared types between the in-browser probe and the Node-side analyzer.
// Kept tiny on purpose — anything the analyzer can re-derive is left off.
export interface ProbeStreamEvent {
/** Summarized payload — long strings truncated, arrays printed as Array(N) */
data?: Record<string, unknown>;
/** Keys present on the event's `data` payload — useful at a glance */
dataKeys?: string[];
/** ServerMessage.id — gateway WS frames carry an event-id we may resume from */
eventId?: string | null;
message?: string;
/** Last 10 chars of the operationId (full id is excessively long) */
opIdTail: string;
raw?: string;
/** Raw frame byte length, when applicable */
rawLen?: number;
/** For non-agent_event server frames (auth_success, heartbeat_ack, …) */
serverType?: string;
sseEvent?: string;
status?: number;
stepIndex?: number;
/** Milliseconds since the probe's t0 (install time). */
t: number;
/** 'ws' for gateway WebSocket frames, 'sse' for direct /api/agent/stream */
transport: 'ws' | 'sse';
/** Either the AgentStreamEvent.type, or a probe sentinel like `_WS_OPEN_` */
type: string;
url?: string;
}
export interface ProbeActionCall {
args?: {
count?: number;
context?: unknown;
params?: unknown;
};
error?: string;
/** `replaceMessages` / `refreshMessages` / `MARK:<label>` / `_WRAP_ERROR_` */
name: string;
stack?: string;
t: number;
}
export interface ProbeMessageSummary {
/** children.length */
chN: number;
/** content.length */
cLen: number;
/** Last 8 chars of the message id */
id: string;
/** reasoning.content.length */
rLen: number;
role: string;
/** tools.length */
tools: number;
}
export interface ProbeTimelineSample {
/** Last 10 chars of activeTopicId, or null */
activeTopic: string | null;
/** Per-key breakdown: display count, db count, message summaries */
byKey: Record<
string,
{
n: number;
dbN: number;
msgs: ProbeMessageSummary[];
}
>;
err?: string;
/** All messagesMap keys that have content at this moment */
keys: string[];
/** Number of operations in 'running' status */
runOps: number;
t: number;
}
export interface ProbeDumpMeta {
callCount: number;
/** Date.now() at dump call */
collectedAt: number;
eventCount: number;
sampleCount: number;
/** Date.now() at probe install */
t0: number;
}
export interface ProbeDump {
actionCalls: ProbeActionCall[];
meta: ProbeDumpMeta;
streamEvents: ProbeStreamEvent[];
timeline: ProbeTimelineSample[];
}
/**
* Globals the probe attaches to `window`. Keeps `as any` casts at the boundary
* instead of sprinkling them through the probe body.
*/
declare global {
interface Window {
__clickTabByKey?: (key: string) => string;
__listTabs?: () => Array<{ i: number; key: string | null; active: boolean; title: string }>;
__LOBE_STORES?: Record<string, () => any>;
__PROBE_ACTION_CALLS?: ProbeActionCall[];
__PROBE_EVENT?: (label: string) => void;
__PROBE_MSG_TIMELINE?: ProbeTimelineSample[];
__PROBE_ORIG_FETCH?: typeof fetch;
__PROBE_ORIG_WEBSOCKET?: typeof WebSocket;
__PROBE_STREAM_EVENTS?: ProbeStreamEvent[];
__PROBE_T0?: number;
__PROBE_TIMELINE_TIMER?: ReturnType<typeof setInterval> | null;
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: pr
description: "Create a PR for the current branch. Use when the user asks to create a pull request, submit PR, or says 'pr'."
description: "Create a PR for the current branch (targets `canary` by default). Use when the user asks to create a pull request, submit a PR, or says 'pr'. Triggers on 'pr', 'create pr', 'submit pr', 'open a PR', 'pull request', '提 PR', '提个 PR', '新建 PR'."
user-invocable: true
---
+56 -58
View File
@@ -1,6 +1,6 @@
---
name: project-overview
description: Complete project architecture and structure guide. Use when exploring the codebase, understanding project organization, finding files, or needing comprehensive architectural context. Triggers on architecture questions, directory navigation, or project overview needs.
description: "LobeHub open-source monorepo architecture map — flat `apps/` + `packages/@lobechat/*` + `src/` layout, per-layer location table, and `src/business/` stubs that the cloud repo overrides. Use when exploring an unfamiliar part of the codebase, locating where a layer lives (store / service / router / schema / etc.), or onboarding to the monorepo. Triggers on 'where does X live', 'project structure', 'monorepo layout', `src/business/` stub, 'architecture overview', '项目结构', '架构总览'."
user-invocable: false
---
@@ -13,11 +13,12 @@ user-invocable: false
## Project Description
Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat).
This repo is the **open-source root** (`github.com/lobehub/lobehub`, package `@lobehub/lobehub`).
**Supported platforms:**
- Web desktop/mobile
- Desktop (Electron)
- Desktop (Electron)`apps/desktop`
- Mobile app (React Native) — **separate repo, already launched** (not in this monorepo)
**Logo emoji:** 🤯
@@ -47,30 +48,28 @@ Open-source, modern-design AI Agent Workspace: **LobeHub** (previously LobeChat)
## Monorepo Layout
This is a monorepo extending the open-source `lobehub` submodule. Two repos:
- **cloud repo root** — `src/` and `packages/business/` (`config`, `const`, `model-runtime`) hold cloud-only SaaS code that overrides/extends the submodule. See `AGENTS.md` for the override mechanism.
- **`lobehub/` submodule** — the open-source product core.
### `lobehub/` submodule — key directories
Flat layout — `apps/`, `packages/`, and `src/` all sit at the repo root. No
git submodules.
```
lobehub/
(repo root)
├── apps/
│ ├── cli/ # LobeHub CLI
│ ├── desktop/ # Electron desktop app
│ └── device-gateway/ # Device gateway service
├── docs/ # changelog, development, self-hosting, usage
├── locales/ # en-US, zh-CN, ...
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
│ ├── agent-runtime/ # Agent runtime
│ ├── cli/ # LobeHub CLI
│ ├── desktop/ # Electron desktop app
│ └── device-gateway/ # Device gateway service
├── docs/ # changelog, development, self-hosting, usage
├── locales/ # en-US, zh-CN, ...
├── packages/ # ~80 @lobechat/* workspace packages — `ls` for the full set. Key ones:
│ ├── agent-runtime/ # Agent runtime core
│ ├── agent-signal/ # Agent Signal pipeline
│ ├── builtin-tool-*/ # Builtin tool packages
│ ├── builtin-tools/ # Builtin tool registries
│ ├── agent-tracing/ # Tracing / snapshots
│ ├── builtin-tool-*/ # Per-tool packages (calculator, web-browsing, claude-code, ...)
│ ├── builtin-tools/ # Central registries that compose builtin-tool-*
│ ├── context-engine/
│ ├── database/ # src/{models,schemas,repositories}
│ ├── model-bank/ # Model definitions & provider cards
│ ├── model-runtime/ # src/{core,providers}
│ ├── business/ # Open-source stubs (config, const, model-bank, model-runtime) — overridden by cloud
│ ├── types/
│ └── utils/
└── src/
@@ -83,55 +82,54 @@ lobehub/
├── spa/ # SPA entries + router config
│ ├── entry.{web,mobile,desktop,popup}.tsx
│ └── router/
├── business/ # Open-source stubs (~50) overridden by cloud src/business/
├── business/ # Open-source stubs (client/server) — cloud repo provides real impls
├── features/ # Domain business components
├── store/ # ~28 zustand stores — `ls` for the full set
├── server/ # featureFlags, globalConfig, modules, routers, services
├── store/ # ~30 zustand stores — `ls` for the full set
├── server/ # featureFlags, globalConfig, modules, routers, services, workflows, agent-hono
└── ... # components, hooks, layout, libs, locales, services, types, utils
```
### cloud repo — key directories
```
(cloud root)
├── packages/business/ # Cloud overrides: config, const, model-runtime
├── src/
│ ├── business/ # Cloud impls of submodule stubs (client/server/locales)
│ ├── routes/ # Cloud-only route groups: (cloud)/, embed/
│ ├── store/ # Cloud-only stores (e.g. subscription/)
│ ├── server/ # Cloud routers & services (billing, budget, risk control...)
│ └── app/(backend)/cron/ # Vercel cron routes (schedules declared in root vercel.ts)
└── vercel.ts # Cron schedule declarations
```
> File search rule: a path like `@/store/x` resolves cloud `src/store/x` first, then
> `lobehub/packages/store/src/x`, then `lobehub/src/store/x`. Cloud override wins.
## Architecture Map
| Layer | Location |
| ---------------- | ---------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `src/tools`, `packages/builtin-tool-*` |
| Cloud-only | `src/business/*`, `packages/business/*` (cloud repo) |
| Layer | Location |
| ---------------- | --------------------------------------------------- |
| UI Components | `src/components`, `src/features` |
| SPA Pages | `src/routes/` |
| React Router | `src/spa/router/` |
| Global Providers | `src/layout` |
| Zustand Stores | `src/store` |
| Client Services | `src/services/` |
| REST API | `src/app/(backend)/webapi` |
| tRPC Routers | `src/server/routers/{async\|lambda\|mobile\|tools}` |
| Server Services | `src/server/services` (can access DB) |
| Server Modules | `src/server/modules` (no DB access) |
| Feature Flags | `src/server/featureFlags` |
| Global Config | `src/server/globalConfig` |
| DB Schema | `packages/database/src/schemas` |
| DB Model | `packages/database/src/models` |
| DB Repository | `packages/database/src/repositories` |
| Third-party | `src/libs` (analytics, oidc, etc.) |
| Builtin Tools | `packages/builtin-tool-*`, `packages/builtin-tools` |
| Open-source stub | `src/business/*`, `packages/business/*` (this repo) |
## Data Flow
```
React UI → Store Actions → Client Service → TRPC Lambda → Server Services → DB Model → PostgreSQL
```
## Note: Relationship to the Cloud Repo
This open-source repo is consumed by a **separate, private cloud (SaaS) repo**
as a git submodule mounted at `lobehub/`. The cloud repo provides:
- **`src/business/{client,server}`** and **`packages/business/*`** implementations
that override the stubs shipped here.
- Cloud-only routes (e.g. `(cloud)/`, `embed/`), cloud-only stores (e.g.
`subscription/`), cloud-only TRPC routers (billing, budget, risk control, …),
and Vercel cron routes under `src/app/(backend)/cron/`.
- File-resolution order in cloud: `@/store/x` → cloud `src/store/x` first, then
`lobehub/packages/store/src/x`, then `lobehub/src/store/x`. **Cloud override wins.**
When working in this repo alone, ignore the cloud layer — the stubs in
`src/business/` and `packages/business/` are the source of truth here.
+45 -23
View File
@@ -1,6 +1,6 @@
---
name: react
description: 'Use when writing or editing any `.tsx` under `src/**`. Triggers: createStaticStyles, createStyles, cssVar, antd-style, Flexbox, Center, Select, Modal, Drawer, Button, Tooltip, DropdownMenu, Popover, Switch, ScrollArea, Link, useNavigate, react-router-dom, next/link, desktopRouter, componentMap.desktop, .desktop.tsx, new component, new page, edit layout, add styles, zustand selector, @lobehub/ui, antd import.'
description: "LobeHub React component conventions — base-ui (`@lobehub/ui/base-ui`) first for headless primitives (Select, Modal, DropdownMenu, ContextMenu, Popover, ScrollArea, Switch, Toast, FloatingSheet), then `@lobehub/ui` root, antd as last resort; styling via `antd-style` `createStaticStyles` + `cssVar.*` (zero-runtime preferred over `createStyles` + `token`); routing via `react-router-dom` (not `next/link`). Use when writing or editing any `.tsx` under `src/**`. Triggers on `createStaticStyles`, `createStyles`, `cssVar`, `antd-style`, `Flexbox`, `Center`, `Select`, `Modal`, `Drawer`, `Button`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `Popover`, `Switch`, `ScrollArea`, `Toast`, `FloatingSheet`, `Link`, `useNavigate`, `react-router-dom`, `next/link`, `desktopRouter`, `componentMap.desktop`, `.desktop.tsx`, `base-ui`, `@lobehub/ui/base-ui`, 'new component', 'new page', 'edit layout', 'add styles', 'zustand selector', '@lobehub/ui', 'antd import'."
user-invocable: false
---
@@ -17,22 +17,41 @@ user-invocable: false
## Component Priority
1. **`src/components`** — project-specific reusable components
2. **`@lobehub/ui/base-ui`** — headless primitives (Select, Modal, DropdownMenu, Popover, Switch, ScrollArea…)
3. **`@lobehub/ui`** — higher-level components (ActionIcon, Markdown, DragPage…)
4. **Custom implementation** — last resort; never reach for antd directly
2. **`@lobehub/ui/base-ui`** — headless primitives. **If the component lives here, use it. Do NOT import the same-named root export.**
3. **`@lobehub/ui`** — higher-level / antd-wrapping components (only when no base-ui equivalent)
4. **antd** — only when neither base-ui nor `@lobehub/ui` root provides it
5. **Custom implementation** — true last resort
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs`.
If unsure about available components, search existing code or check `node_modules/@lobehub/ui/es/index.mjs` and `node_modules/@lobehub/ui/es/base-ui/`.
### Common @lobehub/ui Components
### `@lobehub/ui/base-ui` — always prefer for these
| Category | Components |
| ------------ | ------------------------------------------------------------------------------- |
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
| Data Entry | CodeEditor, CopyButton, EditableText, Form, FormModal, Input, SearchBar, Select |
| Feedback | Alert, Drawer, Modal |
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Dropdown, Menu, SideNav, Tabs |
| Component | Import |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
| `Select` (+ `SelectProps`, `SelectOption`) | `import { Select } from '@lobehub/ui/base-ui';` |
| `Modal` (imperative API) | `import { createModal, confirmModal, useModalContext, type ModalInstance } from '@lobehub/ui/base-ui';` |
| `DropdownMenu` | `import { DropdownMenu } from '@lobehub/ui/base-ui';` |
| `ContextMenu` | `import { ContextMenu } from '@lobehub/ui/base-ui';` |
| `Popover` | `import { Popover } from '@lobehub/ui/base-ui';` |
| `ScrollArea` | `import { ScrollArea } from '@lobehub/ui/base-ui';` |
| `Switch` | `import { Switch } from '@lobehub/ui/base-ui';` |
| `Toast` | `import { Toast } from '@lobehub/ui/base-ui';` |
| `FloatingSheet` | `import { FloatingSheet } from '@lobehub/ui/base-ui';` |
For Modal specifically, see the dedicated **modal** skill — use the imperative `createModal({ content: … })` pattern over the legacy `<Modal open … />` declarative pattern. base-ui has its own `ModalHost` already mounted in `SPAGlobalProvider`.
> Common slip: `import { Select } from '@lobehub/ui'` looks fine but it's the antd-backed Select. Use base-ui Select. Same for `Modal`, `DropdownMenu`, etc.
### `@lobehub/ui` root — use when base-ui has no equivalent
| Category | Components |
| ------------ | ------------------------------------------------------------------------------------- |
| General | ActionIcon, ActionIconGroup, Block, Button, Icon |
| Data Display | Avatar, Collapse, Empty, Highlighter, Markdown, Tag, Tooltip |
| Data Entry | CodeEditor, CopyButton, EditableText, Form, Input, InputPassword, SearchBar, TextArea |
| Feedback | Alert, Drawer |
| Layout | Center, DraggablePanel, Flexbox, Grid, Header, MaskShadow |
| Navigation | Burger, Menu, SideNav, Tabs |
## Layout
@@ -85,12 +104,15 @@ errorElement: <ErrorBoundary />;
## Common Mistakes
| Mistake | Fix |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
| Mistake | Fix |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| Using `next/link` in SPA | Use `react-router-dom` `Link` |
| Using antd directly | Use `@lobehub/ui/base-ui` first, then `@lobehub/ui` |
| `import { Select } from '@lobehub/ui'` | `import { Select } from '@lobehub/ui/base-ui'` |
| `import { Modal } from '@lobehub/ui'` + `<Modal open>` declarative | `createModal` / `confirmModal` from `@lobehub/ui/base-ui` (see modal skill) |
| `import { DropdownMenu/Popover/Switch } from '@lobehub/ui'` | Import same name from `@lobehub/ui/base-ui` instead |
| `createStyles` for static styles | Use `createStaticStyles` + `cssVar` |
| Editing only `desktopRouter.config.tsx` | Must edit both `.tsx` and `.desktop.tsx` |
| Using `margin` for flex spacing | Use `gap` prop on Flexbox |
| Accessing zustand store without selector | Use selectors to access store data (see zustand skill) |
| Text or icon-text actions built with `Flexbox`/`Text` + `onClick` | Use `Button type={'text'} size={'small'}` with `icon` when needed |
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: review-checklist
description: 'Common recurring mistakes in LobeHub code review — console leftovers, missing return await, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs @lobehub/ui, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing PRs, diffs, or branch changes.'
description: "Common recurring mistakes in LobeHub code review — `console.*` leftovers, missing `return await`, hardcoded secrets, hardcoded i18n strings, desktop router pair drift, antd vs `@lobehub/ui`, non-idempotent migrations, cloud impact red flags. Use as a quick checklist when reviewing a PR, diff, or branch change. Triggers on 'code review', 'review the diff', 'review this PR', 'review changes', 'PR review checklist', '审一下', '审 PR'."
user-invocable: false
---
+142
View File
@@ -0,0 +1,142 @@
---
name: skills-audit
description: Weekly audit of `.agents/skills/*/SKILL.md` — surfaces duplicate / overlapping / stale skills, inconsistent descriptions, broken cross-references, and merge/delete candidates. Run as a recurring health-check, not during normal feature work.
disable-model-invocation: true
argument-hint: '[--verbose | --apply]'
---
# Skills Audit
Periodic review of the project-local skill set under `.agents/skills/`. The goal is to catch drift before the catalog becomes confusing — too many skills, overlapping triggers, descriptions that no longer match the body, references to skills that were renamed/deleted.
**Recommended cadence:** weekly, or after any week where >1 skill was added/renamed.
## Procedure
### 1 — Inventory
Build a fresh census of all SKILL.md files. Do NOT trust any prior cached list.
```bash
find .agents/skills -name SKILL.md | wc -l # total count
find .agents/skills -name SKILL.md -exec wc -l {} \; | sort -rn # by body length
```
Group by domain in a mental table (DB / state / UI / agent / testing / workflow / docs / etc.). Note new arrivals since last audit (`git log --since="1 week ago" -- .agents/skills/`).
### 2 — Pull frontmatter for all skills
```bash
# Extract name + description for each SKILL.md
for f in .agents/skills/*/SKILL.md; do
echo "=== $(basename $(dirname $f)) ==="
awk '/^---$/{c++; next} c==1' "$f" | head -20
done
```
Read the description block of every skill. The body can stay unread unless step 4 flags it.
### 3 — Detect overlap / redundancy
For each pair within the same domain, ask:
- **Same description**? → likely duplicate (one is probably a stale rename leftover, or a global-vs-local collision).
- **Trigger keywords substantially overlap**? → either merge, OR tighten one description so the model can choose unambiguously.
- **One skill's body says "see also: foo"**? → confirm `foo` still exists, AND confirm the cross-reference is still meaningful (the referenced skill may have absorbed the referrer's concerns).
- **Skill duplicates content from `AGENTS.md`**? → fold into AGENTS.md or slim the skill to just the delta.
Common false positives (do NOT merge):
- `db-migrations` vs `drizzle` — distinct workflows (migration files vs schema authoring).
- `microcopy` vs `i18n` — content vs mechanics.
- `agent-runtime-hooks` vs `agent-tracing` vs `agent-signal` — different surfaces of the agent system.
- `testing` vs `local-testing` vs `cli-backend-testing` — different test types.
### 4 — Description format consistency
Apply the **standard template**:
```
{Topic + key conventions or scope}. Use when {scenarios — verbs + nouns}. Triggers on {`code-symbols`, 'natural phrases', '中文'}.
```
Skills with `disable-model-invocation: true` (user-invoked only, slash commands) don't need `Triggers on` — they're never auto-routed.
Flag descriptions that:
- ❌ Have NO `Use when` clause (model can't decide when to load it).
- ❌ Have NO `Triggers on` clause (and aren't `disable-model-invocation`).
- ❌ Use weird formats (numbered lists `(1)(2)(3)`, `Triggers:` colon instead of `Triggers on`, `MUST use when ...` as opening word).
- ❌ Are dramatically terse for a 200+ line body, or dramatically verbose for a 60-line body.
- ❌ Reference deleted/renamed skills.
### 5 — Stale-skill check
For narrow domain skills (e.g. `response-compliance`, one-off CLI workflows):
```bash
# Confirm the referenced code surface still exists
rg -l "response-compliance|openresponses" packages/ src/ # adjust per skill
git log --since="3 months ago" -- .agents/skills/ < skill > /SKILL.md # is it being maintained?
```
If the underlying surface is gone and the skill hasn't been edited in 3+ months → flag for archival.
### 6 — Cross-reference integrity
Any skill body mentioning another skill by name:
```bash
# Scan all skill bodies for skill-name references
rg -o '`[a-z][a-z0-9-]+`' .agents/skills/*/SKILL.md | grep -v ':\s*$' | sort -u
```
For each name extracted, confirm `.agents/skills/<name>/SKILL.md` exists. Broken references happen after renames — fix them in the same audit pass.
### 7 — Output report
Produce a markdown summary back to the user with the same structure as the original audit (this skill was created during one):
```markdown
## 📊 Inventory
{count, domain breakdown}
## 🎯 Recommendations
### 🔴 High confidence
- {action} — {reason}
### 🟡 Medium confidence
- {action} — {reason needs verification}
### 🟢 Low confidence / no-op
- {item considered but skipping because ...}
## 📋 Suggested order
{table of actions with risk + LOC estimate}
```
End by asking the user which actions to apply — do NOT auto-apply unless the user passed `--apply` and even then confirm destructive deletes individually.
## Output rules
- Be specific. "Skill X overlaps with Y" is useless without naming the overlapping triggers.
- Cite line numbers when flagging description / body issues.
- Don't recommend merges unless the call sites would actually load the merged skill in the same context.
- Don't recommend deletes for skills that haven't been touched recently — "unused" can mean "stable", not "dead".
## What NOT to do
- ❌ Don't rename skill directories without checking for cross-references AND user memory entries that name the old slug.
- ❌ Don't normalize a description by removing trigger keywords just to fit the template — the keywords are the routing signal.
- ❌ Don't fold a heavy 200+ line skill into another just because they share a domain — large skills get loaded selectively and merging makes everything load.
- ❌ Don't propose `.agents/skills/INDEX.md` or `<domain>-<skill>` prefix renames unless the user explicitly asks — costs > benefits for cosmetic reorgs.
## Related history
- First audit: `chore/skills-audit` branch (2026-05-25) — deleted `source-command-dedupe`, renamed `data-fetching``data-fetching-architecture`, normalized 9 descriptions, created this skill.
@@ -1,44 +0,0 @@
---
name: 'source-command-dedupe'
description: 'Find duplicate GitHub issues'
---
# source-command-dedupe
Use this skill when the user asks to run the migrated source command `dedupe`.
## Command Template
Find up to 3 likely duplicate issues for a given GitHub issue.
To do this, follow these steps precisely:
1. Use an agent to check if the Github issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
2. Use an agent to view a Github issue, and ask the agent to return a summary of the issue
3. Then, launch 5 parallel agents to search Github for duplicates of this issue, using diverse keywords and search approaches, using the summary from #1
4. Next, feed the results from #1 and #2 into another agent, so that it can filter out false positives, that are likely not actually duplicates of the original issue. If there are no duplicates remaining, do not proceed.
5. Finally, comment back on the issue with a list of up to three duplicate issues (or zero, if there are no likely duplicates)
Notes (be sure to tell this to your agents, too):
- Use `gh` to interact with Github, rather than web fetch
- Do not use other tools, beyond `gh` (eg. don't use other MCP servers, file edit, etc.)
- Make a todo list first
- For your comment, follow the following format precisely (assuming for this example that you found 3 suspected duplicates):
---
Found 3 possible duplicate issues:
1. <link to issue>
2. <link to issue>
3. <link to issue>
This issue will be automatically closed as a duplicate in 3 days.
- If your issue is a duplicate, please close it and 👍 the existing issue instead
- To prevent auto-closure, add a comment or 👎 this comment
> 🤖 Generated with Codex
---
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: spa-routes
description: MUST use when editing src/routes/ segments, src/spa/router/desktopRouter.config.tsx or desktopRouter.config.desktop.tsx (always change both together), mobileRouter.config.tsx, or when moving UI/logic between routes and src/features/.
description: "SPA roots-vs-features split for LobeHub — thin route segments under `src/routes/` delegate to domain components under `src/features/`. Use when editing `src/routes/` segments, `src/spa/router/desktopRouter.config.tsx` or `desktopRouter.config.desktop.tsx` (MUST update both together`desktopRouter.sync.test.tsx` enforces this), `mobileRouter.config.tsx`, `popupRouter.config.tsx`, or moving UI/logic between `routes/` and `features/`. Triggers on `desktopRouter.config`, `mobileRouter.config`, `popupRouter.config`, `src/routes/**`, `src/features/**`, 'add a route', 'new page', 'route segment', '路由'."
user-invocable: false
---
@@ -1,6 +1,6 @@
---
name: store-data-structures
description: Zustand store data structure patterns for LobeHub. Covers List vs Detail data structures, Map + Reducer patterns, type definitions, and when to use each pattern. Use when designing store state, choosing data structures, or implementing list/detail pages.
description: "Zustand store data-shape patterns for LobeHub List vs Detail split, Map + Reducer, type definitions sourced from `@lobechat/types` (not `@lobechat/database`). Use when designing store state, choosing between Array (list) and `Record<string, Detail>` (detail map), or implementing a list/detail page pair. Triggers on `messagesMap`, `topicsMap`, `Record<string, Detail>`, 'list vs detail', 'store data shape', 'normalize state', 'state structure'."
user-invocable: false
---
@@ -310,5 +310,5 @@ export interface BenchmarkListItem {
## Related Skills
- `data-fetching` — how to fetch and update this data
- `data-fetching-architecture` — how to fetch and update this data
- `zustand` — general Zustand patterns
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: upstash-workflow
description: 'Upstash Workflow implementation guide. Use when creating async workflows with QStash, implementing fan-out patterns, or building 3-layer workflow architecture (process → paginate → execute).'
description: "Upstash Workflow + QStash implementation guide for LobeHub — 3-layer architecture (process → paginate → execute), fan-out patterns. Use when creating an async workflow, implementing fan-out (paginate → execute), or wiring `serve()` + `context.run` / `context.call` steps. Triggers on `serve()`, `context.run`, `context.call`, `context.sleep`, `qstash`, 'async workflow', 'fan-out workflow', 'QStash workflow'."
user-invocable: false
---
+3
View File
@@ -28,6 +28,9 @@ prd
# Recordings
.records/
# Agent-gateway probe captures (local debugging dumps)
.agent-gateway/
# Temporary files
.temp/
temp/
+1
View File
@@ -7,6 +7,7 @@ Guidelines for using AI coding agents in this LobeHub repository.
- Next.js 16 + React 19 + TypeScript
- SPA inside Next.js with `react-router-dom`
- `@lobehub/ui`, antd for components; antd-style for CSS-in-JS — **prefer `createStaticStyles` with `cssVar.*`** (zero-runtime); only fall back to `createStyles` + `token` when styles genuinely need runtime computation. See `.cursor/docs/createStaticStyles_migration_guide.md`.
- **Component priority**: `@lobehub/ui/base-ui` (headless primitives) **first**, then `@lobehub/ui` root, then antd as last resort. When the component exists in base-ui, use it — never reach for the root or antd counterpart. Base-ui covers `Select`, `Modal` / `createModal` / `confirmModal`, `DropdownMenu`, `ContextMenu`, `Popover`, `ScrollArea`, `Switch`, `Toast`, `FloatingSheet`. Prefer `@lobehub/ui/base-ui` for new code and migrate root-package call sites opportunistically.
- react-i18next for i18n; zustand for state management
- SWR for data fetching; TRPC for type-safe backend
- Drizzle ORM with PostgreSQL; Vitest for testing
+1 -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.20" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.22" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.20",
"version": "0.0.22",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -44,7 +44,7 @@
"picocolors": "^1.1.1",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"ws": "^8.18.1"
},
"publishConfig": {
+2 -2
View File
@@ -248,14 +248,14 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
// Handle tool call requests
client.on('tool_call_request', async (request: ToolCallRequestMessage) => {
const { requestId, toolCall } = request;
const { requestId, timeout, toolCall } = request;
if (isDaemonChild) {
appendLog(`[TOOL] ${toolCall.apiName} (${requestId})`);
} else {
log.toolCall(toolCall.apiName, requestId, toolCall.arguments);
}
const result = await executeToolCall(toolCall.apiName, toolCall.arguments);
const result = await executeToolCall(toolCall.apiName, toolCall.arguments, timeout);
if (isDaemonChild) {
appendLog(`[RESULT] ${result.success ? 'OK' : 'FAIL'} (${requestId})`);
+109
View File
@@ -8,11 +8,20 @@ import { registerHeteroCommand } from './hetero';
const { mockSpawnAgent } = vi.hoisted(() => ({
mockSpawnAgent: vi.fn(),
}));
const { mockGetTrpcClient, mockHeteroFinishMutate, mockHeteroIngestMutate } = vi.hoisted(() => ({
mockGetTrpcClient: vi.fn(),
mockHeteroFinishMutate: vi.fn(),
mockHeteroIngestMutate: vi.fn(),
}));
vi.mock('@lobechat/heterogeneous-agents/spawn', () => ({
spawnAgent: mockSpawnAgent,
}));
vi.mock('../api/client', () => ({
getTrpcClient: mockGetTrpcClient,
}));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
@@ -77,6 +86,17 @@ describe('hetero exec command', () => {
}) as any);
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
mockSpawnAgent.mockReset();
mockHeteroIngestMutate.mockReset();
mockHeteroFinishMutate.mockReset();
mockGetTrpcClient.mockReset();
mockHeteroIngestMutate.mockResolvedValue({ ack: true });
mockHeteroFinishMutate.mockResolvedValue({ ack: true });
mockGetTrpcClient.mockResolvedValue({
aiAgent: {
heteroFinish: { mutate: mockHeteroFinishMutate },
heteroIngest: { mutate: mockHeteroIngestMutate },
},
});
});
afterEach(() => {
@@ -536,4 +556,93 @@ describe('hetero exec command', () => {
expect(errorLine).toBeDefined();
});
});
it('sends full text snapshots before tools and waits for finish until all server ingests ack', async () => {
const callOrder: string[] = [];
mockHeteroIngestMutate.mockImplementation(async ({ events }: any) => {
const first = events[0];
callOrder.push(`ingest:${first.type}:${first.data?.chunkType ?? 'terminal'}`);
return { ack: true };
});
mockHeteroFinishMutate.mockImplementation(async () => {
callOrder.push('finish');
return { ack: true };
});
mockSpawnAgent.mockReturnValue(
createFakeHandle({
events: [
{
data: { chunkType: 'text', content: 'hello ' },
operationId: 'op-server',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
{
data: { chunkType: 'text', content: 'world' },
operationId: 'op-server',
stepIndex: 0,
timestamp: 2,
type: 'stream_chunk',
},
{
data: {
chunkType: 'tools_calling',
toolsCalling: [
{
apiName: 'Bash',
arguments: '{"cmd":"ls"}',
id: 'tc-1',
identifier: 'bash',
type: 'default',
},
],
},
operationId: 'op-server',
stepIndex: 1,
timestamp: 3,
type: 'stream_chunk',
},
{
data: { reason: 'success' },
operationId: 'op-server',
stepIndex: 1,
timestamp: 4,
type: 'agent_runtime_end',
},
],
exitCode: 0,
}),
);
await runCmd([
'hetero',
'exec',
'--type',
'claude-code',
'--prompt',
'hi',
'--topic',
'topic-1',
'--operation-id',
'op-server',
'--render',
'none',
]);
expect(mockHeteroIngestMutate).toHaveBeenCalledTimes(3);
expect(mockHeteroIngestMutate.mock.calls[0][0].events[0].data).toMatchObject({
chunkType: 'text',
content: 'hello world',
snapshotMode: 'replace',
snapshotSeq: 1,
});
expect(callOrder).toEqual([
'ingest:stream_chunk:text',
'ingest:stream_chunk:tools_calling',
'ingest:agent_runtime_end:terminal',
'finish',
]);
});
});
+103 -16
View File
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
@@ -6,12 +7,12 @@ import type {
AgentContentBlock,
AgentImageSource,
AgentPromptInput,
AgentStreamEvent,
} from '@lobechat/heterogeneous-agents/spawn';
import { spawnAgent } from '@lobechat/heterogeneous-agents/spawn';
import type { Command } from 'commander';
import { getTrpcClient } from '../api/client';
import { BatchIngester, NoopIngestSink } from '../utils/BatchIngester';
import { log } from '../utils/logger';
import { TrpcIngestSink } from '../utils/TrpcIngestSink';
@@ -200,6 +201,85 @@ const resolvePrompt = async (options: ExecOptions): Promise<ResolvedPrompt> => {
return buildPromptFromText(raw, images);
};
class SerialServerIngester {
private accumulatedText = '';
private fatalError: Error | null = null;
private inflight: Promise<void> = Promise.resolve();
private nextSnapshotSeq = 0;
private pendingTextEvent: AgentStreamEvent | undefined;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly sink: TrpcIngestSink,
private readonly snapshotFlushMs = 200,
) {}
push(event: AgentStreamEvent): void {
if (this.fatalError) return;
if (
event.type === 'stream_chunk' &&
event.data?.chunkType === 'text' &&
typeof event.data?.content === 'string'
) {
this.accumulatedText += event.data.content;
this.pendingTextEvent = event;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
this.queuePendingTextSnapshot();
}, this.snapshotFlushMs);
return;
}
this.queuePendingTextSnapshot();
this.enqueue(async () => {
await this.sink.ingest([event]);
});
}
async drain(): Promise<void> {
this.queuePendingTextSnapshot();
try {
await this.inflight;
} catch {
// `fatalError` is re-thrown below.
}
if (this.fatalError) throw this.fatalError;
}
private enqueue(task: () => Promise<void>) {
this.inflight = this.inflight.then(task).catch((err) => {
this.fatalError = err instanceof Error ? err : new Error(String(err));
throw this.fatalError;
});
}
private queuePendingTextSnapshot() {
if (!this.pendingTextEvent || this.fatalError) return;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const baseEvent = this.pendingTextEvent;
this.pendingTextEvent = undefined;
const snapshotEvent: AgentStreamEvent = {
...baseEvent,
data: {
...baseEvent.data,
content: this.accumulatedText,
snapshotMode: 'replace',
snapshotSeq: ++this.nextSnapshotSeq,
},
};
this.enqueue(async () => {
await this.sink.ingest([snapshotEvent]);
});
}
}
const exec = async (options: ExecOptions): Promise<void> => {
if (!SUPPORTED_AGENT_TYPES.has(options.type)) {
log.error(
@@ -243,17 +323,22 @@ const exec = async (options: ExecOptions): Promise<void> => {
// server-ingest mode. The tRPC client reads LOBEHUB_JWT (operation-scoped
// JWT injected by the server) for authentication.
const agentType = options.type as 'claude-code' | 'codex';
let sink: InstanceType<typeof TrpcIngestSink> | InstanceType<typeof NoopIngestSink>;
let sink: TrpcIngestSink | undefined;
let serverIngester: SerialServerIngester | undefined;
if (serverIngest) {
const client = await getTrpcClient();
sink = new TrpcIngestSink(client, agentType, operationId, options.topic!);
} else {
sink = new NoopIngestSink();
sink = new TrpcIngestSink(
client,
agentType,
operationId,
options.topic!,
process.env.LOBEHUB_ASSISTANT_MESSAGE_ID,
);
serverIngester = new SerialServerIngester(sink);
}
const ingester = new BatchIngester(sink);
/**
* Spawn one agent process and stream all its events into `ingester`.
* Spawn one agent process and stream all its events into the server ingester.
*
* When `interceptResumeErrors` is true, any `error`-type event whose
* message matches `RESUME_RETRY_PATTERNS` is withheld from the
@@ -297,6 +382,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
// Always pipe to process.stderr too so users see auth prompts / warnings.
const STDERR_CAP = 8 * 1024;
let stderrContent = '';
const stderrEnded = once(handle.stderr, 'end').then(() => undefined);
handle.stderr.on('data', (chunk: Buffer) => {
if (stderrContent.length < STDERR_CAP) {
stderrContent += chunk.toString();
@@ -314,9 +400,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
}
interrupted = true;
handle.kill('SIGINT');
if (serverIngest) {
if (serverIngester && sink) {
try {
await ingester.drain();
await serverIngester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort; process is exiting anyway
@@ -325,9 +411,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
};
const onSigterm = async () => {
handle.kill('SIGTERM');
if (serverIngest) {
if (serverIngester && sink) {
try {
await ingester.drain();
await serverIngester.drain();
await sink.finish({ result: 'cancelled' });
} catch {
// best-effort
@@ -356,16 +442,16 @@ const exec = async (options: ExecOptions): Promise<void> => {
}
}
if (emitJsonl) process.stdout.write(`${JSON.stringify(event)}\n`);
ingester.push(event);
serverIngester?.push(event);
}
} catch (err) {
log.error(
'Stream error from agent process:',
err instanceof Error ? err.message : String(err),
);
if (serverIngest) {
if (serverIngester && sink) {
try {
await ingester.drain();
await serverIngester.drain();
await sink.finish({
error: { message: String(err), type: 'stream_error' },
result: 'error',
@@ -381,6 +467,7 @@ const exec = async (options: ExecOptions): Promise<void> => {
}
const { code, signal } = await handle.exit;
await stderrEnded;
// Fallback stderr detection: CC may exit non-zero without emitting a
// result event (e.g. it writes to stderr and quits immediately).
@@ -451,9 +538,9 @@ const exec = async (options: ExecOptions): Promise<void> => {
const { code, signal, sessionId } = result;
if (serverIngest) {
if (serverIngester && sink) {
try {
await ingester.drain();
await serverIngester.drain();
} catch (err) {
log.error(
'Failed to flush events to server:',
+9 -18
View File
@@ -1,7 +1,5 @@
import { execFileSync } from 'node:child_process';
import { getHermesPort } from './heteroTask';
export interface CheckPlatformCapabilityParams {
platform: 'hermes' | 'openclaw';
}
@@ -42,26 +40,19 @@ export async function checkPlatformCapability(
}
if (platform === 'hermes') {
const port = getHermesPort();
try {
const res = await fetch(`http://localhost:${port}/health`, {
signal: AbortSignal.timeout(5000),
});
if (res.ok) {
let version: string | undefined;
try {
const body = (await res.json()) as { version?: string };
version = body.version;
} catch {
/* ignore parse errors */
}
return { available: true, version };
}
return { available: false, reason: `Hermes gateway returned HTTP ${res.status}` };
const output = execFileSync('hermes', ['--version'], {
encoding: 'utf8',
timeout: 5000,
}).trim();
// output is typically "Hermes Agent vX.Y.Z (...)"
const versionMatch = output.match(/v(\d+\.\d+\.\d+)/);
const version = versionMatch ? versionMatch[1] : output.split(/\s+/).at(-1);
return { available: true, version };
} catch (err) {
return {
available: false,
reason: err instanceof Error ? err.message : `Hermes gateway not reachable on port ${port}`,
reason: err instanceof Error ? err.message : 'hermes not found or failed to run',
};
}
}
+82 -3
View File
@@ -1,5 +1,6 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
@@ -80,13 +81,92 @@ function getOpenClawProfile(agentId?: string): AgentProfileResult {
return { avatar, description, title };
}
/**
* Read the active Hermes profile name from `hermes profile list` output.
* The active profile is marked with ◆ in the first column.
*/
function getActiveHermesProfileName(): string | undefined {
try {
const output = execFileSync('hermes', ['profile', 'list'], {
encoding: 'utf8',
timeout: 5000,
});
const match = output.match(/◆(\S+)/);
return match?.[1];
} catch {
return undefined;
}
}
/**
* Read the filesystem path of a Hermes profile from `hermes profile show <name>`.
*/
function getHermesProfilePath(profileName: string): string | undefined {
try {
const output = execFileSync('hermes', ['profile', 'show', profileName], {
encoding: 'utf8',
timeout: 5000,
});
const match = output.match(/^Path:\s+(.+)/m);
const raw = match?.[1]?.trim();
// Expand leading `~` — Node does not auto-expand home-dir shorthands.
return raw?.replace(/^~(?=\/|$)/, os.homedir());
} catch {
return undefined;
}
}
/**
* Extract a one-line description from a Hermes SOUL.md file.
* Strips HTML comments and Markdown headings, then returns the first
* non-empty line of actual content.
*/
function readHermesSoulDescription(soulPath: string): string | undefined {
try {
const content = fs.readFileSync(soulPath, 'utf8');
// Loop until stable to handle any malformed/nested comment sequences.
let stripped = content;
let previous: string;
do {
previous = stripped;
stripped = stripped
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
} while (stripped !== previous);
const line = stripped
.split('\n')
.map((l) => l.trim())
.find((l) => l.length > 0);
return line || undefined;
} catch {
return undefined;
}
}
function getHermesProfile(): AgentProfileResult {
const profileName = getActiveHermesProfileName();
if (!profileName) return {};
const profilePath = getHermesProfilePath(profileName);
const description = profilePath
? readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
: undefined;
return {
avatar: '⚡',
description,
title: profileName,
};
}
/**
* Fetch the agent profile (title, avatar, description) from the platform
* installed on this device. Dispatched by the server via `device.getAgentProfile`.
*
* - openclaw: `openclaw agents list --json` for name + emoji, workspace
* IDENTITY.md for description fallback
* - hermes: not yet implemented — returns empty profile
* - hermes: active profile name + SOUL.md description
*/
export async function getAgentProfile(params: GetAgentProfileParams): Promise<AgentProfileResult> {
const { platform, agentId } = params;
@@ -96,8 +176,7 @@ export async function getAgentProfile(params: GetAgentProfileParams): Promise<Ag
}
if (platform === 'hermes') {
// Profile fetch not yet implemented for Hermes — return empty
return {};
return getHermesProfile();
}
return {};
+98 -71
View File
@@ -1,4 +1,7 @@
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { RemoteHeterogeneousAgentType } from '@lobechat/heterogeneous-agents';
@@ -6,7 +9,36 @@ import { getTrpcClient } from '../api/client';
import { getTask, listTasks, removeTask, saveTask } from '../daemon/taskRegistry';
import { log } from '../utils/logger';
const DEFAULT_HERMES_PORT = 3456;
// ─── Hermes session persistence ───
// Maps topicId → hermes session_id so multi-turn conversations can resume
// the same session across separate `runHeteroTask` invocations.
const LOBEHUB_DIR_NAME = process.env.LOBEHUB_CLI_HOME || '.lobehub';
const HERMES_SESSIONS_FILE = path.join(os.homedir(), LOBEHUB_DIR_NAME, 'hermes-sessions.json');
function getHermesSessionId(topicId: string): string | undefined {
try {
const data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<
string,
string
>;
return data[topicId];
} catch {
return undefined;
}
}
function saveHermesSessionId(topicId: string, sessionId: string): void {
let data: Record<string, string> = {};
try {
data = JSON.parse(fs.readFileSync(HERMES_SESSIONS_FILE, 'utf8')) as Record<string, string>;
} catch {
// File doesn't exist yet — start fresh.
}
data[topicId] = sessionId;
fs.mkdirSync(path.dirname(HERMES_SESSIONS_FILE), { recursive: true });
fs.writeFileSync(HERMES_SESSIONS_FILE, JSON.stringify(data), 'utf8');
}
/** Resolve the absolute path to the `lh` binary to avoid PATH issues in child processes. */
function resolveLhPath(): string {
@@ -32,40 +64,6 @@ export interface CancelHeteroTaskParams {
taskId: string;
}
export function getHermesPort(): number {
const env = process.env.HERMES_GATEWAY_PORT;
if (env) {
const parsed = Number.parseInt(env, 10);
if (!Number.isNaN(parsed)) return parsed;
}
return DEFAULT_HERMES_PORT;
}
async function isHermesGatewayRunning(port: number): Promise<boolean> {
try {
const res = await fetch(`http://localhost:${port}/health`);
return res.ok;
} catch {
return false;
}
}
async function startHermesGateway(port: number): Promise<void> {
const child = spawn('hermes', ['gateway', 'start'], {
detached: true,
env: { ...process.env },
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
await new Promise<void>((r) => setTimeout(r, 500));
if (await isHermesGatewayRunning(port)) return;
}
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
}
async function sendAutoNotify(
topicId: string,
taskId: string,
@@ -231,37 +229,84 @@ export async function runHeteroTask(params: RunHeteroTaskParams): Promise<string
}
if (agentType === 'hermes') {
const port = getHermesPort();
if (!(await isHermesGatewayRunning(port))) {
log.info(`Hermes gateway not running on port ${port}, starting...`);
await startHermesGateway(port);
// Kill any existing hermes process for this topicId before spawning a new one.
for (const existing of listTasks()) {
if (existing.topicId === topicId && existing.agentType === 'hermes') {
try {
process.kill(existing.pid, 'SIGTERM');
} catch {
// Already exited — nothing to do.
}
removeTask(existing.taskId);
}
}
const res = await fetch(`http://localhost:${port}/message`, {
body: JSON.stringify({ content: prompt, operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
// Resume the previous session for this topic if one exists.
const existingSessionId = getHermesSessionId(topicId);
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
if (existingSessionId) {
hermesArgs.push('--resume', existingSessionId);
}
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
// We capture stdout, parse both fields on exit, and relay the response via notify.
const child = spawn('hermes', hermesArgs, {
cwd: workDir,
detached: true,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'ignore'],
});
if (!res.ok) {
throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
}
const pid = child.pid;
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
child.unref();
// pid is 0 for Hermes — the gateway is long-lived and cancellation uses
// the HTTP /stop API rather than direct signal delivery.
saveTask({
agentId,
agentType,
operationId,
pid: 0,
pid,
startedAt: new Date().toISOString(),
taskId,
topicId,
});
log.info(`Hermes task dispatched: taskId=${taskId} operationId=${operationId}`);
log.info(`Hermes task started: taskId=${taskId} pid=${pid}`);
return JSON.stringify({ operationId, taskId });
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.on('close', (code, signal) => {
removeTask(taskId);
if (code !== 0 || signal !== null) {
const text = signal
? `Task cancelled (signal: ${signal})`
: `Task failed (exit code: ${code})`;
void sendAutoNotify(topicId, taskId, text, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
return;
}
// Parse "session_id: <id>" from the first line, response from the rest.
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
const sessionId = sessionIdMatch?.[1];
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
if (sessionId) saveHermesSessionId(topicId, sessionId);
if (response) {
void sendAutoNotify(topicId, taskId, response, agentId).finally(() =>
sendDoneSignal(topicId, agentId),
);
} else {
void sendDoneSignal(topicId, agentId);
}
});
return JSON.stringify({ pid, taskId });
}
throw new Error(`Unsupported agentType: ${agentType as string}`);
@@ -275,25 +320,7 @@ export async function cancelHeteroTask(params: CancelHeteroTaskParams): Promise<
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
}
if (entry.agentType === 'hermes') {
const port = getHermesPort();
try {
await fetch(`http://localhost:${port}/stop`, {
body: JSON.stringify({ operationId: entry.operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
} catch (err) {
log.warn(
`Failed to send /stop to Hermes gateway: ${err instanceof Error ? err.message : String(err)}`,
);
}
removeTask(taskId);
await sendAutoNotify(entry.topicId, taskId, 'Task cancelled', entry.agentId);
return JSON.stringify({ taskId });
}
// OpenClaw: kill by PID and let the child's close handler send the notify.
// Both openclaw and hermes: kill by PID and let the child's close handler send the notify.
try {
process.kill(entry.pid, signal);
} catch (err) {
+6 -1
View File
@@ -41,6 +41,7 @@ const methodMap: Record<string, (args: any) => Promise<unknown>> = {
export async function executeToolCall(
apiName: string,
argsStr: string,
timeout?: number,
): Promise<{
content: string;
error?: string;
@@ -53,8 +54,12 @@ export async function executeToolCall(
try {
const args = JSON.parse(argsStr);
const finalArgs =
typeof timeout === 'number' && Number.isFinite(timeout) && !('timeout' in args)
? { ...args, timeout }
: args;
const result = await handler(args);
const result = await handler(finalArgs);
const content = typeof result === 'string' ? result : JSON.stringify(result);
return { content, success: true };
+2
View File
@@ -16,6 +16,7 @@ export class TrpcIngestSink implements IngestSink {
private readonly agentType: 'claude-code' | 'codex',
private readonly operationId: string,
private readonly topicId: string,
private readonly assistantMessageId?: string,
) {}
async finish(params: Parameters<IngestSink['finish']>[0]): Promise<void> {
@@ -30,6 +31,7 @@ export class TrpcIngestSink implements IngestSink {
async ingest(events: AgentStreamEvent[]): Promise<void> {
await this.client.aiAgent.heteroIngest.mutate({
agentType: this.agentType,
assistantMessageId: this.assistantMessageId,
events: events as any,
operationId: this.operationId,
topicId: this.topicId,
+1 -1
View File
@@ -104,7 +104,7 @@
"stylelint": "^15.11.0",
"superjson": "^2.2.6",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"undici": "^7.16.0",
"uuid": "^14.0.0",
"vite": "8.0.14",
@@ -1,5 +1,6 @@
import { execFileSync, execSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { AgentRunRequestMessage } from '@lobechat/device-gateway-client';
@@ -13,8 +14,6 @@ import LocalFileCtr from './LocalFileCtr';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
import ShellCommandCtr from './ShellCommandCtr';
const DEFAULT_HERMES_PORT = 3456;
/**
* Inject the lh-notify protocol into the first turn of a new hetero-agent session.
* Tells the agent binary how to push results back to the LobeHub chat UI via `lh notify`.
@@ -66,6 +65,9 @@ export default class GatewayConnectionCtr extends ControllerModule {
/** In-memory registry for running platform agent tasks (openclaw / hermes). */
private readonly platformTasks = new Map<string, PlatformTaskEntry>();
/** Maps topicId → hermes session_id for multi-turn conversation continuity. */
private readonly hermesSessionMap = new Map<string, string>();
// ─── Service Accessor ───
private get service() {
@@ -172,36 +174,25 @@ export default class GatewayConnectionCtr extends ControllerModule {
request: AgentRunRequestMessage,
): Promise<{ reason?: string; status: 'accepted' | 'rejected' }> {
try {
const ctr = this.heterogeneousAgentCtr;
const serverUrl = await this.remoteServerConfigCtr.getRemoteServerUrl();
if (!serverUrl) {
return { reason: 'Remote server URL not configured', status: 'rejected' };
}
// Map agentType to binary name.
// claude-code → `claude` CLI; all other platforms use their type name as the binary.
const command = request.agentType === 'claude-code' ? 'claude' : request.agentType;
// Create a session for the hetero agent.
const { sessionId } = await ctr.startSession({
// Fire-and-forget: lh hetero exec handles spawn -> adapt ->
// BatchIngester -> heteroIngest/heteroFinish -> server -> Gateway -> clients.
// Same command as spawnHeteroSandbox() on the server side.
this.heterogeneousAgentCtr.spawnLhHeteroExec({
agentType: request.agentType,
args: [],
command,
cwd: request.cwd,
// Inject LOBEHUB_JWT so the CLI authenticates against heteroIngest.
env: { LOBEHUB_JWT: request.jwt },
jwt: request.jwt,
operationId: request.operationId,
prompt: request.prompt,
resumeSessionId: request.resumeSessionId,
serverUrl,
topicId: request.topicId,
});
// Fire-and-forget: sendPrompt runs the CLI until completion.
ctr
.sendPrompt({
operationId: request.operationId,
prompt: request.prompt,
sessionId,
})
.catch((err: Error) => {
// Errors are surfaced via heteroFinish on the server side.
// Log locally for desktop debugging only.
console.error('[GatewayConnectionCtr] agent run failed:', err.message);
});
return { status: 'accepted' };
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
@@ -312,10 +303,71 @@ export default class GatewayConnectionCtr extends ControllerModule {
return this.getOpenClawProfile(agentId);
}
// hermes and unknown platforms: not yet implemented
if (platform === 'hermes') {
return this.getHermesProfile();
}
return {};
}
private getHermesProfile(): { avatar?: string; description?: string; title?: string } {
// Find the active profile (marked with ◆ in `hermes profile list`).
let profileName: string | undefined;
try {
const listOutput = execFileSync('hermes', ['profile', 'list'], {
encoding: 'utf8',
timeout: 5000,
});
profileName = listOutput.match(/◆(\S+)/)?.[1];
} catch {
return {};
}
if (!profileName) return {};
// Get the profile's filesystem path.
let profilePath: string | undefined;
try {
const showOutput = execFileSync('hermes', ['profile', 'show', profileName], {
encoding: 'utf8',
timeout: 5000,
});
const raw = showOutput.match(/^Path:\s+(.+)/m)?.[1]?.trim();
profilePath = raw?.replace(/^~(?=\/|$)/, os.homedir());
} catch {
// Profile path unavailable — still return name + avatar.
}
const description = profilePath
? this.readHermesSoulDescription(path.join(profilePath, 'SOUL.md'))
: undefined;
return { avatar: '⚡', description, title: profileName };
}
private readHermesSoulDescription(soulPath: string): string | undefined {
try {
const content = fs.readFileSync(soulPath, 'utf8');
// Loop until stable to handle any malformed/nested comment sequences.
let stripped = content;
let previous: string;
do {
previous = stripped;
stripped = stripped
.replaceAll(/<!--[\s\S]*?-->/g, '') // strip complete HTML comments
.replaceAll(/[<>]/g, '') // strip any remaining HTML delimiter chars
.replaceAll(/^#+\s.*$/gm, ''); // strip Markdown headings
} while (stripped !== previous);
return (
stripped
.split('\n')
.map((l) => l.trim())
.find((l) => l.length > 0) || undefined
);
} catch {
return undefined;
}
}
private getOpenClawProfile(agentId?: string): {
avatar?: string;
description?: string;
@@ -391,6 +443,18 @@ export default class GatewayConnectionCtr extends ControllerModule {
const { agentId, agentType, cwd, operationId, prompt, taskId, topicId } = args;
const workDir = cwd || process.cwd();
const [serverUrl, accessToken] = await Promise.all([
this.remoteServerConfigCtr.getRemoteServerUrl(),
this.remoteServerConfigCtr.getAccessToken(),
]);
// Inject auth into child env so `lh notify` can authenticate without CLI config.
const childEnv: NodeJS.ProcessEnv = {
...process.env,
...(accessToken && { LOBEHUB_JWT: accessToken }),
...(serverUrl && { LOBEHUB_SERVER: serverUrl }),
};
if (agentType === 'openclaw') {
const lhPath = this.resolveLhPath();
const openclawAgent = process.env['OPENCLAW_AGENT_ID'] ?? 'main';
@@ -426,7 +490,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
enrichedPrompt,
'--local',
],
{ cwd: workDir, detached: true, env: { ...process.env }, stdio: 'ignore' },
{ cwd: workDir, detached: true, env: childEnv, stdio: 'ignore' },
);
const pid = child.pid;
@@ -453,20 +517,74 @@ export default class GatewayConnectionCtr extends ControllerModule {
}
if (agentType === 'hermes') {
const port = this.getHermesPort();
if (!(await this.isHermesRunning(port))) {
await this.startHermesGateway(port);
// Kill any existing hermes process for this topicId before spawning a new one.
for (const [existingTaskId, entry] of this.platformTasks) {
if (entry.topicId === topicId && entry.agentType === 'hermes') {
try {
process.kill(entry.pid, 'SIGTERM');
} catch {
// Already exited — nothing to do.
}
this.platformTasks.delete(existingTaskId);
}
}
const res = await fetch(`http://localhost:${port}/message`, {
body: JSON.stringify({ content: prompt, operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!res.ok) throw new Error(`Hermes gateway returned ${res.status}: ${await res.text()}`);
// Resume the previous session for this topic if one exists.
const existingSessionId = this.hermesSessionMap.get(topicId);
const hermesArgs: string[] = ['chat', '--query', prompt, '--quiet', '--accept-hooks'];
if (existingSessionId) {
hermesArgs.push('--resume', existingSessionId);
}
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid: 0, topicId });
return JSON.stringify({ operationId, taskId });
// Hermes prints "session_id: <id>\n<response>" to stdout in --quiet mode.
const child = spawn('hermes', hermesArgs, {
cwd: workDir,
detached: true,
env: childEnv,
stdio: ['ignore', 'pipe', 'ignore'],
});
const pid = child.pid;
if (pid === undefined) throw new Error('Failed to get PID for hermes process');
child.unref();
this.platformTasks.set(taskId, { agentId, agentType, operationId, pid, topicId });
let stdout = '';
child.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString();
});
child.on('close', (code, signal) => {
this.platformTasks.delete(taskId);
if (code !== 0 || signal !== null) {
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 }),
);
return;
}
// Parse "session_id: <id>" from the first line, response from the rest.
const sessionIdMatch = stdout.match(/^session_id:\s*(\S+)/m);
const sessionId = sessionIdMatch?.[1];
const response = stdout.replace(/^session_id:[^\n]*\n?/, '').trim();
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 }),
);
} else {
void this.sendNotify({ agentId, content: '', done: true, role: 'assistant', topicId });
}
});
return JSON.stringify({ pid, taskId });
}
throw new Error(`Unsupported agentType: ${agentType}`);
@@ -480,28 +598,7 @@ export default class GatewayConnectionCtr extends ControllerModule {
return JSON.stringify({ message: `No task found with taskId: ${taskId}`, success: false });
}
if (entry.agentType === 'hermes') {
const port = this.getHermesPort();
try {
await fetch(`http://localhost:${port}/stop`, {
body: JSON.stringify({ operationId: entry.operationId }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
} catch {
// Hermes gateway may already have stopped; ignore
}
this.platformTasks.delete(taskId);
await this.sendNotify({
agentId: entry.agentId,
content: 'Task cancelled',
role: 'assistant',
topicId: entry.topicId,
});
return JSON.stringify({ taskId });
}
// openclaw: kill by PID; the close handler sends the done signal.
// Both openclaw and hermes: kill by PID; the close handler sends the done signal.
try {
process.kill(entry.pid, signal);
} catch {
@@ -536,11 +633,11 @@ export default class GatewayConnectionCtr extends ControllerModule {
]);
if (!serverUrl || !token) return;
await fetch(`${serverUrl}/trpc/agentNotify.notify`, {
await fetch(`${serverUrl}/trpc/lambda/agentNotify.notify`, {
body: JSON.stringify({ json: params }),
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Oidc-Auth': token,
},
method: 'POST',
});
@@ -558,37 +655,4 @@ export default class GatewayConnectionCtr extends ControllerModule {
return 'lh';
}
}
private getHermesPort(): number {
const env = process.env['HERMES_GATEWAY_PORT'];
if (env) {
const parsed = Number.parseInt(env, 10);
if (!Number.isNaN(parsed)) return parsed;
}
return DEFAULT_HERMES_PORT;
}
private async isHermesRunning(port: number): Promise<boolean> {
try {
return (await fetch(`http://localhost:${port}/health`)).ok;
} catch {
return false;
}
}
private async startHermesGateway(port: number): Promise<void> {
const child = spawn('hermes', ['gateway', 'start'], {
detached: true,
env: { ...process.env },
stdio: 'ignore',
});
child.unref();
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
await new Promise<void>((r) => setTimeout(r, 500));
if (await this.isHermesRunning(port)) return;
}
throw new Error(`Hermes gateway did not start within 10s on port ${port}`);
}
}
@@ -1251,4 +1251,69 @@ export default class HeterogeneousAgentCtr extends ControllerModule {
process.on('SIGTERM', onSignal);
process.on('SIGINT', onSignal);
}
/**
* Spawn `lh hetero exec` for gateway-driven agent runs.
* The `lh` CLI handles everything downstream — no local
* AgentStreamPipeline or IPC broadcast needed. Mirrors
* `spawnHeteroSandbox()` on the server side.
*/
spawnLhHeteroExec(params: {
agentType: string;
cwd?: string;
jwt: string;
operationId: string;
prompt: string;
resumeSessionId?: string;
serverUrl: string;
topicId: string;
}): void {
const { agentType, cwd, jwt, operationId, prompt, resumeSessionId, serverUrl, topicId } =
params;
const workDir = cwd ?? process.cwd();
const args = [
'hetero',
'exec',
'--type',
agentType,
'--operation-id',
operationId,
'--topic',
topicId,
'--render',
'none',
'--input-json',
'-',
'--cwd',
workDir,
...(resumeSessionId ? ['--resume', resumeSessionId] : []),
];
const env = {
...process.env,
...buildProxyEnv(this.app.storeManager.get('networkProxy')),
LOBEHUB_JWT: jwt,
LOBEHUB_SERVER: serverUrl,
};
logger.info('spawnLhHeteroExec: type=%s op=%s topic=%s', agentType, operationId, topicId);
const child = spawn('lh', args, {
cwd: workDir,
env,
stdio: ['pipe', 'inherit', 'inherit'],
});
child.stdin.write(JSON.stringify(prompt));
child.stdin.end();
child.on('error', (err) => {
logger.error('spawnLhHeteroExec: spawn failed — %s', err.message);
});
child.on('exit', (code, signal) => {
logger.info('spawnLhHeteroExec: exited — op=%s code=%s signal=%s', operationId, code, signal);
});
}
}
@@ -248,14 +248,15 @@ export default class LocalFileCtr extends ControllerModule {
error?: string;
success: boolean;
}> {
logger.debug('Attempting to open file:', { filePath });
const resolvedPath = expandTilde(filePath) ?? filePath;
logger.debug('Attempting to open file:', { filePath: resolvedPath });
try {
await shell.openPath(filePath);
logger.debug('File opened successfully:', { filePath });
await shell.openPath(resolvedPath);
logger.debug('File opened successfully:', { filePath: resolvedPath });
return { success: true };
} catch (error) {
logger.error(`Failed to open file ${filePath}:`, error);
logger.error(`Failed to open file ${resolvedPath}:`, error);
return { error: (error as Error).message, success: false };
}
}
@@ -265,8 +266,13 @@ export default class LocalFileCtr extends ControllerModule {
error?: string;
success: boolean;
}> {
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
logger.debug('Attempting to open folder:', { folderPath, isDirectory, targetPath });
const resolvedTarget = expandTilde(targetPath) ?? targetPath;
const folderPath = isDirectory ? resolvedTarget : path.dirname(resolvedTarget);
logger.debug('Attempting to open folder:', {
folderPath,
isDirectory,
targetPath: resolvedTarget,
});
try {
await shell.openPath(folderPath);
@@ -200,11 +200,13 @@ const mockShellCommandCtr = {
const mockHeterogeneousAgentCtr = {
sendPrompt: vi.fn().mockResolvedValue(undefined),
spawnLhHeteroExec: vi.fn(),
startSession: vi.fn().mockResolvedValue({ sessionId: 'mock-session-id' }),
} as unknown as HeterogeneousAgentCtr;
const mockRemoteServerConfigCtr = {
getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
getRemoteServerUrl: vi.fn().mockResolvedValue('https://server.example.com'),
isRemoteServerConfigured: vi.fn().mockResolvedValue(true),
refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
} as unknown as RemoteServerConfigCtr;
@@ -631,26 +633,23 @@ describe('GatewayConnectionCtr', () => {
}
beforeEach(() => {
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockClear();
vi.mocked(mockHeterogeneousAgentCtr.sendPrompt).mockClear();
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockClear();
});
it.each([
['openclaw', 'openclaw'],
['hermes', 'hermes'],
['codex', 'codex'],
['claude-code', 'claude'],
] as const)('uses command "%s" for agentType "%s"', async (agentType, expectedCommand) => {
const client = await connectAndOpen();
client.simulateAgentRunRequest(agentType);
await vi.advanceTimersByTimeAsync(0);
it.each(['openclaw', 'hermes', 'codex', 'claude-code'] as const)(
'forwards agentType "%s" to spawnLhHeteroExec',
async (agentType) => {
const client = await connectAndOpen();
client.simulateAgentRunRequest(agentType);
await vi.advanceTimersByTimeAsync(0);
expect(mockHeterogeneousAgentCtr.startSession).toHaveBeenCalledWith(
expect.objectContaining({ agentType, command: expectedCommand }),
);
});
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
expect.objectContaining({ agentType }),
);
},
);
it('sends accepted ack and fires sendPrompt', async () => {
it('sends accepted ack and spawns lh hetero exec', async () => {
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-xyz');
await vi.advanceTimersByTimeAsync(0);
@@ -659,15 +658,37 @@ describe('GatewayConnectionCtr', () => {
operationId: 'op-xyz',
status: 'accepted',
});
expect(mockHeterogeneousAgentCtr.sendPrompt).toHaveBeenCalledWith(
expect.objectContaining({ operationId: 'op-xyz', sessionId: 'mock-session-id' }),
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).toHaveBeenCalledWith(
expect.objectContaining({
agentType: 'openclaw',
jwt: 'mock-jwt',
operationId: 'op-xyz',
prompt: 'hello',
serverUrl: 'https://server.example.com',
topicId: 'topic-1',
}),
);
});
it('sends rejected ack when startSession throws', async () => {
vi.mocked(mockHeterogeneousAgentCtr.startSession).mockRejectedValueOnce(
new Error('binary not found'),
);
it('sends rejected ack when remote server URL is not configured', async () => {
vi.mocked(mockRemoteServerConfigCtr.getRemoteServerUrl).mockResolvedValueOnce('');
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-fail');
await vi.advanceTimersByTimeAsync(0);
expect(client.sendAgentRunAck).toHaveBeenCalledWith({
operationId: 'op-fail',
reason: 'Remote server URL not configured',
status: 'rejected',
});
expect(mockHeterogeneousAgentCtr.spawnLhHeteroExec).not.toHaveBeenCalled();
});
it('sends rejected ack when spawnLhHeteroExec throws', async () => {
vi.mocked(mockHeterogeneousAgentCtr.spawnLhHeteroExec).mockImplementationOnce(() => {
throw new Error('binary not found');
});
const client = await connectAndOpen();
client.simulateAgentRunRequest('openclaw', 'op-fail');
@@ -143,6 +143,17 @@ describe('LocalFileCtr', () => {
expect(result).toEqual({ success: false, error: 'Failed to open' });
});
it('should expand a leading ~ to the user home directory', async () => {
const os = await import('node:os');
const path = await import('node:path');
vi.mocked(mockShell.openPath).mockResolvedValue('');
const result = await localFileCtr.handleOpenLocalFile({ path: '~/git/work/file.txt' });
expect(result).toEqual({ success: true });
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work/file.txt'));
});
});
describe('handleOpenLocalFolder', () => {
@@ -158,6 +169,20 @@ describe('LocalFileCtr', () => {
expect(mockShell.openPath).toHaveBeenCalledWith('/test/folder');
});
it('should expand a leading ~ when opening a directory', async () => {
const os = await import('node:os');
const path = await import('node:path');
vi.mocked(mockShell.openPath).mockResolvedValue('');
const result = await localFileCtr.handleOpenLocalFolder({
path: '~/git/work',
isDirectory: true,
});
expect(result).toEqual({ success: true });
expect(mockShell.openPath).toHaveBeenCalledWith(path.join(os.homedir(), 'git/work'));
});
it('should open parent directory when isDirectory is false', async () => {
vi.mocked(mockShell.openPath).mockResolvedValue('');
@@ -29,10 +29,6 @@ vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
vi.mock('node:crypto', () => ({
randomUUID: vi.fn(() => 'test-uuid-123'),
}));
vi.mock('../CliCtr', () => ({
default: class CliCtr {},
}));
@@ -59,7 +55,9 @@ describe('ShellCommandCtr (thin wrapper)', () => {
mockChildProcess = {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
off: vi.fn(),
on: vi.fn(),
once: vi.fn(),
kill: vi.fn(),
exitCode: null,
};
@@ -73,6 +71,10 @@ describe('ShellCommandCtr (thin wrapper)', () => {
if (event === 'exit') setTimeout(() => callback(0), 10);
return mockChildProcess;
});
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
if (event === 'exit') setTimeout(() => callback(0), 10);
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') setTimeout(() => callback(Buffer.from('output\n')), 5);
return mockChildProcess.stdout;
@@ -89,14 +91,21 @@ describe('ShellCommandCtr (thin wrapper)', () => {
});
it('should delegate handleGetCommandOutput to processManager', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.on.mockImplementation((event: string, callback: any) => {
if (event === 'exit') setTimeout(() => callback(0), 10);
return mockChildProcess;
});
mockChildProcess.once.mockImplementation((event: string, callback: any) => {
if (event === 'exit') setTimeout(() => callback(0), 10);
return mockChildProcess;
});
mockChildProcess.stdout.on.mockImplementation((event: string, callback: any) => {
if (event === 'data') setTimeout(() => callback(Buffer.from('bg output\n')), 5);
return mockChildProcess.stdout;
});
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
await ctr.handleRunCommand({
const runResult = await ctr.handleRunCommand({
command: 'test',
run_in_background: true,
});
@@ -104,7 +113,7 @@ describe('ShellCommandCtr (thin wrapper)', () => {
await new Promise((r) => setTimeout(r, 20));
const result = await ctr.handleGetCommandOutput({
shell_id: 'test-uuid-123',
shell_id: runResult.shell_id!,
});
expect(result.success).toBe(true);
@@ -113,16 +122,17 @@ describe('ShellCommandCtr (thin wrapper)', () => {
it('should delegate handleKillCommand to processManager', async () => {
mockChildProcess.on.mockImplementation(() => mockChildProcess);
mockChildProcess.once.mockImplementation(() => mockChildProcess);
mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout);
mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr);
await ctr.handleRunCommand({
const runResult = await ctr.handleRunCommand({
command: 'test',
run_in_background: true,
});
const result = await ctr.handleKillCommand({
shell_id: 'test-uuid-123',
shell_id: runResult.shell_id!,
});
expect(result.success).toBe(true);
@@ -7,7 +7,7 @@ import { app, protocol } from 'electron';
import { LOCAL_FILE_PROTOCOL_HOST, LOCAL_FILE_PROTOCOL_SCHEME } from '@/const/protocol';
import { createLogger } from '@/utils/logger';
import { getExportMimeType } from '../../utils/mime';
import { resolveLocalFileMimeType } from '../../utils/mime';
const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
allowServiceWorkers: false,
@@ -22,20 +22,6 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTRA_MIME_TYPES: Record<string, string> = {
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
};
const getMimeType = (filePath: string): string => {
const ext = path.extname(filePath).toLowerCase();
return getExportMimeType(filePath) ?? EXTRA_MIME_TYPES[ext] ?? 'application/octet-stream';
};
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
return path.isAbsolute(normalized) ? normalized : null;
@@ -130,7 +116,7 @@ export class LocalFileProtocolManager {
const buffer = await readFile(realResolvedPath);
const headers = new Headers();
headers.set('Content-Type', getMimeType(realResolvedPath));
headers.set('Content-Type', resolveLocalFileMimeType(realResolvedPath, buffer));
headers.set('Content-Length', String(buffer.byteLength));
// Local files are immutable from the renderer's perspective for a
// single preview session; allow short-lived caching to avoid
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import { getExportMimeType, resolveLocalFileMimeType } from '../mime';
describe('getExportMimeType', () => {
it('returns the whitelisted MIME for a known extension', () => {
expect(getExportMimeType('/abs/path/App.tsx')).toBe('text/plain; charset=utf-8');
expect(getExportMimeType('icon.png')).toBe('image/png');
});
it('returns undefined for unmapped extensions', () => {
expect(getExportMimeType('.releaserc.cjs')).toBeUndefined();
expect(getExportMimeType('Makefile')).toBeUndefined();
});
});
describe('resolveLocalFileMimeType', () => {
it('uses the whitelist for known source extensions', () => {
expect(resolveLocalFileMimeType('/repo/App.tsx', Buffer.from(''))).toBe(
'text/plain; charset=utf-8',
);
expect(resolveLocalFileMimeType('/repo/data.json', Buffer.from('{}'))).toBe(
'application/json; charset=utf-8',
);
});
it('serves preview-only image formats with their image MIME', () => {
expect(resolveLocalFileMimeType('/repo/photo.heic', Buffer.from([0xff, 0xd8]))).toBe(
'image/heic',
);
expect(resolveLocalFileMimeType('/repo/diagram.bmp', Buffer.from([0x42, 0x4d]))).toBe(
'image/bmp',
);
});
it('treats unmapped text-looking files (.cjs/.mjs) as text via the sniff fallback', () => {
const cjsContent = Buffer.from(`module.exports = { plugins: ['@semantic-release/npm'] };\n`);
expect(resolveLocalFileMimeType('/repo/.releaserc.cjs', cjsContent)).toBe(
'text/plain; charset=utf-8',
);
const mjsContent = Buffer.from(`export default { settings: ['emoji'] };\n`);
expect(resolveLocalFileMimeType('/repo/.remarkrc.mjs', mjsContent)).toBe(
'text/plain; charset=utf-8',
);
});
it('treats no-extension config files as text via the sniff fallback', () => {
const editorconfig = Buffer.from('root = true\n[*]\nindent_style = space\n');
expect(resolveLocalFileMimeType('/repo/.editorconfig', editorconfig)).toBe(
'text/plain; charset=utf-8',
);
});
it('falls back to application/octet-stream when the sniff detects binary data', () => {
// Embedded null byte → sniff classifies as binary.
const binary = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x01, 0x02, 0x03]);
expect(resolveLocalFileMimeType('/repo/strange.blob', binary)).toBe('application/octet-stream');
});
it('forces known-binary extensions to octet-stream even when the prefix sniffs as text', () => {
// PDF header + xref + dictionary is pure ASCII for the first few KB —
// sniff would classify this as text without the extension short-circuit.
const pdfPrintablePrefix = Buffer.from(
'%PDF-1.7\n%\xC4\xE5\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n',
);
expect(resolveLocalFileMimeType('/repo/manual.pdf', pdfPrintablePrefix)).toBe(
'application/octet-stream',
);
// No null bytes in the first 8KB; without the short-circuit this would
// also be misclassified as text.
const fakeZipPrefix = Buffer.from('PK\x03\x04' + 'A'.repeat(64));
expect(resolveLocalFileMimeType('/repo/bundle.zip', fakeZipPrefix)).toBe(
'application/octet-stream',
);
const fakeMp3Prefix = Buffer.from('ID3' + 'A'.repeat(64));
expect(resolveLocalFileMimeType('/repo/song.mp3', fakeMp3Prefix)).toBe(
'application/octet-stream',
);
});
});
+152 -46
View File
@@ -1,50 +1,156 @@
import path from 'node:path';
export const getExportMimeType = (filePath: string) => {
const ext = path.extname(filePath).toLowerCase();
import { sniffBinaryBuffer } from '@lobechat/file-loaders';
const map: Record<string, string> = {
'.bash': 'text/plain; charset=utf-8',
'.c': 'text/plain; charset=utf-8',
'.cpp': 'text/plain; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.dockerfile': 'text/plain; charset=utf-8',
'.fish': 'text/plain; charset=utf-8',
'.gif': 'image/gif',
'.go': 'text/plain; charset=utf-8',
'.graphql': 'application/graphql; charset=utf-8',
'.h': 'text/plain; charset=utf-8',
'.hpp': 'text/plain; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.jsx': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.log': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.mdx': 'text/markdown; charset=utf-8',
'.mp4': 'video/mp4',
'.png': 'image/png',
'.py': 'text/plain; charset=utf-8',
'.rs': 'text/plain; charset=utf-8',
'.sh': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
'.toml': 'application/toml; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
'.tsx': 'text/plain; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.yaml': 'application/yaml; charset=utf-8',
'.yml': 'application/yaml; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.zsh': 'text/plain; charset=utf-8',
};
return map[ext];
const EXPORT_MIME_MAP: Record<string, string> = {
'.bash': 'text/plain; charset=utf-8',
'.c': 'text/plain; charset=utf-8',
'.cpp': 'text/plain; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.dockerfile': 'text/plain; charset=utf-8',
'.fish': 'text/plain; charset=utf-8',
'.gif': 'image/gif',
'.go': 'text/plain; charset=utf-8',
'.graphql': 'application/graphql; charset=utf-8',
'.h': 'text/plain; charset=utf-8',
'.hpp': 'text/plain; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.js': 'application/javascript; charset=utf-8',
'.jsx': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.log': 'text/plain; charset=utf-8',
'.map': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.mdx': 'text/markdown; charset=utf-8',
'.mp4': 'video/mp4',
'.png': 'image/png',
'.py': 'text/plain; charset=utf-8',
'.rs': 'text/plain; charset=utf-8',
'.sh': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
'.toml': 'application/toml; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
'.tsx': 'text/plain; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.yaml': 'application/yaml; charset=utf-8',
'.yml': 'application/yaml; charset=utf-8',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.zsh': 'text/plain; charset=utf-8',
};
/**
* Lookup table for renderer-bundled assets. The set of extensions is closed
* (whatever `electron-vite` produces under the renderer dir), so a whitelist
* is appropriate here.
*/
export const getExportMimeType = (filePath: string): string | undefined => {
const ext = path.extname(filePath).toLowerCase();
return EXPORT_MIME_MAP[ext];
};
// Image formats we render natively in the preview pane but don't ship as
// bundled assets — kept separate from EXPORT_MIME_MAP so RendererProtocolManager
// stays minimal.
const PREVIEW_IMAGE_MIME_MAP: Record<string, string> = {
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.tif': 'image/tiff',
'.tiff': 'image/tiff',
};
// Extensions whose contents are binary even when the first 8KB sniffs as
// printable ASCII. The classic case is PDF: header + xref + dictionary are
// ASCII and the compressed streams live deeper in the file, so the sniff
// misses the binary body and would otherwise serve the file as text/plain
// — the renderer then hands it to a text highlighter and shows garbage.
//
// Only formats where the printable-prefix problem is realistic need to be
// listed; truly binary blobs with early null bytes still get caught by the
// sniff fallback.
const KNOWN_BINARY_EXTENSIONS = new Set<string>([
// Documents
'.doc',
'.pdf',
'.ppt',
'.xls',
// Archives
'.7z',
'.bz2',
'.gz',
'.rar',
'.tar',
'.tgz',
'.zip',
// Executables / libraries
'.class',
'.dll',
'.dylib',
'.exe',
'.jar',
'.so',
'.war',
'.wasm',
// Disk / database images
'.bin',
'.dat',
'.db',
'.dmg',
'.iso',
'.sqlite',
'.sqlite3',
// Audio / video not already mapped above
'.aac',
'.avi',
'.flac',
'.m4a',
'.mkv',
'.mov',
'.mp3',
'.ogg',
'.opus',
'.wav',
'.webm',
// Design files
'.ai',
'.fig',
'.psd',
'.sketch',
]);
const SNIFF_BYTES = 8192;
const TEXT_FALLBACK_MIME = 'text/plain; charset=utf-8';
const BINARY_FALLBACK_MIME = 'application/octet-stream';
/**
* Resolve the MIME type to serve for a local file preview.
*
* 1. Known source/image extensions go through the whitelist for a stable,
* accurate type (e.g. `.ts` → `text/plain`, not `video/mp2t`).
* 2. Known-binary extensions (PDF, archives, executables, media, …)
* short-circuit to `application/octet-stream`. Their first 8KB can be
* printable ASCII (PDFs are the canonical offender) and we don't want
* the sniff to mistakenly route them through the text highlighter.
* 3. Anything else — no extension, `.cjs` / `.mjs`, `.lock`, `.editorconfig`,
* an arbitrary user file — falls through to a binary sniff on the first
* 8KB. Text → `text/plain`, otherwise `application/octet-stream`. This
* removes the need to maintain an exhaustive text-extension allow-list.
*/
export const resolveLocalFileMimeType = (filePath: string, buffer: Buffer): string => {
const ext = path.extname(filePath).toLowerCase();
const fromWhitelist = EXPORT_MIME_MAP[ext] ?? PREVIEW_IMAGE_MIME_MAP[ext];
if (fromWhitelist) return fromWhitelist;
if (KNOWN_BINARY_EXTENSIONS.has(ext)) return BINARY_FALLBACK_MIME;
const { isBinary } = sniffBinaryBuffer(buffer.subarray(0, SNIFF_BYTES));
return isBinary ? BINARY_FALLBACK_MIME : TEXT_FALLBACK_MIME;
};
+104 -65
View File
@@ -1,3 +1,4 @@
import { Select } from '@base-ui/react/select';
import type {
OverlayCaptureUploadStatus,
ScreenCaptureAgentOption,
@@ -5,9 +6,8 @@ import type {
ScreenCaptureOverlayTheme,
} from '@lobechat/electron-client-ipc';
import { ModelIcon } from '@lobehub/icons';
import { AlertCircleIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
import { AlertCircleIcon, CheckIcon, ChevronDownIcon, Loader2Icon, XIcon } from 'lucide-react';
import type {
ChangeEvent as ReactChangeEvent,
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
@@ -248,7 +248,7 @@ const ChatPanel = memo<ChatPanelProps>(
};
}, [activeSelection, dock]);
const themeStyle = useMemo<CSSProperties | undefined>(() => {
const themeVars = useMemo<Record<string, string> | undefined>(() => {
if (!theme) return undefined;
return {
@@ -268,9 +268,24 @@ const ChatPanel = memo<ChatPanelProps>(
'--lobe-overlay-text-quaternary': theme.colorTextQuaternary,
'--lobe-overlay-text-secondary': theme.colorTextSecondary,
'--lobe-overlay-text-tertiary': theme.colorTextTertiary,
} as CSSProperties;
};
}, [theme]);
const themeStyle = themeVars as CSSProperties | undefined;
useEffect(() => {
if (!themeVars) return;
const root = document.documentElement;
for (const [key, value] of Object.entries(themeVars)) {
root.style.setProperty(key, value);
}
return () => {
for (const key of Object.keys(themeVars)) {
root.style.removeProperty(key);
}
};
}, [themeVars]);
useLayoutEffect(() => {
if (!hidden && textareaRef.current) {
textareaRef.current.focus();
@@ -332,12 +347,12 @@ const ChatPanel = memo<ChatPanelProps>(
const canSend = selected && prompt.trim().length > 0 && allUploadsReady;
const handleAgentChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
setAgentId(e.target.value || undefined);
const handleAgentChange = useCallback((value: string) => {
setAgentId(value || undefined);
}, []);
const handleModelChange = useCallback((e: ReactChangeEvent<HTMLSelectElement>) => {
setModelId(e.target.value || undefined);
const handleModelChange = useCallback((value: string) => {
setModelId(value || undefined);
}, []);
const hasAgents = !!agents && agents.length > 0;
@@ -467,71 +482,95 @@ const ChatPanel = memo<ChatPanelProps>(
<div className={styles.actionBar}>
<div className={styles.actionBarLeft}>
<label
aria-label={OVERLAY_COPY.agentSelectLabel}
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
<Select.Root
disabled={!hasAgents}
value={agentId ?? ''}
onValueChange={handleAgentChange}
>
<OverlayAvatar
avatar={currentAgent?.avatar}
background={currentAgent?.backgroundColor}
size={18}
title={currentAgent?.title}
/>
<span className={styles.chipLabel}>
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
</span>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
<select
<Select.Trigger
aria-label={OVERLAY_COPY.agentSelectLabel}
className={styles.nativeSelect}
disabled={!hasAgents}
value={agentId ?? ''}
onChange={handleAgentChange}
className={cn(styles.selectChip, !hasAgents && styles.selectChipDisabled)}
>
{!hasAgents && <option value="">{OVERLAY_COPY.agentSelectPlaceholder}</option>}
{agents?.map((item) => (
<option key={item.id} value={item.id}>
{item.avatar && typeof item.avatar === 'string' && item.avatar.length <= 4
? `${item.avatar} ${item.title}`
: item.title}
</option>
))}
</select>
</label>
<OverlayAvatar
avatar={currentAgent?.avatar}
background={currentAgent?.backgroundColor}
size={18}
title={currentAgent?.title}
/>
<Select.Value className={styles.chipLabel}>
{currentAgent?.title ?? OVERLAY_COPY.agentSelectPlaceholder}
</Select.Value>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
</Select.Trigger>
<Select.Portal>
<Select.Positioner
align="start"
className={styles.popupPositioner}
sideOffset={6}
>
<Select.Popup className={styles.popup}>
{agents?.map((item) => (
<Select.Item className={styles.popupItem} key={item.id} value={item.id}>
<Select.ItemIndicator className={styles.popupItemIndicator}>
<CheckIcon size={12} strokeWidth={2.4} />
</Select.ItemIndicator>
<Select.ItemText>
{item.avatar &&
typeof item.avatar === 'string' &&
item.avatar.length <= 4
? `${item.avatar} ${item.title}`
: item.title}
</Select.ItemText>
</Select.Item>
))}
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
{showModelSelector && (
<label
aria-label={OVERLAY_COPY.modelSelectLabel}
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
<Select.Root
disabled={!hasModels}
value={modelId ?? ''}
onValueChange={handleModelChange}
>
{currentModel ? (
<span className={styles.modelIconBox}>
<ModelIcon model={currentModel.id} size={16} />
</span>
) : (
<span className={styles.modelIconBoxFallback} />
)}
<span className={styles.chipLabel}>
{currentModel?.displayName ??
currentModel?.id ??
OVERLAY_COPY.modelSelectPlaceholder}
</span>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
<select
<Select.Trigger
aria-label={OVERLAY_COPY.modelSelectLabel}
className={styles.nativeSelect}
disabled={!hasModels}
value={modelId ?? ''}
onChange={handleModelChange}
className={cn(styles.selectChip, !hasModels && styles.selectChipDisabled)}
>
{!hasModels && <option value="">{OVERLAY_COPY.modelSelectPlaceholder}</option>}
{models?.map((item) => (
<option key={item.id} value={item.id}>
{item.displayName ?? item.id}
</option>
))}
</select>
</label>
{currentModel ? (
<span className={styles.modelIconBox}>
<ModelIcon model={currentModel.id} size={16} />
</span>
) : (
<span className={styles.modelIconBoxFallback} />
)}
<Select.Value className={styles.chipLabel}>
{currentModel?.displayName ??
currentModel?.id ??
OVERLAY_COPY.modelSelectPlaceholder}
</Select.Value>
<ChevronDownIcon className={styles.chevron} size={12} strokeWidth={2} />
</Select.Trigger>
<Select.Portal>
<Select.Positioner
align="start"
className={styles.popupPositioner}
sideOffset={6}
>
<Select.Popup className={styles.popup}>
{models?.map((item) => (
<Select.Item className={styles.popupItem} key={item.id} value={item.id}>
<Select.ItemIndicator className={styles.popupItemIndicator}>
<CheckIcon size={12} strokeWidth={2.4} />
</Select.ItemIndicator>
<Select.ItemText>{item.displayName ?? item.id}</Select.ItemText>
</Select.Item>
))}
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>
)}
</div>
+54
View File
@@ -534,3 +534,57 @@ globalStyle(`.${multiSelectionRail}::-webkit-scrollbar`, {
globalStyle(`.${textarea}::selection`, {
background: 'color-mix(in srgb, var(--lobe-overlay-primary) 22%, transparent)',
});
export const popupPositioner = style({
outline: 'none',
zIndex: 114_514,
});
export const popup = style({
background: v(vars.colorBgElevated),
border: `1px solid ${v(vars.colorBorderSecondary)}`,
borderRadius: 10,
boxShadow: v(vars.panelShadow),
color: v(vars.colorText),
fontSize: 12,
maxHeight: 240,
minWidth: 180,
outline: 'none',
overflowY: 'auto',
padding: 4,
});
export const popupItem = style({
alignItems: 'center',
borderRadius: 6,
color: v(vars.colorText),
cursor: 'pointer',
display: 'flex',
gap: 6,
outline: 'none',
padding: '6px 8px 6px 24px',
position: 'relative',
userSelect: 'none',
selectors: {
'&[data-highlighted]': {
background: v(vars.colorFillTertiary),
},
'&[data-disabled]': {
cursor: 'not-allowed',
opacity: 0.45,
},
},
});
export const popupItemIndicator = style({
alignItems: 'center',
color: v(vars.colorPrimary),
display: 'inline-flex',
height: 12,
justifyContent: 'center',
left: 6,
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: 12,
});
+1 -1
View File
@@ -16,7 +16,7 @@
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.12.19",
"@cloudflare/workers-types": "^4.20260301.1",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"vitest": "~3.2.4",
"wrangler": "^4.70.0"
}
@@ -427,10 +427,10 @@ When('用户选择删除选项', async function (this: CustomWorld) {
When('用户确认删除', async function (this: CustomWorld) {
console.log(' 📍 Step: 确认删除...');
// A confirmation modal should appear
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
const confirmButton = this.page
.getByRole('dialog')
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
// Wait for modal to appear
await expect(confirmButton).toBeVisible({ timeout: 5000 });
await confirmButton.click();
+3 -1
View File
@@ -294,7 +294,9 @@ When('用户在菜单中选择删除', async function (this: CustomWorld) {
When('用户在弹窗中确认删除', async function (this: CustomWorld) {
console.log(' 📍 Step: 确认删除...');
const confirmButton = this.page.locator('.ant-modal-confirm-btns button.ant-btn-dangerous');
const confirmButton = this.page
.getByRole('dialog')
.getByRole('button', { name: /^(ok|delete|删除|确认|确定)$/i });
await expect(confirmButton).toBeVisible({ timeout: 5000 });
await confirmButton.click();
await this.page.waitForTimeout(500);
+30
View File
@@ -100,6 +100,35 @@
"channel.groupPolicyOpenHint": "Respond in any group, channel, or thread",
"channel.historyLimit": "History Message Limit",
"channel.historyLimitHint": "Default number of messages to fetch when reading channel history",
"channel.imessage.applicationIdHint": "A stable identifier shared by the cloud channel and the Desktop bridge.",
"channel.imessage.applicationIdPlaceholder": "e.g. home-mac-mini",
"channel.imessage.blueBubblesPassword": "BlueBubbles Password",
"channel.imessage.blueBubblesPasswordHint": "Stored locally in LobeHub Desktop and used only to call the local BlueBubbles server.",
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
"channel.imessage.blueBubblesServerUrlHint": "The local BlueBubbles server URL reachable from this Desktop app.",
"channel.imessage.bridgeEnabled": "Enable Bridge",
"channel.imessage.bridgeEnabledHint": "When enabled, LobeHub Desktop receives local BlueBubbles webhooks and forwards them to LobeHub.",
"channel.imessage.bridgeMissingApplicationId": "Enter the Application ID first.",
"channel.imessage.bridgeMissingPassword": "Enter the BlueBubbles password first.",
"channel.imessage.bridgeMissingServerUrl": "Enter the BlueBubbles Server URL first.",
"channel.imessage.bridgeMissingWebhookSecret": "Enter the Webhook Secret first.",
"channel.imessage.bridgePasswordSavedPlaceholder": "Leave blank to keep the saved password",
"channel.imessage.bridgeRefresh": "Refresh",
"channel.imessage.bridgeRefreshFailed": "Failed to refresh iMessage Desktop bridge",
"channel.imessage.bridgeRunning": "Running",
"channel.imessage.bridgeSave": "Save Bridge",
"channel.imessage.bridgeSaveFailed": "Failed to save iMessage Desktop bridge",
"channel.imessage.bridgeSaved": "iMessage Desktop bridge saved",
"channel.imessage.bridgeStopped": "Stopped",
"channel.imessage.bridgeTest": "Test BlueBubbles",
"channel.imessage.bridgeTestFailed": "BlueBubbles test failed",
"channel.imessage.bridgeTestSuccess": "BlueBubbles connection passed",
"channel.imessage.description": "Connect this assistant to iMessage through the local LobeHub Desktop BlueBubbles bridge.",
"channel.imessage.desktopBridge": "Desktop Bridge",
"channel.imessage.desktopDeviceId": "Desktop Device ID",
"channel.imessage.desktopDeviceIdHint": "The LobeHub Desktop device that runs the local BlueBubbles bridge. Find it in Desktop Gateway settings.",
"channel.imessage.webhookSecret": "Webhook Secret",
"channel.imessage.webhookSecretHint": "A shared secret used between LobeHub Desktop and the cloud webhook. Use the same value in the Desktop bridge config.",
"channel.importConfig": "Import Configuration",
"channel.importFailed": "Failed to import configuration",
"channel.importInvalidFormat": "Invalid configuration file format",
@@ -176,6 +205,7 @@
"channel.userIdHint": "Lets AI tools reach you proactively (e.g. reminders); auto-trusted by the global allowlist",
"channel.userIdHint.discord": "Enable Developer Mode (Settings → Advanced), then right-click your avatar → Copy User ID.",
"channel.userIdHint.feishu": "Open your app on the Feishu / Lark Open Platform → Permissions, then look up your Open ID.",
"channel.userIdHint.imessage": "Use your iMessage handle as seen in BlueBubbles, usually an email address or E.164 phone number.",
"channel.userIdHint.line": "Open the LINE Developers Console → your channel → Basic settings tab, and copy \"Your user ID\" (starts with U, 33 chars).",
"channel.userIdHint.qq": "Your QQ number, shown on your QQ profile page.",
"channel.userIdHint.slack": "Open your Slack profile → ⋮ More → Copy member ID (starts with U).",
+20 -7
View File
@@ -28,8 +28,8 @@
"agentSignal.receipts.memory.detail": "Saved this for future replies",
"agentSignal.receipts.memory.title": "Memory saved",
"agentSignal.receipts.recentActivity": "Recent activity",
"agentSignal.receipts.skill.detail": "Improved how this assistant handles similar requests",
"agentSignal.receipts.skill.title": "Skill updated",
"agentSignal.receipts.skill.detail": "Self-refined how this agent handles similar requests",
"agentSignal.receipts.skill.title": "Auto-learned a new skill",
"agents": "Agents",
"artifact.generating": "Generating",
"artifact.inThread": "Cannot view in subtopic, please switch to the main conversation area to open",
@@ -208,6 +208,17 @@
"heteroAgent.cloudRepo.noRepos": "No repositories configured. Add them in agent settings.",
"heteroAgent.cloudRepo.notSet": "No repo selected",
"heteroAgent.cloudRepo.sectionTitle": "Repositories",
"heteroAgent.executionTarget.infoTooltip": "Pick a remote device to drive that machine from the web. \"This device\" runs the agent locally and is only available inside the desktop app.",
"heteroAgent.executionTarget.loading": "Loading devices…",
"heteroAgent.executionTarget.local": "This device",
"heteroAgent.executionTarget.localDesc": "Run as a local process on this desktop app",
"heteroAgent.executionTarget.noDevices": "No remote devices yet. Install the desktop app or run `lh connect` on another machine.",
"heteroAgent.executionTarget.offline": "Offline",
"heteroAgent.executionTarget.online": "Online",
"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.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.",
@@ -367,8 +378,10 @@
"platformAgent.create.comingSoon": "Coming Soon",
"platformAgent.create.create": "Create Agent",
"platformAgent.create.creating": "Creating...",
"platformAgent.create.desc.amp": "Connect to Amp running on one of your devices",
"platformAgent.create.desc.hermes": "Connect to Hermes running on one of your devices",
"platformAgent.create.desc.openclaw": "Connect to OpenClaw running on one of your devices",
"platformAgent.create.desc.opencode": "Connect to OpenCode running on one of your devices",
"platformAgent.create.descriptionPlaceholder": "Brief description (optional)",
"platformAgent.create.downloadDesktop": "Download Desktop App",
"platformAgent.create.fetchingProfile": "Fetching profile...",
@@ -750,9 +763,9 @@
"taskSchedule.weekdays.thu": "Thu",
"taskSchedule.weekdays.tue": "Tue",
"taskSchedule.weekdays.wed": "Wed",
"thread.closeSubagentThread": "Collapse SubAgent conversation",
"thread.closeSubagentThread": "Hide Detail",
"thread.divider": "Subtopic",
"thread.openSubagentThread": "View full SubAgent conversation",
"thread.openSubagentThread": "View Detail",
"thread.subagentReadOnlyHint": "SubAgent conversations are read-only — execution is driven by the parent agent.",
"thread.threadMessageCount": "{{messageCount}} messages",
"thread.title": "Subtopic",
@@ -805,7 +818,7 @@
"tool.intervention.viewParameters": "View parameters ({{count}})",
"toolAuth.authorize": "Authorize",
"toolAuth.authorizing": "Authorizing...",
"toolAuth.hint": "Without authorization or configuration, Skills may not work. This can limit the Agent or cause errors.",
"toolAuth.hint": "When Skills aren't authorized or configured, the related Skills won't work and the Agent's capabilities may be limited or run into errors.",
"toolAuth.signIn": "Sign In",
"toolAuth.title": "Authorize Skills for this Agent",
"topic.checkOpenNewTopic": "Start a new topic?",
@@ -862,8 +875,8 @@
"workflow.toolDisplayName.addPreferenceMemory": "Saved memory",
"workflow.toolDisplayName.calculate": "Calculated",
"workflow.toolDisplayName.callAgent": "Called an agent",
"workflow.toolDisplayName.callSubAgent": "Dispatched a sub-agent",
"workflow.toolDisplayName.callSubAgents": "Dispatched sub-agents",
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
"workflow.toolDisplayName.clearTodos": "Cleared todos",
"workflow.toolDisplayName.copyDocument": "Copied a document",
"workflow.toolDisplayName.crawlMultiPages": "Crawled pages",
+1
View File
@@ -353,6 +353,7 @@
"messengerBanner.title": "Talk to Lobe AI on your favorite messaging apps",
"more": "More",
"navPanel.agent": "Agents",
"navPanel.bottomDivider": "Items below anchor to bottom",
"navPanel.customizeSidebar": "Customize Sidebar",
"navPanel.displayItems": "Display Items",
"navPanel.hidden": "Hidden",
-25
View File
@@ -60,17 +60,7 @@
"response.520": "We apologize, the server encountered an unexpected issue that prevented it from completing your request. Please try again later; we are working to resolve this issue.",
"response.522": "We apologize, the server connection timed out and was unable to respond to your request in a timely manner. This may be due to an unstable network or the server being temporarily inaccessible. Please try again later; we are working to restore service.",
"response.524": "We apologize, the server timed out while waiting for a response, possibly due to a slow reply. Please try again later.",
"response.AccountDeactivated": "Your account has been deactivated or suspended. This may be due to policy, security, or account review reasons. Please contact the provider support for assistance.",
"response.AgentRuntimeError": "Lobe language model runtime execution error. Please troubleshoot or retry based on the following information.",
"response.ComfyUIBizError": "An error occurred while requesting the ComfyUI service. Please troubleshoot using the information below or try again.",
"response.ComfyUIEmptyResult": "No image was generated by ComfyUI. Please check the model configuration or try again.",
"response.ComfyUIModelError": "Failed to load the ComfyUI model. Please ensure the model file exists.",
"response.ComfyUIServiceUnavailable": "Failed to connect to the ComfyUI service. Please ensure it is running properly and the service URL is correctly configured.",
"response.ComfyUIUploadFailed": "Failed to upload image to ComfyUI. Please check the server connection or try again.",
"response.ComfyUIWorkflowError": "ComfyUI workflow execution failed. Please verify the workflow configuration.",
"response.ConnectionCheckFailed": "The request returned empty. Please check if the API proxy address does not end with `/v1`.",
"response.CreateMessageError": "Sorry, the message could not be sent successfully. Please copy the content and try sending it again. This message will not be retained after refreshing the page.",
"response.ExceededContextWindow": "The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.",
"response.ExceededContextWindowCloud": "The conversation is too long to process. Please edit your last message to reduce input or delete some messages and try again.",
"response.FreePlanLimit": "You are currently a free user and cannot use this feature. Please upgrade to a paid plan to continue using it.",
"response.GoogleAIBlockReason.BLOCKLIST": "The content includes blocked terms. Please rephrase and try again.",
@@ -83,21 +73,9 @@
"response.GoogleAIBlockReason.SPII": "The content may include sensitive personal information (SPII). Please remove sensitive details and try again.",
"response.GoogleAIBlockReason.default": "The content was blocked ({{blockReason}}). Please adjust it and try again.",
"response.InsufficientBudgetForModel": "Your remaining credits are insufficient for this model. Please top up credits, upgrade your plan, or try a less expensive model.",
"response.InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.",
"response.InvalidAccessCode": "Invalid access code or empty. Please enter the correct access code or add a custom API Key.",
"response.InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.",
"response.InvalidComfyUIArgs": "Invalid ComfyUI configuration. Please check the settings and try again.",
"response.InvalidGithubToken": "The GitHub Personal Access Token is incorrect or empty. Please check your GitHub Personal Access Token and try again.",
"response.InvalidOllamaArgs": "Invalid Ollama configuration, please check Ollama configuration and try again",
"response.InvalidProviderAPIKey": "{{provider}} API Key is incorrect or empty, please check your {{provider}} API Key and try again",
"response.InvalidVertexCredentials": "Vertex authentication failed. Please check your credentials and try again.",
"response.LobeHubModelDeprecated": "The model \"{{model}}\" is no longer available. Please pick a current model from the model selector.",
"response.LocationNotSupportError": "We're sorry, your current location does not support this model service. This may be due to regional restrictions or the service not being available. Please confirm if the current location supports using this service, or try using a different location.",
"response.ModelNotFound": "Sorry, the requested model could not be found. It may not exist or you may not have the necessary access permissions. Please try again after changing the API Key or adjusting your access permissions.",
"response.NoOpenAIAPIKey": "OpenAI API Key is empty, please add a custom OpenAI API Key",
"response.OllamaBizError": "Error requesting Ollama service, please troubleshoot or retry based on the following information",
"response.OllamaServiceUnavailable": "Ollama service is unavailable. Please check if Ollama is running properly or if the cross-origin configuration of Ollama is set correctly.",
"response.PermissionDenied": "Sorry, you do not have permission to access this service. Please check if your key has the necessary access rights.",
"response.PluginApiNotFound": "Sorry, the API does not exist in the skill's manifest. Please check if your request method matches the skill manifest API",
"response.PluginApiParamsError": "Sorry, the input parameter validation for the skill request failed. Please check if the input parameters match the API description",
"response.PluginFailToTransformArguments": "Sorry, the skill failed to parse the arguments. Please try regenerating the agent message or switch to a more powerful AI model with Tools Calling capability and try again",
@@ -111,14 +89,11 @@
"response.PluginOpenApiInitError": "Sorry, the OpenAPI client failed to initialize. Please check if the OpenAPI configuration information is correct.",
"response.PluginServerError": "Skill server request returned an error. Please check your skill manifest file, skill configuration, or server implementation based on the error information below",
"response.PluginSettingsInvalid": "This skill needs to be correctly configured before it can be used. Please check if your configuration is correct",
"response.ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
"response.ProviderContentModeration": "Content policy check failed. Revise your prompt and try again.",
"response.ProviderContentModerationWarning": "Repeated content policy rejections detected. Please revise your prompt before retrying.",
"response.ProviderImageContentModerationWarning": "Repeated image safety rejections detected. Similar prompts may temporarily pause image generation.",
"response.QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
"response.QuotaLimitReachedCloud": "The model service is currently under heavy load. Please try again later or switch to another model.",
"response.ServerAgentRuntimeError": "Sorry, the Agent service is currently unavailable. Please try again later or contact us via email for support.",
"response.StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
"response.SubscriptionKeyMismatch": "We apologize for the inconvenience. Due to a temporary system malfunction, your current subscription usage is inactive. Please click the button below to restore your subscription, or contact us via email for support.",
"response.SubscriptionPlanLimit": "Your subscription points have been exhausted, and you cannot use this feature. Please upgrade to a higher plan or configure a custom model API to continue using it.",
"response.SubscriptionPlanLimitUltimate": "Your subscription points have been exhausted, and you cannot use this feature. Please top up credits or configure a custom model API to continue using it.",
+2
View File
@@ -53,6 +53,8 @@
"home.uploadEntries.folder.title": "Upload Folder",
"home.uploadEntries.library.title": "Create New Library",
"home.uploadEntries.newPage.title": "New Page",
"library.hierarchy.empty.desc": "Add files or create a folder to get started",
"library.hierarchy.empty.title": "Nothing here yet",
"library.list.confirmRemoveLibrary": "You are about to delete this library. The files within it will not be deleted but moved to All Files. This action cannot be undone, so please proceed with caution.",
"library.list.empty": "Click <1>+</1> to create a new library",
"library.new": "New Library",
+1 -1
View File
@@ -48,7 +48,7 @@
"generation.actions.seedCopied": "Seed Copied to Clipboard",
"generation.actions.seedCopyFailed": "Failed to Copy Seed",
"generation.metadata.count": "{{count}} Images",
"generation.status.failed": "Generation Failed",
"generation.status.failed": "Generation hit a problem. Adjust the prompt and try again",
"generation.status.generating": "Generating...",
"notSupportGuide.desc": "The current deployment mode does not support AI image generation. Switch to the <1>server database deployment mode</1>, or use <3>LobeHub Cloud</3>.",
"notSupportGuide.features.fileIntegration.desc": "Deep integration with the file management system; generated images are automatically saved to the file system for unified management and organization.",
+2
View File
@@ -3,6 +3,8 @@
"features.agentSelfIteration.title": "Agent Self-iteration",
"features.assistantMessageGroup.desc": "Group agent messages and their tool call results together for display",
"features.assistantMessageGroup.title": "Agent Message Grouping",
"features.executionDeviceSwitcher.desc": "Surface the execution-device switcher in the heterogeneous agent toolbar so you can route runs to this device, a cloud sandbox, or a bound remote device.",
"features.executionDeviceSwitcher.title": "Execution Device Switcher",
"features.gatewayMode.desc": "Execute agent tasks on the server via Gateway WebSocket instead of running locally. Enables faster execution and reduces client resource usage.",
"features.gatewayMode.title": "Server-Side Agent Execution (Gateway)",
"features.groupChat.desc": "Enable multi-agent group chat coordination.",
+36
View File
@@ -0,0 +1,36 @@
{
"AccountDeactivated": "Your account has been deactivated or suspended. This may be due to policy, security, or account review reasons. Please contact the provider support for assistance.",
"AgentRuntimeError": "Lobe language model runtime execution error. Please troubleshoot or retry based on the following information.",
"CapabilityNotSupported": "Sorry, this model does not support the requested capability (such as vision input or tool calling). Please switch to a model that supports it.",
"ComfyUIBizError": "An error occurred while requesting the ComfyUI service. Please troubleshoot using the information below or try again.",
"ComfyUIEmptyResult": "No image was generated by ComfyUI. Please check the model configuration or try again.",
"ComfyUIModelError": "Failed to load the ComfyUI model. Please ensure the model file exists.",
"ComfyUIServiceUnavailable": "Failed to connect to the ComfyUI service. Please ensure it is running properly and the service URL is correctly configured.",
"ComfyUIUploadFailed": "Failed to upload image to ComfyUI. Please check the server connection or try again.",
"ComfyUIWorkflowError": "ComfyUI workflow execution failed. Please verify the workflow configuration.",
"ConnectionCheckFailed": "The request returned empty. Please check if the API proxy address does not end with `/v1`.",
"ContentModeration": "Sorry, the content was rejected by the upstream safety filter. Please revise your prompt and try again.",
"ExceededContextWindow": "The current request content exceeds the length that the model can handle. Please reduce the amount of content and try again.",
"InsufficientQuota": "Sorry, the quota for this key has been reached. Please check if your account balance is sufficient or try again after increasing the key's quota.",
"InvalidBedrockCredentials": "Bedrock authentication failed. Please check the AccessKeyId/SecretAccessKey and retry.",
"InvalidComfyUIArgs": "Invalid ComfyUI configuration. Please check the settings and try again.",
"InvalidGithubToken": "The GitHub Personal Access Token is incorrect or empty. Please check your GitHub Personal Access Token and try again.",
"InvalidOllamaArgs": "Invalid Ollama configuration, please check Ollama configuration and try again",
"InvalidProviderAPIKey": "{{provider}} API Key is incorrect or empty, please check your {{provider}} API Key and try again",
"InvalidRequestFormat": "Sorry, the upstream provider rejected the request as malformed. Please check the input or try a different model.",
"InvalidVertexCredentials": "Vertex authentication failed. Please check your credentials and try again.",
"LocationNotSupportError": "We're sorry, your current location does not support this model service. This may be due to regional restrictions or the service not being available. Please confirm if the current location supports using this service, or try using a different location.",
"ModelNotFound": "Sorry, the requested model could not be found. It may not exist or you may not have the necessary access permissions. Please try again after changing the API Key or adjusting your access permissions.",
"NoAvailableChannel": "Sorry, the proxy or router has no available channel for the requested model. Please switch the channel/key configuration or try again later.",
"OllamaBizError": "Error requesting Ollama service, please troubleshoot or retry based on the following information",
"OllamaServiceUnavailable": "Ollama service is unavailable. Please check if Ollama is running properly or if the cross-origin configuration of Ollama is set correctly.",
"OperationInactivityTimeout": "The agent operation was idle for too long and was terminated. Please retry the request.",
"PermissionDenied": "Sorry, you do not have permission to access this service. Please check if your key has the necessary access rights.",
"ProviderBizError": "Error requesting {{provider}} service, please troubleshoot or retry based on the following information",
"ProviderNetworkError": "Connection to the provider timed out or was dropped. Please check your network and try again.",
"ProviderServiceUnavailable": "The provider is temporarily overloaded or unavailable. Please try again shortly.",
"QuotaLimitReached": "Sorry, the token usage or request count has reached the quota limit for this key. Please increase the key's quota or try again later.",
"RateLimitExceeded": "Sorry, the token usage or request count has reached the rate limit for this key. Please try again later or increase the key's quota.",
"StreamChunkError": "Error parsing the message chunk of the streaming request. Please check if the current API interface complies with the standard specifications, or contact your API provider for assistance.",
"UserConfigError": "Provider configuration is invalid (incorrect base URL, missing environment variable, virtual-key restriction, etc.). Please review the provider settings."
}
+1
View File
@@ -400,6 +400,7 @@
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 is a model that combines high computational efficiency with excellent reasoning and Agent performance. Its approach is based on three major technological breakthroughs: DeepSeek Sparse Attention (DSA), an efficient attention mechanism that significantly reduces computational complexity while maintaining model performance, and is specifically optimized for long-context scenarios; a scalable reinforcement learning framework, through which the model's performance can rival GPT-5, and its high-compute version can rival Gemini-3.0-Pro in reasoning capabilities; and a large-scale Agent task synthesis pipeline, designed to integrate reasoning capabilities into tool-using scenarios, thereby improving instruction-following and generalization abilities in complex interactive environments. The model achieved gold medal results in the 2025 International Mathematical Olympiad (IMO) and International Informatics Olympiad (IOI).",
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 is a 671B-parameter MoE model using MLA and DeepSeekMoE with loss-free load balancing for efficient training and inference. Pretrained on 14.8T high-quality tokens with SFT and RL, it outperforms other open models and approaches leading closed models.",
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash is a preview version of the MoE language model in the DeepSeek-V4 series. The total parameter size is 284B, the activation parameter size is 13B, and it supports 1M tokens ultra-long context.The model uses a hybrid attention architecture that combines CSA and HCA, and introduces mHC and Muon Optimizer to improve long-context reasoning efficiency, training stability, and overall performance.",
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro is the flagship MoE language model in the DeepSeek-V4 series, with 1.6T total parameters and 49B active parameters, natively supporting an ultra-long context of 1 million tokens. The model adopts an innovative hybrid attention architecture combining Compressed Sparse Attention (CSA) and Highly Compressed Attention (HCA), requiring only 27% of DeepSeek-V3.2 per-token inference FLOPs and 10% KV cache at 1M context. It also introduces Manifold-Constrained Hyper Connections (mHC) to enhance inter-layer signal propagation stability, and employs the Muon optimizer to accelerate convergence. DeepSeek-V4-Pro is pretrained on over 32T high-quality diverse tokens, with post-training using a two-stage paradigm of independent domain expert cultivation plus online policy distillation for unified integration. Its maximum reasoning intensity mode DeepSeek-V4-Pro-Max achieves top performance on coding benchmarks and significantly narrows the gap with leading closed-source models on reasoning and agentic tasks, making it one of the strongest open-source models today, supporting Non-think, Think High, and Think Max reasoning intensity modes.",
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat (67B) is an innovative model offering deep language understanding and interaction.",
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 is a next-gen reasoning model with stronger complex reasoning and chain-of-thought for deep analysis tasks.",
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2 is a next-gen reasoning model with stronger complex reasoning and chain-of-thought capabilities.",
+5 -4
View File
@@ -72,10 +72,9 @@
"builtins.lobe-agent.apiName.analyzeVisualMedia": "Analyze visual media",
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} media",
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "Analyze visual media: <question>{{question}}</question>",
"builtins.lobe-agent.apiName.callSubAgent": "Call sub-agent",
"builtins.lobe-agent.apiName.callSubAgent.completed": "Sub-agent dispatched: ",
"builtins.lobe-agent.apiName.callSubAgent.loading": "Dispatching sub-agent: ",
"builtins.lobe-agent.apiName.callSubAgents": "Call sub-agents",
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
"builtins.lobe-agent.apiName.callSubAgents.more": "{{count}} in total",
"builtins.lobe-agent.apiName.clearTodos": "Clear todos",
"builtins.lobe-agent.apiName.clearTodos.modeAll": "all",
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "completed",
@@ -87,6 +86,8 @@
"builtins.lobe-agent.apiName.updatePlan.completed": "Completed",
"builtins.lobe-agent.apiName.updatePlan.modified": "Modified",
"builtins.lobe-agent.apiName.updateTodos": "Update todos",
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} tokens",
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} tools",
"builtins.lobe-agent.title": "Lobe Agent",
"builtins.lobe-claude-code.agent.instruction": "Instruction",
"builtins.lobe-claude-code.agent.result": "Result",
+17
View File
@@ -503,6 +503,8 @@
"plugin.settings.tooltip": "Skill Configuration",
"plugin.store": "Skill Store",
"publishToCommunity": "Publish to Community",
"serviceModel.contextLimit.placeholder": "Context limit",
"serviceModel.memoryModels.title": "Memory Models",
"serviceModel.modelAssignments.title": "Model Assignments",
"serviceModel.optionalFeatures.title": "Optional Features",
"settingAgent.avatar.sizeExceeded": "Image size exceeds 1MB limit, please choose a smaller image",
@@ -549,6 +551,9 @@
"settingChat.enableAutoScrollOnStreaming.desc": "Override global setting for this assistant",
"settingChat.enableAutoScrollOnStreaming.title": "Auto-scroll During AI Response",
"settingChat.enableCompressHistory.title": "Enable Automatic Summary of Chat History",
"settingChat.enableFollowUpChips.desc": "After each reply, show one-click follow-up reply chips below the message. Requires the global Follow-up model to be configured.",
"settingChat.enableFollowUpChips.notConfiguredHint": "Configure the global Follow-up model first to enable this.",
"settingChat.enableFollowUpChips.title": "Follow-up Suggestions",
"settingChat.enableHistoryCount.alias": "Unlimited",
"settingChat.enableHistoryCount.limited": "Include only {{number}} conversation messages",
"settingChat.enableHistoryCount.setlimited": "Set limited history messages",
@@ -840,6 +845,9 @@
"systemAgent.customPrompt.desc": "Once filled out, the system agent will use the custom prompt when generating content",
"systemAgent.customPrompt.placeholder": "Please enter custom prompt",
"systemAgent.customPrompt.title": "Custom Prompt",
"systemAgent.followUpAction.label": "Follow-up Suggestions Model",
"systemAgent.followUpAction.modelDesc": "Model used to suggest one-click follow-up replies under each assistant message",
"systemAgent.followUpAction.title": "Follow-up Suggestions",
"systemAgent.generationTopic.label": "Model",
"systemAgent.generationTopic.modelDesc": "Model used to name AI image topics",
"systemAgent.generationTopic.title": "AI Image Topic Naming",
@@ -850,6 +858,9 @@
"systemAgent.inputCompletion.label": "Model",
"systemAgent.inputCompletion.modelDesc": "Suggests text while you type. When enabled, this model generates the suggestions.",
"systemAgent.inputCompletion.title": "Input Suggestions",
"systemAgent.memoryAnalysisAgentConfig.label": "Model",
"systemAgent.memoryAnalysisAgentConfig.modelDesc": "Model used to decide whether conversations contain memory and extract identities, preferences, contexts, activities, and experiences.",
"systemAgent.memoryAnalysisAgentConfig.title": "Memory Analysis",
"systemAgent.promptRewrite.label": "Model",
"systemAgent.promptRewrite.modelDesc": "Improves prompts before generation. When enabled, this model rewrites the prompt.",
"systemAgent.promptRewrite.title": "Prompt Rewriting",
@@ -863,6 +874,12 @@
"systemAgent.translation.label": "Model",
"systemAgent.translation.modelDesc": "Model used to translate messages",
"systemAgent.translation.title": "Message Translation",
"systemAgent.userMemoryEmbedding.label": "Model",
"systemAgent.userMemoryEmbedding.modelDesc": "Model used to embed memory content for retrieval. The context limit caps each embedding input.",
"systemAgent.userMemoryEmbedding.title": "Memory Embedding",
"systemAgent.userMemoryPersonaWriter.label": "Model",
"systemAgent.userMemoryPersonaWriter.modelDesc": "Model used to write persona-oriented memory summaries.",
"systemAgent.userMemoryPersonaWriter.title": "Memory Persona Writer",
"tab.about": "About",
"tab.addAgentSkill": "Add Agent Skill",
"tab.addCustomMcp": "Add Custom MCP Skill",
+71
View File
@@ -51,6 +51,77 @@
"inPopup.focus": "Focus Popup Window",
"inPopup.title": "Open in Popup Window",
"loadMore": "Load More",
"management.actions.newChat": "New chat",
"management.actions.select": "Select",
"management.actionsMenu.archiveStale.confirm": "Archive {{count}} topics that have been inactive for over 3 months? They will be marked as completed.",
"management.actionsMenu.archiveStale.confirmOk": "Archive",
"management.actionsMenu.archiveStale.done": "Archived {{count}} stale topics.",
"management.actionsMenu.archiveStale.label": "Archive topics inactive for 3+ months",
"management.actionsMenu.archiveStale.noneFound": "No stale topics found.",
"management.actionsMenu.archiveStale.title": "Archive stale topics?",
"management.actionsMenu.autoSummarize.comingSoon": "Auto-summarization is coming soon — track on the roadmap.",
"management.actionsMenu.autoSummarize.label": "Auto-generate summaries for topics without one",
"management.actionsMenu.title": "More actions",
"management.bulk.archive": "Archive",
"management.bulk.cancel": "Cancel",
"management.bulk.delete": "Delete",
"management.bulk.deleteConfirm": "You are about to delete {{count}} topics. This action cannot be undone.",
"management.bulk.deleteTitle": "Delete topics?",
"management.bulk.favorite": "Favorite",
"management.bulk.selectedCount_one": "{{count}} selected",
"management.bulk.selectedCount_other": "{{count}} selected",
"management.card.noPreview": "No preview available",
"management.columns.project": "Project",
"management.columns.status": "Status",
"management.columns.title": "Title",
"management.columns.trigger": "Trigger",
"management.columns.updated": "Updated",
"management.empty.filtered.action": "Clear filters",
"management.empty.filtered.desc": "Try adjusting filters or clearing them to see more topics.",
"management.empty.filtered.title": "No topics match these filters",
"management.empty.noTopics.action": "Start new chat",
"management.empty.noTopics.desc": "Start a conversation with this agent to create your first topic.",
"management.empty.noTopics.title": "No topics yet",
"management.filters.project.empty": "No projects",
"management.filters.project.label": "Project",
"management.filters.status.active": "Active",
"management.filters.status.all": "All",
"management.filters.status.archived": "Archived",
"management.filters.status.completed": "Completed",
"management.filters.status.favorite": "Favorites",
"management.filters.status.running": "Running",
"management.filters.time.all": "All time",
"management.filters.time.label": "Time",
"management.filters.time.month": "Past month",
"management.filters.time.today": "Today",
"management.filters.time.week": "Past week",
"management.filters.trigger.api": "API",
"management.filters.trigger.chat": "Chat",
"management.filters.trigger.eval": "Eval",
"management.filters.trigger.label": "Trigger",
"management.filters.trigger.task": "Task",
"management.group.byProject": "By project",
"management.group.byTime": "By time",
"management.group.label": "Group",
"management.group.noProject": "No project",
"management.group.none": "None",
"management.loadingMore": "Loading more topics…",
"management.searchPlaceholder": "Search this agent's topics…",
"management.sidebarEntry": "Topics",
"management.sort.createdAt": "Created time",
"management.sort.label": "Sort",
"management.sort.title": "Title",
"management.sort.updatedAt": "Updated time",
"management.status.active": "Active",
"management.status.archived": "Archived",
"management.status.completed": "Completed",
"management.status.failed": "Failed",
"management.status.paused": "Paused",
"management.status.running": "Running",
"management.status.waitingForHuman": "Awaiting input",
"management.title": "Topics",
"management.view.card": "Card",
"management.view.list": "List",
"newTopic": "New Topic",
"renameModal.description": "Keep it short and easy to recognize.",
"renameModal.title": "Rename Topic",
+30
View File
@@ -100,6 +100,35 @@
"channel.groupPolicyOpenHint": "在所有群组、频道、子话题中响应",
"channel.historyLimit": "历史消息条数",
"channel.historyLimitHint": "读取频道历史消息时默认获取的消息数量",
"channel.imessage.applicationIdHint": "云端渠道和桌面端桥接共同使用的稳定标识。",
"channel.imessage.applicationIdPlaceholder": "例如 home-mac-mini",
"channel.imessage.blueBubblesPassword": "BlueBubbles 密码",
"channel.imessage.blueBubblesPasswordHint": "仅保存在 LobeHub Desktop 本地,用于访问本机 BlueBubbles Server。",
"channel.imessage.blueBubblesServerUrl": "BlueBubbles Server URL",
"channel.imessage.blueBubblesServerUrlHint": "当前桌面端可以访问到的本机 BlueBubbles Server 地址。",
"channel.imessage.bridgeEnabled": "启用桥接",
"channel.imessage.bridgeEnabledHint": "启用后,LobeHub Desktop 会接收本机 BlueBubbles webhook 并转发给 LobeHub。",
"channel.imessage.bridgeMissingApplicationId": "请先填写 Application ID。",
"channel.imessage.bridgeMissingPassword": "请先填写 BlueBubbles 密码。",
"channel.imessage.bridgeMissingServerUrl": "请先填写 BlueBubbles Server URL。",
"channel.imessage.bridgeMissingWebhookSecret": "请先填写 Webhook Secret。",
"channel.imessage.bridgePasswordSavedPlaceholder": "留空则沿用已保存的密码",
"channel.imessage.bridgeRefresh": "刷新",
"channel.imessage.bridgeRefreshFailed": "刷新 iMessage Desktop 桥接失败",
"channel.imessage.bridgeRunning": "运行中",
"channel.imessage.bridgeSave": "保存桥接",
"channel.imessage.bridgeSaveFailed": "保存 iMessage Desktop 桥接失败",
"channel.imessage.bridgeSaved": "iMessage Desktop 桥接已保存",
"channel.imessage.bridgeStopped": "已停止",
"channel.imessage.bridgeTest": "测试 BlueBubbles",
"channel.imessage.bridgeTestFailed": "BlueBubbles 测试失败",
"channel.imessage.bridgeTestSuccess": "BlueBubbles 连接测试通过",
"channel.imessage.description": "通过 LobeHub Desktop 本地 BlueBubbles 桥接将助手连接到 iMessage。",
"channel.imessage.desktopBridge": "桌面端桥接",
"channel.imessage.desktopDeviceId": "桌面端设备 ID",
"channel.imessage.desktopDeviceIdHint": "运行本地 BlueBubbles 桥接的 LobeHub Desktop 设备,可在桌面端 Gateway 设置中找到。",
"channel.imessage.webhookSecret": "Webhook Secret",
"channel.imessage.webhookSecretHint": "LobeHub Desktop 与云端 webhook 之间使用的共享密钥,需要和桌面端桥接配置保持一致。",
"channel.importConfig": "导入平台配置",
"channel.importFailed": "配置导入失败",
"channel.importInvalidFormat": "配置文件格式无效",
@@ -176,6 +205,7 @@
"channel.userIdHint": "供 AI 工具主动联系你(如提醒、通知),并自动加入全局白名单",
"channel.userIdHint.discord": "在 Discord 设置 → 高级中开启开发者模式,然后右键你的头像 → 复制用户 ID。",
"channel.userIdHint.feishu": "在飞书 / Lark 开放平台打开你的应用 → 权限管理,查看你的 Open ID。",
"channel.userIdHint.imessage": "使用 BlueBubbles 中显示的 iMessage handle,通常是邮箱或 E.164 手机号。",
"channel.userIdHint.line": "打开 LINE Developers Console → 你的 channel → Basic settings 选项卡,复制 \"Your user ID\"(以 U 开头共 33 位)。",
"channel.userIdHint.qq": "你的 QQ 号,在 QQ 资料页可见。",
"channel.userIdHint.slack": "打开 Slack 个人资料 → ⋮ 更多 → 复制 Member ID(以 U 开头)。",
+20 -7
View File
@@ -28,8 +28,8 @@
"agentSignal.receipts.memory.detail": "已保存以供未来回复使用",
"agentSignal.receipts.memory.title": "记忆已保存",
"agentSignal.receipts.recentActivity": "最近活动",
"agentSignal.receipts.skill.detail": "改进了此助手处理似请求的方式",
"agentSignal.receipts.skill.title": "技能已更新",
"agentSignal.receipts.skill.detail": "已自主优化处理似请求的方式",
"agentSignal.receipts.skill.title": "已自动习得新技能",
"agents": "助理",
"artifact.generating": "生成中",
"artifact.inThread": "子话题中暂不支持查看。请回到主对话区打开",
@@ -208,6 +208,17 @@
"heteroAgent.cloudRepo.noRepos": "未配置仓库,请在助理设置中添加。",
"heteroAgent.cloudRepo.notSet": "未选择仓库",
"heteroAgent.cloudRepo.sectionTitle": "代码仓库",
"heteroAgent.executionTarget.infoTooltip": "选择「远程设备」后可在网页中驱动该机器;「本机」仅在桌面端内运行 agent。",
"heteroAgent.executionTarget.loading": "正在加载设备…",
"heteroAgent.executionTarget.local": "本机",
"heteroAgent.executionTarget.localDesc": "在当前桌面端以本地进程运行",
"heteroAgent.executionTarget.noDevices": "暂无远程设备。在另一台机器上安装桌面端或执行 `lh connect` 接入。",
"heteroAgent.executionTarget.offline": "离线",
"heteroAgent.executionTarget.online": "在线",
"heteroAgent.executionTarget.sandbox": "云端沙箱",
"heteroAgent.executionTarget.sandboxDesc": "在临时云端沙箱中运行",
"heteroAgent.executionTarget.title": "执行设备",
"heteroAgent.executionTarget.unknownDevice": "未知设备",
"heteroAgent.fullAccess.label": "完全访问权限",
"heteroAgent.fullAccess.tooltip": "Claude Code 在本地运行,对工作目录拥有完全的读写权限。当前暂不支持切换权限模式。",
"heteroAgent.resumeReset.cwdChanged": "工作目录已切换,之前的 Claude Code 会话只能在原目录下继续,已开始新对话。",
@@ -367,8 +378,10 @@
"platformAgent.create.comingSoon": "即将推出",
"platformAgent.create.create": "创建 Agent",
"platformAgent.create.creating": "创建中...",
"platformAgent.create.desc.amp": "连接你某台设备上的 Amp Agent",
"platformAgent.create.desc.hermes": "连接你某台设备上的 Hermes Agent",
"platformAgent.create.desc.openclaw": "连接你某台设备上的 OpenClaw Agent",
"platformAgent.create.desc.opencode": "连接你某台设备上的 OpenCode Agent",
"platformAgent.create.descriptionPlaceholder": "简短描述(可选)",
"platformAgent.create.downloadDesktop": "下载桌面端",
"platformAgent.create.fetchingProfile": "正在读取配置...",
@@ -750,9 +763,9 @@
"taskSchedule.weekdays.thu": "四",
"taskSchedule.weekdays.tue": "二",
"taskSchedule.weekdays.wed": "三",
"thread.closeSubagentThread": "收起 SubAgent 对话",
"thread.closeSubagentThread": "隐藏详情",
"thread.divider": "子话题",
"thread.openSubagentThread": "查看完整 SubAgent 对话",
"thread.openSubagentThread": "查看详情",
"thread.subagentReadOnlyHint": "SubAgent 对话仅可查看,由父智能体驱动执行",
"thread.threadMessageCount": "{{messageCount}} 条消息",
"thread.title": "子话题",
@@ -805,7 +818,7 @@
"tool.intervention.viewParameters": "查看参数 ({{count}})",
"toolAuth.authorize": "授权",
"toolAuth.authorizing": "授权中…",
"toolAuth.hint": "未授权或未配置时,相关技能无法使用。这可能导致助理能力受限或报错",
"toolAuth.hint": "技能未授权或未配置时,相关技能无法使用可能导致助理能力受限或报错",
"toolAuth.signIn": "登录",
"toolAuth.title": "为助理完成技能授权",
"topic.checkOpenNewTopic": "要开启新话题吗?",
@@ -862,8 +875,8 @@
"workflow.toolDisplayName.addPreferenceMemory": "保存了记忆",
"workflow.toolDisplayName.calculate": "完成了计算",
"workflow.toolDisplayName.callAgent": "调用了助理",
"workflow.toolDisplayName.callSubAgent": "派发了子代理",
"workflow.toolDisplayName.callSubAgents": "派发了多个子代理",
"workflow.toolDisplayName.callSubAgent": "Call SubAgent",
"workflow.toolDisplayName.callSubAgents": "Call SubAgents",
"workflow.toolDisplayName.clearTodos": "清空了待办",
"workflow.toolDisplayName.copyDocument": "复制了文档",
"workflow.toolDisplayName.crawlMultiPages": "抓取了多个页面",
+1
View File
@@ -353,6 +353,7 @@
"messengerBanner.title": "在你喜爱的聊天应用中,与 Lobe AI 畅聊",
"more": "更多",
"navPanel.agent": "助理",
"navPanel.bottomDivider": "下方条目锚定到底部",
"navPanel.customizeSidebar": "自定义侧边栏",
"navPanel.displayItems": "显示条目",
"navPanel.hidden": "已隐藏",
+2
View File
@@ -53,6 +53,8 @@
"home.uploadEntries.folder.title": "上传文件夹",
"home.uploadEntries.library.title": "新建资源库",
"home.uploadEntries.newPage.title": "新建文稿",
"library.hierarchy.empty.desc": "上传文件或新建文件夹开始整理",
"library.hierarchy.empty.title": "这里还没有内容",
"library.list.confirmRemoveLibrary": "将删除该资源库(其中的文件不会删除,会移入「全部文件」)。删除后不可恢复,建议确认无误再继续",
"library.list.empty": "点击 <1>+</1> 创建第一个资源库",
"library.new": "新建资源库",
+1 -1
View File
@@ -48,7 +48,7 @@
"generation.actions.seedCopied": "种子已复制到剪贴板",
"generation.actions.seedCopyFailed": "复制遇到了问题。你可以再试一次",
"generation.metadata.count": "{{count}} 张图片",
"generation.status.failed": "生成遇到了问题。你可以重试,或调整描述后试",
"generation.status.failed": "生成遇到了问题,建议调整描述后试",
"generation.status.generating": "生成中…",
"notSupportGuide.desc": "当前部署模式不支持 AI 图像生成功能。请切换到<1>服务端数据库部署模式</1>,或直接使用 <3>LobeHub Cloud</3>",
"notSupportGuide.features.fileIntegration.desc": "与文件管理深度整合。生成的图片自动保存到文件系统,统一管理和组织",
+2
View File
@@ -3,6 +3,8 @@
"features.agentSelfIteration.title": "Agent 自我迭代",
"features.assistantMessageGroup.desc": "将代理消息及其工具调用结果组合在一起显示",
"features.assistantMessageGroup.title": "代理消息分组",
"features.executionDeviceSwitcher.desc": "在异构 Agent 工具栏中展示「执行设备」切换器,可将运行任务路由到本机、云端沙箱或已绑定的远程设备。",
"features.executionDeviceSwitcher.title": "执行设备切换器",
"features.gatewayMode.desc": "通过 Gateway 在服务端执行 Agent 任务。可实现关闭浏览器后仍然执行 agent。",
"features.gatewayMode.title": "服务端代理执行(Gateway",
"features.groupChat.desc": "启用多代理协同群聊功能。",
+1
View File
@@ -400,6 +400,7 @@
"deepseek-ai/DeepSeek-V3.2.description": "DeepSeek-V3.2 是一款结合高计算效率与卓越推理和 Agent 性能的模型。其方法基于三项主要技术突破:DeepSeek 稀疏注意力(DSA),一种高效的注意力机制,在显著降低计算复杂度的同时保持模型性能,特别针对长上下文场景进行了优化;可扩展的强化学习框架,使模型性能可媲美 GPT-5,其高计算版本在推理能力上可媲美 Gemini-3.0-Pro;以及一个大规模 Agent 任务合成管道,旨在将推理能力集成到工具使用场景中,从而提升复杂交互环境中的指令遵循和泛化能力。该模型在 2025 年国际数学奥林匹克(IMO)和国际信息学奥林匹克(IOI)中获得金牌成绩。",
"deepseek-ai/DeepSeek-V3.description": "DeepSeek-V3 是一个拥有 671B 参数的 MoE 模型,采用 MLA 和 DeepSeekMoE 架构,具备无损负载均衡,实现高效训练与推理。在 14.8T 高质量数据上预训练,并结合 SFT 与 RL,性能超越其他开源模型,接近领先闭源模型。",
"deepseek-ai/DeepSeek-V4-Flash.description": "DeepSeek-V4-Flash 是 DeepSeek-V4 系列中 MoE 语言模型的预览版本。总参数规模为 2840 亿,激活参数规模为 130 亿,支持 1M 令牌超长上下文。该模型采用结合 CSA 和 HCA 的混合注意力架构,并引入 mHC 和 Muon 优化器,以提高长上下文推理效率、训练稳定性和整体性能。",
"deepseek-ai/DeepSeek-V4-Pro.description": "DeepSeek-V4-Pro 是 DeepSeek-V4 系列中的旗舰 MoE 语言模型,拥有 1.6T 总参数、49B 激活参数,原生支持 100 万 tokens 的超长上下文。该模型采用创新的混合注意力架构,结合压缩稀疏注意力(CSA)与高度压缩注意力(HCA),在 1M 上下文下仅需 DeepSeek-V3.2 的 27% 单 token 推理 FLOPs 和 10% KV 缓存。模型还引入流形约束超连接(mHC)增强层间信号传播稳定性,并采用 Muon 优化器加速收敛。DeepSeek-V4-Pro 在超过 32T 高质量多样化 tokens 上预训练,后训练采用「领域专家独立培养 + 在线策略蒸馏统一整合」的两阶段范式。其最大推理强度模式 DeepSeek-V4-Pro-Max 在编程基准上取得顶尖表现,并在推理与 Agentic 任务上大幅缩小与领先闭源模型的差距,是目前最强的开源模型之一,支持 Non-think、Think High、Think Max 三种推理强度模式",
"deepseek-ai/deepseek-llm-67b-chat.description": "DeepSeek LLM Chat67B)是一款创新模型,具备深度语言理解与交互能力。",
"deepseek-ai/deepseek-v3.1-terminus.description": "DeepSeek V3.1 是下一代推理模型,具备更强的复杂推理与链式思维能力,适用于深度分析任务。",
"deepseek-ai/deepseek-v3.2.description": "DeepSeek V3.2是下一代推理模型,具备更强的复杂推理和链式思维能力。",
+5 -4
View File
@@ -72,10 +72,9 @@
"builtins.lobe-agent.apiName.analyzeVisualMedia": "分析视觉媒体",
"builtins.lobe-agent.apiName.analyzeVisualMedia.mediaCount": "{{count}} 个媒体",
"builtins.lobe-agent.apiName.analyzeVisualMedia.result": "分析视觉媒体:<question>{{question}}</question>",
"builtins.lobe-agent.apiName.callSubAgent": "调用子代理",
"builtins.lobe-agent.apiName.callSubAgent.completed": "已派发子代理:",
"builtins.lobe-agent.apiName.callSubAgent.loading": "正在派发子代理:",
"builtins.lobe-agent.apiName.callSubAgents": "调用多个子代理",
"builtins.lobe-agent.apiName.callSubAgent": "Call SubAgent",
"builtins.lobe-agent.apiName.callSubAgents": "Call SubAgents",
"builtins.lobe-agent.apiName.callSubAgents.more": "等 {{count}} 个",
"builtins.lobe-agent.apiName.clearTodos": "清除待办",
"builtins.lobe-agent.apiName.clearTodos.modeAll": "全部",
"builtins.lobe-agent.apiName.clearTodos.modeCompleted": "已完成",
@@ -87,6 +86,8 @@
"builtins.lobe-agent.apiName.updatePlan.completed": "已完成",
"builtins.lobe-agent.apiName.updatePlan.modified": "已修改",
"builtins.lobe-agent.apiName.updateTodos": "更新待办",
"builtins.lobe-agent.subAgent.stats.tokens": "{{count}} tokens",
"builtins.lobe-agent.subAgent.stats.tools": "{{count}} 个工具",
"builtins.lobe-agent.title": "Lobe Agent",
"builtins.lobe-claude-code.agent.instruction": "指令",
"builtins.lobe-claude-code.agent.result": "结果",
+17
View File
@@ -503,6 +503,8 @@
"plugin.settings.tooltip": "技能配置",
"plugin.store": "技能商店",
"publishToCommunity": "发布到社区",
"serviceModel.contextLimit.placeholder": "上下文限制",
"serviceModel.memoryModels.title": "记忆模型",
"serviceModel.modelAssignments.title": "模型分配",
"serviceModel.optionalFeatures.title": "可选功能",
"settingAgent.avatar.sizeExceeded": "图片大小超过 1MB 限制,请选择更小的图片",
@@ -549,6 +551,9 @@
"settingChat.enableAutoScrollOnStreaming.desc": "覆盖此助手的全局设置",
"settingChat.enableAutoScrollOnStreaming.title": "AI 回复时自动滚动",
"settingChat.enableCompressHistory.title": "开启历史消息自动总结",
"settingChat.enableFollowUpChips.desc": "每次回复后,在消息下方展示一键跟进的快捷气泡。需先配置全局跟进建议模型。",
"settingChat.enableFollowUpChips.notConfiguredHint": "请先配置全局跟进建议模型后启用。",
"settingChat.enableFollowUpChips.title": "跟进建议",
"settingChat.enableHistoryCount.alias": "不限制",
"settingChat.enableHistoryCount.limited": "只包含 {{number}} 条会话消息",
"settingChat.enableHistoryCount.setlimited": "使用历史消息数",
@@ -840,6 +845,9 @@
"systemAgent.customPrompt.desc": "填写后,系统助理将在生成内容时使用自定义提示",
"systemAgent.customPrompt.placeholder": "请输入自定义提示词",
"systemAgent.customPrompt.title": "自定义提示词",
"systemAgent.followUpAction.label": "跟进建议模型",
"systemAgent.followUpAction.modelDesc": "用于在每条助手回复下生成一键跟进建议的模型",
"systemAgent.followUpAction.title": "跟进建议",
"systemAgent.generationTopic.label": "模型",
"systemAgent.generationTopic.modelDesc": "用于自动命名 AI 图片话题的模型",
"systemAgent.generationTopic.title": "AI 图片话题命名",
@@ -850,6 +858,9 @@
"systemAgent.inputCompletion.label": "模型",
"systemAgent.inputCompletion.modelDesc": "输入时生成文本建议。开启后,由该模型生成建议。",
"systemAgent.inputCompletion.title": "输入建议",
"systemAgent.memoryAnalysisAgentConfig.label": "模型",
"systemAgent.memoryAnalysisAgentConfig.modelDesc": "用于判断对话是否包含记忆,并提取身份、偏好、上下文、活动和经历。",
"systemAgent.memoryAnalysisAgentConfig.title": "记忆分析",
"systemAgent.promptRewrite.label": "模型",
"systemAgent.promptRewrite.modelDesc": "生成前优化提示词。开启后,由该模型改写提示词。",
"systemAgent.promptRewrite.title": "提示词改写",
@@ -863,6 +874,12 @@
"systemAgent.translation.label": "模型",
"systemAgent.translation.modelDesc": "用于翻译消息内容的模型",
"systemAgent.translation.title": "消息内容翻译",
"systemAgent.userMemoryEmbedding.label": "模型",
"systemAgent.userMemoryEmbedding.modelDesc": "用于为记忆内容生成向量以支持检索。上下文限制会约束每次向量化输入。",
"systemAgent.userMemoryEmbedding.title": "记忆向量化",
"systemAgent.userMemoryPersonaWriter.label": "模型",
"systemAgent.userMemoryPersonaWriter.modelDesc": "用于生成面向画像的记忆摘要。",
"systemAgent.userMemoryPersonaWriter.title": "记忆画像写入",
"tab.about": "关于",
"tab.addAgentSkill": "添加 Agent 技能",
"tab.addCustomMcp": "添加自定义 MCP 技能",
+71
View File
@@ -51,6 +51,77 @@
"inPopup.focus": "聚焦独立窗口",
"inPopup.title": "已在独立窗口中打开",
"loadMore": "更多",
"management.actions.newChat": "新对话",
"management.actions.select": "选择",
"management.actionsMenu.archiveStale.confirm": "将 {{count}} 个超过 3 个月未活动的话题归档(标记为已完成)?",
"management.actionsMenu.archiveStale.confirmOk": "归档",
"management.actionsMenu.archiveStale.done": "已归档 {{count}} 个话题。",
"management.actionsMenu.archiveStale.label": "归档 3 个月未活动的话题",
"management.actionsMenu.archiveStale.noneFound": "没有需要归档的话题。",
"management.actionsMenu.archiveStale.title": "归档过时话题?",
"management.actionsMenu.autoSummarize.comingSoon": "自动生成摘要功能即将上线,敬请期待。",
"management.actionsMenu.autoSummarize.label": "为缺少摘要的话题自动生成",
"management.actionsMenu.title": "更多操作",
"management.bulk.archive": "归档",
"management.bulk.cancel": "取消",
"management.bulk.delete": "删除",
"management.bulk.deleteConfirm": "即将删除 {{count}} 个话题,此操作无法撤销。",
"management.bulk.deleteTitle": "删除话题?",
"management.bulk.favorite": "收藏",
"management.bulk.selectedCount_one": "已选 {{count}} 项",
"management.bulk.selectedCount_other": "已选 {{count}} 项",
"management.card.noPreview": "暂无预览内容",
"management.columns.project": "项目",
"management.columns.status": "状态",
"management.columns.title": "标题",
"management.columns.trigger": "来源",
"management.columns.updated": "更新时间",
"management.empty.filtered.action": "清空筛选",
"management.empty.filtered.desc": "试试调整或清空筛选条件,看更多话题。",
"management.empty.filtered.title": "没有符合条件的话题",
"management.empty.noTopics.action": "开始新对话",
"management.empty.noTopics.desc": "和这个助手聊聊,创建第一个话题。",
"management.empty.noTopics.title": "还没有话题",
"management.filters.project.empty": "暂无项目",
"management.filters.project.label": "项目",
"management.filters.status.active": "活跃",
"management.filters.status.all": "全部",
"management.filters.status.archived": "已归档",
"management.filters.status.completed": "已完成",
"management.filters.status.favorite": "已收藏",
"management.filters.status.running": "运行中",
"management.filters.time.all": "全部时间",
"management.filters.time.label": "时间",
"management.filters.time.month": "最近一月",
"management.filters.time.today": "今天",
"management.filters.time.week": "最近一周",
"management.filters.trigger.api": "API",
"management.filters.trigger.chat": "对话",
"management.filters.trigger.eval": "评测",
"management.filters.trigger.label": "来源",
"management.filters.trigger.task": "任务",
"management.group.byProject": "按项目",
"management.group.byTime": "按时间",
"management.group.label": "分组",
"management.group.noProject": "无项目",
"management.group.none": "不分组",
"management.loadingMore": "正在加载更多话题…",
"management.searchPlaceholder": "在当前助手的话题中搜索…",
"management.sidebarEntry": "话题",
"management.sort.createdAt": "按创建时间",
"management.sort.label": "排序",
"management.sort.title": "按标题",
"management.sort.updatedAt": "按更新时间",
"management.status.active": "活跃",
"management.status.archived": "已归档",
"management.status.completed": "已完成",
"management.status.failed": "已失败",
"management.status.paused": "已暂停",
"management.status.running": "运行中",
"management.status.waitingForHuman": "等待响应",
"management.title": "话题",
"management.view.card": "卡片",
"management.view.list": "列表",
"newTopic": "新话题",
"renameModal.description": "保持简短且易于识别。",
"renameModal.title": "重命名话题",
+6 -4
View File
@@ -245,6 +245,7 @@
"@lobechat/business-model-bank": "workspace:*",
"@lobechat/business-model-runtime": "workspace:*",
"@lobechat/chat-adapter-feishu": "workspace:*",
"@lobechat/chat-adapter-imessage": "workspace:*",
"@lobechat/chat-adapter-line": "workspace:*",
"@lobechat/chat-adapter-qq": "workspace:*",
"@lobechat/chat-adapter-wechat": "workspace:*",
@@ -280,11 +281,11 @@
"@lobehub/analytics": "^1.6.2",
"@lobehub/charts": "^5.0.0",
"@lobehub/desktop-ipc-typings": "workspace:*",
"@lobehub/editor": "^4.9.3",
"@lobehub/editor": "^4.12.0",
"@lobehub/icons": "^5.0.0",
"@lobehub/market-sdk": "0.33.3",
"@lobehub/tts": "^5.1.2",
"@lobehub/ui": "^5.14.1",
"@lobehub/ui": "^5.15.1",
"@modelcontextprotocol/sdk": "^1.26.0",
"@napi-rs/canvas": "^0.1.88",
"@neondatabase/serverless": "^1.0.2",
@@ -536,7 +537,7 @@
"stylelint": "^16.12.0",
"tsx": "^4.21.0",
"type-fest": "^5.4.1",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"vite": "8.0.14",
@@ -567,7 +568,8 @@
"pdfjs-dist": "5.4.530",
"react": "19.2.5",
"react-dom": "19.2.5",
"stylelint-config-clean-order": "7.0.0"
"stylelint-config-clean-order": "7.0.0",
"typescript": "6.0.3"
},
"patchedDependencies": {
"@upstash/qstash": "patches/@upstash__qstash.patch"
+6 -7
View File
@@ -17,6 +17,7 @@ import type {
ToolsCalling,
Usage,
} from '../types';
import { isBlockedStatus } from '../utils/status';
/**
* Simplified Agent Runtime - The "Engine" that executes instructions from an "Agent" (Brain).
@@ -197,7 +198,7 @@ export class AgentRuntime {
}
// Stop execution if blocked
if (currentState.status === 'waiting_for_human' || currentState.status === 'interrupted') {
if (isBlockedStatus(currentState.status)) {
break;
}
}
@@ -214,12 +215,10 @@ export class AgentRuntime {
return {
events: allEvents,
newState: currentState,
// When execution is blocked (waiting for human or interrupted),
// clear nextContext so the outer loop stops instead of continuing
nextContext:
currentState.status === 'waiting_for_human' || currentState.status === 'interrupted'
? undefined
: finalNextContext,
// When execution is blocked (waiting for human, waiting for an async
// tool result, or interrupted), clear nextContext so the outer loop
// stops instead of continuing
nextContext: isBlockedStatus(currentState.status) ? undefined : finalNextContext,
};
} catch (error) {
const errorState = structuredClone(state);
+8 -1
View File
@@ -113,7 +113,14 @@ export interface AgentState {
*/
securityBlacklist?: SecurityBlacklistConfig;
// --- State Machine ---
status: 'idle' | 'running' | 'waiting_for_human' | 'done' | 'error' | 'interrupted';
status:
| 'idle'
| 'running'
| 'waiting_for_human'
| 'waiting_for_async_tool'
| 'done'
| 'error'
| 'interrupted';
// --- Execution Tracking ---
/**
@@ -1,3 +1,4 @@
export * from './messageSelectors';
export * from './status';
export * from './stepContextComputer';
export * from './tokenCounter';
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import type { AgentState } from '../types/state';
import { isBlockedStatus, isParkedStatus } from './status';
const ALL_STATUSES: AgentState['status'][] = [
'idle',
'running',
'waiting_for_human',
'waiting_for_async_tool',
'done',
'error',
'interrupted',
];
describe('isParkedStatus', () => {
it('is true only for the non-terminal resumable pauses', () => {
expect(isParkedStatus('waiting_for_human')).toBe(true);
expect(isParkedStatus('waiting_for_async_tool')).toBe(true);
});
it('is false for running, terminal, and interrupted', () => {
const nonParked = ALL_STATUSES.filter(
(s) => s !== 'waiting_for_human' && s !== 'waiting_for_async_tool',
);
for (const status of nonParked) expect(isParkedStatus(status)).toBe(false);
});
});
describe('isBlockedStatus', () => {
it('is true for parked statuses and user interrupt', () => {
expect(isBlockedStatus('waiting_for_human')).toBe(true);
expect(isBlockedStatus('waiting_for_async_tool')).toBe(true);
expect(isBlockedStatus('interrupted')).toBe(true);
});
it('is false for idle, running, and terminal statuses', () => {
expect(isBlockedStatus('idle')).toBe(false);
expect(isBlockedStatus('running')).toBe(false);
expect(isBlockedStatus('done')).toBe(false);
expect(isBlockedStatus('error')).toBe(false);
});
});
@@ -0,0 +1,19 @@
import type { AgentState } from '../types/state';
/**
* Parked statuses are non-terminal, resumable pauses: the operation is still
* alive but waiting on something out-of-band human approval
* (`waiting_for_human`) or an async tool / sub-agent result
* (`waiting_for_async_tool`). They are deliberately distinct from `interrupted`
* (user cancel) and the terminal `done` / `error`, so the completion lifecycle
* never stamps `completedAt` and the scheduler keeps treating them as active.
*/
export const isParkedStatus = (status: AgentState['status']): boolean =>
status === 'waiting_for_human' || status === 'waiting_for_async_tool';
/**
* Blocked statuses halt the step loop a parked pause or a user interrupt.
* `done` / `error` terminate through their own handling.
*/
export const isBlockedStatus = (status: AgentState['status']): boolean =>
isParkedStatus(status) || status === 'interrupted';
@@ -0,0 +1,21 @@
import type { BuiltinAgentDefinition } from '../../types';
import { BUILTIN_AGENT_SLUGS } from '../../types';
const SELF_ITERATION_TOOL_IDENTIFIER = 'agent-signal-self-iteration';
/**
* Self-Iteration Agent - shared execAgent target for nightly review, post-turn
* reflection, and declared feedback intents.
*
* All three flows share the same tool surface (`agent-signal-self-iteration`);
* the mode-specific guidance is supplied per-call by the caller's prompt builder,
* so the agent itself stays neutral.
*/
export const SELF_ITERATION: BuiltinAgentDefinition = {
runtime: {
plugins: [SELF_ITERATION_TOOL_IDENTIFIER],
systemRole:
'You are the self-iteration agent. Follow the mode-specific instructions in the user prompt and apply safe resource operations using the provided self-iteration tools. Be concise and evidence-driven.',
},
slug: BUILTIN_AGENT_SLUGS.selfIteration,
};
+12
View File
@@ -3,6 +3,7 @@ import { GROUP_AGENT_BUILDER } from './agents/group-agent-builder';
import { GROUP_SUPERVISOR } from './agents/group-supervisor';
import { INBOX } from './agents/inbox';
import { PAGE_AGENT } from './agents/page-agent';
import { SELF_ITERATION } from './agents/self-iteration';
import { TASK_AGENT } from './agents/task-agent';
import { WEB_ONBOARDING } from './agents/web-onboarding';
import type { BuiltinAgentDefinition, BuiltinAgentSlug, RuntimeContext } from './types';
@@ -16,6 +17,7 @@ export { GROUP_AGENT_BUILDER } from './agents/group-agent-builder';
export { GROUP_SUPERVISOR } from './agents/group-supervisor';
export { INBOX } from './agents/inbox';
export { PAGE_AGENT } from './agents/page-agent';
export { SELF_ITERATION } from './agents/self-iteration';
export { TASK_AGENT } from './agents/task-agent';
export { WEB_ONBOARDING } from './agents/web-onboarding';
@@ -28,10 +30,20 @@ export const BUILTIN_AGENTS: Record<BuiltinAgentSlug, BuiltinAgentDefinition> =
[BUILTIN_AGENT_SLUGS.groupSupervisor]: GROUP_SUPERVISOR,
[BUILTIN_AGENT_SLUGS.inbox]: INBOX,
[BUILTIN_AGENT_SLUGS.pageAgent]: PAGE_AGENT,
[BUILTIN_AGENT_SLUGS.selfIteration]: SELF_ITERATION,
[BUILTIN_AGENT_SLUGS.taskAgent]: TASK_AGENT,
[BUILTIN_AGENT_SLUGS.webOnboarding]: WEB_ONBOARDING,
};
/**
* Slugs that belong to the self-iteration family.
* Used by AgentSignal to skip re-triggering signal events
* for builtin background runs (suppressSignal behaviour).
*/
export const SELF_ITERATION_AGENT_SLUGS = new Set<BuiltinAgentSlug>([
BUILTIN_AGENT_SLUGS.selfIteration,
]);
/**
* Get persist config for a builtin agent (for DB operations)
* @param slug - The builtin agent slug
+1
View File
@@ -11,6 +11,7 @@ export const BUILTIN_AGENT_SLUGS = {
groupSupervisor: 'group-supervisor',
inbox: 'inbox',
pageAgent: 'page-agent',
selfIteration: 'self-iteration',
taskAgent: 'task-agent',
webOnboarding: 'web-onboarding',
} as const;
@@ -1,53 +1,76 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { cx } from 'antd-style';
import { GroupBotIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CallSubAgentParams, CallSubAgentState } from '../../../types';
import { SubAgentStats } from '../../components/SubAgentStats';
const styles = createStaticStyles(({ css, cssVar }) => ({
chip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
align-items: center;
min-width: 0;
padding-block: 2px;
padding-inline: 10px;
border-radius: 999px;
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillTertiary};
`,
icon: css`
flex-shrink: 0;
color: ${cssVar.colorTextDescription};
`,
label: css`
flex-shrink: 0;
color: ${cssVar.colorText};
`,
root: css`
gap: 6px;
`,
}));
/**
* Collapsed row for lobe-agent's `callSubAgent`. Mirrors the Claude Code Agent
* tool: leading bot icon + "Call SubAgent" label + the description in a chip.
* Once the run finishes, the persisted state feeds a compact stats tail
* (tool count · model · tokens).
*/
export const CallSubAgentInspector = memo<
BuiltinInspectorProps<CallSubAgentParams, CallSubAgentState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => {
>(({ args, partialArgs, pluginState, isArgumentsStreaming, isLoading }) => {
const { t } = useTranslation('plugin');
const description = args?.description || partialArgs?.description;
if (isArgumentsStreaming) {
if (!description)
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
</div>
);
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent.apiName.callSubAgent.loading')}</span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
if (description) {
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>
{isLoading
? t('builtins.lobe-agent.apiName.callSubAgent.loading')
: t('builtins.lobe-agent.apiName.callSubAgent.completed')}
</span>
<span className={highlightTextStyles.primary}>{description}</span>
</div>
);
}
const description = (args?.description || partialArgs?.description)?.trim();
const isShiny = isArgumentsStreaming || isLoading;
return (
<div className={inspectorTextStyles.root}>
<span>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
<div
className={cx(inspectorTextStyles.root, styles.root, isShiny && shinyTextStyles.shinyText)}
>
<GroupBotIcon className={styles.icon} size={14} />
<span className={styles.label}>{t('builtins.lobe-agent.apiName.callSubAgent')}</span>
{description && <span className={styles.chip}>{description}</span>}
{!isShiny && pluginState && (
<SubAgentStats
model={pluginState.model}
totalTokens={pluginState.totalTokens}
totalToolCalls={pluginState.totalToolCalls}
/>
)}
</div>
);
});
@@ -1,69 +1,87 @@
'use client';
import type { BuiltinInspectorProps } from '@lobechat/types';
import { Icon } from '@lobehub/ui';
import { GroupBotIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
import { ListTodo } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { highlightTextStyles, shinyTextStyles } from '@/styles';
import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { CallSubAgentsParams, CallSubAgentsState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
count: css`
flex-shrink: 0;
margin-inline-start: 4px;
color: ${cssVar.colorTextSecondary};
`,
description: css`
chip: css`
overflow: hidden;
display: inline-flex;
flex-shrink: 1;
align-items: center;
min-width: 0;
padding-block: 2px;
padding-inline: 10px;
border-radius: 999px;
font-size: 12px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
background: ${cssVar.colorFillTertiary};
`,
root: css`
overflow: hidden;
display: flex;
gap: 4px;
align-items: center;
icon: css`
flex-shrink: 0;
color: ${cssVar.colorTextDescription};
`,
title: css`
label: css`
flex-shrink: 0;
color: ${cssVar.colorText};
`,
more: css`
flex-shrink: 0;
color: ${cssVar.colorTextTertiary};
`,
root: css`
gap: 6px;
`,
}));
/** Show every description when there are at most this many; otherwise collapse. */
const MAX_VISIBLE = 2;
/**
* Collapsed row for lobe-agent's `callSubAgents`. Leading bot icon + "Call
* SubAgents" label, then each sub-agent description as a chip when there are
* few (<= 2). Beyond that, only the first is shown followed by a "{{count}} in
* total" tail to keep the row compact.
*/
export const CallSubAgentsInspector = memo<
BuiltinInspectorProps<CallSubAgentsParams, CallSubAgentsState>
>(({ args, partialArgs, isArgumentsStreaming }) => {
const { t } = useTranslation('plugin');
const tasks = args?.tasks || partialArgs?.tasks || [];
const count = tasks.length;
const firstTask = tasks[0];
const descriptions = tasks.map((task) => task?.description?.trim()).filter(Boolean) as string[];
const count = descriptions.length;
if (isArgumentsStreaming && count === 0) {
return (
<div className={cx(styles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-agent.apiName.callSubAgents')}</span>
</div>
);
}
const isShiny = isArgumentsStreaming;
const visible = count > MAX_VISIBLE ? descriptions.slice(0, 1) : descriptions;
const showMore = count > MAX_VISIBLE;
return (
<div className={styles.root}>
<span className={cx(styles.title, isArgumentsStreaming && shinyTextStyles.shinyText)}>
{t('builtins.lobe-agent.apiName.callSubAgents')}:
</span>
{firstTask?.description && (
<span className={cx(styles.description, highlightTextStyles.primary)}>
{firstTask.description}
<div
className={cx(inspectorTextStyles.root, styles.root, isShiny && shinyTextStyles.shinyText)}
>
<GroupBotIcon className={styles.icon} size={14} />
<span className={styles.label}>{t('builtins.lobe-agent.apiName.callSubAgents')}</span>
{visible.map((description, index) => (
<span className={styles.chip} key={index}>
{description}
</span>
)}
{count > 1 && (
<span className={styles.count}>
<Icon icon={ListTodo} size={14} /> {count}
))}
{showMore && (
<span className={styles.more}>
{t('builtins.lobe-agent.apiName.callSubAgents.more', { count })}
</span>
)}
</div>
@@ -1,64 +1,140 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Block } from '@lobehub/ui';
import { Button, Flexbox, Markdown, Text } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { ListTree } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
import type { CallSubAgentParams, CallSubAgentState } from '../../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
instruction: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
container: css`
padding-block: 4px;
`,
label: css`
padding-inline-start: 4px;
font-size: 12px;
line-height: 1.5;
color: ${cssVar.colorTextTertiary};
`,
taskContent: css`
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
labelRow: css`
margin-block-end: 4px;
`,
taskItem: css`
display: flex;
gap: 8px;
align-items: flex-start;
padding-block: 10px;
openThread: css`
height: 22px;
padding-inline: 6px;
font-size: 12px;
`,
promptBox: css`
padding-block: 8px;
padding-inline: 12px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorFillTertiary};
`,
title: css`
font-size: 13px;
line-height: 1.4;
color: ${cssVar.colorText};
resultBox: css`
padding-block: 8px;
padding-inline: 12px;
border-radius: ${cssVar.borderRadiusLG};
background: ${cssVar.colorBgContainer};
`,
}));
export const CallSubAgentRender = memo<BuiltinRenderProps<CallSubAgentParams, CallSubAgentState>>(
({ pluginState }) => {
const { task } = pluginState || {};
/**
* Render for lobe-agent's `callSubAgent` tool.
*
* A sub-agent runs in an isolated Thread via the current runtime, so this view
* shows the instruction sent to it plus its closing summary (the tool result),
* and exposes a toggle to open / collapse that Thread in the portal. The Thread
* is located by the `threadId` persisted in tool state; while the run is still
* starting the lookup can return `undefined`, so the button is hidden rather
* than rendered as a dead no-op.
*/
export const CallSubAgentRender = memo<
BuiltinRenderProps<CallSubAgentParams, CallSubAgentState, string>
>(({ args, content, pluginState }) => {
const { t } = useTranslation('plugin');
const { t: tChat } = useTranslation('chat');
const prompt = args?.instruction?.trim();
const result = typeof content === 'string' ? content.trim() : '';
const threadId = pluginState?.threadId;
if (!task) return null;
const subagentThread = useChatStore((s) =>
threadId
? (threadSelectors.currentTopicThreads(s) ?? []).find((thread) => thread.id === threadId)
: undefined,
);
const openThreadInPortal = useChatStore((s) => s.openThreadInPortal);
const closeThreadPortal = useChatStore((s) => s.closeThreadPortal);
const portalThreadId = useChatStore(portalThreadSelectors.portalThreadId);
const isOpenInPortal = !!subagentThread && portalThreadId === subagentThread.id;
return (
<Block variant={'outlined'} width="100%">
<div className={styles.taskItem}>
<div className={styles.taskContent}>
{task.description && <div className={styles.title}>{task.description}</div>}
{task.instruction && <div className={styles.instruction}>{task.instruction}</div>}
</div>
</div>
</Block>
);
},
);
const handleToggleThread = useCallback(() => {
if (!subagentThread) return;
if (isOpenInPortal) {
closeThreadPortal();
} else {
openThreadInPortal(subagentThread.id, subagentThread.sourceMessageId);
}
}, [subagentThread, isOpenInPortal, openThreadInPortal, closeThreadPortal]);
if (!prompt && !result && !subagentThread) return null;
const showResultSection = !!result || !!subagentThread;
return (
<Flexbox className={styles.container} gap={12}>
{prompt && (
<Flexbox>
<Text className={styles.label} style={{ marginBlockEnd: 4 }}>
{t('builtins.lobe-claude-code.agent.instruction')}
</Text>
<Flexbox className={styles.promptBox}>
<Markdown style={{ maxHeight: 240, overflow: 'auto' }} variant={'chat'}>
{prompt}
</Markdown>
</Flexbox>
</Flexbox>
)}
{showResultSection && (
<Flexbox>
<Flexbox
horizontal
align={'center'}
className={styles.labelRow}
justify={'space-between'}
>
<Text className={styles.label}>{t('builtins.lobe-claude-code.agent.result')}</Text>
{subagentThread && (
<Button
className={styles.openThread}
icon={ListTree}
size={'small'}
type={'text'}
onClick={handleToggleThread}
>
{isOpenInPortal
? tChat('thread.closeSubagentThread')
: tChat('thread.openSubagentThread')}
</Button>
)}
</Flexbox>
{result && (
<Flexbox className={styles.resultBox}>
<Markdown style={{ maxHeight: 320, overflow: 'auto' }} variant={'chat'}>
{result}
</Markdown>
</Flexbox>
)}
</Flexbox>
)}
</Flexbox>
);
});
CallSubAgentRender.displayName = 'CallSubAgentRender';
@@ -1,11 +1,17 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { Block, Text } from '@lobehub/ui';
import { Block, Button, Flexbox } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { ListTree } from 'lucide-react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { CallSubAgentsParams, CallSubAgentsState } from '../../../types';
import { useChatStore } from '@/store/chat';
import { portalThreadSelectors, threadSelectors } from '@/store/chat/selectors';
import type { CallSubAgentsParams, CallSubAgentsState, SubAgentRunStats } from '../../../types';
import { SubAgentStats } from '../../components/SubAgentStats';
const styles = createStaticStyles(({ css, cssVar }) => ({
index: css`
@@ -13,13 +19,18 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
font-size: 12px;
color: ${cssVar.colorTextQuaternary};
`,
taskItem: css`
openThread: css`
height: 22px;
padding-inline: 6px;
font-size: 12px;
`,
row: css`
display: flex;
gap: 8px;
align-items: flex-start;
align-items: center;
justify-content: space-between;
padding-block: 12px;
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px dashed ${cssVar.colorBorderSecondary};
@@ -27,33 +38,93 @@ const styles = createStaticStyles(({ css, cssVar }) => ({
border-block-end: none;
}
`,
title: css`
overflow: hidden;
font-size: 13px;
color: ${cssVar.colorText};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
interface SubAgentRowProps extends SubAgentRunStats {
description: string;
index: number;
threadId: string;
}
const SubAgentRow = memo<SubAgentRowProps>(
({ description, index, threadId, model, totalToolCalls, totalTokens }) => {
const { t: tChat } = useTranslation('chat');
const subagentThread = useChatStore((s) =>
threadId
? (threadSelectors.currentTopicThreads(s) ?? []).find((thread) => thread.id === threadId)
: undefined,
);
const openThreadInPortal = useChatStore((s) => s.openThreadInPortal);
const closeThreadPortal = useChatStore((s) => s.closeThreadPortal);
const portalThreadId = useChatStore(portalThreadSelectors.portalThreadId);
const isOpenInPortal = !!subagentThread && portalThreadId === subagentThread.id;
const handleToggleThread = useCallback(() => {
if (!subagentThread) return;
if (isOpenInPortal) {
closeThreadPortal();
} else {
openThreadInPortal(subagentThread.id, subagentThread.sourceMessageId);
}
}, [subagentThread, isOpenInPortal, openThreadInPortal, closeThreadPortal]);
return (
<div className={styles.row}>
<Flexbox horizontal align={'center'} gap={8} style={{ minWidth: 0 }}>
<span className={styles.index}>{index + 1}.</span>
<span className={styles.title}>{description}</span>
</Flexbox>
<Flexbox horizontal align={'center'} gap={12} style={{ flexShrink: 0 }}>
<SubAgentStats model={model} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
{subagentThread && (
<Button
className={styles.openThread}
icon={ListTree}
size={'small'}
type={'text'}
onClick={handleToggleThread}
>
{isOpenInPortal
? tChat('thread.closeSubagentThread')
: tChat('thread.openSubagentThread')}
</Button>
)}
</Flexbox>
</div>
);
},
);
SubAgentRow.displayName = 'CallSubAgentsRow';
export const CallSubAgentsRender = memo<
BuiltinRenderProps<CallSubAgentsParams, CallSubAgentsState>
>(({ pluginState }) => {
const { tasks } = pluginState || {};
const subAgents = pluginState?.subAgents;
if (!tasks || tasks.length === 0) return null;
if (!subAgents || subAgents.length === 0) return null;
return (
<Block variant={'outlined'} width="100%">
{tasks.map((task, index) => (
<div className={styles.taskItem} key={index}>
<div className={styles.index}>{index + 1}.</div>
<div>
{task.description && (
<Text as={'h4'} fontSize={14} weight={500}>
{task.description}
</Text>
)}
{task.instruction && (
<Text as={'p'} ellipsis={{ rows: 2 }} fontSize={12} type={'secondary'}>
{task.instruction}
</Text>
)}
</div>
</div>
{subAgents.map((subAgent, index) => (
<SubAgentRow
description={subAgent.description}
index={index}
key={subAgent.threadId || index}
model={subAgent.model}
threadId={subAgent.threadId}
totalTokens={subAgent.totalTokens}
totalToolCalls={subAgent.totalToolCalls}
/>
))}
</Block>
);
@@ -0,0 +1,52 @@
'use client';
import { createStaticStyles } from 'antd-style';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { SubAgentRunStats } from '../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
root: css`
overflow: hidden;
flex-shrink: 0;
font-size: 12px;
color: ${cssVar.colorTextTertiary};
text-overflow: ellipsis;
white-space: nowrap;
`,
}));
const formatTokens = (n: number): string => {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`;
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}m`;
};
/**
* Compact one-line sub-agent run stats: tool count · model · token count.
* Renders nothing when no stat is available (e.g. while the run is still in
* flight, before the tool result state is persisted).
*/
export const SubAgentStats = memo<SubAgentRunStats>(({ model, totalToolCalls, totalTokens }) => {
const { t } = useTranslation('plugin');
const items = [
model || null,
typeof totalToolCalls === 'number' && totalToolCalls > 0
? t('builtins.lobe-agent.subAgent.stats.tools', { count: totalToolCalls })
: null,
typeof totalTokens === 'number' && totalTokens > 0
? t('builtins.lobe-agent.subAgent.stats.tokens', { count: formatTokens(totalTokens) })
: null,
].filter(Boolean);
if (items.length === 0) return null;
return <span className={styles.root}>{items.join(' · ')}</span>;
});
SubAgentStats.displayName = 'SubAgentStats';
export default SubAgentStats;
@@ -359,28 +359,41 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
// ==================== Sub-Agent ====================
//
// The executor only constructs the state payload that bridges the tool call
// to the agent-runtime instruction layer. The actual sub-agent dispatch is
// handled by `createAgentExecutors.ts` which reads `state.type` to emit the
// matching `exec_sub_agent` / `exec_client_sub_agent(s)` instruction.
// A sub-agent call is a normal tool call: the executor runs the sub-agent in
// an isolated Thread via `ctx.subAgent` (the current runtime, injected by the
// client) and returns the sub-agent's final output as the tool result. The
// Thread id is persisted in state so the Render can open it in the portal.
callSubAgent = async (
params: CallSubAgentParams,
ctx: BuiltinToolContext,
): Promise<BuiltinToolResult> => {
const { description, instruction, inheritMessages, timeout, runInClient } = params;
const { description, instruction, inheritMessages, timeout } = params;
if (!description || !instruction) {
return { content: 'Sub-agent description and instruction are required.', success: false };
}
const task = { description, inheritMessages, instruction, runInClient, timeout };
const stateType = runInClient ? 'execClientSubAgent' : 'execSubAgent';
if (!ctx.subAgent) {
return { content: 'Sub-agent execution is not available in this runtime.', success: false };
}
const { result, threadId, success, error, model, totalToolCalls, totalTokens } =
await ctx.subAgent.run({
description,
inheritMessages,
instruction,
timeout,
toolMessageId: ctx.messageId,
});
if (!success) {
return { content: error ?? 'Sub-agent execution failed.', success: false };
}
return {
content: `🚀 Dispatched sub-agent for ${runInClient ? 'client-side' : ''} execution:\n- ${description}`,
state: { parentMessageId: ctx.messageId ?? '', task, type: stateType },
stop: true,
content: result,
state: { model, threadId, totalToolCalls, totalTokens },
success: true,
};
};
@@ -395,16 +408,38 @@ class LobeAgentExecutor extends BaseExecutor<typeof LobeAgentApiName> {
return { content: 'No sub-agents provided to dispatch.', success: false };
}
const taskCount = tasks.length;
const taskList = tasks.map((t, i) => `${i + 1}. ${t.description}`).join('\n');
const hasClientTasks = tasks.some((t) => t.runInClient);
const stateType = hasClientTasks ? 'execClientSubAgents' : 'execSubAgents';
const executionMode = hasClientTasks ? 'client-side' : '';
if (!ctx.subAgent) {
return { content: 'Sub-agent execution is not available in this runtime.', success: false };
}
const subAgent = ctx.subAgent;
const results = await Promise.all(
tasks.map((task) =>
subAgent.run({
description: task.description,
inheritMessages: task.inheritMessages,
instruction: task.instruction,
timeout: task.timeout,
toolMessageId: ctx.messageId,
}),
),
);
const content = results
.map((r, i) => `${i + 1}. ${tasks[i].description}\n${r.success ? r.result : `${r.error}`}`)
.join('\n\n');
return {
content: `🚀 Dispatched ${taskCount} sub-agent${taskCount > 1 ? 's' : ''} for ${executionMode} execution:\n${taskList}`,
state: { parentMessageId: ctx.messageId ?? '', tasks, type: stateType },
stop: true,
content,
state: {
subAgents: results.map((r, i) => ({
description: tasks[i].description,
model: r.model,
threadId: r.threadId,
totalToolCalls: r.totalToolCalls,
totalTokens: r.totalTokens,
})),
},
success: true,
};
};
+18 -26
View File
@@ -82,38 +82,30 @@ export interface CallSubAgentsParams {
tasks: SubAgentTask[];
}
/** Execution stats reported back by a finished sub-agent run. */
export interface SubAgentRunStats {
/** Model the sub-agent ran on */
model?: string;
/** Total tokens consumed by the sub-agent run */
totalTokens?: number;
/** Number of tool calls the sub-agent made */
totalToolCalls?: number;
}
/**
* State returned after dispatching a server-side sub-agent.
* State persisted on the callSubAgent tool message.
*
* The `type` value is the wire-level discriminator the `agent-runtime`
* layer (`GeneralChatAgent.tool_result`) inspects to emit the matching
* `exec_sub_agent` / `exec_client_sub_agent` instruction.
* The sub-agent runs in an isolated Thread via the current runtime; the Render
* uses `threadId` to open that Thread in the portal, and the stats feed the
* Inspector row.
*/
export interface CallSubAgentState {
parentMessageId: string;
task: SubAgentTask;
type: 'execSubAgent';
export interface CallSubAgentState extends SubAgentRunStats {
threadId: string;
}
/** State returned after dispatching multiple server-side sub-agents. */
/** State persisted on the callSubAgents tool message (one entry per sub-agent). */
export interface CallSubAgentsState {
parentMessageId: string;
tasks: SubAgentTask[];
type: 'execSubAgents';
}
/** State returned after dispatching a desktop-only client-side sub-agent. */
export interface CallClientSubAgentState {
parentMessageId: string;
task: SubAgentTask;
type: 'execClientSubAgent';
}
/** State returned after dispatching multiple desktop-only client-side sub-agents. */
export interface CallClientSubAgentsState {
parentMessageId: string;
tasks: SubAgentTask[];
type: 'execClientSubAgents';
subAgents: ({ description: string; threadId: string } & SubAgentRunStats)[];
}
// ==================== Todo Item ====================
@@ -185,9 +185,9 @@ export class LocalSystemExecutionRuntime extends ComputerRuntime {
case 'getCommandOutput': {
return {
result: {
exitCode: raw.exit_code,
error: raw.error,
newOutput: raw.output,
running: raw.running,
success: raw.success,
},
success: raw.success,
@@ -148,7 +148,7 @@ describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
};
const spy = vi.spyOn(runtime, 'getCommandOutput').mockResolvedValue({
content: '',
state: { newOutput: '', running: false, success: true },
state: { exitCode: 0, newOutput: '', success: true },
success: true,
});
@@ -161,6 +161,33 @@ describe('localSystemExecutor.getCommandOutput — filter forwarding', () => {
spy.mockRestore();
});
it('preserves `exitCode` state from getCommandOutput', async () => {
const runtime = (localSystemExecutor as any).runtime as {
getCommandOutput: (args: any) => Promise<unknown>;
};
const spy = vi.spyOn(runtime, 'getCommandOutput');
spy.mockResolvedValueOnce({
content: '',
state: { exitCode: undefined, newOutput: '', success: true },
success: true,
});
const runningResult = await localSystemExecutor.getCommandOutput({ shell_id: 'sh-running' });
expect(runningResult.state).toMatchObject({ exitCode: undefined });
spy.mockResolvedValueOnce({
content: '',
state: { exitCode: 0, newOutput: 'done', success: true },
success: true,
});
const doneResult = await localSystemExecutor.getCommandOutput({ shell_id: 'sh-done' });
expect(doneResult.state).toMatchObject({ exitCode: 0 });
spy.mockRestore();
});
});
describe('localSystemExecutor.runCommand — background field normalization', () => {
@@ -263,9 +263,9 @@ export const LocalSystemManifest: BuiltinToolManifest = {
},
},
{
defaultTimeoutMs: 120_000,
defaultTimeoutMs: 30_000,
description:
'Execute a shell command and return its output. Supports both synchronous and background execution with timeout control.',
'Start a terminal session to execute a shell command and return console output collected during the wait window. If the command exits during that window, the result includes `exit_code`; if it is still running, the result includes `shell_id` for later output retrieval or termination.',
humanIntervention: 'required',
name: LocalSystemApiName.runCommand,
parameters: {
@@ -286,13 +286,9 @@ export const LocalSystemManifest: BuiltinToolManifest = {
type: 'object',
},
run_in_background: {
description: 'Set to true to run command in background and return shell_id',
type: 'boolean',
},
timeout: {
description:
'Timeout in milliseconds for this command. Default 120000ms. Server clamps to [1000, 800000]; raise this for long-running tasks (builds, large searches) instead of letting them hit the default and fail.',
type: 'number',
'Set to true to return immediately after starting the terminal session. The result will include a `shell_id` for later observation or termination.',
type: 'boolean',
},
},
required: ['description', 'command'],
@@ -28,9 +28,9 @@ You have access to a set of tools to interact with the user's local file system:
5. **moveFiles**: Moves multiple files or directories. Also handles renames pass the original directory with the new filename in \`newPath\`.
**Shell Commands:**
6. **runCommand**: Execute shell commands with timeout control. Supports both synchronous and background execution. When providing a description, always use the same language as the user's input.
7. **getCommandOutput**: Retrieve output from running background commands. Returns only new output since last check.
8. **killCommand**: Terminate a running background shell command by its ID.
6. **runCommand**: Start a terminal session to execute shell commands and return console output collected during the wait window. When providing a description, always use the same language as the user's input.
7. **getCommandOutput**: Retrieve output from an existing terminal session. Returns only new output since last check.
8. **killCommand**: Terminate a running terminal session by its ID.
**Search & Find:**
9. **searchFiles**: Searches for files based on keywords and other criteria using native search. Use this tool to find files if the user is unsure about the exact path.
@@ -85,14 +85,17 @@ You have access to a set of tools to interact with the user's local file system:
- For executing shell commands: Use 'runCommand'. Provide the following parameters:
- 'command': The shell command to execute.
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
- 'run_in_background' (Optional): Set to true to return immediately after starting the terminal session. The result includes a 'shell_id' for later observation or termination.
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
- 'shell_id': The ID returned from runCommand when run_in_background was true.
- Result semantics:
- 'success' indicates whether the tool call itself succeeded.
- 'shell_id' identifies the terminal session for later observation/termination.
- 'exit_code' is only present after the command has exited. If it is absent, the command is still running.
- For retrieving output from terminal sessions: Use 'getCommandOutput'. Provide:
- 'shell_id': The ID returned from runCommand.
- 'filter' (Optional): A regex pattern to filter output lines.
Returns only new output since the last check.
- For killing background commands: Use 'killCommand' with 'shell_id'.
- For killing running terminal sessions: Use 'killCommand' with 'shell_id'.
- For searching content in files: Use 'grepContent'. Provide:
- 'pattern': The regex pattern to search for.
- 'scope' (Optional): Directory to search in. Defaults to the working directory if omitted.
@@ -118,7 +121,6 @@ You have access to a set of tools to interact with the user's local file system:
- Be cautious with commands that have side effects (e.g., rm, sudo, format).
- Always describe what a command will do before running it, especially for non-trivial operations.
- Always provide a clear 'description' parameter in the user's language to help them understand what the command does.
- Use appropriate timeouts to prevent commands from running indefinitely.
- When editing files:
- Always read the file first to verify its current content.
- Ensure old_string exactly matches the text to be replaced to avoid unintended changes.
@@ -17,9 +17,9 @@ You have access to a set of tools to interact with the user's local file system:
5. **moveFiles**: Moves multiple files or directories. Also handles renames pass the original directory with the new filename in \`newPath\`.
**Shell Commands:**
6. **runCommand**: Execute shell commands with timeout control. Supports both synchronous and background execution. When providing a description, always use the same language as the user's input.
7. **getCommandOutput**: Retrieve output from running background commands. Returns only new output since last check.
8. **killCommand**: Terminate a running background shell command by its ID.
6. **runCommand**: Start a terminal session to execute shell commands and return console output collected during the wait window. When providing a description, always use the same language as the user's input.
7. **getCommandOutput**: Retrieve output from an existing terminal session. Returns only new output since last check.
8. **killCommand**: Terminate a running terminal session by its ID.
**Search & Find:**
9. **searchFiles**: Searches for files based on keywords and other criteria using native search. Use this tool to find files if the user is unsure about the exact path.
@@ -74,14 +74,17 @@ You have access to a set of tools to interact with the user's local file system:
- For executing shell commands: Use 'runCommand'. Provide the following parameters:
- 'command': The shell command to execute.
- 'description' (Optional but recommended): A clear, concise description of what the command does (5-10 words, in active voice). **IMPORTANT: Always use the same language as the user's input.** If the user speaks Chinese, write the description in Chinese; if English, use English, etc.
- 'run_in_background' (Optional): Set to true to run in background and get a shell_id for later checking output.
- 'timeout' (Optional): Timeout in milliseconds (default: 120000ms, max: 800000ms).
- 'run_in_background' (Optional): Set to true to return immediately after starting the terminal session. The result includes a 'shell_id' for later observation or termination.
The command runs in cmd.exe on Windows or /bin/sh on macOS/Linux.
- For retrieving output from background commands: Use 'getCommandOutput'. Provide:
- 'shell_id': The ID returned from runCommand when run_in_background was true.
- Result semantics:
- 'success' indicates whether the tool call itself succeeded.
- 'shell_id' identifies the terminal session for later observation/termination.
- 'exit_code' is only present after the command has exited. If it is absent, the command is still running.
- For retrieving output from terminal sessions: Use 'getCommandOutput'. Provide:
- 'shell_id': The ID returned from runCommand.
- 'filter' (Optional): A regex pattern to filter output lines.
Returns only new output since the last check.
- For killing background commands: Use 'killCommand' with 'shell_id'.
- For killing running terminal sessions: Use 'killCommand' with 'shell_id'.
- For searching content in files: Use 'grepContent'. Provide:
- 'pattern': The regex pattern to search for.
- 'scope' (Optional): Directory to search in. Defaults to the working directory if omitted.
@@ -7,6 +7,7 @@ export const MessageToolIdentifier = 'lobe-message';
export const MessagePlatform = {
discord: 'discord',
feishu: 'feishu',
imessage: 'imessage',
lark: 'lark',
qq: 'qq',
slack: 'slack',
@@ -0,0 +1,112 @@
'use client';
import type { InitDocumentArgs } from '@lobechat/editor-runtime';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { Flexbox, Icon, Text } from '@lobehub/ui';
import { createStaticStyles, cssVar } from 'antd-style';
import { FileText, Hash, ListTree } from 'lucide-react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import StreamingMarkdown from '@/components/StreamingMarkdown';
import { AnimatedNumber } from '../../components/AnimatedNumber';
const MAX_PREVIEW_CHARS = 4000;
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 8px;
background: ${cssVar.colorBgContainer};
`,
header: css`
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
icon: css`
color: ${cssVar.colorPrimary};
`,
meta: css`
color: ${cssVar.colorTextDescription};
`,
preview: css`
max-height: 360px;
overflow: auto;
padding-block: 8px;
padding-inline: 12px;
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
const extractTitle = (markdown: string) => {
const titleLine = markdown
.split(/\r?\n/)
.find((line) => line.startsWith('# ') && line.slice(2).trim().length > 0);
return titleLine?.slice(2).trim();
};
export const InitPageStreaming = memo<BuiltinStreamingProps<InitDocumentArgs>>(({ args }) => {
const { t } = useTranslation('plugin');
const markdown = args?.markdown || '';
const { chars, lines, preview, title } = useMemo(() => {
const preview =
markdown.length > MAX_PREVIEW_CHARS
? `${markdown.slice(0, MAX_PREVIEW_CHARS)}\n\n...`
: markdown;
return {
chars: markdown.length,
lines: markdown ? markdown.split('\n').length : 0,
preview,
title: extractTitle(markdown),
};
}, [markdown]);
if (!markdown) return null;
return (
<Flexbox className={styles.container}>
<Flexbox horizontal align={'center'} className={styles.header} gap={8}>
<FileText className={styles.icon} size={16} />
<Flexbox flex={1} gap={2}>
<div className={styles.title}>
{title || t('builtins.lobe-page-agent.apiName.initPage.creating')}
</div>
<Flexbox horizontal align={'center'} className={styles.meta} gap={10}>
<Text as={'span'} color={cssVar.colorTextDescription} fontSize={12}>
<Icon icon={ListTree} size={12} /> <AnimatedNumber value={lines} />
{t('builtins.lobe-page-agent.apiName.initPage.lines')}
</Text>
<Text as={'span'} color={cssVar.colorTextDescription} fontSize={12}>
<Icon icon={Hash} size={12} /> <AnimatedNumber value={chars} />
{t('builtins.lobe-page-agent.apiName.initPage.chars')}
</Text>
</Flexbox>
</Flexbox>
</Flexbox>
<div className={styles.preview}>
<StreamingMarkdown>{preview}</StreamingMarkdown>
</div>
</Flexbox>
);
});
InitPageStreaming.displayName = 'PageAgentInitPageStreaming';
export default InitPageStreaming;
@@ -1,9 +1,14 @@
import type { BuiltinStreaming } from '@lobechat/types';
import { DocumentApiName } from '../../types';
import { InitPageStreaming } from './InitPage';
/**
* Page Agent Streaming Components Registry
*
* Streaming components are used to render tool calls while arguments
* are still being generated, allowing real-time feedback to users.
*/
export const PageAgentStreamings: Record<string, BuiltinStreaming> = {};
export const PageAgentStreamings: Record<string, BuiltinStreaming> = {
[DocumentApiName.initPage]: InitPageStreaming as BuiltinStreaming,
};
@@ -4,7 +4,6 @@ import { AGENT_SKILLS_IDENTIFIER_PREFIX } from '@lobechat/const';
import { type BuiltinInspectorProps } from '@lobechat/types';
import { SkillsIcon } from '@lobehub/ui/icons';
import { createStaticStyles, cx } from 'antd-style';
import { type TFunction } from 'i18next';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@@ -12,33 +11,34 @@ import { inspectorTextStyles, shinyTextStyles } from '@/styles';
import type { ActivateSkillParams, ActivateSkillSource, ActivateSkillState } from '../../../types';
type SkillLabelKey =
| 'builtins.lobe-skills.apiName.activateAgentSkill'
| 'builtins.lobe-skills.apiName.activateProjectSkill'
| 'builtins.lobe-skills.apiName.activateSkill';
/**
* Resolve the inspector label. State-side `source` is the authority once the
* Resolve the inspector label key. State-side `source` is the authority once the
* tool result has streamed in; while args are still streaming we only have the
* raw `name` to go on, so detect agent skills via the identifier prefix as a
* best-effort fallback. Project skills can't be inferred from the bare name
* (no prefix), so they show "Activate Skill" until the result lands.
*
* `t` is invoked with literal keys per branch so i18next's typed-key map can
* still validate the call site.
*/
const resolveLabel = (
t: TFunction<'plugin'>,
const resolveLabelKey = (
source: ActivateSkillSource | undefined,
rawName: string | undefined,
): string => {
): SkillLabelKey => {
const effective: ActivateSkillSource =
source ?? (rawName?.startsWith(AGENT_SKILLS_IDENTIFIER_PREFIX) ? 'agent' : 'builtin');
switch (effective) {
case 'agent': {
return t('builtins.lobe-skills.apiName.activateAgentSkill');
return 'builtins.lobe-skills.apiName.activateAgentSkill';
}
case 'project': {
return t('builtins.lobe-skills.apiName.activateProjectSkill');
return 'builtins.lobe-skills.apiName.activateProjectSkill';
}
default: {
return t('builtins.lobe-skills.apiName.activateSkill');
return 'builtins.lobe-skills.apiName.activateSkill';
}
}
};
@@ -84,7 +84,7 @@ export const RunSkillInspector = memo<
const name = args?.name || partialArgs?.name;
const displayName = pluginState?.title || pluginState?.name || name;
const label = resolveLabel(t, pluginState?.source, name);
const label = t(resolveLabelKey(pluginState?.source, name));
if (isArgumentsStreaming) {
if (!displayName)
+2
View File
@@ -66,6 +66,7 @@ import type { BuiltinInspector } from '@lobechat/types';
import { CodexInspectors } from './codex';
import { GithubIdentifier, GithubInspectors } from './github';
import { LinearIdentifier, LinearInspectors } from './linear';
import { TwitterIdentifier, TwitterInspectors } from './twitter';
/**
* Builtin tools inspector registry
@@ -113,6 +114,7 @@ const BuiltinToolInspectors: Record<string, Record<string, BuiltinInspector>> =
},
[GithubIdentifier]: GithubInspectors,
[LinearIdentifier]: LinearInspectors,
[TwitterIdentifier]: TwitterInspectors,
};
export interface BuiltinInspectorRegistryEntry {
+2
View File
@@ -33,6 +33,7 @@ import {
} from '@lobechat/builtin-tool-local-system/client';
import { MemoryManifest, MemoryStreamings } from '@lobechat/builtin-tool-memory/client';
import { MessageManifest, MessageStreamings } from '@lobechat/builtin-tool-message/client';
import { PageAgentManifest, PageAgentStreamings } from '@lobechat/builtin-tool-page-agent/client';
import { type BuiltinStreaming } from '@lobechat/types';
/**
@@ -64,6 +65,7 @@ const BuiltinToolStreamings: Record<string, Record<string, BuiltinStreaming>> =
[LocalSystemManifest.identifier]: LocalSystemStreamings as Record<string, BuiltinStreaming>,
[MemoryManifest.identifier]: MemoryStreamings as Record<string, BuiltinStreaming>,
[MessageManifest.identifier]: MessageStreamings as Record<string, BuiltinStreaming>,
[PageAgentManifest.identifier]: PageAgentStreamings as Record<string, BuiltinStreaming>,
};
export interface BuiltinStreamingRegistryEntry {

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