Compare commits

..

219 Commits

Author SHA1 Message Date
Arvin Xu ddf058fb36 🐛 fix: forward serverUrl in WS auth for apiKey verification
The agent gateway verifies an apiKey by calling
\`\${serverUrl}/api/v1/users/me\` with the token, so \`serverUrl\` has to be
part of the WebSocket auth handshake. The device-gateway-client already
does this; \`lh agent run\` was missing it, producing
"Gateway auth failed: Missing serverUrl for apiKey auth".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 00:00:47 +08:00
Arvin Xu 78dda79f81 update cli version 2026-04-14 23:36:47 +08:00
Arvin Xu f6081c9914 🔨 chore: add headless approval and apiKey WS auth to lh agent run (#13819)
 feat: add headless approval and apiKey ws auth to `lh agent run`

Two fixes so `lh agent run` works end-to-end against the WebSocket agent
gateway when the user is authenticated via LOBEHUB_CLI_API_KEY.

- Default to `userInterventionConfig: { approvalMode: 'headless' }` when
  running the agent from the CLI. Without this flag the runtime waits
  for human tool-call approval and local-device commands hang forever.
  Users who want interactive approval can pass `--no-headless`.
- Pass `tokenType` (`jwt` | `apiKey`) in the WebSocket auth handshake so
  the gateway knows how to verify the token. Previously the CLI sent
  only the raw token value and the gateway assumed JWT, rejecting valid
  API keys.

Fixes LOBE-6939

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:28:01 +08:00
Arvin Xu d6f11f80b6 🐛 fix(agent-runtime): harden classifyLLMError so it never masks the original provider error (#13774)
* 🐛 fix(agent-runtime): harden classifyLLMError so it never masks the original provider error

Production traces across multiple providers (openrouter, openai, google)
surface a single opaque error — `e.trim is not a function` with
`errorType: 'unknown'` — hiding whatever the upstream actually returned.

Root cause: `normalizeCode` / `normalizeErrorType` assumed their input is
always `string | undefined` (matching the TypeScript signature), but real
provider error objects frequently carry a numeric `code` (HTTP status) or
a structured object in `errorType`. `value?.trim()` short-circuits only
on null/undefined, so a truthy non-string turns into a TypeError that
the outer catch records as the "final" error, erasing the upstream one.

Fixes:
- Guard `normalizeCode` / `normalizeErrorType` on `typeof value ===
  'string'`, widen parameter type to `unknown`.
- Wrap the whole `classifyLLMError` in a try/catch that falls back to a
  conservative `stop` decision and preserves the best-effort message of
  the ORIGINAL error. A classifier that throws is worse than a
  classifier that's wrong — it must never shadow the real failure.
- `bestEffortMessage` swallows property-access errors (hostile Proxy
  etc.) to guarantee the fallback itself can't throw.

Regression tests cover: numeric `code`, structured `errorType`, nested
OpenAI-SDK-shaped `error.error.code`, and a hostile Proxy that throws on
every property access.

This is a forcing function for root-cause diagnosis: after this lands,
the real upstream errors behind the 'e.trim' mask will finally surface.

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

* Remove fallback warning in classifyLLMError

Removed console warning for classification failure.

* 🐛 fix(agent-runtime): treat numeric provider code as status fallback

Bare HTTP proxies sometimes surface the HTTP status ONLY as a numeric `code`
on the error object (no `status`/`statusCode`, no digits in the message).
After widening `normalizeCode` to require `typeof === 'string'`, those numeric
codes were dropped entirely and auth/permission failures fell through to
retry — wasting the full retry budget on permanent errors.

Forward numeric `raw.code` / `nested?.code` / `nestedError?.code` into the
status chain (after the real status/statusCode lookups, before the
message-digit extractor) so classifyKind still maps 401/403 → stop and
429/5xx → retry.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 23:23:21 +08:00
Rdmclin2 1c75686b70 🐛 fix: gateway typing error (#13820)
fix: gateway typing error
2026-04-14 23:15:41 +08:00
Arvin Xu 7e89fa782d 🐛 fix: detect truncated tool_calls arguments in builtin tools (#13817)
* 🐛 fix: detect truncated tool_call arguments in builtin tools

When an LLM hits max_tokens mid tool_call, the arguments JSON is
truncated. The previous flow passed `{}` to the tool, which returned a
generic "required field missing" error; the model re-tried with the same
payload and the truncation repeated — one observed trace burned 17 min
and $2.46 on 5 blind retries.

Detect structural truncation (unclosed braces/brackets/strings) in
BuiltinToolsExecutor before schema validation, and return a dedicated
TRUNCATED_ARGUMENTS error telling the model to reduce payload size or
raise max_tokens instead of retrying.

Fixes LOBE-7148

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

* 💄 chore: echo raw arguments string and reject all unparseable JSON

Two improvements based on review:

- Append the received arguments string to the error content so the model
  can verify the payload is exactly what it produced (stops it from
  blaming upstream or guessing what went wrong).
- Treat ANY unparseable non-empty argsStr as an error (new code
  INVALID_JSON_ARGUMENTS), not just truncation. The previous fallback
  of passing `{}` to the tool produced generic "missing field" errors
  that hid the real cause. Empty argsStr still falls through to `{}`
  for tools that take no parameters.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:50:49 +08:00
Arvin Xu 18bc2716b2 🔨 fix: wire Gateway-mode stop via direct tRPC interrupt (#13815)
*  feat: wire Gateway-mode stop button to WS interrupt

Frontend half of [LOBE-7142](https://linear.app/lobehub/issue/LOBE-7142)
— the stop button previously silently failed in Gateway mode because:

1. `stopGenerateMessage` only filtered `execAgentRuntime`, so
   `execServerAgentRuntime` ops (Gateway) were skipped.
2. Even if the local op got cancelled, nothing bridged the cancel to
   the server-side agent loop running behind the Agent Gateway WS.

## Changes

**`conversationControl.ts::stopGenerateMessage`** — extend the type
filter to include both op types so both client-side and Gateway-mode
runs are cancelled from the same entry point.

**`gateway.ts::executeGatewayAgent` + `reconnectToGatewayOperation`** —
register an `onOperationCancel` handler on the local `gatewayOpId` that
forwards the server-side operation id to `interruptGatewayAgent(...)`,
which sends `{ type: 'interrupt' }` over the Agent Gateway WS. The
closure cleanly resolves the "local op id vs server op id" mapping —
no metadata lookup needed.

**`operation/actions.ts::cancelOperation`** — `isAborting` flag was
gated on `execAgentRuntime`. Extend to `execServerAgentRuntime` too so
the UI loading state transitions out immediately on Gateway-mode stop,
without waiting for the round-trip `session_complete` from the server.

## What this doesn't do (follow-ups)

- **Backend**: new `POST /api/agent/interrupt` route + Redis LPUSH
  (LOBE-7145). Without it, the WS interrupt reaches Agent Gateway but
  never gets forwarded to cloud.
- **Agent loop**: `AgentRuntimeService.executeStep` LPOP polling of the
  interrupt key (LOBE-7146). Without it, the state never flips to
  `interrupted` server-side.
- **Agent Gateway DO** (external repo): `_forwardInterrupt` HTTP POST
  from the WS interrupt handler (LOBE-7147).

With only this PR merged, clicking stop will clear the local UI state
and send the WS frame correctly — the server-side loop keeps running
until those three are merged too.

## Tests

- `conversationControl.test.ts`: +1 — stopGenerateMessage cancels
  `execServerAgentRuntime`, invokes the onCancel handler, sets
  `isAborting: true`.
- `gateway.test.ts`: +1 — `executeGatewayAgent` registers a handler
  against the local opId, handler invokes `interruptGatewayAgent`
  with the server opId.

All 123 touched-slice tests pass; type-check clean.

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

* 🔨 chore: switch Gateway stop to direct tRPC instead of WS roundtrip

Rewiring only — no new behaviour on top of the previous commit. See
the discussion in PR #13815 for the full reasoning.

TL;DR the WS-based path (client → Agent Gateway WS → DO forwards
HTTP → cloud route → Redis LPUSH → loop LPOP) has the same end-effect
as the tRPC-direct path (client → tRPC → AgentRuntimeService
.interruptOperation → DB state flip), except:

- the tRPC path is one hop instead of three
- the tRPC path reuses infrastructure that's *already on canary* —
  `aiAgentService.interruptTask` → `AiAgentService.interruptTask` →
  `AgentRuntimeService.interruptOperation` → `coordinator.saveAgentState`
  with status='interrupted' — and the existing step-boundary polling
  in `executeStep` (AgentRuntimeService.ts:474, 565) already picks it up
- zero new server code required; zero Agent Gateway (external repo)
  coordination required

The only reason the WS path was in the original spec (LOBE-7142) was
symmetry with the Phase 6.4 tool_execute/tool_result path, but
`interrupt` is a one-shot control signal, not stream data — there's
no actual benefit to routing it through the same channel. Mid-step
abort would require threading an AbortSignal into `runtime.step(...)`,
which WS doesn't help with either.

Closes out the need for LOBE-7145 / LOBE-7146 / LOBE-7147.

Changes:
- `gateway.ts`: both `executeGatewayAgent` and
  `reconnectToGatewayOperation` register the cancel handler against
  the local op id, but the handler body now calls
  `aiAgentService.interruptTask({ operationId: serverOpId })` via
  tRPC instead of `this.interruptGatewayAgent(serverOpId)` (which sent
  the WS interrupt frame).
- `gateway.test.ts`: adjust the one new test case to verify the
  tRPC call rather than the WS-path spy; add `interruptTask` to the
  `aiAgentService` mock.

`AgentStreamClient.sendInterrupt()` and `interruptGatewayAgent()` are
kept as-is — public API, might be useful elsewhere. Just not called
from the cancel handler anymore.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:41:45 +08:00
Rdmclin2 636a3b77c3 🐛 fix: message gateway queue error (#13816)
* fix: gateway sync

* fix: skip  error connection

* feat: add disconnect all &  MESSAGE_GATEWAY_ENABLED env vairable

* chore: add gateway test case

* chore: clean lobehub connnections when switch to message gateway

* chore: optimize disconnect all

* chore: disconnect gateway connnections when using lobehub gateway

* chore: clean up exsiting gateway connections after reconnect and avoid gateway callback when not enabled
2026-04-14 22:10:17 +08:00
Arvin Xu c70ac84da7 feat: support run client tools in agent gateway mode (#13792)
*  feat: receive and execute executor=client tools on desktop Electron

Frontend half of LOBE-7076 (Phase 6.4). Pairs with server PR #13790,
which adds the `clientRuntime` signal + `hasClientExecutor` gate so
`local-system` and stdio MCP can enter the manifest for desktop callers.

Data flow, client side:

  Agent Gateway WS
     └─ tool_execute event ──► AgentStreamClient
            └─ 'agent_event' ──► gatewayEventHandler (case 'tool_execute')
                    └─ internal_executeClientTool (fire-and-forget)
                          ├─ parse args → params
                          ├─ mark pendingClientToolExecutions[toolCallId]
                          ├─ dispatch: builtin → invokeExecutor,
                          │            else   → mcpService.invokeMcpToolCall
                          ├─ clear pending
                          └─ AgentStreamClient.sendToolResult(...)
                                └─ WS → /api/agent/tool-result → LPUSH
                                       → server BLPOP unblocks → loop continues

Key guarantees:

- `internal_executeClientTool` never throws; ALL error paths (parse
  failure, no executor match, thrown executor, missing connection, MCP
  error) still call `sendToolResult({ success: false, error })`. The
  server's BLPOP must never hang on a silent client.
- `case 'tool_execute'` uses `void`, not `await`. A long-running tool
  must not block subsequent `stream_chunk` / `tool_end` events on the
  same WebSocket.
- UI loading state is kept separate from `toolCallingStreamIds` (the
  LLM-streaming animation) via a dedicated
  `pendingClientToolExecutions: Record<toolCallId, true>` map, so a
  renderer can show a distinct "running on device" indicator without
  entangling existing selectors.

Client → server signal:

`executeGatewayAgent` now passes `clientRuntime: isDesktop ? 'desktop' : 'web'`
so the server knows this Electron caller can receive `tool_execute`.

Tests: 39 new cases across AgentStreamClient / internal_executeClientTool
/ gatewayEventHandler covering success, error, MCP fallback, pending
state lifecycle, and fire-and-forget semantics. 148 total in affected
suites.

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

* 🐛 fix: pass server operationId to tool_result dispatch (operationId mismatch)

The gateway event handler received `tool_execute` events but the resulting
`internal_executeClientTool` call looked up `gatewayConnections` by the
*local* operation id (e.g. `op_8chrnd`) instead of the *server-side*
operation id (e.g. `op_1776171452938_...`) the WS connection is actually
keyed on. `conn` was therefore always `undefined`, the early-return in
`send(...)` swallowed the response, and the server's BLPOP waiter timed
out after 60 s.

This was reproducible on canary E2E: server logs showed
`dispatching client tool lobe-local-system/readLocalFile` followed by
`client tool ... timed out after 60027ms`, with no outbound `tool_result`
frame ever reaching the Agent Gateway.

Fix: thread a distinct `gatewayOperationId` through
`createGatewayEventHandler` and use it for the `case 'tool_execute'`
dispatch. The existing `operationId` (used for `dispatchContext` →
`internal_dispatchMessage` keying) is untouched. Both `executeGatewayAgent`
and `reconnectToGatewayOperation` now pass the server id explicitly; when
a caller omits it, it falls back to the local `operationId` for backwards
compatibility.

Verified live on canary: WS now shows
`[in] tool_execute` → `[out] tool_result success=true content=...` and
the agent returns the real local-file contents.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 21:30:13 +08:00
LiJian 116495bd1e 🐛 fix: slove the execAgents tools exec types not correct (#13807)
* fix: slove the execAgents tools exec types not correct

* fix: should inject source:discovery when tools type is lost

* fix: delete the source inject test
2026-04-14 17:51:08 +08:00
LiJian 922f7ace41 🐛 fix: fixed the when call saveCreds the bad request problem (#13809)
* fix: fixed the when call saveCreds the bad request problem

* fix: add the empty kv checked
2026-04-14 17:51:00 +08:00
YuTengjing b369c53bda 🐛 fix(model-bank): disable GLM-5.1 built-in search in LobeHub (#13806) 2026-04-14 17:05:42 +08:00
René Wang 5ecccf4b9e 📝 docs: add April 13 weekly changelog (#13808) 2026-04-14 17:02:10 +08:00
Rdmclin2 f9fbd45fee feat: discord support slash commands and DM (#13805)
* fix: slack not respond to text commands

* feat: add slack slash commands instructions

* chore: add slack validate in test connections

* chore: update slack docs

* chore: remove text commands for slack
2026-04-14 16:48:16 +08:00
LiJian 0b490a7268 🐛 fix: execAgent should get builtin discoverable tools into manifests (#13804)
* fix: execAgent should get all tools manifests

* fix: should add the tools source into payload source

* fix: add the discoverable tools into tools enginer

* fix: update the test, should include the discoverable tools
2026-04-14 16:07:49 +08:00
Innei a9c5badb80 ♻️ refactor(navigation): stable navigate hook and imperative routing (#13795)
*  fix: implement stable navigation hook and refactor navigation handling

- Introduced `useStableNavigate` hook to provide a stable `navigate` function that can be used across the application.
- Refactored components to utilize the new stable navigation approach, replacing direct access to the navigation function from the global store.
- Updated `NavigatorRegistrar` to sync the `navigate` function into a ref for consistent access.
- Removed deprecated navigation handling from various components and actions, ensuring a cleaner and more maintainable codebase.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: refactor navigation handling to prevent state mutation

- Updated navigation reference handling in the global store to use a dedicated function for creating navigation refs, ensuring that the initial state is not mutated by nested writes.
- Adjusted tests and components to utilize the new navigation ref creation method, enhancing stability and maintainability of navigation logic.

Signed-off-by: Innei <tukon479@gmail.com>

*  test: mock Electron's net.fetch in unit tests

- Added a mock for Electron's net.fetch in the AuthCtr and BackendProxyProtocolManager tests to ensure proper handling of remote server requests.
- This change allows tests to simulate network interactions without relying on the actual fetch implementation, improving test reliability.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-14 13:28:12 +08:00
LiJian cd0f65210c ♻️ refactor: update the codesandbox systemRole(preinstalled_software) (#13799)
refactor: update the codesandbox systemRole(preinstalled_software)
2026-04-14 12:11:44 +08:00
Arvin Xu 24be35fd84 🐛 fix(agent-runtime): resolve S3 image keys when refreshing messages (#13794)
messageModel.query() calls inside RuntimeExecutors were missing a
postProcessUrl callback, so imageList/videoList/fileList entries retained
raw S3 keys (e.g. `files/user_xxx/icon.png`). After the first tool batch,
the refreshed state fed those raw keys straight into the next LLM call,
and providers like Anthropic reject anything that isn't an absolute URL or
data URI ("Invalid image URL"). Wire a lazy FileService-backed
postProcessUrl into all three query sites (topic reference resolution,
compression, and post-batch refresh) so imageLists stay resolved across
multi-step operations.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:29:49 +08:00
Arvin Xu 46adf43453 🐛 fix: dispatch executor=client tools to desktop callers when DEVICE_GATEWAY is configured (#13793)
🐛 fix: dispatch executor=client tools to desktop caller even with DEVICE_GATEWAY configured

Two fixes to make Phase 6.4 (LOBE-7076) actually reach a desktop caller on
canary, where DEVICE_GATEWAY is configured and a separate remote device
may be registered.

### 1. AgentToolsEngine: suppress RemoteDevice for desktop callers

The `lobe-remote-device` tool is meant for the legacy "tunnel commands to
a separately registered desktop" flow. When the caller itself is a
desktop Electron client, that's redundant — and worse, the LLM was
picking `listOnlineDevices` + `activateDevice` *first*, then routing the
subsequent `readLocalFile` to a different registered host (a remote
Linux VM in our E2E trace, returning ENOENT for a path that only exists
on the caller).

Adds `&& !hasClientExecutor` to the RemoteDevice enable rule. Desktop
callers now see only `local-system` in their manifest.

### 2. aiAgent.execAgent: mark executor='client' for desktop callers

The existing gate was `if (!gatewayConfigured) { executorMap[...] = 'client' }`.
On canary, `gatewayConfigured === true` (DEVICE_GATEWAY set), so
`local-system` / stdio MCP stayed server-executed and were dispatched to
the Remote Device proxy instead of back to the caller's Agent Gateway WS.

Extends the gate to:
  `if (clientRuntime === 'desktop' || !gatewayConfigured)`

So a caller that explicitly signals it can receive `tool_execute` bypasses
the DEVICE_GATEWAY heuristic. Legacy behaviour unchanged for web callers
and for callers that don't send `clientRuntime`.

### Tests

- AgentToolsEngine: +1 case verifying RemoteDevice is suppressed when
  `clientRuntime === 'desktop'` even with `gatewayConfigured: true`
- execAgent.deviceToolPipeline: +3 cases
  - local-system gets executor='client' for desktop + DEVICE_GATEWAY
  - stdio MCP gets executor='client' for desktop + DEVICE_GATEWAY
  - web caller preserves legacy routing (executor unset)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:22:18 +08:00
Arvin Xu f0a811ef83 🐛 fix: enable executor=client tools for desktop Electron callers (#13790)
*  feat: enable executor=client tools for desktop Electron callers

Adds a `clientRuntime` signal to execAgent so the server knows the caller
itself can execute `executor: 'client'` tools (local-system, stdio MCP) over
its Agent Gateway WebSocket. This is the missing server piece for Phase 6.4
(LOBE-7076): previously `local-system` only entered the manifest when a
*separately registered* remote device was online & auto-activated, so a
desktop Electron caller sitting on the other end of the Gateway WS could
never actually be dispatched to via `tool_execute`.

The new signal is orthogonal to the legacy device-proxy `deviceContext` —
it describes the caller itself, not a third-party device. The enable rule
for LocalSystemManifest simply gets one extra OR branch:

  local && gatewayConfigured && (hasClientExecutor || legacy-device-online-activated)

`toolExecutorMap[LocalSystemManifest.identifier] = 'client'` (LOBE-7067)
then kicks in as soon as the manifest entry is present, so
`RuntimeExecutors.call_tool` (LOBE-7068) will push `tool_execute` over the
Agent Gateway WS to this caller.

Plumbing:
- packages/types: `ExecAgentParams.clientRuntime?: 'desktop' | 'web'`
- lambda router: accepts + forwards `clientRuntime`
- aiAgent service: forwards to `createServerAgentToolsEngine`
- AgentToolsEngine: +1 field, +1 OR branch in LocalSystem enable rule.
  Zero changes to `runtimeMode` / `platform` / `RemoteDeviceManifest` /
  `deviceContext` semantics.

Tests: 3 new cases in AgentToolsEngine covering desktop / web / gateway-off
branches; 3 new cases in execAgent.deviceToolPipeline verifying the
`clientRuntime` param is forwarded verbatim.

Follow-up (separate PR): frontend receives `tool_execute`, runs the tool
via Electron IPC, and sends `tool_result` back over the same WS.

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

* ♻️ refactor: untangle runtime / platform / device-proxy flags in AgentToolsEngine

Renames and separates two orthogonal concerns that used to share the
misleading `isDesktopClient` name:

- `hasClientExecutor` — caller itself can receive `tool_execute` over
  the Agent Gateway WS (Phase 6.4). Property of the caller.
- `hasDeviceProxy` — server has a device-proxy configured that tunnels
  to a separately registered device (legacy Remote Device). Property of
  the server.

`platform` is now derived from the caller (`clientRuntime`) first,
falling back to the device-proxy signal for backwards compat — it was
previously derived purely from the server's proxy config, which
conflated "server can reach a desktop" with "caller is a desktop".

LocalSystem enable rule restructured to read in natural order:
  runtimeMode === 'local'         // user opted in
  && hasDeviceProxy               // server has a Gateway path
  && (hasClientExecutor || ...)   // an execution target exists

Behavior is identical to the previous commit; this is a pure rename /
regrouping refactor. 38 existing tests still pass without changes.

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

* 🐛 fix: decouple hasClientExecutor from hasDeviceProxy in local-system gate

The previous rule required `hasDeviceProxy` as a shared prerequisite for
BOTH enable paths, which is wrong: `hasDeviceProxy` reflects the legacy
device-proxy (`deviceProxy.isConfigured`), while Phase 6.4's
`tool_execute` rides the Agent Gateway WebSocket that this request is
already on. The two systems are orthogonal — a desktop caller on the
Gateway WS can receive `tool_execute` without any device-proxy being
configured server-side.

Correct enable rule:

  runtimeMode === 'local'
  && (hasClientExecutor                              // Phase 6.4, self
      || (hasDeviceProxy && deviceOnline && autoActivated))  // legacy

Updated the `still requires gateway to be configured` test, which was
asserting the incorrect coupling, to instead verify that agent-level
`runtimeMode.desktop === 'none'` opt-out is respected for desktop
callers.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 01:12:45 +08:00
Arvin Xu 10914ff015 🐛 fix: add image-to-video options to CLI generate video command (#13788)
*  feat: add image-to-video options to CLI generate video command

Why: CLI only supported text-to-video. Backend already accepts imageUrl/endImageUrl
for image-to-video, but the CLI had no way to pass them.

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

* update cli version

* update cli version

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 01:12:10 +08:00
Adam Bellinson b857ae6c57 🐛 fix(desktop): use Electron net.fetch for remote server requests (#13400)
* use Electron's net.fetch() so system trusted certs are honored

* 🐛 fix(tests): mock netFetch in unit tests broken by net.fetch migration

Both LocalFileCtr and RemoteServerConfigCtr tests were patching
global.fetch / stubGlobal, which no longer intercepts calls now that
the controllers route through Electron's net.fetch via @/utils/net-fetch.
Hoist the fetch mock and point vi.mock('@/utils/net-fetch') at it directly.
2026-04-14 00:45:54 +08:00
Arvin Xu e11c89fc48 🐛 fix(agent-runtime): skip client-executor marking when gateway is configured (#13787)
Tools flagged as `executor: 'client'` are dispatched via `dispatchClientTool`
through the Agent Gateway WS path. In cloud deployments where the gateway is
configured but no desktop device is connected, this path 404s on
`/api/operations/tool-execute` and the tool fails with `dispatch_failed`.

Only mark local-system and stdio MCP plugins as `'client'` when the gateway
is NOT configured (standalone Electron). When deviceContext is available,
tool routing goes through the RemoteDevice proxy instead.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:25:19 +08:00
LiJian b9a136f9f1 🐛 fix: slove the execAgent not have lobehub skills & builtin tools (#13781)
fix: slove the execAgent not have lobehub skills & builtin tools
2026-04-13 18:15:54 +08:00
Rdmclin2 809e1e0716 🐛 fix: message gateway ensure running (#13780)
fix: message gateway ensure running
2026-04-13 17:43:18 +08:00
Octopus 7953cf5b5a fix(desktop): use low urgency for Linux notifications to prevent GNOME Shell freeze (#13767)
🐛 fix(desktop): use low urgency for Linux notifications to prevent GNOME Shell freeze

On Linux/GNOME Shell, desktop notifications with urgency 'normal' appear
as banner pop-ups. Clicking the dismiss (X) button on these banners can
cause the system to freeze for 30-45 seconds due to heavy gnome-shell
CPU and memory usage.

Setting urgency to 'low' on Linux routes notifications to the message
tray instead of displaying them as banners, which avoids the problematic
X button interaction. The urgency option is ignored on macOS and Windows.

Fixes #13538

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-13 16:19:44 +08:00
LobeHub Bot 012214205e 🌐 chore: translate non-English comments to English in database-tests (#13771)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:14:09 +08:00
Arvin Xu f0f2feb015 🔨 chore(task): add participants to task.list response (#13778)
*  feat(task): add participants array to task.list response

Return a participants array per task (id / type / avatar / name) so
clients can show avatar groups on task cards. For now participants
only contains the assignee agent; future iterations can aggregate
comment authors and topic executors.

Also extract TaskItem into @lobechat/types as an explicit type
definition so it no longer relies on drizzle schema inference.

* ♻️ refactor(task): extract NewTask to @lobechat/types

Remove the drizzle $inferInsert NewTask from schemas and define it
explicitly in @lobechat/types alongside TaskItem.

*  test(task): cover participants in task.list response
2026-04-13 16:09:53 +08:00
Innei f439fb913a 🐛 fix(editor): bump @lobehub/editor to 4.8.1 (#13756)
🐛 fix: bump @lobehub/editor to 4.8.1
2026-04-13 14:17:39 +08:00
Neko 6966d366d1 🐛 fix(userMemories): should trim way too long bm25 (#13744) 2026-04-13 13:45:37 +08:00
LiJian f89adb36b3 🐛 fix: slove the agent details pages not get the agent config always lo… (#13772)
fix: slove the agent details pages not get the agent config always loading problem
2026-04-13 12:46:10 +08:00
Arvin Xu 3c2fc7f368 🔨 chore(agent-runtime): dispatch client-executor tools via Agent Gateway WS (#13769)
 feat(agent-runtime): dispatch client-executor tools via Agent Gateway WS

Wire the block-await dispatch path for tools marked as `executor: 'client'`:

- `aiAgent/index.ts` (6.3a) — derive `toolExecutorMap` from manifests:
  * `local-system` builtin → `'client'` (requires Electron IPC)
  * MCP plugins with `customParams.mcp.type === 'stdio'` → `'client'`
    (subprocess runs on the user's machine)
  Purely manifest-driven; no new context / capability fields needed.

- `dispatchClientTool` (6.3b) — helper that:
  * Pushes a `tool_execute` event via `streamManager.sendToolExecute`
  * Block-awaits on Redis BLPOP via `ToolResultWaiter`
  * Returns a `ToolExecutionResultResponse`-shaped object (drop-in with
    the existing server path)
  * Never throws — timeouts / gateway errors / missing infra all
    produce a failed-but-structured result so the agent loop continues

- `RuntimeExecutors.call_tool` / `call_tools_batch` — route to
  `dispatchClientTool` when `payload.executor === 'client'` AND the
  stream manager exposes `sendToolExecute`. Otherwise fall through to
  the existing server path unchanged. Response API (`source: 'client'`)
  interrupt branch is untouched.

Capped at 270s per tool to match Vercel's streaming function window;
longer tools will be handled by the resumable path in Phase 6.3c.

Covered by:
- 5 unit tests on `dispatchClientTool` (gateway missing, redis missing,
  happy path, timeout, dispatch error)
- 286 existing tests still pass in adjacent suites

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:25:43 +08:00
LobeHub Bot a72ae190a3 🌐 chore: translate non-English comments to English in integration-test-utilities (#13749)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:01:34 +08:00
Arvin Xu 4feafb3fcb ♻️ refactor: migrate memory-user-memory workflows to Hono (#13768)
Replace 6 per-path Next.js `route.ts` handlers (using `@upstash/workflow/nextjs` serve) with a single Hono app mounted at `[[...route]]`. Workflow logic moves to `src/server/workflows-hono/memory-user-memory/`; all public URLs remain unchanged so existing `MemoryExtractionWorkflowService.triggerXxx` callers need no update.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:50:10 +08:00
Arvin Xu eff527de65 🔨 chore(agent-runtime): add ToolResultWaiter for BLPOP-based tool result await (#13763)
*  feat(agent-runtime): add ToolResultWaiter for Redis BLPOP-based tool result await

Introduce ToolResultWaiter — a Promise-based wrapper around Redis BLPOP
that server-side agent loops will use to block-await client-side tool
execution results delivered via the callback API (LPUSH on another
connection).

Design highlights:
- Takes two ioredis clients: a dedicated blocking connection for BLPOP
  (must not be shared with business traffic) and a normal producing
  connection for side effects (cancel sentinel).
- `waitForResult(id, timeoutMs)` returns the parsed payload or null on
  timeout / cancel, never throws for timeout (caller decides fallback).
- `waitForResults(ids[], timeoutMs)` fans out via Promise.all, aligning
  results with input order.
- `cancel(id)` LPUSHes a poison-pill sentinel to wake a pending waiter,
  used when the agent loop is terminated mid-tool.

Covered by unit tests (6 cases: push-before / push-after / timeout /
batch / cancel / malformed payload).

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

* 🐛 fix(agent-runtime): use multi-key BLPOP in waitForResults to avoid N×timeout latency

Promise.all-ing waitForResult over a shared blocking Redis connection
actually serializes: BLPOP holds the socket, so calls run back-to-back
rather than concurrently. A batch of N where some results never arrive
would take up to N × timeoutMs to resolve, stalling tool-call loops
and delaying cancellation.

Rewrite waitForResults to use Redis's multi-key BLPOP in a loop with a
shared deadline: each iteration blocks on all remaining keys with the
remaining budget, wakes when any one arrives, drops that key, and
re-enters with the rest. Total latency is bounded by one timeoutMs
regardless of N. Single-key waitForResult now delegates to this path.

Covered by a new regression test asserting that an N=3 batch of
never-arriving keys completes in ~1 timeout window, not N×.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:47:49 +08:00
Arvin Xu c60563fffc 🔨 chore(api): add POST /api/agent/tool-result callback endpoint (#13764)
 feat(api): add POST /api/agent/tool-result callback endpoint

Agent Gateway forwards client tool execution results to this endpoint;
the handler LPUSHes into a per-toolCallId Redis list with a 120s TTL so
the server-side agent loop's BLPOP can wake and continue.

- Auth via AGENT_GATEWAY_SERVICE_TOKEN bearer header
- Zod-validated body: { toolCallId, content, success, error? }
- Key: tool_result:{toolCallId}
- Idempotency not required; duplicates sit under TTL until expired

No runtime caller yet — wiring lands with the BLPOP waiter in LOBE-7068.

Covered by unit tests (6 cases: missing/wrong token, missing token env,
invalid body, Redis unavailable, happy path, Redis write error).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:35:03 +08:00
Arvin Xu b36c5a2f1b 🔨 chore: add GatewayStreamNotifier.sendToolExecute (#13765)
 feat(agent-runtime): add GatewayStreamNotifier.sendToolExecute

Expose a request-response-style push for tool_execute on top of the
existing Gateway HTTP pipe. Callers use this to delegate tool execution
to the client; failures surface back to the caller so the agent loop
can decide whether to fall back to the interrupt-resume path.

- `IStreamEventManager.sendToolExecute?` — optional interface method,
  only the Gateway-backed notifier implements it (InMemory/Redis-only
  managers intentionally leave it undefined)
- `GatewayStreamNotifier.sendToolExecute(operationId, ToolExecuteData)`
  POSTs to Gateway `/api/operations/tool-execute`
- New private `httpPostAwait` helper preserves the 5s timeout but,
  unlike the fire-and-forget `httpPost`, rejects on non-ok / network
  failure so callers can react

No runtime caller yet; the dispatch branch lands with LOBE-7068.

Covered by unit tests (3 new cases: happy path payload, non-ok
response, network error).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:29:43 +08:00
Arvin Xu 12bbc56db3 🔨 chore: add tool_execute / tool_result protocol types (#13762)
*  feat(agent-stream): add tool_execute / tool_result protocol types

Introduce the type-level scaffold for the Gateway-mediated client tool
execution flow:

- `tool_execute` server→client event with `ToolExecuteData` payload
  (toolCallId, identifier, apiName, arguments, executionTimeoutMs)
- `tool_result` client→server message with success/error and content,
  added to the `ClientMessage` union

No runtime wiring yet; this PR is pure type scaffolding so subsequent
server (Redis BLPOP waiter, Gateway notifier, RuntimeExecutors branch)
and client (gateway handler) work can land independently.

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

* Update types.ts

* 💄 style(agent-stream): reorder ToolResultMessage fields for perfectionist

Move `error?` before `state?` to satisfy `perfectionist/sort-interfaces`
after the `state?: any` field was added to align with ChatToolResult.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:09:53 +08:00
Rdmclin2 73be58ba12 feat: support message gateway (#13761)
* feat: support message gateway

* feat: sync message gateway connections

* chore: add cloudflare http v2

* fix: typing interval

* feat: add connnectionMode to gateway

* chore: add applicationId when connect

* fix: judge typing supoort by  triggerTyping implementation

* fix: skip message gateway & start connnections

* fix: qq platform hint

* chore: skip webhook mode in gateway connection

* fix: test case

* fix:  message gateway check

* chore: add failaure case post

* fix: test case

* feat: add GatewayManager for webhook-mode platforms
2026-04-13 01:27:54 +08:00
Arvin Xu 3ad124ac4f 🔨 chore: support multimodal input for server-side agent execution (#13759)
*  feat(agent): support multimodal input for server-side agent execution

Wires already-uploaded file IDs through the Gateway-mode execAgent path so
SPA-attached images / documents / videos reach the LLM when the agent runs
server-side. Resolves attachments via FileModel.findByIds, classifies by
MIME, parses documents idempotently, and persists the messages_files link
for history replay.

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

* 🐛 fix(agent): dedupe repeated fileIds before writing messages_files

messages_files has a composite PK on (file_id, message_id); a fileIds array
containing the same id twice would fail the insert and abort execAgent. Dedupe
the input while preserving caller-provided order so rendering stays stable.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:52:03 +08:00
Arvin Xu e569c8dee0 ♻️ refactor: introduce ToolExecutor field orthogonal to ToolSource (#13760)
Add ToolExecutor ('client' | 'server') as a new orthogonal dimension
alongside ToolSource to describe where a tool invocation is dispatched.
Thread executorMap through OperationToolSet / ResolvedToolSet / AgentState
and attach executor to the ChatToolPayload emitted in onToolsCalling.

Defaults remain empty (all server-side), so behavior is unchanged. This
is pure scaffolding to unblock subsequent work on client-side dispatch.

Also remove the unused 'plugin' value from ToolSource (no downstream
consumers branched on it; installed plugins now labeled 'mcp').

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:28:30 +08:00
YuTengjing 406cb5554b feat: add GLM-5.1 model support for Zhipu provider (#13757) 2026-04-12 22:14:52 +08:00
Arvin Xu 0486be4773 🐛 fix: guard non-string content in context-engine to prevent e.trim errors (#13753)
🐛 fix: guard non-string content in context-engine to prevent `e.trim is not a function`

Two unguarded `.trim()` / string-concatenation paths in the context-engine
could throw or produce garbage text when a message's `content` is not a
plain string (multimodal parts array, null tool turns). Both are reached
in normal chat and trigger `e.trim is not a function` in production.

- `resolveTopicReferences`: filter out non-string content in the fallback
  `lookupMessages` path before calling `.trim()`. Without this guard, the
  outer try/catch swallows the TypeError and drops the whole fallback.
- `MessageContent` processor: normalize `message.content` (string or
  parts array) before concatenating file context, instead of relying on
  implicit `toString()` coercion which emitted `[object Object]` into
  the LLM prompt.

Adds regression tests for both paths.
2026-04-12 19:27:52 +08:00
Innei f2ee67c3c5 🐛 fix(inbox): restore inbox avatar fallback after deletion (#13752) 2026-04-12 17:40:40 +08:00
Rdmclin2 16ed80701c 🐛 fix: revert anthropic base64 image (#13751)
chore: revert anthropic base64 image
2026-04-12 16:06:19 +08:00
Octopus 37bf1bd191 fix(local-system): restore loc param when calling readLocalFile IPC (#13748)
🐛 fix(local-system): restore loc param when calling readLocalFile IPC

The `denormalizeParams` method in `LocalSystemExecutionRuntime` was
missing a case for `readLocalFile`. It fell through to `default`, which
passed `{startLine, endLine, path}` as-is to the IPC layer. However,
the IPC handler (`LocalFileCtr.readFile`) expects `LocalReadFileParams`
with `loc?: [number, number]`, not `startLine`/`endLine`. As a result,
`loc` was always `undefined` on the IPC side, causing `readLocalFile`
to default to `[0, 200]` and always return content from line 0.

Fix: add an explicit `readLocalFile` case that reconstructs the `loc`
tuple from `startLine` and `endLine` before forwarding to the IPC layer.

Fixes #13735

Co-authored-by: octo-patch <octo-patch@github.com>
2026-04-12 14:34:42 +08:00
Neko e0f97c4920 🐛 fix(userMemories): missing cancel webhook api for cascading cancellation (#13742) 2026-04-12 04:35:17 +08:00
Arvin Xu 93698f76f8 🔨 chore: update cli version (#13741)
update cli
2026-04-12 02:20:08 +08:00
Arvin Xu 2c79b5ab78 🐛 fix: refine ProviderBizError classification for insufficient balance and quota limit (#13740)
* 🐛 fix: refine ProviderBizError classification for insufficient balance and quota limit errors

Extract inline "Insufficient Balance" check into a dedicated `isInsufficientQuotaError` utility with case-insensitive matching and broader patterns. Add "too many tokens" pattern to `isQuotaLimitError` for Moonshot rate-limit messages.

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

* update

* 🐛 fix: remove "account has been deactivated" from InsufficientQuota patterns

Account deactivation can be triggered by policy, security, or account review — not just billing. Classifying it as InsufficientQuota misleads users into topping up balance when the fix is usually permission or support escalation.

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

*  feat: add AccountDeactivated error type for deactivated/suspended accounts

Separate account deactivation from InsufficientQuota so users get actionable guidance (contact support) instead of misleading billing advice.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 02:14:30 +08:00
Rylan Cai 5613935b73 🐛 fix: fix cli message/topic list page indexing (#13731)
* 🐛 fix cli message/topic list page indexing

* ♻️ inline page parsing in message command
2026-04-12 00:46:31 +08:00
Arvin Xu fb7f0c3e92 🐛 fix: preserve error message in ChatCompletionErrorPayload (#13736)
* 🐛 fix: preserve error message in ChatCompletionErrorPayload for ProviderBizError

Add `message` field to `ChatCompletionErrorPayload` and extract SDK error messages in `handleOpenAIError` and `handleAnthropicError`, so downstream consumers (agent tracing, error state) receive human-readable error details instead of generic "ProviderBizError".

Closes LOBE-7019

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

* 🐛 fix: guard nullish error in handleAnthropicError

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:42:03 +08:00
Rdmclin2 08769e5bf1 🐛 fix: layout recent locale and support dismiss banner (#13739)
* fix: CN locale for rencents

* fix: community profile setup modal

* feat: support skill banner dismiss
2026-04-11 23:27:21 +08:00
Arvin Xu 732a3ae54a ♻️ refactor: clean up unused sessionStore selectors and slices (#13738)
Remove dead code from the legacy sessionStore:
- Delete `recent` slice (migrated to homeStore)
- Delete `homeInput` slice (migrated to homeStore)
- Remove unused selectors: currentSessionSafe, hasCustomAgents,
  defaultSessionsCount, defaultSessionsLimited, getSessionMetaById,
  currentGroupMeta, getDescription
- Update store type, initialState, and tests accordingly

Closes LOBE-7018

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:37:22 +08:00
Arvin Xu a8fee05c3e 🔨 chore: resolve author info for task activity list (#13732)
*  feat: resolve author info (avatar + name) for task activity list

Add `author` field to `TaskDetailActivity` with `{id, type, name, avatar}`.
Backend resolves agent/user info via batch queries in `getTaskDetail`:
- Topics: author is the task's assignee agent
- Briefs: author is the brief's agentId
- Comments: author is authorAgentId or authorUserId

Fixes LOBE-7013

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

* ♻️ refactor: move author resolution queries to model layer

Replace direct db.select() calls in TaskService with:
- AgentModel.getAgentAvatarsByIds() for agent info
- UserModel.findByIds() for user info

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:43:00 +08:00
Arvin Xu c255bfe97d 💄 style: show loading state for assistant message during optimistic update (#13733)
🐛 fix: show loading state for assistant message during sendMessage phase

During optimistic update, the assistant message content is "..." but the
loading indicator was not shown because isGenerating only checks
AI_RUNTIME_OPERATION_TYPES (execAgentRuntime), not sendMessage. Include
isCreating state so the loading dots appear immediately when message is sent.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:37:16 +08:00
Arvin Xu f7f2e063d1 💄 style: add delete action to agent profile dropdown menu (#13734)
*  feat: add delete action to agent profile dropdown menu

Add a "Delete" option to the three-dot menu in Agent Profile header,
with confirmation modal. Uses existing `removeAgent` from homeStore.

Fixes LOBE-6582

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

* 🐛 fix: navigate to home after deleting agent from profile

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:31:55 +08:00
Sun13138 39bca4bc1a 🐛 fix(gemini): align thinkingLevel config resolution across the stack (#13457)
* 🐛 fix(gemini): omit empty thinkingConfig and align thinkingLevel keys

- Google runtime: omit empty thinkingConfig to avoid sending thinkingConfig: {} upstream.\n- UI: ThinkingLevel2/3/4/5 sliders read/write only their own config key.\n- Resolver: map model extend params thinkingLevel* to matching chatConfig key (no fallback/priority logic).\n- Tests: add regression coverage for empty thinkingConfig omission.

* 🐛 fix(gemini): restore default thinking levels by model param

* 🐛 fix(gemini): prefer configured thinkingLevel params
2026-04-11 19:10:26 +08:00
Arvin Xu 9b765eb360 update og 2026-04-11 18:43:28 +08:00
Arvin Xu f68c45cab6 Merge remote-tracking branch 'origin/canary' into fix/task-topic-status-cascade 2026-04-11 18:42:57 +08:00
LobeHub Bot 44c569c5db 🌐 chore: translate non-English comments to English in chat store (#13728)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 18:37:00 +08:00
Arvin Xu 390d82d730 🐛 fix: complete operation and show error on gateway error event (#13714)
* 🐛 fix: complete operation and show error on gateway error event

- Error event handler writes inline error immediately via
  internal_dispatchMessage, then fetches from DB for richer detail.
  This ensures the UI always shows an error even when the server
  hasn't persisted the error into the message table.
- disconnected listener only fires onSessionComplete after a terminal
  agent event (agent_runtime_end / error), not on auth failures or
  explicit disconnect calls.
- Track terminal events via agent_event listener with dedup guard to
  prevent double-firing onSessionComplete.

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

* 🐛 fix: persist error into assistant message on agent runtime failure

When an agent runtime step fails, the error was written to error_logs
and Redis state but not to the assistant message in the DB. This caused
the frontend to show an empty message after fetchAndReplaceMessages,
since the message had no error field set.

Now dispatchCompletionHooks writes the error to the assistant message
via messageModel.update when reason is 'error', matching the pattern
used by updateAbortedAssistantMessage.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:22:18 +08:00
Arvin Xu 2b44cdd298 🐛 fix: add null guard for topicId to fix type error
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:05:41 +08:00
Arvin Xu 345f144b1e 🐛 fix: use conditional cancel and fail-fast on interrupt errors
- Add `cancelIfRunning` to TaskTopicModel: atomically cancel only if topic
  is still running, preventing overwrite of concurrent completed/timeout transitions
- Skip topic cancellation when `interruptTask` fails, keeping DB state
  consistent with the still-running remote operation
- Add test for interrupt failure scenario

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 17:52:29 +08:00
YuTengjing f780f43863 🔨 chore: fix skill frontmatter key to use kebab-case (#13730) 2026-04-11 15:53:02 +08:00
Rdmclin2 ac1abbaf8b 🐛 fix: bot error lobe 6925 (#13724)
* chore: remove unused variables

* fix: add  catch error

* chore: use url for anthropic image

* feat: add bot  process warnings to context

* feat: add thread context

* fix: rename thread name when already has one

* chore: update test cases

* fix: warning sanitize

* fix: threadName safe review
2026-04-11 02:11:33 +08:00
Innei b5f98bd745 feat(chat-input): improve mention menu skill and tool icons (#13722)
*  feat(chat-input): improve mention menu skill and tool icons

- Add MentionItemIcon with SkillAvatar, McpIcon, and Avatar fallbacks
- Strip placeholder avatars ending with _AVATAR
- Tweak mention item icon frame (overflow, border-radius)

Made-with: Cursor

* 💄 fix(chat-input): use theme-aware mention skill fallback
2026-04-11 01:35:02 +08:00
Innei 48d0a759a8 🐛 fix(chat): refine workflow collapse headline (#13717)
* 🐛 fix(chat): refine workflow collapse headline

* 🐛 fix(chat): use state machine for workflow headline

* 🐛 fix(chat): backtrack workflow headline state

* ♻️ refactor(chat): simplify workflow headline selector

* 💄 style(chat): use lucide workflow collapse arrow

* ♻️ refactor(chat): use accordion indicator layout

* Move workflow duration text beside the title

* Localize workflow tool display labels

* Update Page workflow localization labels

* fix: sort imports in toolDisplayNames.test.ts
2026-04-11 00:49:25 +08:00
Rdmclin2 5d135b3ae1 🔨 fix: layout sidebar en More (#13723)
* fix: layout sidebar en More

* chore: update i18n files
2026-04-11 00:46:53 +08:00
Tsuki 17b3acead6 feat(subscription): add cross-platform subscription support for mobile IAP (#13413)
feat(subscription): add cross-platform subscription i18n and mobile subscription router

- Add crossPlatform.title/desc/manageOnMobile translations for 18 languages
- Register mobileSubscriptionRouter in mobile tRPC router
- Add mobileSubscription business router placeholder

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:27:51 +08:00
Rdmclin2 2c397390b4 feat: layout sidebar impl (#13719)
* ♻️ Restructure sidebar layout: extract Lobe AI entry, move New Agent button

- Extract Lobe AI (InboxItem) from agent list to standalone top entry in sidebar body
- Move "New Agent" button from header to below Lobe AI entry
- Add "Create" to bottom menu items alongside Community and Resources
- Filter hidden items in BottomMenu component

Fixes LOBE-6938

https://claude.ai/code/session_01RtfXck3GUngoLAgP2yHArz

*  Add unified Recents section to home page

- New TRPC router `recent.getAll` aggregating topics, documents, files, and tasks
- New client service and SWR-based store integration for recents data
- Unified Recents component on home page with type-based icons
- Items sorted by updatedAt, limited to 10, mixed across all types

Fixes LOBE-6938

https://claude.ai/code/session_01RtfXck3GUngoLAgP2yHArz

*  Prefetch agent config on hover for faster page loads

- Add usePrefetchAgent hook using SWR mutate to warm cache
- Trigger prefetch on mouseEnter for sidebar agent items
- Reduces or eliminates loading screen when navigating to agent pages

Fixes LOBE-6938

https://claude.ai/code/session_01RtfXck3GUngoLAgP2yHArz

*  Redesign agent homepage with info, recent topics, and tasks

- New AgentHome feature replacing the old AgentWelcome component
- Agent info section: avatar, name, description, opening questions
- Recent Topics: horizontal scrollable cards for agent-specific topics
- Tasks section: list with status labels for agent-assigned tasks
- Preserve ToolAuthAlert for tool authorization flows

Fixes LOBE-6938

https://claude.ai/code/session_01RtfXck3GUngoLAgP2yHArz

* fix: common misstakes in layout

* chore: add fetch Recents cache

* chore: add back createagents

* chore: add back lobe ai

* feat: add display count

* feat: add create agent button

* feat: add sidebar section order

* chore: move divider

*  feat: show current page size in display items submenu

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

*  feat: add sidebar display management with customize sidebar modal

- Add "Hide section" and "Customize sidebar" to Recents/Agents dropdown menus
- Create CustomizeSidebarModal with eye toggle for section visibility
- BottomMenu (Community/Resources) also manageable via modal
- Show customize sidebar button in footer when all sections hidden
- Add hiddenSidebarSections to store with localStorage persistence
- Rename "Display Items" to "Show" in dropdown menus
- Add 12px margin between accordion sections and bottom menu
- Add i18n keys for en-US and zh-CN

Fixes LOBE-6938

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

* 💄 style: use SlidersHorizontal icon for customize sidebar

Replace Settings2/PanelLeft icon with SlidersHorizontal to avoid
confusion with the settings gear icon.

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

* 💄 style: refine sidebar customization UX

- Move Settings entry from Footer to BottomMenu alongside Community/Resources
- Add Settings to Customize sidebar modal with eye toggle
- Allow hiding all sections (remove disabled constraint)
- Move Customize sidebar button next to help button in Footer
- Merge Agent dropdown: group Create items with Category items
- Use SlidersHorizontal icon for Customize sidebar

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

*  feat: add recents item actions and "more" drawer

- Add inline rename (same as Agent Topic) and delete to Recents items
- Topic/document/file support rename + delete, task supports delete only
- Add "more" button when items exceed pageSize, opens AllRecentsDrawer
- AllRecentsDrawer shows all cached recents from store (up to 50)
- Fetch max(pageSize, 50) items to support drawer without extra request

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

*  feat: add create agent/group modal with ChatInput and examples

- Add CreateAgentModal using base-ui Modal with ChatInputProvider
- Show suggestion examples (agent/group mode) in 2-column grid
- Submit triggers sendAsAgent/sendAsGroup to auto-generate via Agent Builder
- "Create Blank" button for skipping the prompt
- Integrate modal into AgentModalProvider for shared state across sidebar
- Wire up AddButton, NewAgentButton, and dropdown menus to open modal

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

* feat: optimitic update rename

* chore: prefetch agent detail

* feat: add recent topic meta data

* feat: add recents search

*  perf: optimize recents API with single UNION query and prefetch

- Replace 3 separate DB queries with single UNION ALL query (RecentModel)
- Add optimistic updates for rename and delete actions
- Add hover prefetch for resources (usePrefetchResource)
- Add hover prefetch for agent config on topic/task items
- Change default pageSize to 5 for both Agents and Recents
- Unify delete confirmation messages per item type

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

* chore: adjust settings page

* chore: optimize side bar

* feat: recents support right click

* chore: add pin icon to Agents

* chore: add custom side bar modal

* chore: reserve rencent drawer status

* feat: add prefetch route

* feat: add LobeAI prefetch

* fix: document and task rename and delete operation lost

* fix: group route id

* fix: lint error

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 00:13:19 +08:00
Innei cd49e98936 chore: bump lucide-react to v1.8.0 (#13703)
* chore: bump lucide-react from ^0.577.0 to ^1.8.0

 Breaking change: Github icon was removed from lucide-react v1.x (brand icons removed).
 Replaced with Github from @lobehub/icons in 5 affected files.

* fix: use GithubIcon from @lobehub/ui/icons instead of @lobehub/icons
2026-04-10 20:17:23 +08:00
Arvin Xu 7894a0a28e 🐛 fix: cascade cancel running topics when task status transitions out of running
When a task's status changes from `running` to another state (backlog/paused/completed/canceled),
automatically cancel all associated running topics and interrupt their operations.
This prevents 409 CONFLICT errors when users try to re-run a task after manually changing its status.

Fixes LOBE-6719

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:37:03 +08:00
Tsuki 9088a074e2 🚀 release: 20260410 (#13716)
## 📦 Weekly Release 20260410

This release includes **67 commits**. Key user-facing updates below.

### New Features and Enhancements

- Introduced **Prompt Rewrite & Translate** feature for assisted input
editing.
- Added **Skill Panel** with dedicated skills tab in the skill store and
fixed skill icon rendering.
- Introduced `lh notify` CLI command for external agent callbacks.
- Added `migrate openclaw` CLI command.
- Added **GraphAgent** and `agentFactory` for graph-driven agent
execution (experimental).
- New topic auto-creation every 4 hours for long-running sessions.

### Models and Provider Expansion

- Added a new provider: **StreamLake (快手万擎)**.
- Added **GLM-5.1** model support with Kimi CodingPlan fixes.
- Added **Seedance 2.0** & **Seedance 2.0 Fast** video generation models
(pricing adjusted with 20% service fee).
- Expanded AIGC parameter support for image and video generation.
- Improved model type normalization for better provider compatibility.
- Multi-media and multiple connection mode support for ComfyUI
integration.

### Desktop Improvements

- **Embedded CLI** in the desktop app with PATH installation support.
- Added Electron version display in system tools settings.
- Fixed RuntimeConfig instant-apply working directory with recent list.
- Fixed desktop locale restore — now uses stored URL parameter instead
of system locale.
- Improved remote re-auth for batched tRPC and clean OIDC on gateway
disconnect.

### Stability, Security, and UX Fixes

- **Security**: prevented path traversal in
`TempFileManager.writeTempFile`; patched IDOR in
`addFilesToKnowledgeBase`; upgraded `better-auth` with hardened
`humanIntervention` requirement in builtin-tool-activator.
- **Context engine**: added `typeof` guard before `.trim()` calls to
prevent runtime crashes.
- **Agent runtime**: preserved reasoning state across OpenAI providers;
fixed service error serialization producing `[object Object]`; surfaced
error `reasonDetail` in `agent_runtime_end` events.
- **Knowledge Base**: cleaned up vector storage when deleting knowledge
bases.
- **Templates**: allow templates to specify `policyLoad` so default docs
are fully injected.
- **Skills**: inject current agents information when `lobehub_skill` is
activated; filter current agent out of available agents list; fix
`agents_documents` overriding `systemRole`.
- **Google Tools**: use `parametersJsonSchema` for Google tool schemas.
- **Web Crawler**: prevent happy-dom CSS parsing crash in
`htmlToMarkdown`.
- **Mobile/UI**: fixed video page icon collision, missing locale keys,
model query param; hidden LocalFile actions on topic share page; allow
manual close of hidden builtin tools.
- **Auth**: `ENABLE_MOCK_DEV_USER` now supported in `checkAuth` and
openapi auth middleware.
- **Sandbox**: stopped using `sanitizeHTMLContent` to block scripts &
sandbox styles.

### Refactors

- Library/resource tree store for hierarchy and move sync.
- Removed legacy `messageLoadingIds` from chat store.
- Removed promptfoo configs and dependencies.
- `OnboardingContextInjector` wired into context engine.

### Credits

Huge thanks to these contributors (alphabetical):

@arvinxx @canisminor1990 @cy948 @hardy-one @hezhijie0327 @Innei
@MarcellGu @ONLY-yours @rdmclin2 @rivertwilight @sxjeru @tjx666
2026-04-10 17:48:33 +08:00
Arvin Xu b95720d210 🐛 fix: add typeof guard before .trim() calls in context engine (#13715)
Add `typeof !== 'string'` checks before `.trim()` calls in BaseSystemRoleProvider,
SystemRoleInjector, and BaseProcessor to prevent TypeError when a non-string truthy
value (e.g. object, array, number) is passed at runtime.
2026-04-10 14:21:18 +08:00
Marcell Gu 560ec57f75 🐛 fix: changed builtin-tool-activator's humanIntervention to require & upgraded better auth (#13682)
* fix(builtin-tool-activator): add humanIntervention required field to activateTools manifest

- Add humanIntervention: "required" to the activateTools API manifest
- Update better-auth dependency from 1.4.6 to 1.4.9 (GHSA-xg6x-h9c9-2m83, 分数: 7.4)

* Downgrade better-auth version to 1.4.6

Thanks for your correction.
2026-04-10 14:20:51 +08:00
Arvin Xu dbca232e35 feat: support regenerateUserMessage in gateway mode (#13711)
*  feat: add gateway mode branch to regenerateUserMessage

When gateway mode is enabled, regenerateUserMessage now calls
executeGatewayAgent with parentMessageId instead of running
internal_execAgentRuntime locally. The server handles branching
and agent execution.

Fixes LOBE-6934

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

* 🐛 fix: switch branch before gateway regeneration and keep operation open

- Move switchMessageBranch before the gateway/client branch so
  activeBranchIndex is advanced and the UI shows the new response
  immediately (fixes regression from client path)
- Add onComplete callback to executeGatewayAgent so callers can
  run cleanup when the gateway session finishes
- Keep regenerate operation running until onComplete fires,
  preventing duplicate concurrent regenerations via isMessageRegenerating

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 13:23:19 +08:00
Zhijie He c879629439 feat: add prompt rewrite & translate feat (#13523) 2026-04-10 12:33:50 +08:00
Zhijie He 1ecf7d2be8 💄 style(image,video): extend more AIGC params support (#13597)
* 🐛 fix(image,video): preserve prompt and image when switching model

*  feat(image): smart imageUrl ↔ imageUrls conversion on model switch

- When switching from multi-image to single-image model: use imageUrls[0] as imageUrl
- When switching from single-image to multi-image model: wrap imageUrl into [imageUrl] as imageUrls
- Preserves prompt and other compatible parameters
- Add test cases for bidirectional conversion

♻️ refactor(image): simplify preserveImageInputParams logic

- Remove intermediate variables for cleaner code readability
- Condense 9 intermediate variables to 3 core ones
- Inline condition checks for simpler if statements
- Improve code clarity without changing functionality

* 🐛 fix(image): preserve imageUrl when target imageUrls default is empty array

* chore: format imageUrl & imageUrls

* feat: support imageUrls for videoGen

fix: fix ci error

fix: fix ci error

fix: fix + button

fix: fix batch images display

fix: fix muti images upload display

fix: fix ci error

style: add Seedance 2.0 support

style: add Seedance 2.0 support

fix: fix veo imageUrls logic

* style: add watermark & prompt_extend & web_search support

style: update minimax & seedream price

style: fix fix ui error

style: update z-image

style: fix video ui

style: fix seedance & seedream params

style: fix seedance & seedream params

style: fix seedance & seedream params

fix ci error

Update createImage.ts

fix ci error

fix ci error

fix ci error

fix ci error

fix ci error

fix ci error

fix: fix optimize_prompt_options

* fix rebase issue

* fix: seedance 2.0 price missing

* fix: apply some suggestions
2026-04-10 11:50:22 +08:00
LobeHub Bot 8b5aaeebdf 🌐 chore: translate non-English comments to English in comfyui (#13712)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:10:11 +08:00
CanisMinor 4787bed380 💄 style: Update agent onboarding style (#13678)
* 💄 style: Update onboarding

* style: update

* 💄 style: Update i18n

* fix: test
2026-04-10 10:44:09 +08:00
Hardy 5f25efd54c feat: add GLM-5.1 model and fix KimiCodingPlan issues (#13700)
* 🐛 fix: fix Kimi K2.5 model icon display by using deploymentName

- Change model id from 'k2p5' to 'kimi-k2.5' to match Moonshot icon keywords
- Add deploymentName 'k2p5' for API calls to use original model name
- Add KimiCodingPlan to providersWithDeploymentName list

This allows the model icon to display correctly while maintaining
backward compatibility with the API using the original 'k2p5' name.

* 🐛 fix: normalize messages for KimiCodingPlan thinking models

Add message normalization for Kimi K2.5 and K2 Thinking models to ensure
every assistant message has a thinking block when thinking is enabled.

This fixes the issue where regenerating with KimiCodingPlan after using
other providers would fail with "thinking is enabled but reasoning_content
is missing" error, because historical messages from other providers don't
have reasoning fields.

The normalization adds a placeholder thinking block when:
1. Thinking is enabled for Kimi K2.5/K2 Thinking models
2. Assistant message doesn't have reasoning content

*  feat(siliconcloud): add GLM-5.1 model support

Add GLM-5.1 (Pro) model configuration with:
- 198K context window
- Function call and reasoning capabilities
- Tiered pricing (0-32k / 32k+)
- reasoningBudgetToken32k extension parameter

* 🐛 fix: use hardcoded maxOutput mapping for KimiCodingPlan models

Replace getModelPropertyWithFallback with a simple hardcoded mapping to fix
the issue where max_tokens lookup fails when using deploymentName (k2p5).

The model id is converted to deploymentName in ChatService layer before
reaching the provider, causing getModelPropertyWithFallback('k2p5', ...) to
fail since the model card uses id 'kimi-k2.5'.

By using a hardcoded mapping that supports both model id and deploymentName,
we avoid the lookup issue while keeping the code simple (KimiCodingPlan only
has a few models).

*  test(kimiCodingPlan): add tests for thinking and max_tokens handling

Add comprehensive tests for KimiCodingPlan provider covering:
- Hardcoded maxOutput mapping for k2p5, kimi-k2.5, kimi-k2-thinking
- Thinking parameter handling for kimi-k2.5 and kimi-k2-thinking models
- Message normalization with forceThinking for assistant messages
- Tool calls with reasoning content to prevent API error

*  test(kimiCodingPlan): add tests for thinking and max_tokens handling

Add comprehensive tests for KimiCodingPlan provider covering:
- Hardcoded maxOutput mapping for k2p5, kimi-k2.5, kimi-k2-thinking
- Thinking parameter handling for kimi-k2.5 and kimi-k2-thinking models
- Message normalization with forceThinking for assistant messages
- Tool calls with reasoning content to prevent API error
2026-04-10 10:41:06 +08:00
Rylan Cai c85be1265f 🐛 fix:(agent-runtime): keep reasoning state in openai providers (#13701)
* 🐛 fix: preserve assistant reasoning in runtime state

* 🐛 fix: preserve agent reasoning and cached usage conversion

* 💬 docs: move usage retention comment to helper

* ♻️ refactor: remove redundant any cast in runtime executor

* 🐛 filter non-finite OpenAI usage values
2026-04-10 10:19:08 +08:00
Innei 4f1d2d494f feat(conversation): assistant group workflow collapse and activate-tools inspector (#13696)
* refactor(workflow): rewrite WorkflowSummary with status dot and minimal flat style

* refactor(workflow): rewrite WorkflowCollapse with unified borderless container

*  feat(workflow): add WorkflowExpandedList component and fix type errors

* ♻️ refactor(workflow): add missing Workflow components with Minimal Flat design

- WorkflowReasoningLine: cssVar tokens, aligned padding
- WorkflowToolDetail: new expandable result panel with motion animation
- WorkflowToolLine: expand chevron, getToolColor, detail panel integration
- WorkflowExpandedList: flat rendering with reasoning + tool lines

* Add tool call collapse support

Made-with: Cursor

* 💄 style(workflow): align WorkflowCollapse UI with @lobehub/ui design system

- Align border-radius, gap, padding tokens across all Workflow components
- Replace chevron expand/collapse with status icons (CheckCircle2, CircleX, Loader2)
- Use @lobehub/ui Highlighter for tool detail panel with JSON auto-formatting
- Use @lobehub/ui Flexbox for WorkflowExpandedList with proper gap and padding
- Fix delete action to use removeToolFromMessage instead of deleteAssistantMessage
- Wire debug button to existing Tool/Debug panel with full tabs
- Fix auto-collapse to only trigger on incomplete→complete transition
- Single ChevronDown with rotation for WorkflowSummary (match @lobehub/ui pattern)

* 💄 style(workflow): use AccordionItem and inspectorTextStyles for WorkflowCollapse

- Replace custom WorkflowSummary with @lobehub/ui AccordionItem
- Use StatusIndicator pattern (Block outlined 24x24) for status icon
- Apply inspectorTextStyles.root for title text (colorTextSecondary)
- Remove WorkflowSummary.tsx (dead code)
- Match Tool component AccordionItem usage (paddingBlock/Inline=4, borderless)

* 💄 style(workflow): remove divider and gap from WorkflowExpandedList

* 💄 style(workflow): align WorkflowCollapse title bar with Thinking component

* 💄 style(workflow): unify inner item spacing, font size, and colors

*  feat(workflow): add streaming scroll behavior with max-height and auto-scroll

* 💄 refactor(assistant-group): refine workflow collapse UI and duration

- Use Accordion for collapse; align tool/reasoning lines with generation state
- Show workflow header duration from summed block performance, not reasoning only

Made-with: Cursor

*  feat(inspector): enhance ActivateToolsInspector to display not found tools count

- Added localization for not found tools message in English, Chinese, and default locales.
- Updated ActivateToolsInspector to show a tooltip with the count of tools not found.
- Modified StatusIndicator to support a warning state for scenarios where no tools are activated but some are not found.

Signed-off-by: Innei <tukon479@gmail.com>

* 💄 style(workflow): simplify padding in WorkflowExpandedList component

- Removed unnecessary paddingInline from Flexbox elements in WorkflowExpandedList for cleaner layout.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(assistant-group): introduce constants and utility functions for workflow management

- Added constants for workflow timing, limits, and tool display names to enhance the assistant group's functionality.
- Implemented utility functions for processing and scoring post-tool answers, improving the workflow's response handling.
- Created new components for rendering content blocks and managing scroll behavior in the assistant group.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(assistant-group): enhance ContentBlock and Group components with content handling logic

- Added logic to conditionally render message content based on content availability and tool presence in ContentBlock.
- Introduced utility functions to determine substantive content and reasoning in Group, improving block partitioning for workflow management.
- Updated partitioning logic to handle trailing reasoning candidates and streamline answer and working block separation.

Signed-off-by: Innei <tukon479@gmail.com>

* 🙈 chore(gitignore): clarify superpowers local paths

Document that `.superpowers/` and `docs/superpowers/` are plugin/local outputs
and must not be committed.

Made-with: Cursor

* 👷 chore(ci): restore auto-tag-release workflow from canary

Revert unintended workflow edits so release tagging stays on main with
sync-main-to-canary dispatch.

Made-with: Cursor

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-10 02:00:38 +08:00
Innei 3b81a94d76 🐛 fix(kb): clean up vector storage when deleting knowledge bases (#13254)
* 🐛 feat(db): add findExclusiveFileIds, deleteWithFiles, deleteAllWithFiles to KnowledgeBaseModel

Add methods to safely clean up vector storage when deleting knowledge bases:
- findExclusiveFileIds: identifies files belonging only to a specific KB
- deleteWithFiles: deletes KB and its exclusive files with chunks/embeddings
- deleteAllWithFiles: bulk version for deleting all user KBs

* 🐛 fix(kb): wire vector cleanup in TRPC router, OpenAPI service, and client

- TRPC removeKnowledgeBase: use deleteWithFiles when removeFiles=true + S3 cleanup
- TRPC removeAllKnowledgeBases: use deleteAllWithFiles + S3 cleanup
- OpenAPI deleteKnowledgeBase: use deleteWithFiles + S3 cleanup
- Client service: default removeFiles=true when deleting knowledge base

* 🐛 fix(knowledgeBase): change default behavior of deleteKnowledgeBase to not remove files and update related tests

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(knowledgeBase): add optional query parameter to deleteKnowledgeBase for file removal

- Introduced `removeFiles` query parameter to control the deletion of exclusive files and derived data when deleting a knowledge base.
- Updated `KnowledgeBaseController`, `KnowledgeBaseService`, and related schemas to support this new functionality.

This change enhances the flexibility of the delete operation, allowing users to choose whether to remove associated files.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix: cascade knowledge base deletion and add orphan cleanup runbook

*  feat(knowledgeRepo): implement cascading deletion for file-backed documents

- Enhanced the `KnowledgeRepo` to ensure that when a document with an associated file is deleted, all related data (files, chunks, embeddings) are also removed.
- Introduced a new method `deleteDocumentWithRelations` to handle the cascading deletion logic.
- Updated tests to verify that all related entities are deleted when a file-backed document is removed.

This change improves data integrity by ensuring that no orphaned records remain after deletions.

Signed-off-by: Innei <tukon479@gmail.com>

* Defer DocumentService file initialization

* Fix flaky database tests and knowledge repo fixtures

* Add deletion regression tests for folders and external files

*  chore: remove kb orphan cleanup files from pr

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-10 01:56:05 +08:00
Arvin Xu a4d9967e60 🐛 fix: gateway not receiving error reasonDetail in agent_runtime_end event (#13707)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 01:51:19 +08:00
Arvin Xu 6a40eb8a3b 🐛 fix: resolve agent runtime service error serialization producing [object Object] (#13704)
 feat: add remote snapshot fetch for agent-tracing CLI and fix error serialization
2026-04-10 00:01:01 +08:00
Arvin Xu a23e159ef3 🔨 chore: extend execAgent with parentMessageId for Gateway regeneration/continue (#13699)
* 🌐 chore: update execServerAgentRuntime i18n copy

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

*  feat: extend execAgent with parentMessageId for regeneration/continue via Gateway

Add parentMessageId support to the execAgent API, enabling regeneration and continue-generation flows through the Gateway WebSocket path. When parentMessageId is provided, user message creation is skipped (resume mode) and the new assistant message branches from the specified parent.

Fixes LOBE-6933

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

* 🐛 fix: propagate parentMessageId through execAgents batch and fix test types

- Forward parentMessageId in execAgents executeTask to maintain batch parity with execAgent
- Fix ExecAgentResult mock types in gateway tests
- Fix messages table insert type cast in server router test

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:51:59 +08:00
Arvin Xu 1eb1fca7f2 🌐 chore: update execServerAgentRuntime i18n copy (#13698)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:52:07 +08:00
sxjeru 4100f2f700 🐛 fix: enhance model type normalization (#13548)
* feat(modelParse): enhance model type normalization and add tests for invalid types

* feat(modelParse): optimize imports and improve model type handling
2026-04-09 18:46:14 +08:00
Arvin Xu 23f91d044c 🐛 fix: buffer and deduplicate events during gateway resume (#13689)
* 🐛 fix: buffer and deduplicate events during resume to prevent out-of-order display

When reconnecting with empty lastEventId (page reload), live broadcast
events can arrive before resume replay completes, causing content to
appear out of order. Now AgentStreamClient enters resume mode: buffers
all events, waits for a 500ms gap (resume replay is dense, live events
are sparse), then deduplicates by event ID and emits in order.

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

* 🐛 fix: clear runningOperation on agent finish + resume timeout for completed sessions

- RuntimeExecutors.finish clears topic metadata.runningOperation when
  agent reaches terminal state, so stale entries don't trigger reconnect
- AgentStreamClient resume mode: add 3s timeout for empty buffer —
  if no events arrive after resume request, session has already completed,
  emit session_complete and disconnect

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

* 🐛 fix: eagerly fetch messages after topic switch to avoid skeleton flash

After switchTopic in Gateway mode, immediately fetch messages from DB
and replace in store, so the UI renders content right away instead of
showing a skeleton loading state while SWR re-fetches.

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

* 🐛 fix: eliminate skeleton flash on gateway topic switch

Match the client-mode pattern: fetch messages from DB and replaceMessages
BEFORE calling switchTopic with skipRefreshMessage: true. This ensures
messages are already in the store when the topic switches, preventing
a skeleton loading flash.

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

* 🐛 fix: flush resume buffer on session_complete before disconnect

session_complete is a top-level ServerMessage (not an agent_event), so
it bypassed the resume buffer. When it arrived during resume mode,
disconnect() cleared the buffer and all replayed events were lost.

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

* 🐛 fix: limit resume buffering to explicit reconnect scenarios only

Resume mode was triggered for ALL new connections (lastEventId always
empty on first connect), delaying live streaming for normal operations.

Now resume buffering requires explicit opt-in via resumeOnConnect option,
which is only set by reconnectToGatewayOperation (page-reload reconnect).
Normal executeGatewayAgent connections stream events immediately.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:26:21 +08:00
LiJian 06ac87dc45 🐛 fix: should inject current agnets information when actived the lobehub_skill (#13661)
* fix: should inject current agnets information when actived the lobehub skill

* fix: not inject the agent systemRole in lobehub skill inject

* fix: should use the isLobeHubSkillActive hook to judge

* fix: change the tools inject to vars replace function

* fix: add the lost topic id & agent title

* fix: later the PlaceholderVariablesProcessor

* fix: update the description
2026-04-09 16:11:18 +08:00
Zhijie He 6d731dd116 feat: add StreamLake Provider support (#13651)
*  feat: add StreamLake (快手) support

* style: add thinking support

style: add thinking support

style: add thinking support

style: add thinking support

style: add thinking support
2026-04-09 15:00:50 +08:00
LobeHub Bot f804d0fc7c 🌐 chore: translate non-English comments to English in scripts (#13690)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 14:38:29 +08:00
Arvin Xu b268f44f06 🐛 fix(server): prevent path traversal in TempFileManager.writeTempFile (#13684)
🐛 fix(server): prevent path traversal in TempFileManager.writeTempFile

Use path.basename() to strip directory components from user-supplied
filenames before writing temp files, preventing arbitrary file write
via crafted filenames like "../../app/startServer.js".

Fixes LOBE-6904

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:35:20 +08:00
Rdmclin2 475622a4b9 feat: support multi media and multiple connection mode (#13624)
* test: add feishu and qq test cases

* feat: support qq websocket mode

* feat: support slack websocket mode

* feat: feishu/lark support websocket connectMode

* chore: add default connection mode

* fix: discord 401 sign error

* fix: feishu websocket need verification token

* fix: heartbeate interval

* fix: get effective connnection mode

* chore: extract  getEffectiveConnectionMode utils

* chore: merge with default settings

* chore: add connectionMode fallback

* fix: file extract error

* fix: list platforms by connectionMode

* fix: qq bot gateway

* fix: support fileList

* feat: support video list

* chore: migrate local testing to references

* chore: add bot skill

* fix: telegram file serialize error

* feat: extract file extract logic to platform client

* fix: wechat file read

* feat: skip no metion in thread and set default message mode to queue

* chore: refact download resources

* fix: feishu adapter mention and dm error

* fix: feishu thread id

* fix: slack remove action

* fix: bot resovle files
2026-04-09 14:16:03 +08:00
René Wang 7b40538486 feat: add iamge (#13688) 2026-04-09 10:33:08 +08:00
Arvin Xu 5531ff7907 🔨 chore: Gateway reconnect after page reload (#13685)
*  feat: persist runningOperation to topic metadata for gateway reconnect

- Add runningOperation field to ChatTopicMetadata type
- execAgent writes { operationId, assistantMessageId } to topic metadata
  after creating the operation
- onSessionComplete clears runningOperation from metadata (best-effort)
- Extend updateTopicMetadata tRPC schema + service to support the field

Fixes LOBE-6905

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

*  feat: add refreshGatewayToken tRPC endpoint

Signs a fresh JWT for Gateway WebSocket reconnection after page reload.
The token is scoped to the authenticated user via signUserJWT.

Fixes LOBE-6906

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

*  feat: auto-reconnect to running Gateway operation on topic load

- Add reconnectToGatewayOperation to GatewayActionImpl — refreshes JWT,
  creates local operation, and connects WebSocket with event replay
- Add useGatewayReconnect hook — checks topic metadata.runningOperation
  when entering a topic and triggers reconnection
- Wire hook into ConversationArea

Fixes LOBE-6907

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

* 🐛 fix: preserve thread scope in reconnect context and subscribe to topic metadata

- Store scope + threadId in topic metadata.runningOperation
- reconnectToGatewayOperation uses stored scope/threadId instead of
  hardcoded main/null
- useGatewayReconnect subscribes to runningOperation via useChatStore
  selector so it triggers when topic data arrives from SWR (not just
  on mount when data may be empty)

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

* 🐛 fix: update device tests to allow runningOperation metadata writes

The tests asserted updateMetadata was never called, but now execAgent
persists runningOperation. Changed to assert no device-binding metadata
was written (boundDeviceId), which is the actual intent.

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

* ♻️ refactor: use SWR for gateway reconnect lifecycle

Replace useEffect + ref with useSWR keyed by operationId. SWR
naturally deduplicates (same key = no re-fetch), handles the async
reconnect, and doesn't fire when key is null (no runningOperation).

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

* 🐛 fix: validate topic has running operation before issuing gateway token

refreshGatewayToken now requires topicId, verifies the topic belongs to
the user and has a runningOperation in metadata before signing a JWT.

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

* 💄 style: break signin title into two lines

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

* Fix signin.title formatting in auth.json

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:23:57 +08:00
Arvin Xu 4f56868545 🐛 fix: allow templates to specify policyLoad so default docs are fully injected (#13672)
* 🐛 fix: allow templates to specify policyLoad so default docs are fully injected

All documents were hardcoded to PolicyLoad.PROGRESSIVE on creation,
causing CLAW template docs (IDENTITY, SOUL, BOOTSTRAP, AGENTS) to be
progressively disclosed instead of fully injected into context.

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

* 🐛 fix: forward policyLoad through upsertDocument and persist on update

- Add policyLoad to UpsertDocumentParams and pass it through to model
- Add policyLoad param to update() so upsert's existing-document path
  writes the value instead of silently discarding it
- Ensures re-running template init migrates pre-existing docs to ALWAYS

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

* ♻️ refactor: change update() to use named params object instead of positional args

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

* ♻️ refactor: change create() and upsert() to use named params object

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

*  test: improve agentDocuments test coverage to 99%

Add tests for uncovered branches:
- normalizeLoadRule default branch (unknown rule)
- explicit 'always' rule match
- by-time-range with NaN dates
- resolveDocumentLoadPosition fallback paths
- composeToolPolicyUpdate with existing context values
- upsert create path for new filenames
- getAgentContext empty docs path

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

* 🐛 fix: preserve policyLoad when copying documents

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

*  fix: align test assertion with refactored create() params object signature

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:09:05 +08:00
Arvin Xu dc1b43d86c 🐛 fix(database): prevent IDOR in addFilesToKnowledgeBase (#13683)
🐛 fix(database): add ownership check in addFilesToKnowledgeBase to prevent IDOR

Verify that the target knowledge base belongs to the authenticated user
before inserting files, preventing unauthorized file injection into
other users' knowledge bases.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:36:51 +08:00
Arvin Xu 4d7cbfea8e 🐛 fix: skip sendMessageInServer in Gateway mode + NavItem loading fix + i18n (#13681)
* 🐛 fix: reuse existing messages in execAgent when existingMessageIds provided

When existingMessageIds contains [userMsgId, assistantMsgId], skip
creating new messages and reuse the existing ones. This fixes duplicate
messages in Gateway mode where sendMessageInServer already created
the messages before execAgentTask is called.

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

* 🐛 fix: allow clicking NavItem while loading

Loading state should only show a visual indicator, not block onClick.
This fixes topic sidebar items being unclickable during agent execution.

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

* Revert "🐛 fix: reuse existing messages in execAgent when existingMessageIds provided"

This reverts commit 43b808024d5c4a0074b692a85083a72046ab47e0.

* 🐛 fix: skip sendMessageInServer in Gateway mode to avoid duplicate messages

Gateway mode now calls execAgentTask directly instead of going through
sendMessageInServer first. The backend creates user + assistant messages
and topic in one call. executeGatewayAgent handles topic switching
internally after receiving the server response.

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

* 🌐 chore: add i18n for execServerAgentRuntime operation

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

* 🐛 fix: move temp message cleanup after executeGatewayAgent succeeds

Keep temp messages visible during the gateway call so the UI isn't
blank. On failure, mark the operation as failed instead of silently
returning — temp messages remain so the user sees something went wrong.

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

* ♻️ refactor: remove manual temp message cleanup in gateway mode

switchTopic handles new topic navigation, and fetchAndReplaceMessages
replaces the message list from DB — no need to manually delete temp
messages.

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

* 🐛 fix: clear _new key temp messages when gateway creates new topic

Pass clearNewKey: true to switchTopic so temp messages from the
optimistic create don't persist in the _new key after switching
to the server-created topic.

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

* ♻️ refactor: import ExecAgentResult from @lobechat/types

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:33:54 +08:00
Innei e65e2c3628 feat(desktop): embed CLI in app and PATH install (#13669)
*  feat(desktop): embed CLI in app and PATH install

Made-with: Cursor

*  feat(desktop): add CLI command execution feature and UI integration

- Implemented `runCliCommand` method in `ElectronSystemService` to execute CLI commands.
- Added `CliTestSection` component for testing CLI commands within the app.
- Updated `SystemCtr` to include CLI command execution functionality.
- Enhanced `generateCliWrapper` to create short aliases for CLI commands.
- Integrated CLI testing UI in the system tools settings page.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat: enhance working directory handling for desktop

- Updated working directory logic to prioritize topic-level settings over agent-level.
- Introduced local storage management for agent working directories.
- Modified tests to reflect changes in working directory behavior.
- Added checks to ensure working directory retrieval is only performed on desktop environments.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(desktop): implement CLI command routing and cleanup

- Introduced `CliCtr` for executing CLI commands, enhancing the desktop application with CLI capabilities.
- Updated `ShellCommandCtr` to route specific commands to `CliCtr`, improving command handling.
- Removed legacy CLI path installation methods from `SystemCtr` and related services.
- Cleaned up localization files by removing obsolete entries related to CLI path installation.

Signed-off-by: Innei <tukon479@gmail.com>

* 🚸 settings(system-tools): show CLI embedded test only in dev mode

Made-with: Cursor

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-09 00:53:49 +08:00
Arvin Xu eebf9cb056 chore: add gatewayMode translations for labs (#13680)
* 🌐 i18n: add gatewayMode translations for labs

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

* Update labs.json

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:49:32 +08:00
Arvin Xu 3e7ee1fbfc 🔨 chore: integrate Gateway connection management into chat store (#13636)
*  feat: integrate Gateway connection management into chat store

Add GatewayActionImpl to aiChat slice for managing Agent Gateway
WebSocket connections per operationId. Includes connect, disconnect,
interrupt, and status tracking. Also type the execAgentTask return value.

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

*  feat: add Gateway mode branch in sendMessage for server-side agent execution

When agentGatewayUrl is set in server config (enableQueueAgentRuntime),
sendMessage now triggers server-side agent execution via execAgentTask
and receives events through the Agent Gateway WebSocket, instead of
running the agent loop client-side.

Includes:
- Expose agentGatewayUrl in GlobalServerConfig when queue mode is enabled
- Gateway event handler mapping stream events to UI message updates
- Fallback to client-side agent loop when Gateway is not configured

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

* 🐛 fix: emit disconnected event on intentional disconnect

disconnect() was only calling setStatus('disconnected') but not emitting
the 'disconnected' event. This caused the store's cleanup listener to
never fire after terminal events (agent_runtime_end), leaving stale
connections in gatewayConnections.

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

*  feat: enhance Gateway event handler for multi-step agent streaming

Support multi-step agent execution display (LLM → tool calls → next LLM)
using hybrid approach: real-time streaming for current step, DB refresh at
step transitions.

Fixes LOBE-6874

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

*  feat: wire up Gateway JWT token from execAgent to connectToGateway

Pass the RS256 JWT token returned by execAgentTask to connectToGateway
for WebSocket authentication. Also use ExecAgentResult from @lobechat/types
instead of local duplicate definition.

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

* 🐛 fix: handle wss:// protocol in AgentStreamClient buildWsUrl

When gatewayUrl already uses ws:// or wss:// protocol, use it directly
instead of stripping and re-adding the protocol prefix. Previously,
wss://host would become ws://wss://host (double protocol).

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

* 🐛 fix: queue gateway events to ensure stream_chunk waits for refreshMessages

Use a sequential Promise chain to process gateway events, so that
stream_chunk dispatches only run after stream_start's refreshMessages
resolves. Previously, chunks arrived before the new assistant message
existed in dbMessagesMap, causing updates to be silently dropped.

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

* 🐛 fix: pass operationId context to internal_dispatchMessage in gateway handler

Without operationId, internal_dispatchMessage falls back to global state
to compute the messageMapKey, which may differ from the key where
refreshMessages stored the server-created messages. Passing operationId
ensures the correct conversation context is resolved.

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

* 🐛 fix: resolve gateway streaming display issues

- Use fetchAndReplaceMessages (direct DB fetch + replaceMessages) instead
  of refreshMessages which mutates an orphaned SWR key
- Create dedicated execServerAgentRuntime operation with correct topicId
  context for internal_dispatchMessage to resolve the right messageMapKey
- Complete operation on agent_runtime_end instead of relying on
  onSessionComplete callback
- Keep loading state active between steps (only clear on agent_runtime_end)
  so users don't think the session ended during tool execution gaps

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

* 🐛 fix: maintain loading state across gateway step transitions

- Create dedicated execServerAgentRuntime operation with correct topicId
- Use fetchAndReplaceMessages instead of orphaned refreshMessages SWR key
- Re-apply loading after tool_end refresh so UI stays active between steps
- Complete operation on agent_runtime_end
- Add record-app-screen.sh for automated screen recording
- Output recordings to .records/ (gitignored)

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

* 🐛 fix: show loading on assistant message immediately in stream_start

Set loading on the current assistant message BEFORE awaiting
fetchAndReplaceMessages, so the UI shows a loading indicator while
waiting for the DB response instead of appearing frozen.

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

* 🐛 fix: drive gateway loading state via operation system instead of messageLoadingIds

Associate the assistant message with the gateway operation via
associateMessageWithOperation so the Conversation store's operation-based
loading detection (isGenerating) works correctly. This shows the proper
loading skeleton on the assistant message while waiting for gateway events.

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

* ♻️ refactor: remove unused internal_toggleMessageLoading from gateway handler

Loading state is now fully driven by the operation system via
associateMessageWithOperation + completeOperation. The old
messageLoadingIds-based approach is no longer needed.

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

* 🐛 fix: rewrite record-app-screen.sh to use CDP screenshot assembly

Replace broken ffmpeg avfoundation live recording (corrupts on kill) with
agent-browser CDP screenshot capture + ffmpeg assembly on stop. This works
reliably on any screen including external monitors.

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

*  feat: add Gateway Mode lab toggle and fix CI type error

- Add enableGatewayMode to UserLabSchema as experimental feature
- Add lab selector and settings UI toggle in Advanced > Labs
- Gateway mode now requires both server config (agentGatewayUrl) AND
  user opt-in via Labs toggle
- Fix TS2322: result.token (string | undefined) → fallback to ''
- Add i18n keys for gateway mode feature

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

*  feat: hide Gateway Mode toggle when agentGatewayUrl is not configured

Only show the lab toggle when the server has AGENT_GATEWAY_URL set,
so users without gateway infrastructure don't see the option.

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

* 💄 style: move Gateway Mode toggle below Input Markdown in labs section

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

* 🐛 fix: remove default AGENT_GATEWAY_URL value and make schema optional

Without an explicit env var, the gateway URL should be undefined so the
lab toggle and gateway mode are not available.

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

* 📝 docs: update SKILL.md to reference record-app-screen.sh

Replace outdated record-gateway-demo.sh references with the renamed
record-app-screen.sh and its start/stop lifecycle documentation.

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

* 📝 docs: add record-app-screen reference doc and slim down SKILL.md

Move detailed recording documentation to references/record-app-screen.md
and keep SKILL.md concise with a link to the full reference.

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

* 🐛 fix: guard GatewayStreamNotifier with AGENT_GATEWAY_URL check

AGENT_GATEWAY_URL is now optional, so check both URL and service token
before wrapping with GatewayStreamNotifier to avoid TS2345.

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

* ♻️ refactor: extract gateway execution logic to GatewayActionImpl

Move server-side gateway execution logic from conversationLifecycle.ts
into GatewayActionImpl.startGatewayExecution(). The sendMessage flow
now does a simple early return when gateway mode is active, keeping
the existing client-mode code path untouched.

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

* ♻️ refactor: split gateway into isGatewayModeEnabled check + executeGatewayAgent

Replace fire-and-forget startGatewayExecution with explicit check/execute
pattern. Caller does: if (check) { await execute(); return; } — giving
proper error handling and clearer control flow.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:31:26 +08:00
renovate[bot] 84eff30be1 Update dependency lucide-react to ^0.577.0 (#13580)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 23:31:20 +08:00
Innei 50a1cc1ec2 ♻️ refactor(resource): tree store for library hierarchy and move sync (#13640)
*  feat(ResourceManager): integrate tree store for folder management and enhance file operations

- Added `useTreeStore` to manage folder structure and state, replacing previous file store dependencies.
- Updated `EmptyPlaceholder` to utilize `currentFolderId` for file uploads.
- Refactored `MoveToFolderModal` to use tree store for moving items, improving folder navigation.
- Enhanced drag-and-drop functionality in `DndContextWrapper` to support moving items between folders.
- Removed obsolete `LibraryHierarchy` state management, streamlining folder operations.
- Improved file renaming and deletion processes to ensure tree state consistency.

This update enhances the overall file management experience by leveraging a dedicated tree store for better performance and maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(TreeAction): enhance resource movement and update handling

- Updated mutation logic for moving resources to differentiate between items visible in the Explorer and those not visible, improving performance and user experience.
- Added refresh functionality for the file list after resource updates (move, update, delete) to ensure the Explorer reflects the latest state.
- Refactored mutation methods to use async/await for better readability and error handling.

This update streamlines resource management within the tree structure, ensuring a more responsive and consistent user interface.

Signed-off-by: Innei <tukon479@gmail.com>

* Fix file updates and tree move fallback regressions

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-08 23:26:58 +08:00
Arvin Xu d49aba748e 🐛 fix: hide LocalFile actions in topic share page (#12254)
🐛 fix: hide LocalFile actions (Open/Show in Folder) in share page

In topic share pages, the LocalFile component was showing 'Open' and
'Show in Folder' action buttons on hover, which are desktop-only
operations not available to share page viewers.

- Add 'readonly' prop to LocalFile component to disable interactive actions
- Detect share page context via topicShareId in LocalFile Render plugin
- Skip Popover rendering when readonly is true
2026-04-08 22:45:08 +08:00
Arvin Xu 8a0c3cb36a ♻️ refactor: remove legacy messageLoadingIds from chat store (#13662)
* ♻️ refactor: remove legacy messageLoadingIds from chat store

The messageLoadingIds state and internal_toggleMessageLoading action in the
chat store have been fully superseded by the operation system. The state was
being written to but never read by any consumer — all UI components and
selectors already use operation-based selectors (isMessageGenerating,
isMessageProcessing, etc.).

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

* 📝 chore: update skill docs to remove messageLoadingIds references

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

* 🐛 fix: replace messageLoadingIds with operationSelectors in generation action

The Conversation store's regenerateUserMessage was reading messageLoadingIds
from the chat store to check if a message is already being processed. Replace
with operationSelectors.isMessageProcessing which is the correct way to check
operation state.

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

* 🐛 fix: add operationsByMessage to test mocks for operation selector

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:54:11 +08:00
LiJian 26d1d6bbfb 🐛 fix: slove the agents_documents will coverd the systemRole (#13667)
fix: slove the agents_documents will coverd the systemRole
2026-04-08 20:54:20 +08:00
YuTengjing c5ec0ef2a1 💰 chore: adjust Seedance 2.0 pricing with 20% service fee (#13676) 2026-04-08 20:50:18 +08:00
YuTengjing 6d0c8d710a 🐛 fix: video page icon collision, missing locale keys, and model query param (#13671) 2026-04-08 19:44:35 +08:00
Rdmclin2 e10265fadd feat: add skill panel and fix skill icon (#13666)
* fix: custom agent skill icon

* feat: support skill detail

* chore: remove unnecessary custom tag
2026-04-08 18:51:01 +08:00
Arvin Xu c68dfa00df feat(cli): add lh notify command for external agent callbacks (#13664)
*  feat(cli): add `lh notify` command for external agent callbacks

Add a new `lh notify` CLI command and server-side TRPC endpoint that allows
external agents (e.g. Claude Code) to send callback messages to a topic and
trigger the agent loop to process them.

Fixes LOBE-6888

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

* 🔧 chore(cli): replace sessionId with agentId and threadId in notify command

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:03:55 +08:00
Arvin Xu b6a47debfd ♻️ refactor: remove promptfoo configs and dependencies (#13665)
♻️ refactor: remove promptfoo configs and dependencies from packages

Migrate all prompt evaluation tests to the cloud repo's agent-evals framework.
Remove promptfoo directories, configs, dependencies, and generator scripts
from @lobechat/prompts, @lobechat/memory-user-memory, and @lobechat/builtin-tool-memory.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:50:55 +08:00
YuTengjing 147ff3976f feat: add Seedance 2.0 & 2.0 Fast video generation models (#13663) 2026-04-08 17:39:50 +08:00
René Wang 034c7c203b feat: changelog (#13634)
* feat: changelog

* feat: edito content
2026-04-08 15:30:33 +08:00
Rdmclin2 b0b6684294 🔨 chore: optimize model and skills (#13659)
* chore: model detail default close

* fix: model detail show info in normal mode
2026-04-08 15:20:35 +08:00
Arvin Xu 36d2427947 🐛 fix: use parametersJsonSchema for Google tool schemas (#13656)
* 🐛 fix: use parametersJsonSchema for Google tool schemas to support full JSON Schema

Replace Google's restrictive Schema subset with parametersJsonSchema, which accepts
standard JSON Schema directly. This eliminates the need for resolveRefs and
sanitizeSchemaForGoogle, fixing nullable enum (LOBE-6607) and $ref (LOBE-6680) issues.

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

* 🐛 fix: update remaining tests to use parametersJsonSchema

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:08:59 +08:00
Innei 4d15979fab 💄 fix(RuntimeConfig): instant-apply working directory with recent list (#13641)
* 💄 fix(RuntimeConfig): instant-apply working directory with recent list

Remove Save/Cancel buttons from working directory selector.
Directories now apply immediately on click. Show recent directories
list with checkmark for active selection and "Choose a different folder"
entry at bottom.

*  feat(SystemCtr): enhance folder selection to return repository type

Updated the `selectFolder` method to return an object containing the selected folder path and its repository type (either 'git' or 'github'). Added a new private method `detectRepoType` to determine the repository type based on the presence of a `.git/config` file. Introduced a new utility for managing recent directories, allowing the application to display appropriate icons based on the repository type in the UI.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-08 14:56:18 +08:00
Arvin Xu 53786e30b6 🔨 chore: remove redundant update-status call from GatewayStreamNotifier (#13655)
* ♻️ refactor: remove redundant update-status call from GatewayStreamNotifier

Gateway now handles session completion directly in pushEvent when it
receives agent_runtime_end, so the separate update-status HTTP call
is no longer needed.

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

*  test: update GatewayStreamNotifier tests for removed update-status call

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:03:46 +08:00
LiJian 7300b53e99 🐛 fix: not use sanitizeHTMLContent to block the scripts & sandbox styles (#13649)
* fix: not use sanitizeHTMLContent to block the scripts & sandbox styles

* fix: clean the code & remove the allows-popups
2026-04-08 13:34:40 +08:00
Arvin Xu 6f3897a6e8 🔨 chore: generate JWT token for Gateway WebSocket auth in execAgent (#13654)
 feat: generate JWT token for Gateway WebSocket auth in execAgent

Sign a short-lived RS256 JWT via signUserJWT(userId) when creating an agent
operation, and return it in ExecAgentResult.token so the client can
authenticate with the Agent Gateway WebSocket.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:28:37 +08:00
Arvin Xu a6e330cfa9 🐛 fix(web-crawler): prevent happy-dom CSS parsing crash in htmlToMarkdown (#13652)
- Disable CSS file loading and JS evaluation in happy-dom Window (root cause)
- Add try-catch around Readability.parse() for defense in depth
- Add regression tests for invalid CSS selectors and external stylesheet links

Closes LOBE-6869

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:59:49 +08:00
LobeHub Bot accc173068 🌐 chore: translate non-English comments to English in openapi routes (#13647)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:50:17 +08:00
Arvin Xu 81ab8aa07b 🔨 chore: support nested subtask tree in task.detail (#13625)
*  feat: support nested subtask tree in task.detail

Replace flat subtask list with recursive nested tree structure.
Backend builds the complete subtask tree in one response,
eliminating the need for separate getTaskTree API calls.

Fixes LOBE-6814

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

* 🐛 fix: return empty array for root subtasks instead of undefined

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

* 📝 docs: add cli-backend-testing skill

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:49:26 +08:00
YuTengjing 12ee7c9e9a 🐛 fix: support ENABLE_MOCK_DEV_USER in checkAuth and openapi auth middleware (#13648) 2026-04-08 12:37:27 +08:00
LiJian 8d8b60e4f9 🐛 fix: should filiter the current agents in avaiable agents list (#13644)
* fix: should inject the current agents & remove current agent from avaiable agents list

* fix: delete the current agents blocks
2026-04-08 11:24:53 +08:00
YuTengjing 19aedcdf56 fix: skip @mention for team members in PR assign and issue triage (#13633) 2026-04-08 11:00:19 +08:00
YuTengjing 3bb09e0ef9 feat: enhance linear skill with image extraction and in-progress status (#13629) 2026-04-08 10:58:07 +08:00
Arvin Xu 13fc65faa2 update 2026-04-08 10:53:00 +08:00
Arvin Xu de8761cf29 🐛 fix: import hook types before re-exporting for tsgo compatibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:52:11 +08:00
Arvin Xu 4f2f0055e1 ♻️ refactor(agent-runtime): improve AgentInstruction types and extract hook event types
- Each instruction interface now extends AgentInstructionBase directly instead of intersection
- Group instructions by category: LLM, Tool, Task, Human Interaction, Control
- Extract AgentHookType and AgentHookEvent into agent-runtime package
- Keep AgentHook, AgentHookWebhook, SerializedHook in server layer (webhook is server-specific)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 10:45:00 +08:00
Arvin Xu 2290929255 🔨 chore: add GraphAgent and agentFactory for graph-driven agent execution (#13643)
*  feat: add GraphAgent and agentFactory for graph-driven agent execution

- Add GraphAgent: a decorator around GeneralChatAgent that drives execution via declarative ReasoningGraph
  - Agent nodes: delegate to GeneralChatAgent for tool-calling loops, then extract structured output
  - LLM nodes: single structured LLM call
  - Programmatic transition evaluation (not LLM-driven)
  - Backtracking with configurable limits
- Add AgentInstruction.stepLabel: allows any Agent to label steps for display in stream events and hooks
- Add agentFactory to AgentRuntimeServiceOptions: external injection of custom Agent implementations
- Add stepLabel propagation: stream_start/stream_end events and afterStep hooks carry the label
- Fix: sanitize null bytes in MessageModel.create content (consistent with existing plugin argument sanitization)

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

* 🐛 fix(agent-runtime): validate graph node existence and preserve transitions at backtrack limit

- Add node existence check in startNode to prevent runtime crash on invalid entry/transition targets
- Evaluate all transitions even when backtrack limit is reached; only suppress actual backtrack targets
2026-04-08 10:28:15 +08:00
Innei a2eab24536 🐛 fix(device-gateway-client): prevent uncaught WebSocket error on disconnect (#13635)
* 🐛(device-gateway-client): prevent uncaught error when closing connecting WebSocket

Detach ws event listeners safely, temporarily handle close-phase errors, and guard ws.close() so logout/token clear does not surface a main-process uncaught exception.

Made-with: Cursor

* 🧹 refactor(tests): remove unused mockProps from ComfyUIForm test

Cleaned up the ComfyUIForm test by removing the unused mockProps object, streamlining the test setup for better clarity and maintainability.

Signed-off-by: Innei <tukon479@gmail.com>

* Hide onboarding finish tool call and preserve close error listener

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-07 23:59:03 +08:00
Innei b279c108b6 🐛 fix(desktop): use stored locale from URL parameter instead of syste… (#13620)
🐛 fix(desktop): use stored locale from URL parameter instead of system language

When the desktop app restarts, the UI language was reverting to the system
language instead of respecting the user's saved language preference.

Root cause: The inline script in index.html was setting document.documentElement.lang
from navigator.language (system language) before i18n initialization could read
the stored locale from Electron store.

Fix: Check the URL's `lng` query parameter first (which is set by Electron main
process from stored settings in Browser.ts:buildUrlWithLocale()), then fall back
to navigator.language.

Fixes #13616

https://claude.ai/code/session_0128LZAbJL1a5vkGboH4U5FP

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 22:58:09 +08:00
Innei 7a6fd8e865 🐛 fix(desktop): remote re-auth for batched tRPC and clean OIDC on disconnect (#13614)
* 🐛 fix(desktop): remote re-auth for batched tRPC and clean OIDC on disconnect

- Notify authorization required when X-Auth-Required is set, not only on HTTP 401 (207 batch)
- Show AuthRequiredModal after remote config init; do not gate on dataSyncConfig.active
- Desktop: market 401 only silent refresh; avoid community sign-in UI (AuthRequiredModal handles cloud)
- Disconnect: clearRemoteServerConfig to wipe encrypted OIDC tokens

Made-with: Cursor

* 🐛 Reset user-data Zustand stores on remote disconnect and sync refresh

- Add ResetableStoreAction helper and batched reset via userDataStores
- Wire reset into Electron remote disconnect and refreshUserData
- Handle refreshUserData failures in data sync SWR onSuccess

Made-with: Cursor

* 🐛 fix(useUserAvatar): refactor desktop environment checks to use mockConstEnv

- Replace direct manipulation of mockIsDesktop with mockConstEnv.isDesktop for better encapsulation.
- Update all relevant test cases to utilize the new mock structure, ensuring consistent behavior across tests.

This change improves the clarity and maintainability of the test code.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 test: update mocks for ShikiLobeTheme and refactor session/agent mocks

- Added ShikiLobeTheme mock to ComfyUIForm and AddFilesToKnowledgeBase tests for consistent theming.
- Refactored session and agent mocks to use async imports, improving test isolation and performance.

This enhances the clarity and maintainability of the test suite.

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-07 22:57:49 +08:00
lobehubbot 7d599a980f 🔖 chore(release): release version v2.1.48 [skip ci] 2026-04-07 14:50:49 +00:00
lobehubbot 1206db7c12 Merge remote-tracking branch 'origin/main' into canary 2026-04-07 14:48:16 +00:00
Arvin Xu bd61b61843 🚀 release: 20260407 (#13626)
# 🚀 release: 20260407

This release includes **148 commits**. Key updates are below.

- **Response API tool execution is more capable and reliable** — Added
hosted builtin tools + client-side function tools and improved tool-call
streaming/completion behavior.
[#13406](https://github.com/lobehub/lobehub/pull/13406)
[#13414](https://github.com/lobehub/lobehub/pull/13414)
[#13506](https://github.com/lobehub/lobehub/pull/13506)
[#13555](https://github.com/lobehub/lobehub/pull/13555)
- **Input and composition UX upgraded** — Added AI input auto-completion
and multiple chat-input stability fixes.
[#13458](https://github.com/lobehub/lobehub/pull/13458)
[#13551](https://github.com/lobehub/lobehub/pull/13551)
[#13481](https://github.com/lobehub/lobehub/pull/13481)
- **Model/provider compatibility improved** — Better Gemini/Google tool
schema handling and additional model updates.
[#13429](https://github.com/lobehub/lobehub/pull/13429)
[#13465](https://github.com/lobehub/lobehub/pull/13465)
[#13613](https://github.com/lobehub/lobehub/pull/13613)
- **Desktop and CLI reliability improved** — Gateway WebSocket support
and desktop runtime upgrades.
[#13608](https://github.com/lobehub/lobehub/pull/13608)
[#13550](https://github.com/lobehub/lobehub/pull/13550)
[#13557](https://github.com/lobehub/lobehub/pull/13557)
- **Security hardening continued** — Fixed auth and sanitization risks
and upgraded vulnerable dependencies.
[#13535](https://github.com/lobehub/lobehub/pull/13535)
[#13529](https://github.com/lobehub/lobehub/pull/13529)
[#13479](https://github.com/lobehub/lobehub/pull/13479)

### Models & Providers

- Added/updated support for `glm-5v-turbo`, GLM-5.1 updates, and
qwen3.5-omni series.
[#13487](https://github.com/lobehub/lobehub/pull/13487)
[#13405](https://github.com/lobehub/lobehub/pull/13405)
[#13422](https://github.com/lobehub/lobehub/pull/13422)
- Added additional ImageGen providers/models (Wanxiang 2.7 and Keling
from Qwen). [#13478](https://github.com/lobehub/lobehub/pull/13478)
- Improved Gemini/Google tool schema and compatibility handling across
runtime paths. [#13429](https://github.com/lobehub/lobehub/pull/13429)
[#13465](https://github.com/lobehub/lobehub/pull/13465)
[#13613](https://github.com/lobehub/lobehub/pull/13613)

### Response API & Runtime

- Added hosted builtin tools in Response API and client-side function
tool execution support.
[#13406](https://github.com/lobehub/lobehub/pull/13406)
[#13414](https://github.com/lobehub/lobehub/pull/13414)
- Improved stream tool-call argument handling and `response.completed`
output correctness.
[#13506](https://github.com/lobehub/lobehub/pull/13506)
[#13555](https://github.com/lobehub/lobehub/pull/13555)
- Improved runtime error/context handling for intervention and provider
edge cases. [#13420](https://github.com/lobehub/lobehub/pull/13420)
[#13607](https://github.com/lobehub/lobehub/pull/13607)

### Desktop App

- Bumped desktop dependencies and runtime integrations (`agent-browser`,
`electron`). [#13550](https://github.com/lobehub/lobehub/pull/13550)
[#13557](https://github.com/lobehub/lobehub/pull/13557)
- Simplified desktop release channel setup by removing nightly release
flow. [#13480](https://github.com/lobehub/lobehub/pull/13480)

### CLI

- Added OpenClaw migration command.
[#13566](https://github.com/lobehub/lobehub/pull/13566)
- Added local device binding support for `lh agent run`.
[#13277](https://github.com/lobehub/lobehub/pull/13277)
- Added WebSocket gateway support and reconnect reliability
improvements. [#13608](https://github.com/lobehub/lobehub/pull/13608)
[#13418](https://github.com/lobehub/lobehub/pull/13418)

### Security

- Removed risky `apiKey` fallback behavior in webapi auth path to
prevent bypass risk.
[#13535](https://github.com/lobehub/lobehub/pull/13535)
- Sanitized HTML artifact rendering and iframe sandboxing to reduce
XSS-to-RCE risk. [#13529](https://github.com/lobehub/lobehub/pull/13529)
- Upgraded nodemailer to v8 to address SMTP command injection advisory.
[#13479](https://github.com/lobehub/lobehub/pull/13479)

### Bug Fixes

- Fixed image generation model default switch issues.
[#13587](https://github.com/lobehub/lobehub/pull/13587)
- Fixed subtopic re-fork message scope behavior and agent panel reset
edge cases. [#13606](https://github.com/lobehub/lobehub/pull/13606)
[#13556](https://github.com/lobehub/lobehub/pull/13556)
- Fixed chat-input freeze on paste and mention plugin behavior.
[#13551](https://github.com/lobehub/lobehub/pull/13551)
[#13415](https://github.com/lobehub/lobehub/pull/13415)
- Fixed auth/social sign-in and settings UX edge cases.
[#13368](https://github.com/lobehub/lobehub/pull/13368)
[#13392](https://github.com/lobehub/lobehub/pull/13392)
[#13338](https://github.com/lobehub/lobehub/pull/13338)

### Credits

Huge thanks to these contributors:

@chriszf @hardy-one @Innei @LiJian @Neko @octopusnote @rdmclin2
@rivertwilight @RylanCai @suyua9 @sxjeru @Tsuki @WangYK @WindSpiritSR
@Yizhuo @YuTengjing @hezhijie0327 @arvinxx
2026-04-07 22:45:54 +08:00
Arvin Xu 0c49b0a039 🔨 chore: add AgentStreamClient for Agent Gateway WebSocket (#13628)
* 🤖 chore(skills): add electron-dev.sh script and update local-testing skill

Add reusable electron-dev.sh script with start/stop/status/restart commands
that reliably manages all Electron processes (main + helpers + vite).
Update SKILL.md to reference the script instead of inline bash commands.

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

*  feat: add AgentStreamClient for Agent Gateway WebSocket communication

Browser-compatible WebSocket client for receiving agent execution events
from the Agent Gateway. Supports auto-reconnect with exponential backoff,
heartbeat keep-alive, and event replay via lastEventId resume.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:42:54 +08:00
Innei 1beb9d4eb6 feat(desktop): add Electron version display in system tools settings (#13630)
*  feat(desktop): add Electron version display in system tools settings

Display Electron, Chrome, and Node.js versions in the desktop app's Settings > System Tools page under a new "App Environment" section.

https://claude.ai/code/session_01C6nUdBci6A29CZCvQSUuDt

* 🐛 fix(desktop): update preload test for new version properties

https://claude.ai/code/session_01C6nUdBci6A29CZCvQSUuDt

* ♻️ refactor: remove unused i18n name keys for app environment section

Tool names (Electron, Chrome, Node.js) are proper nouns that don't need
localization, matching the existing pattern in ToolDetectorSection.

https://claude.ai/code/session_01C6nUdBci6A29CZCvQSUuDt

* 🐛 fix(desktop): handle undefined electron/chrome versions in test env

process.versions.electron and process.versions.chrome are only available
in Electron runtime, not in the Node.js test environment.

https://claude.ai/code/session_01C6nUdBci6A29CZCvQSUuDt

* 🐛 fix: use const assertion for i18n key type safety

https://claude.ai/code/session_01C6nUdBci6A29CZCvQSUuDt

* 🌐 Add app environment strings to setting locales and refine copy

Made-with: Cursor

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 21:53:27 +08:00
LiJian 021fd07deb 🐛 fix: can manual close the hidden builtin tools (#13631)
* fix: can manual close the hidden builtin tools

* fix: should change it into chatConfigByIdSelectors

* fix: add the always not close tools
2026-04-07 21:37:32 +08:00
LiJian 33f729cd1a 🐛 fix: add the availableAgents into the prompt inject (#13621)
* fix: add the availableAgents into the prompt inject

* fix: should auto inject the avaiable agents into context when use the auto model

* fix: update the prompt

* fix: test fixed
2026-04-07 19:45:29 +08:00
Innei 8b3c871d08 ♻️ refactor(onboarding): add OnboardingContextInjector and wire context engine (#13518)
* ♻️ refactor(onboarding): add OnboardingContextInjector and wire context engine

Made-with: Cursor

* 🔧 refactor(onboarding): update tool call references to use `lobe-user-interaction________builtin`

Modified onboarding documentation and utility functions to standardize the use of the `lobe-user-interaction________builtin` tool call for structured input collection, enhancing clarity and consistency across the codebase.

Signed-off-by: Innei <tukon479@gmail.com>

* 🔧 refactor(onboarding): standardize tool call references to `lobe-user-interaction____askUserQuestion____builtin`

Updated documentation and utility functions to replace instances of the `lobe-user-interaction________builtin` tool call with `lobe-user-interaction____askUserQuestion____builtin`, ensuring consistency in structured input collection across the onboarding process.

Signed-off-by: Innei <tukon479@gmail.com>

* ♻️ refactor(onboarding): move onboarding context before first user

* ♻️ refactor(context-engine): add virtual last user provider

* update v3

* 🐛 fix(onboarding): add early exit escape hatch for boundary cases

The `<next_actions>` directive only prompted finishOnboarding in the
summary phase, but phase transition required all fields + 5 discovery
exchanges — a condition extreme cases rarely meet. This left the model
stuck in discovery, never calling finishOnboarding.

- Add EARLY EXIT hint in discovery phase next_actions
- Add universal completion-signal REMINDER across all phases
- Add minimum-viable discovery fallback in systemRole
- Add explicit completion signal list in Early Exit section
- Add off-topic redirect limit in Boundaries
- Add CRITICAL persistence rule in toolSystemRole

*  test(context-engine): fix OnboardingContextInjector tests to match BaseFirstUserContentProvider

Remove brittle MessagesEngine onboarding test that hardcoded XML content.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-07 19:25:16 +08:00
Arvin Xu bd8143c464 🐛 fix(prompts): enforce user perspective in input completion (#13619)
🐛 fix(prompts): enforce user perspective in input completion prompt

The autocomplete prompt was generating completions from the AI assistant's
perspective (e.g., "How can I help you?") instead of the user's perspective.
Added explicit perspective constraints with good/bad examples.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:31:14 +08:00
LobeHub Bot 68762fc4ae 🌐 chore: translate non-English comments to English in desktop i18nWorkflow (#13604)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 16:51:56 +08:00
Arvin Xu 1a58d530fb ♻️ refactor: add WebSocket gateway support to CLI agent run (#13608)
*  feat: add WebSocket gateway support to CLI agent run

CLI `agent run` now connects to Agent Gateway via WebSocket by default,
falling back to SSE when `--sse` is passed. After auth, sends `resume`
to fetch buffered events (covers race between exec and WS connect).

- Add `streamAgentEventsViaWebSocket` in agentStream.ts
- Add `resolveAgentGatewayUrl` in settings
- Add `OFFICIAL_AGENT_GATEWAY_URL` constant
- Support `AGENT_GATEWAY_SERVICE_TOKEN` env for gateway auth
- Add `--sse` flag for forced SSE fallback

Fixes LOBE-6800

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

*  test: add WebSocket gateway stream tests for CLI

Cover auth flow, resume, event rendering, JSON mode, auth failure,
heartbeat_ack, URL construction, and a multi-step tool-call scenario.

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

* 🐛 fix: persist agentGatewayUrl in saveSettings/loadSettings

saveSettings and loadSettings now handle agentGatewayUrl so custom
gateway configuration survives across CLI runs. Default URL is
stripped like serverUrl to keep the settings file minimal.

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

* 🐛 fix: remove AGENT_GATEWAY_SERVICE_TOKEN and fix JSON double-print in WS stream

1. Remove AGENT_GATEWAY_SERVICE_TOKEN env var — gateway auth should
   only use Oidc-Auth / X-API-Key from the existing auth flow.

2. Fix --json mode printing duplicate JSON arrays: agent_runtime_end,
   session_complete, and onclose all called console.log independently.
   Add jsonPrinted guard so only the first path outputs JSON.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:49:25 +08:00
Arvin Xu ca01385666 🐛 fix(model-runtime): strip additionalProperties and leftover $ref in Google tool schemas (#13613)
Google Gemini / Vertex AI rejects `additionalProperties` and `$ref` in
function declaration schemas. The previous fix (PR #13524) resolved most
`$ref` via `resolveRefs()` but missed two cases:

1. `additionalProperties` was never stripped
2. `$ref` survived when `resolveRefs` hit its depth limit (>10) on
   recursive schemas

Add both keys to UNSUPPORTED_SCHEMA_KEYS so `sanitizeSchemaForGoogle()`
strips them after ref resolution.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:08:15 +08:00
dependabot[bot] 5231bbbcac build(deps-dev): bump electron from 41.0.3 to 41.1.0 in /apps/desktop (#13557)
Bumps [electron](https://github.com/electron/electron) from 41.0.3 to 41.1.0.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v41.0.3...v41.1.0)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.1.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 14:19:46 +08:00
Octopus 496b10f5c0 fix(github-copilot): surface quota exhaustion 429 instead of retrying (#13584)
🐛 fix(github-copilot): surface quota exhaustion 429 instead of retrying

When the GitHub Copilot API returns a 429 with a Retry-After header
exceeding 5 minutes (indicating quota exhaustion rather than transient
rate limiting), throw the error immediately instead of retrying up to
MAX_RATE_LIMIT_RETRIES times with a silently capped 10s delay.

Fixes #13572
2026-04-07 14:06:52 +08:00
Arvin Xu 1800110748 🐛 fix: use main scope messages for subtopic re-fork (#13606)
* 🐛 fix: use main scope messages for thread fork to fix subtopic re-fork failure

When inside a subtopic (activeThreadId set), openThreadCreator and portalAIChats
used activeDisplayMessages which included activeThreadId in the key, returning
thread-scoped messages instead of main conversation messages. This caused
genParentMessages to fail finding the target message, resulting in empty parent
messages and a broken/loading fork UI.

Fix: use messageMapKey with only agentId/topicId to always get main scope messages.

Closes LOBE-5023

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

* 🐛 fix: include groupId in main scope key for group session support

Address Codex review: pass activeGroupId to messageMapKey so that
fork and thread selectors work correctly in group conversations
where messages are keyed by group scope instead of main scope.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:40:59 +08:00
YuTengjing b068c427d4 🐛 fix: preserve backend traceId in error handler (#13607) 2026-04-07 12:58:26 +08:00
Arvin Xu d5eec83a72 🔧 chore: disable input completion by default (#13605)
* 🔧 chore: disable input completion by default

The input auto-completion experience is not polished enough yet,
so disable it by default. Users can still enable it manually in
Settings > Agent.

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

* 🐛 fix: update snapshot for disabled input completion default

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:29:50 +08:00
Arvin Xu 6c9cbb07ee 🔨 chore: add GatewayStreamNotifier for Agent Gateway WebSocket push (#13603)
*  feat: add GatewayStreamNotifier for Agent Gateway WebSocket push

Add a decorator that wraps IStreamEventManager to additionally push
events to the Agent Gateway via HTTP (fire-and-forget). When
AGENT_GATEWAY_SERVICE_TOKEN is configured, the factory automatically
wraps the base stream manager with the gateway notifier. Redis SSE
remains the primary event channel; the gateway is an additive push
layer for WebSocket delivery.

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

*  test: add GatewayStreamNotifier and factory gateway wrapping tests

Ensure the decorator always delegates to the inner stream event manager
first, gateway failure never drops Redis events, and the factory
correctly wraps/skips based on AGENT_GATEWAY_SERVICE_TOKEN.

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

* 🐛 fix: add timeout, bounded concurrency and url-join to gateway notifier

- 5s AbortSignal timeout on every gateway POST to prevent hanging sockets
- Max 20 inflight requests; excess silently dropped with a debug log
- Use url-join for URL construction instead of string concatenation

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

* 🐛 fix: resolve TS18048 possibly undefined in test

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

* ♻️ refactor: move gateway env vars to appEnv

Read AGENT_GATEWAY_SERVICE_TOKEN and AGENT_GATEWAY_URL from the
validated appEnv config instead of raw process.env.

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

* ♻️ refactor: move gateway URL default into appEnv

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:16:49 +08:00
LobeHub Bot b92ee0ade5 🌐 chore: translate non-English comments to English in store/task (#13561)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:16:24 +08:00
Arvin Xu 3327b293d6 🔒 fix: remove apiKey fallback in webapi auth to prevent auth bypass (#13535)
* 🔒 fix: remove XOR auth header and legacy apiKey bypass (GHSA-5mwj-v5jw-5c97)

Completely remove the forgeable X-lobe-chat-auth XOR obfuscation mechanism:

- Remove apiKey fallback in checkAuthMethod (auth bypass vector)
- Rewrite checkAuth to use session/OIDC userId only, never trust client header
- Delete XOR encoding/decoding utilities and tests
- Delete dead keyVaults TRPC middleware (no consumers)
- Simplify createHeaderWithAuth (no longer sends XOR payload)
- Remove SECRET_XOR_KEY constant
- Remove authorizationHeader from TRPC lambda context
- Clean up CLI to only send Oidc-Auth header
- Update all affected tests

The LOBE_CHAT_AUTH_HEADER constant is retained for the async caller
(server-to-server) path which uses AES encryption via KeyVaultsGateKeeper.

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

* 🐛 fix: restore createPayloadWithKeyVaults for fetchOnClient path

The client-side model runtime (fetchOnClient) needs getProviderAuthPayload
and createPayloadWithKeyVaults to build provider SDK init params directly
in the browser. These functions are unrelated to XOR encoding.

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

* 🐛 fix: guard against null session before accessing user id

Add explicit null check before accessing session.user.id to prevent
TypeError when session is null (e.g. unauthenticated requests).

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

* 🐛 fix: add missing AgentRuntimeError import

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

* 🐛 fix: remove dead createRuntime code path causing type error

The createRuntime property was removed from checkAuth's RequestHandler
type but still referenced in the route handler, causing TS2339.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:53:07 +08:00
Innei d7e5d4645d ⬆️ chore(desktop): bump agent-browser to v0.24.0 (#13550)
* ⬆️ chore(desktop): bump agent-browser to v0.24.0

https://claude.ai/code/session_01XnRtpGn54turwVXf4MziLM

* 📝 chore: update agent-browser skill to match upstream v0.24.0

Sync the local-testing skill's agent-browser section with the upstream
SKILL.md from vercel-labs/agent-browser. Adds new commands: batch, auth
vault, semantic locators, annotated screenshots, clipboard, dialog
handling, diff, streaming, iOS simulator, dashboard, cloud providers,
and engine selection.

https://claude.ai/code/session_01XnRtpGn54turwVXf4MziLM

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-07 02:28:50 +08:00
lobehubbot 918e4a8fa1 Merge remote-tracking branch 'origin/main' into canary 2026-04-06 14:54:26 +00:00
Rdmclin2 f58015bb23 docs: clarify WeChat channel availability (#13540)
## Summary
- clarify in the channels overview that WeChat currently requires an
active subscription
- note that community edition users may not see the WeChat option in
channel settings yet
- keep the English and Chinese overview pages aligned

## Testing
- `git diff --check`

Related to #13461.
2026-04-06 22:53:44 +08:00
Zhijie He e6244aaea6 🐛 fix: fix imageGen button always switch to Nano Banaba (#13587) 2026-04-06 10:20:51 +08:00
Arvin Xu e9d43cb43f ♻️ refactor(bot): migrate Bot service to Agent Runtime Hooks framework (#13546)
* ♻️ refactor(bot): migrate Bot service to Agent Runtime Hooks framework

Migrate the last consumer (Bot/AgentBridgeService) from legacy
completionWebhook/stepWebhook/stepCallbacks dual-track pattern
to the unified hooks API. This completes LOBE-6208 Step 4.

- Enrich AgentHookEvent with step presentation + tracking data
- Enrich afterStep hook dispatch with full step context
- Merge executeWithWebhooks + executeWithInMemoryCallbacks into unified hooks
- Remove legacy triggerCompletionWebhook, triggerStepWebhook, stepCallbacks
- Remove completionWebhook/stepWebhook/webhookDelivery from params

LOBE-6675

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

* 🐛 fix(hooks): dispatch completion hooks on early-terminal return and fix totalToolCalls lag

- Add dispatchCompletionHooks in early-terminal branch of executeStep
  so onComplete hooks fire when operation is already interrupted/done/error
  between queued steps (e.g., via /stop)
- Include current step's toolsCalling in afterStep totalToolCalls so
  consumers get an accurate cumulative count instead of lagging by one step

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

*  test: update tests to match hooks-based architecture

- Rewrite executeStep tests to use hookDispatcher spies instead of
  removed registerStepCallbacks/getStepCallbacks API
- Rewrite completionWebhook tests to use hooks param and _hooks metadata
  instead of removed completionWebhook param
- Delete stepLifecycleCallbacks.test.ts (tests removed API, coverage
  now provided by HookDispatcher.test.ts + executeStep.test.ts)
- Update AgentRuntimeService.test.ts abort test to remove stepCallbacks

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

* 🐛 fix: resolve remaining CI failures from hooks migration

- Fix TS18048 errors: guard metadata access with null check in
  _stepTracking block
- Migrate remaining registerStepCallbacks usage in
  AgentRuntimeService.test.ts to hookDispatcher.dispatch spies:
  onComplete error tests and onAfterStep tool result extraction tests

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

*  test(bot): update AgentBridgeService tests for hooks-based execution

Old tests expected execAgent to NOT be called (because APP_URL check
would throw in queue mode). With hooks migration, the APP_URL check
is gone (hooks use relative URLs resolved by HookDispatcher), so
execAgent is now called. Update tests to verify hooks are passed
correctly instead.

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

*  test(bot): add hook payload compatibility tests for BotCallbackService

Add tests verifying that webhook payloads from HookDispatcher (containing
hookId/hookType fields) are correctly handled by BotCallbackService.
This validates the critical contract between the hooks framework and
the bot callback endpoint for step progress, completion, and error paths.

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

*  test: add hooks integration tests for e2e payload verification

Add integration tests that verify the full executeStep → hookDispatcher
chain produces events with all fields bot consumers depend on:

- afterStep event includes content, stepType, totalTokens, executionTimeMs
- afterStep event includes cross-step tracking (lastLLMContent, totalToolCalls)
- afterStep event includes toolsResult for tool_result phases
- onComplete fires on early-terminal states (interrupted) with lastAssistantContent
- All RenderStepParams-required fields are present and correctly typed

These tests catch payload format regressions without needing production
infrastructure (Redis, QStash, real bot platforms).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:14:56 +08:00
Arvin Xu 5b03f009ee 🐛 fix(agentDocuments): add progressive disclosure PolicyLoad mode (#13571)
---------

Co-authored-by: Arvin Xu <arvinxx@ArvindeMacBook-Pro.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by-agent: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:23:32 +08:00
Neko 25cf3bfafd 🐛 fix(userMemories): i18n for purge button (#13569) 2026-04-05 00:28:10 +08:00
Rdmclin2 3cb7206d90 feat: create new topic every 4 hours (#13570)
* feat: create new topic every  4 hours

* fix: bot topic try catch

* fix: test case
2026-04-04 23:40:04 +08:00
Rdmclin2 e364b9a516 feat: skill store add skills tab (#13568)
* feat: add skill list and mcp list

* feat: support market skill detail

* fix: market skill detail render

* feat: add task emoji

* chore: lost  setting locales

* fix: build market download url
2026-04-04 22:11:17 +08:00
Arvin Xu a7e3d198df 🐛 fix(chat-input): memoize mentionOption/slashOption to prevent freeze on paste (#13551)
* 🐛 fix(chat-input): memoize mentionOption and slashOption to prevent page freeze on paste

Stabilize mentionOption and slashOption references with useMemo/useCallback to break the
infinite re-render loop that occurs when pasting text triggers autocomplete.

Fixes LOBE-6684

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

* 🐛 fix(chat-input): trim trailing newlines from autocomplete result to prevent empty lines

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

* 🐛 fix(chat-input): skip autocomplete during IME composition to prevent interrupting Chinese input

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:09:17 +08:00
Arvin Xu 14cd81b624 feat(cli): add migrate openclaw command (#13566)
*  feat(cli): add `migrate openclaw` command for importing OpenClaw workspace

Add a new CLI command `lh migrate openclaw` that imports all files from the
OpenClaw workspace (~/.openclaw/workspace) as agent documents into the LobeHub
inbox agent. Supports --source, --agent-id, --slug, --dry-run, and --yes options.

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

* ♻️ refactor(cli): restructure migrate as directory for future providers

Refactor `migrate` command from a single file to a directory structure
(`migrate/index.ts` + `migrate/openclaw.ts`) to support future migration
sources like ChatGPT, Claude, Codex, etc.

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

* 🐛 fix(cli): remove unnecessary `as any` casts in migrate openclaw

Use proper TrpcClient type instead of casting to any. Extract
resolveInboxAgentId helper with correct typing.

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

* ♻️ refactor(cli): migrate openclaw creates a new "OpenClaw" agent by default

Instead of importing into the inbox, the default behavior now creates a
dedicated "OpenClaw" agent and imports workspace files as its documents.
Use --agent-id to import into an existing agent instead.

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

*  feat(cli): restore --agent-id and --slug options for migrate openclaw

Support three modes: --agent-id (by ID), --slug (by slug, e.g. "inbox"),
or default (create a new "OpenClaw" agent).

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

*  feat(cli): print agent URL after migrate openclaw completes

Show a clickable link (e.g. https://app.lobehub.com/agent/<id>) at the
end of the import so users can open the agent directly.

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

*  feat(cli): check login state early in migrate openclaw

Verify authentication before scanning files so users get a clear
"Run 'lh login' first" message upfront instead of after confirmation.

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

*  feat(cli): read agent name, description, avatar from OpenClaw workspace

Parse IDENTITY.md (or SOUL.md) for Name, Creature/Vibe/Description, and
Emoji fields to populate the new agent's title, description, and avatar
instead of hardcoding "OpenClaw".

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

* 💄 style(cli): show emoji + name instead of agent ID in migrate output

Display the agent avatar emoji and title throughout the migrate flow
(confirmation, creation, importing). The agent ID only appears in the
final URL.

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

* 🐛 fix(cli): exclude .venv from openclaw workspace scan

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

* 🔧 chore(cli): expand excluded dirs/files for openclaw workspace scan

Filter out IDE configs, VCS dirs, OS artifacts, dependency dirs, Python
caches, build outputs, env files, and other common non-content items.

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

* update version

*  feat(cli): use `ignore` package for gitignore-based file filtering & improve output

- Replace hardcoded EXCLUDED_NAMES set with `ignore` package (gitignore syntax)
- Respect workspace .gitignore if present, plus comprehensive default rules
- Cover all common languages/tools: Python, Ruby, Rust, Go, Java, .NET, etc.
- Improve final output: friendlier completion message with agent name + URL

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

*  test(cli): add tests for migrate openclaw command

Cover profile parsing, file filtering (gitignore + default rules),
dry-run, agent resolution (--agent-id, --slug, default create),
confirmation flow, error handling, and output formatting.

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

*  feat(cli): filter placeholder emoji and binary/database files

- Skip avatar values like (待定), _(待定)_, TBD, N/A, etc.
- Add ignore rules for database files (*.sqlite, *.db, *.mdb, etc.),
  images, media, fonts, lock files, and compiled binaries
- Runtime binary detection: check first 8KB for null bytes and skip
  binary files that slip through the extension filter
- Add tests for placeholder emoji filtering, binary skip, and db exclusion

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

*  feat(api,cli): support optional createdAt for agent document upsert

Thread an optional `createdAt` parameter through all layers:
- Model: AgentDocumentModel.create/upsert accept optional createdAt,
  set both createdAt and updatedAt on documents + agent_documents rows
- Service: UpsertDocumentParams includes createdAt
- Router: agentDocument.upsertDocument accepts optional z.date()
- CLI: migrate openclaw passes file mtime as createdAt to preserve
  original file timestamps

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

* 💄 style(cli): add npx usage hint to auth error message

Show 'npx -y @lobehub/cli login' alongside 'lh login' so users who
haven't installed the CLI globally know how to authenticate.

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

* update version

*  feat(api,cli): support optional updatedAt for agent document upsert

Add updatedAt alongside createdAt through all layers. When both are
provided, updatedAt is used independently; when only createdAt is
given, updatedAt falls back to createdAt.

CLI now passes file birthtime as createdAt and mtime as updatedAt.

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

* 🐛 fix(cli): use os.homedir() for default source & wrap file reads in try

- Replace process.env.HOME || '~' with os.homedir() so the default
  --source path resolves correctly on Windows and when HOME is unset
- Move fs.readFileSync/statSync inside the try block so a single
  unreadable file doesn't abort the entire migration

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:45:04 +08:00
Arvin Xu bd345d35a8 🐛 fix(openapi): fix response.completed output missing message, wrong tool name & id (#13555)
* 🐛 fix(openapi): fix response.completed output missing message, wrong tool name & id

Fix three bugs in extractOutputItems for the Response API:
1. Assistant message with text+tool_calls was dropped from output
2. Function call names kept internal ____-separated format instead of identifier/apiName
3. Function call IDs were off by one due to missing message item

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

*  test(openapi): exercise real ResponsesService in regression tests

Replace local reimplementation with vi.mock stubs + real class import
so the tests fail if the production extractOutputItems regresses.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:08:58 +08:00
Neko 40d0825d79 🐛 fix(agent,topic): should reset agent side panel if agent state changes (#13556) 2026-04-04 03:46:45 +08:00
Neko ea725aca9e test(agentDocuments): incorrect assertion against agent document (#13552) 2026-04-03 23:32:39 +08:00
Innei dbdbe16da9 ♻️ refactor: move skills/tools to @ mention with direct context injection (#13419)
* ♻️ refactor: move skills/tools from slash menu to @ mention with direct context injection

Separates slash menu (/) and @ mention responsibilities:
- Slash menu: only commands (compact, newTopic)
- @ mention: agents, topics, skills, tools

Replaces fake activateSkill tool-call preload messages with direct
content injection via SelectedSkillInjector/SelectedToolInjector,
preventing models from mimicking activateSkill calls.

Fixes LOBE-6048

* ♻️ refactor: skip activateSkill when skill content already injected via selected_skill_context

Fixes LOBE-6048

* ♻️ refactor: align @-mention skills/tools with context injectors and preload

Made-with: Cursor

* 🐛 fix(chat): preserve editorData across queue and home input sends

* Update home send APIs and align related tests
2026-04-03 22:09:48 +08:00
Innei 5cd4e390e3 👷 build(model-bank): align pnpm setup with packageManager (#13545)
Remove hardcoded pnpm versions in the model-bank release workflow so CI uses the repository packageManager setting and avoids pnpm version mismatch failures. Also align release commit identity with the lobehub bot account used by other release workflows.

Made-with: Cursor
2026-04-03 21:13:43 +08:00
Rdmclin2 5c17a0d652 feat: bot related common features (#13483)
* chore: remove default platform header

* fix: clean speaker tag when copy

* fix: discord client memory leak

* feat: support tool display config

* fix: test case

* fix: lint error
2026-04-03 19:58:32 +08:00
Innei ec3dd471b1 👷 build(model-bank): add release workflow (#13384)
* 👷 build(model-bank): add release workflow

* 🐛 fix(model-bank): bundle business const for publish

* Remove ModelBank CI package rewrite before publish
2026-04-03 19:35:26 +08:00
Innei 1d7a0d6bd8 👷 build(desktop): remove nightly release channel (#13480)
* 👷 build(desktop): remove nightly release channel

* 🐛 fix(database): remove invalid tool_call_id from messages inserts in tests

* 🧪 test(desktop): fix updater channel migration mocks

* ♻️ refactor(desktop): migrate update channel in bootstrap

* ♻️ refactor(desktop): extract store migrations

* 🐛 fix(desktop): use custom store migration runner

* ♻️ refactor(desktop): split store migrations into files

* update

Signed-off-by: Innei <tukon479@gmail.com>

---------

Signed-off-by: Innei <tukon479@gmail.com>
Co-authored-by: codex-514 <codex514@users.noreply.github.com>
2026-04-03 19:13:25 +08:00
Neko 71df4aa473 🐛 fix(agentDocuments): should fetch passively for agent documents (#13508) 2026-04-03 18:29:15 +08:00
renovate[bot] 48d14bfb7e chore(deps): update dependency electron to v39 [security] (#13527)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-03 17:53:21 +08:00
dependabot[bot] 74bcf41fe8 build(deps-dev): bump electron from 41.0.2 to 41.0.3 in /apps/desktop (#13525)
Bumps [electron](https://github.com/electron/electron) from 41.0.2 to 41.0.3.
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v41.0.2...v41.0.3)

---
updated-dependencies:
- dependency-name: electron
  dependency-version: 41.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-03 17:20:15 +08:00
Zhijie He 210f020092 💄 style: add wanxiang2.7 & keling ImageGen from Qwen (#13478) 2026-04-03 17:17:21 +08:00
suyua9 306691b4d7 docs: clarify WeChat channel availability 2026-04-03 17:03:11 +08:00
WangYK f531c65fbb 🐛 fix: align message sharing modal with topic sharing (#13003) 2026-04-03 16:15:07 +08:00
YuTengjing 6d742388fa 🐛 fix: hide copy link button when share visibility is private (#13537) 2026-04-03 15:42:46 +08:00
LiJian aec2d30506 ♻️ refactor: add the cronjob tools executiors (#13536)
* fix: add the cronjob tools executiors

* fix: should use mutate to refresh the cronjob when it changes && add it into backend runtime

* fix: add the lost deps

* fix: should await the delete servers
2026-04-03 15:21:32 +08:00
Rylan Cai eb086b8456 feat: support local device binding in lh agent run (#13277)
*  support device binding in lh agent run

*  align device binding tests with current behavior
2026-04-03 13:44:12 +08:00
LiJian 3dd91a04fa 🐛 fix: slove the lobehub skill cant use activator to active (#13534)
fix: slove the lobehub skill cant use activator to active
2026-04-03 12:26:38 +08:00
Rylan Cai 9264a9c66d ♻️ refactor(eval): + resume agent run (#13412)
* ♻️ refactor: support minimal execAgent resume flow

* ♻️ refactor execAgent resume to caller-owned continuation context

* 📝 fix execAgent topic metadata comment

* 🚚 revert non-essential public execAgent resume changes

* 🚚 narrow execAgent resume changes to internal service scope

* 🚚 keep execAgent resume scoped to internal service

* 📝 restore taskId in execAgent runtime appContext

*  add agent eval trajectory resume

* ♻️ route agent eval resume through workflow

* 🩹 tighten eval resume workflow semantics

* ♻️ refine agent eval resume semantics

* ♻️ simplify resume workflow dispatch

* wip: rm redundancy interfaces

* wip: trim code

* wip: remove unuse

* wip: add eval detail resume btn

* 🐛 fix: message chains

* 🐛 fix: incorrect steps & cost count

* 🐛 fix: should allow start from non-zero step

* 🐛 fix: batch resume

* 🐛 fix: import

* ♻️ restore retry visibility guard in eval case table

* 🐛 fix: should not check run status

* 🐛 fix agent eval resume test regressions

* 🐛 fix: allow retry pass@k trajectory

* 🐛 fix eval case thread messages during run

* 🐛 fix pass@k batch resume target resolution

* 🐛 fix eval resume thread state handling

* ♻️ simplify eval resume validation

* 🚑 fix lint:ts interface order

* wip: fix lint

* 🐛 enforce max steps per resumed eval thread

* 🐛 avoid topic-level max steps check for pass@k resumes
2026-04-03 12:17:57 +08:00
Arvin Xu f9f7283fec 🐛 fix(model-runtime): resolve Vertex AI $ref schema error and toolConfig incompatibility (#13524)
* 🐛 fix(model-runtime): resolve Vertex AI $ref schema error and toolConfig incompatibility

1. Dereference $ref in JSON Schema before sending to Google/Vertex AI — the memory
   tool manifest (from neko's recent refactor) uses $ref which Vertex AI rejects.
2. Skip includeServerSideToolInvocations for Vertex AI — only Google AI supports it.

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

* 🐛 fix(model-runtime): preserve sibling schema fields when resolving $ref

When a schema node has $ref plus sibling keys (e.g. description from
allOf unwrapping), the resolved definition now merges with those siblings
instead of dropping them. This preserves argument-level descriptions for
fields like timeIntent, improving tool-call quality.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:06:27 +08:00
Arvin Xu 25e851b359 🔒 fix: sanitize HTML artifact content and sandbox iframe to prevent XSS-to-RCE (#13529)
* 🔒 fix: sanitize HTML artifact content and sandbox iframe to prevent XSS-to-RCE

- Add sanitizeHTMLContent() using DOMPurify to strip dangerous tags (script, embed, object, etc.) and all on* event handler attributes
- Add sandbox="" attribute to HTML artifact iframe to block all script execution and parent frame access
- Replace doc.write() with srcDoc for cleaner rendering
- Extract shared FORBID_EVENT_HANDLERS list to DRY up SVG and HTML sanitization

Ref: GHSA-xq4x-622m-q8fq

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

* 🐛 fix: correct import path from @lobehub/utils to @lobechat/utils

The package name is @lobechat/utils, not @lobehub/utils. This caused a build failure in Electron desktop app.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 12:05:54 +08:00
Tsuki f2a95f9ae6 🔨 chore: add Task store — service layer, selectors, and 4 slices (#13500)
 feat: add Task store with service layer, selectors, and 4 slices (LOBE-6597)

Implement frontend Task system state management:
- Service layer wrapping all TRPC task/brief endpoints
- List slice: SWR fetch by agent, list/kanban view mode
- Detail slice: CRUD with optimistic updates, immer reducer
- Lifecycle slice: run/pause/cancel/complete/resume, heartbeat ping
- Config slice: checkpoint, review, brief ops (model config deferred to LOBE-6634)
- Selectors: list (kanban columns, display status), detail (field accessors, operation guards), activity (sorted/filtered)
- Types derived from TRPC inference (TaskListItem, TaskStatus)
- 118 tests across 9 test files

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:57:46 +08:00
Chris Z 4e0bcf1c4d 📝 docs: sync contributing guide branding (#13533) 2026-04-03 11:57:06 +08:00
Arvin Xu bbcb3304dc 📝 docs: add SECURITY.md with vulnerability reporting policy (#13528)
📝 docs: add SECURITY.md with vulnerability reporting policy

Define supported versions, reporting guidelines, response timeline, scope (in/out), and disclosure policy for security vulnerabilities.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 10:48:19 +08:00
Tsuki 3b316e3a4e 🐛 fix(task): include config in getTaskDetail response (#13521) 2026-04-03 09:49:35 +08:00
Arvin Xu 251e12c7d1 feat(editor): implement AI input auto-completion (#13458)
*  feat: implement AI input auto-completion with ReactAutoCompletePlugin

Adds GitHub Copilot-style ghost text completion to the chat input,
powered by a configurable system agent (disabled by default).

Key changes:
- Add `inputCompletion` system agent config (type, default, selector, i18n)
- Create `chainInputCompletion` prompt chain (V2 few-shot, benchmarked)
- Mount `ReactAutoCompletePlugin` in InputEditor when enabled
- Wire `getMessages` through ChatInput store for conversation context
- Add settings UI in Service Model page with enable toggle

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

*  test: update systemAgent snapshot for inputCompletion

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

* 🐛 fix: restrict auto-complete context to visible user/assistant turns

Filter getMessages to use displayMessages (active visible thread)
instead of dbMessages (raw DB records including tool messages and
inactive branches). Also limit to last 10 user/assistant turns to
keep payload small and relevant.

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

*  feat: enable input completion by default

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

* ️ perf: use non-streaming for input completion requests

Autocomplete needs the full result before displaying ghost text,
so streaming adds unnecessary overhead. Setting stream: false
reduces latency by avoiding SSE chunking.

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

* 🐛 fix: revert stream:false for input completion

fetchPresetTaskResult uses fetchSSE internally which cannot handle
non-streaming JSON responses, causing the editor to freeze after
receiving the completion result.

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

* ️ perf: use non-streaming for input completion requests

Autocomplete waits for the full result before displaying ghost text.
fetchSSE handles non-streaming responses via its fallback path
(response.clone().text()), avoiding SSE chunking overhead.

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

* ️ perf: skip contextEngineering for input completion

Call getChatCompletion directly instead of fetchPresetTaskResult
to avoid triggering agentDocument.getDocuments on every autocomplete
request. Input completion only needs a simple LLM call with the
prompt chain, not the full context engineering pipeline.

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

* ♻️ refactor: revert to fetchPresetTaskResult for input completion

Use the standard contextEngineering pipeline. The agentDocument
overhead will be addressed separately.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 02:00:18 +08:00
Arvin Xu 3b13a1b6d4 🐛 fix: expose tool discovery config to context engine and inject available tools (#13417)
♻️ refactor: extract server tool discovery config builder
2026-04-03 01:54:22 +08:00
Arvin Xu 126db9612f 🐛 fix: stream tool call arguments incrementally in Response API (#13506)
* 🐛 fix: stream tool call arguments incrementally in Response API

The tool_calling stream chunks contain accumulated arguments (not
deltas), but the Response API was treating each chunk as a complete
independent output_item — creating a new lifecycle (added → delta →
done) per token and incrementing output_index to 90+.

Fix: track active tool calls by call_id and compute true incremental
deltas by slicing off previously-seen content. Each tool call now
gets a single stable output_item with proper streaming deltas,
finalized only when the stream ends or tool execution begins.

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

* 🐛 fix: clear stale tool-call state on LLM stream retry

When call_llm retries after a failed attempt, activeToolCalls may
contain entries from the failed stream that never received a
tool_end. Without clearing, finishActiveToolCalls would emit
phantom function_call done events and misalign output_index for
the successful attempt. Reset the map on stream_retry.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 01:46:14 +08:00
Arvin Xu dd7819b1be 🔨 chore(cli): register task command and add kanban board view (#13511)
*  feat(cli): register task command and add kanban board view

Register the missing `registerTaskCommand` in program.ts so `lh task` commands are accessible. Add `--board` flag to `task list` that renders a kanban-style view grouping tasks by status columns (backlog, running, paused, completed, etc.) with color-coded borders.

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

* update

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:54:12 +08:00
Arvin Xu 3415df3715 ♻️ refactor: remove chat-plugin-sdk (#13512)
* ♻️ refactor: remove @lobehub/chat-plugin-sdk dependency

Plugins have been deprecated. This removes the SDK entirely:

- Define built-in ToolManifest, ToolManifestSettings, ToolErrorType types
- Delete src/features/PluginsUI/ (plugin iframe rendering)
- Delete src/store/tool/slices/oldStore/ (deprecated plugin store)
- Delete src/server/services/pluginGateway/ (plugin gateway)
- Delete src/app/(backend)/webapi/plugin/gateway/ (plugin API route)
- Migrate all ~50 files from SDK imports to @lobechat/types
- Remove @lobehub/chat-plugin-sdk, @lobehub/chat-plugins-gateway deps
- Remove @swagger-api/apidom-reference override and patch

Fixes LOBE-6655

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

* 🐛 fix: add missing getInstalledPlugins mock in customPlugin test

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

* 🔧 chore: increase Vercel build memory limit to 8192MB

The 6144MB limit was causing OOM during Vite SPA chunk rendering.
Aligned with other build commands that already use 8192MB.

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

* ♻️ refactor: unify default tool type to builtin and fix CustomRender

- Remove `invokeDefaultTypePlugin` — default type now falls through to builtin in both server and client execution paths
- Fix `CustomRender` to actually render builtin tool components via `getBuiltinRender` instead of always returning null
- Increase SPA build memory limit from 7168MB to 8192MB to fix OOM

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

* ♻️ refactor: remove legacy plugin gateway and type-specific invocations

- Delete `runPluginApi`, `internal_callPluginApi`, `invokeMarkdownTypePlugin`, `invokeStandaloneTypePlugin`
- Remove plugin gateway endpoint (`/webapi/plugin/gateway`) from URL config
- Remove special `builtin → default` runtimeType mapping in plugin model
- Clean up unused imports and related tests

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

* 🐛 fix: add 'builtin' to runtimeType union to fix type error

Use ToolManifestType instead of inline union for runtimeType fields
so that 'builtin' is included as a valid type.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:46:19 +08:00
YuTengjing 0dc8930750 🔨 chore: update team assignment and fix prompt formatting (#13520) 2026-04-03 00:40:45 +08:00
Zhijie He 9f2d7daa17 💄 style: add more videoGen provider support (#13428)
Co-authored-by: YuTengjing <ytj2713151713@gmail.com>
2026-04-03 00:37:15 +08:00
YuTengjing 249483c3e1 🔨 chore: skip PR welcome comment for maintainer (#13519) 2026-04-02 23:42:28 +08:00
YuTengjing eb2731183f 🔨 chore(i18n): remove unused suspectedReason locale key (#13517) 2026-04-02 22:21:00 +08:00
YuTengjing d9c50b97f8 🐛 fix(database): apply injectSearchSettings consistently for unmodified builtin models (#13514) 2026-04-02 21:48:02 +08:00
Innei 8b445a1dc3 refactor: consolidate imports and add electron styling to theme/lang buttons (#13495)
🐛 fix(electron): add nodrag to userinfo dropdown menus

Add `-webkit-app-region: no-drag` to ThemeButton and LangButton
dropdown popups to prevent Electron from capturing click events
when the dropdown appears in the titlebar drag region.

https://claude.ai/code/session_01K6FLLJ4PMhKWqbRmrGEZkS

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-02 20:27:21 +08:00
Arvin Xu be99aaebd0 ♻️ refactor: unify tool content formatting with ComputerRuntime and shared UI (#13470)
* ♻️ refactor: unify tool content formatting with ComputerRuntime and shared UI components

Introduce `@lobechat/tool-runtime` with `ComputerRuntime` abstract class to ensure consistent
content formatting (via `formatCommandResult`, `formatFileContent`, etc.) across local-system,
cloud-sandbox, and skills packages. Create `@lobechat/shared-tool-ui` to share Render and
Inspector components, eliminating duplicated UI code across tool packages.

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

* 🐛 fix: address review issues — state mapping for renders and IPC param denormalization

- Add legacy state field mappings in local-system executor (listResults, fileContent,
  searchResults) for backward compatibility with existing render components
- Add denormalizeParams in LocalSystemExecutionRuntime to map ComputerRuntime params
  back to IPC-expected field names (file_path, items, shell_id, etc.)
- Fix i18n type casting for dynamic translation keys in shared-tool-ui inspectors

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

* ♻️ refactor: inject render capabilities via context, unify state shape for cross-package render reuse

- Add ToolRenderContext with injectable capabilities (openFile, openFolder,
  isLoading, displayRelativePath) to shared-tool-ui
- Update local-system render components (ReadLocalFile, ListFiles, SearchFiles,
  MoveLocalFiles, FileItem) to use context instead of direct Electron imports
- Enrich ReadFileState with render-compatible fields (filename, fileType,
  charCount, loc, totalCharCount)
- Cloud-sandbox now fully reuses local-system renders — renders degrade
  gracefully when capabilities are not provided (no open file buttons in sandbox)
- Remove executor-level state mapping hacks

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

* 🐛 fix: fix sandbox render bugs — SearchFiles, GrepContent, MoveFiles, GlobFiles

- SearchFiles: ensure results is always an array (not object passthrough)
- GrepContent: update formatGrepResults to support object matches
  `{path, content, lineNumber}` alongside string matches
- MoveFiles: render now handles both IPC format (items/oldPath/newPath) and
  ComputerRuntime format (operations/source/destination)
- GlobFiles: fallback totalCount to files.length when API returns 0

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

* 🐛 fix: unify SearchLocalFiles inspector with shared factory

SearchLocalFiles inspector now supports all keyword field variants
(keyword, keywords, query) and reads from unified state (results/totalCount).

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

* 🐛 fix: handle missing path in grep matches to avoid undefined display

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

* 🐛 fix: improve render field compatibility for sandbox

- EditLocalFile render: support both file_path (IPC) and path (sandbox) args
- SearchFiles render: support keyword/keywords/query arg variants
- FileItem: derive name from path when not provided

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

* 🐛 fix: add missing cloud-sandbox i18n key for noResults

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:42:45 +08:00
Arvin Xu f96edd56fb 🔨 chore(task): add task.groupList API for kanban view (#13507)
*  feat(task): add task.groupList API for kanban board view

Support querying tasks grouped by status in a single request, with per-group independent pagination. Returns array structure with hasMore/limit/offset for each group.

LOBE-6589

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

* 🐛 fix(task): bound groupList groups and statuses array size

Prevent query storms from oversized requests by capping groups to 20
and statuses per group to 10.

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

* 🔧 chore(task): reduce groupList max groups from 20 to 10

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 19:38:12 +08:00
Arvin Xu 074de037cd 🔨 chore(task): add generic updateTaskConfig for safe config merging (#13502)
*  feat(task): add generic updateTaskConfig method for safe config merging

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

*  test: add updateTaskConfig tests and use deep merge

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:41:23 +08:00
YuTengjing 297c884b88 🐛 fix(model-runtime): ensure before* hook errors trigger on*Error handlers (#13496) 2026-04-02 16:12:15 +08:00
Arvin Xu 04b32e3152 🔨 chore: add agent avatar data to brief list API (#13489)
*  feat: add agent avatar data to brief list API

Enrich brief list and listUnresolved endpoints with agent avatars
from the task tree. For each brief's associated task, walks up to
find the root task, then collects all agents (assignee + creator)
across the full tree.

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

*  test: add BriefService and unit tests for brief agent enrichment

Extract enrichBriefsWithAgents logic into BriefService for reuse.
Add unit tests for TaskModel.getTreeAgentIdsForTaskIds,
AgentModel.getAgentAvatarsByIds, and BriefService.

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

* 🔒 fix: scope recursive CTE to current user in getTreeAgentIdsForTaskIds

Add created_by_user_id filter to both the ancestor walk-up and
descendant walk-down recursive legs to prevent cross-tenant tree
traversal.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:41:08 +08:00
Zhijie He bbd09d6785 💄 style: add glm-5v-turbo support (#13487) 2026-04-02 14:39:07 +08:00
Neko 6a2ca59592 ♻️ refacor(database,userMemories): rework of user memory search (#13453) 2026-04-02 14:13:06 +08:00
LiJian 8aeb47eda3 🐛 fix: should clean up tools when the old tools is deprecated (#13492)
* fix: should clean up tools when the old tools is deprecated

* fixshould try delete incetent first
2026-04-02 12:39:51 +08:00
LiJian da1bccfd20 🐛 fix: slove the creds detail page cant modify the kv creds (#13473)
fix: slove the creds detail page cant modify the kv creds
2026-04-02 12:36:58 +08:00
LobeHub Bot 03c7a3fd42 🌐 chore: translate non-English comments to English in database messages tests (#13491)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 11:57:33 +08:00
Innei be8903e707 refactor: Extract web onboarding runtime to builtin package (#13446)
*  feat: add server runtime for lobe-web-onboarding tool

Implement server-side execution runtime for onboarding agent tools, enabling them to run in server environment without client-side dependencies.

https://claude.ai/code/session_01Das8jWLe5ibm6vJUFGu6Xb

* ♻️ refactor: deduplicate web onboarding utils by exporting from package

Move formatWebOnboardingStateMessage, createDocumentReadResult, createWebOnboardingToolResult, and EMPTY_DOCUMENT_MESSAGES into @lobechat/builtin-tool-web-onboarding/utils and update all consumers to import from there.

https://claude.ai/code/session_01Das8jWLe5ibm6vJUFGu6Xb

* 🔧 fix: sort imports in webOnboardingToolResult test

https://claude.ai/code/session_01Das8jWLe5ibm6vJUFGu6Xb

* 🔧 fix: sort imports with eslint --fix

https://claude.ai/code/session_01Das8jWLe5ibm6vJUFGu6Xb

* 🐛 fix: add missing properties to OnboardingStateContext interface

https://claude.ai/code/session_01Das8jWLe5ibm6vJUFGu6Xb

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-02 02:19:26 +08:00
Innei d8534c2966 🐛 fix(chat-input): preserve fullscreen editor state and send behavior (#13481)
* 🐛 fix(chat-input): preserve editor state and behavior in fullscreen

Keep chat input content and interaction consistent when toggling fullscreen by restoring editor JSON state, adjusting Enter/Cmd+Enter semantics, and rendering fullscreen input in the desktop layout container.

Made-with: Cursor

* 🐛 fix(chat-input): auto-collapse fullscreen after send

Automatically exit fullscreen after sending from chat input so users do not need a second manual collapse action, and clear saved editor snapshot to avoid stale restore.

Made-with: Cursor
2026-04-02 02:13:15 +08:00
Innei d25db6e6f8 🐛 fix(conversation): hide loading placeholder when AI generation is stopped (#13482)
🐛 fix: hide loading placeholder when AI generation is stopped

Only render ContentLoading for LOADING_FLAT messages when actively generating.
Previously, stopping AI mid-generation left the "..." placeholder visible
with a loading animation even though nothing was being generated.
2026-04-02 01:44:45 +08:00
YuTengjing df6d8f19f8 🔒 fix: upgrade nodemailer to v8 to fix SMTP command injection (#13479) 2026-04-01 21:51:32 +08:00
Arvin Xu 8af28a778b 🐛 fix(fetch-sse): stop injecting contextBody into structured provider errors (#13477)
* 🐛 fix(fetch-sse): stop injecting contextBody into structured provider errors

Structured errors (ProviderBizError etc.) already contain complete context.
Spreading contextBody into their body overwrites fields like `provider` and
pollutes the error structure that downstream renderers depend on.

Fixes #13476

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

*  test(fetch-sse): add regression test for structured error body pollution

Ensures structured provider errors (e.g. ProviderBizError) are passed through
unchanged without contextBody injection, and that contextBody is only applied
to unknown/unstructured errors.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 21:24:01 +08:00
Innei 6ecae1bbd1 ♻️ refactor: gate agent onboarding with dedicated business flag (#13472)
* ♻️ refactor: gate agent onboarding with dedicated business flag

Made-with: Cursor

* 🗑️ chore(migrations): remove agent onboarding column from users table

Signed-off-by: Innei <tukon479@gmail.com>

*  feat(onboarding): enable agent onboarding based on environment and add redirect to classic onboarding

- Updated AGENT_ONBOARDING_ENABLED to be true in development mode.
- Introduced RedirectToClassicOnboarding component to handle navigation to classic onboarding.
- Simplified ClassicOnboardingPage by removing the mode switch button for non-development environments.
- Adjusted OnBoardingContainer to conditionally render the skip onboarding button based on the current route.

This change enhances the onboarding experience by ensuring that the agent onboarding feature is only available in development, while also improving navigation for users.

Signed-off-by: Innei <tukon479@gmail.com>

* 🐛 fix(test): inline emoji-mart and @lobehub/* deps in Vitest to fix ESM JSON import error

Widen server.deps.inline to include `emoji-mart` and all `@lobehub/*`
packages so their transitive `@emoji-mart/data` import (a .json main
entry) goes through Vite's transform pipeline instead of Node's native
ESM loader, which requires `with { type: "json" }`.

---------

Signed-off-by: Innei <tukon479@gmail.com>
2026-04-01 19:38:14 +08:00
lobehubbot 11318f8ab9 🔖 chore(release): release version v2.1.47 [skip ci] 2026-03-27 08:07:22 +00:00
1593 changed files with 79993 additions and 27123 deletions
+298
View File
@@ -0,0 +1,298 @@
---
name: bot
description: 'Bot platform architecture (Discord, Slack, Telegram, Feishu/Lark, QQ, WeChat). Use when working on inbound webhooks, Chat SDK message routing, agent execution from chat platforms, queue-mode callbacks, gateway lifecycle (websocket/polling), bot provider CRUD/credentials, or platform-specific clients/adapters/schemas. Triggers on bot, channel, webhook, mention, Chat SDK, agent bot provider, gateway, bot-callback, qstash bot.'
---
# Bot System
> **Last updated: 2026-04-08.** Implementation evolves quickly — this doc is a map, not the source of truth. Always read the key files below to verify behavior, especially per-platform quirks. Update this doc when the architecture changes.
LobeChat agents can answer inside external chat platforms. Inbound messages flow through the Chat SDK (`chat` npm package), get routed to the right agent by `(platform, applicationId)`, executed via `AiAgentService`, and replied back through a per-platform `PlatformClient`. There are **two execution modes** (in-memory vs queue/QStash) and **three connection modes** (`webhook`, `websocket`, `polling`).
## Supported Platforms
| Platform | id | Default mode | Markdown | Edit | Notes |
| -------- | ---------- | ------------------------------- | ----------------- | ------ | -------------------------------------------------------------------------------------- |
| Discord | `discord` | `websocket` | yes | yes | Persistent gateway via Chat SDK adapter; reaction-thread quirks; native slash commands |
| Slack | `slack` | `websocket` (Socket Mode) | yes (mrkdwn) | yes | Multi-mode — user can pick `webhook` per provider |
| Telegram | `telegram` | `webhook` | yes (HTML) | yes | `setMyCommands` menu via `registerBotCommands` |
| Feishu | `feishu` | `websocket` (Lark SDK WSClient) | **no** (stripped) | yes | Multi-mode; shared client with Lark |
| Lark | `lark` | `websocket` | **no** | yes | Same client/schema as Feishu, different domain |
| QQ | `qq` | `websocket` | **no** | **no** | All replies are final-only |
| WeChat | `wechat` | `polling` (iLink long-poll) | **no** | **no** | 10-minute gateway window |
`supportsMarkdown=false` ⇒ outbound markdown is stripped to plain text via `stripMarkdown` and the AI is told not to use markdown. `supportsMessageEdit=false` ⇒ no progress edits — only the final reply is sent.
**Multi-mode connection** — Slack/Feishu/Lark/QQ shipped as websocket but support `webhook` per-provider via `settings.connectionMode`. Legacy rows without that field stay on `webhook` (see `LEGACY_WEBHOOK_PLATFORMS` in `platforms/utils.ts`) — **never add new platforms to that list**.
## Inbound Flow (one webhook → reply)
```
Platform server
│ POST /api/agent/webhooks/[platform]/[appId]
route.ts ── catch-all `[[...appId]]` route
BotMessageRouter (singleton)
│ • lazy-loads bot per `platform:applicationId`
│ • merges schema defaults + provider.settings (mergeWithDefaults)
│ • builds Chat SDK Chat<any> with createIoRedisState (if Redis available)
│ • registerHandlers: onNewMention / onSubscribedMessage / onNewMessage(/.dm)
│ • registerCommands: /new (reset topic), /stop (interrupt)
chatBot.webhooks[platform](req) ← Chat SDK parses → fires events
AgentBridgeService.handleMention / handleSubscribedMessage
│ • activeThreads guard (no duplicate runs per thread)
│ • adds 👀 reaction (eyes), startTyping
│ • merges debounced/queued skipped messages (mergeSkippedMessages)
│ • extractFiles (buffer → fetchData → url)
│ • formatPrompt (sanitize mention + speaker tag + referenced_message)
├── In-memory mode ──► AiAgentService.execAgent({ stepCallbacks })
│ → onAfterStep edits progress message live
│ → onComplete edits final reply, splits via splitMessage(charLimit)
└── Queue mode (isQueueAgentRuntimeEnabled) ──► execAgent({ stepWebhook, completionWebhook, webhookDelivery: 'qstash' })
→ returns immediately, callbacks land at /api/agent/webhooks/bot-callback
```
The router caches loaded bots in memory. Cache is **invalidated** by `BotMessageRouter.invalidateBot(platform, appId)` whenever the TRPC `update`/`delete` mutations run, so new credentials/settings take effect on the next webhook.
## Execution Modes
### In-memory (default)
`AgentBridgeService.executeWithInMemoryCallbacks` wraps `execAgent` with `stepCallbacks`. Lives in one process — Promise-based wait, 30-min timeout, edits the same `progressMessage` after every step. Topic title is summarized inline via `SystemAgentService`.
### Queue (`isQueueAgentRuntimeEnabled`)
`AgentBridgeService.executeWithWebhooks`:
1. Posts the `renderStart` placeholder, captures `progressMessageId`.
2. Calls `execAgent` with `stepWebhook` and `completionWebhook` pointing at `${INTERNAL_APP_URL ?? APP_URL}/api/agent/webhooks/bot-callback`, plus `webhookDelivery: 'qstash'`.
3. Returns immediately; the bridge `finally` block keeps the active-thread marker held until the `completion` callback fires.
`/api/agent/webhooks/bot-callback/route.ts` verifies the QStash signature and hands off to `BotCallbackService.handleCallback`:
- `type: 'step'``handleStep` re-renders `renderStepProgress`, edits `progressMessageId` (skipped if `displayToolCalls=false` or platform `supportsMessageEdit=false`).
- `type: 'completion'``handleCompletion` writes the final reply (or error/interrupted message), removes the 👀 reaction, clears active-thread tracker, fires async `summarizeTopicTitle`.
`BotCallbackService.createMessenger` reloads provider + credentials from DB and rebuilds a `PlatformClient` per call (no in-memory state).
## Commands
Defined in `BotMessageRouter.buildCommands` and registered via two paths:
- **Native slash commands** (Slack/Discord): `bot.onSlashCommand('/<name>', ...)`
- **Text-based fallback** (Telegram/Feishu/QQ/Lark/WeChat): `bot.onNewMessage(/^\/(new|stop)(\s|$|@)/, ...)` plus a per-mention `tryDispatch` so commands work even before subscribe.
Built-in commands:
- `/new` — clears `topicId` in thread state, next message starts a fresh topic.
- `/stop` — interrupts the active execution (calls `AiAgentService.interruptTask` if `operationId` is known; otherwise queues a deferred stop via `requestStop`/`pendingStopThreads`, also aborts the startup phase via `startupControllers`).
To add a command, append to `buildCommands` — it auto-registers everywhere; on Telegram it also surfaces in the `/` menu via `client.registerBotCommands``setMyCommands`.
## Active-thread State (statics on `AgentBridgeService`)
- `activeThreads: Set<threadId>` — prevents duplicate runs per thread (must guard before stale-topic check, otherwise concurrent messages can drop).
- `activeOperations: Map<threadId, operationId>` — needed by `/stop` once `execAgent` returns.
- `startupControllers: Map<threadId, AbortController>` — cancels pre-`operationId` work (topic/tool prep).
- `pendingStopThreads: Set<threadId>``/stop` arrived before `operationId` existed; consumed once available.
In **queue mode**, the bridge `finally` skips cleanup so the marker persists until `BotCallbackService.handleCompletion` calls `clearActiveThread`.
## Topic Lifecycle in Threads
- `handleMention` always treats the message as the start of a new conversation.
- `handleSubscribedMessage` reads `topicId` from `thread.state`. If the topic is stale (`> 4 hours` since `updatedAt`), state is cleared and it retries as a fresh mention.
- If `execAgent` fails with a Postgres FK violation on `topic_id` (cached topic was deleted), the bridge clears state and retries as a mention.
- `subscribe()` is gated by `client.shouldSubscribe(threadId)` — Discord top-level channels return `false` so we don't follow up there.
## Attachments
`AgentBridgeService.extractFiles` resolves attachments in priority order:
1. `att.buffer` — already downloaded by the adapter (WeChat/Feishu inbound).
2. `att.fetchData()` — adapter-provided lazy download with auth (Telegram, Slack, Feishu history). **Required** when URLs are token-protected — naive `fetch(url)` later in `ingestAttachment.ts` has no credentials.
3. `att.url` — public CDN fallback (Discord, public QQ).
`inferMimeType` / `inferName` patch Telegram-style `photo` payloads (no `mimeType`/`name` from Bot API → defaults to `image/jpeg`) so vision models actually see them. Quoted-message attachments are also pulled from `raw.referenced_message.attachments` (Discord).
## Concurrency
`settings.concurrency` is `'queue'` or `'debounce'`:
- `debounce` → Chat SDK debounces inbound messages by `debounceMs`; `mergeSkippedMessages` joins skipped texts/attachments into the current message before handing to the agent.
- `queue` → Chat SDK serializes per-thread; the bridge's own `activeThreads` set is still required because in queue mode the SDK lock releases before the agent finishes.
## Gateway (persistent platforms)
Webhook platforms run fine in serverless functions. Persistent platforms (`websocket`, `polling`) need a long-running listener — that's the **gateway**.
**`GatewayService.startClient(platform, appId, userId)`** (`src/server/services/gateway/index.ts`):
- On Vercel + persistent mode → `BotConnectQueue.push` (Redis hash) and mark runtime status `queued`. The cron picks it up.
- On Vercel + webhook mode → start the client inline (one HTTP call).
- Off-Vercel → `GatewayManager` singleton holds long-lived clients in process.
**`GET /api/agent/gateway/route.ts`** (cron, `Bearer ${CRON_SECRET}`):
- Iterates registered platforms and starts every enabled persistent provider with `durationMs = 10min`, then in `after(...)` polls `BotConnectQueue` every 30s for new connect requests, until the window expires.
- `getEffectiveConnectionMode(platform, settings)` is the only place that resolves per-provider mode — respect it everywhere.
**`POST /api/agent/gateway/start/route.ts`** is the non-Vercel `ensureRunning` entry point (`Bearer ${KEY_VAULTS_SECRET}`).
**Runtime status** is stored in Redis at `bot:runtime-status:platform:appId` with TTL ≈ `durationMs + 60s`. States: `starting | connected | disconnected | failed | queued`. Updated by each `PlatformClient.start/stop` and by the gateway service.
## Platform Definitions
Each platform exposes a `PlatformDefinition` registered in `platforms/index.ts`:
```ts
{
id: 'discord',
name: 'Discord',
connectionMode: 'websocket', // recommended default
schema: FieldSchema[], // applicationId + credentials + settings
clientFactory: new DiscordClientFactory(),
supportsMarkdown?: boolean, // default true
supportsMessageEdit?: boolean, // default true
documentation?: { portalUrl, setupGuideUrl },
}
```
`schema` drives both server validation (`mergeWithDefaults`, `extractDefaults`) **and** the auto-generated UI form. Top-level keys `applicationId` / `credentials` / `settings` map to DB columns. Common settings fields live in `platforms/const.ts` (`displayToolCallsField`, `serverIdField`, `userIdField`).
Each platform implements `PlatformClient` (see `platforms/types.ts`):
- Lifecycle: `start(opts?)`, `stop()`
- Inbound: `createAdapter()` → Chat SDK adapter map
- Outbound: `getMessenger(platformThreadId)``{ createMessage, editMessage, removeReaction, triggerTyping, updateThreadName? }`
- Formatting: `formatMarkdown?`, `formatReply?` (usage-stats footer when `showUsageStats`)
- Helpers: `extractChatId`, `parseMessageId`, `sanitizeUserInput`, `shouldSubscribe`, `resolveReactionThreadId`
- Optional patches: `applyChatPatches(chatBot)` (Discord uses this for `forwardedInteractions` + `threadRecovery`)
- Optional menu: `registerBotCommands(commands)` (Telegram `setMyCommands`)
`ClientFactory.validateCredentials` is called from the TRPC `testConnection` mutation — implement it to hit the platform API and return useful per-field errors.
## Database
**Schema** (`packages/database/src/schemas/agentBotProvider.ts`):
```ts
agent_bot_providers (
id uuid pk,
agent_id text fk agents.id (cascade),
user_id text fk users.id (cascade),
platform varchar(50), // 'discord' | 'slack' | …
application_id varchar(255),
credentials text, // KeyVaults-encrypted JSON
settings jsonb default '{}',
enabled boolean default true,
timestamps
)
unique (platform, application_id)
```
**Model** (`packages/database/src/models/agentBotProvider.ts`):
- User-scoped: `create / update / delete / query / findById / findByAgentId / findEnabledByApplicationId`. Credentials are encrypted/decrypted via the injected `KeyVaultsGateKeeper`.
- Static (system-wide): `findByPlatformAndAppId`, `findEnabledByPlatform` — used by webhook routing & gateway sync, since they don't have a user context yet.
**TRPC router** (`src/server/routers/lambda/agentBotProvider.ts`):
| Procedure | Notes | |
| -------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------ |
| `listPlatforms` | Returns `SerializedPlatformDefinition[]` (no `clientFactory`) | |
| `create` / `update` / `delete` | Calls `BotMessageRouter.invalidateBot` + `GatewayService.stopClient` so changes take effect | |
| `list` / `getByAgentId` / `getRuntimeStatus` | Decorate rows with Redis runtime status | |
| `connectBot` | Returns \`{ status: 'started' | 'queued' }\` |
| `testConnection` | Calls `clientFactory.validateCredentials` | |
| `wechatGetQrCode` / `wechatPollQrStatus` | iLink onboarding flow | |
Client service: `src/services/agentBotProvider.ts`. Store actions: `src/store/agent/slices/bot/action.ts`. UI: `src/routes/(main)/agent/channel/{list,detail}` — settings form is auto-generated from each platform's `schema`.
## Reply Templates
`src/server/services/bot/replyTemplate.ts` exports `renderStart`, `renderStepProgress`, `renderFinalReply`, `renderError`, `renderStopped`, `splitMessage`. Step progress carries elapsed time, last LLM content, last tools, totals; final reply uses `client.formatMarkdown` then `client.formatReply` (which optionally appends `formatUsageStats`). `splitMessage(text, charLimit)` chunks at paragraph → line → hard cut.
`src/server/services/bot/ackPhrases/` provides randomized ack phrases.
## Key Files
```plaintext
Webhook routes:
src/app/(backend)/api/agent/webhooks/[platform]/[[...appId]]/route.ts — inbound catch-all
src/app/(backend)/api/agent/webhooks/bot-callback/route.ts — qstash bot callback
src/app/(backend)/api/agent/gateway/route.ts — cron gateway (10min window)
src/app/(backend)/api/agent/gateway/start/route.ts — non-Vercel ensureRunning
Bot service:
src/server/services/bot/index.ts — barrel
src/server/services/bot/BotMessageRouter.ts — lazy bot loading + handler registration + commands
src/server/services/bot/AgentBridgeService.ts — Chat SDK ↔ AiAgentService bridge, both exec modes
src/server/services/bot/BotCallbackService.ts — qstash callback handler
src/server/services/bot/formatPrompt.ts — speaker tag + referenced_message + sanitize
src/server/services/bot/replyTemplate.ts — render*/splitMessage
src/server/services/bot/ackPhrases/ — randomized acks
src/server/services/bot/__tests__/ — unit tests for the above
Platform abstraction:
src/server/services/bot/platforms/index.ts — registry singleton + exports
src/server/services/bot/platforms/types.ts — PlatformClient/Definition/FieldSchema/ClientFactory
src/server/services/bot/platforms/registry.ts — PlatformRegistry class
src/server/services/bot/platforms/utils.ts — mergeWithDefaults, getEffectiveConnectionMode, formatUsageStats, runtimeKey
src/server/services/bot/platforms/const.ts — shared FieldSchema fragments (displayToolCalls, serverId, userId)
src/server/services/bot/platforms/stripMarkdown.ts — used by no-markdown platforms
Per-platform (each ships definition.ts, schema.ts, client.ts, const.ts, protocol-spec.md):
src/server/services/bot/platforms/discord/ — websocket gateway + chat patches
src/server/services/bot/platforms/slack/ — multi-mode (Socket Mode / webhook), markdownToMrkdwn
src/server/services/bot/platforms/telegram/ — webhook, markdownToHTML, registerBotCommands
src/server/services/bot/platforms/feishu/ — feishu + lark share client/schema (definitions/{feishu,lark,shared}.ts)
src/server/services/bot/platforms/qq/ — websocket, no markdown, no edit
src/server/services/bot/platforms/wechat/ — long-poll, no markdown, no edit
Gateway:
src/server/services/gateway/index.ts — GatewayService (Vercel-aware startClient/stopClient)
src/server/services/gateway/GatewayManager.ts — long-running client registry (non-Vercel)
src/server/services/gateway/botConnectQueue.ts — Redis hash queue with TTL
src/server/services/gateway/runtimeStatus.ts — Redis bot:runtime-status keys
Database:
packages/database/src/schemas/agentBotProvider.ts — agent_bot_providers table
packages/database/src/models/agentBotProvider.ts — encrypted CRUD + system-wide finders
TRPC + client:
src/server/routers/lambda/agentBotProvider.ts — TRPC router
src/services/agentBotProvider.ts — client wrapper
src/store/agent/slices/bot/action.ts — Zustand actions
UI:
src/routes/(main)/agent/channel/list.tsx — channel list
src/routes/(main)/agent/channel/detail/ — auto-generated form (Header/Body/Footer)
src/routes/(main)/agent/channel/const.ts — platform icons
Types & runtime status:
src/types/botRuntimeStatus.ts — BOT_RUNTIME_STATUSES enum + snapshot type
```
## Adding a New Platform
1. Create `src/server/services/bot/platforms/<id>/`:
- `definition.ts``PlatformDefinition` registered in `platforms/index.ts`
- `schema.ts``FieldSchema[]` (`applicationId` + `credentials` + `settings`); reuse fragments from `../const.ts`
- `client.ts``class XClientFactory extends ClientFactory` returning a `PlatformClient` (lifecycle + adapter + messenger + helpers)
- `const.ts``DEFAULT_X_CONNECTION_MODE`, history limits, etc.
- `protocol-spec.md` — protocol notes (every existing platform has one)
2. Pick the right `connectionMode` — webhook is much simpler if the platform supports it.
3. If the platform can't render markdown, set `supportsMarkdown: false` and implement `formatMarkdown` via `stripMarkdown`.
4. If it can't edit messages, set `supportsMessageEdit: false``BotCallbackService` will skip step edits and only send the final reply.
5. Implement `validateCredentials` so the UI's "Test connection" button gives useful errors.
6. Add the platform icon in `src/routes/(main)/agent/channel/const.ts` and register the platform in `src/server/services/bot/platforms/index.ts`.
7. Add i18n keys under `channel.*` in `src/locales/default/setting.ts` (or wherever the channel namespace lives) — the schema's `label`/`description`/`placeholder`/`enumLabels` are i18n keys.
+218
View File
@@ -0,0 +1,218 @@
---
name: cli-backend-testing
description: >
CLI + Backend integration testing workflow. Use when verifying backend API changes
(TRPC routers, services, models) via the LobeHub CLI against a local dev server.
Triggers on 'cli test', 'test with cli', 'verify with cli', 'local cli test',
'backend test with cli', or when needing to validate server-side changes end-to-end.
---
# CLI + Backend Integration Testing
Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) against a local dev server.
## When to Use
- Verifying TRPC router / service / model changes end-to-end
- Testing new API fields or response structure changes
- Validating CLI command output after backend modifications
- Debugging data flow issues between server and CLI
## Prerequisites
| Requirement | Details |
| ------------ | ------------------------------------------------------------- |
| Dev server | `localhost:3011` (Next.js) |
| CLI source | `lobehub/apps/cli/` |
| CLI dev mode | Uses `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
| Auth | Device Code Flow login to local server |
## Quick Reference
All CLI dev commands run from `lobehub/apps/cli/`:
```bash
# Shorthand for all commands below
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
```
## Workflow
### Step 1: Ensure Dev Server is Running
Check if the dev server is already running:
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
```
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
- **If unreachable**: start the server:
```bash
# From cloud repo root
pnpm run dev:next
```
To **restart** (pick up server-side code changes):
```bash
lsof -ti:3011 | xargs kill
pnpm run dev:next
```
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
### Step 2: Check CLI Authentication
Check if dev credentials already exist:
```bash
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
```
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
```bash
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
```
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
### Step 3: Test with CLI Commands
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
```bash
cd lobehub/apps/cli
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Step 4: Clean Up Test Data
Delete any test data created during verification:
```bash
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
```
## Common Testing Patterns
### Task System
```bash
# List tasks
$CLI task list
# Create test data with nesting
$CLI task create -n "Root Task" -i "Test instruction"
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
# View task detail (tests getTaskDetail service)
$CLI task view T-1
# View task tree
$CLI task tree T-1
# Test lifecycle
$CLI task edit T-1 --status running
$CLI task comment T-1 -m "Test comment"
# Clean up
$CLI task delete T-1 -y
```
### Agent System
```bash
# List agents
$CLI agent list
# View agent detail
$CLI agent view <agent-id>
# Run agent (tests agent execution pipeline)
$CLI agent run <agent-id> -m "Test prompt"
```
### Document & Knowledge Base
```bash
# List documents
$CLI doc list
# Create and view
$CLI doc create -t "Test Doc" -c "Content here"
$CLI doc view <doc-id>
# Knowledge base
$CLI kb list
$CLI kb tree <kb-id>
```
### Model & Provider
```bash
# List models and providers
$CLI model list
$CLI provider list
# Test provider connectivity
$CLI provider test <provider-id>
```
## Dev-Test Cycle
The standard cycle for backend development:
```
1. Make code changes (service/model/router/type)
|
2. Run unit tests (fast feedback)
bunx vitest run --silent='passed-only' '<test-file>'
|
3. Restart dev server (if server-side changes)
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
4. CLI verification (end-to-end)
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
5. Clean up test data
```
### When Server Restart is Needed
| Change Location | Restart? |
| ----------------------------------------- | -------- |
| `lobehub/src/server/` (routers, services) | Yes |
| `lobehub/packages/database/` (models) | Yes |
| `lobehub/packages/types/` | Yes |
| `lobehub/packages/prompts/` | Yes |
| `lobehub/apps/cli/` (CLI code) | No |
| `src/` (cloud overrides) | Yes |
### When Server Restart is NOT Needed
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
## Troubleshooting
| Issue | Solution |
| --------------------------- | --------------------------------------------------------------------- |
| `No authentication found` | Run `login --server http://localhost:3011` |
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
| `ECONNREFUSED` | Dev server not running; start with `pnpm run dev:next` |
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
## Credential Isolation
| Mode | Credential Dir | Server |
| ---------- | -------------------------------- | ----------------- |
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
| Production | `~/.lobehub/` | `app.lobehub.com` |
The two environments are completely isolated. Dev mode credentials are gitignored.
+5 -3
View File
@@ -20,9 +20,11 @@ This is NON-NEGOTIABLE. Skipping Linear comments is a workflow violation.
## Workflow
1. **Retrieve issue details** before starting: `mcp__linear-server__get_issue`
2. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
3. **Update issue status** when completing: `mcp__linear-server__update_issue`
4. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
2. **Read images**: If the issue description contains images, MUST use `mcp__linear-server__extract_images` to read image content for full context
3. **Check for sub-issues**: Use `mcp__linear-server__list_issues` with `parentId` filter
4. **Mark as In Progress**: When starting to plan or implement an issue, immediately update status to **"In Progress"** via `mcp__linear-server__update_issue`
5. **Update issue status** when completing: `mcp__linear-server__update_issue`
6. **Add completion comment** (REQUIRED): `mcp__linear-server__create_comment`
## Creating Issues
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
# Discord Bot Testing
**App name:** `Discord` | **Process name:** `Discord`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Discord
osascript -e 'tell application "Discord" to activate'
sleep 1
# Open Quick Switcher (Cmd+K) to navigate to a channel
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
sleep 1
osascript -e 'tell application "System Events" to key code 36' # Enter
sleep 2
```
## Send Message to Bot
```bash
# The message input is focused after navigating to a channel
# Type a message
osascript -e 'tell application "System Events" to keystroke "/hello"'
sleep 0.5
osascript -e 'tell application "System Events" to key code 36' # Enter
```
## Send Long Message (via clipboard)
```bash
osascript -e '
tell application "Discord" to activate
delay 0.5
set the clipboard to "Write a 3000 word essay about space exploration"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
## Verify Bot Response
```bash
# Wait for bot to respond, then screenshot
sleep 10
screencapture /tmp/discord-bot-response.png
# Read with the Read tool for visual verification
```
## Full Bot Test Example
```bash
#!/usr/bin/env bash
# test-discord-bot.sh — Send message and verify bot response
# 1. Activate Discord and navigate to channel
osascript -e '
tell application "Discord" to activate
delay 1
-- Quick Switcher
tell application "System Events" to keystroke "k" using command down
delay 0.5
tell application "System Events" to keystroke "bot-testing"
delay 1
tell application "System Events" to key code 36
delay 2
'
# 2. Send test message
osascript -e '
set the clipboard to "!ping"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
# 3. Wait for response and capture
sleep 5
screencapture /tmp/discord-test-result.png
echo "Screenshot saved to /tmp/discord-test-result.png"
```
## Script
```bash
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "!ping"
./.agents/skills/local-testing/scripts/test-discord-bot.sh "bot-testing" "/ask Tell me a joke" 30
```
@@ -0,0 +1,61 @@
# Lark / 飞书 Bot Testing
**App name:** `Lark` or `飞书` | **Process name:** `Lark` or `飞书`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Lark (auto-detects Lark or 飞书)
osascript -e 'tell application "Lark" to activate' 2> /dev/null \
|| osascript -e 'tell application "飞书" to activate'
sleep 1
# Quick Switcher / Search (Cmd+K)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e '
set the clipboard to "bot-testing"
tell application "System Events"
keystroke "v" using command down
delay 1.5
key code 36 -- Enter
end tell
'
sleep 2
```
## Send Message to Bot
```bash
osascript -e '
set the clipboard to "@MyBot help me with this task"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/lark-bot-response.png
```
## Lark-Specific Notes
- App name varies: `Lark` (international) vs `飞书` (China mainland) — the script auto-detects
- Uses `Cmd+K` for quick search (same as Discord/Slack)
- Enter sends message by default
- Always use clipboard paste for CJK characters
## Script
```bash
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "@MyBot hello"
./.agents/skills/local-testing/scripts/test-lark-bot.sh "bot-testing" "Help me with this" 30
```
@@ -0,0 +1,217 @@
# osascript Common Patterns
Shared AppleScript / `osascript` patterns used by all platform bot tests. Read this first, then refer to the per-platform file for app-specific quirks.
## Core Patterns
### Activate an App
```bash
osascript -e 'tell application "Discord" to activate'
```
### Type Text
```bash
# Type character by character (reliable, but slow for long text)
osascript -e 'tell application "System Events" to keystroke "Hello world"'
# Press Enter
osascript -e 'tell application "System Events" to key code 36'
# Press Tab
osascript -e 'tell application "System Events" to key code 48'
# Press Escape
osascript -e 'tell application "System Events" to key code 53'
```
### Paste from Clipboard (fast, for long text)
```bash
# Set clipboard and paste — much faster than keystroke for long messages
osascript -e 'set the clipboard to "Your long message here"'
osascript -e 'tell application "System Events" to keystroke "v" using command down'
```
Or in one shot:
```bash
osascript -e '
set the clipboard to "Your long message here"
tell application "System Events" to keystroke "v" using command down
'
```
### Keyboard Shortcuts
```bash
# Cmd+K (quick switcher in Discord/Slack)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
# Cmd+F (search)
osascript -e 'tell application "System Events" to keystroke "f" using command down'
# Cmd+N (new message/chat)
osascript -e 'tell application "System Events" to keystroke "n" using command down'
# Cmd+Shift+K (example: multi-modifier)
osascript -e 'tell application "System Events" to keystroke "k" using {command down, shift down}'
```
### Click at Position
```bash
# Click at absolute screen coordinates
osascript -e '
tell application "System Events"
click at {500, 300}
end tell
'
```
### Get Window Info
```bash
# Get window position and size
osascript -e '
tell application "System Events"
tell process "Discord"
get {position, size} of window 1
end tell
end tell
'
```
### Screenshot
```bash
# Full screen
screencapture /tmp/screenshot.png
# Interactive region select
screencapture -i /tmp/screenshot.png
# Specific window (by window ID from CGWindowList)
screencapture -l < WINDOW_ID > /tmp/screenshot.png
```
To get window ID for a specific app:
```bash
osascript -e '
tell application "System Events"
tell process "Discord"
get id of window 1
end tell
end tell
'
```
### Read Accessibility Elements
```bash
# Get all UI elements of the frontmost window (can be slow/large)
osascript -e '
tell application "System Events"
tell process "Discord"
entire contents of window 1
end tell
end tell
'
# Get a specific element's value
osascript -e '
tell application "System Events"
tell process "Discord"
get value of text field 1 of window 1
end tell
end tell
'
```
> **Warning:** `entire contents` can be extremely slow on complex UIs. Prefer screenshots + `Read` tool for visual verification.
### Read Screen Text via Clipboard
For reading the latest message or response from an app:
```bash
# Select all text in the focused area and copy
osascript -e '
tell application "System Events"
keystroke "a" using command down
keystroke "c" using command down
end tell
'
sleep 0.5
# Read clipboard
pbpaste
```
---
## Common Bot Testing Workflow
Regardless of platform, the pattern is:
```bash
APP_NAME="Discord" # or "Slack", "Telegram", "微信"
CHANNEL="bot-testing"
MESSAGE="Hello bot!"
WAIT_SECONDS=10
# 1. Activate
osascript -e "tell application \"$APP_NAME\" to activate"
sleep 1
# 2. Navigate to channel/chat (via Quick Switcher or Search)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e "tell application \"System Events\" to keystroke \"$CHANNEL\""
sleep 1
osascript -e 'tell application "System Events" to key code 36'
sleep 2
# 3. Send message
osascript -e "set the clipboard to \"$MESSAGE\""
osascript -e '
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
# 4. Wait for bot response
sleep "$WAIT_SECONDS"
# 5. Screenshot for verification
screencapture /tmp/"${APP_NAME,,}"-bot-test.png
echo "Result saved to /tmp/${APP_NAME,,}-bot-test.png"
```
### Tips
- **Use clipboard paste** (`Cmd+V`) for messages containing special characters or long text — `keystroke` can mangle non-ASCII
- **Add `delay`** between actions — apps need time to process UI events
- **Screenshot for verification** — use `screencapture` + `Read` tool for visual checks
- **Use a dedicated test channel/chat** — avoid polluting real conversations
- **Check app name** — some apps have different names in different locales (e.g., `微信` vs `WeChat`)
- **Accessibility permissions required** — System Events automation requires granting Accessibility access in System Preferences > Privacy & Security > Accessibility
---
## Gotchas
- **Accessibility permission required** — first run will prompt for access; grant it in System Preferences > Privacy & Security > Accessibility for Terminal / iTerm / Claude Code
- **`keystroke` is slow for long text** — always use clipboard paste (`Cmd+V`) for messages over \~20 characters
- **`keystroke` can mangle non-ASCII** — use clipboard paste for Chinese, emoji, or special characters
- **`key code 36` is Enter** — this is the hardware key code, works regardless of keyboard layout
- **`entire contents` is extremely slow** — avoid for complex UIs; use screenshots instead
- **App name varies by locale** — `微信` vs `WeChat`, `企业微信` vs `WeCom`; handle both
- **WeChat Enter sends immediately** — use `Shift+Enter` for newlines within a message
- **Rate limiting** — don't send messages too fast; platforms may throttle or flag automated input
- **Lark / 飞书 app name varies** — `Lark` (international) vs `飞书` (China mainland); scripts auto-detect
- **QQ uses `Cmd+F` for search** — not `Cmd+K` like Discord/Slack/Lark
- **Bot response times vary** — AI-powered bots may take 10-60s; use generous sleep values
@@ -0,0 +1,62 @@
# QQ Bot Testing
**App name:** `QQ` | **Process name:** `QQ`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
osascript -e 'tell application "QQ" to activate'
sleep 1
# Search for contact/group (Cmd+F)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.8
end tell
'
osascript -e '
set the clipboard to "bot-testing"
tell application "System Events"
keystroke "v" using command down
delay 1.5
key code 36 -- Enter
end tell
'
sleep 2
```
## Send Message to Bot
```bash
osascript -e '
set the clipboard to "Hello bot!"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36 -- Enter
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/qq-bot-response.png
```
## QQ-Specific Notes
- Enter sends message by default; Shift+Enter for newlines
- Uses `Cmd+F` for search (not `Cmd+K` like Discord/Slack/Lark)
- Always use clipboard paste for CJK characters
## Script
```bash
./.agents/skills/local-testing/scripts/test-qq-bot.sh "bot-testing" "Hello bot" 15
./.agents/skills/local-testing/scripts/test-qq-bot.sh "MyBot" "/help" 10
```
@@ -0,0 +1,73 @@
# Slack Bot Testing
**App name:** `Slack` | **Process name:** `Slack`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Slack
osascript -e 'tell application "Slack" to activate'
sleep 1
# Quick Switcher (Cmd+K)
osascript -e 'tell application "System Events" to keystroke "k" using command down'
sleep 0.5
osascript -e 'tell application "System Events" to keystroke "bot-testing"'
sleep 1
osascript -e 'tell application "System Events" to key code 36' # Enter
sleep 2
```
## Send Message to Bot
```bash
# Direct message input (focused after channel nav)
osascript -e 'tell application "System Events" to keystroke "@mybot hello"'
sleep 0.3
osascript -e 'tell application "System Events" to key code 36'
```
## Send Long Message
```bash
osascript -e '
tell application "Slack" to activate
delay 0.5
set the clipboard to "A long test message for the bot..."
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
## Slash Command Test
```bash
osascript -e '
tell application "Slack" to activate
delay 0.5
tell application "System Events"
keystroke "/ask What is the meaning of life?"
delay 0.5
key code 36
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/slack-bot-response.png
```
## Script
```bash
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "@mybot hello"
./.agents/skills/local-testing/scripts/test-slack-bot.sh "bot-testing" "/ask What is 2+2?" 20
```
@@ -0,0 +1,80 @@
# Telegram Bot Testing
**App name:** `Telegram` | **Process name:** `Telegram`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate Telegram
osascript -e 'tell application "Telegram" to activate'
sleep 1
# Search for a bot (Cmd+F or click search)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.5
keystroke "MyTestBot"
delay 1
key code 36 -- Enter to select
end tell
'
sleep 2
```
## Send Message to Bot
```bash
# After navigating to bot chat, input is focused
osascript -e '
tell application "System Events"
keystroke "/start"
delay 0.3
key code 36
end tell
'
```
## Send Long Message
```bash
osascript -e '
tell application "Telegram" to activate
delay 0.5
set the clipboard to "Tell me about quantum computing in detail"
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/telegram-bot-response.png
```
## Telegram Bot API (programmatic alternative)
For sending messages directly to the bot's chat without UI:
```bash
# Send message as the bot (for testing webhooks/responses)
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
-d "chat_id=$CHAT_ID&text=test message"
# Get recent updates
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates?limit=5" | jq .
```
## Script
```bash
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "MyTestBot" "/start"
./.agents/skills/local-testing/scripts/test-telegram-bot.sh "GPTBot" "Hello" 60
```
@@ -0,0 +1,81 @@
# WeChat / 微信 Bot Testing
**App name:** `微信` or `WeChat` | **Process name:** `WeChat`
See [osascript-common.md](./osascript-common.md) for shared patterns.
## Activate & Navigate
```bash
# Activate WeChat
osascript -e 'tell application "微信" to activate'
sleep 1
# Search for a contact/bot (Cmd+F)
osascript -e '
tell application "System Events"
keystroke "f" using command down
delay 0.5
keystroke "TestBot"
delay 1
key code 36 -- Enter to select
end tell
'
sleep 2
```
## Send Message
```bash
# After navigating to a chat, the input is focused
osascript -e '
tell application "System Events"
keystroke "Hello bot!"
delay 0.3
key code 36
end tell
'
```
## Send Long Message (clipboard)
```bash
osascript -e '
tell application "微信" to activate
delay 0.5
set the clipboard to "Please help me with this task..."
tell application "System Events"
keystroke "v" using command down
delay 0.3
key code 36
end tell
'
```
## Verify Response
```bash
sleep 10
screencapture /tmp/wechat-bot-response.png
```
## WeChat-Specific Notes
- WeChat macOS app name can be `微信` or `WeChat` depending on system language. Try both:
```bash
osascript -e 'tell application "微信" to activate' 2> /dev/null \
|| osascript -e 'tell application "WeChat" to activate'
```
- WeChat uses **Enter** to send (not Cmd+Enter by default, but configurable)
- For multi-line messages without sending, use **Shift+Enter**:
```bash
osascript -e 'tell application "System Events" to key code 36 using shift down'
```
- Always use clipboard paste for CJK characters — `keystroke` mangles non-ASCII
## Script
```bash
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "文件传输助手" "test message" 5
./.agents/skills/local-testing/scripts/test-wechat-bot.sh "MyBot" "Tell me a joke" 30
```
@@ -0,0 +1,142 @@
# record-app-screen.sh
General-purpose screen recording tool for the Electron app. Captures CDP screenshots as video frames and gallery snapshots, then assembles into an MP4 on stop.
## Why CDP Screenshots Instead of ffmpeg Screen Capture
- **Works on any screen** — CDP screenshots capture the browser viewport directly, so external monitors, Retina scaling, and window positioning are all handled automatically
- **No signal handling issues** — ffmpeg-static (npm) produces corrupt MP4 files when killed (missing moov atom). CDP screenshots avoid this entirely
- **Consistent output** — Screenshots are resolution-independent and don't require crop coordinate calculations
## Commands
```bash
# Start recording (Electron must be running with CDP)
.agents/skills/local-testing/scripts/record-app-screen.sh start [output_name]
# Stop recording and assemble video
.agents/skills/local-testing/scripts/record-app-screen.sh stop
# Check if recording is active
.agents/skills/local-testing/scripts/record-app-screen.sh status
```
### Arguments
| Argument | Default | Description |
| ------------- | --------------------------- | -------------------------- |
| `output_name` | `recording-YYYYMMDD-HHMMSS` | Base name for output files |
### Environment Variables
| Variable | Default | Description |
| ---------------------- | ------- | -------------------------------------- |
| `CDP_PORT` | `9222` | Chrome DevTools Protocol port |
| `SCREENSHOT_INTERVAL` | `3` | Seconds between gallery screenshots |
| `VIDEO_FRAME_INTERVAL` | `0.5` | Seconds between video frames (\~2 fps) |
## Output Structure
```
.records/
<name>.mp4 # Video assembled from frames (~2 fps)
<name>/ # Gallery screenshots (every 3s)
0000.png
0001.png
0002.png
...
```
The `.records/` directory is at the project root and is gitignored.
## How It Works
### Start
1. Creates two background loops:
- **Video frames** — `agent-browser screenshot` every `VIDEO_FRAME_INTERVAL` seconds into a temp directory (`/tmp/record-frames-XXXXXX/`)
- **Gallery screenshots** — `agent-browser screenshot` every `SCREENSHOT_INTERVAL` seconds into `.records/<name>/`
2. Saves PIDs and paths to `/tmp/record-app-screen.pids` and `/tmp/record-app-screen.state`
### Stop
1. Kills both background loops
2. Assembles video frames into MP4 using ffmpeg:
```
ffmpeg -framerate 2 -i frame_%06d.png -c:v libx264 -crf 23 -pix_fmt yuv420p <output>.mp4
```
3. Cleans up temp frame directory
4. Reports file sizes and paths
## Usage Examples
### Basic Test Recording
```bash
# Start Electron
.agents/skills/local-testing/scripts/electron-dev.sh start
# Start recording
.agents/skills/local-testing/scripts/record-app-screen.sh start my-test
# Run automation
agent-browser --cdp 9222 click @e61
agent-browser --cdp 9222 type @e42 "hello"
agent-browser --cdp 9222 press Enter
sleep 10
# Stop and get results
.agents/skills/local-testing/scripts/record-app-screen.sh stop
# → .records/my-test.mp4 + .records/my-test/*.png
```
### Gateway Streaming Demo
```bash
.agents/skills/local-testing/scripts/electron-dev.sh start
# Inject gateway URL
agent-browser --cdp 9222 eval --stdin << 'EOF'
(function() {
var store = window.global_serverConfigStore;
store.setState({ serverConfig: { ...store.getState().serverConfig,
agentGatewayUrl: 'https://agent-gateway.lobehub.com' } });
return 'ready';
})()
EOF
# Record
.agents/skills/local-testing/scripts/record-app-screen.sh start gateway-demo
# Navigate to agent, send message, wait for completion...
# (automation commands here)
.agents/skills/local-testing/scripts/record-app-screen.sh stop
open .records/gateway-demo.mp4
```
### Check Active Recording
```bash
.agents/skills/local-testing/scripts/record-app-screen.sh status
# [record] Active recording
# Frames: 42 captured (running: yes)
# Screenshots: 14 captured (running: yes)
# Output: .records/my-test.mp4
```
## Prerequisites
- **ffmpeg** — For video assembly. Install via `bun add -g ffmpeg-static` or `brew install ffmpeg`
- **agent-browser** — For CDP screenshots. Install via `npm i -g agent-browser`
- **Electron app running** — With CDP enabled (use `electron-dev.sh start`)
## Troubleshooting
| Problem | Solution |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| "No active recording found" on stop | PID file was cleaned up. Check if background processes are still running with `ps aux \| grep agent-browser` |
| "A recording is already active" | Run `stop` first, or manually clean: `rm /tmp/record-app-screen.pids /tmp/record-app-screen.state` |
| Video is 0 bytes | No frames were captured. Ensure Electron is running and CDP port is correct |
| Screenshots are blank/white | SPA may not have loaded yet. Wait for `electron-dev.sh` to report "Renderer ready" |
| ffmpeg assembly fails | Check `/tmp/ffmpeg-assemble.log`. Ensure ffmpeg is installed and frames exist |
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env bash
#
# electron-dev.sh — Manage Electron dev environment for testing
#
# Usage:
# ./electron-dev.sh start # Kill existing, start fresh, wait until ready
# ./electron-dev.sh stop # Kill all Electron-related processes
# ./electron-dev.sh status # Check if Electron is running and CDP is reachable
# ./electron-dev.sh restart # Stop then start
#
# Environment variables:
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
# ELECTRON_LOG — Log file path (default: /tmp/electron-dev.log)
# ELECTRON_WAIT_S — Max seconds to wait for Electron process (default: 60)
# RENDERER_WAIT_S — Max seconds to wait for renderer/SPA (default: 60)
#
set -euo pipefail
CDP_PORT="${CDP_PORT:-9222}"
ELECTRON_LOG="${ELECTRON_LOG:-/tmp/electron-dev.log}"
ELECTRON_WAIT_S="${ELECTRON_WAIT_S:-60}"
RENDERER_WAIT_S="${RENDERER_WAIT_S:-60}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
PIDFILE="/tmp/electron-dev-cdp-${CDP_PORT}.pid"
# ── Helpers ──────────────────────────────────────────────────────────
# Get the Electron binary path used by this project
electron_bin_pattern() {
echo "${PROJECT_ROOT}/apps/desktop/node_modules/.pnpm/electron@*/node_modules/electron/dist/Electron.app"
}
# Find all PIDs related to the project's Electron dev session
find_electron_pids() {
local pids=""
# 1. Main Electron process (launched with --remote-debugging-port)
local main_pids
main_pids=$(pgrep -f "Electron\.app.*--remote-debugging-port=${CDP_PORT}" 2>/dev/null || true)
[ -n "$main_pids" ] && pids="$pids $main_pids"
# 2. Electron Helper processes (gpu, renderer, utility) spawned from the project's electron binary
local helper_pids
helper_pids=$(pgrep -f "${PROJECT_ROOT}/apps/desktop/node_modules/.*Electron Helper" 2>/dev/null || true)
[ -n "$helper_pids" ] && pids="$pids $helper_pids"
# 3. electron-vite dev server
local vite_pids
vite_pids=$(pgrep -f "electron-vite.*dev" 2>/dev/null || true)
[ -n "$vite_pids" ] && pids="$pids $vite_pids"
# 4. PID from pidfile (fallback)
if [ -f "$PIDFILE" ]; then
local saved_pid
saved_pid=$(cat "$PIDFILE")
if kill -0 "$saved_pid" 2>/dev/null; then
pids="$pids $saved_pid"
fi
fi
# Deduplicate
echo "$pids" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' || true
}
do_stop() {
echo "[electron-dev] Stopping Electron dev environment..."
local pids
pids=$(find_electron_pids)
if [ -z "$pids" ]; then
echo "[electron-dev] No Electron processes found."
else
echo "[electron-dev] Killing PIDs: $pids"
for pid in $pids; do
kill "$pid" 2>/dev/null || true
done
# Wait up to 5s for graceful exit, then force-kill survivors
local waited=0
while [ $waited -lt 5 ]; do
local alive=""
for pid in $pids; do
kill -0 "$pid" 2>/dev/null && alive="$alive $pid"
done
[ -z "$alive" ] && break
sleep 1
waited=$((waited + 1))
done
# Force-kill any remaining
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then
echo "[electron-dev] Force-killing PID $pid"
kill -9 "$pid" 2>/dev/null || true
fi
done
fi
# Also close any agent-browser sessions connected to this port
agent-browser --cdp "$CDP_PORT" close --all 2>/dev/null || true
rm -f "$PIDFILE"
echo "[electron-dev] Stopped."
}
do_status() {
local pids
pids=$(find_electron_pids)
if [ -z "$pids" ]; then
echo "[electron-dev] Electron is NOT running."
return 1
fi
echo "[electron-dev] Electron is running (PIDs: $pids)"
# Check CDP connectivity
if agent-browser --cdp "$CDP_PORT" get url >/dev/null 2>&1; then
local url
url=$(agent-browser --cdp "$CDP_PORT" get url 2>&1 | tail -1)
echo "[electron-dev] CDP port ${CDP_PORT} is reachable. URL: $url"
return 0
else
echo "[electron-dev] CDP port ${CDP_PORT} is NOT reachable (Electron may still be loading)."
return 2
fi
}
wait_for_electron() {
echo "[electron-dev] Waiting for Electron process (up to ${ELECTRON_WAIT_S}s)..."
local elapsed=0
local interval=3
while [ $elapsed -lt "$ELECTRON_WAIT_S" ]; do
if strings "$ELECTRON_LOG" 2>/dev/null | grep -q "starting electron"; then
echo "[electron-dev] Electron process started."
return 0
fi
sleep "$interval"
elapsed=$((elapsed + interval))
echo "[electron-dev] Still waiting... (${elapsed}/${ELECTRON_WAIT_S}s)"
done
echo "[electron-dev] ERROR: Electron did not start within ${ELECTRON_WAIT_S}s"
echo "[electron-dev] Last 20 lines of log:"
tail -20 "$ELECTRON_LOG" 2>/dev/null || true
return 1
}
wait_for_renderer() {
echo "[electron-dev] Waiting for renderer/SPA to load (up to ${RENDERER_WAIT_S}s)..."
# Initial delay — renderer needs time to bootstrap
sleep 10
local elapsed=10
local interval=5
while [ $elapsed -lt "$RENDERER_WAIT_S" ]; do
if agent-browser --cdp "$CDP_PORT" wait 2000 >/dev/null 2>&1; then
# Check if interactive elements are present (SPA loaded)
local snap
snap=$(agent-browser --cdp "$CDP_PORT" snapshot -i 2>&1 || true)
if echo "$snap" | grep -qE 'link |button '; then
echo "[electron-dev] Renderer ready (interactive elements found)."
return 0
fi
fi
sleep "$interval"
elapsed=$((elapsed + interval))
echo "[electron-dev] SPA still loading... (${elapsed}/${RENDERER_WAIT_S}s)"
done
echo "[electron-dev] WARNING: Timed out waiting for renderer, proceeding anyway."
return 0
}
do_start() {
# If already running and healthy, skip
local status_ok=0
do_status >/dev/null 2>&1 || status_ok=$?
if [ "$status_ok" -eq 0 ]; then
echo "[electron-dev] Electron is already running and CDP is reachable. Skipping start."
echo "[electron-dev] Use 'restart' to force a fresh session, or 'stop' to tear down."
return 0
fi
# Clean up any stale processes
do_stop
# Start fresh
echo "[electron-dev] Starting Electron dev server..."
echo "[electron-dev] Project: $PROJECT_ROOT"
echo "[electron-dev] CDP port: $CDP_PORT"
echo "[electron-dev] Log: $ELECTRON_LOG"
: > "$ELECTRON_LOG" # Truncate log
(
cd "$PROJECT_ROOT/apps/desktop" && \
ELECTRON_ENABLE_LOGGING=1 npx electron-vite dev -- --remote-debugging-port="$CDP_PORT" \
>> "$ELECTRON_LOG" 2>&1
) &
local bg_pid=$!
echo "$bg_pid" > "$PIDFILE"
echo "[electron-dev] Background PID: $bg_pid"
# Wait for Electron process to start
if ! wait_for_electron; then
echo "[electron-dev] Failed to start. Cleaning up..."
do_stop
return 1
fi
# Wait for renderer to be interactive
if ! wait_for_renderer; then
echo "[electron-dev] Renderer not ready, but Electron is running. You may need to wait more."
fi
echo "[electron-dev] Ready! Use: agent-browser --cdp $CDP_PORT snapshot -i"
}
do_restart() {
do_stop
sleep 2
do_start
}
# ── Main ─────────────────────────────────────────────────────────────
case "${1:-help}" in
start) do_start ;;
stop) do_stop ;;
status) do_status ;;
restart) do_restart ;;
*)
echo "Usage: $0 {start|stop|status|restart}"
echo ""
echo " start — Start Electron dev with CDP (idempotent, skips if already running)"
echo " stop — Kill all Electron dev processes (main + helpers + vite)"
echo " status — Check if Electron is running and CDP is reachable"
echo " restart — Stop then start"
exit 1
;;
esac
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
#
# record-app-screen.sh — Record the Electron app window (video + screenshots)
#
# Captures screenshots via agent-browser (CDP), then assembles into video on stop.
# Works on any screen (including external monitors) since it uses CDP, not screen capture.
#
# Usage:
# ./record-app-screen.sh start [output_name] # Begin recording
# ./record-app-screen.sh stop # Stop and save
# ./record-app-screen.sh status # Check recording state
#
# Outputs to .records/ directory:
# .records/<name>.mp4 — Video assembled from screenshots (~2 fps)
# .records/<name>/ — Screenshots every SCREENSHOT_INTERVAL seconds
#
# Prerequisites:
# - ffmpeg installed (bun add -g ffmpeg-static, or brew install ffmpeg)
# - agent-browser CLI installed
# - Electron app already running with CDP enabled
#
# Environment variables:
# CDP_PORT — Chrome DevTools Protocol port (default: 9222)
# SCREENSHOT_INTERVAL — Seconds between gallery screenshots (default: 3)
# VIDEO_FRAME_INTERVAL — Seconds between video frames (default: 0.5)
#
# Examples:
# ./electron-dev.sh start
# ./record-app-screen.sh start gateway-demo
# # ... run automation via agent-browser ...
# ./record-app-screen.sh stop
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
RECORDS_DIR="$PROJECT_DIR/.records"
PID_FILE="/tmp/record-app-screen.pids"
STATE_FILE="/tmp/record-app-screen.state"
CDP_PORT="${CDP_PORT:-9222}"
SCREENSHOT_INTERVAL="${SCREENSHOT_INTERVAL:-3}"
VIDEO_FRAME_INTERVAL="${VIDEO_FRAME_INTERVAL:-0.5}"
AB="agent-browser --cdp $CDP_PORT"
# ─── Commands ───
cmd_start() {
local output_name="${1:-recording-$(date +%Y%m%d-%H%M%S)}"
local output_video="$RECORDS_DIR/${output_name}.mp4"
local screenshot_dir="$RECORDS_DIR/${output_name}"
local frames_dir
frames_dir=$(mktemp -d /tmp/record-frames-XXXXXX)
if [ -f "$PID_FILE" ]; then
echo "[record] A recording is already active. Run '$0 stop' first."
exit 1
fi
mkdir -p "$RECORDS_DIR" "$screenshot_dir"
# Video frames loop (~2 fps via agent-browser CDP screenshots)
(
local idx=0
while true; do
local fname
fname=$(printf "%s/frame_%06d.png" "$frames_dir" "$idx")
$AB screenshot "$fname" 2>/dev/null || true
idx=$((idx + 1))
sleep "$VIDEO_FRAME_INTERVAL"
done
) &
local frames_pid=$!
# Gallery screenshots loop (every N seconds for human review)
(
local idx=0
while true; do
local fname
fname=$(printf "%s/%04d.png" "$screenshot_dir" "$idx")
$AB screenshot "$fname" 2>/dev/null || true
idx=$((idx + 1))
sleep "$SCREENSHOT_INTERVAL"
done
) &
local screenshot_pid=$!
# Save state
echo "$frames_pid $screenshot_pid" > "$PID_FILE"
echo "$output_video $frames_dir $screenshot_dir" > "$STATE_FILE"
echo "[record] Started!"
echo " Video frames: every ${VIDEO_FRAME_INTERVAL}s (PID $frames_pid)"
echo " Screenshots: every ${SCREENSHOT_INTERVAL}s → $screenshot_dir/"
echo " Stop with: $0 stop"
}
cmd_stop() {
if [ ! -f "$PID_FILE" ] || [ ! -f "$STATE_FILE" ]; then
echo "[record] No active recording found."
return 0
fi
local frames_pid screenshot_pid
read -r frames_pid screenshot_pid < "$PID_FILE"
local output_video frames_dir screenshot_dir
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
# Stop both capture loops
kill "$frames_pid" 2>/dev/null || true
kill "$screenshot_pid" 2>/dev/null || true
wait "$frames_pid" 2>/dev/null || true
wait "$screenshot_pid" 2>/dev/null || true
# Assemble frames into video
local frame_count
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ')
if [ "$frame_count" -gt 0 ]; then
echo "[record] Assembling $frame_count frames into video..."
ffmpeg -y -framerate 2 -i "$frames_dir/frame_%06d.png" \
-c:v libx264 -crf 23 -pix_fmt yuv420p -an \
"$output_video" > /tmp/ffmpeg-assemble.log 2>&1
if [ ! -s "$output_video" ]; then
echo " [warn] Video assembly failed. Check /tmp/ffmpeg-assemble.log"
echo " Frames preserved in: $frames_dir/"
fi
else
echo " [warn] No frames captured."
fi
rm -rf "$frames_dir" 2>/dev/null
rm -f "$PID_FILE" "$STATE_FILE"
local video_size screenshot_count
video_size=$(ls -lh "$output_video" 2>/dev/null | awk '{print $5}' || echo "?")
screenshot_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
echo "[record] Stopped!"
echo " Video: $output_video ($video_size)"
echo " Screenshots: ${screenshot_count} files in $screenshot_dir/"
echo " Play: open $output_video"
}
cmd_status() {
if [ ! -f "$PID_FILE" ]; then
echo "[record] No active recording."
return 0
fi
local frames_pid screenshot_pid
read -r frames_pid screenshot_pid < "$PID_FILE"
local frames_ok="no" screenshot_ok="no"
kill -0 "$frames_pid" 2>/dev/null && frames_ok="yes"
kill -0 "$screenshot_pid" 2>/dev/null && screenshot_ok="yes"
if [ -f "$STATE_FILE" ]; then
local output_video frames_dir screenshot_dir
read -r output_video frames_dir screenshot_dir < "$STATE_FILE"
local frame_count ss_count
frame_count=$(ls -1 "$frames_dir"/frame_*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
ss_count=$(ls -1 "$screenshot_dir"/*.png 2>/dev/null | wc -l | tr -d ' ' || echo "0")
echo "[record] Active recording"
echo " Frames: $frame_count captured (running: $frames_ok)"
echo " Screenshots: $ss_count captured (running: $screenshot_ok)"
echo " Output: $output_video"
fi
}
# ─── Main ───
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
status) cmd_status ;;
*)
echo "Usage: $0 {start [name] | stop | status}"
echo ""
echo " start [name] Start recording (default: recording-YYYYMMDD-HHMMSS)"
echo " stop Stop recording and save outputs"
echo " status Check if recording is active"
exit 1
;;
esac
+1 -1
View File
@@ -1,7 +1,7 @@
---
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'."
user_invocable: true
user-invocable: true
---
# Create Pull Request
+6 -3
View File
@@ -71,15 +71,18 @@ internal_createTopic: async (params) => {
**Actions:**
- Public: `createTopic`, `sendMessage`
- Internal: `internal_createTopic`, `internal_updateMessageContent`
- Dispatch: `internal_dispatchTopic`
- Toggle: `internal_toggleMessageLoading`
**State:**
**State:**
- ID arrays: `topicEditingIds`
- ID arrays: `messageLoadingIds`, `topicEditingIds`
- Maps: `topicMaps`, `messagesMap`
- Active: `activeTopicId`
- Init flags: `topicsInit`
## Detailed Guides
@@ -30,16 +30,13 @@ internal_createMessage: async (message, context) => {
let tempId = context?.tempMessageId;
if (!tempId) {
tempId = internal_createTmpMessage(message);
internal_toggleMessageLoading(true, tempId);
}
try {
const id = await messageService.createMessage(message);
await refreshMessages();
internal_toggleMessageLoading(false, tempId);
return id;
} catch (e) {
internal_toggleMessageLoading(false, tempId);
internal_dispatchMessage({
id: tempId,
type: 'updateMessage',
+5
View File
@@ -162,11 +162,15 @@ describe('ModuleName', () => {
### 5. Create Pull Request
- Create a new branch: `automatic/add-tests-[module-name]-[date]`
- Commit changes with message format:
```
✅ test: add unit tests for [module-name]
```
- Push the branch
- Create a PR with:
- Title: `✅ test: add unit tests for [module-name]`
@@ -198,6 +202,7 @@ describe('ModuleName', () => {
- Test approach: [brief description]
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
+12 -4
View File
@@ -77,20 +77,24 @@ Create `e2e/src/features/{module-name}/README.md` with:
# {Module} 模块 E2E 测试覆盖
## 模块概述
**路由**: `/module`, `/module/[id]`
## 功能清单与测试覆盖
### 1. 功能分组名称
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
| ------ | ---- | ------ | ---- | -------- |
| 功能点 | 描述 | 优先级 | 状态 | 测试文件 |
| ------ | ---- | ------ | ---- | ------------- |
| 功能A | xxx | P0 | ✅ | `xxx.feature` |
| 功能B | xxx | P1 | ⏳ | |
| 功能B | xxx | P1 | ⏳ | |
## 测试文件结构
## 测试执行
## 已知问题
## 更新记录
```
@@ -228,7 +232,7 @@ const testId = pickle.tags.find(
tag.name.startsWith('@COMMUNITY-') ||
tag.name.startsWith('@AGENT-') ||
tag.name.startsWith('@HOME-') ||
tag.name.startsWith('@PAGE-') || // Add new prefix
tag.name.startsWith('@PAGE-') || // Add new prefix
tag.name.startsWith('@ROUTES-'),
);
```
@@ -300,11 +304,15 @@ HEADLESS=true BASE_URL=http://localhost:3006 \
### 10. Create Pull Request
- Branch name: `test/e2e-{module-name}`
- Commit message format:
```
✅ test: add E2E tests for {module-name}
```
- PR title: `✅ test: add E2E tests for {module-name}`
- PR body template:
````markdown
+8
View File
@@ -36,6 +36,7 @@ If you detect any leaked secrets, respond IMMEDIATELY with:
⚠️ **Security Warning**: Your comment appears to contain sensitive information (API keys, secrets, or credentials).
**Please delete your comment immediately** to protect your account security, then:
1. Rotate/regenerate any exposed credentials
2. Re-post your question with secrets redacted (e.g., `AUTH_SECRET=***`)
@@ -73,12 +74,17 @@ Look for the "Troubleshooting" or "FAQ" section in the migration docs and match
## Response Guidelines
1. **Be helpful and friendly** - Users are often frustrated when migration doesn't work
2. **Be specific** - Provide exact commands or configuration examples
3. **Reference documentation** - Point users to relevant docs sections
4. **Ask for logs** - If the issue is unclear, ask for Docker logs:
```bash
docker logs <container_name> 2>&1 | tail -100
```
5. **One issue at a time** - Focus on solving one problem before moving to the next
## Response Format
@@ -90,6 +96,7 @@ Use this format for your responses:
[If missing information]
To help you effectively, please provide:
- [List missing items]
[If you can help]
@@ -102,6 +109,7 @@ Based on your description, here's what I suggest:
[If the issue is complex or unknown]
This issue needs further investigation. I've notified the team. In the meantime, please:
1. [Any immediate steps they can try]
2. Share your Docker logs if you haven't already
```
+13 -10
View File
@@ -2,15 +2,15 @@
## Quick Reference by Name
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling , mcp
- **@arvinxx**: Last resort only, mention for priority:high issues, tool calling, mcp, database
- **@canisminor1990**: Design, UI components, editor, markdown rendering
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace
- **@Innei**: Knowledge base, files (KB-related), group chat
- **@nekomeowww**: Memory, backend, deployment, DevOps
- **@tjx666**: Image/video generation, vision, cloud version, documentation, TTS, auth, login/register, database
- **@ONLY-yours**: Performance, streaming, settings, general bugs, web platform, marketplace, agent builder, schedule task
- **@Innei**: Knowledge base, files (KB-related), group chat, Electron, desktop client, build system
- **@nekomeowww**: Memory, backend, deployment, DevOps, database
- **@sudongyuer**: Mobile app (React Native)
- **@sxjeru**: Model providers and configuration
- **@rdmclin2**: Team workspace
- **@rdmclin2**: Team workspace, IM and bot integration
- **@tcmonster**: Subscription, refund, recharge, business cooperation
Quick reference for assigning issues based on labels.
@@ -28,7 +28,7 @@ Quick reference for assigning issues based on labels.
| Label | Owner | Notes |
| ------------------ | ----------- | -------------------------------------- |
| `platform:mobile` | @sudongyuer | React Native mobile app |
| `platform:desktop` | @ONLY-yours | Electron desktop client (general) |
| `platform:desktop` | @Innei | Electron desktop client, build system |
| `platform:web` | @ONLY-yours | Web platform (unless specific feature) |
### Feature Labels (feature:\*)
@@ -60,6 +60,9 @@ Quick reference for assigning issues based on labels.
| `feature:group-chat` | @arvinxx | Group chat functionality |
| `feature:memory` | @nekomeowww | Memory feature |
| `feature:team-workspace` | @rdmclin2 | Team workspace application |
| `feature:im-integration` | @rdmclin2 | IM and bot integration (Slack, Discord, etc.) |
| `feature:agent-builder` | @ONLY-yours | Agent builder |
| `feature:schedule-task` | @ONLY-yours | Schedule task |
| `feature:subscription` | @tcmonster | Subscription and billing |
| `feature:refund` | @tcmonster | Refund requests |
| `feature:recharge` | @tcmonster | Recharge and payment |
@@ -125,18 +128,18 @@ Quick reference for assigning issues based on labels.
**Single owner:**
```
```plaintext
@username - This is a [feature/component] issue. Please take a look.
```
**Multiple owners:**
```
```plaintext
@primary @secondary - This involves [features]. Please coordinate.
```
**High priority:**
```
```plaintext
@owner @arvinxx - High priority [feature] issue.
```
+5
View File
@@ -72,11 +72,15 @@ Module granularity examples:
### 5. Create Pull Request
- Create a new branch: `automatic/translate-comments-[module-name]-[date]`
- Commit changes with message format:
```
🌐 chore: translate non-English comments to English in [module-name]
```
- Push the branch
- Create a PR with:
- Title: `🌐 chore: translate non-English comments to English in [module-name]`
@@ -100,6 +104,7 @@ Module granularity examples:
`[module-path]`
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
+11
View File
@@ -408,3 +408,14 @@ OPENAI_API_KEY=sk-xxxxxxxxx
# IMPORTANT: This key is stored server-side only and NEVER exposed to the client
# When this key is set, Klavis integration will be automatically enabled
# KLAVIS_API_KEY=your_klavis_api_key_here
# #######################################
# #### Message Gateway (IM Integration) ##
# #######################################
# External message-gateway for unified IM platform connection management.
# Set ENABLED=1 to activate. To migrate away, remove ENABLED first (keep URL/TOKEN)
# so LobeHub can automatically disconnect leftover gateway connections.
# MESSAGE_GATEWAY_ENABLED=1
# MESSAGE_GATEWAY_URL=https://message-gateway.lobehub.com
# MESSAGE_GATEWAY_SERVICE_TOKEN=your_service_token_here
+13
View File
@@ -0,0 +1,13 @@
AmAzing129
arvinxx
canisminor1990
ilimei
Innei
lobehubbot
nekomeowww
ONLY-yours
rdmclin2
rivertwilight
sudongyuer
tcmonster
tjx666
+11 -1
View File
@@ -18,6 +18,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Check if author is a team member
id: check-team
run: |
ISSUE_AUTHOR="${{ github.event.issue.user.login }}"
if grep -iq "^${ISSUE_AUTHOR}$" .github/maintainers.txt; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Copy triage prompts
run: |
mkdir -p /tmp/claude-prompts
@@ -62,7 +72,7 @@ jobs:
**IMPORTANT**:
- Follow ALL steps in the issue-triage.md guide
- Apply labels according to the guide's rules
- Post a mention comment to the appropriate team member(s) based on team-assignment.md
- ${{ steps.check-team.outputs.is_team == 'true' && 'The issue author is a team member. Do NOT post any @mention comment.' || 'Post a mention comment to the appropriate team member(s) based on team-assignment.md' }}
- Replace [ISSUE_NUMBER] with: ${{ github.event.issue.number }}
**Start the triage process now.**
+12
View File
@@ -21,7 +21,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Check if author is a team member
id: check-team
run: |
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
if grep -iq "^${PR_AUTHOR}$" .github/maintainers.txt; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Copy prompts
if: steps.check-team.outputs.is_team == 'false'
run: |
mkdir -p /tmp/claude-prompts
cp .claude/prompts/pr-assign.md /tmp/claude-prompts/
@@ -29,6 +40,7 @@ jobs:
cp .claude/prompts/security-rules.md /tmp/claude-prompts/
- name: Run Claude Code for PR Reviewer Assignment
if: steps.check-team.outputs.is_team == 'false'
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ secrets.GH_TOKEN }}
+13 -1
View File
@@ -28,9 +28,21 @@ jobs:
✅ @{{ author }}
This issue is closed, If you have any questions, you can comment and reply.
- name: Checkout repository
if: github.event_name == 'pull_request_target' && github.event.pull_request.merged == true
uses: actions/checkout@v4
- name: Check if PR author is maintainer
if: github.event.pull_request.merged == true
id: maintainer-check
run: |
if [ -f .github/maintainers.txt ] && grep -qx "${{ github.event.pull_request.user.login }}" .github/maintainers.txt; then
echo "skip=true" >> $GITHUB_OUTPUT
fi
- name: Auto Comment on Pull Request Merged
uses: actions-cool/pr-welcome@main
if: github.event.pull_request.merged == true
if: github.event.pull_request.merged == true && steps.maintainer-check.outputs.skip != 'true'
with:
token: ${{ secrets.GH_TOKEN }}
comment: |
+8 -8
View File
@@ -6,10 +6,10 @@ on:
channel:
description: 'Release channel for desktop build (affects version suffix and workflow:set-desktop-version)'
required: true
default: nightly
default: canary
type: choice
options:
- nightly
- canary
- beta
- stable
build_macos:
@@ -118,8 +118,8 @@ jobs:
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
CSC_FOR_PULL_REQUEST: true
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
@@ -184,8 +184,8 @@ jobs:
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
TEMP: C:\temp
TMP: C:\temp
@@ -228,8 +228,8 @@ jobs:
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_PROJECT_ID || secrets.UMAMI_NIGHTLY_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'beta' && secrets.UMAMI_BETA_DESKTOP_BASE_URL || secrets.UMAMI_NIGHTLY_DESKTOP_BASE_URL }}
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_PROJECT_ID || secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ inputs.channel == 'stable' && secrets.UMAMI_STABLE_DESKTOP_BASE_URL || secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
- name: Upload artifact
uses: actions/upload-artifact@v6
+3 -3
View File
@@ -7,7 +7,7 @@ name: Release Desktop Beta
# 如: v2.0.0-beta.1, v2.0.0-alpha.1, v2.0.0-rc.1
#
# 注意: Stable 版本 (如 v2.0.0) 由 release-desktop-stable.yml 处理
# 注意: Nightly 版本 (如 v2.1.0-nightly.xxx) 由 release-desktop-nightly.yml 处理
# 注意: Nightly 版本已停用,不再参与 Desktop 发布流程
# ============================================
on:
@@ -41,10 +41,10 @@ jobs:
version="${version#v}"
echo "version=${version}" >> $GITHUB_OUTPUT
# Beta 版本包含 beta/alpha/rc (nightly 由 release-desktop-nightly.yml 处理)
# Beta 版本包含 beta/alpha/rcnightly 标签已停用
if [[ "$version" == *"nightly"* ]]; then
echo "is_beta=false" >> $GITHUB_OUTPUT
echo "⏭️ Skipping: $version is a nightly release (handled by release-desktop-nightly.yml)"
echo "⏭️ Skipping: $version is a disabled nightly release tag"
elif [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]] || [[ "$version" == *"rc"* ]]; then
echo "is_beta=true" >> $GITHUB_OUTPUT
echo "✅ Beta release detected: $version"
+65 -22
View File
@@ -45,6 +45,7 @@ jobs:
name: Calculate Canary Version
runs-on: ubuntu-latest
outputs:
release_notes: ${{ steps.release-notes.outputs.release_notes }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
should_build: ${{ steps.check.outputs.should_build }}
@@ -121,6 +122,66 @@ jobs:
echo "✅ Canary version: ${version}"
echo "🏷️ Tag: ${tag}"
- name: Generate canary release notes
if: steps.check.outputs.should_build == 'true'
id: release-notes
env:
TAG: ${{ steps.version.outputs.tag }}
run: |
previous_canary=$(git tag --sort=-creatordate | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-canary\.[0-9]+$' | head -n 1)
latest_stable=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
if [ -n "$previous_canary" ]; then
compare_from="$previous_canary"
compare_range="${previous_canary}..HEAD"
elif [ -n "$latest_stable" ]; then
compare_from="$latest_stable"
compare_range="${latest_stable}..HEAD"
else
compare_from="initial commit"
compare_range="HEAD"
fi
commit_count=$(git rev-list --count "$compare_range")
commits=$(git log --no-merges --pretty='- `%h` %s (%an)' "$compare_range")
if [ -z "$commits" ]; then
commits='- No new commits recorded.'
fi
{
echo "release_notes<<EOF"
echo "## 🐤 Canary Build — ${TAG}"
echo
echo "> Automated canary build from \`canary\` branch."
echo
echo "### Commit Information"
echo
echo "- Based on changes since \`${compare_from}\`"
echo "- Commit count: ${commit_count}"
echo
printf '%s\n' "$commits"
echo
echo "### ⚠️ Important Notes"
echo
echo "- **This is an automated canary build and is NOT intended for production use.**"
echo "- Canary builds are triggered by \`build\`/\`fix\`/\`style\` commits on the \`canary\` branch."
echo "- May contain **unstable or incomplete changes**. **Use at your own risk.**"
echo "- It is strongly recommended to **back up your data** before using a canary build."
echo
echo "### 📦 Installation"
echo
echo "Download the appropriate installer for your platform from the assets below."
echo
echo "| Platform | File |"
echo "|----------|------|"
echo "| macOS (Apple Silicon) | \`.dmg\` (arm64) |"
echo "| macOS (Intel) | \`.dmg\` (x64) |"
echo "| Windows | \`.exe\` |"
echo "| Linux | \`.AppImage\` / \`.deb\` |"
echo "EOF"
} >> $GITHUB_OUTPUT
# ============================================
# 代码质量检查
# ============================================
@@ -182,6 +243,7 @@ jobs:
env:
UPDATE_CHANNEL: canary
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
@@ -201,6 +263,7 @@ jobs:
env:
UPDATE_CHANNEL: canary
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
@@ -216,6 +279,7 @@ jobs:
env:
UPDATE_CHANNEL: canary
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
RELEASE_NOTES: ${{ needs.calculate-version.outputs.release_notes }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
@@ -299,28 +363,7 @@ jobs:
tag_name: ${{ needs.calculate-version.outputs.tag }}
name: 'Desktop Canary ${{ needs.calculate-version.outputs.tag }}'
prerelease: true
body: |
## 🐤 Canary Build — ${{ needs.calculate-version.outputs.tag }}
> Automated canary build from `canary` branch.
### ⚠️ Important Notes
- **This is an automated canary build and is NOT intended for production use.**
- Canary builds are triggered by `build`/`fix`/`style` commits on the `canary` branch.
- May contain **unstable or incomplete changes**. **Use at your own risk.**
- It is strongly recommended to **back up your data** before using a canary build.
### 📦 Installation
Download the appropriate installer for your platform from the assets below.
| Platform | File |
|----------|------|
| macOS (Apple Silicon) | `.dmg` (arm64) |
| macOS (Intel) | `.dmg` (x64) |
| Windows | `.exe` |
| Linux | `.AppImage` / `.deb` |
body: ${{ needs.calculate-version.outputs.release_notes }}
files: |
release/latest*
release/*.dmg*
@@ -1,415 +0,0 @@
name: Release Desktop Nightly
# ============================================
# Nightly 自动发版工作流
# ============================================
# 触发条件:
# 1. 定时: 每天 UTC+8 14:00 (UTC 06:00)
# 2. 手动触发 (workflow_dispatch)
#
# 版本策略:
# 基于最新 tag 的 minor+1, 格式: X.(Y+1).0-nightly.YYYYMMDDHHMM
# 例: 当前 tag v2.0.12 → v2.1.0-nightly.202502091400
# 使用精确到分钟的时间戳避免同一天多次触发时 tag 冲突
# ============================================
on:
schedule:
- cron: '0 6 * * *'
workflow_dispatch:
inputs:
force:
description: 'Force build (skip diff check)'
required: false
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions: read-all
env:
NODE_VERSION: '24.11.1'
jobs:
# ============================================
# 计算 Nightly 版本号
# ============================================
calculate-version:
name: Calculate Nightly Version
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
has_changes: ${{ steps.changes.outputs.has_changes }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Check for code changes since last nightly
id: changes
run: |
# 手动触发 + force 时跳过 diff 检查
if [ "${{ inputs.force }}" == "true" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "🔧 Force build requested, skipping diff check"
exit 0
fi
# 查找上一个 nightly tag
last_nightly=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.' | head -n 1)
if [ -z "$last_nightly" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "📦 No previous nightly tag found, proceeding with first nightly build"
exit 0
fi
echo "📌 Last nightly tag: $last_nightly"
# 对比指定目录是否有变更
changes=$(git diff --name-only "$last_nightly"..HEAD -- package.json src/ packages/ apps/desktop/)
if [ -z "$changes" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "⏭️ No code changes since $last_nightly, skipping nightly build"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
change_count=$(echo "$changes" | wc -l | tr -d ' ')
echo "✅ ${change_count} file(s) changed since $last_nightly:"
echo "$changes" | head -20
[ "$change_count" -gt 20 ] && echo " ... and $((change_count - 20)) more"
fi
- name: Calculate nightly version
if: steps.changes.outputs.has_changes == 'true'
id: version
run: |
# 获取最新的 tag (排除 nightly tag)
latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)
if [ -z "$latest_tag" ]; then
echo "❌ No stable tag found"
exit 1
fi
echo "📌 Latest stable tag: $latest_tag"
# 去掉 v 前缀
base_version="${latest_tag#v}"
# 解析 major.minor.patch
IFS='.' read -r major minor patch <<< "$base_version"
# minor + 1, patch 归零
new_minor=$((minor + 1))
timestamp=$(date -u +"%Y%m%d%H%M")
version="${major}.${new_minor}.0-nightly.${timestamp}"
tag="v${version}"
echo "version=${version}" >> $GITHUB_OUTPUT
echo "tag=${tag}" >> $GITHUB_OUTPUT
echo "✅ Nightly version: ${version}"
echo "🏷️ Tag: ${tag}"
# ============================================
# 代码质量检查
# ============================================
test:
name: Code quality check
needs: [calculate-version]
if: needs.calculate-version.outputs.has_changes == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout base
uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/setup-env
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install deps
run: pnpm install
- name: Lint
run: bun run lint
# ============================================
# 多平台构建
# ============================================
build:
needs: [calculate-version, test]
if: needs.calculate-version.outputs.has_changes == 'true'
name: Build Desktop App
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-15, macos-15-intel, windows-2025, ubuntu-latest]
steps:
- uses: actions/checkout@v6
- name: Setup build environment
uses: ./.github/actions/desktop-build-setup
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set package version
run: npm run workflow:set-desktop-version ${{ needs.calculate-version.outputs.version }} nightly
# macOS 构建前清理 (修复 hdiutil 问题 https://github.com/electron-userland/electron-builder/issues/8415)
- name: Clean previous build artifacts (macOS)
if: runner.os == 'macOS'
run: |
sudo rm -rf apps/desktop/release || true
sudo rm -rf apps/desktop/dist || true
sudo rm -rf /tmp/electron-builder* || true
# macOS 构建
- name: Build artifact on macOS
if: runner.os == 'macOS'
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: nightly
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE_BASE64 }}
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
CSC_FOR_PULL_REQUEST: true
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
# Windows 构建
- name: Build artifact on Windows
if: runner.os == 'Windows'
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: nightly
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
TEMP: C:\temp
TMP: C:\temp
# Linux 构建
- name: Build artifact on Linux
if: runner.os == 'Linux'
run: npm run desktop:package:app
env:
UPDATE_CHANNEL: nightly
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
APP_URL: http://localhost:3015
DATABASE_URL: 'postgresql://postgres@localhost:5432/postgres'
KEY_VAULTS_SECRET: 'oLXWIiR/AKF+rWaqy9lHkrYgzpATbW3CtJp3UfkVgpE='
NEXT_PUBLIC_DESKTOP_PROJECT_ID: ${{ secrets.UMAMI_BETA_DESKTOP_PROJECT_ID }}
NEXT_PUBLIC_DESKTOP_UMAMI_BASE_URL: ${{ secrets.UMAMI_BETA_DESKTOP_BASE_URL }}
- name: Upload artifacts
uses: ./.github/actions/desktop-upload-artifacts
with:
artifact-name: release-${{ matrix.os }}
retention-days: 3
# ============================================
# 合并 macOS 多架构 latest-mac.yml 文件
# ============================================
merge-mac-files:
needs: [build]
name: Merge macOS Release Files
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup environment
uses: ./.github/actions/setup-env
with:
node-version: ${{ env.NODE_VERSION }}
- name: Download artifacts
uses: actions/download-artifact@v7
with:
path: release
pattern: release-*
merge-multiple: true
- name: List downloaded artifacts
run: ls -R release
- name: Install yaml only for merge step
run: |
cd scripts/electronWorkflow
if [ ! -f package.json ]; then
echo '{"name":"merge-mac-release","private":true}' > package.json
fi
bun add --no-save yaml@2.8.1
- name: Merge latest-mac.yml files
run: bun run scripts/electronWorkflow/mergeMacReleaseFiles.js
- name: Upload artifacts with merged macOS files
uses: actions/upload-artifact@v6
with:
name: merged-release
path: release/
retention-days: 1
# ============================================
# 创建 Nightly Release
# ============================================
publish-release:
needs: [merge-mac-files, calculate-version]
name: Publish Nightly Release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download merged artifacts
uses: actions/download-artifact@v7
with:
name: merged-release
path: release
- name: List final artifacts
run: ls -R release
- name: Create Nightly Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ needs.calculate-version.outputs.tag }}
name: 'Desktop Nightly ${{ needs.calculate-version.outputs.tag }}'
prerelease: true
body: |
## 🌙 Nightly Build — ${{ needs.calculate-version.outputs.tag }}
> Automated nightly build from `main` branch.
### ⚠️ Important Notes
- **This is an automated nightly build and is NOT intended for production use.**
- Nightly builds are generated from the latest `main` branch and may contain **unstable, untested, or incomplete features**.
- **No guarantees** are made regarding stability, data integrity, or backward compatibility.
- Bugs, crashes, and breaking changes are expected. **Use at your own risk.**
- **Do NOT report bugs** from nightly builds unless you can reproduce them on the latest beta or stable release.
- Nightly builds may have **different update channels** — they will not auto-update to/from stable or beta versions.
- It is strongly recommended to **back up your data** before using a nightly build.
### 📦 Installation
Download the appropriate installer for your platform from the assets below.
| Platform | File |
|----------|------|
| macOS (Apple Silicon) | `.dmg` (arm64) |
| macOS (Intel) | `.dmg` (x64) |
| Windows | `.exe` |
| Linux | `.AppImage` / `.deb` |
files: |
release/latest*
release/*.dmg*
release/*.zip*
release/*.exe*
release/*.AppImage
release/*.deb*
release/*.snap*
release/*.rpm*
release/*.tar.gz*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ============================================
# 发布到 S3 更新服务器
# ============================================
publish-s3:
needs: [merge-mac-files, calculate-version]
name: Publish to S3
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/desktop-publish-s3
with:
channel: nightly
version: ${{ needs.calculate-version.outputs.version }}
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
s3-region: ${{ secrets.UPDATE_S3_REGION }}
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
# ============================================
# 清理旧的 Nightly Releases (保留最近 7 个)
# ============================================
cleanup-old-nightlies:
needs: [publish-release, publish-s3]
name: Cleanup Old Nightly Releases
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Delete old nightly GitHub releases
uses: actions/github-script@v7
with:
script: |
const { data: releases } = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const nightlyReleases = releases
.filter(r => r.tag_name.includes('-nightly.'))
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
const toDelete = nightlyReleases.slice(7);
for (const release of toDelete) {
console.log(`🗑️ Deleting old nightly release: ${release.tag_name}`);
// Delete the release
await github.rest.repos.deleteRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
});
// Delete the tag
try {
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${release.tag_name}`,
});
} catch (e) {
console.log(`⚠️ Could not delete tag ${release.tag_name}: ${e.message}`);
}
}
console.log(`✅ Cleanup complete. Kept ${Math.min(nightlyReleases.length, 7)} nightly releases, deleted ${toDelete.length}.`);
- name: Cleanup old S3 versions
uses: ./.github/actions/desktop-cleanup-s3
with:
channel: nightly
keep-count: '15'
aws-access-key-id: ${{ secrets.UPDATE_AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.UPDATE_AWS_SECRET_ACCESS_KEY }}
s3-bucket: ${{ secrets.UPDATE_S3_BUCKET }}
s3-region: ${{ secrets.UPDATE_S3_REGION }}
s3-endpoint: ${{ secrets.UPDATE_S3_ENDPOINT }}
+89
View File
@@ -0,0 +1,89 @@
name: Release ModelBank
permissions:
contents: write
id-token: write
on:
push:
branches:
- canary
paths:
- packages/model-bank/**
workflow_dispatch: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build ModelBank
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.11.1
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Build package
run: pnpm --filter model-bank build
publish:
name: Publish ModelBank
if: ${{ github.event_name == 'workflow_dispatch' }}
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24.11.1
registry-url: https://registry.npmjs.org
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install dependencies
run: pnpm install
- name: Bump patch version
id: version
run: |
npm version patch --no-git-tag-version --prefix packages/model-bank
echo "version=$(node -p 'require(\"./packages/model-bank/package.json\").version')" >> "$GITHUB_OUTPUT"
- name: Build package
run: pnpm --filter model-bank build
- name: Publish to npm
run: npm publish --provenance
working-directory: packages/model-bank
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Commit version bump
env:
MODEL_BANK_VERSION: ${{ steps.version.outputs.version }}
run: |
git config user.name "lobehubbot"
git config user.email "i@lobehub.com"
git add packages/model-bank/package.json
git commit -m "🔖 chore(model-bank): release v${MODEL_BANK_VERSION}"
git push
+9 -1
View File
@@ -25,6 +25,9 @@ Desktop.ini
*.code-workspace
.vscode/sessions.json
prd
# Recordings
.records/
# Temporary files
.temp/
temp/
@@ -137,5 +140,10 @@ pnpm-lock.yaml
.turbo
spaHtmlTemplates.ts
# Embedded CLI bundle (built at pack time)
apps/desktop/resources/bin/lobe-cli.js
apps/desktop/resources/cli-package.json
# Superpowers plugin brainstorm/spec outputs (local only; do not commit)
.superpowers/
docs/superpowers
docs/superpowers/
+4 -4
View File
@@ -1,6 +1,6 @@
const { defineConfig } = require('@lobehub/i18n-cli');
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
module.exports = defineConfig({
entry: 'locales/en-US',
@@ -27,14 +27,14 @@ module.exports = defineConfig({
],
temperature: 0,
saveImmediately: true,
modelName: 'chatgpt-4o-latest',
modelName: 'gpt-5.1-chat-latest',
experimental: {
jsonMode: true,
},
markdown: {
reference:
'You need to maintain the component format of the mdx file; the output text does not need to be wrapped in any code block syntax on the outermost layer.\n' +
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf-8'),
fs.readFileSync(path.join(__dirname, 'docs/glossary.md'), 'utf8'),
entry: ['./README.md', './docs/**/*.md', './docs/**/*.mdx'],
entryLocale: 'en-US',
outputLocales: ['zh-CN'],
+7 -7
View File
@@ -1,8 +1,8 @@
# Lobe Chat - Contributing Guide 🌟
# LobeHub - Contributing Guide 🌟
We're thrilled that you want to contribute to Lobe Chat, the future of communication! 😄
We're thrilled that you want to contribute to LobeHub, the future of communication! 😄
Lobe Chat is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
LobeHub is an open-source project, and we welcome your collaboration. Before you jump in, let's make sure you're all set to contribute effectively and have loads of fun along the way!
## Table of Contents
@@ -69,11 +69,11 @@ git fetch upstream
git merge upstream/main
```
This ensures you're working on the most current version of Lobe Chat. Stay fresh! 💨
This ensures you're working on the most current version of LobeHub. Stay fresh! 💨
## Open a Pull Request
🚀 Time to share your contribution! Head over to the original Lobe Chat repository and open a Pull Request (PR). Our maintainers will review your work.
🚀 Time to share your contribution! Head over to the original LobeHub repository and open a Pull Request (PR). Our maintainers will review your work.
## Review and Collaboration
@@ -81,8 +81,8 @@ This ensures you're working on the most current version of Lobe Chat. Stay fresh
## Celebrate 🎉
🎈 Congratulations! Your contribution is now part of Lobe Chat. 🥳
🎈 Congratulations! Your contribution is now part of LobeHub. 🥳
Thank you for making Lobe Chat even more magical. We can't wait to see what you create! 🌠
Thank you for making LobeHub even more magical. We can't wait to see what you create! 🌠
Happy Coding! 🚀🦄
+81
View File
@@ -0,0 +1,81 @@
# Security Policy
## Supported Versions
We only provide security fixes for the **latest 2.x release**. Older versions (including all 1.x releases) are end-of-life and will not receive patches.
| Version | Supported |
| ------------ | --------- |
| 2.x (latest) | ✅ |
| 1.x | ❌ |
| 0.x | ❌ |
If you are running a 1.x deployment, we strongly recommend upgrading to the latest 2.x release.
## Reporting a Vulnerability
Please report security vulnerabilities through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/lobehub/lobehub/security/advisories/new) tab.
**Please do not report security vulnerabilities through public GitHub issues.**
### Response Timeline
- **Acknowledgement**: We aim to respond to all reports within **7 days**.
- **Fix**: Confirmed vulnerabilities will be addressed within **30 days**.
- **Urgent issues**: If you believe the vulnerability is critical and actively exploitable, you can reach out directly on Discord (`arvinxu`) for faster coordination.
### What to Include
A good vulnerability report should include:
- A clear description of the issue and its potential impact
- The affected version (must be the latest 2.x release)
- Step-by-step reproduction instructions or a working PoC
- Any relevant logs, screenshots, or code references
## Scope
### In Scope
- Security issues affecting the **latest 2.x release** of LobeHub
- Vulnerabilities in the **server-side deployment** (LobeHub Cloud or self-hosted server mode)
- Issues that can be exploited **without requiring admin/owner access** to the deployment
### Out of Scope (Not a Vulnerability)
The following are considered **by design** or **out of scope** and will not be accepted as vulnerability reports:
#### 1. End-of-Life Versions
Any issue that only affects 1.x or earlier versions. This includes but is not limited to the `X-lobe-chat-auth` header mechanism, `webapi` route authentication, and other 1.x-specific architectures that have been completely removed in 2.x.
#### 2. File Proxy Public Access (`/f/:id`)
The file proxy endpoint `/f/:id` uses randomly generated, non-enumerable IDs as [capability URLs](https://www.w3.org/TR/capability-urls/). This is a deliberate design choice, similar to how S3 presigned URLs or Google Docs sharing links work. Knowing the URL grants access — this is by design, not an authorization bypass.
#### 3. User Enumeration on Login Flows
Endpoints such as `check-user` that indicate whether an account exists are part of the standard login UX. This is a common and intentional pattern used by most modern authentication flows.
#### 4. Self-Hosted Client-Side API Key Storage
In self-hosted client-side mode, users configure their own API keys which are stored in the browser's local storage. This is the expected behavior for client-side deployments where the user is both the operator and the consumer.
#### 5. Issues Requiring Admin or Owner Privileges
Actions that require administrative access to the deployment (e.g., environment variable configuration, server-side settings) are not considered security vulnerabilities, as the admin is already a trusted party.
#### 6. Theoretical Attacks Without Practical Impact
Reports based on theoretical attack scenarios without a working proof of concept against a realistic deployment, or issues that require unlikely preconditions (e.g., physical access to the server, pre-existing compromise of the host system).
## Disclosure Policy
- We follow [coordinated vulnerability disclosure](https://en.wikipedia.org/wiki/Coordinated_vulnerability_disclosure).
- We will credit reporters in the security advisory unless they prefer to remain anonymous.
- Please allow us reasonable time to address the issue before any public disclosure.
## Contact
- **Primary**: [GitHub Security Advisories](https://github.com/lobehub/lobehub/security/advisories/new)
- **Urgent**: Discord — `arvinxu`
+10 -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.1\-canary.14" "User Commands"
.TH LH 1 "" "@lobehub/cli 0.0.6" "User Commands"
.SH NAME
lh \- LobeHub CLI \- manage and connect to LobeHub services
.SH SYNOPSIS
@@ -83,6 +83,9 @@ Manage agent skills
.B session\-group
Manage agent session groups
.TP
.B task
Manage agent tasks
.TP
.B thread
Manage message threads
.TP
@@ -95,6 +98,9 @@ Manage messages
.B model
Manage AI models
.TP
.B notify
Send a callback message to a topic and trigger the agent to process it
.TP
.B provider
Manage AI providers
.TP
@@ -112,6 +118,9 @@ View usage statistics
.TP
.B eval
Manage evaluation workflows
.TP
.B migrate
Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)
.SH OPTIONS
.TP
.B \-V, \-\-version
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/cli",
"version": "0.0.1-canary.14",
"version": "0.0.6",
"type": "module",
"bin": {
"lh": "./dist/index.js",
@@ -37,6 +37,7 @@
"debug": "^4.4.0",
"diff": "^8.0.3",
"fast-glob": "^3.3.3",
"ignore": "^7.0.5",
"picocolors": "^1.1.1",
"superjson": "^2.2.6",
"tsdown": "^0.21.4",
+3 -1
View File
@@ -39,7 +39,9 @@ async function getAuthAndServer() {
const result = await getValidToken();
if (!result) {
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
log.error(
`No authentication found. Run 'lh login' (or 'npx -y @lobehub/cli login') first, or set ${CLI_API_KEY_ENV}.`,
);
process.exit(1);
}
+63 -22
View File
@@ -3,29 +3,9 @@ import { CLI_API_KEY_ENV } from '../constants/auth';
import { resolveServerUrl } from '../settings';
import { log } from '../utils/logger';
// Must match the server's SECRET_XOR_KEY (src/envs/auth.ts)
const SECRET_XOR_KEY = 'LobeHub · LobeHub';
/**
* XOR-obfuscate a payload and encode as Base64.
* The /webapi/* routes require `X-lobe-chat-auth` with this encoding.
*/
function obfuscatePayloadWithXOR(payload: Record<string, any>): string {
const jsonString = JSON.stringify(payload);
const dataBytes = new TextEncoder().encode(jsonString);
const keyBytes = new TextEncoder().encode(SECRET_XOR_KEY);
const result = new Uint8Array(dataBytes.length);
for (let i = 0; i < dataBytes.length; i++) {
result[i] = dataBytes[i] ^ keyBytes[i % keyBytes.length];
}
return btoa(String.fromCharCode(...result));
}
export interface AuthInfo {
accessToken: string;
/** Headers required for /webapi/* endpoints (includes both X-lobe-chat-auth and Oidc-Auth) */
/** Headers required for /webapi/* endpoints (Oidc-Auth for authentication) */
headers: Record<string, string>;
serverUrl: string;
}
@@ -52,8 +32,69 @@ export async function getAuthInfo(): Promise<AuthInfo> {
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': accessToken,
'X-lobe-chat-auth': obfuscatePayloadWithXOR({}),
},
serverUrl,
};
}
export type AgentStreamTokenType = 'jwt' | 'apiKey';
export interface AgentStreamAuthInfo {
headers: Record<string, string>;
serverUrl: string;
/**
* Raw token value (without header prefix). Used for WebSocket auth messages
* where header-based auth is not available.
*/
token: string;
/**
* How the token should be verified by downstream services (agent gateway WS).
* jwt → validate with JWKS
* apiKey → validate by calling /api/v1/users/me
*/
tokenType: AgentStreamTokenType;
}
export async function getAgentStreamAuthInfo(): Promise<AgentStreamAuthInfo> {
const serverUrl = resolveServerUrl();
const envJwt = process.env.LOBEHUB_JWT;
if (envJwt) {
return {
headers: { 'Oidc-Auth': envJwt },
serverUrl,
token: envJwt,
tokenType: 'jwt',
};
}
const envApiKey = process.env[CLI_API_KEY_ENV];
if (envApiKey) {
return {
headers: { 'X-API-Key': envApiKey },
serverUrl,
token: envApiKey,
tokenType: 'apiKey',
};
}
const result = await getValidToken();
if (!result) {
log.error(`No authentication found. Run 'lh login' first, or set ${CLI_API_KEY_ENV}.`);
process.exit(1);
return {
headers: {},
serverUrl,
token: '',
tokenType: 'jwt',
};
}
return {
headers: { 'Oidc-Auth': result.credentials.accessToken },
serverUrl,
token: result.credentials.accessToken,
tokenType: 'jwt',
};
}
+199 -7
View File
@@ -27,6 +27,9 @@ const { mockTrpcClient } = vi.hoisted(() => ({
execAgent: { mutate: vi.fn() },
getOperationStatus: { query: vi.fn() },
},
device: {
listDevices: { query: vi.fn() },
},
},
}));
@@ -38,13 +41,18 @@ const { mockStreamAgentEvents } = vi.hoisted(() => ({
mockStreamAgentEvents: vi.fn(),
}));
const { mockGetAuthInfo } = vi.hoisted(() => ({
mockGetAuthInfo: vi.fn(),
const { mockGetAgentStreamAuthInfo } = vi.hoisted(() => ({
mockGetAgentStreamAuthInfo: vi.fn(),
}));
const { mockResolveLocalDeviceId } = vi.hoisted(() => ({
mockResolveLocalDeviceId: vi.fn(),
}));
vi.mock('../api/client', () => ({ getTrpcClient: mockGetTrpcClient }));
vi.mock('../api/http', () => ({ getAuthInfo: mockGetAuthInfo }));
vi.mock('../api/http', () => ({ getAgentStreamAuthInfo: mockGetAgentStreamAuthInfo }));
vi.mock('../utils/agentStream', () => ({ streamAgentEvents: mockStreamAgentEvents }));
vi.mock('../utils/device', () => ({ resolveLocalDeviceId: mockResolveLocalDeviceId }));
vi.mock('../utils/logger', () => ({
log: { debug: vi.fn(), error: vi.fn(), heartbeat: vi.fn(), info: vi.fn(), warn: vi.fn() },
setVerbose: vi.fn(),
@@ -58,12 +66,12 @@ describe('agent command', () => {
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockGetAuthInfo.mockResolvedValue({
accessToken: 'test-token',
headers: { 'Content-Type': 'application/json', 'Oidc-Auth': 'test-token' },
mockGetAgentStreamAuthInfo.mockResolvedValue({
headers: { 'Oidc-Auth': 'test-token' },
serverUrl: 'https://example.com',
});
mockStreamAgentEvents.mockResolvedValue(undefined);
mockResolveLocalDeviceId.mockReset();
for (const method of Object.values(mockTrpcClient.agent)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
@@ -74,6 +82,11 @@ describe('agent command', () => {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
for (const method of Object.values(mockTrpcClient.device)) {
for (const fn of Object.values(method)) {
(fn as ReturnType<typeof vi.fn>).mockReset();
}
}
});
afterEach(() => {
@@ -297,7 +310,6 @@ describe('agent command', () => {
expect.objectContaining({ json: undefined, verbose: undefined }),
);
});
it('should support --slug option', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-456',
@@ -384,6 +396,186 @@ describe('agent command', () => {
);
});
it('should pass --device local as deviceId', async () => {
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
mockTrpcClient.device.listDevices.query.mockResolvedValue([
{ deviceId: 'local-device-1', online: true },
]);
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-device',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--device',
'local',
]);
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', deviceId: 'local-device-1', prompt: 'Hi' }),
);
});
it('should pass --topic-id and --device local together', async () => {
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
mockTrpcClient.device.listDevices.query.mockResolvedValue([
{ deviceId: 'local-device-1', online: true },
]);
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-topic-device',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--topic-id',
't1',
'--device',
'local',
]);
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ appContext: { topicId: 't1' }, deviceId: 'local-device-1' }),
);
});
it('should pass explicit --device id as deviceId', async () => {
mockTrpcClient.device.listDevices.query.mockResolvedValue([
{ deviceId: 'device-remote-1', online: true },
]);
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-explicit-device',
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--device',
'device-remote-1',
]);
expect(mockResolveLocalDeviceId).not.toHaveBeenCalled();
expect(mockTrpcClient.aiAgent.execAgent.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', deviceId: 'device-remote-1', prompt: 'Hi' }),
);
});
it('should exit when explicit device is not found', async () => {
mockTrpcClient.device.listDevices.query.mockResolvedValue([
{ deviceId: 'other-device', online: true },
]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--device',
'device-remote-1',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('was not found'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when local device cannot be resolved', async () => {
mockResolveLocalDeviceId.mockReturnValue(undefined);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--device',
'local',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining("Run 'lh connect' first"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when local device is offline', async () => {
mockResolveLocalDeviceId.mockReturnValue('local-device-1');
mockTrpcClient.device.listDevices.query.mockResolvedValue([
{ deviceId: 'local-device-1', online: false },
]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--device',
'local',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('is not online'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should exit when explicit device is offline', async () => {
mockTrpcClient.device.listDevices.query.mockResolvedValue([
{ deviceId: 'device-remote-1', online: false },
]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'agent',
'run',
'--agent-id',
'a1',
'--prompt',
'Hi',
'--device',
'device-remote-1',
]);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('Bring it online'));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it('should pass --json to stream options', async () => {
mockTrpcClient.aiAgent.execAgent.mutate.mockResolvedValue({
operationId: 'op-j',
+81 -9
View File
@@ -4,8 +4,14 @@ import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { getAuthInfo } from '../api/http';
import { replayAgentEvents, streamAgentEvents } from '../utils/agentStream';
import { getAgentStreamAuthInfo } from '../api/http';
import { resolveAgentGatewayUrl } from '../settings';
import {
replayAgentEvents,
streamAgentEvents,
streamAgentEventsViaWebSocket,
} from '../utils/agentStream';
import { resolveLocalDeviceId } from '../utils/device';
import { confirm, outputJson, printTable, truncate } from '../utils/format';
import { log, setVerbose } from '../utils/logger';
@@ -248,17 +254,29 @@ export function registerAgentCommand(program: Command) {
.option('-p, --prompt <text>', 'User prompt')
.option('-t, --topic-id <id>', 'Reuse an existing topic')
.option('--no-auto-start', 'Do not auto-start the agent')
.option(
'--device <target>',
'Target device ID, or use "local" for the current connected device',
)
.option(
'--no-headless',
"Disable headless mode and wait for human approval on tool calls (default: headless — tools auto-run, matching the CLI's non-interactive nature)",
)
.option('--json', 'Output full JSON event stream')
.option('-v, --verbose', 'Show detailed tool call info')
.option('--replay <file>', 'Replay events from a saved JSON file (offline)')
.option('--sse', 'Force SSE stream instead of WebSocket gateway')
.action(
async (options: {
agentId?: string;
autoStart?: boolean;
device?: string;
headless?: boolean;
json?: boolean;
prompt?: string;
replay?: string;
slug?: string;
sse?: boolean;
topicId?: string;
verbose?: boolean;
}) => {
@@ -285,12 +303,53 @@ export function registerAgentCommand(program: Command) {
const client = await getTrpcClient();
let deviceId: string | undefined;
if (options.device !== undefined) {
if (options.device === 'local') {
deviceId = resolveLocalDeviceId();
if (!deviceId) {
log.error(
"No local device found. Run 'lh connect' first, then retry with --device local.",
);
process.exit(1);
return;
}
} else {
deviceId = options.device;
}
const devices = await client.device.listDevices.query();
const matchedDevice = devices.find(
(device: { deviceId?: string; online?: boolean }) => device.deviceId === deviceId,
);
if (!matchedDevice) {
log.error(`Device "${deviceId}" was not found. Check 'lh device list' and try again.`);
process.exit(1);
return;
}
if (!matchedDevice.online) {
log.error(
options.device === 'local'
? `Local device "${deviceId}" is not online. Reconnect with 'lh connect' and try again.`
: `Device "${deviceId}" is not online. Bring it online and try again.`,
);
process.exit(1);
return;
}
}
// 1. Exec agent to get operationId
const input: Record<string, any> = { prompt: options.prompt };
if (options.agentId) input.agentId = options.agentId;
if (deviceId) input.deviceId = deviceId;
if (options.slug) input.slug = options.slug;
if (options.topicId) input.appContext = { topicId: options.topicId };
if (options.autoStart === false) input.autoStart = false;
// commander's --no-headless sets `headless` to false. Anything else
// (undefined, true) → headless mode is on and tool calls auto-execute.
if (options.headless !== false) {
input.userInterventionConfig = { approvalMode: 'headless' };
}
const result = await client.aiAgent.execAgent.mutate(input as any);
const r = result as any;
@@ -305,14 +364,27 @@ export function registerAgentCommand(program: Command) {
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(r.topicId || 'n/a')}`);
}
// 2. Connect to SSE stream
const { serverUrl, headers } = await getAuthInfo();
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
// 2. Connect to stream (WebSocket via Gateway, or fallback to SSE)
const { serverUrl, headers, token, tokenType } = await getAgentStreamAuthInfo();
const agentGatewayUrl = options.sse ? undefined : resolveAgentGatewayUrl();
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
if (agentGatewayUrl) {
await streamAgentEventsViaWebSocket({
gatewayUrl: agentGatewayUrl,
json: options.json,
operationId,
serverUrl,
token,
tokenType,
verbose: options.verbose,
});
} else {
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
await streamAgentEvents(streamUrl, headers, {
json: options.json,
verbose: options.verbose,
});
}
},
);
+32 -1
View File
@@ -96,7 +96,7 @@ vi.mock('@lobechat/device-gateway-client', () => ({
// eslint-disable-next-line import-x/first
import { resolveToken } from '../auth/resolveToken';
// eslint-disable-next-line import-x/first
import { spawnDaemon, stopDaemon } from '../daemon/manager';
import { removeStatus, spawnDaemon, stopDaemon, writeStatus } from '../daemon/manager';
// eslint-disable-next-line import-x/first
import { loadSettings, saveSettings } from '../settings';
// eslint-disable-next-line import-x/first
@@ -130,6 +130,36 @@ describe('connect command', () => {
return program;
}
it('should persist deviceId in status for foreground connections', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
expect(writeStatus).toHaveBeenCalledWith(
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
);
clientEventHandlers.connected?.();
expect(writeStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
);
});
it('should persist deviceId in status for daemon child connections', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect', '--daemon-child']);
expect(writeStatus).toHaveBeenCalledWith(
expect.objectContaining({ connectionStatus: 'connecting', deviceId: 'mock-device-id' }),
);
clientEventHandlers.connected?.();
expect(writeStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ connectionStatus: 'connected', deviceId: 'mock-device-id' }),
);
});
it('should connect to gateway', async () => {
const program = createProgram();
await program.parseAsync(['node', 'test', 'connect']);
@@ -288,6 +318,7 @@ describe('connect command', () => {
}
expect(cleanupAllProcesses).toHaveBeenCalled();
expect(removeStatus).toHaveBeenCalled();
});
it('should handle auth_expired when refresh fails', async () => {
+9 -10
View File
@@ -221,16 +221,15 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
info(` Mode : ${isDaemonChild ? 'daemon' : 'foreground'}`);
info('───────────────────');
// Update status file for daemon mode
// Update local connection status so other CLI commands can resolve the current device
const updateStatus = (connectionStatus: string) => {
if (isDaemonChild) {
writeStatus({
connectionStatus,
gatewayUrl: resolvedGatewayUrl,
pid: process.pid,
startedAt: startedAt.toISOString(),
});
}
writeStatus({
connectionStatus,
deviceId: client.currentDeviceId,
gatewayUrl: resolvedGatewayUrl,
pid: process.pid,
startedAt: startedAt.toISOString(),
});
};
const startedAt = new Date();
@@ -333,8 +332,8 @@ async function runConnect(options: ConnectOptions, isDaemonChild: boolean) {
info('Shutting down...');
cleanupAllProcesses();
client.disconnect();
removeStatus();
if (isDaemonChild) {
removeStatus();
removePid();
}
};
+42 -1
View File
@@ -61,7 +61,6 @@ describe('generate command', () => {
headers: {
'Content-Type': 'application/json',
'Oidc-Auth': 'test-token',
'X-lobe-chat-auth': 'test-xor-token',
},
serverUrl: 'https://app.lobehub.com',
});
@@ -271,6 +270,48 @@ describe('generate command', () => {
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Video generation started'));
});
it('should pass image-to-video params', async () => {
mockTrpcClient.generationTopic.createTopic.mutate.mockResolvedValue('topic-3');
mockTrpcClient.video.createVideo.mutate.mockResolvedValue({
data: { generationId: 'gen-v2' },
success: true,
});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'generate',
'video',
'a cat waving',
'--model',
'cogvideox',
'--provider',
'zhipu',
'--image',
'https://example.com/first.png',
'--end-image',
'https://example.com/last.png',
'--images',
'https://example.com/a.png',
'https://example.com/b.png',
]);
expect(mockTrpcClient.video.createVideo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
generationTopicId: 'topic-3',
model: 'cogvideox',
params: {
endImageUrl: 'https://example.com/last.png',
imageUrl: 'https://example.com/first.png',
imageUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
prompt: 'a cat waving',
},
provider: 'zhipu',
}),
);
});
});
describe('tts', () => {
+10 -1
View File
@@ -6,13 +6,16 @@ import { getTrpcClient } from '../../api/client';
export function registerVideoCommand(parent: Command) {
parent
.command('video <prompt>')
.description('Generate a video from text')
.description('Generate a video from text or image(s)')
.requiredOption('-m, --model <model>', 'Model ID')
.requiredOption('-p, --provider <provider>', 'Provider name')
.option('--aspect-ratio <ratio>', 'Aspect ratio (e.g. 16:9)')
.option('--duration <sec>', 'Duration in seconds')
.option('--resolution <res>', 'Resolution (e.g. 720p, 1080p)')
.option('--seed <n>', 'Random seed')
.option('--image <url>', 'First-frame image URL (image-to-video)')
.option('--images <urls...>', 'Multiple reference image URLs')
.option('--end-image <url>', 'Last-frame image URL')
.option('--json', 'Output raw JSON')
.action(
async (
@@ -20,6 +23,9 @@ export function registerVideoCommand(parent: Command) {
options: {
aspectRatio?: string;
duration?: string;
endImage?: string;
image?: string;
images?: string[];
json?: boolean;
model: string;
provider: string;
@@ -35,6 +41,9 @@ export function registerVideoCommand(parent: Command) {
if (options.duration) params.duration = Number.parseInt(options.duration, 10);
if (options.resolution) params.resolution = options.resolution;
if (options.seed) params.seed = Number.parseInt(options.seed, 10);
if (options.image) params.imageUrl = options.image;
if (options.images && options.images.length > 0) params.imageUrls = options.images;
if (options.endImage) params.endImageUrl = options.endImage;
const result = await client.video.createVideo.mutate({
generationTopicId: topicId as string,
+51
View File
@@ -79,6 +79,57 @@ describe('message command', () => {
);
expect(mockTrpcClient.message.listAll.query).not.toHaveBeenCalled();
});
it('should keep first page on the backend default offset for filtered queries', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'message',
'list',
'--topic-id',
't1',
'-L',
'200',
]);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 200, topicId: 't1' }),
);
});
it('should convert page 2 to current 1 for filtered queries', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'message',
'list',
'--topic-id',
't1',
'--page',
'2',
]);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ current: 1, topicId: 't1' }),
);
});
it('should support the short page flag for filtered queries', async () => {
mockTrpcClient.message.getMessages.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'message', 'list', '--topic-id', 't1', '-P', '2']);
expect(mockTrpcClient.message.getMessages.query).toHaveBeenCalledWith(
expect.objectContaining({ current: 1, topicId: 't1' }),
);
});
});
describe('search', () => {
+4 -2
View File
@@ -16,7 +16,7 @@ export function registerMessageCommand(program: Command) {
.option('--topic-id <id>', 'Filter by topic ID')
.option('--agent-id <id>', 'Filter by agent ID')
.option('-L, --limit <n>', 'Page size', '30')
.option('--page <n>', 'Page number', '1')
.option('-P, --page <n>', 'Page number', '1')
.option('--user', 'Only show user messages')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
@@ -32,7 +32,9 @@ export function registerMessageCommand(program: Command) {
const hasFilter = options.topicId || options.agentId;
const pageSize = options.limit ? Number.parseInt(options.limit, 10) : undefined;
const current = options.page ? Number.parseInt(options.page, 10) : undefined;
const current = options.page
? Math.max(Number.parseInt(options.page, 10) - 1, 0)
: undefined;
let items: any[];
+11
View File
@@ -0,0 +1,11 @@
import type { Command } from 'commander';
import { registerOpenClawMigration } from './openclaw';
export function registerMigrateCommand(program: Command) {
const migrate = program
.command('migrate')
.description('Migrate data from external tools (OpenClaw, ChatGPT, Claude, etc.)');
registerOpenClawMigration(migrate);
}
@@ -0,0 +1,588 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// ── Mocks ──────────────────────────────────────────────
const { mockTrpcClient } = vi.hoisted(() => ({
mockTrpcClient: {
agent: {
createAgent: { mutate: vi.fn() },
getBuiltinAgent: { query: vi.fn() },
},
agentDocument: {
upsertDocument: { mutate: vi.fn() },
},
},
}));
const { getTrpcClient: mockGetTrpcClient } = vi.hoisted(() => ({
getTrpcClient: vi.fn(),
}));
const { mockConfirm } = vi.hoisted(() => ({
mockConfirm: vi.fn(),
}));
vi.mock('../../api/client', () => ({
getTrpcClient: mockGetTrpcClient,
}));
vi.mock('../../settings', () => ({
resolveServerUrl: () => 'https://app.lobehub.com',
}));
vi.mock('../../utils/format', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, confirm: mockConfirm };
});
vi.mock('../../utils/logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
setVerbose: vi.fn(),
}));
// eslint-disable-next-line import-x/first
import { log } from '../../utils/logger';
// eslint-disable-next-line import-x/first
import { registerOpenClawMigration } from './openclaw';
// ── Helpers ────────────────────────────────────────────
let tmpDir: string;
function createProgram() {
const program = new Command();
program.exitOverride();
const migrate = program.command('migrate');
registerOpenClawMigration(migrate);
return program;
}
function writeFile(relativePath: string, content: string) {
const fullPath = path.join(tmpDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
// ── Setup / teardown ───────────────────────────────────
let exitSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-test-'));
exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as any);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
mockGetTrpcClient.mockResolvedValue(mockTrpcClient);
mockConfirm.mockResolvedValue(true);
});
afterEach(() => {
exitSpy.mockRestore();
consoleSpy.mockRestore();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ── Tests ──────────────────────────────────────────────
describe('migrate openclaw', () => {
// ── Profile parsing ────────────────────────────────
describe('agent profile from workspace', () => {
it('should read name, description, and emoji from IDENTITY.md', async () => {
writeFile(
'IDENTITY.md',
['# IDENTITY.md', '- **Name:** 龙虾', '- **Creature:** AI 助手', '- **Emoji:** 🦞'].join(
'\n',
),
);
writeFile('hello.md', 'hello');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
config: {
avatar: '🦞',
description: 'AI 助手',
title: '龙虾',
},
});
});
it('should filter out placeholder emoji like (待定)', async () => {
writeFile(
'IDENTITY.md',
['# IDENTITY.md', '- **Name:** TestBot', '- **Emoji:**', ' _(待定)_'].join('\n'),
);
writeFile('hello.md', 'hello');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
config: {
avatar: undefined,
description: undefined,
title: 'TestBot',
},
});
});
it('should fall back to "OpenClaw" when no identity files exist', async () => {
writeFile('doc.md', 'content');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledWith({
config: {
avatar: undefined,
description: undefined,
title: 'OpenClaw',
},
});
});
});
// ── File filtering ─────────────────────────────────
describe('file collection and filtering', () => {
it('should exclude common directories like node_modules and .git', async () => {
writeFile('README.md', 'readme');
writeFile('node_modules/pkg/index.js', 'module');
writeFile('.git/config', 'git');
writeFile('.idea/workspace.xml', 'ide');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ filename: 'README.md' }),
);
});
it('should exclude files matching glob patterns like *.pyc and *.log', async () => {
writeFile('main.py', 'print("hi")');
writeFile('main.pyc', 'bytecode');
writeFile('app.log', 'log data');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ filename: 'main.py' }),
);
});
it('should respect workspace .gitignore', async () => {
writeFile('.gitignore', 'secret.txt\ndata/\n');
writeFile('README.md', 'readme');
writeFile('secret.txt', 'password');
writeFile('data/dump.sql', 'sql');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls.map(
(c: any[]) => c[0].filename,
);
expect(filenames).toContain('README.md');
expect(filenames).not.toContain('secret.txt');
expect(filenames).not.toContain('data/dump.sql');
});
it('should skip binary files during import', async () => {
writeFile('readme.md', 'text content');
// Write a file with null bytes (binary)
const binPath = path.join(tmpDir, 'image.dat');
fs.writeFileSync(binPath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x01]));
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
// Only the text file should be upserted
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ filename: 'readme.md' }),
);
// Binary file should show as skipped in output
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(allOutput).toContain('skipped');
});
it('should exclude database files by extension', async () => {
writeFile('data.md', 'notes');
writeFile('local.sqlite', 'fake-sqlite');
writeFile('app.db', 'fake-db');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(1);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ filename: 'data.md' }),
);
});
it('should collect files in subdirectories', async () => {
writeFile('docs/guide.md', 'guide');
writeFile('docs/api.md', 'api');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
const filenames = mockTrpcClient.agentDocument.upsertDocument.mutate.mock.calls
.map((c: any[]) => c[0].filename)
.sort();
expect(filenames).toEqual(['docs/api.md', 'docs/guide.md']);
});
});
// ── Dry run ────────────────────────────────────────
describe('--dry-run', () => {
it('should list files without calling API', async () => {
writeFile('file.md', 'content');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--dry-run',
]);
expect(mockGetTrpcClient).not.toHaveBeenCalled();
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).not.toHaveBeenCalled();
expect(log.info).toHaveBeenCalledWith(expect.stringContaining('Dry run'));
});
});
// ── Agent resolution ───────────────────────────────
describe('agent resolution', () => {
it('should use --agent-id directly when provided', async () => {
writeFile('file.md', 'content');
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--agent-id',
'agt_existing',
'--yes',
]);
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'agt_existing' }),
);
});
it('should resolve agent by --slug', async () => {
writeFile('file.md', 'content');
mockTrpcClient.agent.getBuiltinAgent.query.mockResolvedValue({ id: 'agt_inbox' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--slug',
'inbox',
'--yes',
]);
expect(mockTrpcClient.agent.getBuiltinAgent.query).toHaveBeenCalledWith({ slug: 'inbox' });
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'agt_inbox' }),
);
});
it('should create a new agent by default', async () => {
writeFile('file.md', 'content');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_new' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agent.createAgent.mutate).toHaveBeenCalledTimes(1);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'agt_new' }),
);
});
});
// ── Confirmation ───────────────────────────────────
describe('confirmation', () => {
it('should cancel when user declines', async () => {
writeFile('file.md', 'content');
mockConfirm.mockResolvedValue(false);
const program = createProgram();
await program.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', tmpDir]);
expect(mockTrpcClient.agent.createAgent.mutate).not.toHaveBeenCalled();
expect(consoleSpy).toHaveBeenCalledWith('Cancelled.');
});
it('should skip confirmation with --yes', async () => {
writeFile('file.md', 'content');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockConfirm).not.toHaveBeenCalled();
});
});
// ── Error handling ─────────────────────────────────
describe('error handling', () => {
it('should exit when source path does not exist', async () => {
const program = createProgram();
await program
.parseAsync(['node', 'test', 'migrate', 'openclaw', '--source', '/nonexistent/path'])
.catch(() => {}); // process.exit throws
expect(exitSpy).toHaveBeenCalledWith(1);
expect(log.error).toHaveBeenCalledWith(expect.stringContaining('not found'));
});
it('should report failed files without aborting', async () => {
writeFile('a.md', 'ok');
writeFile('b.md', 'fail');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
// Files are iterated in readdir order; mock first success then failure
mockTrpcClient.agentDocument.upsertDocument.mutate
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error('upload error'));
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
expect(mockTrpcClient.agentDocument.upsertDocument.mutate).toHaveBeenCalledTimes(2);
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(allOutput).toContain('1 imported');
expect(allOutput).toContain('1 failed');
});
it('should show no files message for empty workspace', async () => {
// Only excluded items
writeFile('.git/config', 'git');
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--dry-run',
]);
expect(log.info).toHaveBeenCalledWith('No files found in workspace.');
});
});
// ── Output ─────────────────────────────────────────
describe('output', () => {
it('should print agent URL on completion', async () => {
writeFile('file.md', 'content');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_abc123' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(allOutput).toContain('https://app.lobehub.com/agent/agt_abc123');
});
it('should show friendly completion message on success', async () => {
writeFile('file.md', 'content');
mockTrpcClient.agent.createAgent.mutate.mockResolvedValue({ agentId: 'agt_test' });
mockTrpcClient.agentDocument.upsertDocument.mutate.mockResolvedValue({});
const program = createProgram();
await program.parseAsync([
'node',
'test',
'migrate',
'openclaw',
'--source',
tmpDir,
'--yes',
]);
const allOutput = consoleSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
expect(allOutput).toContain('Migration complete');
});
});
});
+466
View File
@@ -0,0 +1,466 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Command } from 'commander';
import ignore from 'ignore';
import pc from 'picocolors';
import type { TrpcClient } from '../../api/client';
import { getTrpcClient } from '../../api/client';
import { resolveServerUrl } from '../../settings';
import { confirm } from '../../utils/format';
import { log } from '../../utils/logger';
const DEFAULT_AGENT_NAME = 'OpenClaw';
// Files to look for agent identity (tried in order)
const IDENTITY_FILES = ['IDENTITY.md', 'SOUL.md'];
// Default ignore rules (gitignore syntax) applied when no .gitignore is found
const DEFAULT_IGNORE_RULES = [
// VCS
'.git',
'.svn',
'.hg',
// OpenClaw internal
'.openclaw',
// OS artifacts
'.DS_Store',
'Thumbs.db',
'desktop.ini',
// IDE / editor
'.idea',
'.vscode',
'.fleet',
'.cursor',
'.zed',
'*.swp',
'*.swo',
'*~',
// Dependencies
'node_modules',
'.pnp',
'.yarn',
'bower_components',
'vendor',
'jspm_packages',
// Python
'.venv',
'venv',
'env',
'__pycache__',
'*.pyc',
'*.pyo',
'.mypy_cache',
'.ruff_cache',
'.pytest_cache',
'.tox',
'.eggs',
'*.egg-info',
// Ruby
'.bundle',
// Rust
'target',
// Go
'go.sum',
// Java / JVM
'.gradle',
'.m2',
// .NET
'bin',
'obj',
'packages',
// Build / cache / output
'.cache',
'.parcel-cache',
'.next',
'.nuxt',
'.turbo',
'.output',
'dist',
'build',
'out',
'.sass-cache',
// Env / secrets
'.env',
'.env.*',
// Test / coverage
'coverage',
'.nyc_output',
// Infra
'.terraform',
// Temp
'tmp',
'.tmp',
// Logs
'*.log',
'logs',
// Databases
'*.sqlite',
'*.sqlite3',
'*.db',
'*.db-shm',
'*.db-wal',
'*.ldb',
'*.mdb',
'*.accdb',
// Archives / binaries
'*.zip',
'*.tar',
'*.tar.gz',
'*.tgz',
'*.gz',
'*.bz2',
'*.xz',
'*.rar',
'*.7z',
'*.jar',
'*.war',
'*.dll',
'*.so',
'*.dylib',
'*.exe',
'*.bin',
'*.o',
'*.a',
'*.lib',
'*.class',
// Images / media / fonts
'*.png',
'*.jpg',
'*.jpeg',
'*.gif',
'*.bmp',
'*.ico',
'*.webp',
'*.svg',
'*.mp3',
'*.mp4',
'*.wav',
'*.avi',
'*.mov',
'*.mkv',
'*.flac',
'*.ogg',
'*.pdf',
'*.woff',
'*.woff2',
'*.ttf',
'*.otf',
'*.eot',
// Lock files
'package-lock.json',
'yarn.lock',
'pnpm-lock.yaml',
'Gemfile.lock',
'Cargo.lock',
'poetry.lock',
'composer.lock',
];
interface AgentProfile {
avatar?: string;
description?: string;
title: string;
}
/**
* Try to extract the agent name, description, and avatar emoji from
* IDENTITY.md or SOUL.md. Falls back to "OpenClaw" if neither file
* exists or parsing fails.
*/
function readAgentProfile(workspacePath: string): AgentProfile {
for (const filename of IDENTITY_FILES) {
const filePath = path.join(workspacePath, filename);
if (!fs.existsSync(filePath)) continue;
const content = fs.readFileSync(filePath, 'utf8');
// Try to extract **Name:** value
const nameMatch = content.match(/\*{0,2}Name:?\*{0,2}\s*(.+)/i);
const title = nameMatch ? nameMatch[1].trim() : DEFAULT_AGENT_NAME;
// Try to extract **Creature:** or **Vibe:** or **Description:** as description
const descMatch = content.match(/\*{0,2}(?:Creature|Vibe|Description):?\*{0,2}\s*(.+)/i);
const description = descMatch ? descMatch[1].trim() : undefined;
// Try to extract **Emoji:** value (single emoji)
const emojiMatch = content.match(/\*{0,2}Emoji:?\*{0,2}\s*(.+)/i);
const rawAvatar = emojiMatch ? emojiMatch[1].trim() : undefined;
// Filter out placeholder text like (待定)(Chinese TBD), _(待定)_, (TBD), N/A, etc.
const isPlaceholder =
rawAvatar && /^[_*(].*[)_*]$|^(?:tbd|todo|n\/?a|none|待定|未定)$/i.test(rawAvatar);
const avatar = rawAvatar && !isPlaceholder ? rawAvatar : undefined;
return { avatar, description, title };
}
return { title: DEFAULT_AGENT_NAME };
}
/**
* Build an ignore filter for the workspace. Uses .gitignore if present,
* otherwise falls back to a comprehensive default rule set.
*/
function buildIgnoreFilter(workspacePath: string) {
const ig = ignore();
const gitignorePath = path.join(workspacePath, '.gitignore');
if (fs.existsSync(gitignorePath)) {
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
}
// Always apply default rules on top
ig.add(DEFAULT_IGNORE_RULES);
return ig;
}
/**
* Recursively collect all files under `dir`, filtered by ignore rules.
* Returns paths relative to `baseDir`.
*/
function collectFiles(dir: string, baseDir: string, ig: ReturnType<typeof ignore>): string[] {
const results: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const relativePath = path.relative(baseDir, path.join(dir, entry.name));
// Directories need a trailing slash for ignore to match correctly
const testPath = entry.isDirectory() ? `${relativePath}/` : relativePath;
if (ig.ignores(testPath)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...collectFiles(fullPath, baseDir, ig));
} else if (entry.isFile()) {
results.push(relativePath);
}
}
return results;
}
/**
* Quick check: read the first 8KB and look for null bytes.
* If found, the file is likely binary and should be skipped.
*/
function isBinaryFile(filePath: string): boolean {
const fd = fs.openSync(filePath, 'r');
try {
const buf = Buffer.alloc(8192);
const bytesRead = fs.readSync(fd, buf, 0, 8192, 0);
for (let i = 0; i < bytesRead; i++) {
if (buf[i] === 0) return true;
}
return false;
} finally {
fs.closeSync(fd);
}
}
function formatAgentLabel(profile: AgentProfile): string {
return profile.avatar ? `${profile.avatar} ${profile.title}` : profile.title;
}
/**
* Resolve the target agent ID.
* Priority: --agent-id > --slug > create new agent from workspace profile.
*/
async function resolveAgentId(
client: TrpcClient,
opts: { agentId?: string; slug?: string },
profile: AgentProfile,
): Promise<string> {
if (opts.agentId) return opts.agentId;
if (opts.slug) {
const agent = await client.agent.getBuiltinAgent.query({ slug: opts.slug });
if (!agent) {
log.error(`Agent not found for slug: ${opts.slug}`);
process.exit(1);
}
return agent.id;
}
const label = formatAgentLabel(profile);
log.info(`Creating new agent ${pc.bold(label)}...`);
const result = await client.agent.createAgent.mutate({
config: {
avatar: profile.avatar,
description: profile.description,
title: profile.title,
},
});
const id = result.agentId;
if (!id) {
log.error('Failed to create agent — no agentId returned.');
process.exit(1);
}
console.log(`${pc.green('✓')} Agent created: ${pc.bold(label)}`);
return id;
}
export function registerOpenClawMigration(migrate: Command) {
migrate
.command('openclaw')
.description('Import OpenClaw workspace files as agent documents')
.option(
'--source <path>',
'Path to OpenClaw workspace',
path.join(os.homedir(), '.openclaw', 'workspace'),
)
.option('--agent-id <id>', 'Import into an existing agent by ID')
.option('--slug <slug>', 'Import into an existing agent by slug (e.g. "inbox")')
.option('--dry-run', 'Preview files without importing')
.option('--yes', 'Skip confirmation prompt')
.action(
async (options: {
agentId?: string;
dryRun?: boolean;
slug?: string;
source: string;
yes?: boolean;
}) => {
// Check auth early so users don't scan files only to find out they're not logged in
if (!options.dryRun) {
await getTrpcClient();
}
const workspacePath = path.resolve(options.source);
// Validate source directory
if (!fs.existsSync(workspacePath)) {
log.error(`OpenClaw workspace not found: ${workspacePath}`);
process.exit(1);
}
if (!fs.statSync(workspacePath).isDirectory()) {
log.error(`Not a directory: ${workspacePath}`);
process.exit(1);
}
// Read agent profile from workspace identity files
const profile = readAgentProfile(workspacePath);
const label = formatAgentLabel(profile);
// Collect files (respects .gitignore + default rules)
const ig = buildIgnoreFilter(workspacePath);
const files = collectFiles(workspacePath, workspacePath, ig);
if (files.length === 0) {
log.info('No files found in workspace.');
return;
}
console.log(
`Found ${pc.bold(String(files.length))} file(s) in ${pc.dim(workspacePath)}:\n`,
);
for (const f of files) {
console.log(` ${pc.dim('•')} ${f}`);
}
console.log();
if (options.dryRun) {
log.info('Dry run — no changes made.');
return;
}
// Confirm
if (!options.yes) {
const target = options.agentId
? `agent ${pc.bold(options.agentId)}`
: options.slug
? `agent slug "${pc.bold(options.slug)}"`
: `a new ${pc.bold(label)} agent`;
const confirmed = await confirm(
`Import ${files.length} file(s) as agent documents into ${target}?`,
);
if (!confirmed) {
console.log('Cancelled.');
return;
}
}
const client = await getTrpcClient();
// Create or reuse agent
const agentId = await resolveAgentId(client, options, profile);
console.log(`\nImporting to ${pc.bold(label)}...\n`);
let success = 0;
let failed = 0;
let skipped = 0;
for (const relativePath of files) {
const fullPath = path.join(workspacePath, relativePath);
try {
// Skip binary files that slipped through the extension filter
if (isBinaryFile(fullPath)) {
console.log(` ${pc.dim('○')} ${relativePath} ${pc.dim('(binary, skipped)')}`);
skipped++;
continue;
}
const content = fs.readFileSync(fullPath, 'utf8');
const stat = fs.statSync(fullPath);
await client.agentDocument.upsertDocument.mutate({
agentId,
content,
createdAt: stat.birthtime,
filename: relativePath,
updatedAt: stat.mtime,
});
console.log(` ${pc.green('✓')} ${relativePath}`);
success++;
} catch (err: any) {
console.log(` ${pc.red('✗')} ${relativePath}${err.message || err}`);
failed++;
}
}
const agentUrl = `${resolveServerUrl()}/agent/${agentId}`;
const skippedInfo = skipped > 0 ? `, ${skipped} skipped` : '';
console.log();
if (failed === 0) {
console.log(
`${pc.green('✓')} Migration complete! ${pc.bold(String(success))} file(s) imported to ${pc.bold(label)}.${skippedInfo}`,
);
} else {
console.log(
`${pc.yellow('⚠')} Migration finished with issues: ${pc.bold(String(success))} imported, ${pc.red(String(failed))} failed${skippedInfo}.`,
);
}
console.log(`\n ${pc.dim('→')} ${pc.underline(agentUrl)}`);
console.log();
},
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../api/client';
import { log } from '../utils/logger';
export function registerNotifyCommand(program: Command) {
program
.command('notify')
.description('Send a callback message to a topic and trigger the agent to process it')
.requiredOption('--topic <topicId>', 'Target topic ID')
.requiredOption('-c, --content <content>', 'Message content')
.option('--agent-id <agentId>', 'Agent ID (overrides topic default)')
.option('--thread-id <threadId>', 'Thread ID for threaded conversations')
.option('--json', 'Output JSON')
.action(
async (options: {
agentId?: string;
content: string;
json?: boolean;
threadId?: string;
topic: string;
}) => {
log.debug('notify: topic=%s, agentId=%s', options.topic, options.agentId);
const client = await getTrpcClient();
try {
const result = await client.agentNotify.notify.mutate({
agentId: options.agentId,
content: options.content,
threadId: options.threadId,
topicId: options.topic,
});
if (options.json) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(`${pc.green('✓')} Message sent to topic ${pc.bold(result.topicId)}`);
if (result.operationId) {
console.log(` Operation ID: ${result.operationId}`);
}
} catch (error: any) {
console.error(`${pc.red('✗')} Failed to send notification: ${error.message}`);
process.exit(1);
}
},
);
}
+83 -16
View File
@@ -2,10 +2,12 @@ import type { Command } from 'commander';
import pc from 'picocolors';
import { getTrpcClient } from '../../api/client';
import type { KanbanColumn } from '../../utils/format';
import {
confirm,
displayWidth,
outputJson,
printKanban,
printTable,
timeAgo,
truncate,
@@ -37,10 +39,12 @@ export function registerTaskCommand(program: Command) {
.option('-L, --limit <n>', 'Page size', '50')
.option('--offset <n>', 'Offset', '0')
.option('--tree', 'Display as tree structure')
.option('--board', 'Display as kanban board grouped by status')
.option('--json [fields]', 'Output JSON')
.action(
async (options: {
agent?: string;
board?: boolean;
json?: string | boolean;
limit?: string;
offset?: string;
@@ -59,8 +63,8 @@ export function registerTaskCommand(program: Command) {
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
// For tree mode, fetch all tasks (no pagination limit)
if (options.tree) {
// For tree/board mode, fetch all tasks (no pagination limit)
if (options.tree || options.board) {
input.limit = 100;
delete input.offset;
}
@@ -77,6 +81,58 @@ export function registerTaskCommand(program: Command) {
return;
}
if (options.board) {
// Kanban board grouped by status
const statusOrder = [
'backlog',
'blocked',
'running',
'paused',
'completed',
'failed',
'timeout',
'canceled',
];
const statusColors: Record<string, (s: string) => string> = {
backlog: pc.dim,
blocked: pc.red,
canceled: pc.dim,
completed: pc.green,
failed: pc.red,
paused: pc.yellow,
running: pc.blue,
timeout: pc.red,
};
// Group tasks by status
const grouped = new Map<string, any[]>();
for (const t of result.data) {
const status = t.status || 'backlog';
const list = grouped.get(status) || [];
list.push(t);
grouped.set(status, list);
}
const kanbanColumns: KanbanColumn[] = statusOrder
.filter((s) => grouped.has(s))
.map((status) => ({
color: statusColors[status],
items: grouped.get(status)!.map((t: any) => ({
badge: pc.dim(t.identifier),
meta: t.assigneeAgentId ? `agent: ${t.assigneeAgentId}` : undefined,
title: t.name || t.instruction,
})),
title: status.toUpperCase(),
}));
console.log();
printKanban(kanbanColumns);
console.log();
log.info(`Total: ${result.total}`);
return;
}
if (options.tree) {
// Build tree display
const taskMap = new Map<string, any>();
@@ -240,23 +296,34 @@ export function registerTaskCommand(program: Command) {
}
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
// ── Subtasks ──
// ── Subtasks (nested tree) ──
if (t.subtasks && t.subtasks.length > 0) {
// Build lookup: which subtasks are completed
const completedIdentifiers = new Set(
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
);
// Build lookup: which subtasks are completed (flatten tree)
const collectCompleted = (nodes: typeof t.subtasks, set: Set<string>): Set<string> => {
for (const s of nodes!) {
if (s.status === 'completed') set.add(s.identifier);
if (s.children) collectCompleted(s.children, set);
}
return set;
};
const completedIdentifiers = collectCompleted(t.subtasks, new Set());
const renderSubtasks = (nodes: typeof t.subtasks, indent: string) => {
for (const s of nodes!) {
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
console.log(
`${indent}${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
);
if (s.children && s.children.length > 0) {
renderSubtasks(s.children, indent + ' ');
}
}
};
console.log(`\n${pc.bold('Subtasks:')}`);
for (const s of t.subtasks) {
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
console.log(
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
);
}
renderSubtasks(t.subtasks, ' ');
}
// ── Dependencies ──
+42
View File
@@ -77,6 +77,48 @@ describe('topic command', () => {
expect.objectContaining({ agentId: 'a1' }),
);
});
it('should keep first page on the backend default offset', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-L', '200']);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', pageSize: 200 }),
);
});
it('should convert page 2 to current 1', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync([
'node',
'test',
'topic',
'list',
'--agent-id',
'a1',
'--page',
'2',
]);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', current: 1 }),
);
});
it('should support the short page flag', async () => {
mockTrpcClient.topic.getTopics.query.mockResolvedValue([]);
const program = createProgram();
await program.parseAsync(['node', 'test', 'topic', 'list', '--agent-id', 'a1', '-P', '2']);
expect(mockTrpcClient.topic.getTopics.query).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'a1', current: 1 }),
);
});
});
describe('search', () => {
+3 -2
View File
@@ -17,7 +17,7 @@ export function registerTopicCommand(program: Command) {
.description('List topics')
.option('--agent-id <id>', 'Filter by agent ID')
.option('-L, --limit <n>', 'Page size', '30')
.option('--page <n>', 'Page number', '1')
.option('-P, --page <n>', 'Page number', '1')
.option('--json [fields]', 'Output JSON, optionally specify fields (comma-separated)')
.action(
async (options: {
@@ -31,7 +31,8 @@ export function registerTopicCommand(program: Command) {
const input: Record<string, any> = {};
if (options.agentId) input.agentId = options.agentId;
if (options.limit) input.pageSize = Number.parseInt(options.limit, 10);
if (options.page) input.current = Number.parseInt(options.page, 10);
const page = options.page ? Number.parseInt(options.page, 10) : undefined;
if (page !== undefined && page > 1) input.current = page - 1;
const result = await client.topic.getTopics.query(input as any);
const items = Array.isArray(result) ? result : ((result as any).items ?? []);
+1
View File
@@ -1,2 +1,3 @@
export const OFFICIAL_AGENT_GATEWAY_URL = 'https://agent-gateway.lobehub.com';
export const OFFICIAL_SERVER_URL = 'https://app.lobehub.com';
export const OFFICIAL_GATEWAY_URL = 'https://device-gateway.lobehub.com';
+2 -1
View File
@@ -23,6 +23,7 @@ function getLogFilePath() {
export interface DaemonStatus {
connectionStatus: string;
deviceId?: string;
gatewayUrl: string;
pid: number;
startedAt: string;
@@ -159,7 +160,7 @@ export function spawnDaemon(args: string[]): number {
// Re-run the same entry with --daemon-child (internal flag)
const child = spawn(process.execPath, [...process.execArgv, ...args, '--daemon-child'], {
detached: true,
env: { ...process.env, LOBEHUB_DAEMON: '1' },
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', LOBEHUB_DAEMON: '1' },
stdio: ['ignore', logFd, logFd],
});
+1 -1
View File
@@ -1,3 +1,3 @@
import { createProgram } from './program';
createProgram().parse();
createProgram().parse(process.argv, { from: 'node' });
+6
View File
@@ -20,13 +20,16 @@ import { registerLogoutCommand } from './commands/logout';
import { registerManCommand } from './commands/man';
import { registerMemoryCommand } from './commands/memory';
import { registerMessageCommand } from './commands/message';
import { registerMigrateCommand } from './commands/migrate';
import { registerModelCommand } from './commands/model';
import { registerNotifyCommand } from './commands/notify';
import { registerPluginCommand } from './commands/plugin';
import { registerProviderCommand } from './commands/provider';
import { registerSearchCommand } from './commands/search';
import { registerSessionGroupCommand } from './commands/session-group';
import { registerSkillCommand } from './commands/skill';
import { registerStatusCommand } from './commands/status';
import { registerTaskCommand } from './commands/task';
import { registerThreadCommand } from './commands/thread';
import { registerTopicCommand } from './commands/topic';
import { registerUserCommand } from './commands/user';
@@ -61,15 +64,18 @@ export function createProgram() {
registerFileCommand(program);
registerSkillCommand(program);
registerSessionGroupCommand(program);
registerTaskCommand(program);
registerThreadCommand(program);
registerTopicCommand(program);
registerMessageCommand(program);
registerModelCommand(program);
registerNotifyCommand(program);
registerProviderCommand(program);
registerPluginCommand(program);
registerUserCommand(program);
registerConfigCommand(program);
registerEvalCommand(program);
registerMigrateCommand(program);
return program;
}
+16 -4
View File
@@ -2,10 +2,11 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { OFFICIAL_SERVER_URL } from '../constants/urls';
import { OFFICIAL_AGENT_GATEWAY_URL, OFFICIAL_SERVER_URL } from '../constants/urls';
import { log } from '../utils/logger';
export interface StoredSettings {
agentGatewayUrl?: string;
gatewayUrl?: string;
serverUrl?: string;
}
@@ -25,15 +26,24 @@ export function resolveServerUrl(): string {
return envServerUrl || settingsServerUrl || OFFICIAL_SERVER_URL;
}
export function resolveAgentGatewayUrl(): string | undefined {
const envUrl = normalizeUrl(process.env.AGENT_GATEWAY_URL);
const settingsUrl = normalizeUrl(loadSettings()?.agentGatewayUrl);
return envUrl || settingsUrl || OFFICIAL_AGENT_GATEWAY_URL;
}
export function saveSettings(settings: StoredSettings): void {
const serverUrl = normalizeUrl(settings.serverUrl);
const agentGatewayUrl = normalizeUrl(settings.agentGatewayUrl);
const gatewayUrl = normalizeUrl(settings.gatewayUrl);
const serverUrl = normalizeUrl(settings.serverUrl);
const normalized: StoredSettings = {
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
gatewayUrl,
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
};
if (!normalized.serverUrl && !normalized.gatewayUrl) {
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) {
try {
fs.unlinkSync(SETTINGS_FILE);
} catch {}
@@ -50,14 +60,16 @@ export function loadSettings(): StoredSettings | null {
try {
const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
const parsed = JSON.parse(data) as StoredSettings;
const agentGatewayUrl = normalizeUrl(parsed.agentGatewayUrl);
const gatewayUrl = normalizeUrl(parsed.gatewayUrl);
const serverUrl = normalizeUrl(parsed.serverUrl);
const normalized: StoredSettings = {
agentGatewayUrl: agentGatewayUrl === OFFICIAL_AGENT_GATEWAY_URL ? undefined : agentGatewayUrl,
gatewayUrl,
serverUrl: serverUrl === OFFICIAL_SERVER_URL ? undefined : serverUrl,
};
if (!normalized.serverUrl && !normalized.gatewayUrl) return null;
if (!normalized.serverUrl && !normalized.gatewayUrl && !normalized.agentGatewayUrl) return null;
return normalized;
} catch {
+417 -1
View File
@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { streamAgentEvents } from './agentStream';
import { streamAgentEvents, streamAgentEventsViaWebSocket } from './agentStream';
vi.mock('./logger', () => ({
log: {
debug: vi.fn(),
error: vi.fn(),
heartbeat: vi.fn(),
info: vi.fn(),
@@ -193,3 +194,418 @@ describe('streamAgentEvents', () => {
exitSpy.mockRestore();
});
});
// ── WebSocket stream tests ──────────────────────────────
let capturedWs: MockWebSocket | undefined;
class MockWebSocket {
static OPEN = 1;
static CONNECTING = 0;
static CLOSED = 3;
readyState = MockWebSocket.CONNECTING;
onopen: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onerror: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
sent: string[] = [];
private autoAuthSuccess = true;
constructor(
public url: string,
autoAuth = true,
) {
this.autoAuthSuccess = autoAuth;
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
// Trigger onopen on next microtask (after handlers are assigned)
queueMicrotask(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.({ type: 'open' });
});
}
send(data: string) {
this.sent.push(data);
const msg = JSON.parse(data);
if (msg.type === 'auth' && this.autoAuthSuccess) {
queueMicrotask(() => {
this.onmessage?.({ data: JSON.stringify({ type: 'auth_success' }) });
});
}
}
close() {
this.readyState = MockWebSocket.CLOSED;
// Async like real WebSocket — fires after current microtask
queueMicrotask(() => this.onclose?.({ code: 1000, reason: '' }));
}
simulateMessage(msg: Record<string, unknown>) {
this.onmessage?.({ data: JSON.stringify(msg) });
}
}
describe('streamAgentEventsViaWebSocket', () => {
let stdoutSpy: ReturnType<typeof vi.spyOn>;
let consoleSpy: ReturnType<typeof vi.spyOn>;
const originalWebSocket = globalThis.WebSocket;
beforeEach(() => {
capturedWs = undefined;
stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
(globalThis as any).WebSocket = MockWebSocket;
});
afterEach(() => {
stdoutSpy.mockRestore();
consoleSpy.mockRestore();
globalThis.WebSocket = originalWebSocket;
});
/** Wait for microtasks + short delay so WS open/auth cycle completes */
const flush = () => new Promise((r) => setTimeout(r, 20));
it('should connect, authenticate, and send resume', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'test-token',
});
await flush();
const ws = capturedWs!;
// Note: serverUrl is not set here, and JSON.stringify drops undefined keys,
// so the parsed auth message will not contain a `serverUrl` field.
expect(ws.sent.map((s) => JSON.parse(s))).toEqual([
{ token: 'test-token', tokenType: 'jwt', type: 'auth' },
{ lastEventId: '', type: 'resume' },
]);
ws.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should send tokenType=apiKey and serverUrl when the caller uses an API key', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
serverUrl: 'https://app.lobehub.com',
token: 'lh_sk_abc',
tokenType: 'apiKey',
});
await flush();
const ws = capturedWs!;
// serverUrl is forwarded so the gateway can call back to /api/v1/users/me
// to verify the API key.
expect(ws.sent.map((s) => JSON.parse(s))[0]).toEqual({
serverUrl: 'https://app.lobehub.com',
token: 'lh_sk_abc',
tokenType: 'apiKey',
type: 'auth',
});
ws.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should render agent_event messages using existing renderEvent', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'test-token',
});
await flush();
const ws = capturedWs!;
ws.simulateMessage({
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 1, type: 'step_start' },
id: '1',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { chunkType: 'text', content: 'Hello WS!' },
operationId: 'op-1',
stepIndex: 0,
timestamp: 2,
type: 'stream_chunk',
},
id: '2',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { stepCount: 1 },
operationId: 'op-1',
stepIndex: 0,
timestamp: 3,
type: 'agent_runtime_end',
},
id: '3',
type: 'agent_event',
});
await promise;
expect(stdoutSpy).toHaveBeenCalledWith('Hello WS!');
});
it('should output JSON when json option is set', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
json: true,
operationId: 'op-1',
token: 'test-token',
});
await flush();
const ws = capturedWs!;
ws.simulateMessage({
event: {
data: null,
operationId: 'op-1',
stepIndex: 0,
timestamp: 1,
type: 'agent_runtime_init',
},
id: '1',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { stepCount: 1 },
operationId: 'op-1',
stepIndex: 0,
timestamp: 2,
type: 'agent_runtime_end',
},
id: '2',
type: 'agent_event',
});
await promise;
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_init"'));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('"agent_runtime_end"'));
});
it('should reject on auth failure', async () => {
// Override mock to return auth_failed instead of auth_success
(globalThis as any).WebSocket = class extends MockWebSocket {
constructor(url: string) {
super(url, false); // disable auto auth_success
capturedWs = this; // eslint-disable-line @typescript-eslint/no-this-alias
}
override send(data: string) {
this.sent.push(data);
const msg = JSON.parse(data);
if (msg.type === 'auth') {
queueMicrotask(() => {
this.onmessage?.({
data: JSON.stringify({ reason: 'invalid token', type: 'auth_failed' }),
});
});
}
}
};
await expect(
streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'bad-token',
}),
).rejects.toThrow('Gateway auth failed');
});
it('should resolve on session_complete', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'test-token',
});
await flush();
capturedWs!.simulateMessage({ id: '1', summary: 'All done', type: 'session_complete' });
await expect(promise).resolves.toBeUndefined();
});
it('should ignore heartbeat_ack messages', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'test-token',
});
await flush();
const ws = capturedWs!;
ws.simulateMessage({ type: 'heartbeat_ack' });
expect(stdoutSpy).not.toHaveBeenCalled();
ws.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should construct correct WebSocket URL from HTTPS gateway URL', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://agent-gateway.lobehub.com',
operationId: 'op-123',
token: 'tok',
});
await flush();
expect(capturedWs!.url).toBe('wss://agent-gateway.lobehub.com/ws?operationId=op-123');
capturedWs!.simulateMessage({ id: '1', type: 'session_complete' });
await promise;
});
it('should render a multi-step agent run with tool calls', async () => {
const promise = streamAgentEventsViaWebSocket({
gatewayUrl: 'https://gw.test.com',
operationId: 'op-1',
token: 'tok',
verbose: true,
});
await flush();
const ws = capturedWs!;
const { log } = await import('./logger');
// Step 1: thinking + text + tool call
ws.simulateMessage({
event: {
data: null,
operationId: 'op-1',
stepIndex: 0,
timestamp: 1,
type: 'agent_runtime_init',
},
id: '1',
type: 'agent_event',
});
ws.simulateMessage({
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 2, type: 'step_start' },
id: '2',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { chunkType: 'reasoning', reasoning: 'Let me search...' },
operationId: 'op-1',
stepIndex: 0,
timestamp: 3,
type: 'stream_chunk',
},
id: '3',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { chunkType: 'text', content: 'Searching for news.' },
operationId: 'op-1',
stepIndex: 0,
timestamp: 4,
type: 'stream_chunk',
},
id: '4',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { toolCalling: { apiName: 'search', id: 'tc-1' } },
operationId: 'op-1',
stepIndex: 0,
timestamp: 5,
type: 'tool_start',
},
id: '5',
type: 'agent_event',
});
ws.simulateMessage({
event: { data: null, operationId: 'op-1', stepIndex: 0, timestamp: 6, type: 'stream_end' },
id: '6',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { stepIndex: 0 },
operationId: 'op-1',
stepIndex: 0,
timestamp: 7,
type: 'step_complete',
},
id: '7',
type: 'agent_event',
});
// Step 2: tool result + final text
ws.simulateMessage({
event: { data: null, operationId: 'op-1', stepIndex: 1, timestamp: 8, type: 'step_start' },
id: '8',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: {
isSuccess: true,
payload: { toolCalling: { id: 'tc-1' } },
result: { content: 'Results...' },
},
operationId: 'op-1',
stepIndex: 1,
timestamp: 9,
type: 'tool_end',
},
id: '9',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { chunkType: 'text', content: 'Here are the results.' },
operationId: 'op-1',
stepIndex: 1,
timestamp: 10,
type: 'stream_chunk',
},
id: '10',
type: 'agent_event',
});
ws.simulateMessage({
event: {
data: { cost: { total: 0.05 }, stepCount: 2, usage: { total_tokens: 500 } },
operationId: 'op-1',
stepIndex: 1,
timestamp: 11,
type: 'agent_runtime_end',
},
id: '11',
type: 'agent_event',
});
await promise;
// Verify reasoning was rendered (dim)
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining('Let me search...'));
// Verify text chunks
expect(stdoutSpy).toHaveBeenCalledWith('Searching for news.');
expect(stdoutSpy).toHaveBeenCalledWith('Here are the results.');
// Verify tool call was logged
expect(log.toolCall).toHaveBeenCalledWith('search', 'tc-1', undefined);
// Verify tool result was logged
expect(log.toolResult).toHaveBeenCalled();
// Verify finish line
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Agent finished'));
});
});
+141
View File
@@ -1,4 +1,5 @@
import pc from 'picocolors';
import urlJoin from 'url-join';
import { log } from './logger';
@@ -16,6 +17,23 @@ interface StreamOptions {
verbose?: boolean;
}
interface WebSocketStreamOptions extends StreamOptions {
gatewayUrl: string;
operationId: string;
/**
* LobeHub server URL the gateway should call back to when verifying
* an apiKey token (via `/api/v1/users/me`). Required when
* `tokenType === 'apiKey'`; ignored for JWT.
*/
serverUrl?: string;
token: string;
/**
* How the gateway should verify `token`. `jwt` is the default for
* backwards compatibility with existing callers.
*/
tokenType?: 'jwt' | 'apiKey';
}
/**
* Connect to the agent SSE stream and render events to the terminal.
* Resolves when the stream ends (agent_runtime_end or connection close).
@@ -152,6 +170,129 @@ export function replayAgentEvents(events: AgentStreamEvent[], options: StreamOpt
}
}
const HEARTBEAT_INTERVAL = 30_000;
/**
* Connect to the Agent Gateway via WebSocket and render events to the terminal.
* Resolves when the session completes or the connection closes.
*/
export async function streamAgentEventsViaWebSocket(
options: WebSocketStreamOptions,
): Promise<void> {
const { gatewayUrl, operationId, serverUrl, token, tokenType = 'jwt', ...streamOpts } = options;
const wsUrl = urlJoin(
gatewayUrl.replace(/^http/, 'ws'),
`/ws?operationId=${encodeURIComponent(operationId)}`,
);
log.debug(`Connecting to gateway: ${wsUrl} (auth: ${tokenType})`);
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(wsUrl);
const jsonEvents: AgentStreamEvent[] = [];
const ctx = createRenderContext();
let lastEventId = '';
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
let jsonPrinted = false;
const cleanup = () => {
if (heartbeatTimer) clearInterval(heartbeatTimer);
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
};
ws.onopen = () => {
// `serverUrl` is required so the gateway can call back to verify an
// apiKey token. Harmless (but unused) for JWT, so we always include it
// when available to match the device-gateway-client contract.
ws.send(JSON.stringify({ serverUrl, token, tokenType, type: 'auth' }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data as string);
if (msg.type === 'auth_success') {
log.debug('Gateway authenticated');
// Request all buffered events (covers events pushed before WS connected)
ws.send(JSON.stringify({ lastEventId: '', type: 'resume' }));
heartbeatTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'heartbeat' }));
}
}, HEARTBEAT_INTERVAL);
return;
}
if (msg.type === 'auth_failed') {
cleanup();
reject(new Error(`Gateway auth failed: ${msg.reason}`));
return;
}
if (msg.type === 'heartbeat_ack') return;
if (msg.type === 'agent_event') {
const agentEvent: AgentStreamEvent = msg.event;
if (msg.id) lastEventId = msg.id;
if (streamOpts.json) {
jsonEvents.push(agentEvent);
} else {
renderEvent(agentEvent, ctx, streamOpts);
}
if (agentEvent.type === 'agent_runtime_end') {
if (streamOpts.json && !jsonPrinted) {
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
} else if (!streamOpts.json) {
renderEnd(agentEvent);
}
cleanup();
resolve();
return;
}
if (agentEvent.type === 'error') {
if (streamOpts.json && !jsonPrinted) {
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
}
log.error(
`Agent error: ${agentEvent.data?.message || agentEvent.data?.error || 'Unknown error'}`,
);
cleanup();
process.exit(1);
}
}
if (msg.type === 'session_complete') {
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
}
cleanup();
resolve();
}
};
ws.onerror = (err) => {
cleanup();
reject(err);
};
ws.onclose = () => {
if (heartbeatTimer) clearInterval(heartbeatTimer);
if (streamOpts.json && jsonEvents.length > 0 && !jsonPrinted) {
jsonPrinted = true;
console.log(JSON.stringify(jsonEvents, null, 2));
}
resolve();
};
});
}
// ── Render helpers ──────────────────────────────────────
interface RenderContext {
+5
View File
@@ -0,0 +1,5 @@
import { readStatus } from '../daemon/manager';
export function resolveLocalDeviceId(): string | undefined {
return readStatus()?.deviceId;
}
+96
View File
@@ -387,6 +387,102 @@ export function printCalendarHeatmap(
console.log();
}
// ── Kanban Board ─────────────────────────────────────
export interface KanbanColumn {
color?: (s: string) => string;
items: KanbanCard[];
title: string;
}
export interface KanbanCard {
badge?: string;
meta?: string;
title: string;
}
/**
* Render a kanban board with side-by-side columns.
* Adapts column width to terminal width automatically.
*/
export function printKanban(columns: KanbanColumn[]) {
// Filter out empty columns
const cols = columns.filter((c) => c.items.length > 0);
if (cols.length === 0) return;
const termWidth = process.stdout.columns || 100;
// Each column gets equal width, with 1-char gap between
const colWidth = Math.max(20, Math.floor((termWidth - (cols.length - 1)) / cols.length));
const innerWidth = colWidth - 4; // 2 chars border + 2 padding
const maxRows = Math.max(...cols.map((c) => c.items.length));
// ── Header ──
const topBorder = cols
.map((c) => {
const titleStr = ` ${c.title} (${c.items.length}) `;
const color = c.color || pc.white;
const remaining = colWidth - 2 - displayWidth(titleStr);
const left = Math.floor(remaining / 2);
const right = remaining - left;
return color(
'┌' + '─'.repeat(Math.max(0, left)) + titleStr + '─'.repeat(Math.max(0, right)) + '┐',
);
})
.join(' ');
console.log(topBorder);
// ── Rows ──
for (let row = 0; row < maxRows; row++) {
const line = cols
.map((c) => {
const color = c.color || pc.white;
const item = c.items[row];
if (!item) {
return color('│') + ' '.repeat(colWidth - 2) + color('│');
}
const badge = item.badge ? item.badge + ' ' : '';
const badgeWidth = displayWidth(badge);
const titleMaxWidth = innerWidth - badgeWidth;
const title = truncate(item.title, titleMaxWidth);
const titleWidth = displayWidth(title);
const pad = ' '.repeat(Math.max(0, colWidth - 2 - badgeWidth - titleWidth - 2));
return color('│') + ' ' + badge + title + pad + ' ' + color('│');
})
.join(' ');
console.log(line);
// Print meta line if any card in this row has meta
const hasMeta = cols.some((c) => c.items[row]?.meta);
if (hasMeta) {
const metaLine = cols
.map((c) => {
const color = c.color || pc.white;
const item = c.items[row];
if (!item?.meta) {
return color('│') + ' '.repeat(colWidth - 2) + color('│');
}
const meta = truncate(item.meta, innerWidth);
const metaWidth = displayWidth(meta);
const pad = ' '.repeat(Math.max(0, colWidth - 2 - metaWidth - 2));
return color('│') + ' ' + pc.dim(meta) + pad + ' ' + color('│');
})
.join(' ');
console.log(metaLine);
}
}
// ── Bottom border ──
const bottomBorder = cols
.map((c) => {
const color = c.color || pc.white;
return color('└' + '─'.repeat(colWidth - 2) + '┘');
})
.join(' ');
console.log(bottomBorder);
}
export function confirm(message: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stderr });
return new Promise((resolve) => {
+4
View File
@@ -9,6 +9,10 @@ export default defineConfig({
entry: ['src/index.ts'],
fixedExtension: false,
format: ['esm'],
minify: true,
outputOptions: {
codeSplitting: false,
},
platform: 'node',
target: 'node18',
});
+24 -1
View File
@@ -109,6 +109,26 @@ const config = {
console.info('📦 Downloading agent-browser binary...');
execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname });
// Build and copy CLI bundle for embedding
console.info('📦 Building CLI for embedding...');
execSync('npm run build', { stdio: 'inherit', cwd: path.resolve(__dirname, '../cli') });
const cliSrc = path.resolve(__dirname, '../cli/dist/index.js');
const cliDest = path.resolve(__dirname, 'resources/bin/lobe-cli.js');
await fs.copyFile(cliSrc, cliDest);
// Write a minimal package.json next to the CLI bundle so that
// createRequire('../package.json') resolves correctly in the packaged app.
// The CLI script lives at Resources/bin/lobe-cli.js, so '../package.json'
// resolves to Resources/package.json.
const cliPkg = JSON.parse(
await fs.readFile(path.resolve(__dirname, '../cli/package.json'), 'utf8'),
);
await fs.writeFile(
path.resolve(__dirname, 'resources/cli-package.json'),
JSON.stringify({ name: cliPkg.name, type: 'module', version: cliPkg.version }),
);
console.info('✅ CLI bundle copied to resources/bin/lobe-cli.js');
},
/**
* AfterPack hook for post-processing:
@@ -296,7 +316,10 @@ const config = {
releaseNotes: process.env.RELEASE_NOTES || undefined,
},
extraResources: [{ from: 'resources/bin', to: 'bin' }],
extraResources: [
{ from: 'resources/bin', to: 'bin' },
{ from: 'resources/cli-package.json', to: 'package.json' },
],
win: {
executableName: 'LobeHub',
-1
View File
@@ -90,7 +90,6 @@ export default defineConfig({
outDir: 'dist/preload',
sourcemap: isDev ? 'inline' : false,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src/main'),
+3 -1
View File
@@ -68,7 +68,9 @@
if (resolvedTheme === 'dark' || resolvedTheme === 'light') {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}
var locale = navigator.language || 'en-US';
// Check URL query parameter for locale (set by Electron main process from stored settings)
var urlParams = new URLSearchParams(window.location.search);
var locale = urlParams.get('lng') || navigator.language || 'en-US';
document.documentElement.lang = locale;
var rtl = ['ar', 'arc', 'dv', 'fa', 'ha', 'he', 'khw', 'ks', 'ku', 'ps', 'ur', 'yi'];
document.documentElement.dir =
+2 -1
View File
@@ -11,6 +11,7 @@
"author": "LobeHub",
"main": "./dist/main/index.js",
"scripts": {
"build:cli": "cd ../cli && bun run build",
"build:main": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build",
"build:run-unpack": "electron .",
"dev": "electron-vite dev",
@@ -68,7 +69,7 @@
"cookie": "^1.1.1",
"cross-env": "^10.1.0",
"diff": "^8.0.4",
"electron": "41.0.2",
"electron": "41.1.0",
"electron-builder": "^26.8.1",
"electron-devtools-installer": "4.0.0",
"electron-is": "^3.0.0",
@@ -5,7 +5,7 @@ import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
const VERSION = '0.20.1';
const VERSION = '0.24.0';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const binDir = path.join(__dirname, '..', 'resources', 'bin');
@@ -9,7 +9,7 @@ import { tagWhite, writeJSON } from './utils';
export const genDefaultLocale = () => {
consola.info(`默认语言为 ${i18nConfig.entryLocale}...`);
// 确保入口语言目录存在
// Ensure entry locale directory exists
const entryLocaleDir = localeDir(i18nConfig.entryLocale);
if (!existsSync(entryLocaleDir)) {
mkdirSync(entryLocaleDir, { recursive: true });
@@ -23,7 +23,7 @@ export const genDefaultLocale = () => {
for (const [ns, value] of data) {
const filepath = entryLocaleJsonFilepath(`${ns}.json`);
// 确保目录存在
// Ensure directory exists
const dir = dirname(filepath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
+6 -6
View File
@@ -5,7 +5,7 @@ import { genDefaultLocale } from './genDefaultLocale';
import { genDiff } from './genDiff';
import { split } from './utils';
// 确保所有语言目录存在
// Ensure all locale directories exist
const ensureLocalesDirs = () => {
[i18nConfig.entryLocale, ...i18nConfig.outputLocales].forEach((locale) => {
const dir = localeDir(locale);
@@ -15,20 +15,20 @@ const ensureLocalesDirs = () => {
});
};
// 运行工作流
// Run workflow
const run = async () => {
// 确保目录存在
// Ensure directories exist
ensureLocalesDirs();
// 差异分析
// Diff analysis
split('差异分析');
genDiff();
// 生成默认语言文件
// Generate default locale files
split('生成默认语言文件');
genDefaultLocale();
// 生成国际化文件
// Generate i18n files
split('生成国际化文件');
};
+4 -3
View File
@@ -12,6 +12,7 @@ import { BrowserWindow, shell } from 'electron';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
@@ -360,10 +361,10 @@ export default class AuthCtr extends ControllerModule {
logger.debug(`Polling for credentials: ${url.toString()}`);
// Send HTTP request directly
// Use Electron net.fetch to respect system CA store (self-signed/private CA certs)
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
appendVercelCookie(headers);
const response = await fetch(url.toString(), { headers, method: 'GET' });
const response = await netFetch(url.toString(), { headers, method: 'GET' });
// Check response status
if (response.status === 404) {
@@ -481,7 +482,7 @@ export default class AuthCtr extends ControllerModule {
'Content-Type': 'application/x-www-form-urlencoded',
};
appendVercelCookie(tokenHeaders);
const response = await fetch(tokenUrl.toString(), {
const response = await netFetch(tokenUrl.toString(), {
body,
headers: tokenHeaders,
method: 'POST',
@@ -0,0 +1,58 @@
import { exec } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { promisify } from 'node:util';
import { getCliWrapperDir } from '@/modules/cliEmbedding';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
import RemoteServerConfigCtr from './RemoteServerConfigCtr';
const logger = createLogger('controllers:CliCtr');
function normalizeServerUrl(url: string): string {
return url.replace(/\/$/, '');
}
export default class CliCtr extends ControllerModule {
static override readonly groupName = 'cli';
@IpcMethod()
async runCliCommand(args: string): Promise<{ exitCode: number; stderr: string; stdout: string }> {
const execAsync = promisify(exec);
const wrapperDir = getCliWrapperDir();
const cmd = process.platform === 'win32' ? 'lobehub.cmd' : 'lobehub';
const wrapperPath = path.join(wrapperDir, cmd);
const env = { ...process.env };
const remoteCtr = this.app.getController(RemoteServerConfigCtr);
if (remoteCtr) {
const [token, serverUrl] = await Promise.all([
remoteCtr.getAccessToken(),
remoteCtr.getRemoteServerUrl(),
]);
if (token && serverUrl) {
env.LOBEHUB_JWT = token;
env.LOBEHUB_SERVER = normalizeServerUrl(serverUrl);
logger.debug('Injected LOBEHUB_JWT / LOBEHUB_SERVER for CLI command');
}
}
try {
const { stdout, stderr } = await execAsync(`"${wrapperPath}" ${args}`, {
env,
timeout: 15_000,
});
return { exitCode: 0, stderr, stdout };
} catch (error: any) {
return {
exitCode: error.code ?? 1,
stderr: error.stderr ?? '',
stdout: error.stdout ?? String(error.message),
};
}
}
}
@@ -48,6 +48,7 @@ import { type FileResult, type SearchOptions } from '@/modules/fileSearch';
import ContentSearchService from '@/services/contentSearchSrv';
import FileSearchService from '@/services/fileSearchSrv';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
@@ -341,7 +342,7 @@ export default class LocalFileCtr extends ControllerModule {
}
try {
const response = await fetch(url);
const response = await netFetch(url);
if (!response.ok) {
throw new Error(
`Failed to download skill package: ${response.status} ${response.statusText}`,
@@ -3,7 +3,7 @@ import type {
ShowDesktopNotificationParams,
} from '@lobechat/electron-client-ipc';
import { app, Notification } from 'electron';
import { macOS, windows } from 'electron-is';
import { linux, macOS, windows } from 'electron-is';
import { getIpcContext } from '@/utils/ipc';
import { createLogger } from '@/utils/logger';
@@ -131,7 +131,12 @@ export default class NotificationCtr extends ControllerModule {
silent: params.silent || false,
timeoutType: 'default',
title: params.title,
urgency: 'normal',
// On Linux/GNOME Shell, urgency 'normal' causes notifications to appear as banners.
// Clicking the dismiss (X) button on such banners can freeze the system for 30-45 seconds
// due to heavy gnome-shell processing. Using 'low' urgency routes notifications to the
// message tray instead, preventing the banner's X button from being shown.
// The urgency option is ignored on macOS and Windows.
urgency: linux() ? 'low' : 'normal',
});
// Add more event listeners for debugging
@@ -9,6 +9,7 @@ import { OFFICIAL_CLOUD_SERVER } from '@/const/env';
import GatewayConnectionService from '@/services/gatewayConnectionSrv';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
import { ControllerModule, IpcMethod } from './index';
@@ -485,7 +486,7 @@ export default class RemoteServerConfigCtr extends ControllerModule {
'Content-Type': 'application/x-www-form-urlencoded',
};
appendVercelCookie(headers);
const response = await fetch(tokenUrl.toString(), { body, headers, method: 'POST' });
const response = await netFetch(tokenUrl.toString(), { body, headers, method: 'POST' });
if (!response.ok) {
// Try to parse error response
@@ -10,17 +10,38 @@ import { runCommand, ShellProcessManager } from '@lobechat/local-file-shell';
import { createLogger } from '@/utils/logger';
import CliCtr from './CliCtr';
import { ControllerModule, IpcMethod } from './index';
const logger = createLogger('controllers:ShellCommandCtr');
const processManager = new ShellProcessManager();
/** Prefix for a simple `lh`/`lobe`/`lobehub` invocation (keyword + boundary, args via slice). */
const SIMPLE_LH_PREFIX = /^\s*(?:lh|lobe|lobehub)(?=\s|$)/;
export default class ShellCommandCtr extends ControllerModule {
static override readonly groupName = 'shellCommand';
@IpcMethod()
async handleRunCommand(params: RunCommandParams): Promise<RunCommandResult> {
const prefixMatch = SIMPLE_LH_PREFIX.exec(params.command);
if (prefixMatch) {
const cliCtr = this.app.getController(CliCtr);
if (cliCtr) {
const args = params.command.slice(prefixMatch[0].length).trim();
logger.debug('Routing lh command to CliCtr.runCliCommand:', args);
const result = await cliCtr.runCliCommand(args);
return {
exit_code: result.exitCode,
output: result.stdout + result.stderr,
stderr: result.stderr,
stdout: result.stdout,
success: result.exitCode === 0,
};
}
}
return runCommand(params, { logger, processManager });
}
+18 -2
View File
@@ -1,3 +1,5 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import type { ElectronAppState, ThemeMode } from '@lobechat/electron-client-ipc';
@@ -169,7 +171,7 @@ export default class SystemController extends ControllerModule {
async selectFolder(payload?: {
defaultPath?: string;
title?: string;
}): Promise<string | undefined> {
}): Promise<{ path: string; repoType?: 'git' | 'github' } | undefined> {
const mainWindow = this.app.browserManager.getMainWindow()?.browserWindow;
const result = await dialog.showOpenDialog(mainWindow!, {
@@ -182,7 +184,10 @@ export default class SystemController extends ControllerModule {
return undefined;
}
return result.filePaths[0];
const folderPath = result.filePaths[0];
const repoType = await this.detectRepoType(folderPath);
return { path: folderPath, repoType };
}
@IpcMethod()
@@ -230,6 +235,17 @@ export default class SystemController extends ControllerModule {
}
}
private async detectRepoType(dirPath: string): Promise<'git' | 'github' | undefined> {
const gitConfigPath = path.join(dirPath, '.git', 'config');
try {
const config = await readFile(gitConfigPath, 'utf8');
if (config.includes('github.com')) return 'github';
return 'git';
} catch {
return undefined;
}
}
private async setSystemThemeMode(themeMode: ThemeMode) {
nativeTheme.themeSource = themeMode;
}
@@ -1,5 +1,6 @@
import type { UpdateChannel, UpdaterState } from '@lobechat/electron-client-ipc';
import { UPDATE_CHANNEL } from '@/modules/updater/configs';
import { createLogger } from '@/utils/logger';
import { ControllerModule, IpcMethod } from './index';
@@ -46,11 +47,11 @@ export default class UpdaterCtr extends ControllerModule {
@IpcMethod()
async getUpdateChannel(): Promise<UpdateChannel> {
return this.app.storeManager.get('updateChannel') ?? 'stable';
return this.app.storeManager.get('updateChannel') ?? UPDATE_CHANNEL;
}
/**
* Get the build-time channel (stable, nightly, canary, beta).
* Get the build-time channel (stable, canary, beta, or legacy nightly).
* Used for display in About page to distinguish pre-release builds.
*/
@IpcMethod()
@@ -61,11 +62,12 @@ export default class UpdaterCtr extends ControllerModule {
@IpcMethod()
async setUpdateChannel(channel: UpdateChannel): Promise<void> {
const validChannels = new Set(['stable', 'nightly', 'canary']);
const validChannels = new Set<UpdateChannel>(['stable', 'canary']);
if (!validChannels.has(channel)) {
logger.warn(`Invalid update channel: ${channel}, ignoring`);
return;
}
logger.info(`Set update channel requested: ${channel}`);
this.app.storeManager.set('updateChannel', channel);
this.app.updaterManager.switchChannel(channel);
@@ -29,6 +29,11 @@ vi.mock('electron', () => ({
ipcMain: {
handle: ipcMainHandleMock,
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
shell: {
openExternal: vi.fn().mockResolvedValue(undefined),
},
@@ -5,11 +5,14 @@ import { type App } from '@/core/App';
import LocalFileCtr from '../LocalFileCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
const { ipcMainHandleMock, fetchMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
fetchMock: vi.fn(),
}));
const fetchMock = vi.fn();
vi.mock('@/utils/net-fetch', () => ({
netFetch: fetchMock,
}));
// Mock logger
vi.mock('@/utils/logger', () => ({
@@ -37,8 +40,6 @@ vi.mock('electron', () => ({
},
}));
vi.stubGlobal('fetch', fetchMock);
// Mock node:fs/promises and node:fs
vi.mock('node:fs/promises', () => ({
access: vi.fn(),
@@ -41,6 +41,7 @@ vi.mock('electron', () => {
// Mock electron-is
vi.mock('electron-is', () => ({
linux: vi.fn(() => false),
macOS: vi.fn(() => false),
windows: vi.fn(() => false),
}));
@@ -180,6 +181,26 @@ describe('NotificationCtr', () => {
expect(result).toEqual({ success: true });
});
it('should use low urgency on Linux to prevent GNOME Shell freeze', async () => {
const { linux } = await import('electron-is');
const { Notification } = await import('electron');
vi.mocked(linux).mockReturnValue(true);
vi.mocked(Notification.isSupported).mockReturnValue(true);
mockBrowserWindow.isVisible.mockReturnValue(false);
const promise = controller.showDesktopNotification(params);
vi.advanceTimersByTime(100);
await promise;
expect(Notification).toHaveBeenCalledWith(
expect.objectContaining({
urgency: 'low',
}),
);
vi.mocked(linux).mockReturnValue(false);
});
it('should show notification when window is minimized', async () => {
const { Notification } = await import('electron');
vi.mocked(Notification.isSupported).mockReturnValue(true);
@@ -5,8 +5,13 @@ import type { App } from '@/core/App';
import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
const { ipcMainHandleMock, mockFetch } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
mockFetch: vi.fn(),
}));
vi.mock('@/utils/net-fetch', () => ({
netFetch: mockFetch,
}));
// Mock logger
@@ -420,13 +425,6 @@ describe('RemoteServerConfigCtr', () => {
});
describe('refreshAccessToken', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch;
});
it('should return error when remote server is not active', async () => {
mockStoreManager.get.mockImplementation((key) => {
if (key === 'dataSyncConfig') {
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App } from '@/core/App';
import CliCtr from '../CliCtr';
import ShellCommandCtr from '../ShellCommandCtr';
const { ipcMainHandleMock } = vi.hoisted(() => ({
@@ -32,7 +33,17 @@ vi.mock('node:crypto', () => ({
randomUUID: vi.fn(() => 'test-uuid-123'),
}));
const mockApp = {} as unknown as App;
vi.mock('../CliCtr', () => ({
default: class CliCtr {},
}));
const mockCliCtr = {
runCliCommand: vi.fn().mockResolvedValue({ exitCode: 0, stderr: '', stdout: 'cli output\n' }),
};
const mockApp = {
getController: vi.fn((c: unknown) => (c === CliCtr ? mockCliCtr : undefined)),
} as unknown as App;
describe('ShellCommandCtr (thin wrapper)', () => {
let ctr: ShellCommandCtr;
@@ -118,6 +129,28 @@ describe('ShellCommandCtr (thin wrapper)', () => {
expect(mockChildProcess.kill).toHaveBeenCalled();
});
it('should route lh commands to CliCtr.runCliCommand', async () => {
const result = await ctr.handleRunCommand({
command: 'lh status --json',
description: 'lh status',
});
expect(mockCliCtr.runCliCommand).toHaveBeenCalledWith('status --json');
expect(result.success).toBe(true);
expect(result.stdout).toContain('cli output');
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should route lobehub commands to CliCtr.runCliCommand', async () => {
const result = await ctr.handleRunCommand({
command: 'lobehub search test',
description: 'lobehub search',
});
expect(mockCliCtr.runCliCommand).toHaveBeenCalledWith('search test');
expect(result.success).toBe(true);
});
it('should return error for non-existent shell_id', async () => {
const result = await ctr.handleGetCommandOutput({
shell_id: 'non-existent',
@@ -8,9 +8,14 @@ import UpdaterCtr from '../UpdaterCtr';
vi.mock('@/utils/logger', () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock('@/modules/updater/configs', () => ({
UPDATE_CHANNEL: 'stable',
}));
const { ipcMainHandleMock } = vi.hoisted(() => ({
ipcMainHandleMock: vi.fn(),
}));
@@ -26,13 +31,23 @@ const mockCheckForUpdates = vi.fn();
const mockDownloadUpdate = vi.fn();
const mockInstallNow = vi.fn();
const mockInstallLater = vi.fn();
const mockGetUpdaterState = vi.fn();
const mockSwitchChannel = vi.fn();
const mockStoreGet = vi.fn();
const mockStoreSet = vi.fn();
const mockApp = {
storeManager: {
get: mockStoreGet,
set: mockStoreSet,
},
updaterManager: {
checkForUpdates: mockCheckForUpdates,
downloadUpdate: mockDownloadUpdate,
getUpdaterState: mockGetUpdaterState,
installNow: mockInstallNow,
installLater: mockInstallLater,
switchChannel: mockSwitchChannel,
},
} as unknown as App;
@@ -42,6 +57,8 @@ describe('UpdaterCtr', () => {
beforeEach(() => {
vi.clearAllMocks();
ipcMainHandleMock.mockClear();
mockStoreGet.mockReset();
mockStoreSet.mockReset();
updaterCtr = new UpdaterCtr(mockApp);
});
@@ -73,6 +90,36 @@ describe('UpdaterCtr', () => {
});
});
describe('update channel', () => {
it('should return stored update channel', async () => {
mockStoreGet.mockReturnValueOnce('canary');
await expect(updaterCtr.getUpdateChannel()).resolves.toBe('canary');
});
it('should return default update channel when store is empty', async () => {
mockStoreGet.mockReturnValueOnce(undefined);
await expect(updaterCtr.getUpdateChannel()).resolves.toBe('stable');
});
it('should keep canary input unchanged', async () => {
await updaterCtr.setUpdateChannel('canary');
expect(mockStoreSet).toHaveBeenCalledWith('updateChannel', 'canary');
expect(mockSwitchChannel).toHaveBeenCalledWith('canary');
});
it('should ignore invalid legacy input', async () => {
await updaterCtr.setUpdateChannel(
'nightly' as unknown as Parameters<UpdaterCtr['setUpdateChannel']>[0],
);
expect(mockStoreSet).not.toHaveBeenCalled();
expect(mockSwitchChannel).not.toHaveBeenCalled();
});
});
// 测试错误处理
describe('error handling', () => {
it('should handle errors when checking for updates', async () => {
@@ -2,6 +2,7 @@ import type { CreateServicesResult, IpcServiceConstructor, MergeIpcService } fro
import AuthCtr from './AuthCtr';
import BrowserWindowsCtr from './BrowserWindowsCtr';
import CliCtr from './CliCtr';
import DevtoolsCtr from './DevtoolsCtr';
import GatewayConnectionCtr from './GatewayConnectionCtr';
import LocalFileCtr from './LocalFileCtr';
@@ -23,6 +24,7 @@ import UploadFileCtr from './UploadFileCtr';
export const controllerIpcConstructors = [
AuthCtr,
BrowserWindowsCtr,
CliCtr,
DevtoolsCtr,
GatewayConnectionCtr,
LocalFileCtr,
+8 -2
View File
@@ -13,6 +13,7 @@ import { isDev } from '@/const/env';
import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol';
import type { IControlModule } from '@/controllers';
import AuthCtr from '@/controllers/AuthCtr';
import { generateCliWrapper, getCliWrapperDir } from '@/modules/cliEmbedding';
import {
astSearchDetectors,
browserAutomationDetectors,
@@ -89,9 +90,9 @@ export class App {
logger.info('----------------------------------------------');
logger.info('Starting LobeHub...');
// Append bundled binaries directory to PATH for fallback tool resolution
// Append bundled binaries and CLI wrapper directories to PATH for tool resolution
const pathSep = process.platform === 'win32' ? ';' : ':';
process.env.PATH = `${process.env.PATH}${pathSep}${binDir}`;
process.env.PATH = `${process.env.PATH}${pathSep}${binDir}${pathSep}${getCliWrapperDir()}`;
logger.debug('Initializing App');
// Initialize store manager
@@ -226,6 +227,11 @@ export class App {
// Initialize app
await this.makeAppReady();
// Generate CLI wrapper for terminal usage
generateCliWrapper().catch((error) => {
logger.warn('Failed to generate CLI wrapper:', error);
});
// Initialize i18n. Note: app.getLocale() must be called after app.whenReady() to get the correct value
await this.i18n.init();
this.menuManager.initialize();
@@ -4,6 +4,7 @@ import { BrowserWindow, type Session } from 'electron';
import { isDev } from '@/const/env';
import { appendVercelCookie } from '@/utils/http-headers';
import { createLogger } from '@/utils/logger';
import { netFetch } from '@/utils/net-fetch';
interface BackendProxyProtocolManagerOptions {
getAccessToken: () => Promise<string | undefined | null>;
@@ -137,7 +138,7 @@ export class BackendProxyProtocolManager {
let upstreamResponse: Response;
try {
upstreamResponse = await fetch(rewrittenUrl, requestInit);
upstreamResponse = await netFetch(rewrittenUrl, requestInit);
} catch (error) {
this.logger.error(`${logPrefix} upstream fetch failed: ${rewrittenUrl}`, error);
@@ -160,14 +161,13 @@ export class BackendProxyProtocolManager {
responseHeaders.set('Access-Control-Allow-Headers', '*');
responseHeaders.set('X-Src-Url', rewrittenUrl);
// Handle 401 Unauthorized: only notify authorization required for real auth failures
// The server sets X-Auth-Required header for real authentication failures (e.g., token expired)
// Other 401 errors (e.g., invalid API keys) should not trigger re-authentication
if (upstreamResponse.status === 401) {
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
if (authRequired) {
this.notifyAuthorizationRequired();
}
// Re-auth prompt: rely on X-Auth-Required (set by tRPC responseMeta for UNAUTHORIZED).
// Batched tRPC responses can use HTTP 207 when calls mix success (200) and UNAUTHORIZED (401);
// checking only status === 401 misses that case and the login modal never opens.
// Other failures keep 401 without this header (e.g., invalid API keys) and must not notify here.
const authRequired = upstreamResponse.headers.get(AUTH_REQUIRED_HEADER) === 'true';
if (authRequired) {
this.notifyAuthorizationRequired();
}
return new Response(upstreamResponse.body, {
@@ -6,6 +6,7 @@ import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import type { App } from '../App';
import { runStoreMigrations } from './migration';
// Create logger
const logger = createLogger('core:StoreManager');
@@ -27,6 +28,7 @@ export class StoreManager {
defaults: STORE_DEFAULTS,
name: STORE_NAME,
});
runStoreMigrations(this.store);
logger.info('StoreManager initialized with store name:', STORE_NAME);
const storagePath = this.store.get('storagePath');
@@ -139,9 +139,7 @@ export class UpdaterManager {
public switchChannel = (channel: UpdateChannel) => {
logger.info(`Switching update channel: ${this.currentChannel} -> ${channel}`);
const isDowngrade =
(this.currentChannel === 'canary' && channel !== 'canary') ||
(this.currentChannel === 'nightly' && channel === 'stable');
const isDowngrade = this.currentChannel === 'canary' && channel === 'stable';
this.currentChannel = channel;
autoUpdater.allowDowngrade = isDowngrade;
@@ -366,7 +364,7 @@ export class UpdaterManager {
/**
* Strip trailing channel path from URL so we can re-append the correct channel.
* Handles both base URL (https://cdn.example.com) and legacy URL with channel (https://cdn.example.com/stable)
* Handles both base URL (https://cdn.example.com) and legacy URLs with channel suffixes.
*/
private getBaseUpdateUrl(): string | undefined {
if (!UPDATE_SERVER_URL) return undefined;
@@ -1,4 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AUTH_REQUIRED_HEADER } from '@lobechat/desktop-bridge';
import { BrowserWindow } from 'electron';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BackendProxyProtocolManager } from '../BackendProxyProtocolManager';
@@ -37,12 +39,27 @@ vi.mock('@/utils/logger', () => ({
}),
}));
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: vi.fn(),
},
net: {
fetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
global.fetch(input as any, init as any),
),
},
}));
describe('BackendProxyProtocolManager', () => {
beforeEach(() => {
vi.clearAllMocks();
protocolHandlerRef.current = null;
});
afterEach(() => {
vi.useRealTimers();
});
it('should rewrite url to remote base and inject Oidc-Auth token', async () => {
const manager = new BackendProxyProtocolManager();
const session = { protocol: mockProtocol } as any;
@@ -209,4 +226,41 @@ describe('BackendProxyProtocolManager', () => {
} as any),
).rejects.toThrow('network down');
});
it('should broadcast authorizationRequired when X-Auth-Required is set on HTTP 207 (batched tRPC)', async () => {
vi.useFakeTimers();
const send = vi.fn();
vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([
{ isDestroyed: () => false, webContents: { send } },
] as any);
const manager = new BackendProxyProtocolManager();
const session = { protocol: mockProtocol } as any;
const headers = new Headers({
[AUTH_REQUIRED_HEADER]: 'true',
'Content-Type': 'application/json',
});
const fetchMock = vi.fn<FetchMock>(
async () => new Response('[]', { headers, status: 207, statusText: 'Multi-Status' }),
);
vi.stubGlobal('fetch', fetchMock as any);
manager.registerWithRemoteBaseUrl(session, {
getAccessToken: async () => null,
getRemoteBaseUrl: async () => 'https://remote.example.com',
scheme: 'lobe-backend',
});
const handler = protocolHandlerRef.current;
await handler({
headers: new Headers(),
method: 'GET',
url: 'lobe-backend://app/trpc/lambda/batch?batch=1',
} as any);
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(send).toHaveBeenCalledWith('authorizationRequired');
});
});
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { App as AppCore } from '../../App';
import { APPLIED_STORE_MIGRATIONS_KEY, getStoreMigrations, runStoreMigrations } from '../migration';
import { StoreManager } from '../StoreManager';
// Use vi.hoisted to define mocks before hoisting
@@ -46,6 +47,11 @@ vi.mock('@/utils/file-system', () => ({
makeSureDirExist: mockMakeSureDirExist,
}));
vi.mock('@/modules/updater/configs', () => ({
coerceStoredUpdateChannel: (channel?: string | null) =>
channel === 'canary' ? 'canary' : 'stable',
}));
// Mock store constants
vi.mock('@/const/store', () => ({
STORE_DEFAULTS: {
@@ -77,18 +83,52 @@ describe('StoreManager', () => {
describe('constructor', () => {
it('should create electron-store with correct options', () => {
expect(MockStore).toHaveBeenCalledWith({
defaults: {
locale: 'auto',
storagePath: '/default/storage/path',
},
name: 'test-config',
});
expect(MockStore).toHaveBeenCalledWith(
expect.objectContaining({
defaults: {
locale: 'auto',
storagePath: '/default/storage/path',
},
name: 'test-config',
}),
);
});
it('should ensure storage directory exists', () => {
expect(mockMakeSureDirExist).toHaveBeenCalledWith('/mock/storage/path');
});
it('should migrate legacy nightly channel and record applied migration ids', () => {
const store = {
get: vi.fn((key: string) => {
if (key === APPLIED_STORE_MIGRATIONS_KEY) return undefined;
if (key === 'updateChannel') return 'nightly';
}),
set: vi.fn(),
} as any;
runStoreMigrations(store);
expect(store.set).toHaveBeenCalledWith('updateChannel', 'stable');
expect(store.set).toHaveBeenCalledWith(APPLIED_STORE_MIGRATIONS_KEY, [
getStoreMigrations()[0].id,
]);
});
it('should skip already applied migrations', () => {
const appliedMigrationId = getStoreMigrations()[0].id;
const store = {
get: vi.fn((key: string) => {
if (key === APPLIED_STORE_MIGRATIONS_KEY) return [appliedMigrationId];
if (key === 'updateChannel') return 'nightly';
}),
set: vi.fn(),
} as any;
runStoreMigrations(store);
expect(store.set).not.toHaveBeenCalled();
});
});
describe('get', () => {
@@ -0,0 +1,15 @@
import { coerceStoredUpdateChannel } from '@/modules/updater/configs';
import { defineMigration } from './defineMigration';
export default defineMigration({
id: '001-normalize-update-channel',
up: (store) => {
const storedChannel = store.get('updateChannel');
const normalizedChannel = coerceStoredUpdateChannel(storedChannel);
if (storedChannel && storedChannel !== normalizedChannel) {
store.set('updateChannel', normalizedChannel);
}
},
});
@@ -0,0 +1,10 @@
import type Store from 'electron-store';
import type { ElectronMainStore } from '@/types/store';
export interface StoreMigration {
id: string;
up: (store: Store<ElectronMainStore>) => void;
}
export const defineMigration = (migration: StoreMigration): StoreMigration => migration;
@@ -0,0 +1,55 @@
import type Store from 'electron-store';
import type { ElectronMainStore } from '@/types/store';
import { createLogger } from '@/utils/logger';
import normalizeUpdateChannelMigration from './001-normalize-update-channel';
import type { StoreMigration } from './defineMigration';
export const APPLIED_STORE_MIGRATIONS_KEY = 'lobeDesktopAppliedStoreMigrations';
const logger = createLogger('core:storeMigration');
const migrations: StoreMigration[] = [normalizeUpdateChannelMigration];
const getAppliedMigrationIds = (store: Store<ElectronMainStore>): string[] => {
return (
(store.get(APPLIED_STORE_MIGRATIONS_KEY as keyof ElectronMainStore) as string[] | undefined) ??
[]
);
};
const setAppliedMigrationIds = (store: Store<ElectronMainStore>, ids: string[]) => {
store.set(
APPLIED_STORE_MIGRATIONS_KEY as keyof ElectronMainStore,
ids as ElectronMainStore[keyof ElectronMainStore],
);
};
export const getStoreMigrations = () => migrations;
export const runStoreMigrations = (store: Store<ElectronMainStore>) => {
logger.info('Store migrations started');
const appliedMigrationIds = new Set(getAppliedMigrationIds(store));
let hasNewMigrationApplied = false;
for (const migration of migrations) {
if (appliedMigrationIds.has(migration.id)) continue;
logger.info(`Running store migration: ${migration.id}`);
migration.up(store);
appliedMigrationIds.add(migration.id);
hasNewMigrationApplied = true;
}
if (hasNewMigrationApplied) {
setAppliedMigrationIds(store, [...appliedMigrationIds]);
}
logger.info(
hasNewMigrationApplied
? 'Store migrations finished (updates applied)'
: 'Store migrations finished (nothing pending)',
);
};
@@ -0,0 +1,97 @@
import { chmod, mkdir, rename, symlink, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { app } from 'electron';
import { createLogger } from '@/utils/logger';
const logger = createLogger('modules:cliEmbedding');
/**
* Resolve the correct Electron binary path per platform.
* - AppImage: use APPIMAGE env var (the actual .AppImage file)
* - Others: app.getPath('exe')
*/
function resolveElectronBinary(): string {
if (process.platform === 'linux' && process.env.APPIMAGE) {
return process.env.APPIMAGE;
}
return app.getPath('exe');
}
/**
* Resolve the CLI script path inside packaged resources.
*/
function resolveCliScript(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'bin', 'lobe-cli.js');
}
// Dev mode: app.getAppPath() points to apps/desktop/, go up to apps/cli/
return path.join(app.getAppPath(), '..', 'cli', 'dist', 'index.js');
}
/**
* Get the user-writable bin directory for CLI wrapper.
*/
export function getCliWrapperDir(): string {
return path.join(app.getPath('userData'), 'bin');
}
/**
* Generate shell wrapper scripts that invoke the embedded CLI
* using Electron's Node.js runtime via ELECTRON_RUN_AS_NODE=1.
*
* Called on every app launch to keep paths up-to-date after auto-updates.
*/
export async function generateCliWrapper(): Promise<void> {
const electronBin = resolveElectronBinary();
const cliScript = resolveCliScript();
const wrapperDir = getCliWrapperDir();
await mkdir(wrapperDir, { recursive: true });
if (process.platform === 'win32') {
const content = [
'@echo off',
'set ELECTRON_RUN_AS_NODE=1',
`"${electronBin}" "${cliScript}" %*`,
].join('\r\n');
const cmdPath = path.join(wrapperDir, 'lobehub.cmd');
await atomicWrite(cmdPath, content);
// Create short aliases: lh.cmd, lobe.cmd (copies on Windows, symlinks unreliable)
for (const alias of ['lh.cmd', 'lobe.cmd']) {
await atomicWrite(path.join(wrapperDir, alias), content);
}
logger.info(`CLI wrapper generated: ${cmdPath}`);
} else {
const content = [
'#!/bin/sh',
`ELECTRON_RUN_AS_NODE=1 exec "${electronBin}" "${cliScript}" "$@"`,
].join('\n');
const wrapperPath = path.join(wrapperDir, 'lobehub');
await atomicWrite(wrapperPath, content);
await chmod(wrapperPath, 0o755);
// Create short aliases: lh, lobe → lobehub
for (const alias of ['lh', 'lobe']) {
const linkPath = path.join(wrapperDir, alias);
await unlink(linkPath).catch(() => {});
await symlink('lobehub', linkPath);
}
logger.info(`CLI wrapper generated: ${wrapperPath}`);
}
}
/**
* Atomic write: write to temp file then rename to avoid partial reads.
*/
async function atomicWrite(filePath: string, content: string): Promise<void> {
const tmpPath = `${filePath}.tmp.${process.pid}`;
await writeFile(tmpPath, content, 'utf8');
await rename(tmpPath, filePath);
}
@@ -0,0 +1 @@
export { generateCliWrapper, getCliWrapperDir } from './generateCliWrapper';
@@ -63,11 +63,82 @@ export const pythonDetector: IToolDetector = {
priority: 3,
};
/**
* Bun runtime detector
*/
export const bunDetector: IToolDetector = createCommandDetector('bun', {
description: 'Bun - fast JavaScript runtime and package manager',
priority: 4,
});
/**
* Bunx package runner detector
*/
export const bunxDetector: IToolDetector = createCommandDetector('bunx', {
description: 'bunx - Bun package runner for executing npm packages',
priority: 5,
});
/**
* pnpm package manager detector
*/
export const pnpmDetector: IToolDetector = createCommandDetector('pnpm', {
description: 'pnpm - fast, disk space efficient package manager',
priority: 6,
});
/**
* uv Python package manager detector
*/
export const uvDetector: IToolDetector = createCommandDetector('uv', {
description: 'uv - extremely fast Python package manager',
priority: 7,
});
/**
* LobeHub CLI detector
* Tries lobehub, lobe, lh in order; validates via --help output containing "LobeHub"
*/
export const lobehubDetector: IToolDetector = {
description: 'LobeHub CLI - manage and connect to LobeHub services',
async detect(): Promise<ToolStatus> {
const commands = ['lobehub', 'lobe', 'lh'];
const whichCmd = platform() === 'win32' ? 'where' : 'which';
for (const cmd of commands) {
try {
const { stdout: pathOut } = await execPromise(`${whichCmd} ${cmd}`, { timeout: 3000 });
const toolPath = pathOut.trim().split('\n')[0];
// Validate it's actually LobeHub CLI by checking help output
const { stdout: helpOut } = await execPromise(`${cmd} --help`, { timeout: 3000 });
if (!helpOut.includes('LobeHub')) continue;
const { stdout: versionOut } = await execPromise(`${cmd} --version`, { timeout: 3000 });
const version = versionOut.trim().split('\n')[0];
return { available: true, path: toolPath, version };
} catch {
continue;
}
}
return { available: false };
},
name: 'lobehub',
priority: 0,
};
/**
* All runtime environment detectors
*/
export const runtimeEnvironmentDetectors: IToolDetector[] = [
lobehubDetector,
nodeDetector,
npmDetector,
pythonDetector,
bunDetector,
bunxDetector,
pnpmDetector,
uvDetector,
];
@@ -5,14 +5,13 @@ import { getDesktopEnv } from '@/env';
// Build-time default channel, can be overridden at runtime via store
const rawChannel = getDesktopEnv().UPDATE_CHANNEL || 'stable';
const VALID_CHANNELS = new Set<UpdateChannel>(['stable', 'nightly', 'canary']);
/** Raw build channel for display (stable, nightly, canary, beta) */
export const coerceStoredUpdateChannel = (channel?: string | null): UpdateChannel =>
channel === 'canary' ? 'canary' : 'stable';
/** Raw build channel for display (stable, canary, beta, or legacy nightly). */
export const BUILD_CHANNEL: string = rawChannel;
export const UPDATE_CHANNEL: UpdateChannel = VALID_CHANNELS.has(rawChannel as UpdateChannel)
? (rawChannel as UpdateChannel)
: rawChannel === 'beta'
? 'nightly'
: 'stable';
export const UPDATE_CHANNEL: UpdateChannel =
rawChannel === 'canary' || rawChannel === 'beta' ? 'canary' : 'stable';
// S3 base URL for all channels
// e.g., https://releases.lobehub.com

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