Compare commits

..

26 Commits

Author SHA1 Message Date
Arvin Xu 67d314b089 🐛 fix: seed message:list cache even when replaceMessages store-set is a no-op
Optimistic flows (optimisticUpdateMessageContent / optimisticDeleteMessage[s])
dispatch the mutation into dbMessagesMap first, then call
replaceMessages(server). When the server echo equals the already-applied
optimistic state, the isEqual early-return skipped the store-set AND the
write-through, leaving message:list at the pre-mutation snapshot — a later
remount could hydrate stale content / deleted rows.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:53:41 +08:00
Arvin Xu 22eddbc474 ️ perf: write message mutations through to the message:list SWR cache
Message mutations only touched the in-memory store, so the message:list
SWR/IndexedDB cache stayed stale until a network refetch. Because the
Conversation store is recreated on every topic/session switch and
re-hydrates from that cache, the stale cache is what forced a refetch on
every switch.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:48:48 +08:00
YuTengjing e7f1f73e27 💄 style: add top-up best value copy (#15924) 2026-06-16 21:24:23 +08:00
Arvin Xu cd93856561 🐛 fix(boot): one continuous loading screen instead of brand-logo flash on cold start (#15926)
🐛 fix(boot): keep one continuous loading screen instead of flashing the brand logo on cold start

CacheHydrationGate held the routed app behind its own full-screen
ProductLogo while the SWR IndexedDB cache tier hydrated. Because it only
gated once auth resolved, the boot painted the app shell first, then
flipped to the logo when auth switched the scope anon→user (triggering a
cache reload), then back to the app — an app→logo→app flicker.

Now the gate renders nothing while booting and keeps the static HTML
#loading-screen visible, then removes it in the same layout pass that
mounts the children — one continuous loader → app hand-off, no second
in-React logo. It also gates through the pre-auth phase so the scope flip
no longer causes a mid-boot flash. SPAGlobalProvider no longer removes
#loading-screen on mount; that is now owned by the gate.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:21:47 +08:00
YuTengjing 3c907ff0fd 💄 style(referral): update reward rules copy (#15923) 2026-06-16 20:08:49 +08:00
Tsuki 85d94f2f74 feat(mobile): expose deviceRouter on mobileRouter (#15925)
Mounts the existing `deviceRouter` (from lambda) on the mobile tRPC root
so the mobile app can call `device.listDevices` (and other device RPCs)
to drive the chat input's execution-target picker — aligning mobile
device handling with the web `HeteroDeviceSwitcher` UX.

`deviceProcedure` only uses `authedProcedure + serverDatabase`, both of
which the mobile route already provides via `createLambdaContext`, so no
context changes are needed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-16 19:38:13 +08:00
Arvin Xu 69dedc4eeb 🐛 fix(fleet): re-sync running topics each time the Observation tab opens (#15922)
* 🐛 fix(fleet): re-sync running topics each time the Observation tab opens

The board seeded its columns through a `seeded` flag on the session-singleton
store, so the seed effect only ran once per app load. Desktop tabs are in-SPA
navigations that remount FleetView, but the flag stayed true — so re-opening
the tab showed a column set frozen from its first open, and topics that started
running afterward never appeared.

Replace the once-per-load seed with a per-mount sync: on each open, add a
column for every currently-running topic via the idempotent addColumn, tracking
already-synced keys so newly-running topics still pop in, manually-closed
columns stay closed, and manual/reordered columns are preserved. Remove the now
dead seedColumns/seeded from the store.

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

* 🐛 fix(conversation): init agent config in ChatList so author titles resolve

ChatList now self-inits its conversation's agent config into the agent
store, so message author titles resolve via useAgentMeta instead of
falling back to "未命名助理". Secondary mounts (each Fleet column, the
share page) never went through the route-level init that populates
agentMap. Idempotent via SWR key dedup; gated on isLogin.

Also gate the Fleet column reply bar on messages being loaded.

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

* 💄 style(fleet): move create-task to a pinned footer, count as a tag, rename to Running Board

- Move the "create task" entry out of the cramped header into a full-width
  button pinned at the bottom of the sidebar (added an optional `footer` slot
  to SideBarLayout) — it was easy to miss at the top-right.
- Show the open-column count as a Tag beside the title, hidden when zero.
- Rename the sidebar title from "Running Tasks" to "Running Board" (运行看板).

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

* 📝 docs(fleet): update sidebar doc comment for footer create-task

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

* 💄 style(fleet): move create-task to the top of the sidebar body

Place the "create task" button at the top of the running-board list instead of
a pinned footer, and drop the now-unused footer slot from SideBarLayout.

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

* 💄 style(fleet): shrink create-task button to default sidebar size

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

* 💄 style(fleet): persist board columns, refetch on focus, polish drag UI

Make the running board a live, durable overview:

- Refetch the running-topic set on focus near-instantly (focusThrottleInterval
  1s) so newly-running topics appear the moment the user looks at the board.
- Persist the column set (manual pins + kept running topics) and per-column
  closures to localStorage, replacing the once-per-mount syncedKeys seed with a
  syncRunningColumns reconciliation that only appends: new running topics pop
  in, manual pins and ordering stay put, and a column closed while still
  running won't immediately re-add (dismissal clears once it stops).
- Columns now stay until the user closes them; a topic that drops out of the
  running set reads as "idle" (StatusDot accepts an absent status).
- Round the dragged column's corners (8px) and clip its contents.

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

* ♻️ refactor(fleet): move running-topics SWR key into the central registry

Replace the inline literal/local const with a dedicated `fleet` domain in the
SWR key registry (`fleetKeys.runningTopics()` → ['fleet:runningTopics']), so the
board's cache key follows the `<domain>:<resource>` convention and the tiered
cache provider / matchDomain('fleet:') treat it as its own namespace.

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

* 💄 style(fleet): turn collapse-reply control into a labeled text button

Replace the bare collapse ActionIcon with a centered text Button carrying the
"Collapse" label so the reply-collapse affordance reads clearly.

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

* 💄 style(fleet): live topic status, collapsible reply, row layout & pin controls

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

* 🐛 fix(fleet): use running-op start time for sidebar elapsed clock

The sidebar running-time readout anchored on topic.createdAt, so it counted
from topic creation (hours off) instead of the current run. Switch to the same
baseline the sidebar topic row uses — operationSelectors
.getAgentRuntimeStartTimeByContext, the running operation's metadata.startTime —
falling back to the StatusDot label when no running op is loaded.

Also widen the OpenAI Responses `create` spy assignment (overloaded signature
isn't assignable to the generic MockInstance fallback under tsgo).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:53:13 +08:00
Arvin Xu c10be159c3 🐛 fix(agent-gateway-client): drive resume completion off authoritative DO status (#15919)
* 🐛 fix(agent-gateway-client): drive resume completion off authoritative DO status

A fresh subscriber (no lastEventId) on a hibernated DO replays zero events.
The client used to guess "completed" from a 3s silence and emit session_complete,
which cleared the shared topic.metadata.runningOperation and cancelled the run
on every device — opening a topic on a 2nd device killed the 1st device's run
(LOBE-10443).

Consume the new `resume_complete` message (the DO's stored status, which
survives hibernation) as ground truth: still running / waiting → stay connected
and keep streaming; terminal → complete. The destructive 3s empty-replay
timeout is removed entirely — completion is never guessed from silence. If
`resume_complete` never arrives (e.g. a rolled-back DO), the client just waits,
a safe and recoverable state, with heartbeat loss still forcing reconnect.

Requires agent-gateway#8 (DO sends resume_complete), which deploys first.

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

* 🐛 fix(agent-gateway-client): opt into resume_complete via wantStatus flag

Set `wantStatus: true` on the resume message so the (revised, non-destructive)
gateway hands back the authoritative session status only to clients that
understand it. A legacy gateway ignores the flag and replays only; this client
then relies on live events and never guesses completion from silence.

Pairs with agent-gateway forward-fix (opt-in gating, no synthesized
session_complete). Safe against both old and new gateways, in either deploy
order.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:36:05 +08:00
Arvin Xu 9face81ef3 💄 style(topic): unread-reply indicator on collapsed project groups (#15915)
*  feat(topic): show unread-reply indicator on collapsed project groups

When a project topic group is collapsed, surface an aggregated unread
indicator (animated ripple dot) if any child topic has an unread
completed generation, so users notice replies without expanding.

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

* 🐛 fix(device): stabilize device ordering with createdAt tie-break

`lastSeenAt` is written from a JS `new Date()` (ms precision), so two
rapid registers can tie on it and leave ordering undefined. Break ties
by `createdAt` (DB-side now(), µs precision) for stable ordering.

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

* 💄 style(tool-ui): drop redundant Request section from Linear render

The Inspector already surfaces tool inputs, so rendering request args
again in the Linear result view is redundant.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:05:42 +08:00
Arvin Xu 10b3cbda3e ♻️ refactor(agent): run callAgent as deferred tool (#15765)
* ♻️ refactor(agent): delegate callAgent via server runner

* ♻️ refactor(agent): run callAgent as deferred tool

*  test(agent): cover server callAgent deferred flow
2026-06-16 14:29:36 +08:00
Arvin Xu b6ad1ed4be ♻️ refactor(conversation-flow): role-aware dual-form message-chain reader (#15908)
* ♻️ refactor(conversation-flow): role-aware dual-form message-chain reader

Make the read side role-aware so both persisted chain shapes parse to
equivalent display output (LOBE-10445 phase 1):

- tool-anchored (legacy): next step's assistant hangs off the previous
  step's last tool result
- assistant-anchored (new): next step's assistant hangs off the most
  recent non-tool message, so a tool result and the next assistant are
  siblings under one assistant

Two invariants drive a single reader: a `tool` message is always inline
data of its assistant; a branch is >=2 non-tool siblings under one parent.
The continuation walk now looks for the next spine assistant among the
assistant's own non-tool children as well as its tools' children;
group detection keys on "has >=1 tool child"; branch detection counts
non-tool children only.

Pure read-side, no write-path change — ships independently. Verified
against 5 fixture classes (old / new / mixed / parallel-tool /
regenerate-branch) asserting flatList + contextTree parity.

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

* 🐛 fix(conversation-flow): guard assistant-anchored chain continuation

Address two edge cases in the dual-form continuation finder where seeding
the candidate set with the assistant's own id (the new-form path) bypassed
logic the per-tool path already had:

1. Regenerated continuation: a tool-using assistant can have two non-tool
   assistant children beside its tool result. The finder flattened all
   candidates and returned the earliest, ignoring the parent's
   activeBranchIndex and dropping the other branch. Route >1 non-tool-child
   sets through BranchResolver before picking a linear continuation.

2. Async-task summary: when a tool spawned tasks but the follow-up summary
   uses the assistant-anchored parent (summary.parentId === assistant.id),
   the assistant seed bypassed the task/AgentCouncil fan-out guard and the
   summary got folded into the AssistantGroup before the tasks aggregation.
   Apply the same fan-out guard to the assistant-anchored candidate so the
   group -> tasks -> summary order is preserved.

Both the flat (findFlatChainContinuation) and tree (findChainContinuationNode)
variants share a resolveActiveContinuationId helper; BranchResolver is now
injected into MessageCollector. Adds two fixtures (⑥ regenerated branch,
⑦ async-task summary). conversation-flow: 143 passed, type-check clean.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:26:28 +08:00
Arvin Xu 5c5a719186 🐛 fix(device): lock a run to the explicitly selected device, never offer device-switching (#15914)
When a device is explicitly selected (`boundDeviceId`), the run must stay on
it and the model must never be able to activate / switch to another machine.

The remote-device (activate-device) tool was gated only by `!autoActivated`.
That left a hole: when the selected device went OFFLINE the plan became
`device-unrouted`, `autoActivated` flipped to false, and the activate-device
tool resurfaced — letting the model silently hop onto a *different* online
device. That is exactly the "auto-replace to another device after offline"
behavior we want gone.

Also suppress the tool whenever `boundDeviceId` is set, regardless of online
status. An explicitly selected device now locks the run: the tool is never
offered, so the run stays unrouted until that device comes back instead of
switching machines. The unbound case is unchanged — the tool is still offered
so the model/user can pick a device.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:24:55 +08:00
LobeHub Bot 3d594e77f5 🌐 chore: translate non-English comments to English in openapi-remaining-services (#15913)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 13:43:26 +08:00
Arvin Xu ac8f324a3b chore: remove LOBE-XXX markers from code comments (2026-06-16) (#15905)
chore: remove LOBE-XXX markers from code comments

Replace LOBE-XXX ticket references in code comments with descriptive
context from the corresponding Linear issues. The markers served as
internal tracking anchors during development but are inappropriate
for the open-source codebase.

Files changed:
- AgentRuntimeService.ts: LOBE-10385 → async sub-agent suspend/resume
  stability hardening context
- observability-otel/agent-runtime/index.ts: same LOBE-10385 context
- buildRunLifecycle.ts: LOBE-10378/10379/10382 → run lifecycle and
  transport unification context
- streamingExecutor.ts: LOBE-10378 reference removed
- modelExtendParams.test.ts: LOBE-10442 → Gemini 3 Pro reasoning token
  context

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
2026-06-16 13:38:09 +08:00
René Wang 53e82d2e13 📝 docs: add June 15 weekly changelog (#15907)
* 📝 docs: add June 15 weekly changelog

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

* 💄 docs: restructure last 12 changelog entries into Features/Improvements/Fixes

Normalize section headings, split improvements from fixes, plainer wording, and fewer em-dashes.

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

* 📝 docs: remove Claude Fable 5 from June 15 changelog

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

* 🍱 docs: add cover image for June 15 changelog

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:00:03 +08:00
LiJian bbbe1a96d7 🐛 fix(connector): restore credentials in edit mode, prevent silent wipe on save (#15909)
* 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode

In gateway mode, AgentBuilder's tool calls (updateConfig / updatePrompt /
installPlugin) were targeting the builtin builder agent instead of the agent
being edited, and the left-sidebar never refreshed after a successful write.

Two root causes fixed:

1. **Wrong agentId on server** — `executeGatewayAgent` only sent `context.agentId`
   (= the AgentBuilder builtin) to the server. The editing target was only held
   in `chatStore.activeAgentId` (synced by AgentBuilderProvider) but never
   forwarded. Now, when `scope === 'agent_builder'`, the client sends
   `appContext.editingAgentId = chatStore.activeAgentId`. The tRPC Zod schema
   and `ExecAgentAppContext` type both accept the new field, and
   `aiAgent/index.ts` uses it to override the operation's `agentId` so
   `state.metadata.agentId` (and therefore `ctx.agentId` in the server
   executor) points to the correct editing target.

2. **No sidebar refresh** — In client mode the `AgentManagerRuntime` directly
   calls `agentStore.optimisticUpdateAgentConfig()`, which triggers a Zustand
   re-render. In gateway mode the update happens server-side so no Zustand
   mutation ever fires. Fixed by adding an `onAfterCall` hook to
   `AgentBuilderExecutor`: after any successful write it reads the editing
   agent ID from `chatStore.activeAgentId` and calls
   `getAgentStoreState().internal_refreshAgentConfig()` to re-fetch and
   re-render the sidebar.

Closes LOBE-10441

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

* 🐛 fix(agent-builder): isolate editingAgentId to avoid message ownership desync

Per code review: the previous fix overrode state.metadata.agentId with the
editing target, but messages are already written with persistAgentId =
resolvedAgentId (the builder builtin). AgentRuntimeService.queryUiMessages
reads metadata.agentId to filter messages, so overriding it would cause the
gateway handler to snapshot the wrong topic and desync the builder conversation.

Correct approach: keep agentId as the builder builtin throughout. Carry
editingAgentId as a separate metadata field that only flows through to
ToolExecutionContext, where the AgentBuilder server runtime reads it via
ctx.editingAgentId ?? ctx.agentId. No other part of the pipeline is affected.

Changes:
- apps/server/src/services/aiAgent/index.ts: revert agentId override; keep
  editingAgentId as an independent appContext field (conditional spread)
- apps/server/src/services/toolExecution/types.ts: add editingAgentId to
  ToolExecutionContext
- apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts: forward
  state.metadata.editingAgentId into the ToolExecutionContext
- apps/server/src/services/toolExecution/serverRuntimes/agentBuilder.ts:
  use ctx.editingAgentId ?? ctx.agentId in all three write methods

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

* 🐛 fix(connector): restore credentials in edit mode, prevent silent wipe on save

Three bugs caused custom connector headers / bearer tokens to be lost silently:

1. Dead-code branch in edit-mode save: `authType === 'header'` could never be
   true (the auth radio only has none/bearer/oauth2), so every save with
   `authType === 'none'` hit `patch.credentials = null` and wiped whatever
   was stored — including valid header credentials.  Fixed by mirroring the
   create-mode logic: `authType !== 'oauth2'` → check Advanced headers → save
   `{type:'header'}` if present, null otherwise.

2. `list` API strips credentials entirely, so `editValue` always computed
   `authType = 'none'` and `headers = undefined`, leaving the edit form blank
   even when credentials were saved.  Added `getForEdit` tRPC query that
   returns the decrypted user-set credentials (bearer token, custom headers)
   while still excluding machine-managed OAuth tokens and DCR client secrets.
   `CustomConnectorModal` now fetches this on open and builds `editValue`
   from the real data.

3. `DevModal` seeded the form once on mount (`useEffect([], [])`).  Since
   credentials are loaded asynchronously after open, the form was already
   seeded with empty data before the fetch completed.  Changed to a
   `seededRef`-guarded effect on `[open, value]`: resets on close, seeds once
   when the value arrives, and never overwrites user edits mid-session.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 11:06:17 +08:00
Arvin Xu a94b3a4ce2 ️ perf: optimize agent document list query (#15904) 2026-06-16 10:47:19 +08:00
Innei 30a62ab478 🐛 fix(desktop): remove web onboarding aliases (#15902) 2026-06-16 10:42:27 +08:00
LiJian d3fbc19473 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode (#15888)
* 🐛 fix(agent-builder): correct target agentId and refresh sidebar in gateway mode

In gateway mode, AgentBuilder's tool calls (updateConfig / updatePrompt /
installPlugin) were targeting the builtin builder agent instead of the agent
being edited, and the left-sidebar never refreshed after a successful write.

Two root causes fixed:

1. **Wrong agentId on server** — `executeGatewayAgent` only sent `context.agentId`
   (= the AgentBuilder builtin) to the server. The editing target was only held
   in `chatStore.activeAgentId` (synced by AgentBuilderProvider) but never
   forwarded. Now, when `scope === 'agent_builder'`, the client sends
   `appContext.editingAgentId = chatStore.activeAgentId`. The tRPC Zod schema
   and `ExecAgentAppContext` type both accept the new field, and
   `aiAgent/index.ts` uses it to override the operation's `agentId` so
   `state.metadata.agentId` (and therefore `ctx.agentId` in the server
   executor) points to the correct editing target.

2. **No sidebar refresh** — In client mode the `AgentManagerRuntime` directly
   calls `agentStore.optimisticUpdateAgentConfig()`, which triggers a Zustand
   re-render. In gateway mode the update happens server-side so no Zustand
   mutation ever fires. Fixed by adding an `onAfterCall` hook to
   `AgentBuilderExecutor`: after any successful write it reads the editing
   agent ID from `chatStore.activeAgentId` and calls
   `getAgentStoreState().internal_refreshAgentConfig()` to re-fetch and
   re-render the sidebar.

Closes LOBE-10441

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

* 🐛 fix(agent-builder): isolate editingAgentId to avoid message ownership desync

Per code review: the previous fix overrode state.metadata.agentId with the
editing target, but messages are already written with persistAgentId =
resolvedAgentId (the builder builtin). AgentRuntimeService.queryUiMessages
reads metadata.agentId to filter messages, so overriding it would cause the
gateway handler to snapshot the wrong topic and desync the builder conversation.

Correct approach: keep agentId as the builder builtin throughout. Carry
editingAgentId as a separate metadata field that only flows through to
ToolExecutionContext, where the AgentBuilder server runtime reads it via
ctx.editingAgentId ?? ctx.agentId. No other part of the pipeline is affected.

Changes:
- apps/server/src/services/aiAgent/index.ts: revert agentId override; keep
  editingAgentId as an independent appContext field (conditional spread)
- apps/server/src/services/toolExecution/types.ts: add editingAgentId to
  ToolExecutionContext
- apps/server/src/modules/AgentRuntime/RuntimeExecutors.ts: forward
  state.metadata.editingAgentId into the ToolExecutionContext
- apps/server/src/services/toolExecution/serverRuntimes/agentBuilder.ts:
  use ctx.editingAgentId ?? ctx.agentId in all three write methods

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 10:19:30 +08:00
Arvin Xu 11f0083074 💄 style(chat): add breathing room around message refresh hint (#15906)
* 💄 style(chat): add breathing room around message refresh hint

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

* 💄 style(chat): keep refresh hint top flush, widen bottom gap to 24px

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 10:19:21 +08:00
Arvin Xu 89b74fd7eb 🐛 fix(chat): show cached message refresh hint (#15901)
* 🐛 fix(chat): show cached message refresh hint

* 🐛 fix(chat): show refresh hint for store-backed cache

* 🐛 fix(chat): wait for model config before agent notice
2026-06-16 01:47:06 +08:00
Arvin Xu 68aaa2f6f2 🐛 fix(agent-runtime): forward model extend params on server-side agent runtime (#15891)
* 🐛 fix(agent-runtime): forward model extend params on server-side agent runtime

Share the model extend-params resolution between the client chat service and
the server-side agent runtime so reasoning/thinking params (e.g. Gemini's
thinkingLevel) actually reach the request. Previously only the client resolved
them, so server-driven agent runs returned empty thought summaries.

- extract applyModelExtendParams into @lobechat/model-runtime
- client resolveModelExtendParams delegates to the shared core
- server RuntimeExecutors resolves extendParams (with canonical-card fallback
  for aggregation providers like lobehub) and forwards them in the payload

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

*  test(agent-runtime): mock applyModelExtendParams in agent-runtime suites

The executor now imports applyModelExtendParams from @lobechat/model-runtime,
which these suites mock as a fixed object. Add the new named export (returning
an empty result, preserving prior payload behavior) so the mocked module
resolves and call_llm can run.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:37:49 +08:00
Arvin Xu 906e385e8a feat(desktop): support approved external local file previews (#15895)
*  feat(desktop): support approved external local file previews

* 🐛 fix: keep external local tabs in close scope
2026-06-16 01:24:47 +08:00
AmAzing- 91d8025421 💄 style(agent): clarify workspace copy and move actions (#15897) 2026-06-16 01:13:04 +08:00
Innei 3e261ca2c9 🐛 fix(sidebar): anchor spacer immediately after the accordion block (#15871)
* 🐛 fix(sidebar): anchor spacer immediately after the accordion block

The home sidebar spacer (`__spacer__`) drifts away from the recents+agent
accordion block in two reachable cases: (1) the dropdown-menu "move recents/
agent up/down" leaves the spacer floating above the accordion, and the
CustomizeSidebarModal then silently relocates it on the next drag; (2)
`withAllKnownKeys` appends every missing default to the tail, so any future
top-group default would land in the bottom group for existing users.

Enforce a single invariant in the selector: the spacer always sits right
after the last accordion item. `normalizeSpacerPosition` re-anchors on read
so legacy state self-heals, `withAllKnownKeys` splits backfilled defaults
into top vs bottom by their position in `DEFAULT_SIDEBAR_ITEMS`, and
`reorderSidebarItems` normalizes its result and returns the input reference
when the move is a visible no-op so callers' `next === items` short-circuit
still fires.

* 🐛 fix(sidebar): keep customize drag overlay within modal context

* 🐛 fix(sidebar): apply customization after confirm
2026-06-16 01:10:33 +08:00
Innei 2746a4b454 🐛 fix(locale): align dayjs locale imports (#15896) 2026-06-16 01:10:21 +08:00
169 changed files with 5311 additions and 2369 deletions
+10 -6
View File
@@ -111,7 +111,7 @@ First check the repo root for `.env`:
Do not start the standalone e2e server as the product under test.
Use `scripts/init-dev-env.sh`. It follows the e2e setup pattern — Postgres,
migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
Redis, migrations, auth/key-vault/S3 test env, seed user — but it is owned by this
skill and starts the repo's dev server (`pnpm run dev:next` / `bun run dev`),
not `e2e/scripts/setup.ts --start`. The script hard-blocks when root `.env`
exists, so it cannot accidentally override a user's local config. When `.env`
@@ -132,19 +132,19 @@ fi
Bootstrap flow when no `.env` exists:
```bash
# From repo root. Managed DB flow requires Docker Desktop.
# From repo root. Managed Postgres/Redis flow requires Docker Desktop.
./.agents/skills/agent-testing/scripts/init-dev-env.sh setup-db
./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
If using an existing Postgres instead of the managed Docker DB, set
`DATABASE_URL` and skip `setup-db`:
`DATABASE_URL` and `REDIS_URL`, then skip `setup-db`:
```bash
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh migrate
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh seed-user
DATABASE_URL=postgresql://... REDIS_URL=redis://... ./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
```
For backend-only checks, `dev-next` is available, but Web smoke needs the
@@ -170,6 +170,9 @@ Default script env:
- `APP_URL=http://localhost:3010`
- `DATABASE_URL=postgresql://postgres:postgres@localhost:5433/postgres`
- `DATABASE_DRIVER=node`
- `AGENT_RUNTIME_MODE=queue` so backend-only agent runtime checks use the
same queued execution path as production
- `REDIS_URL=redis://localhost:6380` for queue-mode agent runtime state
- `FEATURE_FLAGS=-agent_self_iteration` so local smoke does not require QStash
- Local QStash defaults (`QSTASH_URL`, `QSTASH_TOKEN`, signing keys) are exported;
run `init-dev-env.sh qstash` in a separate terminal when the path under test
@@ -177,6 +180,7 @@ Default script env:
- `KEY_VAULTS_SECRET`, `AUTH_SECRET`, auth verification off
- S3 mock vars
- Managed DB container: `lobehub-agent-testing-postgres`
- Managed Redis container: `lobehub-agent-testing-redis`
`seed-user` creates `agent-testing@lobehub.com` / `TestPassword123!` with
onboarding already completed, plus a local API key in
@@ -48,14 +48,15 @@ curl -s -o /dev/null -w '%{http_code}' "$SERVER_URL/"
```bash
# Start backend only.
# With root .env: use the existing local config.
pnpm run dev:next
# Agent runtime queue mode is required to mirror production async execution.
AGENT_RUNTIME_MODE=queue pnpm run dev:next
# Without root .env: use the self-contained agent-testing env.
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev-next
# Full-stack SPA + backend. Required for Web smoke.
# With root .env:
bun run dev
AGENT_RUNTIME_MODE=queue bun run dev
# Without root .env:
./.agents/skills/agent-testing/scripts/init-dev-env.sh dev
@@ -91,6 +92,8 @@ in doubt.
| `ECONNREFUSED` | Server not running — start it |
| `EADDRINUSE` on the port | Already running — `lsof -ti:<port> \| xargs kill` first |
| Stale data / old behavior | Server needs a restart to pick up code changes |
| Agent call runs inline | Set `AGENT_RUNTIME_MODE=queue`, make sure `REDIS_URL` is configured, then restart the server |
| Queue mode needs Redis | Run `init-dev-env.sh setup-db`, or provide `REDIS_URL=redis://...` for an existing Redis |
| QStash workflow failures | Start `init-dev-env.sh qstash` and make sure dev server inherited the script's `QSTASH_*` env |
Marketplace/community endpoints are not part of the local agent-testing auth
@@ -12,16 +12,16 @@
# Usage:
# init-dev-env.sh env # print shell exports
# init-dev-env.sh write [file] # write a source-able env file
# init-dev-env.sh setup-db # start local Postgres and run migrations
# init-dev-env.sh setup-db # start local Postgres/Redis and run migrations
# init-dev-env.sh migrate # run DB migrations against the configured DB
# init-dev-env.sh seed-user # seed the baseline test user + CLI API key
# init-dev-env.sh qstash # run local Upstash QStash dev server
# init-dev-env.sh dev-next # exec `pnpm run dev:next` with this env
# init-dev-env.sh dev # exec `bun run dev` with this env
# init-dev-env.sh clean-db # remove the managed Postgres container
# init-dev-env.sh clean-db # remove the managed Postgres/Redis containers
#
# Overrides:
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres QSTASH_DEV_PORT=8080
# SERVER_PORT=3010 DB_PORT=5433 DB_CONTAINER=lobehub-agent-testing-postgres REDIS_PORT=6380 REDIS_CONTAINER=lobehub-agent-testing-redis QSTASH_DEV_PORT=8080
set -euo pipefail
@@ -32,6 +32,9 @@ SERVER_PORT="${SERVER_PORT:-3010}"
DB_PORT="${DB_PORT:-5433}"
DB_CONTAINER="${DB_CONTAINER:-lobehub-agent-testing-postgres}"
DATABASE_URL="${DATABASE_URL:-postgresql://postgres:postgres@localhost:${DB_PORT}/postgres}"
REDIS_PORT="${REDIS_PORT:-6380}"
REDIS_CONTAINER="${REDIS_CONTAINER:-lobehub-agent-testing-redis}"
REDIS_URL="${REDIS_URL:-redis://localhost:${REDIS_PORT}}"
ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-dev.env"
CLI_ENV_FILE_DEFAULT="$REPO_ROOT/.records/env/agent-testing-cli.env"
AGENT_TESTING_API_KEY="${AGENT_TESTING_API_KEY:-sk-lh-agenttesting0001}"
@@ -54,6 +57,7 @@ guard_no_root_env() {
}
apply_env() {
export AGENT_RUNTIME_MODE="${AGENT_RUNTIME_MODE:-queue}"
export APP_URL="${APP_URL:-http://localhost:${SERVER_PORT}}"
export AUTH_EMAIL_VERIFICATION="${AUTH_EMAIL_VERIFICATION:-0}"
export AUTH_SECRET="${AUTH_SECRET:-agent-testing-local-auth-secret-32chars}"
@@ -69,6 +73,7 @@ apply_env() {
export QSTASH_NEXT_SIGNING_KEY="${QSTASH_NEXT_SIGNING_KEY:-$QSTASH_LOCAL_NEXT_SIGNING_KEY}"
export QSTASH_TOKEN="${QSTASH_TOKEN:-$QSTASH_LOCAL_TOKEN}"
export QSTASH_URL="${QSTASH_URL:-http://127.0.0.1:${QSTASH_DEV_PORT}}"
export REDIS_URL
export S3_ACCESS_KEY_ID="${S3_ACCESS_KEY_ID:-agent-testing-access-key}"
export S3_BUCKET="${S3_BUCKET:-agent-testing-bucket}"
export S3_ENDPOINT="${S3_ENDPOINT:-https://agent-testing-s3.localhost}"
@@ -78,6 +83,7 @@ apply_env() {
env_keys() {
printf '%s\n' \
APP_URL \
AGENT_RUNTIME_MODE \
AUTH_EMAIL_VERIFICATION \
AUTH_SECRET \
DATABASE_DRIVER \
@@ -92,6 +98,7 @@ env_keys() {
QSTASH_NEXT_SIGNING_KEY \
QSTASH_TOKEN \
QSTASH_URL \
REDIS_URL \
S3_ACCESS_KEY_ID \
S3_BUCKET \
S3_ENDPOINT \
@@ -137,6 +144,15 @@ wait_for_db() {
printf '\n'
}
wait_for_redis() {
printf ' waiting for Redis'
until docker exec "$REDIS_CONTAINER" redis-cli ping > /dev/null 2>&1; do
printf '.'
sleep 1
done
printf '\n'
}
start_db() {
require_docker
@@ -157,6 +173,25 @@ start_db() {
wait_for_db
}
start_redis() {
require_docker
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "Redis container already running: $REDIS_CONTAINER"
elif docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker start "$REDIS_CONTAINER" > /dev/null
ok "started existing Redis container: $REDIS_CONTAINER"
else
docker run -d \
--name "$REDIS_CONTAINER" \
-p "${REDIS_PORT}:6379" \
redis:7-alpine > /dev/null
ok "created Redis container: $REDIS_CONTAINER"
fi
wait_for_redis
}
migrate_db() {
apply_env
cd "$REPO_ROOT"
@@ -327,9 +362,11 @@ cmd_status() {
apply_env
echo "agent-testing local dev env:"
note "APP_URL=$APP_URL"
note "AGENT_RUNTIME_MODE=$AGENT_RUNTIME_MODE"
note "DATABASE_URL=$DATABASE_URL"
note "PORT=$PORT"
note "QSTASH_URL=$QSTASH_URL"
note "REDIS_URL=$REDIS_URL"
if command -v docker > /dev/null 2>&1; then
ok "docker CLI available"
if docker ps --format '{{.Names}}' | grep -Fxq "$DB_CONTAINER"; then
@@ -337,6 +374,11 @@ cmd_status() {
else
note "managed Postgres is not running: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
ok "managed Redis running: $REDIS_CONTAINER"
else
note "managed Redis is not running: $REDIS_CONTAINER"
fi
else
bad "docker CLI is not available"
fi
@@ -373,6 +415,15 @@ cmd_clean_db() {
else
note "Postgres container not found: $DB_CONTAINER"
fi
if docker ps --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker stop "$REDIS_CONTAINER" > /dev/null
fi
if docker ps -a --format '{{.Names}}' | grep -Fxq "$REDIS_CONTAINER"; then
docker rm "$REDIS_CONTAINER" > /dev/null
ok "removed Redis container: $REDIS_CONTAINER"
else
note "Redis container not found: $REDIS_CONTAINER"
fi
}
usage() {
@@ -391,6 +442,7 @@ case "$COMMAND" in
write) shift; write_env "${1:-}" ;;
setup-db)
start_db
start_redis
migrate_db
;;
migrate) migrate_db ;;
@@ -438,12 +438,14 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreviewUrl({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewUrlResult> {
try {
const url = await this.app.localFileProtocolManager.createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -462,12 +464,14 @@ export default class LocalFileCtr extends ControllerModule {
@IpcMethod()
async getLocalFilePreview({
accept,
allowExternalFile,
path: filePath,
workingDirectory,
}: LocalFilePreviewUrlParams): Promise<LocalFilePreviewResult> {
try {
const preview = await this.app.localFileProtocolManager.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot: workingDirectory,
});
@@ -226,6 +226,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -262,6 +263,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -270,6 +272,29 @@ describe('LocalFileCtr', () => {
url: 'localfile://file/workspace/image.png?token=abc',
});
});
it('should forward user-approved external preview URL access', async () => {
mockLocalFileProtocolManager.createPreviewUrl.mockResolvedValue(
'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
);
const result = await localFileCtr.getLocalFilePreviewUrl({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.createPreviewUrl).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
success: true,
url: 'localfile://file/tmp/worktree-switcher-demo.html?token=abc',
});
});
});
describe('getLocalFilePreview', () => {
@@ -287,6 +312,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: undefined,
allowExternalFile: undefined,
filePath: '/workspace/app.ts',
workspaceRoot: '/workspace',
});
@@ -329,6 +355,7 @@ describe('LocalFileCtr', () => {
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
accept: 'image',
allowExternalFile: undefined,
filePath: '/workspace/image.png',
workspaceRoot: '/workspace',
});
@@ -341,6 +368,35 @@ describe('LocalFileCtr', () => {
success: true,
});
});
it('should forward user-approved external preview reads', async () => {
mockLocalFileProtocolManager.readPreviewFile.mockResolvedValue({
buffer: Buffer.from('<h1>Demo</h1>'),
contentType: 'text/html',
realPath: '/tmp/worktree-switcher-demo.html',
});
const result = await localFileCtr.getLocalFilePreview({
allowExternalFile: true,
path: '/tmp/worktree-switcher-demo.html',
workingDirectory: '/tmp',
});
expect(mockLocalFileProtocolManager.readPreviewFile).toHaveBeenCalledWith({
allowExternalFile: true,
accept: undefined,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(result).toEqual({
preview: {
content: '<h1>Demo</h1>',
contentType: 'text/html',
type: 'text',
},
success: true,
});
});
});
describe('handleWriteFile', () => {
@@ -21,6 +21,7 @@ const LOCAL_FILE_PROTOCOL_PRIVILEGES = {
const logger = createLogger('core:LocalFileProtocolManager');
const PREVIEW_TOKEN_TTL_MS = 5 * 60 * 1000;
const EXTERNAL_PREVIEW_APPROVAL_TTL_MS = 10 * 60 * 1000;
const normalizeAbsolutePath = (filePath: string): string | null => {
const normalized = path.normalize(filePath);
@@ -59,10 +60,7 @@ type PreviewFileAccept = 'image';
const normalizeContentType = (contentType: string): string =>
contentType.split(';')[0].trim().toLowerCase();
const isAcceptedPreviewContentType = (
contentType: string,
accept?: PreviewFileAccept,
): boolean => {
const isAcceptedPreviewContentType = (contentType: string, accept?: PreviewFileAccept): boolean => {
if (!accept) return true;
const normalizedContentType = normalizeContentType(contentType);
@@ -84,6 +82,8 @@ const isAcceptedPreviewContentType = (
export class LocalFileProtocolManager {
private readonly approvedWorkspaceRoots = new Set<string>();
private readonly externalPreviewApprovals = new Map<string, number>();
private readonly indexedProjectRoots = new Set<string>();
private handlerRegistered = false;
@@ -229,10 +229,12 @@ export class LocalFileProtocolManager {
async createPreviewUrl({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<string | null> {
@@ -243,11 +245,12 @@ export class LocalFileProtocolManager {
? (
await this.readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
})
)?.realPath
: await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
: await this.resolveApprovedPreviewPath({ allowExternalFile, filePath, workspaceRoot });
if (!realFilePath) return null;
this.cleanupExpiredTokens();
@@ -263,14 +266,21 @@ export class LocalFileProtocolManager {
async readPreviewFile({
accept,
allowExternalFile,
filePath,
workspaceRoot,
}: {
accept?: PreviewFileAccept;
allowExternalFile?: boolean;
filePath: string;
workspaceRoot: string;
}): Promise<PreviewFileReadResult | null> {
const realFilePath = await this.resolveApprovedPreviewPath({ filePath, workspaceRoot });
const realFilePath = await this.resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval: false,
workspaceRoot,
});
if (!realFilePath) return null;
const fileStat = await stat(realFilePath);
@@ -280,6 +290,10 @@ export class LocalFileProtocolManager {
const contentType = resolveLocalFileMimeType(realFilePath, buffer);
if (!isAcceptedPreviewContentType(contentType, accept)) return null;
if (allowExternalFile) {
this.grantExternalPreviewApproval(realFilePath);
}
return {
buffer,
contentType,
@@ -327,10 +341,14 @@ export class LocalFileProtocolManager {
}
private async resolveApprovedPreviewPath({
allowExternalFile,
filePath,
persistExternalApproval = true,
workspaceRoot,
}: {
allowExternalFile?: boolean;
filePath: string;
persistExternalApproval?: boolean;
workspaceRoot: string;
}): Promise<string | null> {
const normalizedFilePath = normalizeAbsolutePath(filePath);
@@ -345,15 +363,44 @@ export class LocalFileProtocolManager {
const normalizedRealWorkspaceRoot = normalizeAbsolutePath(realWorkspaceRoot);
if (!normalizedRealFilePath || !normalizedRealWorkspaceRoot) return null;
const workspaceRootApproved =
this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) ||
this.indexedProjectRoots.has(normalizedRealWorkspaceRoot);
if (
!this.approvedWorkspaceRoots.has(normalizedRealWorkspaceRoot) &&
!this.indexedProjectRoots.has(normalizedRealWorkspaceRoot)
workspaceRootApproved &&
isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)
) {
return null;
return normalizedRealFilePath;
}
if (!isPathWithinRoot(normalizedRealFilePath, normalizedRealWorkspaceRoot)) return null;
return normalizedRealFilePath;
if (this.hasExternalPreviewApproval(normalizedRealFilePath)) return normalizedRealFilePath;
if (allowExternalFile) {
return this.approveExternalPreviewFile(normalizedRealFilePath, {
persist: persistExternalApproval,
});
}
return null;
}
private async approveExternalPreviewFile(
realFilePath: string,
{ persist = true }: { persist?: boolean } = {},
): Promise<string | null> {
const fileStat = await stat(realFilePath);
if (!fileStat.isFile()) return null;
if (persist) {
this.grantExternalPreviewApproval(realFilePath);
}
return realFilePath;
}
private grantExternalPreviewApproval(realFilePath: string) {
this.cleanupExpiredExternalPreviewApprovals();
this.externalPreviewApprovals.set(realFilePath, Date.now() + EXTERNAL_PREVIEW_APPROVAL_TTL_MS);
}
private cleanupExpiredTokens() {
@@ -365,6 +412,15 @@ export class LocalFileProtocolManager {
}
}
private cleanupExpiredExternalPreviewApprovals() {
const now = Date.now();
for (const [realPath, expiresAt] of this.externalPreviewApprovals) {
if (expiresAt <= now) {
this.externalPreviewApprovals.delete(realPath);
}
}
}
private hasPreviewToken(token: string): boolean {
const record = this.previewTokens.get(token);
if (!record) return false;
@@ -383,4 +439,16 @@ export class LocalFileProtocolManager {
return record.realPath === realResolvedPath;
}
private hasExternalPreviewApproval(realFilePath: string): boolean {
const expiresAt = this.externalPreviewApprovals.get(realFilePath);
if (!expiresAt) return false;
if (expiresAt <= Date.now()) {
this.externalPreviewApprovals.delete(realFilePath);
return false;
}
return true;
}
}
@@ -263,6 +263,31 @@ describe('LocalFileProtocolManager', () => {
expect(url).toBeNull();
});
it('mints preview URLs for user-approved external files only', async () => {
const manager = new LocalFileProtocolManager();
const url = await manager.createPreviewUrl({
allowExternalFile: true,
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
if (!url) throw new Error('Expected external local file preview URL');
expect(url).toContain('token=');
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/worktree-switcher-demo.html',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toContain('token=');
const neighborUrl = await manager.createPreviewUrl({
filePath: '/tmp/other.html',
workspaceRoot: '/tmp',
});
expect(neighborUrl).toBeNull();
});
it('can approve a project root derived from an already approved nested scope', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveWorkspaceRoot('/Users/alice/project/packages/app');
@@ -326,6 +351,26 @@ describe('LocalFileProtocolManager', () => {
expect(mockReadFile).toHaveBeenCalledWith('/Users/alice/project/.env');
});
it('does not keep external approval when an image-only external preview rejects text', async () => {
const manager = new LocalFileProtocolManager();
mockReadFile.mockResolvedValue(Buffer.from('SECRET=value'));
const result = await manager.readPreviewFile({
accept: 'image',
allowExternalFile: true,
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(result).toBeNull();
const repeatedUrl = await manager.createPreviewUrl({
filePath: '/tmp/secret.txt',
workspaceRoot: '/tmp',
});
expect(repeatedUrl).toBeNull();
});
it('does not read preview payloads outside the approved workspace root', async () => {
const manager = new LocalFileProtocolManager();
await manager.approveIndexedProjectRoot('/Users/alice/project');
@@ -38,7 +38,12 @@ import {
ToolResolver,
} from '@lobechat/context-engine';
import { parse } from '@lobechat/conversation-flow';
import { consumeStreamUntilDone } from '@lobechat/model-runtime';
import {
applyModelExtendParams,
type ChatStreamPayload,
consumeStreamUntilDone,
type ModelExtendParams,
} from '@lobechat/model-runtime';
import {
context as otelContext,
SpanKind,
@@ -67,6 +72,7 @@ import {
} from '@lobechat/types';
import { sanitizeToolCallArguments, serializePartsForStorage } from '@lobechat/utils';
import debug from 'debug';
import type { ExtendParamsType } from 'model-bank';
import { composioEnv } from '@/config/composio';
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
@@ -873,6 +879,7 @@ export const createRuntimeExecutors = (
type ContentPart = { text: string; type: 'text' } | { image: string; type: 'image' };
let shouldReplayAssistantReasoning = false;
let preserveThinkingForPayload: boolean | undefined;
let resolvedExtendParams: ModelExtendParams | undefined;
// Process messages through serverMessagesEngine to inject system role, knowledge, etc.
// Rebuild params from agentConfig at execution time (capabilities built dynamically)
@@ -888,19 +895,36 @@ export const createRuntimeExecutors = (
: undefined;
const preserveThinkingRequested = preserveThinkingConfigured === true;
const readExtendParams = (
card: (typeof builtinModels)[number] | undefined,
): string[] | undefined =>
card &&
'settings' in card &&
card.settings &&
typeof card.settings === 'object' &&
'extendParams' in card.settings
? (card.settings as { extendParams?: string[] }).extendParams
: undefined;
const modelCard = builtinModels.find(
(item) =>
item.providerId === provider &&
(item.id === model || item.config?.deploymentName === model),
);
const modelExtendParams =
modelCard &&
'settings' in modelCard &&
modelCard.settings &&
typeof modelCard.settings === 'object' &&
'extendParams' in modelCard.settings
? (modelCard.settings as { extendParams?: string[] }).extendParams
: undefined;
let modelExtendParams = readExtendParams(modelCard);
// Aggregation providers (e.g. `lobehub`) may serve a model without copying
// its origin `settings.extendParams`. Fall back to the canonical model card
// (matched by id across any provider) so reasoning/thinking params like
// `thinkingLevel` still reach the model. Mirrors the client-side
// `transformToAiModelList` re-namespacing behavior.
if (!modelExtendParams || modelExtendParams.length === 0) {
const canonicalCard = builtinModels.find(
(item) => item.id === model || item.config?.deploymentName === model,
);
modelExtendParams = readExtendParams(canonicalCard);
}
const modelSupportsPreserveThinkingFromCard =
Array.isArray(modelExtendParams) && modelExtendParams.includes('preserveThinking');
@@ -915,6 +939,19 @@ export const createRuntimeExecutors = (
modelSupportsPreserveThinking && typeof preserveThinkingConfigured === 'boolean'
? preserveThinkingConfigured
: undefined;
// Resolve model extend params (thinkingLevel, reasoning effort, urlContext, …)
// from the agent chat config so the server-side agent runtime forwards the same
// runtime params the client chat service does. Without this, e.g. Gemini 3 Pro's
// `thinkingLevel` never reaches the request and thought summaries come back empty.
if (agentConfig.chatConfig) {
resolvedExtendParams = applyModelExtendParams({
chatConfig: agentConfig.chatConfig,
extendParams: modelExtendParams as ExtendParamsType[] | undefined,
model,
});
}
const messagesForContext = shouldReplayAssistantReasoning
? (llmPayload.messages as UIChatMessage[])
: stripAssistantReasoningForReplay(llmPayload.messages as UIChatMessage[]);
@@ -1356,6 +1393,9 @@ export const createRuntimeExecutors = (
model,
stream,
tools,
// ModelExtendParams keeps provider-specific effort/thinking values as loose
// strings (e.g. hy3's 'no_think'); the runtime payload narrows them, so cast.
...(resolvedExtendParams as Partial<ChatStreamPayload>),
...(typeof preserveThinkingForPayload === 'boolean' && {
preserveThinking: preserveThinkingForPayload,
}),
@@ -2621,6 +2661,7 @@ export const createRuntimeExecutors = (
payload.parentMessageId,
),
documentId: state.metadata?.documentId,
editingAgentId: state.metadata?.editingAgentId,
execSubAgent: ctx.execSubAgent,
executionTimeoutMs: timeoutMs,
groupId: state.metadata?.groupId,
@@ -58,6 +58,9 @@ vi.mock('@/server/services/message', () => ({
// @lobechat/model-runtime resolves to @cloud/business-model-runtime which has
// cloud-specific dependencies that are unavailable in the test environment
vi.mock('@lobechat/model-runtime', () => ({
// The executor resolves extend params via this helper; an empty result keeps
// the runtime payload unchanged, matching this suite's pre-existing behavior.
applyModelExtendParams: vi.fn(() => ({})),
consumeStreamUntilDone: vi.fn().mockResolvedValue(undefined),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -125,6 +128,7 @@ describe('RuntimeExecutors', () => {
mockMessageModel = {
create: vi.fn().mockResolvedValue({ id: 'msg-123' }),
deleteMessage: vi.fn().mockResolvedValue({ success: true }),
// call_llm does a parent existence preflight; return a truthy row by
// default so existing tests don't have to stub it.
findById: vi.fn().mockResolvedValue({ id: 'msg-existing' }),
@@ -4850,10 +4854,9 @@ describe('RuntimeExecutors', () => {
...overrides,
});
it('call_tool sets stop:true in tool_result payload when tool returns execSubAgent state', async () => {
// Simulate agentManagement.callAgent returning execSubAgent state
it('call_tool preserves stop:true for legacy execSubAgent state', async () => {
mockToolExecutionService.executeTool.mockResolvedValue({
content: '🚀 Triggered async task to call agent "target-agent"',
content: 'Legacy async task result',
executionTime: 10,
state: {
parentMessageId: 'tool-msg-id',
@@ -4894,13 +4897,112 @@ describe('RuntimeExecutors', () => {
expect((result.nextContext?.payload as any).stop).toBe(true);
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgentTask = vi
it('call_tool lets server callAgent run as a deferred tool via the subAgent runner', async () => {
const mockExecVirtualSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgentTask,
execVirtualSubAgent: mockExecVirtualSubAgent,
topicId: 'topic-123',
};
mockMessageModel.create.mockResolvedValueOnce({ id: 'tool-msg-id' });
mockToolExecutionService.executeTool.mockImplementation(
async (_payload: any, context: any) => {
const subAgent = await context.subAgent.run({
agentId: 'target-agent-id',
description: 'Call agent target-agent',
instruction: 'Do something useful',
timeout: 1_800_000,
});
return {
content: '',
deferred: true,
executionTime: 10,
state: {
status: 'pending',
subOperationId: subAgent.subOperationId,
targetAgentId: 'target-agent-id',
threadId: subAgent.threadId,
},
success: subAgent.started,
};
},
);
const executors = createRuntimeExecutors(ctxWithCallback);
const state = createMockState();
const instruction = {
payload: {
parentMessageId: 'assistant-msg-id',
toolCalling: {
apiName: 'callAgent',
arguments: JSON.stringify({
agentId: 'target-agent-id',
instruction: 'Do something useful',
runAsTask: true,
}),
id: 'tool-call-1',
identifier: 'lobe-agent-management',
type: 'default' as const,
},
},
type: 'call_tool' as const,
};
const result = await executors.call_tool!(instruction, state);
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
plugin: expect.objectContaining({
apiName: 'callAgent',
identifier: 'lobe-agent-management',
}),
pluginState: { status: 'pending' },
parentId: 'assistant-msg-id',
role: 'tool',
tool_call_id: 'tool-call-1',
topicId: 'topic-123',
}),
);
expect(mockExecVirtualSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
parentMessageId: 'tool-msg-id',
parentOperationId: 'op-123',
title: 'Call agent target-agent',
topicId: 'topic-123',
}),
);
expect(result.newState.status).toBe('waiting_for_async_tool');
expect(result.newState.pendingToolsCalling).toEqual([
expect.objectContaining({
apiName: 'callAgent',
id: 'tool-call-1',
identifier: 'lobe-agent-management',
}),
]);
expect(result.events).toEqual([
expect.objectContaining({
canResume: true,
reason: 'async_tool',
type: 'interrupted',
}),
]);
expect(result.nextContext).toBeUndefined();
});
it('exec_sub_agent executor creates task message and calls execSubAgent callback', async () => {
const mockExecSubAgent = vi
.fn()
.mockResolvedValue({ success: true, operationId: 'child-op', threadId: 'thread-child' });
const ctxWithCallback = {
...ctx,
execSubAgent: mockExecSubAgent,
topicId: 'topic-123',
};
@@ -4926,6 +5028,9 @@ describe('RuntimeExecutors', () => {
expect(mockMessageModel.create).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'parent-agent-id',
metadata: expect.objectContaining({
targetAgentId: 'target-agent-id',
}),
role: 'task',
parentId: 'tool-msg-id',
topicId: 'topic-123',
@@ -4933,7 +5038,7 @@ describe('RuntimeExecutors', () => {
);
// execSubAgent callback fired with targetAgentId
expect(mockExecSubAgentTask).toHaveBeenCalledWith(
expect(mockExecSubAgent).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'target-agent-id',
instruction: 'Do something useful',
@@ -4947,10 +5052,10 @@ describe('RuntimeExecutors', () => {
});
it('exec_sub_agent blocks nested dispatch when current state is already a sub-agent', async () => {
const mockExecSubAgentTask = vi.fn();
const mockExecSubAgent = vi.fn();
const ctxWithCallback = {
...ctx,
execSubAgentTask: mockExecSubAgentTask,
execSubAgent: mockExecSubAgent,
topicId: 'topic-123',
};
@@ -4983,7 +5088,7 @@ describe('RuntimeExecutors', () => {
success: false,
});
expect(mockMessageModel.create).not.toHaveBeenCalled();
expect(mockExecSubAgentTask).not.toHaveBeenCalled();
expect(mockExecSubAgent).not.toHaveBeenCalled();
});
it('exec_sub_agent gracefully skips dispatch when execSubAgent not injected', async () => {
@@ -659,6 +659,59 @@ describe('createServerAgentToolsEngine', () => {
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when a device is explicitly bound (locked to the selection)', () => {
// A user-selected (bound) device locks the run to that device — the
// activate-device tool is never offered, so the model cannot switch.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
autoActivated: true,
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should disable RemoteDevice when the bound device is OFFLINE — no silent hop to another machine', () => {
// The bound device going offline makes the plan device-unrouted, so
// `autoActivated` is false. Without the `boundDeviceId` gate the tool
// would resurface and let the model activate a *different* online device.
// The explicit selection must keep the run locked instead.
const context = createMockContext();
const engine = createServerAgentToolsEngine(context, {
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
canUseDevice: true,
deviceContext: {
boundDeviceId: 'device-001',
deviceOnline: true,
gatewayConfigured: true,
},
model: 'gpt-4',
provider: 'openai',
});
const result = engine.generateToolsDetailed({
toolIds: [RemoteDeviceManifest.identifier],
model: 'gpt-4',
provider: 'openai',
});
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
});
it('should enable RemoteDevice in bot conversations when caller is trusted (canUseDevice=true)', () => {
// The `!isBotConversation` clause was dropped in — the
// confused-deputy concern that motivated it is now handled at a
@@ -231,12 +231,20 @@ export const createServerAgentToolsEngine = (
// Only auto-enable in bot conversations; otherwise let user's plugin selection take effect
...(isBotConversation && { [MessageManifest.identifier]: true }),
// Remote-device proxy: shown only for device-capable targets when the
// server has a proxy but no specific device is auto-activated yet (user
// must pick). External bot senders never reach it: the plan degrades
// denied targets to `none` (→ not deviceCapable) and the physical
// manifest walls drop it for `canUseDevice=false` turns.
// server has a proxy, no specific device is auto-activated yet, AND the
// user has NOT explicitly selected a device. Once a device is explicitly
// selected (`boundDeviceId`), the run is locked to it: we never expose the
// activate-device tool, so the model can never switch to another machine —
// not even when the selected device is offline (the run stays unrouted
// until that device comes back, rather than silently hopping elsewhere).
// External bot senders never reach it: the plan degrades denied targets to
// `none` (→ not deviceCapable) and the physical manifest walls drop it for
// `canUseDevice=false` turns.
[RemoteDeviceManifest.identifier]:
deviceCapable && hasDeviceProxy && !deviceContext?.autoActivated,
deviceCapable &&
hasDeviceProxy &&
!deviceContext?.autoActivated &&
!deviceContext?.boundDeviceId,
[AgentDocumentsManifest.identifier]: hasAgentDocuments,
[WebBrowsingManifest.identifier]: isSearchEnabled,
};
@@ -0,0 +1,289 @@
// @vitest-environment node
/**
* Integration test for the server `lobe-agent-management.callAgent` deferred
* execution flow.
*
* Verifies the full lifecycle end-to-end on the in-memory runtime:
* 1. Parent op LLM emits a `lobe-agent-management____callAgent` tool call.
* 2. The real server executor parks the parent, creates a pending tool
* placeholder, and forks the target agent as a child op.
* 3. The child op completes.
* 4. The completion bridge backfills the placeholder and resumes the parent.
* 5. The parent reaches `done`.
*/
import { type LobeChatDatabase } from '@lobechat/database';
import { agentOperations, agents, messagePlugins, messages } from '@lobechat/database/schemas';
import { getTestDB } from '@lobechat/database/test-utils';
import { and, eq } from 'drizzle-orm';
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { inMemoryAgentStateManager } from '@/server/modules/AgentRuntime/InMemoryAgentStateManager';
import { inMemoryStreamEventManager } from '@/server/modules/AgentRuntime/InMemoryStreamEventManager';
import { aiAgentRouter } from '../../../aiAgent';
import { cleanupTestUser, createTestUser } from '../setup';
import { createMockResponsesStream, waitForOperationComplete } from './helpers';
process.env.OPENAI_API_KEY = 'sk-test-fake-api-key-for-testing';
let testDB: LobeChatDatabase;
vi.mock('@/database/core/db-adaptor', () => ({
getServerDB: vi.fn(() => testDB),
}));
vi.mock('@/server/services/file', () => ({
FileService: vi.fn().mockImplementation(() => ({
getFullFileUrl: vi.fn().mockImplementation((path: string) => (path ? `/files${path}` : null)),
})),
}));
let mockResponsesCreate: any;
let serverDB: LobeChatDatabase;
let userId: string;
let parentAgentId: string;
let targetAgentId: string;
const TARGET_ANSWER = 'The target agent completed the delegated callAgent work.';
const PARENT_FINAL = 'I received the target agent result and the delegated work is complete.';
const createTestContext = () => ({ jwtPayload: { userId }, userId });
const createCallAgentResponse = () => {
const responseId = `resp_call_agent_${Date.now()}`;
const msgItemId = `msg_call_agent_${Date.now()}`;
const callId = 'call_agent_1';
const fnCall = {
arguments: JSON.stringify({
agentId: targetAgentId,
instruction: 'Handle the delegated backend integration task.',
runAsTask: true,
taskTitle: 'Delegated backend integration task',
timeout: 30_000,
}),
call_id: callId,
name: 'lobe-agent-management____callAgent',
type: 'function_call',
};
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
item: {
content: [],
id: msgItemId,
role: 'assistant',
status: 'in_progress',
type: 'message',
},
output_index: 0,
type: 'response.output_item.added',
},
{
content_index: 0,
delta: 'I will delegate this to the target agent.',
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{ item: fnCall, output_index: 1, type: 'response.output_item.added' },
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: 'I will delegate this to the target agent.', type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
fnCall,
],
status: 'completed',
usage: { input_tokens: 30, output_tokens: 20, total_tokens: 50 },
},
type: 'response.completed',
},
]);
};
const createFinalTextResponse = (content: string) => {
const responseId = `resp_final_${Date.now()}_${content.length}`;
const msgItemId = `msg_final_${Date.now()}_${content.length}`;
return createMockResponsesStream([
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [],
status: 'in_progress',
},
type: 'response.created',
},
{
content_index: 0,
delta: content,
item_id: msgItemId,
output_index: 0,
type: 'response.output_text.delta',
},
{
response: {
created_at: Math.floor(Date.now() / 1000),
id: responseId,
model: 'gpt-5-pro',
object: 'response',
output: [
{
content: [{ text: content, type: 'output_text' }],
id: msgItemId,
role: 'assistant',
status: 'completed',
type: 'message',
},
],
status: 'completed',
usage: { input_tokens: 40, output_tokens: 20, total_tokens: 60 },
},
type: 'response.completed',
},
]);
};
beforeEach(async () => {
serverDB = await getTestDB();
testDB = serverDB;
userId = await createTestUser(serverDB);
const insertedAgents = await serverDB
.insert(agents)
.values([
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: ['lobe-agent-management'],
provider: 'openai',
systemRole: 'You are a supervisor that delegates work to other agents.',
title: 'callAgent Supervisor',
userId,
},
{
chatConfig: {},
model: 'gpt-5-pro',
plugins: [],
provider: 'openai',
systemRole: 'You are the target agent. Return a concise result.',
title: 'callAgent Target',
userId,
},
])
.returning();
parentAgentId = insertedAgents[0].id;
targetAgentId = insertedAgents[1].id;
// `create` is overloaded (streaming / non-streaming); its precise spy type
// isn't assignable to the generic MockInstance fallback, so widen via unknown.
mockResponsesCreate = vi.spyOn(
OpenAI.Responses.prototype,
'create',
) as unknown as typeof mockResponsesCreate;
});
afterEach(async () => {
await cleanupTestUser(serverDB, userId);
vi.clearAllMocks();
vi.restoreAllMocks();
inMemoryAgentStateManager.clear();
inMemoryStreamEventManager.clear();
});
describe('Server callAgent deferred execution', () => {
it('parks the parent, runs the target agent, backfills the tool message and resumes', async () => {
let callCount = 0;
mockResponsesCreate.mockImplementation(() => {
callCount++;
if (callCount === 1) return Promise.resolve(createCallAgentResponse() as any);
if (callCount === 2) return Promise.resolve(createFinalTextResponse(TARGET_ANSWER) as any);
return Promise.resolve(createFinalTextResponse(PARENT_FINAL) as any);
});
const caller = aiAgentRouter.createCaller(createTestContext());
const createResult = await caller.execAgent({
agentId: parentAgentId,
prompt: 'Delegate this work to the target agent and report back.',
userInterventionConfig: { approvalMode: 'headless' },
});
expect(createResult.success).toBe(true);
const finalState = await waitForOperationComplete(
inMemoryAgentStateManager,
createResult.operationId,
{ maxWaitTime: 20_000 },
);
expect(finalState.status).toBe('done');
expect(finalState.pendingToolsCalling ?? []).toHaveLength(0);
expect(mockResponsesCreate).toHaveBeenCalledTimes(3);
const childOps = await serverDB
.select()
.from(agentOperations)
.where(eq(agentOperations.parentOperationId, createResult.operationId));
expect(childOps).toHaveLength(1);
expect(childOps[0]).toMatchObject({
agentId: targetAgentId,
status: 'done',
});
const toolMessages = await serverDB
.select({
content: messages.content,
role: messages.role,
state: messagePlugins.state,
identifier: messagePlugins.identifier,
apiName: messagePlugins.apiName,
toolCallId: messagePlugins.toolCallId,
})
.from(messages)
.innerJoin(messagePlugins, eq(messagePlugins.id, messages.id))
.where(
and(
eq(messages.userId, userId),
eq(messagePlugins.identifier, 'lobe-agent-management'),
eq(messagePlugins.apiName, 'callAgent'),
),
);
expect(toolMessages).toHaveLength(1);
expect(toolMessages[0]).toMatchObject({
apiName: 'callAgent',
content: TARGET_ANSWER,
identifier: 'lobe-agent-management',
role: 'tool',
toolCallId: 'call_agent_1',
});
expect(toolMessages[0].state).toMatchObject({
status: 'completed',
threadId: childOps[0].threadId,
});
}, 30_000);
});
@@ -139,6 +139,8 @@ const ExecAgentSchema = z
.object({
defaultTaskAssigneeAgentId: z.string().optional(),
documentId: z.string().optional().nullable(),
/** The agent being edited when scope is 'agent_builder' (not the builder builtin itself). */
editingAgentId: z.string().optional(),
groupId: z.string().optional().nullable(),
initialTopicMetadata: z
.object({
@@ -115,6 +115,33 @@ export const connectorRouter = router({
return toolsByConnector;
}),
/**
* Return the connector record with decrypted user-set credentials so the
* edit form can pre-fill accurately. Only the connector owner can call this
* (enforced by connectorProcedure ownership check).
*
* Machine-managed secrets are intentionally excluded:
* - OAuth access/refresh tokens (type 'oauth2') stripped, returned as null
* - oidcConfig.clientSecret (DCR-registered secret) stripped
* User-set credentials (bearer token, custom headers) are returned as-is so
* the edit form can display them.
*/
getForEdit: connectorProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input, ctx }) => {
const connector = await ctx.connectorModel.findById(input.id);
if (!connector)
throw new TRPCError({ code: 'NOT_FOUND', message: 'Connector not found' });
const { oidcConfig, credentials, ...rest } = connector;
const safeOidcConfig = oidcConfig ? { ...oidcConfig, clientSecret: undefined } : oidcConfig;
// OAuth tokens are machine-managed — don't return them; the UI only needs
// to know an OAuth flow is configured (reflected via oidcConfig presence).
const safeCredentials = credentials?.type === 'oauth2' ? null : credentials;
return { ...rest, credentials: safeCredentials, oidcConfig: safeOidcConfig };
}),
/**
* The exact redirect URI the server will send to the OAuth/DCR endpoints.
* The Add modal must display THIS value (not a client-derived origin) so the
+1 -7
View File
@@ -563,13 +563,7 @@ export const deviceRouter = router({
deviceId: z.string(),
friendlyName: z.string().max(100).nullable().optional(),
workingDirs: z
.array(
z.object({
git: z.object({ activeWorktree: z.string().optional() }).optional(),
path: z.string(),
repoType: z.enum(['git', 'github']).optional(),
}),
)
.array(z.object({ path: z.string(), repoType: z.enum(['git', 'github']).optional() }))
.max(20)
.optional(),
}),
+2
View File
@@ -13,6 +13,7 @@ import { aiProviderRouter } from '../lambda/aiProvider';
import { briefRouter } from '../lambda/brief';
import { chunkRouter } from '../lambda/chunk';
import { configRouter } from '../lambda/config';
import { deviceRouter } from '../lambda/device';
import { documentRouter } from '../lambda/document';
import { fileRouter } from '../lambda/file';
import { homeRouter } from '../lambda/home';
@@ -36,6 +37,7 @@ export const mobileRouter = router({
aiProvider: aiProviderRouter,
chunk: chunkRouter,
config: configRouter,
device: deviceRouter,
document: documentRouter,
file: fileRouter,
healthcheck: publicProcedure.query(() => "i'm live!"),
@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
isMarketConnectionsAuthError,
isMarketConnectionsTimeoutError,
listMarketConnectionsWithTimeout,
listOptionalMarketConnectionsWithTimeout,
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
} from './marketConnections';
@@ -31,4 +33,33 @@ describe('marketConnections helpers', () => {
expect(isMarketConnectionsTimeoutError(new DOMException('Aborted', 'AbortError'))).toBe(true);
expect(isMarketConnectionsTimeoutError(new Error('market failed'))).toBe(false);
});
it('detects Market auth failures', () => {
expect(
isMarketConnectionsAuthError({
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
status: 401,
}),
).toBe(true);
expect(isMarketConnectionsAuthError(new Error('Network error'))).toBe(false);
});
it('returns empty connections for optional auth failures', async () => {
const listConnections = vi.fn().mockRejectedValue({
errorBody: { error: 'unauthorized', error_description: 'Missing bearer token' },
status: 401,
});
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).resolves.toEqual({
connections: [],
success: true,
});
});
it('rethrows non-auth failures for optional connections', async () => {
const error = new Error('Market API unavailable');
const listConnections = vi.fn().mockRejectedValue(error);
await expect(listOptionalMarketConnectionsWithTimeout({ listConnections })).rejects.toBe(error);
});
});
@@ -4,6 +4,47 @@ export const MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS = 10_000;
type MarketConnectClient = Pick<MarketSDK['connect'], 'listConnections'>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const getStringField = (value: unknown, key: string) => {
if (!isRecord(value)) return;
const field = value[key];
return typeof field === 'string' ? field : undefined;
};
const includesAuthError = (value?: string) => {
const normalized = value?.toLowerCase();
if (!normalized) return false;
return (
normalized === 'unauthorized' ||
normalized === 'invalid_token' ||
normalized === 'token_expired' ||
normalized.includes('missing bearer token') ||
normalized.includes('unauthorized') ||
normalized.includes('invalid_token') ||
normalized.includes('token expired')
);
};
export const isMarketConnectionsAuthError = (error: unknown): boolean => {
if (!isRecord(error)) return false;
const status = error.status;
const errorBody = error.errorBody;
return (
status === 401 ||
includesAuthError(getStringField(error, 'name')) ||
includesAuthError(getStringField(error, 'message')) ||
includesAuthError(getStringField(errorBody, 'error')) ||
includesAuthError(getStringField(errorBody, 'error_description'))
);
};
export const isMarketConnectionsTimeoutError = (error: unknown): boolean =>
error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
@@ -15,3 +56,18 @@ export const listMarketConnectionsWithTimeout = async (
signal: AbortSignal.timeout(timeoutMs),
});
};
export const listOptionalMarketConnectionsWithTimeout = async (
marketConnect: MarketConnectClient,
timeoutMs = MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
): Promise<ListConnectionsResponse> => {
try {
return await listMarketConnectionsWithTimeout(marketConnect, timeoutMs);
} catch (error) {
if (isMarketConnectionsAuthError(error)) {
return { connections: [], success: true };
}
throw error;
}
};
+2 -2
View File
@@ -23,7 +23,7 @@ import { createSandboxService } from '@/server/services/sandbox';
import { scheduleToolCallReport } from './_helpers';
import {
isMarketConnectionsTimeoutError,
listMarketConnectionsWithTimeout,
listOptionalMarketConnectionsWithTimeout,
MARKET_CONNECTIONS_REQUEST_TIMEOUT_MS,
} from './_helpers/marketConnections';
@@ -540,7 +540,7 @@ export const marketRouter = router({
log('connectListConnections');
try {
const response = await listMarketConnectionsWithTimeout(ctx.marketSDK.connect);
const response = await listOptionalMarketConnectionsWithTimeout(ctx.marketSDK.connect);
// Debug logging
log('connectListConnections raw response: %O', response);
log('connectListConnections connections: %O', response.connections);
@@ -98,6 +98,8 @@ describe('AgentDocumentsService', () => {
findByFilename: vi.fn(),
findSkillDocsByAgent: vi.fn(),
hasByAgent: vi.fn(),
listByAgent: vi.fn(),
listByDocumentIds: vi.fn(),
rename: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
@@ -282,21 +284,19 @@ describe('AgentDocumentsService', () => {
describe('listDocuments', () => {
it('should return a list of documents with documentId, filename, id, and title', async () => {
mockModel.findByAgent.mockResolvedValue([
mockModel.listByAgent.mockResolvedValue([
{
content: 'c1',
documentId: 'documents-1',
filename: 'a.md',
id: 'doc-1',
policy: null,
loadPosition: undefined,
title: 'A',
},
{
content: 'c2',
documentId: 'documents-2',
filename: 'b.md',
id: 'doc-2',
policy: null,
loadPosition: undefined,
title: 'B',
},
]);
@@ -304,7 +304,8 @@ describe('AgentDocumentsService', () => {
const service = new AgentDocumentsService(db, userId);
const result = await service.listDocuments('agent-1');
expect(mockModel.findByAgent).toHaveBeenCalledWith('agent-1');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1');
expect(mockModel.findByAgent).not.toHaveBeenCalled();
expect(result).toEqual([
{
documentId: 'documents-1',
@@ -322,6 +323,16 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the model', async () => {
mockModel.listByAgent.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocuments('agent-1', 'web');
expect(mockModel.listByAgent).toHaveBeenCalledWith('agent-1', { sourceType: 'web' });
expect(mockModel.findByAgent).not.toHaveBeenCalled();
});
});
describe('listDocumentsForTopic', () => {
@@ -330,19 +341,19 @@ describe('AgentDocumentsService', () => {
{ id: 'documents-2', title: 'B' },
{ id: 'documents-1', title: 'A' },
]);
mockModel.findByDocumentIds.mockResolvedValue([
mockModel.listByDocumentIds.mockResolvedValue([
{
documentId: 'documents-1',
filename: 'a.md',
id: 'agent-doc-1',
policy: null,
loadPosition: undefined,
title: 'A',
},
{
documentId: 'documents-2',
filename: 'b.md',
id: 'agent-doc-2',
policy: null,
loadPosition: undefined,
title: 'B',
},
]);
@@ -351,10 +362,11 @@ describe('AgentDocumentsService', () => {
const result = await service.listDocumentsForTopic('agent-1', 'topic-1');
expect(mockTopicDocumentModel.findByTopicId).toHaveBeenCalledWith('topic-1');
expect(mockModel.findByDocumentIds).toHaveBeenCalledWith('agent-1', [
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', [
'documents-2',
'documents-1',
]);
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
expect(result).toEqual([
{
documentId: 'documents-2',
@@ -372,6 +384,19 @@ describe('AgentDocumentsService', () => {
},
]);
});
it('should pass sourceType filtering to the topic document summary query', async () => {
mockTopicDocumentModel.findByTopicId.mockResolvedValue([{ id: 'documents-1' }]);
mockModel.listByDocumentIds.mockResolvedValue([]);
const service = new AgentDocumentsService(db, userId);
await service.listDocumentsForTopic('agent-1', 'topic-1', 'web');
expect(mockModel.listByDocumentIds).toHaveBeenCalledWith('agent-1', ['documents-1'], {
sourceType: 'web',
});
expect(mockModel.findByDocumentIds).not.toHaveBeenCalled();
});
});
describe('getDocumentByFilename', () => {
@@ -13,6 +13,8 @@ import type {
AgentDocument,
AgentDocumentContextPayload,
AgentDocumentContextRow,
AgentDocumentListItem,
AgentDocumentListSourceType,
AgentDocumentWithRules,
ToolUpdateLoadRule,
} from '@/database/models/agentDocuments';
@@ -611,54 +613,27 @@ export class AgentDocumentsService {
}
}
async listDocuments(agentId: string, sourceType?: 'all' | 'file' | 'web') {
const docs = await this.agentDocumentModel.findByAgent(agentId);
const filtered =
sourceType && sourceType !== 'all' ? docs.filter((d) => d.sourceType === sourceType) : docs;
return filtered.map((d) => ({
...deriveAgentDocumentFields(d),
description: d.description,
documentId: d.documentId,
fileType: d.fileType,
filename: d.filename,
id: d.id,
loadPosition: d.policy?.context?.position,
parentId: d.parentId,
sourceType: d.sourceType,
templateId: d.templateId,
title: d.title,
updatedAt: d.updatedAt,
}));
async listDocuments(agentId: string, sourceType?: AgentDocumentListSourceType) {
if (!sourceType) return this.agentDocumentModel.listByAgent(agentId);
return this.agentDocumentModel.listByAgent(agentId, { sourceType });
}
async listDocumentsForTopic(
agentId: string,
topicId: string,
sourceType?: 'all' | 'file' | 'web',
sourceType?: AgentDocumentListSourceType,
) {
const topicDocs = await this.topicDocumentModel.findByTopicId(topicId);
const documentIds = topicDocs.map((doc) => doc.id);
const docs = await this.agentDocumentModel.findByDocumentIds(agentId, documentIds);
const docs = sourceType
? await this.agentDocumentModel.listByDocumentIds(agentId, documentIds, { sourceType })
: await this.agentDocumentModel.listByDocumentIds(agentId, documentIds);
const docsByDocumentId = new Map(docs.map((doc) => [doc.documentId, doc]));
return topicDocs
.map((topicDoc) => docsByDocumentId.get(topicDoc.id))
.filter((doc): doc is AgentDocumentWithRules => Boolean(doc))
.filter((doc) => !sourceType || sourceType === 'all' || doc.sourceType === sourceType)
.map((doc) => ({
...deriveAgentDocumentFields(doc),
description: doc.description,
documentId: doc.documentId,
fileType: doc.fileType,
filename: doc.filename,
id: doc.id,
loadPosition: doc.policy?.context?.position,
parentId: doc.parentId,
sourceType: doc.sourceType,
templateId: doc.templateId,
title: doc.title,
updatedAt: doc.updatedAt,
}));
.filter((doc): doc is AgentDocumentListItem => Boolean(doc));
}
async getDocumentByFilename(agentId: string, filename: string) {
@@ -17,6 +17,9 @@ import {
} from './types';
vi.mock('@lobechat/model-runtime', () => ({
// RuntimeExecutors (loaded transitively) resolves extend params via this
// helper; an empty result keeps the runtime payload unchanged.
applyModelExtendParams: vi.fn(() => ({})),
getModelPropertyWithFallback: vi.fn(),
// `llmErrorClassification.ts` reads these at module-load time; an empty
// spec map is fine here because this suite never exercises the runtime
@@ -104,7 +104,7 @@ const ASYNC_TOOL_VERIFY_DELAY_MS = 15_000;
* shot) so a transient miss a read-replica lag, a sibling dying between
* backfill and resume is retried rather than leaving the parent stuck in
* `waiting_for_async_tool` forever. With exponential backoff from a 15s base,
* 5 attempts span ~15s ~7.75min total before giving up. See LOBE-10385.
* 5 attempts span ~15s ~7.75min total before giving up. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff instead of single-shot verification.
*/
const ASYNC_TOOL_VERIFY_MAX_ATTEMPTS = 5;
@@ -645,7 +645,7 @@ export class AgentRuntimeService {
// CAS guarantees at most one real resume regardless of how many checks run.
// Opt back into `scheduleVerifyOnHold` with the next attempt so an
// unsatisfied barrier re-arms (bounded backoff) instead of giving up after
// a single shot — the core LOBE-10385 fix.
// a single shot — bounded watchdog retry ensures transient misses are recovered.
if (verifyAsyncToolBarrier) {
const attempt = asyncToolVerifyAttempt ?? 1;
log(
@@ -1832,7 +1832,7 @@ export class AgentRuntimeService {
* miss (read-replica lag, a sibling dying between backfill and resume) is thus
* retried instead of permanently stranding the parent. Once attempts are
* exhausted the chain stops and the `verify_exhausted` metric fires so the
* orphan is observable. See LOBE-10385.
* orphan is observable. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff.
*/
private async maybeScheduleAsyncToolVerify(
parentOperationId: string,
@@ -2713,6 +2713,13 @@ export class AiAgentService {
// context (state.metadata.agentId) targets the reviewed agent; ordinary
// runs (no marker) fall back to the resolved executing agent.
agentId: appContext?.agentSignal?.agentId ?? resolvedAgentId,
// When scope === 'agent_builder', agentId stays as the builder builtin so
// message ownership and queryUiMessages remain correct. editingAgentId
// carries the actual editing target separately; only the AgentBuilder server
// runtime reads it, keeping the rest of the pipeline unaffected.
...(appContext?.scope === 'agent_builder' && appContext?.editingAgentId
? { editingAgentId: appContext.editingAgentId }
: {}),
// Run-scoped Agent Signal marker for background self-iteration / memory
// runs — lands in state.metadata.agentSignal so the completion path can
// project receipts/briefs. Undefined for ordinary chat runs.
@@ -36,18 +36,6 @@ describe('resolveDeviceWorkingDirectory', () => {
).toBe('/per-device');
});
it('uses the active worktree from the per-device source entry as the effective cwd', () => {
expect(
resolveDeviceWorkingDirectory({
deviceDefaultCwd: '/default',
deviceId: 'device-1',
workingDirByDevice: {
'device-1': { git: { activeWorktree: '/repo-fix' }, path: '/repo', repoType: 'git' },
},
}),
).toBe('/repo-fix');
});
it('only matches the per-device pick for the dispatched device', () => {
expect(
resolveDeviceWorkingDirectory({
@@ -1,6 +1,3 @@
import type { WorkingDirConfigValue } from '@lobechat/types';
import { getWorkingDirEffectivePath } from '@lobechat/types';
/**
* Resolve the working directory for a device-bound run.
*
@@ -25,12 +22,10 @@ export const resolveDeviceWorkingDirectory = (params: {
deviceId?: string;
initialWorkingDirectory?: string;
topicWorkingDirectory?: string;
workingDirByDevice?: Record<string, WorkingDirConfigValue> | null;
workingDirByDevice?: Record<string, string> | null;
}): string | undefined =>
params.topicWorkingDirectory ||
params.initialWorkingDirectory ||
getWorkingDirEffectivePath(
params.deviceId ? params.workingDirByDevice?.[params.deviceId] : undefined,
) ||
(params.deviceId ? params.workingDirByDevice?.[params.deviceId] : undefined) ||
params.deviceDefaultCwd ||
undefined;
@@ -74,6 +74,112 @@ describe('agentManagementRuntime', () => {
expect(PluginModel).toHaveBeenCalledWith(expect.anything(), 'user-1', 'workspace-1');
});
describe('callAgent', () => {
it('fails when the server sub-agent runner is unavailable', async () => {
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
runAsTask: true,
},
{ toolManifestMap: {} },
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({ code: 'AGENT_CALL_UNAVAILABLE' });
});
it('returns a deferred tool result and forks the target agent through the sub-agent runner', async () => {
const run = vi.fn().mockResolvedValue({
started: true,
subOperationId: 'op-child',
threadId: 'thread-child',
});
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
runAsTask: true,
taskTitle: 'Delegated task',
timeout: 1234,
},
{
subAgent: { run },
toolManifestMap: {},
},
);
expect(run).toHaveBeenCalledWith({
agentId: 'agent-target',
description: 'Delegated task',
instruction: 'Do delegated work',
timeout: 1234,
});
expect(result).toMatchObject({
content: '',
deferred: true,
success: true,
});
expect(result.state).toMatchObject({
status: 'pending',
subOperationId: 'op-child',
targetAgentId: 'agent-target',
threadId: 'thread-child',
});
});
it('returns a non-deferred failure when the target agent cannot start', async () => {
const run = vi.fn().mockResolvedValue({
started: false,
threadId: '',
});
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
runAsTask: true,
},
{
subAgent: { run },
toolManifestMap: {},
},
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({
code: 'AGENT_CALL_START_FAILED',
});
expect(result.deferred).toBeUndefined();
});
it('rejects nested server callAgent execution', async () => {
const run = vi.fn();
const runtime = createRuntime();
const result = await runtime.callAgent(
{
agentId: 'agent-target',
instruction: 'Do delegated work',
},
{
isSubAgent: true,
subAgent: { run },
toolManifestMap: {},
},
);
expect(result.success).toBe(false);
expect(result.error).toMatchObject({ code: 'NESTED_AGENT_CALL_NOT_ALLOWED' });
expect(run).not.toHaveBeenCalled();
});
});
describe('searchAgent', () => {
it('reports the real total and a pagination hint when more agents exist', async () => {
mockQueryAgents.mockResolvedValue(makeAgents(20));
@@ -152,7 +152,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
params: UpdateAgentConfigParams,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> => {
const agentId = ctx.agentId;
const agentId = ctx.editingAgentId ?? ctx.agentId;
if (!agentId) {
return {
@@ -240,7 +240,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
params: UpdatePromptParams,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> => {
const agentId = ctx.agentId;
const agentId = ctx.editingAgentId ?? ctx.agentId;
if (!agentId) {
return {
@@ -272,7 +272,7 @@ export const agentBuilderRuntime: ServerRuntimeRegistration = {
params: InstallPluginParams,
ctx: ToolExecutionContext,
): Promise<ToolExecutionResult> => {
const agentId = ctx.agentId;
const agentId = ctx.editingAgentId ?? ctx.agentId;
if (!agentId) {
return {
@@ -43,20 +43,60 @@ export const agentManagementRuntime: ServerRuntimeRegistration = {
): Promise<ToolExecutionResult> => {
const { agentId, instruction, taskTitle, timeout } = params;
// Server runtime always uses the legacy async invocation path because
// there is no client-side `registerAfterCompletion` callback available
// to execute synchronous agent calls.
return {
content: `🚀 Triggered async task to call agent "${agentId}"${taskTitle ? `: ${taskTitle}` : ''}`,
state: {
parentMessageId: ctx.messageId,
task: {
description: taskTitle || `Call agent ${agentId}`,
instruction,
targetAgentId: agentId,
timeout: timeout || 1_800_000,
if (ctx.isSubAgent) {
return {
content: 'Agent calls cannot be triggered from within another sub-agent.',
error: {
code: 'NESTED_AGENT_CALL_NOT_ALLOWED',
message: 'Agent calls cannot be triggered from within another sub-agent.',
},
type: 'execSubAgent',
success: false,
};
}
if (!ctx.subAgent) {
return {
content: 'Agent execution is not available in this runtime.',
error: { code: 'AGENT_CALL_UNAVAILABLE' },
success: false,
};
}
if (!instruction || typeof instruction !== 'string') {
return {
content: 'instruction is required.',
error: { code: 'INVALID_ARGUMENTS', message: 'instruction is required.' },
success: false,
};
}
const description = taskTitle || `Call agent ${agentId}`;
const { started, subOperationId, threadId } = await ctx.subAgent.run({
agentId,
description,
instruction,
timeout: timeout || 1_800_000,
});
if (!started) {
return {
content: `Agent "${agentId}" failed to start.`,
error: {
code: 'AGENT_CALL_START_FAILED',
message: `Agent "${agentId}" failed to start.`,
},
success: false,
};
}
return {
content: '',
deferred: true,
state: {
status: 'pending',
subOperationId,
targetAgentId: agentId,
threadId,
},
success: true,
};
@@ -121,6 +121,12 @@ export interface ToolExecutionContext {
agentMember?: ServerAgentMemberRunner;
/** Current page document ID for page-scoped conversations */
documentId?: string | null;
/**
* When scope is 'agent_builder', the ID of the agent being edited. Kept
* separate from agentId so message ownership and queryUiMessages remain
* bound to the builder builtin; only AgentBuilder tool methods read this.
*/
editingAgentId?: string;
/**
* Legacy agent invocation callback forwarded from RuntimeExecutorContext.
* Kept for tool runtimes that still dispatch through exec_sub_agent style
+2 -1
View File
@@ -474,5 +474,6 @@
"https://github.com/user-attachments/assets/fd60ab55-ead2-4930-ad00-fdf77662f5a0": "/blog/assets276a4e8748e9bd300b30dcd9d0e24980.webp",
"https://file.rene.wang/task.png": "/blog/assets4aa1732a45832afc780600e6e329860c.webp",
"https://file.rene.wang/Platform Agent.png": "/blog/assets10cadd434aeb36bd1beb3c7b3d371fbd.webp",
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp"
"https://file.rene.wang/clipboard-1780888016983-b47fcdab831b1.png": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
"https://file.rene.wang/device.png": "/blog/assets6aebb8b5341dc37202ffecb363b9b906.webp"
}
+8 -10
View File
@@ -11,18 +11,16 @@ tags:
# Image & Video Generation Redesign
This update refines the visual creation flow, so moving between image and video work feels quicker and more natural.
## Features
## Whats New
- **Redesigned image and video generation.** Switch between image and video creation directly, instead of moving between separate flows.
- **Clear all memory in one action.** When you need a clean context, clear every memory entry at once.
The generation interface has been redesigned for faster switching between image and video creation. Instead of jumping between separate flows, you can now move between both media modes more directly.
## Improvements
Memory reset is also simpler now. When you need a clean context, you can clear all memory entries in one action.
- Better mobile menu behavior.
Under the hood, the agent architecture was refactored to improve reliability and leave more room for future extensions.
## Fixes
## Experience Improvements
- Fixed visual glitches in compression view.
- Improved mobile menu behavior.
- Corrected message count display accuracy.
- Fixed visual glitches in the compression view.
- Corrected message count accuracy.
@@ -9,18 +9,16 @@ tags:
# 图片与视频生成重设计
这次更新聚焦于优化视觉创作流程,让图片与视频生成之间的切换更自然、操作更高效。
## 新功能
## 更新内容
这次首先重做了生成流程。图片与视频创作不再像两条割裂的路径,新界面让你在两种模式之间切换更直接。
记忆管理也变得更干净利落。需要重置上下文时,你现在可以一键清空全部记忆条目。
此外,Agent 内部架构也做了重构,重点是提升稳定性,并为后续能力扩展留出空间。
- **重新设计的图片与视频生成。** 图片与视频创作不再是两条割裂的路径,可以更直接地在两种模式之间切换。
- **一键清空全部记忆。** 需要重置上下文时,可一次性清空所有记忆条目。
## 体验优化
- 修复压缩视图显示异常
- 优化移动端菜单交互。
- 修正消息计数显示准确性。
- 移动端菜单交互更顺畅
## 问题修复
- 修复压缩视图的显示异常。
- 修正消息计数的准确性。
+12 -7
View File
@@ -12,15 +12,20 @@ tags:
# Agent Tasks and Agent Management
This update improves how teams adopt and run Agents day to day, especially when coordinating bots across multiple workflows.
## Features
LobeHub now includes in-app notifications, so important updates and alerts appear directly in the product. Agent management is also more flexible, with stronger rendering support and richer content handling in bot experiences.
- **In-app notifications.** Important updates and alerts now appear inside the product.
- **Guided onboarding.** A new onboarding path helps teams get started faster.
- **Slash command icons.** Skill-specific icons make slash commands easier to find.
Getting started is smoother as well. A new guided onboarding path helps teams ramp up faster, and slash command discoverability improves with skill-specific icons. GitHub Copilot compatibility is also improved, including better vision-related behavior.
## Improvements
## Experience Improvements
- Agent management is more flexible, with richer rendering and content handling in bots.
- Better GitHub Copilot compatibility, including vision.
- The Marketplace entry moved below Resources in the sidebar for a cleaner layout.
- A visual cue now shows when AI generation is interrupted.
- A more user-friendly fallback state when errors occur.
## Fixes
- Moved the Marketplace entry below Resources in the sidebar for a cleaner layout.
- Added a visual cue when AI generation is interrupted.
- Fixed display issues when switching between topics.
- Improved error handling with a more user-friendly fallback state.
@@ -10,15 +10,20 @@ tags:
# 智能体任务系统与 Agent 管理
这次更新重点优化了 Agent 的上手和日常运营体验,尤其适合在多场景协作中使用 Bot 的团队。
## 新功能
LobeHub 现在支持应用内通知重要更新和提醒可以直接在产品内收到。Agent 管理能力也进一步增强,在内容渲染和展示上更灵活,能覆盖更丰富的 Bot 使用场景
上手流程也更顺了。新版引导可以帮助团队更快进入 Agent 工作流,斜杠菜单加入技能专属图标后,常用命令更易定位。与此同时,GitHub Copilot 兼容性也进一步提升,包含视觉相关能力的改进。
- **应用内通知。** 重要更新和提醒现在会直接在产品内出现
- **引导式上手。** 新版引导帮助团队更快进入工作流。
- **斜杠命令图标。** 技能专属图标让斜杠命令更易定位。
## 体验优化
- 将市场入口移至侧边栏「资源」下方,整体布局更清晰
- 在 AI 生成被中断时增加可视化提示
- 修复话题切换时的显示异常
- 改进错误处理,提供更友好的降级界面
- Agent 管理更灵活,在 Bot 场景中的渲染与内容处理更丰富
- GitHub Copilot 兼容性提升,包含视觉能力
- 市场入口移至侧边栏「资源」下方,布局更清晰
- AI 生成被中断时会显示可视化提示
- 出错时提供更友好的降级界面。
## 问题修复
- 修复了话题切换时的显示异常。
+15 -11
View File
@@ -1,8 +1,8 @@
---
title: AI Auto-Completion & Real-Time Gateway
description: >-
Added AI-powered input auto-completion, WebSocket-based real-time messaging
gateway, expanded bot platform support, and improved context injection.
Added AI-powered input auto-completion, a real-time messaging gateway,
expanded bot platform support, and improved context injection.
tags:
- Auto-Completion
- WebSocket Gateway
@@ -12,17 +12,21 @@ tags:
# AI Auto-Completion & Real-Time Gateway
This release focuses on removing small points of friction in writing and real-time collaboration.
## Features
The editor now supports AI auto-completion while you type, so drafting messages is faster and requires less context switching. On the delivery side, the new WebSocket-based Agent Gateway streams responses with lower latency, making live conversations feel more immediate.
- **AI auto-completion.** The editor suggests completions as you type, so drafting messages is faster.
- **Real-time Agent Gateway.** The new Agent Gateway streams responses with lower latency.
- **More bot platforms.** Feishu/Lark, Slack, and QQ now support a more reliable connection mode.
- **Skills and tools via `@`.** Trigger skills and tools with `@` mentions and direct context injection, replacing the old slash-command flow.
- **Dedicated Skills tab.** The Skill Store now has its own Skills tab.
- **Auto-created topics.** New topics are created every four hours to keep conversations organized.
Cross-platform bot connectivity is also broader. Feishu/Lark, Slack, and QQ now support WebSocket connection mode for more reliable message delivery. Context invocation is also simpler: skills and tools can be triggered with `@` mentions and direct context injection, replacing the older slash-command-heavy flow.
## Improvements
To keep navigation cleaner over time, the Skill Store now has a dedicated Skills tab, and topics are automatically created every four hours to keep conversations organized.
- Agent documents load progressively, showing content as it becomes available instead of blocking the page.
- Pasting large clipboard content no longer freezes the chat input.
## Experience Improvements
## Fixes
- Agent documents now load progressively, showing content as it becomes available instead of blocking the full page
- Fixed the image generation button incorrectly defaulting to a wrong model
- Improved paste performance by preventing the chat input from freezing on large clipboard content
- Strengthened security by sanitizing HTML artifacts and removing an auth bypass vector
- The image generation button no longer defaults to the wrong model.
- Closed an authentication bypass and sanitized HTML artifacts.
@@ -1,6 +1,6 @@
---
title: AI 自动补全与实时消息网关
description: 新增 AI 输入自动补全、基于 WebSocket 的实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
description: 新增 AI 输入自动补全、实时消息网关、扩展 Bot 平台支持,以及改进的上下文注入机制。
tags:
- 自动补全
- WebSocket 网关
@@ -10,17 +10,21 @@ tags:
# AI 自动补全与实时消息网关
这版更新聚焦在减少写作和实时协作中的细碎阻力,让整体体验更连贯。
## 新功能
编辑器现在支持 AI 自动补全,你在输入时就能收到建议,消息撰写更快、上下文切换更少。消息链路方面,全新的 WebSocket Agent 网关支持实时推送响应,整体对话延迟更低
Bot 连接能力也扩展到了更多平台。飞书、Slack 和 QQ 已支持 WebSocket 连接模式,消息传递更稳定。与此同时,上下文调用也更直接:通过 `@` 提及即可触发技能和工具,并完成直接上下文注入,逐步替代以斜杠命令为主的旧方式。
为了让长期使用时的导航更清晰,技能商店新增专属「技能」标签页,系统也会每 4 小时自动创建新话题,帮助你持续整理会话上下文
- **AI 自动补全。** 编辑器会在你输入时给出补全建议,撰写消息更快
- **实时 Agent 网关。** 全新的 Agent 网关以更低延迟流式返回响应。
- **更多 Bot 平台。** 飞书、Slack 和 QQ 已支持更稳定的连接模式。
- **通过 `@` 调用技能与工具。** 用 `@` 提及即可触发技能和工具并直接注入上下文,取代以斜杠命令为主的旧方式。
- **独立的「技能」标签页。** 技能商店新增专属「技能」标签页。
- **自动创建话题。** 系统每 4 小时自动创建新话题,帮助整理会话。
## 体验优化
- 助理文档现在支持渐进式加载,内容就绪时即时展示,不再阻塞整个页面
- 修复了图片生成按钮错误默认选择模型的问题
- 优化了粘贴性能,防止在粘贴大量剪贴板内容时聊天输入框卡顿
- 加强了安全性,清理了 HTML 工件并修复了一个认证绕过漏洞
- 助理文档支持渐进式加载,内容就绪展示,不再阻塞整个页面
- 粘贴大量剪贴板内容时,聊天输入框不再卡顿。
## 问题修复
- 图片生成按钮不再默认选到错误的模型。
- 修复了一个认证绕过漏洞,并清理了 HTML 工件。
+17 -16
View File
@@ -1,8 +1,8 @@
---
title: Agent Gateway & Customizable Sidebar
description: >-
Server-side agent execution via Gateway mode, customizable sidebar layout,
agent workspace with document management, and new model support.
Run agents through Gateway mode, customize the sidebar layout, manage
documents in the agent workspace, and use new models.
tags:
- Gateway
- Sidebar
@@ -12,22 +12,23 @@ tags:
# Agent Gateway & Customizable Sidebar
This release focuses on making everyday Agent work more stable and easier to manage.
## Features
Agents can now run on the server through Gateway mode and stream results over WebSocket. When you switch topics or hit a short disconnect, sessions reconnect and resume more smoothly, so long-running execution is less likely to break.
- **Agent Gateway mode.** Agents can run on the server and stream results back. When you switch topics or hit a brief disconnect, sessions reconnect and resume, so long runs are less likely to break.
- **Customizable sidebar.** Choose which items appear in the sidebar and reorder them in a dedicated modal. Recents now has search, rename, and quick actions.
- **Agent workspace.** A right-side workspace to browse, rename, delete, and review history for agent documents. Running tasks move into a separate task manager with their own topic state, so your main conversations stay focused.
- **In-chat prompt editing.** Rewrite or translate a prompt in the chat input before sending.
- **Screen capture.** Grab screen content with an overlay picker and attach it in one step.
- **CLI on desktop.** Install the LobeHub CLI to your system `PATH` with one click.
- **New models.** Zhipu GLM-5.1, Seedance 2.0 video generation, and the StreamLake provider.
Navigation is now easier to shape around your own habits. You can choose which items appear in the sidebar, reorder them in a dedicated customization modal, and use a stronger Recents experience with search, rename, and quick actions.
## Improvements
Document and task workflows are now more centralized. A dedicated right-side workspace gives you one place to browse, rename, delete, and review history for Agent documents. Running tasks move into an isolated task manager view with independent topic state, so your main conversations stay focused.
- Remote requests from the desktop app are more reliable.
- Loading states reduce flicker while the assistant is thinking.
- Clearer error messages for insufficient balance and deactivated accounts.
This update also improves several high-frequency input and tooling actions. You can rewrite or translate prompts directly in chat input before sending, capture screen content with an overlay picker and attach it in one step, and use LobeHub CLI from desktop with one-click install to your system `PATH`.
## Fixes
On model coverage, this release adds Zhipu GLM-5.1, Seedance 2.0 video generation, and the StreamLake provider.
## Improvements and fixes
- Desktop now uses Electron native `fetch` for more reliable remote requests.
- Optimistic loading states reduce streaming flicker while the assistant is thinking.
- Agent detail pages now load correctly after refresh instead of staying in a spinner state.
- Error classification now gives clearer messages for insufficient balance and deactivated accounts.
- Fixed a context engine crash path caused by non-string content in document injection.
- Agent detail pages load correctly after a refresh instead of getting stuck on a spinner.
- Fixed a crash when injecting certain document content into context.
@@ -1,6 +1,6 @@
---
title: Agent 网关与可自定义侧边栏
description: 通过网关模式实现服务端智能体执行、可自定义侧边栏布局、带文档管理的智能体工作区,以及新模型支持。
description: 通过网关模式运行服务端智能体、可自定义侧边栏布局、带文档管理的智能体工作区,以及新模型支持。
tags:
- 网关
- 侧边栏
@@ -10,22 +10,23 @@ tags:
# Agent 网关与可自定义侧边栏
这次更新聚焦在两件事:让日常 Agent 协作更稳定,也让操作路径更集中。
## 新功能
Agent 现在可以通过网关模式在服务端运行,并通过 WebSocket 流式返回结果。切换话题或短暂断线后,会更顺畅地自动重连并恢复会话,长流程执行不再那么容易中断。
- **Agent 网关模式。** 智能体可以在服务端运行并流式返回结果。切换话题或短暂断线后,会话会自动重连并恢复,长流程执行不再那么容易中断。
- **可自定义侧边栏。** 你可以在专用弹窗里选择侧边栏的显示项并调整顺序;「最近」也补齐了搜索、重命名和快捷操作。
- **智能体工作区。** 右侧工作区可在一处完成智能体文档的浏览、重命名、删除和历史查看。运行中的任务进入独立的任务管理器,使用独立的话题状态,不再打断主对话。
- **聊天内改写提示词。** 提示词可在发送前直接在输入框中改写或翻译。
- **屏幕截图。** 通过覆盖层选择屏幕内容,并一步附加到对话。
- **桌面端命令行。** 一键把 LobeHub CLI 安装到系统 `PATH`。
- **新模型。** 智谱 GLM-5.1、Seedance 2.0 视频生成,以及 StreamLake 提供商。
导航也更贴合个人习惯。你可以在专用弹窗里选择侧边栏显示项并调整顺序;「最近」板块也补齐了搜索、重命名和快捷操作,日常切换会更快。
## 体验优化
文档与任务这类高频操作也更集中。新增的右侧工作区可以在一处完成 Agent 文档的浏览、重命名、删除和历史查看。运行中的任务则进入独立任务视图,并使用独立话题状态,不再打断主对话
- 桌面端发起的远程请求更稳定
- 加载状态减少了助手思考阶段的界面闪烁。
- 余额不足与账户停用场景的错误提示更清晰。
另外,这版也优化了几项高频输入与工具操作。提示词可在发送前直接改写或翻译;屏幕截图支持覆盖层选择并一步附加到对话;LobeHub CLI 已内嵌到桌面应用,并可在设置中一键安装到系统 `PATH`。
## 问题修复
模型覆盖方面,本次新增智谱 GLM-5.1、Seedance 2.0 视频生成能力,以及 StreamLake 提供商
## 体验优化与修复
- 桌面端现使用 Electron 原生 `fetch` 发起远程请求,连接更稳定。
- 乐观更新的加载状态减少了助手思考阶段的界面闪烁。
- Agent 详情页刷新后可正常加载,不再长期停留在加载动画。
- 余额不足与账户停用场景的错误分类更准确,提示信息更清晰。
- 修复了非字符串内容进入文档注入链路时触发的上下文引擎崩溃。
- 智能体详情页刷新后可正常加载,不再卡在加载动画
- 修复了注入某些文档内容到上下文时触发的崩溃。
+20 -18
View File
@@ -1,8 +1,8 @@
---
title: 'Daily Brief, Document History & Approval Flow'
description: >-
A new Daily Brief on the homepage, Notion-style document history, server-side
human approval for agent actions, and Discord DM support.
A new Daily Brief on the homepage, Notion-style document history, human
approval for agent actions, and Discord DM support.
tags:
- Daily Brief
- Document History
@@ -12,23 +12,25 @@ tags:
# Daily Brief, Document History & Approval Flow
A personalized Daily Brief on the homepage, Notion-style document history, server-side human approval for agent actions, and expanded Discord support.
## Features
## Key Updates
- **Daily Brief.** A homepage module that surfaces a personalized daily summary across your agents.
- **Page history.** A Notion-style timeline for browsing previous versions of agent documents, with per-source retention limits.
- **Human approval.** Agents can pause and ask for your approval before running sensitive actions, with one approve/reject UI and headless support in the CLI.
- **Agent working panel.** A collapsible panel for managing agent documents and in-progress work next to the conversation.
- **Discord slash commands and DMs.** Discord bots now support slash commands and direct messages.
- **Client tools in Gateway mode.** Desktop can run client-side tools while the agent runs in Gateway mode.
- **Pinnable sidebar items.** Lock important items so they stay put while you customize the sidebar.
- **Task filters.** Filter tasks by multiple values with pagination, and see participant avatars on task cards.
- Daily Brief: a new module on the homepage surfaces a personalized daily summary across your agents
- Page history: a Notion-style timeline lets you browse previous versions of agent documents, with per-source retention limits
- Human approval flow: agents can now pause and request your approval before running sensitive actions, with a unified approve/reject UI and support for headless mode in the CLI
- Agent working panel: a new collapsible panel for managing agent documents and in-progress work alongside the conversation
- Discord upgrades: Discord bots now support slash commands and direct messages for smoother team workflows
- Client tools in Gateway mode: desktop / Electron can now execute client-side tools while the agent runs in Gateway mode
- Pinnable sidebar items: lock important items in the sidebar so they stay in place during customization
- Task filters: filter tasks by multiple values with pagination, and see participant avatars directly on task cards
## Improvements
## Experience Improvements
- Multi-step command runs are split into separate assistant messages, so each step is easier to follow.
- The settings save bar stays pinned to the bottom with an instant toggle.
- Nav panel transitions feel snappier after removing the content-switch animation.
- Restored the knowledge-base file listing and detail tools.
- Multi-step command executions are now split into separate assistant messages, making each step easier to follow
- Settings SaveBar stays pinned to the bottom with an instant toggle, so changes are one click away
- Nav panel transitions feel snappier after removing the content-switch animation
- Restored the lobe-kb file listing and detail tools, and fixed a case where the recent-items delete action wasn't taking effect
- Gateway-mode stop button, approve/reject routing, and paused-operation cleanup are all more reliable
## Fixes
- The recent-items delete action now takes effect.
- The Gateway-mode stop button, approve/reject routing, and paused-operation cleanup are more reliable.
+19 -17
View File
@@ -1,6 +1,6 @@
---
title: 每日简报、文档历史与审批流程
description: 首页新增每日简报模块、Notion 风格的文档历史、智能体操作的服务端人工审批流程,以及 Discord 私信支持。
description: 首页新增每日简报模块、Notion 风格的文档历史、智能体操作的人工审批流程,以及 Discord 私信支持。
tags:
- 每日简报
- 文档历史
@@ -10,23 +10,25 @@ tags:
# 每日简报、文档历史与审批流程
首页新增个性化每日简报、Notion 风格的文档历史、智能体操作的服务端人工审批流程,以及更完善的 Discord 支持。
## 新功能
## 重要更新
- 每日简报:首页新增模块,呈现跨智能体的个性化每日摘要
- 文档历史:Notion 风格的时间线让你浏览智能体文档的历史版本,并支持按来源设置保留上限
- 人工审批流程:智能体现可在执行敏感操作前暂停并请求你的审批,统一的同意 / 拒绝交互,同时支持 CLI 的无头审批模式
- 智能体工作面板:新增可折叠面板,在对话旁管理智能体文档与进行中的工作
- Discord 升级:Discord 机器人现已支持斜杠命令和私信,团队协作更顺畅
- 网关模式下的客户端工具:桌面 / Electron 可在智能体运行于网关模式时执行客户端工具
- 可固定侧边栏项目:在自定义时锁定重要项目,保持位置不变
- 任务筛选:支持多值筛选与分页,任务卡片上直接显示参与者头像
- **每日简报。** 首页新增模块,呈现跨智能体的个性化每日摘要。
- **文档历史。** Notion 风格的时间线,可浏览智能体文档的历史版本,并支持按来源设置保留上限。
- **人工审批。** 智能体可在执行敏感操作前暂停并请求你的审批,提供统一的同意 / 拒绝界面,CLI 也支持无头审批。
- **智能体工作面板。** 新增可折叠面板,在对话旁管理智能体文档与进行中的工作。
- **Discord 斜杠命令与私信。** Discord 机器人现已支持斜杠命令和私信。
- **网关模式下的客户端工具。** 智能体运行于网关模式时,桌面端可执行客户端工具。
- **可固定侧边栏项目。** 自定义侧边栏时锁定重要项目,保持位置不变。
- **任务筛选。** 支持多值筛选与分页,任务卡片上直接显示参与者头像。
## 体验优化
- 多步命令执行现在会拆分为独立的助手消息,每一步更易跟踪
- 设置中的 SaveBar 固定在底部并支持即时切换,修改一键可达
- 移除导航面板内容切换动画后,切换更流畅
- 恢复了 lobe-kb 文件列表详情工具,修复了最近项目删除操作未生效的问题
- 网关模式的停止按钮、审批 / 拒绝路由,以及暂停操作的清理更加稳定可靠
- 多步命令执行会拆分为独立的助手消息,每一步更易跟踪
- 设置中的保存栏固定在底部并支持即时切换
- 移除导航面板内容切换动画后,切换更流畅
- 恢复了知识库的文件列表详情工具
## 问题修复
- 最近项目的删除操作现在会生效。
- 网关模式的停止按钮、同意 / 拒绝路由,以及暂停操作的清理更加稳定。
@@ -1,5 +1,5 @@
---
title: Coding Agent Claude Code & Codex on Desktop
title: 'Coding Agent: Claude Code & Codex on Desktop'
description: >-
Claude Code and Codex graduate to first-class desktop runtimes, alongside a
new Agent Signal runtime and a wave of flagship models.
@@ -13,17 +13,20 @@ tags:
## Features
- Topic remembers its own scroll position
- User message stays pinned to the viewport top with long messages folded, the last user message can be edited and resent inline, and follow-up sends queue cleanly during a concurrent turn.
- Delegating 3rd party coding agents such as Claude Code and Codex
- Quick chat and capture your screen and ask LobeHun with desktop app
- New models: GPT-5.5, DeepSeek V4 Flash and Pro with a reasoning-effort slider, LobeHub-hosted gpt-image-2, Kimi K2.6, MiMo-V2.5 and Pro
- New providers: OpenCode Zen and OpenCode Go.
- **Delegate Claude Code and Codex.** Run third-party coding agents like Claude Code and Codex from the desktop app.
- **Quick Chat on desktop.** Capture your screen and ask LobeHub about it.
- **New models.** GPT-5.5, DeepSeek V4 Flash and Pro with a reasoning-effort slider, LobeHub-hosted gpt-image-2, Kimi K2.6, and MiMo-V2.5 and Pro.
- **New providers.** OpenCode Zen and OpenCode Go.
## Improvements and fixes
## Improvements
- Disabled markdown streaming on the first assistant block to avoid mid-stream layout shifts.
- Conversation no longer repins to the bottom after a manual scroll.
- Tool inspectors render correctly for Codex and heterogeneous-agent follow-ups.
- FileEditor migrated from antd Modal to base-ui Modal for consistent focus and keyboard behavior.
- QStash heartbeat self-reschedules to keep long-running tasks alive.
- Topics remember their scroll position.
- Long user messages fold and stay pinned to the top of the view, the last user message can be edited and resent inline, and follow-up sends queue cleanly during a running turn.
- The file editor has more consistent focus and keyboard behavior.
## Fixes
- The first assistant block no longer shifts layout while streaming.
- The conversation no longer jumps back to the bottom after you scroll up.
- Tool inspectors render correctly for Codex and follow-up turns.
- Long-running tasks stay alive instead of stalling.
@@ -1,5 +1,5 @@
---
title: 编程 Agent —— Claude Code 与 Codex 进入桌面端
title: 编程 AgentClaude Code 与 Codex 进入桌面端
description: Claude Code 与 Codex 成为桌面端的一等运行时,全新 Agent Signal 运行时上线,并迎来一批旗舰模型。
tags:
- 异构 Agent
@@ -11,17 +11,20 @@ tags:
## 新功能
- 话题级别记忆滚动位置
- 用户消息固定在视口顶部,过长内容自动折叠;最后一条用户消息可直接编辑并重发;并发对话期间的后续发送会顺序排队
- 接入 Claude Code、Codex 等第三方编程 Agent
- 在桌面端通过 Quick Chat 与屏幕截图直接向 LobeHub 提问
- 新模型:GPT-5.5、DeepSeek V4 Flash / Pro(带思考强度滑块)、LobeHub 托管的 gpt-image-2、Kimi K2.6、MiMo-V2.5 与 Pro
- 新提供商:OpenCode Zen 与 OpenCode Go
- **接入 Claude Code 与 Codex。** 可在桌面端运行 Claude Code、Codex 等第三方编程 Agent。
- **桌面端 Quick Chat。** 截取屏幕内容并就此向 LobeHub 提问。
- **新模型。** GPT-5.5、DeepSeek V4 Flash 与 Pro(带思考强度滑块)、LobeHub 托管的 gpt-image-2、Kimi K2.6,以及 MiMo-V2.5 与 Pro。
- **新提供商。** OpenCode Zen 与 OpenCode Go。
## 体验优化与修复
## 体验优化
- 第一条助手消息不再启用 Markdown 流式渲染,避免渲染过程中的布局抖动
- 手动滚动后不再重新自动钉住对话底部
- 修复了 Codex 与异构 Agent 后续轮次中工具检查器渲染异常的问题
- FileEditor 从 antd Modal 迁移到 base-ui Modal,焦点与键盘行为更一致。
- QStash 心跳支持自我重调度,长任务运行更稳定。
- 话题会记住各自的滚动位置
- 过长的用户消息会折叠并固定在视口顶部,最后一条用户消息可直接编辑并重发,运行期间的后续发送也会顺序排队
- 文件编辑器的焦点与键盘行为更一致
## 问题修复
- 第一条助手消息在流式渲染时不再发生布局抖动。
- 手动向上滚动后,对话不再跳回底部。
- Codex 与后续轮次的工具检查器渲染正常。
- 长任务能持续保活,不再中途停滞。
+17 -15
View File
@@ -13,24 +13,26 @@ tags:
# Delegate Claude Code and Codex
Now you can control coding agents in LobeHub. Simply click `Create Agent` and choose your coding agent. This feature is only available on desktop app.
![](/blog/assets71fe5959cbc6f0a89243d7262f48fafc.webp)
## Features
- Agent-specific topic grouping: switch the topic list to group by agent, with a friendlier empty state
- Review tab: a new tab that aggregates bulk git diffs across a tree, \~9× faster on large repos
- Local file mention snapshots: drag a file into chat and a snapshot is captured for the model to reason over
- Visual understanding tool: a new built-in tool for image analysis and visual reasoning
- Line bot support: connect a Line channel as an agent endpoint
- New models: `grok-4.3`, DeepSeek Anthropic runtime, plus `gpt-image-2` and Grok 4.20 in the model library
- **Delegate Claude Code and Codex.** Control coding agents inside LobeHub: click Create Agent and choose your coding agent. Desktop only.
- **Group topics by agent.** Switch the topic list to group by agent, with a friendlier empty state.
- **Review tab.** A new tab that gathers bulk Git diffs across a tree, about 9× faster on large repos.
- **Local file snapshots.** Drag a file into chat and a snapshot is captured for the model to read.
- **Visual understanding tool.** A built-in tool for image analysis and visual reasoning.
- **Line bot support.** Connect a Line channel as an agent endpoint.
- **New models.** `grok-4.3`, the DeepSeek Anthropic runtime, plus `gpt-image-2` and Grok 4.20 in the model library.
## Improvements and fixes
## Improvements
- DeepSeek now shows pricing in the model card and respects model defaults.
- Document modal shows a skeleton while the title loads and surfaces the document update time in space.
- Agent documents can be exposed as a virtual file system with fs-compatible output.
- Sessions are revoked after a password reset, and tRPC pagination now enforces a max limit.
- Skill OAuth no longer breaks the desktop app by skipping `redirectUri` on Electron.
- CAPTCHA retries during sign-in are handled cleanly instead of failing the flow.
- DeepSeek shows pricing in the model card and respects model defaults.
- The document modal shows a skeleton while the title loads, and surfaces the document's update time in the space.
- Agent documents can be exposed as a file system for tools to read.
## Fixes
- Sessions are revoked after a password reset, and list pagination now enforces a maximum page size.
- Skill OAuth no longer breaks the desktop app.
- CAPTCHA retries during sign-in are handled cleanly instead of failing.
@@ -13,25 +13,26 @@ tags:
# 在 LobeHub 中调度 Claude Code 与 Codex
现在你可以在 LobeHub 内使用 Coding Agents。新建助手时选择你最喜欢的 Coding Agent 即可。此功能仅在桌面端可用。
![](/blog/assets71fe5959cbc6f0a89243d7262f48fafc.webp)
## 新功能
- 新增:在 LobeHub 中调度 Claude Code 与 Codex
- 按 Agent 分组话题可将话题列表切换为按 Agent 分组,并带有更友好的空状态
- Review 标签页新增 Review 标签页,可聚合树级别的批量 git diff,大型仓库下速度提升约 9 倍
- 本地文件提及快照:将文件拖入聊天即可生成快照供模型理解
- 视觉理解工具内置的图像分析与视觉推理工具
- Line Bot 接入可将 Line 频道作为 Agent 接入端
- 新模型`grok-4.3`、DeepSeek Anthropic 运行时,以及模型库新增的 `gpt-image-2` 和 Grok 4.20
- **调度 Claude Code 与 Codex。** 在 LobeHub 内直接控制编程 Agent:新建助手时选择你的编程 Agent 即可。仅在桌面端可用。
- **按 Agent 分组话题。** 可将话题列表切换为按 Agent 分组,并带有更友好的空状态
- **Review 标签页。** 新增 Review 标签页,可聚合树级别的批量 git diff,大型仓库下速度提升约 9 倍
- **本地文件快照。** 将文件拖入聊天即可生成快照供模型读取。
- **视觉理解工具。** 内置的图像分析与视觉推理工具
- **Line Bot 接入。** 可将 Line 频道作为 Agent 接入端
- **新模型。** `grok-4.3`、DeepSeek Anthropic 运行时,以及模型库新增的 `gpt-image-2` 和 Grok 4.20
## 体验优化与修复
## 体验优化
- DeepSeek 模型卡片展示价格并尊重模型默认配置。
- 文档弹窗在标题加载时显示骨架,并在 Space 中展示文档更新时间。
- Agent 文档可作为虚拟文件系统暴露,输出兼容 fs 接口
- 重置密码后会立即吊销已有会话,tRPC 分页接口新增最大条数限制。
- 在桌面端跳过 Skill OAuth 的 `redirectUri`,避免应用进入异常状态。
- DeepSeek 模型卡片展示价格并遵循模型默认配置。
- 文档弹窗在标题加载时显示骨架,并在空间中展示文档更新时间。
- Agent 文档可作为文件系统暴露,供工具读取
## 问题修复
- 重置密码后会立即吊销已有会话,列表分页接口新增最大条数限制。
- Skill OAuth 不再导致桌面应用进入异常状态。
- 登录流程中的 CAPTCHA 重试可正常处理,不再直接失败。
+16 -24
View File
@@ -13,31 +13,23 @@ tags:
# Agent Tasks GA & Cloud Heterogeneous Agent
## Tasks
Think of Agent Tasks like Linear, but with agents as your teammates. Create tasks the same way you'd file an issue — title, description, optional template — and assign them to an agent instead of a person. The agent picks up the task, executes the work, posts updates in comments, and moves the status forward (todo → in progress → done) as it makes progress.
Tasks can have subtasks with explicit dependencies, so a parent task can fan out work and the agent will run subtasks in dependency order. Recurring tasks can be wired to a cron schedule, parent assignments can be reshuffled at any time, and every task has its own thread of comments where you and the agent can coordinate.
Learn more in the [Task guide](/docs/usage/getting-started/task).
## Features
- Agent Tasks goes GA: the full task platform with templates, scheduled cron, comment tools, parent reassignment, and dependency-ordered batch subtask runs
- Nightly self-review: Agent Signal pipeline runs automatic self-review with skill-aware policies and pushes activity into briefs
- Cloud heterogeneous agents: Claude Code and Codex now execute server-side with persistent sessions that survive Vercel replica restarts
- `lh hetero exec` CLI: run a standalone heterogeneous agent from the terminal, with multimodal input support across desktop / CLI
- Claude Code can now pause and ask you a question mid-execution
- Inline agents in chat: `lobeAgents` markdown tag renders agent profile cards, and a newly created agent shows up as a clickable card
- Bot platforms expand: Messenger, Line, and Telegram integrations with DM pair policy and per-sender device tool gating
- New models: Gemini 3.1 Flash-Lite, SiliconCloud model sync, and DeepSeek V4 Pro as the new OSS default
- **Agent Tasks (GA).** Create a task the way you'd file an issue (title, description, optional template) and assign it to an agent instead of a person. The agent picks up the task, does the work, posts updates in comments, and moves the status from todo to in progress to done. Tasks can have subtasks with dependencies, so a parent task fans out work and runs subtasks in order. Recurring tasks can run on a schedule, parent assignments can change at any time, and each task has its own comment thread. [Task guide](/docs/usage/getting-started/task)
- **Nightly self-review.** Agents run an automatic self-review each night and add what they find to your briefs.
- **Cloud heterogeneous agents.** Claude Code and Codex now run in the cloud, with sessions that survive restarts.
- **`lh hetero exec` command.** Run a standalone coding agent from the terminal, with multimodal input on desktop and CLI.
- **Mid-run questions.** Claude Code can pause and ask you a question while it works.
- **Inline agents in chat.** The `lobeAgents` Markdown tag renders agent cards, and a newly created agent shows up as a clickable card.
- **More bot platforms.** Messenger, Line, and Telegram, with direct-message pairing and per-sender tool controls.
- **New models.** Gemini 3.1 Flash-Lite, SiliconCloud model sync, and DeepSeek V4 Pro as the new open-source default.
## Improvements and fixes
## Improvements
- Inline document grounding in the KB tool via BM25 search and `docs_*` reads.
- Daily Brief redesigned with linkable welcome card and a paired input hint; resolved briefs now show a mute icon.
- Long tool-call parameters now wrap instead of truncating; tool execution time formatted as `Xmin Ys`.
- Visible divider between queued messages so it's clear which sends are pending.
- Copy session ID added to the topic dropdown menu.
- Home sidebar collapse state persists across reloads.
- Desktop app tray visibility is now a setting.
- Knowledge-base answers can cite specific passages from your documents.
- The Daily Brief was redesigned with a linkable welcome card and a paired input hint, and resolved briefs show a mute icon.
- Long tool-call parameters wrap instead of truncating, and tool run time shows as `Xmin Ys`.
- A divider between queued messages makes it clear which sends are still pending.
- Copy session ID is now in the topic dropdown menu.
- The home sidebar remembers its collapsed state across reloads.
- The desktop tray icon's visibility is now a setting.
@@ -12,31 +12,23 @@ tags:
# Agent 任务系统 GA 与云端异构 Agent
## Agent 任务系统
Agent 任务系统的体感类似 Linear,但「队友」是 Agent。你像建 Issue 一样创建任务 —— 标题、描述、可选模板 —— 把它分配给 Agent 而不是某个人。Agent 接到任务后会执行工作、在评论中同步进展,并随着推进更新状态(待办 → 进行中 → 已完成)。
任务支持带显式依赖的子任务,父任务可以拆分工作,Agent 会按依赖顺序运行子任务。周期性任务可以挂接 Cron 计划;父任务的指派可以随时重新调整;每个任务都有自己的评论线,方便你和 Agent 协作沟通。
详见 [任务使用指南](/docs/usage/getting-started/task)。
## 新功能
- Agent 任务系统 GA:完整的任务平台,支持模板、Cron 定时、评论工具、父任务重指派,以及按依赖顺序的批量子任务运行
- 夜间自审Agent Signal 流水线自动运行自审,结合技能感知策略并将活动推送到简报
- 云端异构 AgentClaude Code 与 Codex 在服务端运行,会话持久化可跨 Vercel 副本恢复
- `lh hetero exec` CLI在终端独立运行异构 Agent,桌面端 / CLI 支持多模态输入
- AskUserQuestion 工具:Claude Code 可在执行过程中暂停并向你提问
- 聊天内联 Agent`lobeAgents` Markdown 标签渲染 Agent 卡片,新建的 Agent 会以可点击卡片形式出现
- Bot 平台扩展:新增 Messenger、Line、Telegram 接入,支持 DM 配对策略与按发送者识别的设备工具网关
- 新模型Gemini 3.1 Flash-Lite、SiliconCloud 模型同步,DeepSeek V4 Pro 成为开源版默认模型
- **Agent 任务系统GA)。** 你可以像建 Issue 一样创建任务(标题、描述、可选模板),并把它分配给 Agent 而不是某个人。Agent 接到任务后会执行工作、在评论中同步进展,并把状态从「待办」推进到「进行中」「已完成」。任务支持带依赖的子任务,父任务可以拆分工作并按依赖顺序运行子任务;周期性任务可以挂接计划,父任务的指派可随时调整,每个任务都有自己的评论线。[任务使用指南](/docs/usage/getting-started/task)
- **夜间自审。** Agent 每晚自动复盘,并把发现写进你的简报
- **云端异构 Agent。** Claude Code 与 Codex 现在在云端运行,会话可在重启后恢复
- **`lh hetero exec` 命令。** 在终端独立运行一个编程 Agent,桌面端与命令行均支持多模态输入
- **执行中提问。** Claude Code 可在工作过程中暂停并向你提问
- **聊天内联 Agent。** `lobeAgents` Markdown 标签渲染 Agent 卡片,新建的 Agent 会以可点击卡片形式出现
- **更多 Bot 平台。** 新增 Messenger、Line、Telegram,支持私信配对与按发送者的工具权限控制。
- **新模型。** Gemini 3.1 Flash-Lite、SiliconCloud 模型同步,DeepSeek V4 Pro 成为开源版默认模型
## 体验优化与修复
## 体验优化
- 知识库工具支持通过 BM25 搜索与 `docs_*` 读取实现内联文档落地
- 每日简报改版:欢迎卡片可链接、输入提示成对出现已处理的简报示静音图标。
- 工具调用参数过长时自动换行,不再截断;工具执行时间格式化为 `Xmin Ys`。
- 排队消息之间新增可见分隔线,方便辨认待发送的内容。
- 话题下拉菜单新增「复制会话 ID」操作
- 首页侧边栏的折叠状态在刷新后会保留
- 桌面应用托盘可见性现已纳入设置。
- 知识库回答可以基于你文档中的具体段落作答
- 每日简报改版:欢迎卡片可链接、输入提示成对出现已处理的简报会显示静音图标。
- 工具调用参数过长时自动换行,不再截断;工具执行时间显示为 `Xmin Ys`。
- 排队消息之间新增分隔线,方便辨认待发送的内容。
- 话题下拉菜单新增「复制会话 ID」。
- 首页侧边栏在刷新后会记住折叠状态
- 桌面端托盘图标的可见性现已纳入设置。
@@ -1,8 +1,8 @@
---
title: Introducing CAO Your Chief Agent Operator
title: 'Introducing CAO: Your Chief Agent Operator'
description: >-
Meet CAO: agents that review their own work, recruit teammates when they need
help, and only stop to ask you when it really matters.
CAO is an agent that reviews its own work, brings in sub-agents when it needs
help, and only stops to ask you when a decision matters.
tags:
- CAO
- Agent Teams
@@ -10,31 +10,27 @@ tags:
- Models
---
# Introducing CAO Your Chief Agent Operator
# Introducing CAO: Your Chief Agent Operator
## Meet CAO
## Features
CAO (Chief Agent Operator) turns short back-and-forth chats into agents that just keep going. Instead of waiting for you to say "ok, next step", your agent now checks its own work, decides what to do next, and gets on with it. When something genuinely needs your call, it pauses, tells you what it tried, and asks a clear question — no more guessing in the dark.
- **CAO (Chief Agent Operator).** Your agent can now keep going on its own. It reviews its own work, decides the next step, and continues without waiting for you to say "next." When a decision genuinely needs you, it pauses, tells you what it tried, and asks a clear question. For larger tasks, it can put together a small team of sub-agents and hand off parts of the work while the main agent stays in charge of the plan. You can open any sub-agent's conversation to see its progress. [Learn more about CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
- **Project skills in the working sidebar**, with a built-in Markdown preview.
- **Per-suggestion models.** Pick a different model for each kind of follow-up suggestion.
- **New model:** Baidu Ernie 5.1.
CAO can also bring in help. If a task gets big, your agent can put together a small team of sub-agents and hand pieces off to them. You can peek into any of their conversations to see what they're up to, while your main agent stays in charge of the overall plan.
## Improvements
[Learn more about CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
## What else is new
- Recurring tasks are more reliable, with a cap on how many times a task can run and protection against schedules that fire too often
- A cleaner Agent Documents view — easier to find what you put there, and no more clutter from old web crawls
- Project skills now show up in the working sidebar, with a built-in markdown preview
- You can pick a different model for each kind of follow-up suggestion
- New model: Baidu Ernie 5.1
- DeepSeek pricing is back to official rates
## Improvements and fixes
- Desktop no longer signs you out from background token refreshes, and remembers which page you were on after an app update.
- Chat input actions show as icon + label, and only the latest reply animates as it streams.
- Chat input actions show as icon and label, and only the latest reply animates while streaming.
- Cleaner tab bar styling, and task schedule labels no longer wrap awkwardly.
- Local-file search handles hidden files starting with `.`, and grep parameters now flow through correctly.
- Documents fail clearly when they aren't supported, instead of producing confusing errors.
- Gemini tool calls handle thinking signatures and enums correctly, and per-tool timeouts now stick.
- Task pages keep the right agent context; memory updates stay out of your main thread.
- Recurring tasks are more reliable, with a run-count cap and protection against schedules that fire too often.
- A cleaner Agent Documents view: easier to find what you saved, without old web-crawl clutter.
- DeepSeek is priced at official rates again.
## Fixes
- Desktop no longer signs you out during background token refreshes, and it remembers which page you were on after an update.
- Local-file search handles hidden files that start with `.`, and grep parameters pass through correctly.
- Unsupported documents now fail with a clear message instead of a confusing error.
- Gemini tool calls handle thinking signatures and enums correctly, and per-tool timeouts now hold.
- Task pages keep the right agent context, and memory updates stay out of your main thread.
@@ -1,6 +1,6 @@
---
title: 隆重推出 CAO —— 你的首席智能体运营官
description: 认识 CAO:会自己复盘、能临时组队,只在真正需要时才停下来问你的智能体。
title: 'CAO你的首席智能体运营官'
description: CAO 是一种会自己复盘、需要时能组建子智能体、只在需要你拍板时才停下来的智能体。
tags:
- CAO
- 智能体团队
@@ -8,30 +8,26 @@ tags:
- 模型
---
# 隆重推出 CAO —— 你的首席智能体运营官
# CAO你的首席智能体运营官
## 认识 CAO
## 新功能
CAO(首席智能体运营官)让原本一问一答的对话,变成一个能自己往前推进的智能体。不必再每一步都告诉它「好,下一步」—— 它会自己复盘已完成的部分,决定接下来要做什么,然后继续推进。当遇到真正需要你拍板的事,它会停下来,告诉你已经尝试了什么,并提出一个清晰的问题,而不是凭感觉硬猜。
- **CAO(首席智能体运营官)。** 智能体现在可以自己往前推进:它会复盘已完成的部分,决定下一步,并继续执行,不必每一步都等你说「下一步」。遇到真正需要你拍板的事,它会停下来,告诉你已经尝试了什么,并提出一个清晰的问题。任务较大时,它可以组建一支子智能体小队并分派工作,主智能体始终掌控整体节奏。你可以打开任意子智能体的对话查看进展。[了解更多 CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
- **项目技能进入工作侧栏**,并自带 Markdown 预览。
- **按建议类型选择模型。** 不同类型的后续建议可以分别选择不同的模型。
- **新模型:** 百度文心一言 5.1。
CAO 也会找帮手。任务变大时,它可以临时组建一支子智能体小队,把不同部分交给队友处理。你随时可以看任意一个子智能体的对话进展,而主智能体始终掌控整体节奏。
## 体验优化
[了解更多 CAO →](https://x.com/lobehub/status/2056371816265097337?s=20)
## 还有这些新东西
- 周期性任务更稳了:可以限制总执行次数,也会阻止设置得过于频繁的计划
- 智能体文档视图更清爽 —— 更容易找到自己放进去的东西,也不再混着旧的网页抓取记录
- 项目技能现在会出现在工作侧栏,并自带 Markdown 预览
- 不同类型的后续建议可以分别选择不同的模型
- 新增模型:百度文心一言 5.1
- DeepSeek 价格已恢复官方定价
## 体验优化与修复
- 桌面端不再因为后台刷新 token 而把你踢出登录,应用更新后也能回到你之前所在的页面。
- 聊天输入框的动作按钮调整为「图标 + 标签」样式,只有最新一条回复会有打字动画。
- 标签栏样式更清爽,任务计划标签不再换行错位。
- 周期性任务更稳定:可限制总执行次数,并阻止设置得过于频繁的计划。
- 智能体文档视图更清爽:更容易找到自己放进去的东西,也不再混着旧的网页抓取记录。
- DeepSeek 已恢复官方定价。
## 问题修复
- 桌面端不再因为后台刷新 token 而把你踢出登录,应用更新后也能回到你之前所在的页面。
- 本地文件搜索可以正确处理以 `.` 开头的隐藏文件,搜索参数也能完整传递。
- 不支持的文档现在会明确报错,而不是抛出令人困惑的提示。
- Gemini 工具调用对思考签名和枚举类型的处理已修复,每个工具的超时设置也确实生效。
@@ -11,39 +11,30 @@ tags:
# Platform Agents & Drag-Drop Skills
## Platform agents, local or remote (beta)
## Features
You can now create platform agents like **OpenClaw** and **Hermes** and choose, right from the composer, whether they run on your own machine or on a remote device. A new device switcher in the chat input lets you swap targets without leaving the conversation, and your registered devices are remembered so you can pick up where you left off.
- **Platform agents, local or remote (beta).** Create platform agents like OpenClaw and Hermes and choose, right from the composer, whether they run on your own machine or a remote device. A device switcher in the chat input swaps targets without leaving the conversation, and registered devices are remembered. On desktop, the recent-directories list can be reordered by drag-and-drop, and devices auto-register with a stable ID. Platform agents are in beta; turn them on under Settings → Advanced → Labs.
- **Drag-and-drop skills and folders.** Drag a skill from the right panel into the message box and it becomes an action tag. Type `/` mid-sentence to open a menu of every installed skill, from built-ins to Skill Market skills to your own agents'. On desktop, drag a whole folder into chat and it appears as a `@localFile` reference instead of uploading every file inside.
- **Bots that send files.** Discord, Telegram, Slack, Feishu, WeChat, LINE, and QQ can now exchange images, video, voice, and files, not just text.
- **Page Agent sharing.** Share an agent's working pages with one click.
- **Export an agent.** Download any agent's profile as a Markdown file.
- **New models.** Claude Opus 4.8, DeepSeek V4 Flash/Pro, Gemini 3.5 Flash, Qwen 3.7 Max, intern-s2-preview, and step-3.7-flash.
On desktop, the recent-directories list can be reordered by drag-and-drop, and devices auto-register with a stable ID — set it once, use it everywhere.
## Improvements
> Platform agents are in beta. Head to **Settings → Advanced → Labs** and turn on the platform-agent flag to try them.
- Non-Markdown documents render as read-only highlighted code, and you can open a thread chat inside the document preview.
- Drop files and images directly into a task.
- Cost estimates appear alongside replies.
- Guided agent creation, and new topics aren't created until you send your first message.
- Multi-select delete in the agent documents explorer.
- Follow-up suggestions now appear in general chats, not just agent ones.
## Drag-and-drop skills & folders
The chat input got more direct. Drag a skill from the right panel into the message box and it becomes an action tag — no menu hunting. Typing `/` mid-sentence pops up a slash menu of every skill you have installed, from built-ins to ones from the Skill Market or your own agents.
On desktop, drag a whole folder into chat and it shows up as a `@localFile` reference instead of trying to upload every file inside it.
## Other improvements
- **Bots that send files**: Discord, Telegram, Slack, Feishu, WeChat, LINE, and QQ can now exchange images, videos, voice, and files — not just text
- **Page Agent sharing**: share an agent's working pages with one click
- **Document highlights**: non-markdown documents render as read-only highlighted code; you can open a thread chat inside the document preview
- **Tasks with attachments**: drop files and images directly into a task
- **Export an agent**: download any agent's profile as a Markdown file
- **New models**: Claude Opus 4.8, DeepSeek V4 Flash/Pro, Gemini 3.5 Flash, Qwen 3.7 Max, intern-s2-preview, step-3.7-flash
- **Chat cost estimates** shown alongside replies
- **Smoother first run**: guided agent creation, and new topics aren't created until you send your first message
- **Multi-select delete** in the agent documents explorer
- **Follow-up suggestions** in general chats, not just agent ones
## Fixes & polish
## Fixes
- Input drafts persist when you switch tabs.
- Action bar stays open while you hover the next message.
- The action bar stays open while you hover the next message.
- Copying a user message no longer leaves escaped Markdown.
- Cmd +//0 shows a zoom HUD on desktop, and `~` paths expand correctly.
- Empty replies retry instead of silently finishing; market OAuth re-login pops the right modal when a session expires.
- On desktop, Cmd +//0 shows a zoom indicator, and `~` paths expand correctly.
- Empty replies retry instead of finishing silently, and market re-login shows the right modal when a session expires.
- Topic list pagination is preserved after creating, deleting, or moving topics.
- File preview now covers `.cjs`, `.mjs`, and extension-less text files; Bedrock structured output and Gemini diagnostics fixes also landed.
- File preview now covers `.cjs`, `.mjs`, and extension-less text files. Bedrock structured output and Gemini diagnostics fixes also landed.
@@ -9,39 +9,30 @@ tags:
# 平台智能体与拖拽即用的技能
## 平台智能体:本地或远程(Beta
## 新功能
现在可以创建 **OpenClaw**、**Hermes** 这类平台智能体,并直接在输入框选择它们运行在你的本机还是某台远程设备上。聊天输入区新增了执行设备切换器无需离开会话就能切换目标注册过的设备会被记住,下次直接接着用
- **平台智能体:本地或远程(Beta)。** 你可以创建 OpenClawHermes 这类平台智能体,并直接在输入框选择它们运行在本机还是某台远程设备上。聊天输入区执行设备切换器无需离开会话即可切换目标注册过的设备会被记住。桌面端的「最近目录」支持拖拽重新排序,设备会基于稳定的机器 ID 自动注册。平台智能体目前为 Beta,可在 设置 → 高级 → Labs 中开启
- **拖拽即用的技能与文件夹。** 从右侧面板把技能拖进消息框,它会变成一枚动作标签。在句子中间输入 `/` 会弹出包含全部已安装技能的菜单,涵盖内置、技能市场以及你自己助理提供的技能。桌面端可以把整个文件夹拖进聊天,它会以 `@localFile` 引用形式出现,而不是把里面的每个文件都上传一遍。
- **会发文件的 Bot。** Discord、Telegram、Slack、飞书、微信、LINE、QQ 现在都能收发图片、视频、语音和文件,不再仅限于文字。
- **Page Agent 共享。** 一键分享智能体的工作页面。
- **导出智能体。** 把任意智能体导出成 Markdown 文件。
- **新模型。** Claude Opus 4.8、DeepSeek V4 Flash / Pro、Gemini 3.5 Flash、Qwen 3.7 Max、intern-s2-preview、step-3.7-flash。
桌面端的「最近目录」支持拖拽重新排序;设备会基于稳定的机器 ID 自动注册——只设置一次,到哪都能用。
## 体验优化
> 平台智能体目前为 Beta。前往 **设置 → 高级 → Labs** 开启对应开关后即可体验
- 非 Markdown 文档以只读的代码高亮形式呈现,并可在文档预览中直接开启线程对话
- 图片和文件可以直接拖进任务。
- 回复旁会显示费用预估。
- 新建智能体有引导界面,发送第一条消息后才会真正创建话题。
- 智能体文档浏览器支持多选删除。
- 后续建议现在也会出现在普通对话里,不再仅限于智能体对话。
## 拖拽即用的技能与文件夹
聊天输入更直接了。从右侧面板把技能拖进消息框,它会变成一枚动作标签——不用再翻菜单。在句子中间输入 `/` 也会弹出包含全部已安装技能的菜单,无论是内置的、来自技能市场的,还是你自己 Agent 提供的。
桌面端可以把整个文件夹拖进聊天,它会以 `@localFile` 引用形式出现,而不是把里面的每个文件都上传一遍。
## 其他改进
- **会发文件的 Bot**Discord、Telegram、Slack、飞书、微信、LINE、QQ 现在都能收发图片、视频、语音和文件,不再仅限于文字
- **Page Agent 共享**:一键分享智能体的工作页面
- **文档高亮**:非 Markdown 文档以只读的代码高亮形式呈现,并可在文档预览中直接开启线程对话
- **任务支持附件**:图片和文件可以直接挂到任务上
- **导出智能体**:把任意智能体导出成 Markdown 文件
- **新模型**Claude Opus 4.8、DeepSeek V4 Flash / Pro、Gemini 3.5 Flash、Qwen 3.7 Max、intern-s2-preview、step-3.7-flash
- **回复旁显示费用预估**
- **更顺手的初次体验**:新建智能体有引导界面,发送第一条消息后才会真正创建话题
- 智能体文档浏览器支持**多选删除**
- **后续建议**现在也会出现在普通对话里,不再仅限于 Agent 对话
## 修复与打磨
## 问题修复
- 切换标签页后,输入草稿仍会保留。
- 鼠标悬停到下一条消息时,操作栏不会闪退
- 鼠标悬停到下一条消息时,操作栏保持展开
- 复制用户消息时不再带出转义的 Markdown 字符。
- 桌面端 Cmd +/−/0 显示缩放 HUD`~` 路径会被正确展开。
- 桌面端 Cmd +//0 显示缩放提示,`~` 路径会被正确展开。
- 空回复会自动重试,而不是悄悄结束;技能市场会话过期时会弹出正确的重新登录窗口。
- 创建、删除、移动话题后,话题列表的分页状态会被保留。
- 桌面端现在可预览 `.cjs`、`.mjs` 和无后缀文本文件;同时修复了 Bedrock 结构化输出与 Gemini 诊断相关问题。
+20 -24
View File
@@ -12,32 +12,28 @@ tags:
# Connectors & Connect Agents
## Connectors: one place to govern every tool
## Features
Connectors bring all of an agent's tools MCP servers, Skill Market skills, built-in tools, and third-party integrations — under a single permission layer. For each tool you decide whether it runs freely, pauses for your approval, or stays off, and read-only actions (like fetching or listing) are detected automatically so they aren't blocked by mistake. It's the clearest way yet to see what your agents can reach, and to keep write actions on a short leash.
- **Connectors.** All of an agent's tools (MCP servers, Skill Market skills, built-in tools, and third-party integrations) now sit under one permission layer. For each tool you choose whether it runs freely, pauses for your approval, or stays off. Read-only actions like fetching or listing are detected automatically, so they aren't blocked by mistake.
- **Connect Agents.** What you used to create as a "Platform Agent" is now a Connect Agent: a third-party agent that runs on your own device rather than on LobeHub. The execution-device switcher appears for every agent, so you can point any conversation at a specific machine. Agents can call MCP tools through your device with results shown inline, and server-run agents scan the project folder you bind them to, picking up `.agents/skills`, `.claude/skills`, and your `AGENTS.md` / `CLAUDE.md`.
- **Token usage in the heatmap.** The activity heatmap added a token-usage mode, so you can switch from how often you chatted to how much each day cost without leaving the page.
- **New model: MiniMax M3**, including its video runtime.
- **Configurable model routing and starters**, for finer control over which model handles what.
## Connect Agents, running on your own machine
## Improvements
What you used to create as a "Platform Agent" is now a **Connect Agent** — a name that better reflects what it is: a third-party agent running on your own device, not on LobeHub. The execution-device switcher now appears for every agent, so you can point any conversation at a specific machine. Agents can call stdio MCP tools directly through your device and their results render inline in chat, and server-run agents now scan the project folder you bind them to — automatically picking up `.agents/skills`, `.claude/skills`, and your `AGENTS.md` / `CLAUDE.md`.
- The chat input's "+" menu was reworked with toggle switches and grouped submenus, and pinned tools now have their own section.
- Command output renders ANSI colors, so command logs read like your terminal.
- Inside a task, the comment box is now a full chat input that can start a new run.
- The topic sidebar can group conversations by status, and one click collapses or expands every group.
- Cleaner auto-generated topic titles, with better results on DeepSeek and stray Markdown stripped from fallback titles.
- The agent document editor renders system docs, defaults new files to `.md`, and keeps typing smooth for Chinese, Japanese, and Korean input.
- Delete confirmations have clearer titles and wording across the app.
- Agent documents load noticeably faster.
## See where your tokens go
## Fixes
The activity heatmap added a token-usage mode, so you can switch from "how often did I chat" to "how much did each day cost" without leaving the page. The topic sidebar can now group conversations by status, and one click collapses or expands every group at once.
## New model and chat-input polish
- **New model**: MiniMax M3, including its video runtime
- **Configurable model routing and starters**, for finer control over which model handles what
- The chat input's **`+` menu** was reworked with toggle switches and grouped submenus, and app-fixed tools now live in a dedicated **Pinned** section
- Command output now **renders ANSI colors**, so `RunCommand` logs read just like your terminal
- Inside a task, the comment box is now a full chat input that **kicks off a new run**
## Improvements and fixes
- Page-agent edits now run server-side, so they no longer break when you switch tabs, navigate away, or hit a network blip.
- Cleaner auto-generated topic titles, with better results on DeepSeek, and stray Markdown tokens stripped from fallback titles.
- The agent document editor renders system docs, defaults new files to `.md`, and preserves IME composition for CJK input.
- Delete confirmations were restructured for clearer titles and wording across the app.
- Desktop: macOS auto-update signing works again, the updater can quit cleanly, CLI tools resolve from your shell `PATH`, and a startup renderer crash is fixed.
- Streaming no longer duplicates after a stale reconnect, and home-screen starters load more reliably.
- The GitHub bot renders its `runCommand` result card, and agent documents load with noticeably less latency.
- Page Agent edits now run in the cloud, so they no longer break when you switch tabs, navigate away, or hit a network blip.
- Streaming no longer duplicates after a dropped reconnect, and home-screen starters load more reliably.
- Desktop: macOS auto-update signing works again, the updater quits cleanly, CLI tools resolve from your shell `PATH`, and a startup crash is fixed.
- The GitHub bot now shows its command-result card.
+21 -25
View File
@@ -10,32 +10,28 @@ tags:
# 连接器与接入助理
## 连接器:统一管控每一个工具
## 新功能
连接器把助理的所有工具——MCP 服务器、技能市场的技能、内置工具,以及第三方集成——都纳入同一套权限体系。你可以为每个工具单独决定:直接放行、先暂停等你批准,还是干脆关闭;只读类操作(例如获取、列举)会被自动识别,不会被误拦。这是迄今最清晰的方式,让你看清助理能触达哪些能力,也把写入类操作牢牢攥在手里
- **连接器。** 助理的所有工具MCP 服务器、技能市场的技能、内置工具,以及第三方集成)现在都归入同一套权限体系。你可以为每个工具单独决定:直接放行、先暂停等你批准,还是关闭。获取、列举等只读操作会被自动识别,不会被误拦
- **接入助理。** 过去你创建的「平台 Agent」现在叫接入助理:它是运行在你自己设备上的第三方助理,而非 LobeHub 托管。执行设备切换器现在对所有助理可见,你可以把任意会话指向某台机器。助理能通过你的设备调用 MCP 工具,并把结果内嵌在聊天里呈现;在服务端运行的助理会扫描你绑定的项目目录,自动读取 `.agents/skills`、`.claude/skills` 以及 `AGENTS.md` / `CLAUDE.md`。
- **热力图中的 Token 用量。** 活跃度热力图新增 Token 用量模式,无需离开页面,就能从「每天聊了多少次」切到「每天花了多少」。
- **新模型:MiniMax M3**,含视频运行时。
- **可配置的模型路由与开场白**,更精细地决定由哪个模型处理什么。
## 接入助理,跑在你自己的设备上
## 体验优化
过去你创建的「平台 Agent」,现在叫 **接入助理**——这个名字更贴切:它是运行在你自己设备上的第三方助理,而非 LobeHub 托管的助理。执行设备切换器现在对所有助理可见,你可以把任意会话指向某台指定机器。助理能直接通过你的设备调用 stdio MCP 工具,结果会内嵌在聊天里呈现;在服务端运行的助理还会扫描你为它绑定的项目目录,自动读取 `.agents/skills`、`.claude/skills` 以及 `AGENTS.md` / `CLAUDE.md`
## 看清 Token 花在哪
活跃度热力图新增了 Token 用量模式,无需离开页面,就能从「每天聊了多少次」切到「每天花了多少」。话题侧边栏现在支持按状态分组,一次点击即可折叠或展开全部分组。
## 新模型与输入框打磨
- **新模型**MiniMax M3,含视频运行时
- **可配置的模型路由与开场白**,更精细地决定由哪个模型处理什么
- 聊天输入框的 **`+` 菜单** 重做,改用开关切换并分组归类;应用固定的工具现在收进独立的 **「固定」区**
- 命令输出会**渲染 ANSI 颜色**`RunCommand` 的日志读起来和终端里一样
- 在任务里,评论框现在是完整的聊天输入框,可**直接发起一次新的运行**
## 体验优化与修复
- Page Agent 的编辑改到服务端执行,切换标签页、离开页面或网络抖动时不再中断。
- 聊天输入框的「+」菜单重做,改用开关切换并分组归类,固定的工具单独成区
- 命令输出会渲染 ANSI 颜色,命令日志读起来和终端里一样。
- 在任务里,评论框现在是完整的聊天输入框,可直接发起一次新的运行。
- 话题侧边栏可按状态分组,一次点击即可折叠或展开全部分组。
- 自动生成的话题标题更干净,在 DeepSeek 上效果更好,兜底标题里残留的 Markdown 符号也会被清除。
- 助理文档编辑器可渲染系统文档,新建文件默认 `.md`,并保留中日韩输入法(IME)的组合输入
- 各处删除确认弹窗重新梳理了标题与文案更清晰。
- 桌面端:修复 macOS 自动更新签名、更新时能正常退出、CLI 工具可从 shell `PATH` 解析,以及启动时的渲染进程崩溃
- 修复重连后偶发的重复流式输出,首页开场白加载更稳定。
- GitHub Bot 能正确渲染 `runCommand` 结果卡片,助理文档的加载延迟明显降低。
- 助理文档编辑器可渲染系统文档,新建文件默认 `.md`,并让中文、日文、韩文输入更顺畅
- 各处删除确认弹窗标题与文案更清晰。
- 助理文档的加载明显更快
## 问题修复
- Page Agent 的编辑改到云端执行,切换标签页、离开页面或网络抖动时不再中断。
- 修复重连断开后偶发的重复流式输出,首页开场白加载更稳定。
- 桌面端:修复 macOS 自动更新签名、更新时能正常退出、CLI 工具可从 shell `PATH` 解析,以及启动崩溃。
- GitHub Bot 现在会显示命令结果卡片。
@@ -0,0 +1,37 @@
---
title: Browser Pairing & Live Run Status
description: >-
Pair a device straight from your browser, watch every run's time and cost above the chat input, and try a fresh batch of new models and providers.
tags:
- Devices
- Connect Agent
- Usage
- Models
---
# Browser Pairing & Live Run Status
## Features
- **Pair a device from your browser.** The Devices page adds a "Via Browser" option: generate a pairing code, exchange it, and the current browser tab becomes a device an agent can run on, with no desktop app required.
- **Browse a connected device from chat.** Approved local files open in read-only preview tabs, including HTML rendered inline, even when a file sits outside the current workspace. Agents can list a device's Git worktrees, the branch switcher gained rename and delete actions, and project skills from the device appear in the working sidebar.
- **Live run status.** A status tray sits above the chat input while an agent works, showing elapsed time, total tokens, and cost for the current run, along with the cache hit rate.
- **New models and providers.** AntGroup and SenseNova (6.7 Flash Lite and U1 Fast) join as new providers, along with updated Longcat models that fetch their list live. Model cards now show each model's knowledge cutoff, and the model tab bar stays visible.
## Improvements
- Move many conversations to another agent at once, see status indicators in the topic sidebar, and reach more actions from the topic selector.
- Refreshed topic sharing, with a new share page and a one-click share popover.
- Add your own custom OAuth MCP connector during setup, and edit or uninstall connectors from their detail panel.
- GitHub, Linear, and external links render as link cards in Markdown.
- The page editor groups autosave history into 10-minute versions, and pages, agents, groups, and tasks lock while someone else is editing them.
- View, rename, and delete skills from the working sidebar.
- Desktop: open a new Home tab from the "+" button, drag to reorder tabs, and double-click the tray icon to open the main window. Cloud desktop builds are back.
- New signups now land directly in onboarding.
## Fixes
- More reliable runs on Codex and Claude Code.
- Better structured output on DeepSeek.
- Clearer errors when a model list fails to load.
@@ -0,0 +1,35 @@
---
title: 浏览器配对与实时运行状态
description: 直接在浏览器里配对设备,在聊天输入框上方实时查看每次运行的耗时与花费,并用上一批新模型与新服务商。
tags:
- 设备
- 接入助理
- 用量
- 模型
---
# 浏览器配对与实时运行状态
## 新功能
- **在浏览器里配对设备。** 设备页面新增「通过浏览器」方式:生成配对码并完成交换,当前浏览器标签页就成为一台助理可运行的设备,无需安装桌面端。
- **在聊天里浏览已连接的设备。** 经批准的本地文件以只读预览标签打开,其中 HTML 可内嵌渲染,即便文件位于当前空间之外也行。助理可以列出设备上的 Git worktree,分支切换器新增了重命名与删除操作,设备项目里的技能也会出现在工作侧边栏。
- **实时运行状态。** 助理工作时,聊天输入框上方会出现状态栏,显示本次运行的耗时、Token 总量与花费,并附带缓存命中率。
- **新模型与服务商。** 新增蚂蚁百灵(AntGroup)、商汤 SenseNova6.7 Flash Lite 与 U1 Fast)等服务商,以及可在线拉取列表的 Longcat 模型。模型卡片现在会标注知识截止时间,模型标签栏也常驻显示。
## 体验优化
- 可一次性把多个会话转移到另一个助理,话题侧边栏新增状态标识,话题选择器也能直达更多操作。
- 全新的话题分享体验,带来新的分享页与一键分享气泡。
- 可在接入流程中添加自定义的 OAuth MCP 连接器,也能在连接器详情里编辑或卸载。
- Markdown 里的 GitHub、Linear 与外部链接会以链接卡片形式呈现。
- 文稿编辑器把自动保存历史按 10 分钟合并为一个版本;文稿、助理、群组与任务在他人编辑时会自动加锁。
- 可在工作侧边栏里查看、重命名和删除技能。
- 桌面端:可从标签栏「+」打开新的首页标签、拖拽重排标签、双击托盘图标打开主窗口;云端桌面版构建已恢复。
- 新注册用户现在会直接进入引导流程。
## 问题修复
- Codex 与 Claude Code 的运行更稳定。
- DeepSeek 结构化输出更好。
- 模型列表加载失败时的报错更清晰。
+9
View File
@@ -2,6 +2,15 @@
"$schema": "https://github.com/lobehub/lobe-chat/blob/main/docs/changelog/schema.json",
"cloud": [],
"community": [
{
"image": "/blog/assets6aebb8b5341dc37202ffecb363b9b906.webp",
"id": "2026-06-15-browser-pairing",
"date": "2026-06-15",
"versionRange": [
"2.2.3",
"2.2.4"
]
},
{
"image": "/blog/assets65dddd1748c3de8646c8ad56abf53390.webp",
"id": "2026-06-08-connectors",
+11 -11
View File
@@ -252,20 +252,20 @@
"channel.wechatTips": "Please update WeChat to the latest version and restart it. The ClawBot plugin is in gradual rollout, so check Settings > Plugins to confirm access.",
"channel.wechatUserId": "WeChat User ID",
"channel.wechatUserIdHint": "WeChat account identifier returned by the authorization flow.",
"transfer.button": "Transfer",
"transfer.confirm.botChannels": "Bot channel connections may need to be refreshed after transfer",
"transfer.button": "Move",
"transfer.confirm.botChannels": "Bot channel connections may need to be refreshed after moving",
"transfer.confirm.chatGroups": "Multi-agent group associations will be removed",
"transfer.confirm.desc": "This will move the agent and all associated data (topics, messages, files, etc.) to the target workspace.",
"transfer.confirm.plugins": "Custom plugins may not be available in the target workspace",
"transfer.confirm.title": "Transfer Agent",
"transfer.confirm.warning": "Some features don't transfer:",
"transfer.copyTo": "Copy To",
"transfer.desc": "Transfer this agent to another workspace or your personal account.",
"transfer.error": "Failed to transfer agent",
"transfer.confirm.title": "Move Agent",
"transfer.confirm.warning": "Some links won't move:",
"transfer.copyTo": "Copy to...",
"transfer.desc": "Move this Agent to another Workspace or your personal account.",
"transfer.error": "Failed to move agent",
"transfer.personalAccount": "Personal Account",
"transfer.searchWorkspace": "Search workspaces...",
"transfer.selectTarget": "Transfer Agent To",
"transfer.success": "Agent transferred successfully",
"transfer.title": "Transfer",
"transfer.transferTo": "Transfer To"
"transfer.selectTarget": "Move Agent to",
"transfer.success": "Agent moved successfully",
"transfer.title": "Move",
"transfer.transferTo": "Move to..."
}
+1
View File
@@ -44,6 +44,7 @@
"builtinCopilot": "Built-in Copilot",
"chatList.expandMessage": "Expand Message",
"chatList.longMessageDetail": "View Details",
"chatList.refreshing": "Fetching latest messages...",
"chatMode.agent": "Agent",
"chatMode.agentCap.env": "Runtime env",
"chatMode.agentCap.files": "File access",
+1 -13
View File
@@ -4,7 +4,6 @@
"workingDirectory.addFolderTitle": "Add working directory",
"workingDirectory.agentDescription": "Default working directory for all conversations with this Agent",
"workingDirectory.agentLevel": "Agent Working Directory",
"workingDirectory.bareWorktree": "bare",
"workingDirectory.branchSearchPlaceholder": "Search branches",
"workingDirectory.branchesEmpty": "No local branches",
"workingDirectory.branchesHeading": "Branches",
@@ -14,19 +13,15 @@
"workingDirectory.checkoutAction": "Checkout",
"workingDirectory.checkoutFailed": "Checkout failed",
"workingDirectory.chooseDifferentFolder": "Choose a folder...",
"workingDirectory.clean": "clean",
"workingDirectory.clear": "Clear",
"workingDirectory.createBranchAction": "Checkout new branch…",
"workingDirectory.createBranchTitle": "Create new branch",
"workingDirectory.current": "Current working directory",
"workingDirectory.currentWorktree": "current",
"workingDirectory.deleteBranchAction": "Delete branch",
"workingDirectory.deleteBranchConfirm": "Delete branch “{{name}}”? This permanently removes it, including any unmerged commits.",
"workingDirectory.deleteBranchTitle": "Delete branch",
"workingDirectory.deleteFailed": "Delete failed",
"workingDirectory.detachedHead": "Detached HEAD at {{sha}}",
"workingDirectory.detachedHeadShort": "detached@{{sha}}",
"workingDirectory.detachedWorktree": "detached",
"workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}",
"workingDirectory.filesAdded": "Added",
"workingDirectory.filesDeleted": "Deleted",
@@ -34,7 +29,6 @@
"workingDirectory.filesLoading": "Loading changes…",
"workingDirectory.filesModified": "Modified",
"workingDirectory.ghMissing": "Install and log in to the GitHub CLI (`gh`) to see linked pull requests",
"workingDirectory.lockedWorktree": "locked",
"workingDirectory.newBranchPlaceholder": "feature/new-branch-name",
"workingDirectory.noRecent": "No recent directories",
"workingDirectory.notSet": "Click to set working directory",
@@ -42,7 +36,6 @@
"workingDirectory.pathNotExist": "This path doesn't exist on the device",
"workingDirectory.placeholder": "Enter directory path, e.g. /Users/name/projects",
"workingDirectory.prTooltipWithExtra": "{{title}} (+{{count}} more open PR on this branch)",
"workingDirectory.prunableWorktree": "prunable",
"workingDirectory.pullAction": "Click to pull {{count}} commit(s) from {{upstream}}",
"workingDirectory.pullFailed": "Pull failed",
"workingDirectory.pullInProgress": "Pulling…",
@@ -66,10 +59,5 @@
"workingDirectory.topicLevel": "Conversation override",
"workingDirectory.topicOverride": "Override for this conversation",
"workingDirectory.uncommittedChanges_one": "Uncommitted changes: {{count}} file",
"workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files",
"workingDirectory.worktreeCount": "{{count}} worktrees",
"workingDirectory.worktreeSwitchDescription": "Switch the current conversation working directory",
"workingDirectory.worktreeUnavailable": "unavailable",
"workingDirectory.worktreesEmpty": "No worktrees found",
"workingDirectory.worktreesHeading": "Worktrees"
"workingDirectory.uncommittedChanges_other": "Uncommitted changes: {{count}} files"
}
+7 -1
View File
@@ -3,18 +3,24 @@
"fleet.allShown": "All running tasks are shown",
"fleet.backToHome": "Back to home",
"fleet.closeColumn": "Close column",
"fleet.collapseReply": "Collapse",
"fleet.createTask": "Create task",
"fleet.dragHint": "Drag to reorder",
"fleet.empty": "No tasks open",
"fleet.emptyDesc": "Pick a running task on the left, or use + to add a column.",
"fleet.noRunningTasks": "No running tasks",
"fleet.openInChat": "Open in chat",
"fleet.pin": "Pin column",
"fleet.reply": "Reply",
"fleet.runningTasks": "Running Tasks",
"fleet.rows.one": "Single row",
"fleet.rows.two": "Two rows",
"fleet.runningBoard": "Running Board",
"fleet.status.idle": "Idle",
"fleet.status.paused": "Paused",
"fleet.status.running": "Running",
"fleet.status.scheduled": "Scheduled",
"fleet.tooltip": "View all agents side by side",
"fleet.unpin": "Unpin column",
"gateway.description": "Description",
"gateway.descriptionPlaceholder": "Optional",
"gateway.deviceName": "Device Name",
+32 -32
View File
@@ -120,16 +120,16 @@
"agentDocuments.overwriteConfirm.title": "Overwrite existing documents?",
"agentDocuments.overwriteConfirm.warning": "Existing documents with the same filename will be replaced.",
"agentDocuments.title": "Agent Documents",
"agentImport.action": "Import to workspace",
"agentImport.description": "Fork a copy of this agent into one of your workspaces. The original stays in your personal space — no sync after import.",
"agentImport.failed": "Failed to import agent.",
"agentImport.action": "Copy to Workspace...",
"agentImport.description": "Create an independent copy in a Workspace. The original Agent stays in your personal account.",
"agentImport.failed": "Failed to copy agent.",
"agentImport.modal.configIncluded": "Agent configuration is copied by default.",
"agentImport.modal.confirm": "Import",
"agentImport.modal.confirm": "Copy",
"agentImport.modal.includeHistory": "Copy topics and messages",
"agentImport.modal.includeHistoryDesc": "Optional. Copies this agents conversation history into the new agent.",
"agentImport.modal.knowledgeNotice": "Knowledge bindings and files are not copied yet.",
"agentImport.success": "Agent imported to {{name}}.",
"agentImport.title": "Import to workspace",
"agentImport.success": "Agent copied to {{name}}.",
"agentImport.title": "Copy to Workspace",
"agentInfoDescription.basic.avatar": "Avatar",
"agentInfoDescription.basic.description": "Description",
"agentInfoDescription.basic.name": "Name",
@@ -893,9 +893,9 @@
"storage.actions.copyAgentGroups.button": "Copy To",
"storage.actions.copyAgentGroups.desc": "Copy agent groups and their member agents into another workspace or personal account.",
"storage.actions.copyAgentGroups.title": "Agent Groups Copy",
"storage.actions.copyLobeAI.button": "Copy To",
"storage.actions.copyLobeAI.desc": "Copy agents, including LobeAI, into another workspace or personal account. Topics and messages are optional.",
"storage.actions.copyLobeAI.title": "Agents Copy",
"storage.actions.copyLobeAI.button": "Copy to...",
"storage.actions.copyLobeAI.desc": "Keep the originals and create independent copies in another Workspace or your personal account. Topics and messages are optional.",
"storage.actions.copyLobeAI.title": "Copy Agents",
"storage.actions.export.button": "Export",
"storage.actions.export.exportType.agent": "Export Agent Settings",
"storage.actions.export.exportType.agentWithMessage": "Export Agent and Messages",
@@ -907,12 +907,12 @@
"storage.actions.import.button": "Import",
"storage.actions.import.title": "Import Data",
"storage.actions.title": "Advanced Operations",
"storage.actions.transfer.button": "Transfer To",
"storage.actions.transfer.desc": "Move agents and their data to a workspace you have access to. LobeAI, the default inbox Agent, cannot be transferred; use Copy Agents to copy it to a workspace or personal account instead.",
"storage.actions.transfer.title": "Agents Migration",
"storage.actions.transferAgentGroups.button": "Transfer To",
"storage.actions.transferAgentGroups.desc": "Move agent groups, their members, and group conversation data to a workspace you have access to.",
"storage.actions.transferAgentGroups.title": "Agent Groups Migration",
"storage.actions.transfer.button": "Move to...",
"storage.actions.transfer.desc": "Move agents and their data to another Workspace or your personal account. The originals leave the current space. LobeAI cannot be moved; copy it instead.",
"storage.actions.transfer.title": "Move Agents",
"storage.actions.transferAgentGroups.button": "Move to...",
"storage.actions.transferAgentGroups.desc": "Move groups, member Agents, and group conversation data to another Workspace or your personal account.",
"storage.actions.transferAgentGroups.title": "Move Groups",
"storage.desc": "Current storage usage in the browser",
"storage.embeddings.used": "Vector Storage",
"storage.migration.title": "Data Migration",
@@ -1171,7 +1171,6 @@
"tools.builtins.uninstallConfirm.desc": "Are you sure you want to uninstall {{name}}? This skill will be removed from the current agent.",
"tools.builtins.uninstallConfirm.title": "Uninstall {{name}}",
"tools.builtins.uninstalled": "Uninstalled",
"tools.disabled": "The current model does not support function calls and cannot use the skill",
"tools.composio.addServer": "Add Server",
"tools.composio.authCompleted": "Authentication Completed",
"tools.composio.authFailed": "Authentication Failed",
@@ -1181,9 +1180,6 @@
"tools.composio.disconnect": "Disconnect",
"tools.composio.disconnected": "Disconnected",
"tools.composio.error": "Error",
"tools.composio.remove": "Remove",
"tools.composio.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
"tools.composio.removeConfirm.title": "Remove {{name}}?",
"tools.composio.groupName": "Composio Tools",
"tools.composio.manage": "Manage Composio",
"tools.composio.manageTitle": "Manage Composio Integration",
@@ -1192,6 +1188,9 @@
"tools.composio.oauthRequired": "Please complete OAuth authentication in the new window",
"tools.composio.pendingAuth": "Pending Authentication",
"tools.composio.reauthorize": "Re-authorize",
"tools.composio.remove": "Remove",
"tools.composio.removeConfirm.desc": "{{name}} will be permanently removed from your connected services. This action cannot be undone.",
"tools.composio.removeConfirm.title": "Remove {{name}}?",
"tools.composio.serverCreated": "Server created successfully",
"tools.composio.serverCreatedFailed": "Failed to create server",
"tools.composio.serverRemoved": "Server removed",
@@ -1244,6 +1243,7 @@
"tools.composio.servers.zendesk.readme": "Integrate with Zendesk to manage support tickets and customer interactions. Create, update, and track support requests, access customer data, and streamline your support operations.",
"tools.composio.tools": "tools",
"tools.composio.verifyAuth": "I have completed authentication",
"tools.disabled": "The current model does not support function calls and cannot use the skill",
"tools.lobehubSkill.authorize": "Authorize",
"tools.lobehubSkill.connect": "Connect",
"tools.lobehubSkill.connected": "Connected",
@@ -1598,9 +1598,9 @@
"workspace.general.copyAgentGroups.modal.untitledGroup": "Untitled Agent Group",
"workspace.general.copyLobeAI.modal.back": "Back",
"workspace.general.copyLobeAI.modal.continue": "Continue",
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "Required. Copies the model, prompt, tools, and Agent profile.",
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "Required. Copies the model, prompt, tools, and Agent profile into a new Agent.",
"workspace.general.copyLobeAI.modal.copyOptions.config.title": "Agent configuration",
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "Optional. Copies selected agents topics and messages into the new agents.",
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "Optional. Copies selected Agents topics and messages into the new Agents.",
"workspace.general.copyLobeAI.modal.copyOptions.history.title": "Topics and messages",
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason": "Not supported yet. Reconnect them in the target workspace or personal account after copying.",
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.title": "Knowledge bases and files",
@@ -1612,14 +1612,14 @@
"workspace.general.copyLobeAI.modal.defaultInboxTitle": "LobeAI",
"workspace.general.copyLobeAI.modal.failed": "Failed to copy agents",
"workspace.general.copyLobeAI.modal.includeHistory": "Copy topics and messages",
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "Optional. Copies selected agents conversation history into the new agents.",
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "Optional. Copies selected Agents conversation history into the new Agents.",
"workspace.general.copyLobeAI.modal.loadFailed": "Failed to load agents",
"workspace.general.copyLobeAI.modal.noAgents": "No agents available to copy",
"workspace.general.copyLobeAI.modal.selectAgents": "Select agents to copy.",
"workspace.general.copyLobeAI.modal.selectAgents": "Select Agents to copy. Originals stay where they are.",
"workspace.general.copyLobeAI.modal.selectPlaceholder": "Select workspace or personal account...",
"workspace.general.copyLobeAI.modal.selectTarget": "Choose the target workspace or personal account. Agent configuration is copied by default.",
"workspace.general.copyLobeAI.modal.selectTarget": "Choose where to create the copies. The originals stay where they are.",
"workspace.general.copyLobeAI.modal.selected": "selected",
"workspace.general.copyLobeAI.modal.selectedAgent": "Agent to copy.",
"workspace.general.copyLobeAI.modal.selectedAgent": "This Agent will be copied. The original stays where it is.",
"workspace.general.copyLobeAI.modal.success": "{{count}} agent(s) copied",
"workspace.general.copyLobeAI.modal.title": "Copy Agents",
"workspace.general.copyLobeAI.modal.untitledAgent": "Untitled Agent",
@@ -1695,17 +1695,17 @@
"workspace.general.transferAgentGroups.modal.untitledGroup": "Untitled Agent Group",
"workspace.general.transferAgents.modal.back": "Back",
"workspace.general.transferAgents.modal.continue": "Continue",
"workspace.general.transferAgents.modal.failed": "Failed to transfer agents",
"workspace.general.transferAgents.modal.failed": "Failed to move agents",
"workspace.general.transferAgents.modal.loadFailed": "Failed to load agents",
"workspace.general.transferAgents.modal.noAgents": "No agents in this workspace",
"workspace.general.transferAgents.modal.selectAgents": "Select agents to transfer to {{target}}.",
"workspace.general.transferAgents.modal.selectAgents": "Select Agents to move to {{target}}.",
"workspace.general.transferAgents.modal.selectPlaceholder": "Select workspace or personal account...",
"workspace.general.transferAgents.modal.selectTarget": "Choose a workspace or personal account to transfer agents to.",
"workspace.general.transferAgents.modal.selectTarget": "Choose where to move the Agents. They will leave the current space.",
"workspace.general.transferAgents.modal.selected": "selected",
"workspace.general.transferAgents.modal.selectedAgent": "Agent to transfer to {{target}}.",
"workspace.general.transferAgents.modal.success": "{{count}} agent(s) transferred successfully",
"workspace.general.transferAgents.modal.title": "Transfer Agents",
"workspace.general.transferAgents.modal.transfer": "Transfer {{count}} agent(s)",
"workspace.general.transferAgents.modal.selectedAgent": "This Agent will move to {{target}} and leave the current space.",
"workspace.general.transferAgents.modal.success": "{{count}} agent(s) moved",
"workspace.general.transferAgents.modal.title": "Move Agents",
"workspace.general.transferAgents.modal.transfer": "Move {{count}} agent(s)",
"workspace.general.transferAgents.modal.warning": "Custom plugins may not be available and multi-agent group associations will be removed.",
"workspace.general.transferAgents.personalAccount": "Personal Account",
"workspace.general.transferPrimary.cta": "Transfer Primary Owner",
+6 -4
View File
@@ -89,6 +89,9 @@
"credits.packages.tabs.expired": "Expired",
"credits.packages.tabs.expiredCount": "Expired ({{count}})",
"credits.packages.title": "My Credit Packages",
"credits.topUp.bestValue.cta": "View Ultimate annual",
"credits.topUp.bestValue.savings": "Save ${{savings}} on this purchase",
"credits.topUp.bestValue.title": "{{plan}} annual unlocks the lowest top-up rate: ${{price}} / 1M {{creditLabel}}",
"credits.topUp.cancel": "Cancel",
"credits.topUp.custom": "Custom",
"credits.topUp.freeFeeHint": "Free plan top-ups include a {{fee}} service fee per 1M credits.",
@@ -410,14 +413,13 @@
"referral.rules.backfill.title": "Backfill Invite Code",
"referral.rules.description": "Learn about referral reward program rules",
"referral.rules.expiry": "Credit validity: Available referral credits will be cleared after 100 days of user inactivity",
"referral.rules.missedCode": "Missed invite code: You can <0>backfill</0> within 7 days of registration. After backfilling, you still need to perform a valid action and complete a payment to receive rewards",
"referral.rules.missedCode": "Missed invite code: You can <0>backfill</0> within 7 days of registration. If you have already made a real payment and pass verification, rewards are processed after binding; otherwise they are processed after your first real payment",
"referral.rules.priority": "Credit consumption priority: Free credits → Subscription credits → Referral credits → Top-up credits",
"referral.rules.registration": "Registration method: Invited users register via referral link or enter referral code on registration page",
"referral.rules.reward": "Reward: Referrer and invitee each receive {{reward}}M credits",
"referral.rules.rewardDelay": "Reward processing: Credits will be distributed within 1 hour after the invitee completes a payment and passes verification",
"referral.rules.rewardDelay": "Reward processing: Credits are granted after the invitee completes a real payment and passes verification",
"referral.rules.title": "Program Rules",
"referral.rules.validInvitation": "Valid invitation: Invitee registers with your referral code, performs one valid action, and completes a payment (subscription or credit top-up)",
"referral.rules.validOperation": "Valid action criteria: Send one message on Chat page, or generate one image on image page",
"referral.rules.validInvitation": "Valid invitation: Invitee registers with your referral code and completes a real payment (subscription or personal credit top-up)",
"referral.stats.availableBalance": "Available Balance",
"referral.stats.description": "View your referral statistics",
"referral.stats.title": "Referral Overview",
+3
View File
@@ -143,6 +143,7 @@
"management.status.archived": "Archived",
"management.status.completed": "Completed",
"management.status.failed": "Failed",
"management.status.idle": "Idle",
"management.status.paused": "Paused",
"management.status.running": "Running",
"management.status.waitingForHuman": "Awaiting input",
@@ -154,6 +155,8 @@
"projectStatus.failed_other": "{{count}} failed topics",
"projectStatus.loading_one": "{{count}} loading topic",
"projectStatus.loading_other": "{{count}} loading topics",
"projectStatus.unread_one": "{{count}} topic with unread reply",
"projectStatus.unread_other": "{{count}} topics with unread replies",
"projectStatus.waitingForHuman_one": "{{count}} topic awaiting input",
"projectStatus.waitingForHuman_other": "{{count}} topics awaiting input",
"renameModal.description": "Keep it short and easy to recognize.",
+15 -15
View File
@@ -252,20 +252,20 @@
"channel.wechatTips": "请将微信更新至最新版本并重新启动。ClawBot 插件正在逐步推广,请前往设置 > 插件确认是否已获得访问权限。",
"channel.wechatUserId": "微信用户 ID",
"channel.wechatUserIdHint": "通过授权流程返回的微信账号标识符。",
"transfer.button": "移",
"transfer.confirm.botChannels": "移后可能需要刷新机器人频道连接",
"transfer.confirm.chatGroups": "多代理组关联将被移除",
"transfer.confirm.desc": "这将把代理及所有相关数据(题、消息、文件等)移到目标工作区。",
"transfer.confirm.plugins": "自定义插件可能无法在目标工作区使用",
"transfer.confirm.title": "转移代理",
"transfer.confirm.warning": "以下功能无法转移",
"transfer.copyTo": "复制到",
"transfer.desc": "将此代理转移到另一个工作区或您的个人账户。",
"transfer.error": "代理转移失败",
"transfer.button": "移",
"transfer.confirm.botChannels": "移后可能需要重新刷新机器人频道连接",
"transfer.confirm.chatGroups": "多助理群组关联将被移除",
"transfer.confirm.desc": "这会把助理及相关数据(题、消息、文件等)移到目标空间。",
"transfer.confirm.plugins": "自定义技能可能无法在目标空间使用",
"transfer.confirm.title": "移动助理",
"transfer.confirm.warning": "部分关联不会一起移动",
"transfer.copyTo": "复制到...",
"transfer.desc": "将此助理移动到另一个空间或您的个人账户。",
"transfer.error": "移动助理失败",
"transfer.personalAccount": "个人账户",
"transfer.searchWorkspace": "搜索工作区...",
"transfer.selectTarget": "将代理转移到",
"transfer.success": "代理转移成功",
"transfer.title": "移",
"transfer.transferTo": "转移到"
"transfer.searchWorkspace": "搜索空间...",
"transfer.selectTarget": "将助理移动到",
"transfer.success": "助理已移动",
"transfer.title": "移",
"transfer.transferTo": "移动到..."
}
+1
View File
@@ -44,6 +44,7 @@
"builtinCopilot": "内置 Copilot",
"chatList.expandMessage": "展开消息",
"chatList.longMessageDetail": "查看详情",
"chatList.refreshing": "正在获取最新消息...",
"chatMode.agent": "智能",
"chatMode.agentCap.env": "运行环境",
"chatMode.agentCap.files": "文件访问",
+1 -13
View File
@@ -4,7 +4,6 @@
"workingDirectory.addFolderTitle": "添加工作目录",
"workingDirectory.agentDescription": "该助手下所有对话的默认工作目录",
"workingDirectory.agentLevel": "代理工作目录",
"workingDirectory.bareWorktree": "bare",
"workingDirectory.branchSearchPlaceholder": "搜索分支",
"workingDirectory.branchesEmpty": "暂无本地分支",
"workingDirectory.branchesHeading": "分支",
@@ -14,19 +13,15 @@
"workingDirectory.checkoutAction": "切换分支",
"workingDirectory.checkoutFailed": "切换分支失败",
"workingDirectory.chooseDifferentFolder": "选择其他文件夹",
"workingDirectory.clean": "clean",
"workingDirectory.clear": "清除目录",
"workingDirectory.createBranchAction": "检出新分支…",
"workingDirectory.createBranchTitle": "创建新分支",
"workingDirectory.current": "当前工作目录",
"workingDirectory.currentWorktree": "current",
"workingDirectory.deleteBranchAction": "删除分支",
"workingDirectory.deleteBranchConfirm": "确定删除分支「{{name}}」吗?此操作会永久删除该分支,包括尚未合并的提交。",
"workingDirectory.deleteBranchTitle": "删除分支",
"workingDirectory.deleteFailed": "删除失败",
"workingDirectory.detachedHead": "游离 HEAD,当前提交 {{sha}}",
"workingDirectory.detachedHeadShort": "detached@{{sha}}",
"workingDirectory.detachedWorktree": "detached",
"workingDirectory.diffStatTooltip": "新增 {{added}} · 修改 {{modified}} · 删除 {{deleted}}",
"workingDirectory.filesAdded": "新增",
"workingDirectory.filesDeleted": "删除",
@@ -34,7 +29,6 @@
"workingDirectory.filesLoading": "加载中…",
"workingDirectory.filesModified": "修改",
"workingDirectory.ghMissing": "安装并登录 GitHub CLIgh)即可显示关联的 Pull Request",
"workingDirectory.lockedWorktree": "locked",
"workingDirectory.newBranchPlaceholder": "feature/新分支名称",
"workingDirectory.noRecent": "暂无最近目录",
"workingDirectory.notSet": "点击设置工作目录",
@@ -42,7 +36,6 @@
"workingDirectory.pathNotExist": "该设备上不存在此路径",
"workingDirectory.placeholder": "输入目录路径,如 /Users/name/projects",
"workingDirectory.prTooltipWithExtra": "{{title}}(此分支还有 {{count}} 个开放 PR",
"workingDirectory.prunableWorktree": "prunable",
"workingDirectory.pullAction": "点击从 {{upstream}} 拉取 {{count}} 个提交",
"workingDirectory.pullFailed": "拉取失败",
"workingDirectory.pullInProgress": "拉取中…",
@@ -66,10 +59,5 @@
"workingDirectory.topicLevel": "对话覆盖",
"workingDirectory.topicOverride": "为当前对话覆盖设置",
"workingDirectory.uncommittedChanges_one": "未提交的更改:{{count}} 个文件",
"workingDirectory.uncommittedChanges_other": "未提交的更改:{{count}} 个文件",
"workingDirectory.worktreeCount": "{{count}} 个 worktree",
"workingDirectory.worktreeSwitchDescription": "切换当前对话的 working directory",
"workingDirectory.worktreeUnavailable": "不可用",
"workingDirectory.worktreesEmpty": "未发现 worktree",
"workingDirectory.worktreesHeading": "选择 Worktree"
"workingDirectory.uncommittedChanges_other": "未提交的更改:{{count}} 个文件"
}
+7 -1
View File
@@ -3,18 +3,24 @@
"fleet.allShown": "所有运行中的任务都已展示",
"fleet.backToHome": "返回首页",
"fleet.closeColumn": "关闭此列",
"fleet.collapseReply": "收起",
"fleet.createTask": "创建任务",
"fleet.dragHint": "拖动以调整位置",
"fleet.empty": "暂无打开的任务",
"fleet.emptyDesc": "从左侧选择一个运行中的任务,或点击 + 添加一列。",
"fleet.noRunningTasks": "暂无运行中的任务",
"fleet.openInChat": "在对话中打开",
"fleet.pin": "固定此列",
"fleet.reply": "回复",
"fleet.runningTasks": "运行中的任务",
"fleet.rows.one": "单排",
"fleet.rows.two": "双排",
"fleet.runningBoard": "运行看板",
"fleet.status.idle": "空闲",
"fleet.status.paused": "已暂停",
"fleet.status.running": "运行中",
"fleet.status.scheduled": "已排期",
"fleet.tooltip": "并排查看所有助手",
"fleet.unpin": "取消固定",
"gateway.description": "描述",
"gateway.descriptionPlaceholder": "可选",
"gateway.deviceName": "设备名称",
+51 -51
View File
@@ -120,16 +120,16 @@
"agentDocuments.overwriteConfirm.title": "要覆盖现有文档吗?",
"agentDocuments.overwriteConfirm.warning": "同名文档将被替换。",
"agentDocuments.title": "助理文档",
"agentImport.action": "导入到工作区…",
"agentImport.description": "将此代理的副本分叉到您的一个工作区中。原始代理保留在您的个人空间中——导入后不会同步。",
"agentImport.failed": "导入代理失败。",
"agentImport.modal.configIncluded": "理配置默认会被复制。",
"agentImport.modal.confirm": "导入",
"agentImport.modal.includeHistory": "复制题和消息",
"agentImport.modal.includeHistoryDesc": "可选。将此理的对话历史复制到新理中。",
"agentImport.action": "复制到空间...",
"agentImport.description": "在空间中创建一份独立副本。原助理会保留在个人账户中。",
"agentImport.failed": "复制助理失败。",
"agentImport.modal.configIncluded": "理配置默认会被复制。",
"agentImport.modal.confirm": "复制",
"agentImport.modal.includeHistory": "复制题和消息",
"agentImport.modal.includeHistoryDesc": "可选。将此理的对话历史复制到新理中。",
"agentImport.modal.knowledgeNotice": "知识绑定和文件尚未复制。",
"agentImport.success": "理已导入到 {{name}}。",
"agentImport.title": "导入到工作区",
"agentImport.success": "理已复制到 {{name}}。",
"agentImport.title": "复制到空间",
"agentInfoDescription.basic.avatar": "头像",
"agentInfoDescription.basic.description": "描述",
"agentInfoDescription.basic.name": "名称",
@@ -893,9 +893,9 @@
"storage.actions.copyAgentGroups.button": "复制到",
"storage.actions.copyAgentGroups.desc": "将代理组及其成员代理复制到另一个工作区或个人账户。",
"storage.actions.copyAgentGroups.title": "代理组复制",
"storage.actions.copyLobeAI.button": "复制到",
"storage.actions.copyLobeAI.desc": "将代理(包括 LobeAI)复制到另一个工作区或个人账户。主题和消息为可选项。",
"storage.actions.copyLobeAI.title": "代理复制",
"storage.actions.copyLobeAI.button": "复制到...",
"storage.actions.copyLobeAI.desc": "保留原助理,并在另一个空间或个人账户中创建独立副本。话题和消息为可选项。",
"storage.actions.copyLobeAI.title": "复制助理",
"storage.actions.export.button": "导出",
"storage.actions.export.exportType.agent": "导出助理设定",
"storage.actions.export.exportType.agentWithMessage": "导出助理和消息",
@@ -907,12 +907,12 @@
"storage.actions.import.button": "导入",
"storage.actions.import.title": "导入数据",
"storage.actions.title": "高级操作",
"storage.actions.transfer.button": "转移到",
"storage.actions.transfer.desc": "将理及其数据转移到您有权限访问的工作区。LobeAI(默认收件箱代理)无法转移;请使用“复制代理”将其复制到工作区或个人账户。",
"storage.actions.transfer.title": "代理迁移",
"storage.actions.transferAgentGroups.button": "转移到",
"storage.actions.transferAgentGroups.desc": "将代理组、成员组对话数据转移到您有权限访问的工作区。",
"storage.actions.transferAgentGroups.title": "代理组迁移",
"storage.actions.transfer.button": "移动到...",
"storage.actions.transfer.desc": "将理及其数据移动到另一个空间或个人账户。原助理会离开当前空间。LobeAI 无法移动,请改用复制。",
"storage.actions.transfer.title": "移动助理",
"storage.actions.transferAgentGroups.button": "移动到...",
"storage.actions.transferAgentGroups.desc": "将组、成员助理和群组对话数据移动到另一个空间或个人账户。",
"storage.actions.transferAgentGroups.title": "移动群组",
"storage.desc": "当前浏览器中的存储用量",
"storage.embeddings.used": "向量存储",
"storage.migration.title": "数据迁移",
@@ -1171,7 +1171,6 @@
"tools.builtins.uninstallConfirm.desc": "确定要卸载 {{name}} 吗?此技能将从当前助手中移除。",
"tools.builtins.uninstallConfirm.title": "卸载 {{name}}",
"tools.builtins.uninstalled": "已卸载",
"tools.disabled": "当前模型不支持函数调用,无法使用技能",
"tools.composio.addServer": "添加服务器",
"tools.composio.authCompleted": "认证完成",
"tools.composio.authFailed": "认证失败",
@@ -1181,9 +1180,6 @@
"tools.composio.disconnect": "断开连接",
"tools.composio.disconnected": "已断开连接",
"tools.composio.error": "错误",
"tools.composio.remove": "移除",
"tools.composio.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。",
"tools.composio.removeConfirm.title": "移除 {{name}}",
"tools.composio.groupName": "Composio 工具",
"tools.composio.manage": "管理 Composio",
"tools.composio.manageTitle": "管理 Composio 集成",
@@ -1192,6 +1188,9 @@
"tools.composio.oauthRequired": "请在新窗口中完成 OAuth 认证",
"tools.composio.pendingAuth": "待认证",
"tools.composio.reauthorize": "重新授权",
"tools.composio.remove": "移除",
"tools.composio.removeConfirm.desc": "{{name}} 将从您的已连接服务中永久移除,此操作不可撤销。",
"tools.composio.removeConfirm.title": "移除 {{name}}",
"tools.composio.serverCreated": "服务器创建成功",
"tools.composio.serverCreatedFailed": "服务器创建失败",
"tools.composio.serverRemoved": "服务器已删除",
@@ -1244,6 +1243,7 @@
"tools.composio.servers.zendesk.readme": "集成 Zendesk 以管理支持工单和客户互动。创建、更新和跟踪支持请求,访问客户数据,并优化您的客服流程。",
"tools.composio.tools": "个工具",
"tools.composio.verifyAuth": "我已完成认证",
"tools.disabled": "当前模型不支持函数调用,无法使用技能",
"tools.lobehubSkill.authorize": "授权",
"tools.lobehubSkill.connect": "连接",
"tools.lobehubSkill.connected": "已连接",
@@ -1598,31 +1598,31 @@
"workspace.general.copyAgentGroups.modal.untitledGroup": "未命名代理组",
"workspace.general.copyLobeAI.modal.back": "返回",
"workspace.general.copyLobeAI.modal.continue": "继续",
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "必选。复制模型、提示、工具和代理配置文件。",
"workspace.general.copyLobeAI.modal.copyOptions.config.title": "理配置",
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "可选。将选定代理的题和消息复制到新理中。",
"workspace.general.copyLobeAI.modal.copyOptions.history.title": "题和消息",
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason": "尚不支持。复制后重新连接到目标工作区或个人账户。",
"workspace.general.copyLobeAI.modal.copyOptions.config.desc": "必选。模型、提示、工具和助理档案复制到新助理中。",
"workspace.general.copyLobeAI.modal.copyOptions.config.title": "理配置",
"workspace.general.copyLobeAI.modal.copyOptions.history.desc": "可选。将所选助理的题和消息复制到新理中。",
"workspace.general.copyLobeAI.modal.copyOptions.history.title": "题和消息",
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason": "尚不支持。复制后可在目标空间或个人账户中重新连接。",
"workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.title": "知识库和文件",
"workspace.general.copyLobeAI.modal.copyOptions.optional": "可选",
"workspace.general.copyLobeAI.modal.copyOptions.required": "默认选中",
"workspace.general.copyLobeAI.modal.copyOptions.title": "复制选项",
"workspace.general.copyLobeAI.modal.copyOptions.unsupported": "不可用",
"workspace.general.copyLobeAI.modal.create": "复制 {{count}} 个理",
"workspace.general.copyLobeAI.modal.create": "复制 {{count}} 个理",
"workspace.general.copyLobeAI.modal.defaultInboxTitle": "LobeAI",
"workspace.general.copyLobeAI.modal.failed": "复制理失败",
"workspace.general.copyLobeAI.modal.includeHistory": "复制题和消息",
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "可选。将选定代理的对话历史复制到新理中。",
"workspace.general.copyLobeAI.modal.loadFailed": "加载理失败",
"workspace.general.copyLobeAI.modal.noAgents": "没有可复制的理",
"workspace.general.copyLobeAI.modal.selectAgents": "选择要复制的代理。",
"workspace.general.copyLobeAI.modal.selectPlaceholder": "选择工作区或个人账户...",
"workspace.general.copyLobeAI.modal.selectTarget": "选择目标工作区或个人账户。代理配置默认会被复制。",
"workspace.general.copyLobeAI.modal.failed": "复制理失败",
"workspace.general.copyLobeAI.modal.includeHistory": "复制题和消息",
"workspace.general.copyLobeAI.modal.includeHistoryDesc": "可选。将所选助理的对话历史复制到新理中。",
"workspace.general.copyLobeAI.modal.loadFailed": "加载理失败",
"workspace.general.copyLobeAI.modal.noAgents": "没有可复制的理",
"workspace.general.copyLobeAI.modal.selectAgents": "选择要复制的助理。原助理会保留在原位置。",
"workspace.general.copyLobeAI.modal.selectPlaceholder": "选择空间或个人账户...",
"workspace.general.copyLobeAI.modal.selectTarget": "选择副本创建位置。原助理会保留在原位置。",
"workspace.general.copyLobeAI.modal.selected": "已选",
"workspace.general.copyLobeAI.modal.selectedAgent": "要复制的代理。",
"workspace.general.copyLobeAI.modal.success": "{{count}} 个理已复制",
"workspace.general.copyLobeAI.modal.title": "复制理",
"workspace.general.copyLobeAI.modal.untitledAgent": "未命名理",
"workspace.general.copyLobeAI.modal.selectedAgent": "此助理会被复制,原助理会保留在原位置。",
"workspace.general.copyLobeAI.modal.success": "{{count}} 个理已复制",
"workspace.general.copyLobeAI.modal.title": "复制理",
"workspace.general.copyLobeAI.modal.untitledAgent": "未命名理",
"workspace.general.delete.confirm.content": "此操作无法撤销。输入工作区名称 \"{{name}}\" 以确认。",
"workspace.general.delete.confirm.continue": "继续",
"workspace.general.delete.confirm.mismatch": "名称不匹配。删除已中止。",
@@ -1695,18 +1695,18 @@
"workspace.general.transferAgentGroups.modal.untitledGroup": "未命名代理组",
"workspace.general.transferAgents.modal.back": "返回",
"workspace.general.transferAgents.modal.continue": "继续",
"workspace.general.transferAgents.modal.failed": "转移代理失败",
"workspace.general.transferAgents.modal.loadFailed": "加载理失败",
"workspace.general.transferAgents.modal.noAgents": "此工作区中没有理",
"workspace.general.transferAgents.modal.selectAgents": "选择要移到 {{target}} 的理。",
"workspace.general.transferAgents.modal.selectPlaceholder": "选择工作区或个人账户...",
"workspace.general.transferAgents.modal.selectTarget": "选择一个工作区或个人账户以转移代理。",
"workspace.general.transferAgents.modal.failed": "移动助理失败",
"workspace.general.transferAgents.modal.loadFailed": "加载理失败",
"workspace.general.transferAgents.modal.noAgents": "此空间中没有理",
"workspace.general.transferAgents.modal.selectAgents": "选择要移到 {{target}} 的理。",
"workspace.general.transferAgents.modal.selectPlaceholder": "选择空间或个人账户...",
"workspace.general.transferAgents.modal.selectTarget": "选择助理移动到哪里。原助理会离开当前空间。",
"workspace.general.transferAgents.modal.selected": "已选",
"workspace.general.transferAgents.modal.selectedAgent": "要转移到 {{target}} 的代理。",
"workspace.general.transferAgents.modal.success": "{{count}} 个理已成功转移",
"workspace.general.transferAgents.modal.title": "转移代理",
"workspace.general.transferAgents.modal.transfer": "移 {{count}} 个理",
"workspace.general.transferAgents.modal.warning": "自定义插件可能不可用,多代理组关联将被移除。",
"workspace.general.transferAgents.modal.selectedAgent": "此助理会移动到 {{target}},并离开当前空间。",
"workspace.general.transferAgents.modal.success": "{{count}} 个理已移",
"workspace.general.transferAgents.modal.title": "移动助理",
"workspace.general.transferAgents.modal.transfer": "移 {{count}} 个理",
"workspace.general.transferAgents.modal.warning": "自定义技能可能不可用,多助理群组关联将被移除。",
"workspace.general.transferAgents.personalAccount": "个人账户",
"workspace.general.transferPrimary.cta": "转移主要所有者",
"workspace.general.transferPrimary.description": "将主要所有权转移给另一个所有者。新主要所有者将接管此工作区的计费和主要权限。",
+6 -4
View File
@@ -89,6 +89,9 @@
"credits.packages.tabs.expired": "已过期",
"credits.packages.tabs.expiredCount": "已过期({{count}}",
"credits.packages.title": "我的积分包",
"credits.topUp.bestValue.cta": "查看 Ultimate 年费",
"credits.topUp.bestValue.savings": "本次可节省 ${{savings}}",
"credits.topUp.bestValue.title": "{{plan}}年费享最低充值价:${{price}} / 每百万 {{creditLabel}}",
"credits.topUp.cancel": "取消",
"credits.topUp.custom": "自定义",
"credits.topUp.freeFeeHint": "免费计划充值每百万积分包含 {{fee}} 手续费。",
@@ -410,14 +413,13 @@
"referral.rules.backfill.title": "补填邀请码",
"referral.rules.description": "了解推荐奖励计划规则",
"referral.rules.expiry": "积分有效期:用户 100 天未活跃后,返利积分将被清除",
"referral.rules.missedCode": "忘记填写邀请码:注册七天内可以<0>补填邀请码</0>,补填后需完成一次有效操作并完成付费才可获得奖励",
"referral.rules.missedCode": "忘记填写邀请码:注册七天内可以<0>补填邀请码</0>。如果已完成真实付费并通过审核,绑定后会处理奖励;否则会在首次真实付费后处理奖励",
"referral.rules.priority": "积分使用优先级:免费积分 → 订阅积分 → 返利积分 → 充值积分",
"referral.rules.registration": "注册方式:被邀请用户通过推荐链接注册或在注册页输入推荐码",
"referral.rules.reward": "奖励:邀请人和被邀请人各获得 {{reward}}M 积分",
"referral.rules.rewardDelay": "奖励处理:被邀请人付费且审核通过后,积分将在 1 小时内发放",
"referral.rules.rewardDelay": "奖励处理:被邀请人完成真实付费并通过审核后发放积分",
"referral.rules.title": "计划规则",
"referral.rules.validInvitation": "有效邀请:被邀请人使用您的推荐码注册、完成一次有效操作,并完成付费(订阅或积分充值)",
"referral.rules.validOperation": "有效操作标准:在对话页发送一条消息,或在图片页生成一张图片",
"referral.rules.validInvitation": "有效邀请:被邀请人使用您的推荐码注册,并完成真实付费(订阅或个人积分充值)",
"referral.stats.availableBalance": "可用余额",
"referral.stats.description": "查看您的推荐统计数据",
"referral.stats.title": "推荐概览",
+3
View File
@@ -143,6 +143,7 @@
"management.status.archived": "已归档",
"management.status.completed": "已完成",
"management.status.failed": "已失败",
"management.status.idle": "空闲中",
"management.status.paused": "已暂停",
"management.status.running": "运行中",
"management.status.waitingForHuman": "等待响应",
@@ -154,6 +155,8 @@
"projectStatus.failed_other": "{{count}} 个错误话题",
"projectStatus.loading_one": "{{count}} 个加载中话题",
"projectStatus.loading_other": "{{count}} 个加载中话题",
"projectStatus.unread_one": "{{count}} 个有未读回复的话题",
"projectStatus.unread_other": "{{count}} 个有未读回复的话题",
"projectStatus.waitingForHuman_one": "{{count}} 个等待处理的话题",
"projectStatus.waitingForHuman_other": "{{count}} 个等待处理的话题",
"renameModal.description": "保持简短且易于识别。",
@@ -159,7 +159,7 @@ describe('AgentStreamClient', () => {
// First message is auth, second is resume
expect(ws.sent).toHaveLength(2);
expect(JSON.parse(ws.sent[1])).toEqual({ lastEventId: '', type: 'resume' });
expect(JSON.parse(ws.sent[1])).toEqual({ lastEventId: '', type: 'resume', wantStatus: true });
});
it('should not connect if already connected', async () => {
@@ -267,7 +267,7 @@ describe('AgentStreamClient', () => {
// Resume should use the tracked lastEventId
const resumeMsg = JSON.parse(ws2.sent[1]);
expect(resumeMsg).toEqual({ lastEventId: 'evt-5', type: 'resume' });
expect(resumeMsg).toEqual({ lastEventId: 'evt-5', type: 'resume', wantStatus: true });
});
it('should disconnect on agent_runtime_end', async () => {
@@ -319,6 +319,109 @@ describe('AgentStreamClient', () => {
});
});
// Regression guard for LOBE-10443: a fresh subscriber (no lastEventId) on a
// hibernated DO replays zero events. The client must NOT guess "completed"
// from silence (the old 3s timeout did, which cleared the shared
// runningOperation and cancelled the run on every device). Completion is now
// driven purely by the DO's authoritative `resume_complete` status.
describe('resume_complete (authoritative status)', () => {
async function connectAndAuthResume(client: AgentStreamClient): Promise<MockWebSocket> {
client.connect();
await vi.advanceTimersByTimeAsync(1);
const ws = getLatestWs();
ws.simulateMessage({ type: 'auth_success' });
return ws;
}
it('never auto-completes from silence — no resume_complete, no events', async () => {
const client = createClient({ resumeOnConnect: true });
const onComplete = vi.fn();
client.on('session_complete', onComplete);
await connectAndAuthResume(client);
// DO is silent (hibernated buffer, slow status). Far past the old 3s window.
await vi.advanceTimersByTimeAsync(30_000);
expect(onComplete).not.toHaveBeenCalled();
expect(client.connectionStatus).toBe('connected');
});
it('does NOT complete when DO reports status running', async () => {
const client = createClient({ resumeOnConnect: true });
const onComplete = vi.fn();
client.on('session_complete', onComplete);
const ws = await connectAndAuthResume(client);
// DO replayed nothing (hibernated buffer) but tells us the run is alive.
ws.simulateMessage({ status: 'running', type: 'resume_complete' });
await vi.advanceTimersByTimeAsync(5000);
expect(onComplete).not.toHaveBeenCalled();
expect(client.connectionStatus).toBe('connected');
});
it('still streams live events after a running resume_complete', async () => {
const client = createClient({ resumeOnConnect: true });
const events: any[] = [];
client.on('agent_event', (e) => events.push(e));
const ws = await connectAndAuthResume(client);
ws.simulateMessage({ status: 'running', type: 'resume_complete' });
ws.simulateMessage({
event: {
data: { content: 'live' },
operationId: 'op-123',
stepIndex: 0,
timestamp: 1,
type: 'stream_chunk',
},
id: 'evt-9',
type: 'agent_event',
});
expect(events).toHaveLength(1);
expect(events[0].data.content).toBe('live');
});
it('completes when DO reports a terminal status', async () => {
const client = createClient({ resumeOnConnect: true });
const onComplete = vi.fn();
client.on('session_complete', onComplete);
const ws = await connectAndAuthResume(client);
ws.simulateMessage({ status: 'completed', type: 'resume_complete' });
expect(onComplete).toHaveBeenCalledOnce();
expect(client.connectionStatus).toBe('disconnected');
});
it('flushes replayed events before completing on a terminal status', async () => {
const client = createClient({ resumeOnConnect: true });
const events: any[] = [];
const order: string[] = [];
client.on('agent_event', (e) => {
events.push(e);
order.push('event');
});
client.on('session_complete', () => order.push('complete'));
const ws = await connectAndAuthResume(client);
// Buffered during resume replay…
ws.simulateMessage({
event: { data: {}, operationId: 'op-123', stepIndex: 0, timestamp: 1, type: 'step_start' },
id: 'evt-1',
type: 'agent_event',
});
// …then the terminal authoritative status.
ws.simulateMessage({ status: 'completed', type: 'resume_complete' });
expect(events).toHaveLength(1);
expect(order).toEqual(['event', 'complete']);
});
});
describe('heartbeat', () => {
it('should send heartbeats at 30s intervals', async () => {
const client = createClient();
+49 -14
View File
@@ -15,7 +15,6 @@ const INITIAL_RECONNECT_DELAY = 1000; // 1s
const MAX_RECONNECT_DELAY = 30_000; // 30s
const MAX_MISSED_HEARTBEATS = 3;
const RESUME_FLUSH_DELAY = 500; // 500ms debounce after last resume event
const RESUME_TIMEOUT = 3000; // 3s: if no events after resume request, session is already done
// ─── Typed Event Emitter (browser-compatible, no node:events) ───
@@ -235,24 +234,29 @@ export class AgentStreamClient extends TypedEmitter {
// Enter resume mode only for explicit reconnect scenarios (page reload).
// Buffer all events until resume replay completes, then deduplicate and emit.
// This is NOT enabled for normal first-connect to avoid delaying live streaming.
//
// The replay is terminated by an authoritative `resume_complete` (the
// DO's stored status, which survives hibernation) — that message is
// what exits resume mode and decides completion. We deliberately do
// NOT arm a timeout to guess completion from silence: an empty replay
// no longer means "finished" (the DO may simply have hibernated its
// event buffer), and guessing was exactly the LOBE-10443 multi-device
// false-cancel bug. If `resume_complete` never arrives (e.g. a
// rolled-back DO that predates LOBE-10443), we just keep waiting — a
// safe, recoverable state, with heartbeat loss still forcing reconnect
// — instead of cancelling a live run.
if (this.resumeOnConnect && !this.lastEventId) {
this.resumeMode = true;
this.resumeBuffer = [];
// Safety timeout: if no events arrive after resume, the session has already
// completed and the DO has nothing to replay. Exit resume mode and signal completion.
this.resumeFlushTimer = setTimeout(() => {
if (this.resumeMode && this.resumeBuffer.length === 0) {
this.resumeMode = false;
this.sessionEnded = true;
this.emit('session_complete');
this.disconnect();
}
}, RESUME_TIMEOUT);
}
// Request all buffered events (covers events pushed before WS connected)
this.sendMessage({ lastEventId: this.lastEventId, type: 'resume' });
// Request all buffered events (covers events pushed before WS connected).
// `wantStatus` opts into the authoritative `resume_complete` reply
// (LOBE-10443): this client knows how to consume it, so a current
// gateway will hand back the real session status. Legacy gateways
// ignore the flag and just replay — we then rely on live events, never
// guessing completion from silence.
this.sendMessage({ lastEventId: this.lastEventId, type: 'resume', wantStatus: true });
this.emit('connected');
break;
}
@@ -303,6 +307,37 @@ export class AgentStreamClient extends TypedEmitter {
break;
}
case 'resume_complete': {
// Authoritative status from the DO, sent right after resume replay
// (LOBE-10443) — this is the definitive end-of-replay marker, so
// cancel the pending debounce flush and act on it immediately.
if (this.resumeFlushTimer) {
clearTimeout(this.resumeFlushTimer);
this.resumeFlushTimer = null;
}
// Emit any events buffered during resume, in order, before deciding.
if (this.resumeMode) {
this.flushResumeBuffer();
}
const terminal =
message.status === 'completed' ||
message.status === 'error' ||
message.status === 'interrupted';
if (terminal) {
this.sessionEnded = true;
this.emit('session_complete');
this.disconnect();
}
// Non-terminal (running / waiting_input / waiting_confirmation): the
// run is alive. Stay connected and keep streaming live events — do NOT
// fire session_complete. This is the core fix: a fresh subscriber on a
// hibernated DO no longer false-completes and clears runningOperation.
break;
}
case 'session_complete': {
this.sessionEnded = true;
// Flush any buffered resume events before disconnecting
@@ -162,6 +162,12 @@ export interface AuthMessage {
export interface ResumeMessage {
lastEventId: string;
type: 'resume';
/**
* Opt into the authoritative `resume_complete` reply (LOBE-10443). Set by
* this client so a current gateway hands back the stored session status;
* legacy gateways ignore it and replay only.
*/
wantStatus?: boolean;
}
export interface HeartbeatMessage {
@@ -229,12 +235,37 @@ export interface SessionCompleteMessage {
type: 'session_complete';
}
/**
* Authoritative session status. Mirrors the gateway DO's `SessionStatus`.
*/
export type SessionStatus =
| 'running'
| 'waiting_input'
| 'waiting_confirmation'
| 'completed'
| 'error'
| 'interrupted';
/**
* Server Client: sent right after a `resume` replay, carrying the DO's
* authoritative `status` from storage. Because the DO's in-memory event buffer
* is wiped by hibernation, an empty replay is ambiguous the run may still be
* alive. This message resolves that ambiguity so the client never guesses
* "completed" from silence (which would clear the shared `runningOperation` and
* cancel the run on every device). See LOBE-10443.
*/
export interface ResumeCompleteMessage {
status: SessionStatus;
type: 'resume_complete';
}
export type ServerMessage =
| AgentEventMessage
| AuthExpiredMessage
| AuthFailedMessage
| AuthSuccessMessage
| HeartbeatAckMessage
| ResumeCompleteMessage
| SessionCompleteMessage;
// ─── Connection Status ───
@@ -5,9 +5,11 @@
* Delegates to AgentManagerRuntime for actual implementation.
*/
import { AgentManagerRuntime } from '@lobechat/agent-manager-runtime';
import type { BuiltinToolContext, BuiltinToolResult } from '@lobechat/types';
import type { BuiltinToolContext, BuiltinToolResult, ToolAfterCallContext } from '@lobechat/types';
import { BaseExecutor } from '@lobechat/types';
import { getAgentStoreState } from '@/store/agent';
import { getChatStoreState } from '@/store/chat';
import { agentService } from '@/services/agent';
import { discoverService } from '@/services/discover';
@@ -20,6 +22,13 @@ import type {
} from './types';
import { AgentBuilderApiName, AgentBuilderIdentifier } from './types';
// Write APIs that mutate agent state and require a client-side store refresh.
const WRITE_APIS = new Set<string>([
AgentBuilderApiName.updateAgentConfig,
AgentBuilderApiName.updatePrompt,
AgentBuilderApiName.installPlugin,
]);
const runtime = new AgentManagerRuntime({
agentService,
discoverService,
@@ -94,6 +103,21 @@ class AgentBuilderExecutor extends BaseExecutor<typeof AgentBuilderApiName> {
return runtime.installPlugin(agentId, params);
};
// ==================== Hooks ====================
onAfterCall = async ({ apiName, result }: ToolAfterCallContext): Promise<void> => {
if (!result.success || !WRITE_APIS.has(apiName)) return;
// AgentBuilderProvider keeps chatStore.activeAgentId in sync with the agent
// being edited. After a successful write the server has already updated the
// DB, so we re-fetch the config here to update the Zustand store and
// re-render the left-sidebar without requiring a page reload.
const editingAgentId = getChatStoreState().activeAgentId;
if (!editingAgentId) return;
await getAgentStoreState().internal_refreshAgentConfig(editingAgentId);
};
}
export const agentBuilderExecutor = new AgentBuilderExecutor();
@@ -0,0 +1,455 @@
import { describe, expect, it } from 'vitest';
import { parse } from '../parse';
import type { Message } from '../types/shared';
/**
* Dual-form message-chain reader (LOBE-10445, phase 1)
*
* Two persisted chain shapes must parse to equivalent display output:
*
* - **tool-anchored (old)**: the next step's assistant hangs off the
* previous step's *last tool result* (`assistant.parent = lastToolMsgIdEver`).
* - **assistant-anchored (new)**: the next step's assistant hangs off the
* *most recent non-tool message* (`assistant.parent = prev assistant/user`),
* so a tool result and the next assistant are siblings under one assistant.
*
* Invariants the role-aware reader enforces:
* 1. a `tool` message is always inline data of its assistant (both forms).
* 2. a branch is 2 *non-tool* siblings under one parent.
*
* Under both invariants the five fixture classes below old, new, mixed,
* parallel-tool, regenerate-branch must produce the same active flatList.
*/
interface StepSpec {
content?: string;
id: string;
/** tool_call_ids; each spawns a tool-result message `${id}__${tc}` */
tools?: string[];
}
type Form = 'old' | 'new';
/**
* Build a single linear multi-step assistant turn in either chain form.
* Tool-result messages always parent to their calling assistant; only the
* *next assistant's* parent differs between forms.
*/
const buildTurn = (
userId: string,
steps: StepSpec[],
form: Form,
agentId = 'agent-a',
): Message[] => {
const msgs: Message[] = [];
let clock = 0;
msgs.push({ content: 'q', createdAt: clock++, id: userId, role: 'user', updatedAt: 0 });
let prevNonToolId = userId; // new-form anchor: most recent non-tool message
let lastToolIdEver: string | undefined; // old-form anchor: lastToolMsgIdEver
steps.forEach((step, i) => {
const parentId =
i === 0 ? userId : form === 'old' ? (lastToolIdEver ?? prevNonToolId) : prevNonToolId;
const assistant: Message = {
agentId,
content: step.content ?? '',
createdAt: clock++,
id: step.id,
parentId,
role: 'assistant',
updatedAt: 0,
};
if (step.tools?.length) {
assistant.tools = step.tools.map((tc) => ({
apiName: 'x',
arguments: '{}',
id: tc,
identifier: 'x',
result_msg_id: `${step.id}__${tc}`,
type: 'default',
}));
}
msgs.push(assistant);
for (const tc of step.tools ?? []) {
const toolId = `${step.id}__${tc}`;
msgs.push({
content: 'r',
createdAt: clock++,
id: toolId,
parentId: step.id,
role: 'tool',
tool_call_id: tc,
updatedAt: 0,
});
lastToolIdEver = toolId;
}
prevNonToolId = step.id;
});
return msgs;
};
/** Normalize a flatList into a render-shape comparable across chain forms. */
const shape = (flatList: Message[]) =>
flatList.map((m) => ({
childIds: (m as any).children?.map((c: any) => ({
id: c.id,
tools: (c.tools ?? []).map((t: any) => t.result_msg_id),
})),
id: m.id,
role: m.role,
}));
describe('dual-form message chain (LOBE-10445)', () => {
// Canonical turn: u1 → a1(tc1) → a2(tc2) → a3(final, no tool)
const canonical: StepSpec[] = [
{ content: 'step1', id: 'a1', tools: ['tc1'] },
{ content: 'step2', id: 'a2', tools: ['tc2'] },
{ content: 'final', id: 'a3' },
];
// Expected: user + one merged assistantGroup holding the whole chain.
const expectedCanonical = [
{ childIds: undefined, id: 'u1', role: 'user' },
{
childIds: [
{ id: 'a1', tools: ['a1__tc1'] },
{ id: 'a2', tools: ['a2__tc2'] },
{ id: 'a3', tools: [] },
],
id: 'a1',
role: 'assistantGroup',
},
];
it('① tool-anchored (old) → single merged group', () => {
const result = parse(buildTurn('u1', canonical, 'old'));
expect(shape(result.flatList)).toEqual(expectedCanonical);
});
it('② assistant-anchored (new) → single merged group', () => {
const result = parse(buildTurn('u1', canonical, 'new'));
expect(shape(result.flatList)).toEqual(expectedCanonical);
});
it('② new form parses equivalent to old form (flatList + contextTree)', () => {
const oldR = parse(buildTurn('u1', canonical, 'old'));
const newR = parse(buildTurn('u1', canonical, 'new'));
expect(shape(newR.flatList)).toEqual(shape(oldR.flatList));
// contextTree must also collapse to [message(u1), assistantGroup(a1)] in both forms
expect(newR.contextTree).toEqual(oldR.contextTree);
expect(newR.contextTree.map((n) => ({ id: n.id, type: n.type }))).toEqual([
{ id: 'u1', type: 'message' },
{ id: 'a1', type: 'assistantGroup' },
]);
});
it('③ mixed forms inside one turn → single merged group', () => {
// a2 attaches old-style (under a1's tool); a3 attaches new-style (under a2)
const msgs: Message[] = [
{ content: 'q', createdAt: 0, id: 'u1', role: 'user', updatedAt: 0 },
{
agentId: 'agent-a',
content: 'step1',
createdAt: 1,
id: 'a1',
parentId: 'u1',
role: 'assistant',
tools: [
{
apiName: 'x',
arguments: '{}',
id: 'tc1',
identifier: 'x',
result_msg_id: 'a1__tc1',
type: 'default',
},
],
updatedAt: 0,
},
{
content: 'r',
createdAt: 2,
id: 'a1__tc1',
parentId: 'a1',
role: 'tool',
tool_call_id: 'tc1',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'step2',
createdAt: 3,
id: 'a2',
parentId: 'a1__tc1', // OLD-style: under the tool
role: 'assistant',
tools: [
{
apiName: 'x',
arguments: '{}',
id: 'tc2',
identifier: 'x',
result_msg_id: 'a2__tc2',
type: 'default',
},
],
updatedAt: 0,
},
{
content: 'r',
createdAt: 4,
id: 'a2__tc2',
parentId: 'a2',
role: 'tool',
tool_call_id: 'tc2',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'final',
createdAt: 5,
id: 'a3',
parentId: 'a2', // NEW-style: under the assistant (sibling of a2__tc2)
role: 'assistant',
updatedAt: 0,
},
];
const result = parse(msgs);
expect(shape(result.flatList)).toEqual(expectedCanonical);
});
it('④ parallel tools then continuation → merged group (both forms)', () => {
const steps: StepSpec[] = [
{ content: 'step1', id: 'a1', tools: ['tc1', 'tc2'] },
{ content: 'final', id: 'a2' },
];
const expected = [
{ childIds: undefined, id: 'u1', role: 'user' },
{
childIds: [
{ id: 'a1', tools: ['a1__tc1', 'a1__tc2'] },
{ id: 'a2', tools: [] },
],
id: 'a1',
role: 'assistantGroup',
},
];
expect(shape(parse(buildTurn('u1', steps, 'old')).flatList)).toEqual(expected);
expect(shape(parse(buildTurn('u1', steps, 'new')).flatList)).toEqual(expected);
});
it('⑤ regenerate branch: tool siblings do not inflate branch count', () => {
// user has TWO assistant branches (regenerate). Branch a-x is a tool group;
// branch a-y is a plain reply. activeBranchIndex picks a-y.
const msgs: Message[] = [
{
content: 'q',
createdAt: 0,
id: 'u1',
metadata: { activeBranchIndex: 1 },
role: 'user',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'branch x',
createdAt: 1,
id: 'a-x',
parentId: 'u1',
role: 'assistant',
tools: [
{
apiName: 'x',
arguments: '{}',
id: 'tcx',
identifier: 'x',
result_msg_id: 'a-x__tcx',
type: 'default',
},
],
updatedAt: 0,
},
{
content: 'r',
createdAt: 2,
id: 'a-x__tcx',
parentId: 'a-x',
role: 'tool',
tool_call_id: 'tcx',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'branch y',
createdAt: 3,
id: 'a-y',
parentId: 'u1',
role: 'assistant',
updatedAt: 0,
},
];
const result = parse(msgs);
const ids = result.flatList.map((m) => m.id);
// active branch is a-y (index 1); a-x's tool result must NOT appear as a peer entry
expect(ids).toEqual(['u1', 'a-y']);
});
// A regenerated continuation in the new form: the tool-using assistant a1 has
// its tool result PLUS two non-tool assistant children (a2a, a2b). These are a
// branch, so the active one must be chosen via activeBranchIndex — not the
// earliest — and the inactive one must not leak into the merged group chain.
const regenContinuation = (activeBranchIndex: number): Message[] => [
{ content: 'q', createdAt: 0, id: 'u1', role: 'user', updatedAt: 0 },
{
agentId: 'agent-a',
content: 'step1',
createdAt: 1,
id: 'a1',
metadata: { activeBranchIndex },
parentId: 'u1',
role: 'assistant',
tools: [
{
apiName: 'x',
arguments: '{}',
id: 'tc1',
identifier: 'x',
result_msg_id: 'a1__tc1',
type: 'default',
},
],
updatedAt: 0,
},
{
content: 'r',
createdAt: 2,
id: 'a1__tc1',
parentId: 'a1',
role: 'tool',
tool_call_id: 'tc1',
updatedAt: 0,
},
// two regenerated continuations, both children of a1 (siblings of the tool)
{
agentId: 'agent-a',
content: 'cont A',
createdAt: 3,
id: 'a2a',
parentId: 'a1',
role: 'assistant',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'cont B',
createdAt: 4,
id: 'a2b',
parentId: 'a1',
role: 'assistant',
updatedAt: 0,
},
];
it('⑥ assistant-anchored regenerated continuation follows activeBranchIndex', () => {
// activeBranchIndex 1 → a2b is the active continuation merged into the group
const r1 = parse(regenContinuation(1));
expect(shape(r1.flatList)).toEqual([
{ childIds: undefined, id: 'u1', role: 'user' },
{
childIds: [
{ id: 'a1', tools: ['a1__tc1'] },
{ id: 'a2b', tools: [] },
],
id: 'a1',
role: 'assistantGroup',
},
]);
expect(r1.flatList.map((m) => m.id)).not.toContain('a2a');
// activeBranchIndex 0 → the OTHER branch (a2a) is active; not blindly earliest-by-rule
const r0 = parse(regenContinuation(0));
expect((r0.flatList[1] as any).children.map((c: any) => c.id)).toEqual(['a1', 'a2a']);
expect(r0.flatList.map((m) => m.id)).not.toContain('a2b');
});
it('⑦ async-task summary with assistant-anchored parent stays out of the group', () => {
// a1 spawns async tasks under its tool; the follow-up summary uses the NEW
// assistant-anchored parent (summary.parentId === a1). It must render after
// the tasks aggregation (group → tasks → summary), NOT inside the group.
const msgs: Message[] = [
{ content: 'q', createdAt: 0, id: 'u1', role: 'user', updatedAt: 0 },
{
agentId: 'agent-a',
content: 'spawning',
createdAt: 1,
id: 'a1',
parentId: 'u1',
role: 'assistant',
tools: [
{
apiName: 'dispatch',
arguments: '{}',
id: 'tc1',
identifier: 'x',
result_msg_id: 'a1__tc1',
type: 'default',
},
],
updatedAt: 0,
},
{
content: 'r',
createdAt: 2,
id: 'a1__tc1',
parentId: 'a1',
role: 'tool',
tool_call_id: 'tc1',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'task 1',
createdAt: 3,
id: 'task-1',
parentId: 'a1__tc1',
role: 'task',
updatedAt: 0,
},
{
agentId: 'agent-a',
content: 'task 2',
createdAt: 4,
id: 'task-2',
parentId: 'a1__tc1',
role: 'task',
updatedAt: 0,
},
// assistant-anchored post-task summary
{
agentId: 'agent-a',
content: 'summary',
createdAt: 5,
id: 'summary',
parentId: 'a1',
role: 'assistant',
updatedAt: 0,
},
];
const result = parse(msgs);
const rows = result.flatList.map((m) => ({ id: m.id, role: m.role }));
expect(rows).toEqual([
{ id: 'u1', role: 'user' },
{ id: 'a1', role: 'assistantGroup' },
{ id: result.flatList[2].id, role: 'tasks' },
{ id: 'summary', role: 'assistant' },
]);
// the group must contain ONLY a1 — the summary must not be folded inside it
expect((result.flatList[1] as any).children.map((c: any) => c.id)).toEqual(['a1']);
});
});
@@ -156,8 +156,13 @@ export class ContextTreeBuilder {
return;
}
// Priority 6: Branch (multiple children)
if (idNode.children.length > 1) {
// Priority 6: Branch multiple NON-TOOL children (LOBE-10445 invariant 2).
// Tool children are inline data of their assistant (handled by Priority 4),
// never branch candidates.
const nonToolChildren = idNode.children.filter(
(child) => this.messageMap.get(child.id)?.role !== 'tool',
);
if (nonToolChildren.length > 1) {
// Add current message node
const messageNode = this.createMessageNode(message);
contextTree.push(messageNode);
@@ -201,13 +206,13 @@ export class ContextTreeBuilder {
private isAssistantGroupNode(message: Message, idNode: IdNode): boolean {
if (message.role !== 'assistant') return false;
return (
idNode.children.length > 0 &&
idNode.children.every((child) => {
const childMsg = this.messageMap.get(child.id);
return childMsg?.role === 'tool';
})
);
// Role-aware (LOBE-10445): an assistant heads a group when it has ANY tool
// child — not only when ALL children are tools. In the assistant-anchored
// form the next step's assistant is a sibling of the tool results, so a
// group head legitimately has a mix of tool + assistant children. (In the
// old tool-anchored form a tool-using assistant only ever had tool children,
// so this stays a no-op for legacy data.)
return idNode.children.some((child) => this.messageMap.get(child.id)?.role === 'tool');
}
/**
@@ -290,6 +290,11 @@ export class FlatListBuilder {
// Priority 3a: Compare mode from user message metadata
const childMessages = this.childrenMap.get(message.id) ?? [];
// Non-tool children only are branch candidates (LOBE-10445 invariant 2):
// a tool child is inline data of its assistant, never a sibling branch.
const nonToolChildMessages = childMessages.filter(
(childId) => this.messageMap.get(childId)?.role !== 'tool',
);
if (this.isCompareMode(message) && childMessages.length > 1) {
// Add user message
flatList.push(message);
@@ -334,10 +339,10 @@ export class FlatListBuilder {
// Priority 3d: User message with branches (multiple assistant children)
// Branch indicator should be on the active assistant child message
if (message.role === 'user' && childMessages.length > 1) {
if (message.role === 'user' && nonToolChildMessages.length > 1) {
const activeBranchId = this.branchResolver.getActiveBranchIdFromMetadata(
message,
childMessages,
nonToolChildMessages,
this.childrenMap,
);
@@ -353,7 +358,7 @@ export class FlatListBuilder {
flatList.push(message);
processedIds.add(message.id);
const activeBranchIndex = childMessages.indexOf(activeBranchId);
const activeBranchIndex = nonToolChildMessages.indexOf(activeBranchId);
// Continue with active branch - check if it's an assistantGroup
const activeBranchMsg = this.messageMap.get(activeBranchId);
@@ -384,7 +389,7 @@ export class FlatListBuilder {
// Add branch info to the assistantGroup message
const groupMessageWithBranches = this.createMessageWithBranches(
groupMessage,
childMessages.length,
nonToolChildMessages.length,
activeBranchIndex,
);
flatList.push(groupMessageWithBranches);
@@ -404,7 +409,7 @@ export class FlatListBuilder {
// Regular assistant message (not assistantGroup) - add branch info
const activeBranchWithBranches = this.createMessageWithBranches(
activeBranchMsg,
childMessages.length,
nonToolChildMessages.length,
activeBranchIndex,
);
flatList.push(activeBranchWithBranches);
@@ -419,10 +424,10 @@ export class FlatListBuilder {
// Priority 3e: Assistant message with branches (multiple user children)
// Branch indicator should be on the active user child message
if (message.role === 'assistant' && childMessages.length > 1) {
if (message.role === 'assistant' && nonToolChildMessages.length > 1) {
const activeBranchId = this.branchResolver.getActiveBranchIdFromMetadata(
message,
childMessages,
nonToolChildMessages,
this.childrenMap,
);
@@ -438,7 +443,7 @@ export class FlatListBuilder {
flatList.push(message);
processedIds.add(message.id);
const activeBranchIndex = childMessages.indexOf(activeBranchId);
const activeBranchIndex = nonToolChildMessages.indexOf(activeBranchId);
// Continue with active branch and add branch info to the user child
const activeBranchMsg = this.messageMap.get(activeBranchId);
@@ -446,7 +451,7 @@ export class FlatListBuilder {
// Add branch info to the active user child message
const activeBranchWithBranches = this.createMessageWithBranches(
activeBranchMsg,
childMessages.length,
nonToolChildMessages.length,
activeBranchIndex,
);
flatList.push(activeBranchWithBranches);
@@ -1,4 +1,5 @@
import type { ContextNode, IdNode, Message, MessageNode, SignalCallbacksNode } from '../types';
import { BranchResolver } from './BranchResolver';
/**
* Persisted external-signal lineage on `message.metadata.signal`
@@ -55,6 +56,8 @@ export class MessageCollector {
constructor(
private messageMap: Map<string, Message>,
private childrenMap: Map<string | null, string[]>,
// BranchResolver is stateless; default keeps existing 2-arg call sites working.
private branchResolver: BranchResolver = new BranchResolver(),
) {}
/**
@@ -132,61 +135,116 @@ export class MessageCollector {
const toolMessages = this.collectToolMessages(currentAssistant, allMessages);
allToolMessages.push(...toolMessages);
// Find next assistant after tools
for (const toolMsg of toolMessages) {
// Stop if tool message has agentCouncil mode - its children belong to AgentCouncil
if ((toolMsg.metadata as any)?.agentCouncil === true) {
continue;
}
// Find the next step's assistant. Role-aware dual-form walk (LOBE-10445):
// the continuation may hang off this assistant directly (assistant-anchored
// / new form) OR off one of its tool results (tool-anchored / old form).
const continuation = this.findFlatChainContinuation(
currentAssistant,
toolMessages,
allMessages,
processedIds,
groupAgentId,
);
if (!continuation) return;
const nextMessages = allMessages.filter((m) => m.parentId === toolMsg.id);
// Stop if there are task children - they should be handled separately, not part of AssistantGroup
// This ensures that messages after a task are not merged into the AssistantGroup before the task
const taskChildren = nextMessages.filter((m) => m.role === 'task');
if (taskChildren.length > 0) {
continue;
}
for (const nextMsg of nextMessages) {
// Skip an already-collected follower: a duplicated tool_call_id can make
// collectToolMessages surface an earlier turn's tool result first, and
// returning after that no-op recursion would drop this assistant's real
// continuation under a later tool.
if (processedIds.has(nextMsg.id)) continue;
// Only continue if the next assistant has the SAME agentId
// Different agentId means it's a different agent responding (e.g., via speak tool)
const isSameAgent = nextMsg.agentId === groupAgentId;
// Skip signal-tagged toolless callbacks () — they're a
// side-channel under the same parent tool and get collected
// separately by `collectFlatSignalCallbacks`.
if (getMessageSignal(nextMsg)) continue;
if (
nextMsg.role === 'assistant' &&
nextMsg.tools &&
nextMsg.tools.length > 0 &&
isSameAgent
) {
// Continue the chain only for same agent
this.collectAssistantChain(
nextMsg,
allMessages,
assistantChain,
allToolMessages,
processedIds,
);
return;
} else if (nextMsg.role === 'assistant' && isSameAgent) {
// Final assistant without tools (same agent)
assistantChain.push(nextMsg);
return;
}
// If different agentId, don't add to chain - let it be processed separately
}
if (continuation.tools && continuation.tools.length > 0) {
// Continue the chain (recursion marks it processed at the top)
this.collectAssistantChain(
continuation,
allMessages,
assistantChain,
allToolMessages,
processedIds,
);
} else {
// Final assistant without tools — caller marks the whole chain processed
assistantChain.push(continuation);
}
}
/**
* Find the next assistant in a tool-using step's chain (flat variant).
*
* Dual-form aware: candidates are gathered from BOTH the assistant's own
* non-tool children (new assistant-anchored form, where the next assistant is
* a sibling of the tool results) AND each tool result's children (old
* tool-anchored form).
*
* Two guards keep the assistant-anchored candidate honest:
* - **Fan-out guard**: if any tool hosts an AgentCouncil or spawned async
* tasks, the chain does NOT continue linearly through this step neither
* through that tool's children nor through an assistant-anchored follow-up
* (a post-task summary whose `parentId === currentAssistant.id`). Those are
* emitted by the council/tasks flow AFTER the group, so the assistant seed
* is dropped and the chain ends here.
* - **Branch resolution**: when >1 non-tool same-agent continuations share a
* parent (e.g. a regenerated continuation), pick the active one via
* `activeBranchIndex` instead of blindly taking the earliest.
*/
private findFlatChainContinuation(
currentAssistant: Message,
toolMessages: Message[],
allMessages: Message[],
processedIds: Set<string>,
groupAgentId: string | undefined,
): Message | undefined {
const candidateParentIds = new Set<string>();
let hasFanOutTool = false;
for (const toolMsg of toolMessages) {
const isCouncil = (toolMsg.metadata as any)?.agentCouncil === true;
const toolChildren = allMessages.filter((m) => m.parentId === toolMsg.id);
const hasTaskChild = toolChildren.some((m) => m.role === 'task');
if (isCouncil || hasTaskChild) {
hasFanOutTool = true;
continue;
}
candidateParentIds.add(toolMsg.id);
}
// Assistant-anchored continuation only counts when this step did not fan out.
if (!hasFanOutTool) candidateParentIds.add(currentAssistant.id);
const candidates = allMessages
.filter((m) => m.parentId != null && candidateParentIds.has(m.parentId))
.filter((m) => m.role !== 'tool' && !processedIds.has(m.id))
.filter((m) => m.role === 'assistant' && m.agentId === groupAgentId && !getMessageSignal(m))
.sort((a, b) => a.createdAt - b.createdAt);
const activeId = this.resolveActiveContinuationId(candidates);
return activeId ? candidates.find((m) => m.id === activeId) : undefined;
}
/**
* Pick the active continuation among same-step candidates (sorted by
* createdAt). One candidate a linear continuation. >1 non-tool siblings
* under a single parent a branch (e.g. a regenerated continuation), so
* consult the parent's `activeBranchIndex` via BranchResolver instead of
* blindly taking the earliest otherwise the inactive branch is silently
* chosen and the active one dropped. Returns undefined when there is no
* continuation, or the active branch is an optimistic not-yet-created one.
*/
private resolveActiveContinuationId(sortedCandidates: Message[]): string | undefined {
if (sortedCandidates.length === 0) return undefined;
const earliest = sortedCandidates[0];
const parentId = earliest.parentId;
if (parentId == null) return earliest.id;
// Branch siblings share one parent; only those under the earliest
// candidate's parent participate in this branch decision. Use childrenMap
// (creation) order so it lines up with how activeBranchIndex is assigned.
const eligibleIds = new Set(sortedCandidates.map((m) => m.id));
const siblingIds = (this.childrenMap.get(parentId) ?? []).filter((id) => eligibleIds.has(id));
if (siblingIds.length <= 1) return earliest.id;
const parentMsg = this.messageMap.get(parentId);
if (!parentMsg) return earliest.id;
return this.branchResolver.getActiveBranchIdFromMetadata(
parentMsg,
siblingIds,
this.childrenMap,
);
}
/**
* Flat-list variant of {@link collectSignalCallbacks} finds signal
* callback blocks (Monitor stdout pushes, etc.) for an assistant
@@ -296,41 +354,61 @@ export class MessageCollector {
}
children.push(messageNode);
// Find next assistant message after tools
// Find the next step's assistant (dual-form aware, see findChainContinuationNode)
const nextNode = this.findChainContinuationNode(idNode, agentId);
if (nextNode) {
const nextMsg = this.messageMap.get(nextNode.id)!;
this.collectAssistantGroupMessages(nextMsg, nextNode, children, agentId);
}
}
/**
* Find the IdNode of the next assistant in a tool-using step's chain
* (contextTree variant of {@link findFlatChainContinuation}). Same fan-out
* guard (AgentCouncil / async tasks end the chain including any
* assistant-anchored post-task summary) and branch resolution (>1 non-tool
* siblings under one parent pick the active branch) as the flat variant.
* Signal-tagged toolless siblings (Monitor callbacks etc.) are skipped so the
* main chain walks the real follower.
*/
private findChainContinuationNode(idNode: IdNode, groupAgentId?: string): IdNode | undefined {
const candidateNodes: IdNode[] = [];
let hasFanOutTool = false;
// (b) each tool result's children (old form); detect fan-out tools
for (const toolNode of idNode.children) {
const toolMsg = this.messageMap.get(toolNode.id);
if (toolMsg?.role !== 'tool') continue;
// Stop if tool message has agentCouncil mode - its children belong to AgentCouncil
if ((toolMsg.metadata as any)?.agentCouncil === true) {
const isCouncil = (toolMsg.metadata as any)?.agentCouncil === true;
const hasTaskChild = toolNode.children.some(
(child) => this.messageMap.get(child.id)?.role === 'task',
);
if (isCouncil || hasTaskChild) {
hasFanOutTool = true;
continue;
}
candidateNodes.push(...toolNode.children);
}
// Stop if there are ANY task children - they should be processed separately, not part of AssistantGroup
// This ensures that messages after a task are not merged into the AssistantGroup before the task
const taskChildren = toolNode.children.filter((child) => {
const childMsg = this.messageMap.get(child.id);
return childMsg?.role === 'task';
});
if (taskChildren.length > 0) {
continue;
}
// Find the next main-chain assistant under this tool. Signal-tagged
// toolless siblings (Monitor callbacks etc., ) share the
// same parent tool but live on a side-channel — skip them here so
// the main chain still walks the real follower. The signal blocks
// are emitted separately by `collectSignalCallbacks`.
for (const nextChild of toolNode.children) {
const nextMsg = this.messageMap.get(nextChild.id);
if (nextMsg?.role !== 'assistant') continue;
if (nextMsg.agentId !== agentId) continue;
if (getMessageSignal(nextMsg)) continue; // skip signal callbacks
// Recursively collect this assistant and its descendants (same agent only)
this.collectAssistantGroupMessages(nextMsg, nextChild, children, agentId);
return; // Only follow one path
// (a) the assistant's own non-tool children (new form) — only when the step
// did not fan out (otherwise they are post-fan-out summaries, not inline)
if (!hasFanOutTool) {
for (const child of idNode.children) {
if (this.messageMap.get(child.id)?.role === 'tool') continue;
candidateNodes.push(child);
}
}
const eligible = candidateNodes
.map((node) => ({ msg: this.messageMap.get(node.id), node }))
.filter(
(c) =>
c.msg?.role === 'assistant' && c.msg.agentId === groupAgentId && !getMessageSignal(c.msg),
)
.sort((a, b) => a.msg!.createdAt - b.msg!.createdAt);
const activeId = this.resolveActiveContinuationId(eligible.map((c) => c.msg!));
return activeId ? eligible.find((c) => c.node.id === activeId)?.node : undefined;
}
/**
@@ -503,51 +581,21 @@ export class MessageCollector {
* Only follows messages from the SAME agent (matching agentId)
*/
findLastNodeInAssistantGroup(idNode: IdNode, groupAgentId?: string): IdNode | null {
// Check if has tool children
const toolChildren = idNode.children.filter((child) => {
const childMsg = this.messageMap.get(child.id);
return childMsg?.role === 'tool';
});
// Walk the chain to its next step (dual-form aware, see findChainContinuationNode)
const nextNode = this.findChainContinuationNode(idNode, groupAgentId);
if (nextNode) {
return this.findLastNodeInAssistantGroup(nextNode, groupAgentId);
}
// No further same-agent assistant. If this step still owns tool results
// (e.g. the last tool hosts an AgentCouncil / tasks), return the last tool
// node so findNextAfterTools can inspect it; otherwise this node is the tail.
const toolChildren = idNode.children.filter(
(child) => this.messageMap.get(child.id)?.role === 'tool',
);
if (toolChildren.length === 0) {
return idNode;
}
// Check if any tool has an assistant child with the same agentId
for (const toolNode of toolChildren) {
const toolMsg = this.messageMap.get(toolNode.id);
// Stop if tool message has agentCouncil mode - its children belong to AgentCouncil
if ((toolMsg?.metadata as any)?.agentCouncil === true) {
continue;
}
// Stop if there are ANY task children - they should be processed separately, not part of AssistantGroup
// This ensures that messages after a task are not merged into the AssistantGroup before the task
const taskNodes = toolNode.children.filter((child) => {
const childMsg = this.messageMap.get(child.id);
return childMsg?.role === 'task';
});
if (taskNodes.length > 0) {
continue;
}
// Pick the next main-chain assistant under this tool. Mirror the
// skip rule used by `collectAssistantGroupMessages`: signal-tagged
// toolless siblings (Monitor callbacks etc., ) share the
// parent tool but live on a side-channel — if they appear before
// the real follower, blindly taking children[0] would end the
// walk on a callback node and truncate the AssistantGroup tail.
for (const nextChild of toolNode.children) {
const nextMsg = this.messageMap.get(nextChild.id);
if (nextMsg?.role !== 'assistant') continue;
if (nextMsg.agentId !== groupAgentId) continue;
if (getMessageSignal(nextMsg)) continue;
return this.findLastNodeInAssistantGroup(nextChild, groupAgentId);
}
}
// No more assistant messages from the same agent, return the last tool node
return toolChildren.at(-1) ?? null;
}
}
@@ -31,7 +31,11 @@ export class Transformer {
// Initialize utility classes
this.branchResolver = new BranchResolver();
this.messageCollector = new MessageCollector(this.messageMap, helperMaps.childrenMap);
this.messageCollector = new MessageCollector(
this.messageMap,
helperMaps.childrenMap,
this.branchResolver,
);
this.messageTransformer = new MessageTransformer();
// Initialize builder classes
@@ -361,6 +361,32 @@ describe('AgentDocumentModel', () => {
expect(result.map((doc) => doc.id)).toEqual([ownDoc.id]);
});
it('should list current-agent document summaries by underlying document ids', async () => {
const ownDoc = await agentDocumentModel.create(agentId, 'own.md', 'own content', {
sourceType: 'file',
});
const webDoc = await agentDocumentModel.create(agentId, 'web-page', 'web content', {
fileType: 'article',
sourceType: 'web',
});
const secondAgentDoc = await agentDocumentModel.create(
secondAgentId,
'second.md',
'second content',
{ sourceType: 'file' },
);
const result = await agentDocumentModel.listByDocumentIds(
agentId,
[ownDoc.documentId, webDoc.documentId, secondAgentDoc.documentId],
{ sourceType: 'file' },
);
expect(result.map((doc) => doc.id)).toEqual([ownDoc.id]);
expect(result[0]).not.toHaveProperty('content');
expect(result[0]).not.toHaveProperty('editorData');
});
});
describe('update and upsert', () => {
@@ -733,6 +759,48 @@ describe('AgentDocumentModel', () => {
expect(byTemplate.every((item) => item.templateId === 'claw')).toBe(true);
});
it('should list document summaries without content or editor data', async () => {
const fileDoc = await agentDocumentModel.create(agentId, 'file.md', 'file content', {
editorData: { root: { children: [{ text: 'file content' }] } },
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
sourceType: 'file',
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
await agentDocumentModel.create(agentId, 'web-page', 'web content', {
fileType: 'article',
sourceType: 'web',
updatedAt: new Date('2026-01-01T00:00:01.000Z'),
});
await agentDocumentModel.create(secondAgentId, 'other-agent.md', 'other content', {
sourceType: 'file',
});
const all = await agentDocumentModel.listByAgent(agentId);
expect(all.map((item) => item.filename)).toEqual(['web-page', 'file.md']);
for (const item of all) {
expect(item).not.toHaveProperty('content');
expect(item).not.toHaveProperty('editorData');
}
const fileSummary = all.find((item) => item.id === fileDoc.id);
expect(fileSummary).toMatchObject({
category: 'document',
documentId: fileDoc.documentId,
filename: 'file.md',
id: fileDoc.id,
isFolder: false,
isSkillBundle: false,
isSkillIndex: false,
loadPosition: DocumentLoadPosition.BEFORE_SYSTEM,
sourceType: 'file',
title: 'file',
});
const webOnly = await agentDocumentModel.listByAgent(agentId, { sourceType: 'web' });
expect(webOnly.map((item) => item.filename)).toEqual(['web-page']);
});
it('should return only skill-managed docs for skill registry assembly', async () => {
const bundle = await agentDocumentModel.create(agentId, 'bug-triage', 'bundle body', {
fileType: SKILL_BUNDLE_FILE_TYPE,
@@ -18,6 +18,8 @@ import {
import type {
AgentDocument,
AgentDocumentContextRow,
AgentDocumentListItem,
AgentDocumentListSourceType,
AgentDocumentPolicy,
AgentDocumentSourceType,
AgentDocumentWithRules,
@@ -70,6 +72,20 @@ interface ConvertAgentDocumentToSkillIndexParams {
title: string;
}
interface AgentDocumentListQueryRow {
description: string | null;
documentId: string;
filename: string | null;
fileType: string;
id: string;
parentId: string | null;
policy: unknown;
sourceType: AgentDocumentSourceType;
templateId: string | null;
title: string | null;
updatedAt: Date;
}
export class AgentDocumentModel {
private userId: string;
private workspaceId?: string;
@@ -164,6 +180,29 @@ export class AgentDocumentModel {
};
}
private toAgentDocumentListItem(row: AgentDocumentListQueryRow): AgentDocumentListItem {
const filename = row.filename ?? '';
const policy = (row.policy as AgentDocumentPolicy | null) ?? null;
const item = {
description: row.description ?? null,
documentId: row.documentId,
fileType: row.fileType,
filename,
id: row.id,
loadPosition: policy?.context?.position,
parentId: row.parentId ?? null,
sourceType: row.sourceType,
templateId: row.templateId ?? null,
title: row.title ?? filename,
updatedAt: row.updatedAt,
};
return {
...item,
...deriveAgentDocumentFields(item),
};
}
private buildDeletedAtFilters(options?: AgentDocumentQueryOptions) {
if (options?.deletedOnly) return [isNotNull(agentDocuments.deletedAt)];
if (options?.includeDeleted) return [];
@@ -910,6 +949,40 @@ export class AgentDocumentModel {
});
}
async listByAgent(
agentId: string,
options?: { sourceType?: AgentDocumentListSourceType },
): Promise<AgentDocumentListItem[]> {
const sourceType = options?.sourceType;
const results = await this.db
.select({
description: documents.description,
documentId: agentDocuments.documentId,
fileType: documents.fileType,
filename: documents.filename,
id: agentDocuments.id,
parentId: documents.parentId,
policy: agentDocuments.policy,
sourceType: documents.sourceType,
templateId: agentDocuments.templateId,
title: documents.title,
updatedAt: agentDocuments.updatedAt,
})
.from(agentDocuments)
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
.where(
and(
this.agentDocOwnership(),
eq(agentDocuments.agentId, agentId),
isNull(agentDocuments.deletedAt),
...(sourceType && sourceType !== 'all' ? [eq(documents.sourceType, sourceType)] : []),
),
)
.orderBy(desc(agentDocuments.updatedAt));
return results.map((row) => this.toAgentDocumentListItem(row));
}
async findSkillDocsByAgent(agentId: string): Promise<AgentDocumentWithRules[]> {
const results = await this.db
.select({ doc: documents, settings: agentDocuments })
@@ -1053,6 +1126,44 @@ export class AgentDocumentModel {
});
}
async listByDocumentIds(
agentId: string,
documentIds: string[],
options?: { sourceType?: AgentDocumentListSourceType },
): Promise<AgentDocumentListItem[]> {
if (documentIds.length === 0) return [];
const sourceType = options?.sourceType;
const results = await this.db
.select({
description: documents.description,
documentId: agentDocuments.documentId,
fileType: documents.fileType,
filename: documents.filename,
id: agentDocuments.id,
parentId: documents.parentId,
policy: agentDocuments.policy,
sourceType: documents.sourceType,
templateId: agentDocuments.templateId,
title: documents.title,
updatedAt: agentDocuments.updatedAt,
})
.from(agentDocuments)
.innerJoin(documents, eq(agentDocuments.documentId, documents.id))
.where(
and(
this.agentDocOwnership(),
eq(agentDocuments.agentId, agentId),
inArray(agentDocuments.documentId, documentIds),
isNull(agentDocuments.deletedAt),
...(sourceType && sourceType !== 'all' ? [eq(documents.sourceType, sourceType)] : []),
),
)
.orderBy(desc(agentDocuments.updatedAt));
return results.map((row) => this.toAgentDocumentListItem(row));
}
async hasByAgent(agentId: string): Promise<boolean> {
const [result] = await this.db
.select({ id: agentDocuments.id })
@@ -6,6 +6,7 @@
import type {
AgentDocumentPolicy,
DocumentLoadFormat,
DocumentLoadPosition as DocumentLoadPositionType,
DocumentLoadRules,
PolicyLoad,
} from '@lobechat/agent-templates';
@@ -23,6 +24,7 @@ export {
export type { AgentDocumentPolicy, DocumentLoadRules } from '@lobechat/agent-templates';
export type AgentDocumentSourceType = 'file' | 'web' | 'api' | 'topic' | 'agent' | 'agent-signal';
export type AgentDocumentListSourceType = 'all' | 'file' | 'web';
/**
* UI-facing tab grouping for an agent document. Derived from `fileType` +
@@ -81,6 +83,20 @@ export interface AgentDocumentWithRules extends AgentDocument, AgentDocumentDeri
loadRules: DocumentLoadRules;
}
export interface AgentDocumentListItem extends AgentDocumentDerivedFields {
description: string | null;
documentId: string;
filename: string;
fileType: string;
id: string;
loadPosition: DocumentLoadPositionType | undefined;
parentId: string | null;
sourceType: AgentDocumentSourceType;
templateId: string | null;
title: string;
updatedAt: Date;
}
export interface AgentDocumentContextRow extends AgentDocumentDerivedFields {
content: string;
contentCharCount?: number;
+4 -1
View File
@@ -73,7 +73,10 @@ export class DeviceModel {
query = async (): Promise<DeviceItem[]> => {
return this.db.query.devices.findMany({
orderBy: [desc(devices.lastSeenAt)],
// `lastSeenAt` is written from a JS `new Date()` (ms precision), so two
// rapid registers can tie on it and leave the order undefined. Break ties
// by `createdAt` (DB-side now(), µs precision) for a stable ordering.
orderBy: [desc(devices.lastSeenAt), desc(devices.createdAt)],
where: eq(devices.userId, this.userId),
});
};
@@ -85,10 +85,4 @@ describe('executeDeviceRpc', () => {
const result = await executeDeviceRpc('listGitBranches', { path: root }, makeDeps());
expect(Array.isArray(result)).toBe(true);
});
it('routes listGitWorktrees through the shared git dispatcher', async () => {
// Not a git repo → the shared local-file-shell impl returns an empty list.
const result = await executeDeviceRpc('listGitWorktrees', { path: root }, makeDeps());
expect(Array.isArray(result)).toBe(true);
});
});
-6
View File
@@ -10,7 +10,6 @@ import {
getLinkedPullRequest,
listGitBranches,
listGitRemoteBranches,
listGitWorktrees,
pullGitBranch,
pushGitBranch,
renameGitBranch,
@@ -47,7 +46,6 @@ export const DEVICE_RPC_METHODS = [
'getGitAheadBehind',
'listGitBranches',
'listGitRemoteBranches',
'listGitWorktrees',
'checkoutGitBranch',
'renameGitBranch',
'deleteGitBranch',
@@ -131,10 +129,6 @@ export const executeDeviceRpc = async (
return listGitRemoteBranches((params as { path: string }).path);
}
case 'listGitWorktrees': {
return listGitWorktrees((params as { path: string }).path);
}
case 'checkoutGitBranch': {
return checkoutGitBranch(params as { branch: string; create?: boolean; path: string });
}
@@ -115,6 +115,11 @@ export type LocalFilePreviewAccept = 'image';
export interface LocalFilePreviewUrlParams {
accept?: LocalFilePreviewAccept;
/**
* Allows previewing one user-selected file outside approved workspace roots.
* This is only for renderer previews and must not expand agent file access.
*/
allowExternalFile?: boolean;
path: string;
workingDirectory: string;
}
+11 -11
View File
@@ -304,21 +304,21 @@ export default {
'channel.statusQueued': 'Queued',
'channel.statusStarting': 'Starting',
'transfer.title': 'Transfer',
'transfer.copyTo': 'Copy To',
'transfer.desc': 'Transfer this agent to another workspace or your personal account.',
'transfer.button': 'Transfer',
'transfer.selectTarget': 'Transfer Agent To',
'transfer.title': 'Move',
'transfer.copyTo': 'Copy to...',
'transfer.desc': 'Move this Agent to another Workspace or your personal account.',
'transfer.button': 'Move',
'transfer.selectTarget': 'Move Agent to',
'transfer.searchWorkspace': 'Search workspaces...',
'transfer.personalAccount': 'Personal Account',
'transfer.confirm.title': 'Transfer Agent',
'transfer.confirm.title': 'Move Agent',
'transfer.confirm.desc':
'This will move the agent and all associated data (topics, messages, files, etc.) to the target workspace.',
'transfer.confirm.warning': "Some features don't transfer:",
'transfer.confirm.warning': "Some links won't move:",
'transfer.confirm.plugins': 'Custom plugins may not be available in the target workspace',
'transfer.confirm.chatGroups': 'Multi-agent group associations will be removed',
'transfer.confirm.botChannels': 'Bot channel connections may need to be refreshed after transfer',
'transfer.success': 'Agent transferred successfully',
'transfer.transferTo': 'Transfer To',
'transfer.error': 'Failed to transfer agent',
'transfer.confirm.botChannels': 'Bot channel connections may need to be refreshed after moving',
'transfer.success': 'Agent moved successfully',
'transfer.transferTo': 'Move to...',
'transfer.error': 'Failed to move agent',
} as const;
+1
View File
@@ -53,6 +53,7 @@ export default {
'builtinCopilot': 'Built-in Copilot',
'chatList.expandMessage': 'Expand Message',
'chatList.longMessageDetail': 'View Details',
'chatList.refreshing': 'Fetching latest messages...',
'clearCurrentMessages': 'Clear current session messages',
'compressedHistory': 'Compressed History',
'compression.cancel': 'Uncompress',
-12
View File
@@ -16,7 +16,6 @@ export default {
'workingDirectory.checkoutFailed': 'Checkout failed',
'workingDirectory.chooseDifferentFolder': 'Choose a folder...',
'workingDirectory.clear': 'Clear',
'workingDirectory.clean': 'clean',
'workingDirectory.createBranchAction': 'Checkout new branch…',
'workingDirectory.createBranchTitle': 'Create new branch',
'workingDirectory.current': 'Current working directory',
@@ -66,15 +65,4 @@ export default {
'workingDirectory.topicOverride': 'Override for this conversation',
'workingDirectory.uncommittedChanges_one': 'Uncommitted changes: {{count}} file',
'workingDirectory.uncommittedChanges_other': 'Uncommitted changes: {{count}} files',
'workingDirectory.bareWorktree': 'bare',
'workingDirectory.currentWorktree': 'current',
'workingDirectory.detachedHeadShort': 'detached@{{sha}}',
'workingDirectory.detachedWorktree': 'detached',
'workingDirectory.lockedWorktree': 'locked',
'workingDirectory.prunableWorktree': 'prunable',
'workingDirectory.worktreeCount': '{{count}} worktrees',
'workingDirectory.worktreeSwitchDescription': 'Switch the current conversation working directory',
'workingDirectory.worktreeUnavailable': 'unavailable',
'workingDirectory.worktreesEmpty': 'No worktrees found',
'workingDirectory.worktreesHeading': 'Worktrees',
};
+7 -1
View File
@@ -36,16 +36,22 @@ export default {
'fleet.allShown': 'All running tasks are shown',
'fleet.backToHome': 'Back to home',
'fleet.closeColumn': 'Close column',
'fleet.collapseReply': 'Collapse',
'fleet.dragHint': 'Drag to reorder',
'fleet.empty': 'No tasks open',
'fleet.emptyDesc': 'Pick a running task on the left, or use + to add a column.',
'fleet.noRunningTasks': 'No running tasks',
'fleet.openInChat': 'Open in chat',
'fleet.runningTasks': 'Running Tasks',
'fleet.pin': 'Pin column',
'fleet.rows.one': 'Single row',
'fleet.rows.two': 'Two rows',
'fleet.runningBoard': 'Running Board',
'fleet.status.idle': 'Idle',
'fleet.status.paused': 'Paused',
'fleet.status.running': 'Running',
'fleet.status.scheduled': 'Scheduled',
'fleet.tooltip': 'View all agents side by side',
'fleet.unpin': 'Unpin column',
'notification.finishChatGeneration': 'AI message generation completed',
'tab.closeCurrentTab': 'Close Tab',
'tab.closeLeftTabs': 'Close Tabs to the Left',
+32 -29
View File
@@ -1,18 +1,18 @@
export default {
'_cloud.officialProvider': '{{name}} Official Model Service',
'about.title': 'About',
'agentImport.action': 'Import to workspace',
'agentImport.action': 'Copy to Workspace...',
'agentImport.description':
'Fork a copy of this agent into one of your workspaces. The original stays in your personal space — no sync after import.',
'agentImport.failed': 'Failed to import agent.',
'Create an independent copy in a Workspace. The original Agent stays in your personal account.',
'agentImport.failed': 'Failed to copy agent.',
'agentImport.modal.configIncluded': 'Agent configuration is copied by default.',
'agentImport.modal.confirm': 'Import',
'agentImport.modal.confirm': 'Copy',
'agentImport.modal.includeHistory': 'Copy topics and messages',
'agentImport.modal.includeHistoryDesc':
'Optional. Copies this agents conversation history into the new agent.',
'agentImport.modal.knowledgeNotice': 'Knowledge bindings and files are not copied yet.',
'agentImport.success': 'Agent imported to {{name}}.',
'agentImport.title': 'Import to workspace',
'agentImport.success': 'Agent copied to {{name}}.',
'agentImport.title': 'Copy to Workspace',
'accountDeletion.cancelButton': 'Cancel Deletion',
'accountDeletion.cancelConfirmTitle': 'Cancel account deletion request?',
'accountDeletion.cancelFailed': 'Failed to cancel deletion request',
@@ -1033,19 +1033,19 @@ When I am ___, I need ___
'[Skill Request] Summarize the skill you need in one sentence',
'skillStore.wantMore.reachedEnd': "You've reached the end. Can't find what you need?",
'startConversation': 'Start Conversation',
'storage.actions.transfer.button': 'Transfer To',
'storage.actions.transfer.button': 'Move to...',
'storage.actions.transfer.desc':
'Move agents and their data to a workspace you have access to. LobeAI, the default inbox Agent, cannot be transferred; use Copy Agents to copy it to a workspace or personal account instead.',
'storage.actions.transfer.title': 'Agents Migration',
'storage.actions.transferAgentGroups.button': 'Transfer To',
'Move agents and their data to another Workspace or your personal account. The originals leave the current space. LobeAI cannot be moved; copy it instead.',
'storage.actions.transfer.title': 'Move Agents',
'storage.actions.transferAgentGroups.button': 'Move to...',
'storage.actions.transferAgentGroups.desc':
'Move agent groups, their members, and group conversation data to a workspace you have access to.',
'storage.actions.transferAgentGroups.title': 'Agent Groups Migration',
'storage.actions.copyLobeAI.button': 'Copy To',
'Move groups, member Agents, and group conversation data to another Workspace or your personal account.',
'storage.actions.transferAgentGroups.title': 'Move Groups',
'storage.actions.copyLobeAI.button': 'Copy to...',
'storage.actions.copyLobeAI.desc':
'Copy agents, including LobeAI, into another workspace or personal account. Topics and messages are optional.',
'storage.actions.copyLobeAI.title': 'Agents Copy',
'storage.actions.copyAgentGroups.button': 'Copy To',
'Keep the originals and create independent copies in another Workspace or your personal account. Topics and messages are optional.',
'storage.actions.copyLobeAI.title': 'Copy Agents',
'storage.actions.copyAgentGroups.button': 'Copy to...',
'storage.actions.copyAgentGroups.desc':
'Copy agent groups and their member agents into another workspace or personal account.',
'storage.actions.copyAgentGroups.title': 'Agent Groups Copy',
@@ -1648,19 +1648,20 @@ When I am ___, I need ___
'You will lose access to "{{name}}" immediately. You can rejoin only if you are invited again.',
'workspace.general.transferAgents.modal.back': 'Back',
'workspace.general.transferAgents.modal.continue': 'Continue',
'workspace.general.transferAgents.modal.failed': 'Failed to transfer agents',
'workspace.general.transferAgents.modal.failed': 'Failed to move agents',
'workspace.general.transferAgents.modal.loadFailed': 'Failed to load agents',
'workspace.general.transferAgents.modal.noAgents': 'No agents in this workspace',
'workspace.general.transferAgents.modal.selectAgents': 'Select agents to transfer to {{target}}.',
'workspace.general.transferAgents.modal.selectAgents': 'Select Agents to move to {{target}}.',
'workspace.general.transferAgents.modal.selectPlaceholder':
'Select workspace or personal account...',
'workspace.general.transferAgents.modal.selectTarget':
'Choose a workspace or personal account to transfer agents to.',
'Choose where to move the Agents. They will leave the current space.',
'workspace.general.transferAgents.modal.selected': 'selected',
'workspace.general.transferAgents.modal.selectedAgent': 'Agent to transfer to {{target}}.',
'workspace.general.transferAgents.modal.success': '{{count}} agent(s) transferred successfully',
'workspace.general.transferAgents.modal.title': 'Transfer Agents',
'workspace.general.transferAgents.modal.transfer': 'Transfer {{count}} agent(s)',
'workspace.general.transferAgents.modal.selectedAgent':
'This Agent will move to {{target}} and leave the current space.',
'workspace.general.transferAgents.modal.success': '{{count}} agent(s) moved',
'workspace.general.transferAgents.modal.title': 'Move Agents',
'workspace.general.transferAgents.modal.transfer': 'Move {{count}} agent(s)',
'workspace.general.transferAgents.modal.warning':
'Custom plugins may not be available and multi-agent group associations will be removed.',
'workspace.general.transferAgents.personalAccount': 'Personal Account',
@@ -1684,10 +1685,10 @@ When I am ___, I need ___
'workspace.general.copyLobeAI.modal.back': 'Back',
'workspace.general.copyLobeAI.modal.continue': 'Continue',
'workspace.general.copyLobeAI.modal.copyOptions.config.desc':
'Required. Copies the model, prompt, tools, and Agent profile.',
'Required. Copies the model, prompt, tools, and Agent profile into a new Agent.',
'workspace.general.copyLobeAI.modal.copyOptions.config.title': 'Agent configuration',
'workspace.general.copyLobeAI.modal.copyOptions.history.desc':
'Optional. Copies selected agents topics and messages into the new agents.',
'Optional. Copies selected Agents topics and messages into the new Agents.',
'workspace.general.copyLobeAI.modal.copyOptions.history.title': 'Topics and messages',
'workspace.general.copyLobeAI.modal.copyOptions.knowledgeBase.reason':
'Not supported yet. Reconnect them in the target workspace or personal account after copying.',
@@ -1701,15 +1702,17 @@ When I am ___, I need ___
'workspace.general.copyLobeAI.modal.failed': 'Failed to copy agents',
'workspace.general.copyLobeAI.modal.includeHistory': 'Copy topics and messages',
'workspace.general.copyLobeAI.modal.includeHistoryDesc':
'Optional. Copies selected agents conversation history into the new agents.',
'Optional. Copies selected Agents conversation history into the new Agents.',
'workspace.general.copyLobeAI.modal.loadFailed': 'Failed to load agents',
'workspace.general.copyLobeAI.modal.noAgents': 'No agents available to copy',
'workspace.general.copyLobeAI.modal.selected': 'selected',
'workspace.general.copyLobeAI.modal.selectedAgent': 'Agent to copy.',
'workspace.general.copyLobeAI.modal.selectAgents': 'Select agents to copy.',
'workspace.general.copyLobeAI.modal.selectedAgent':
'This Agent will be copied. The original stays where it is.',
'workspace.general.copyLobeAI.modal.selectAgents':
'Select Agents to copy. Originals stay where they are.',
'workspace.general.copyLobeAI.modal.selectPlaceholder': 'Select workspace or personal account...',
'workspace.general.copyLobeAI.modal.selectTarget':
'Choose the target workspace or personal account. Agent configuration is copied by default.',
'Choose where to create the copies. The originals stay where they are.',
'workspace.general.copyLobeAI.modal.success': '{{count}} agent(s) copied',
'workspace.general.copyLobeAI.modal.title': 'Copy Agents',
'workspace.general.copyLobeAI.modal.untitledAgent': 'Untitled Agent',
+7 -5
View File
@@ -140,6 +140,10 @@ export default {
'credits.topUp.unitPrice': 'Unit Price',
'credits.topUp.unitPriceFormat': '${{price}} / 1M {{creditLabel}}',
'credits.topUp.unitPriceSuffix': '/ 1M {{creditLabel}}',
'credits.topUp.bestValue.cta': 'View Ultimate annual',
'credits.topUp.bestValue.savings': 'Save ${{savings}} on this purchase',
'credits.topUp.bestValue.title':
'{{plan}} annual unlocks the lowest top-up rate: ${{price}} / 1M {{creditLabel}}',
'credits.topUp.upgradePrefix': 'Upgrade to',
'credits.topUp.upgradeSuffix': 'to save ${{savings}}',
'credits.topUp.validityInfo': '{{months}} months validity',
@@ -478,21 +482,19 @@ export default {
'referral.rules.expiry':
'Credit validity: Available referral credits will be cleared after 100 days of user inactivity',
'referral.rules.missedCode':
'Missed invite code: You can <0>backfill</0> within 7 days of registration. After backfilling, you still need to perform a valid action and complete a payment to receive rewards',
'Missed invite code: You can <0>backfill</0> within 7 days of registration. If you have already made a real payment and pass verification, rewards are processed after binding; otherwise they are processed after your first real payment',
'referral.rules.priority':
'Credit consumption priority: Free credits → Subscription credits → Referral credits → Top-up credits',
'referral.rules.registration':
'Registration method: Invited users register via referral link or enter referral code on registration page',
'referral.rules.reward': 'Reward: Referrer and invitee each receive {{reward}}M credits',
'referral.rules.rewardDelay':
'Reward processing: Credits will be distributed within 1 hour after the invitee completes a payment and passes verification',
'Reward processing: Credits are granted after the invitee completes a real payment and passes verification',
'referral.rules.title': 'Program Rules',
'referral.rules.validInvitation':
'Valid invitation: Invitee registers with your referral code, performs one valid action, and completes a payment (subscription or credit top-up)',
'Valid invitation: Invitee registers with your referral code and completes a real payment (subscription or personal credit top-up)',
'referral.rules.antiAbuse':
'If fraudulent activity is detected (e.g., mass registration of disposable email accounts), the associated accounts will be permanently banned',
'referral.rules.validOperation':
'Valid action criteria: Send one message on Chat page, or generate one image on image page',
'referral.stats.availableBalance': 'Available Balance',
'referral.stats.description': 'View your referral statistics',
'referral.stats.title': 'Referral Overview',
+3
View File
@@ -71,6 +71,8 @@ export default {
'projectStatus.failed_other': '{{count}} failed topics',
'projectStatus.loading_one': '{{count}} loading topic',
'projectStatus.loading_other': '{{count}} loading topics',
'projectStatus.unread_one': '{{count}} topic with unread reply',
'projectStatus.unread_other': '{{count}} topics with unread replies',
'projectStatus.waitingForHuman_one': '{{count}} topic awaiting input',
'projectStatus.waitingForHuman_other': '{{count}} topics awaiting input',
'management.actions.newChat': 'New chat',
@@ -156,6 +158,7 @@ export default {
'management.status.archived': 'Archived',
'management.status.completed': 'Completed',
'management.status.failed': 'Failed',
'management.status.idle': 'Idle',
'management.status.paused': 'Paused',
'management.status.running': 'Running',
'management.status.waitingForHuman': 'Awaiting input',
+6
View File
@@ -79,4 +79,10 @@ export { consumeStreamUntilDone } from './utils/consumeStream';
export { AgentRuntimeError } from './utils/createError';
export { getModelPropertyWithFallback } from './utils/getFallbackModelProperty';
export { getModelPricing } from './utils/getModelPricing';
export {
applyModelExtendParams,
type ApplyModelExtendParamsContext,
type ModelExtendParams,
resolveDefaultThinkingLevelForModel,
} from './utils/modelExtendParams';
export { parseDataUri } from './utils/uriParser';
@@ -0,0 +1,90 @@
import type { LobeAgentChatConfig } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { applyModelExtendParams, resolveDefaultThinkingLevelForModel } from './modelExtendParams';
const chatConfig = (config: Partial<LobeAgentChatConfig> = {}): LobeAgentChatConfig =>
({ ...config }) as LobeAgentChatConfig;
describe('applyModelExtendParams', () => {
it('returns empty when the model has no extend params', () => {
expect(
applyModelExtendParams({
chatConfig: chatConfig({ enableReasoning: true }),
extendParams: undefined,
model: 'gpt-4',
}),
).toEqual({});
expect(
applyModelExtendParams({
chatConfig: chatConfig({ thinkingLevel3: 'high' }),
extendParams: [],
model: 'gemini-3.1-pro-preview',
}),
).toEqual({});
});
// Gemini 3 Pro via the agent path (provider=lobehub) billed reasoning tokens but
// returned empty thinking summaries because thinkingLevel never reached the
// request. With the model's extendParams present, thinkingLevel must default
// to 'high' even when the chat config does not set thinkingLevel3.
it('defaults thinkingLevel to high for gemini-3.1-pro-preview (thinkingLevel3)', () => {
const result = applyModelExtendParams({
chatConfig: chatConfig({}),
extendParams: ['thinkingLevel3', 'urlContext'],
model: 'gemini-3.1-pro-preview',
});
expect(result.thinkingLevel).toBe('high');
});
it('honors an explicit thinkingLevel3 config value', () => {
const result = applyModelExtendParams({
chatConfig: chatConfig({ thinkingLevel3: 'medium' }),
extendParams: ['thinkingLevel3'],
model: 'gemini-3.1-pro-preview',
});
expect(result.thinkingLevel).toBe('medium');
});
it('forwards urlContext only when enabled in the chat config', () => {
expect(
applyModelExtendParams({
chatConfig: chatConfig({ urlContext: true }),
extendParams: ['thinkingLevel3', 'urlContext'],
model: 'gemini-3.1-pro-preview',
}).urlContext,
).toBe(true);
expect(
applyModelExtendParams({
chatConfig: chatConfig({}),
extendParams: ['thinkingLevel3', 'urlContext'],
model: 'gemini-3.1-pro-preview',
}).urlContext,
).toBeUndefined();
});
it('resolves reasoning effort variants', () => {
expect(
applyModelExtendParams({
chatConfig: chatConfig({ reasoningEffort: 'high' }),
extendParams: ['reasoningEffort'],
model: 'some-model',
}).reasoning_effort,
).toBe('high');
});
});
describe('resolveDefaultThinkingLevelForModel', () => {
it('falls back to high without a model', () => {
expect(resolveDefaultThinkingLevelForModel()).toBe('high');
});
it('uses per-model defaults', () => {
expect(resolveDefaultThinkingLevelForModel('gemini-3.5-flash')).toBe('medium');
expect(resolveDefaultThinkingLevelForModel('gemini-3.1-flash-lite')).toBe('minimal');
});
});
@@ -0,0 +1,318 @@
import type { LobeAgentChatConfig } from '@lobechat/types';
import type { ExtendParamsType } from 'model-bank';
/**
* Extended parameters for model runtime
*/
export interface ModelExtendParams {
deepseekV4ReasoningEffort?: string;
effort?: string;
enabledContextCaching?: boolean;
imageAspectRatio?: string;
imageResolution?: string;
preserveThinking?: boolean;
reasoning_effort?: string;
thinking?: {
budget_tokens?: number;
type?: string;
};
thinkingBudget?: number;
thinkingLevel?: string;
urlContext?: boolean;
verbosity?: string;
}
type ThinkingLevelExtendParam =
| 'thinkingLevel'
| 'thinkingLevel2'
| 'thinkingLevel3'
| 'thinkingLevel4';
type ThinkingLevelValue = NonNullable<LobeAgentChatConfig['thinkingLevel']>;
const DEFAULT_THINKING_LEVEL_BY_EXTEND_PARAM = {
thinkingLevel: 'high',
thinkingLevel2: 'high',
thinkingLevel3: 'high',
thinkingLevel4: 'minimal',
} as const satisfies Record<ThinkingLevelExtendParam, ThinkingLevelValue>;
const MODEL_THINKING_LEVEL_DEFAULTS: Partial<
Record<string, Partial<Record<ThinkingLevelExtendParam, ThinkingLevelValue>>>
> = {
'gemini-3.5-flash': {
thinkingLevel: 'medium',
},
'gemini-3.1-flash-lite': {
thinkingLevel: 'minimal',
},
'gemini-3.1-flash-lite-preview': {
thinkingLevel: 'minimal',
},
} as const;
/**
* Preserves legacy `thinking` preferences for users created before `enableReasoning`.
* Without this fallback, an old `thinking: 'enabled'` or `thinking: 'disabled'`
* setting would be treated as unset by models that now expose the `enableReasoning` switch.
*/
const resolveEnableReasoningValue = (chatConfig: LobeAgentChatConfig): boolean | undefined => {
if (Object.hasOwn(chatConfig, 'enableReasoning')) return chatConfig.enableReasoning;
if (chatConfig.thinking === 'enabled') return true;
if (chatConfig.thinking === 'disabled') return false;
return undefined;
};
const resolveThinkingLevelDefault = (
model: string,
extendParam: ThinkingLevelExtendParam,
): ThinkingLevelValue => {
return (
MODEL_THINKING_LEVEL_DEFAULTS[model]?.[extendParam] ??
DEFAULT_THINKING_LEVEL_BY_EXTEND_PARAM[extendParam]
);
};
const isThinkingLevelExtendParam = (
extendParam: ExtendParamsType,
): extendParam is ThinkingLevelExtendParam => extendParam in DEFAULT_THINKING_LEVEL_BY_EXTEND_PARAM;
export const resolveDefaultThinkingLevelForModel = (model?: string): ThinkingLevelValue => {
if (!model) return DEFAULT_THINKING_LEVEL_BY_EXTEND_PARAM.thinkingLevel;
return resolveThinkingLevelDefault(model, 'thinkingLevel');
};
export interface ApplyModelExtendParamsContext {
chatConfig: LobeAgentChatConfig;
/**
* The model's supported extend params (`settings.extendParams` from its model card).
*/
extendParams: ExtendParamsType[] | undefined;
model: string;
}
/**
* Resolves extended runtime parameters from a model's supported `extendParams`
* list and the agent chat config.
*
* This is the provider/store-agnostic core shared by the client chat service
* (`resolveModelExtendParams`) and the server-side agent runtime, so both paths
* forward the same runtime params (thinking level, reasoning effort, url context, )
* to the model. Callers are responsible for looking up the `extendParams` list.
*/
export const applyModelExtendParams = (ctx: ApplyModelExtendParamsContext): ModelExtendParams => {
const { extendParams: modelExtendParams, chatConfig, model } = ctx;
const extendParams: ModelExtendParams = {};
if (!modelExtendParams || modelExtendParams.length === 0) {
return extendParams;
}
// Reasoning configuration
if (modelExtendParams.includes('enableReasoning')) {
const enableReasoning = resolveEnableReasoningValue(chatConfig);
if (enableReasoning) {
const thinking: NonNullable<ModelExtendParams['thinking']> = {
type: 'enabled',
};
// Determine which budget field to use based on model support
let budgetTokens: number | undefined;
if (modelExtendParams.includes('reasoningBudgetToken32k')) {
budgetTokens = chatConfig.reasoningBudgetToken32k || 1024;
} else if (modelExtendParams.includes('reasoningBudgetToken80k')) {
budgetTokens = chatConfig.reasoningBudgetToken80k || 1024;
} else {
budgetTokens = chatConfig.reasoningBudgetToken || 1024;
}
thinking.budget_tokens = budgetTokens;
extendParams.thinking = thinking;
} else {
extendParams.thinking = {
budget_tokens: 0,
type: 'disabled',
};
}
} else if (modelExtendParams.includes('reasoningBudgetToken32k')) {
// For models that only have reasoningBudgetToken32k without enableReasoning
extendParams.thinking = {
budget_tokens: chatConfig.reasoningBudgetToken32k || 1024,
type: 'enabled',
};
} else if (modelExtendParams.includes('reasoningBudgetToken80k')) {
// For models that only have reasoningBudgetToken80k without enableReasoning
extendParams.thinking = {
budget_tokens: chatConfig.reasoningBudgetToken80k || 1024,
type: 'enabled',
};
} else if (modelExtendParams.includes('reasoningBudgetToken')) {
// For models that only have reasoningBudgetToken without enableReasoning
extendParams.thinking = {
budget_tokens: chatConfig.reasoningBudgetToken || 1024,
};
}
// Adaptive thinking (Claude Opus/Sonnet 4.6)
if (modelExtendParams.includes('enableAdaptiveThinking')) {
if (chatConfig.enableAdaptiveThinking) {
extendParams.thinking = {
type: 'adaptive',
};
} else if (!modelExtendParams.includes('enableReasoning')) {
// Only disable when the model has no enableReasoning fallback
extendParams.thinking = {
type: 'disabled',
};
}
// When adaptive is off and model also has enableReasoning, let enableReasoning result stand
}
// Context caching
if (modelExtendParams.includes('disableContextCaching') && chatConfig.disableContextCaching) {
extendParams.enabledContextCaching = false;
}
// Preserve historical thinking content (provider support required)
if (
modelExtendParams.includes('preserveThinking') &&
typeof chatConfig.preserveThinking === 'boolean'
) {
extendParams.preserveThinking = chatConfig.preserveThinking;
}
// Reasoning effort variants
if (modelExtendParams.includes('reasoningEffort') && chatConfig.reasoningEffort) {
extendParams.reasoning_effort = chatConfig.reasoningEffort;
}
if (modelExtendParams.includes('gpt5ReasoningEffort') && chatConfig.gpt5ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.gpt5ReasoningEffort;
}
if (modelExtendParams.includes('gpt5_1ReasoningEffort') && chatConfig.gpt5_1ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.gpt5_1ReasoningEffort;
}
if (modelExtendParams.includes('gpt5_2ReasoningEffort') && chatConfig.gpt5_2ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.gpt5_2ReasoningEffort;
}
if (
modelExtendParams.includes('gpt5_2ProReasoningEffort') &&
chatConfig.gpt5_2ProReasoningEffort
) {
extendParams.reasoning_effort = chatConfig.gpt5_2ProReasoningEffort;
}
if (modelExtendParams.includes('grok4_20ReasoningEffort') && chatConfig.grok4_20ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.grok4_20ReasoningEffort;
}
if (modelExtendParams.includes('grok4_3ReasoningEffort') && chatConfig.grok4_3ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.grok4_3ReasoningEffort;
}
if (modelExtendParams.includes('hy3ReasoningEffort') && chatConfig.hy3ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.hy3ReasoningEffort;
}
if (modelExtendParams.includes('ring2_6ReasoningEffort') && chatConfig.ring2_6ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.ring2_6ReasoningEffort;
}
if (modelExtendParams.includes('codexMaxReasoningEffort') && chatConfig.codexMaxReasoningEffort) {
extendParams.reasoning_effort = chatConfig.codexMaxReasoningEffort;
}
// DeepSeek reasoning effort is reconciled last to avoid invalid combinations.
if (modelExtendParams.includes('deepseekV4ReasoningEffort')) {
const deepseekV4ReasoningEffort = chatConfig.deepseekV4ReasoningEffort;
if (typeof deepseekV4ReasoningEffort === 'string') {
if (deepseekV4ReasoningEffort === 'none') {
delete extendParams.reasoning_effort;
extendParams.thinking = {
type: 'disabled',
};
} else {
extendParams.reasoning_effort = deepseekV4ReasoningEffort;
extendParams.thinking = {
type: 'enabled',
};
}
}
}
if (modelExtendParams.includes('effort') && chatConfig.effort) {
extendParams.effort = chatConfig.effort;
}
if (modelExtendParams.includes('opus47Effort') && chatConfig.opus47Effort) {
extendParams.effort = chatConfig.opus47Effort;
}
if (modelExtendParams.includes('step3_5ReasoningEffort') && chatConfig.step3_5ReasoningEffort) {
extendParams.reasoning_effort = chatConfig.step3_5ReasoningEffort;
}
// Text verbosity
if (modelExtendParams.includes('textVerbosity') && chatConfig.textVerbosity) {
extendParams.verbosity = chatConfig.textVerbosity;
}
// Thinking configuration
if (modelExtendParams.includes('thinking') && chatConfig.thinking) {
extendParams.thinking = { type: chatConfig.thinking };
}
if (modelExtendParams.includes('thinkingBudget') && chatConfig.thinkingBudget !== undefined) {
extendParams.thinkingBudget = chatConfig.thinkingBudget;
}
const supportedThinkingLevelParams = modelExtendParams.filter(isThinkingLevelExtendParam);
for (const supportedThinkingLevelParam of supportedThinkingLevelParams) {
const value = chatConfig[supportedThinkingLevelParam];
if (typeof value === 'string') {
extendParams.thinkingLevel = value;
break;
}
}
if (!extendParams.thinkingLevel && supportedThinkingLevelParams.length > 0) {
extendParams.thinkingLevel = resolveThinkingLevelDefault(
model,
supportedThinkingLevelParams[0],
);
}
// URL context
if (modelExtendParams.includes('urlContext') && chatConfig.urlContext) {
extendParams.urlContext = chatConfig.urlContext;
}
// Image generation params
if (modelExtendParams.includes('imageAspectRatio') && chatConfig.imageAspectRatio) {
extendParams.imageAspectRatio = chatConfig.imageAspectRatio;
}
if (modelExtendParams.includes('imageAspectRatio2') && chatConfig.imageAspectRatio2) {
extendParams.imageAspectRatio = chatConfig.imageAspectRatio2;
}
if (modelExtendParams.includes('imageResolution') && chatConfig.imageResolution) {
extendParams.imageResolution = chatConfig.imageResolution;
}
if (modelExtendParams.includes('imageResolution2') && chatConfig.imageResolution2) {
extendParams.imageResolution = chatConfig.imageResolution2;
}
return extendParams;
};
@@ -23,7 +23,7 @@ export const tracer = trace.getTracer('@lobechat/agent-runtime', '0.0.1');
*
* Lets orphaned `waiting_for_async_tool` parents be detected via the
* `barrier_held` / `no_pending` / `verify_exhausted` series instead of
* accumulating silently. See LOBE-10385.
* accumulating silently. For details see: async sub-agent suspend/resume stability hardening bounded watchdog retry with exponential backoff.
*/
export const asyncToolResumeCounter = meter.createCounter('agent_runtime_async_tool_resume_total', {
description: 'Count of async sub-agent parent resume attempts grouped by outcome.',
+27 -27
View File
@@ -33,7 +33,7 @@ export class AgentService extends BaseService {
* @returns The user's Agent list
*/
async queryAgents(request: GetAgentsRequest): ServiceResult<AgentListResponse> {
this.log('info', '获取 Agent 列表', { request });
this.log('info', 'get agent list', { request });
const { keyword } = request;
@@ -58,14 +58,14 @@ export class AgentService extends BaseService {
const [agentsList, totalResult] = await Promise.all([query, countQuery]);
this.log('info', `查询到 ${agentsList.length} 个 Agent`);
this.log('info', `found ${agentsList.length} agents`);
return {
agents: agentsList,
total: totalResult[0]?.count ?? 0,
};
} catch (error) {
this.handleServiceError(error, '获取 Agent 列表');
this.handleServiceError(error, 'get agent list');
}
}
@@ -75,7 +75,7 @@ export class AgentService extends BaseService {
* @returns Created Agent info
*/
async createAgent(request: CreateAgentRequest): ServiceResult<AgentDetailResponse> {
this.log('info', '创建智能体', { title: request.title });
this.log('info', 'create agent', { title: request.title });
try {
return await this.db.transaction(async (tx) => {
@@ -99,12 +99,12 @@ export class AgentService extends BaseService {
// Insert into database
const [createdAgent] = await tx.insert(agents).values(newAgentData).returning();
this.log('info', 'Agent 创建成功', { id: createdAgent.id, slug: createdAgent.slug });
this.log('info', 'agent created successfully', { id: createdAgent.id, slug: createdAgent.slug });
return createdAgent;
});
} catch (error) {
this.handleServiceError(error, '创建 Agent');
this.handleServiceError(error, 'create agent');
}
}
@@ -114,7 +114,7 @@ export class AgentService extends BaseService {
* @returns Updated Agent info
*/
async updateAgent(request: UpdateAgentRequest): ServiceResult<AgentDetailResponse> {
this.log('info', '更新智能体', { id: request.id, title: request.title });
this.log('info', 'update agent', { id: request.id, title: request.title });
try {
// Permission validation
@@ -123,7 +123,7 @@ export class AgentService extends BaseService {
});
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权更新此 Agent');
throw this.createAuthorizationError(permissionResult.message || 'No permission to update this agent');
}
return await this.db.transaction(async (tx) => {
@@ -138,7 +138,7 @@ export class AgentService extends BaseService {
});
if (!existingAgent) {
throw this.createBusinessError(`Agent ID "${request.id}" 不存在`);
throw this.createBusinessError(`Agent ID "${request.id}" not found`);
}
// Only update fields actually provided in the request to avoid overwriting existing values with undefined
@@ -176,11 +176,11 @@ export class AgentService extends BaseService {
.where(and(...whereConditions))
.returning();
this.log('info', 'Agent 更新成功', { id: updatedAgent.id, slug: updatedAgent.slug });
this.log('info', 'agent updated successfully', { id: updatedAgent.id, slug: updatedAgent.slug });
return updatedAgent;
});
} catch (error) {
this.handleServiceError(error, '更新 Agent');
this.handleServiceError(error, 'update agent');
}
}
@@ -189,7 +189,7 @@ export class AgentService extends BaseService {
* @param request Delete request parameters
*/
async deleteAgent(request: AgentDeleteRequest): ServiceResult<void> {
this.log('info', '删除智能体', {
this.log('info', 'delete agent', {
agentId: request.agentId,
migrateSessionTo: request.migrateSessionTo,
});
@@ -201,7 +201,7 @@ export class AgentService extends BaseService {
});
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权删除此 Agent');
throw this.createAuthorizationError(permissionResult.message || 'No permission to delete this agent');
}
// Check if the Agent to be deleted exists
@@ -210,7 +210,7 @@ export class AgentService extends BaseService {
});
if (!targetAgent) {
throw this.createBusinessError(`Agent ID ${request.agentId} 不存在`);
throw this.createBusinessError(`Agent ID ${request.agentId} not found`);
}
if (request.migrateSessionTo) {
@@ -220,13 +220,13 @@ export class AgentService extends BaseService {
});
if (!migrateTarget) {
throw this.createBusinessError(`迁移目标 Agent ID ${request.migrateSessionTo} 不存在`);
throw this.createBusinessError(`Migration target agent ID ${request.migrateSessionTo} not found`);
}
// Migrate session associations to the target Agent
await this.migrateAgentSessions(request.agentId, request.migrateSessionTo);
this.log('info', '会话迁移完成', {
this.log('info', 'session migration completed', {
from: request.agentId,
to: request.migrateSessionTo,
});
@@ -241,9 +241,9 @@ export class AgentService extends BaseService {
await agentModel.delete(request.agentId);
}
this.log('info', 'Agent 删除成功', { agentId: request.agentId });
this.log('info', 'agent deleted successfully', { agentId: request.agentId });
} catch (error) {
this.handleServiceError(error, '删除 Agent');
this.handleServiceError(error, 'delete agent');
}
}
@@ -253,7 +253,7 @@ export class AgentService extends BaseService {
* @returns Agent details
*/
async getAgentById(agentId: string): ServiceResult<AgentDetailResponse | null> {
this.log('info', '根据 ID 获取 Agent 详情', { agentId });
this.log('info', 'get agent details by ID', { agentId });
try {
// Permission validation
@@ -262,11 +262,11 @@ export class AgentService extends BaseService {
});
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权访问此 Agent');
throw this.createAuthorizationError(permissionResult.message || 'No permission to access this agent');
}
if (!this.userId) {
throw this.createAuthError('未登录,无法获取 Agent 详情');
throw this.createAuthError('Not logged in, cannot get agent details');
}
// Reuse AgentModel methods to get the full Agent configuration
@@ -274,13 +274,13 @@ export class AgentService extends BaseService {
const agent = await agentModel.getAgentConfigById(agentId);
if (!agent || !agent.id) {
this.log('warn', 'Agent 不存在', { agentId });
this.log('warn', 'agent not found', { agentId });
return null;
}
return agent as AgentDetailResponse;
} catch (error) {
this.handleServiceError(error, '获取 Agent 详情');
this.handleServiceError(error, 'get agent details');
}
}
@@ -291,7 +291,7 @@ export class AgentService extends BaseService {
* @private
*/
private async migrateAgentSessions(fromAgentId: string, toAgentId: string): Promise<void> {
this.log('info', '开始迁移会话', { fromAgentId, toAgentId });
this.log('info', 'start migrating sessions', { fromAgentId, toAgentId });
try {
await this.db.transaction(async (tx) => {
@@ -345,12 +345,12 @@ export class AgentService extends BaseService {
);
}
this.log('info', '迁移会话完成', { count: newSessionIds.length });
this.log('info', 'session migration completed', { count: newSessionIds.length });
});
this.log('info', '会话迁移成功', { fromAgentId, toAgentId });
this.log('info', 'session migration succeeded', { fromAgentId, toAgentId });
} catch (error) {
this.handleServiceError(error, '会话迁移');
this.handleServiceError(error, 'session migration');
}
}
}
+40 -40
View File
@@ -44,7 +44,7 @@ export class RoleService extends BaseService {
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_READ');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权访问角色列表');
throw this.createAuthorizationError(permissionResult.message || 'No permission to access role list');
}
const conditions = [];
@@ -87,7 +87,7 @@ export class RoleService extends BaseService {
total: totalResult[0]?.count || 0,
};
} catch (error) {
this.handleServiceError(error, '获取角色列表');
this.handleServiceError(error, 'get role list');
}
}
@@ -99,7 +99,7 @@ export class RoleService extends BaseService {
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_READ');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权访问活跃角色列表');
throw this.createAuthorizationError(permissionResult.message || 'No permission to access active role list');
}
try {
@@ -108,7 +108,7 @@ export class RoleService extends BaseService {
where: and(eq(roles.isActive, true), this.getRoleScopeWhere()),
});
} catch (error) {
this.handleServiceError(error, '获取活跃角色列表');
this.handleServiceError(error, 'get active role list');
}
}
@@ -121,7 +121,7 @@ export class RoleService extends BaseService {
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_READ');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权访问此角色');
throw this.createAuthorizationError(permissionResult.message || 'No permission to access this role');
}
try {
@@ -130,7 +130,7 @@ export class RoleService extends BaseService {
});
return role || null;
} catch (error) {
this.handleServiceError(error, '获取角色详情');
this.handleServiceError(error, 'get role details');
}
}
@@ -143,7 +143,7 @@ export class RoleService extends BaseService {
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_READ');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权访问此角色');
throw this.createAuthorizationError(permissionResult.message || 'No permission to access this role');
}
try {
@@ -152,7 +152,7 @@ export class RoleService extends BaseService {
});
return role || null;
} catch (error) {
this.handleServiceError(error, '获取角色详情');
this.handleServiceError(error, 'get role details');
}
}
@@ -160,11 +160,11 @@ export class RoleService extends BaseService {
* Create a new role
*/
async createRole(payload: CreateRoleRequest): ServiceResult<RoleItem> {
this.log('info', '创建角色', { payload });
this.log('info', 'create role', { payload });
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_CREATE');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权创建角色');
throw this.createAuthorizationError(permissionResult.message || 'No permission to create role');
}
try {
@@ -174,7 +174,7 @@ export class RoleService extends BaseService {
where: and(eq(roles.name, payload.name), this.getRoleScopeWhere()),
});
if (existingRole) {
throw this.createBusinessError(`角色名称 "${payload.name}" 已存在`);
throw this.createBusinessError(`Role name "${payload.name}" already exists`);
}
const [createdRole] = await tx
@@ -189,11 +189,11 @@ export class RoleService extends BaseService {
})
.returning();
this.log('info', '角色创建成功', { roleId: createdRole.id, roleName: createdRole.name });
this.log('info', 'role created successfully', { roleId: createdRole.id, roleName: createdRole.name });
return createdRole;
});
} catch (error) {
this.handleServiceError(error, '创建角色');
this.handleServiceError(error, 'create role');
}
}
@@ -209,7 +209,7 @@ export class RoleService extends BaseService {
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_PERMISSION_READ');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权访问角色权限');
throw this.createAuthorizationError(permissionResult.message || 'No permission to access role permissions');
}
const conditions: SQL<unknown>[] = [eq(rolePermissions.roleId, request.roleId)];
@@ -258,7 +258,7 @@ export class RoleService extends BaseService {
total: totalResult[0]?.count || 0,
};
} catch (error) {
this.handleServiceError(error, '获取角色权限');
this.handleServiceError(error, 'get role permissions');
}
}
@@ -269,11 +269,11 @@ export class RoleService extends BaseService {
roleId: string,
payload: UpdateRolePermissionsRequest,
): ServiceResult<{ granted: number; revoked: number; roleId: string }> {
this.log('info', '更新角色权限', { payload, roleId });
this.log('info', 'update role permissions', { payload, roleId });
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_UPDATE');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权更新角色权限');
throw this.createAuthorizationError(permissionResult.message || 'No permission to update role permissions');
}
const rawGrantIds = payload.grant ?? [];
@@ -298,14 +298,14 @@ export class RoleService extends BaseService {
const revokeIds = Array.from(revokeSet);
if (!grantIds.length && !revokeIds.length) {
throw this.createBusinessError('未提供有效的 grant revoke 权限 ID');
throw this.createBusinessError('No valid grant or revoke permission IDs provided');
}
try {
return await this.db.transaction(async (tx) => {
const existingRole = await tx.query.roles.findFirst({ where: eq(roles.id, roleId) });
if (!existingRole) {
throw this.createNotFoundError(`角色 ID "${roleId}" 不存在`);
throw this.createNotFoundError(`Role ID "${roleId}" not found`);
}
let granted = 0;
@@ -319,7 +319,7 @@ export class RoleService extends BaseService {
const missingPermissionIds = grantIds.filter((id) => !validPermissionIds.includes(id));
if (missingPermissionIds.length) {
throw this.createNotFoundError(
`权限 ID [${missingPermissionIds.join(', ')}] 不存在,无法授权`,
`Permission IDs [${missingPermissionIds.join(', ')}] not found, cannot grant`,
);
}
@@ -367,7 +367,7 @@ export class RoleService extends BaseService {
return { granted, revoked, roleId };
});
} catch (error) {
this.handleServiceError(error, '更新角色权限');
this.handleServiceError(error, 'update role permissions');
}
}
@@ -378,12 +378,12 @@ export class RoleService extends BaseService {
* @returns Promise<RoleItem> - Updated role item
*/
async updateRole(id: string, updateData: UpdateRoleRequest): ServiceResult<RoleItem> {
this.log('info', '更新角色信息', { roleId: id, updateData });
this.log('info', 'update role info', { roleId: id, updateData });
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_UPDATE');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权更新角色');
throw this.createAuthorizationError(permissionResult.message || 'No permission to update role');
}
try {
@@ -394,12 +394,12 @@ export class RoleService extends BaseService {
});
if (!existingRole) {
throw this.createNotFoundError(`角色 ID "${id}" 不存在`);
throw this.createNotFoundError(`Role ID "${id}" not found`);
}
// Check if it is a system role; system roles cannot have certain fields modified
if (existingRole.isSystem && (updateData.name || updateData.isSystem === false)) {
throw this.createBusinessError('系统角色不允许修改名称或系统属性');
throw this.createBusinessError('System roles cannot have their name or system attribute modified');
}
// If the role name is being modified, check whether the new name already exists
@@ -409,7 +409,7 @@ export class RoleService extends BaseService {
});
if (duplicateRole) {
throw this.createBusinessError(`角色名称 "${updateData.name}" 已存在`);
throw this.createBusinessError(`Role name "${updateData.name}" already exists`);
}
}
@@ -430,11 +430,11 @@ export class RoleService extends BaseService {
.where(and(eq(roles.id, id), this.getRoleScopeWhere()))
.returning();
this.log('info', '角色更新成功', { roleId: id, roleName: updatedRole.name });
this.log('info', 'role updated successfully', { roleId: id, roleName: updatedRole.name });
return updatedRole;
});
} catch (error) {
this.handleServiceError(error, '更新角色');
this.handleServiceError(error, 'update role');
}
}
@@ -442,12 +442,12 @@ export class RoleService extends BaseService {
* Clear a role's permission mappings
*/
async clearRolePermissions(roleId: string): ServiceResult<{ removed: number; roleId: string }> {
this.log('info', '清空角色权限', { roleId });
this.log('info', 'clear role permissions', { roleId });
// Permission check
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_UPDATE');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权清空角色权限');
throw this.createAuthorizationError(permissionResult.message || 'No permission to clear role permissions');
}
try {
@@ -456,7 +456,7 @@ export class RoleService extends BaseService {
where: and(eq(roles.id, roleId), this.getRoleScopeWhere()),
});
if (!existingRole) {
throw this.createNotFoundError(`角色 ID "${roleId}" 不存在`);
throw this.createNotFoundError(`Role ID "${roleId}" not found`);
}
// Count and delete
@@ -469,7 +469,7 @@ export class RoleService extends BaseService {
return { removed: Number(before[0]?.count || 0), roleId };
} catch (error) {
this.handleServiceError(error, '清空角色权限');
this.handleServiceError(error, 'clear role permissions');
}
}
@@ -477,11 +477,11 @@ export class RoleService extends BaseService {
* Delete role by ID
*/
async deleteRole(id: string): ServiceResult<{ deleted: boolean; id: string }> {
this.log('info', '删除角色', { roleId: id });
this.log('info', 'delete role', { roleId: id });
const permissionResult = await this.resolveOperationPermission('RBAC_ROLE_DELETE');
if (!permissionResult.isPermitted) {
throw this.createAuthorizationError(permissionResult.message || '无权删除角色');
throw this.createAuthorizationError(permissionResult.message || 'No permission to delete role');
}
try {
@@ -491,18 +491,18 @@ export class RoleService extends BaseService {
});
if (!existingRole) {
throw this.createNotFoundError(`角色 ID "${id}" 不存在`);
throw this.createNotFoundError(`Role ID "${id}" not found`);
}
if (existingRole.isSystem) {
throw this.createBusinessError('系统角色不允许删除');
throw this.createBusinessError('System roles cannot be deleted');
}
const linkedUser = await tx.query.userRoles.findFirst({
where: and(eq(userRoles.roleId, id), this.getUserRoleScopeWhere()),
});
if (linkedUser) {
throw this.createBusinessError('角色仍然关联用户,无法删除');
throw this.createBusinessError('Role is still associated with users and cannot be deleted');
}
const [deletedRole] = await tx
@@ -511,14 +511,14 @@ export class RoleService extends BaseService {
.returning({ id: roles.id });
if (!deletedRole) {
throw this.createBusinessError('删除角色失败');
throw this.createBusinessError('Failed to delete role');
}
this.log('info', '角色删除成功', { roleId: id });
this.log('info', 'role deleted successfully', { roleId: id });
return { deleted: true, id: deletedRole.id };
});
} catch (error) {
this.handleServiceError(error, '删除角色');
this.handleServiceError(error, 'delete role');
}
}
}
@@ -225,22 +225,15 @@ const LinearRender = memo<BuiltinRenderProps<Record<string, unknown>, unknown, u
() => buildLinearRenderModel({ apiName, args, content, pluginError }),
[apiName, args, content, pluginError],
);
const hasRequest = hasItems(model.requestFields) || hasItems(model.requestLinks);
const hasResult =
hasItems(model.resultEntities) || Boolean(model.resultText) || Boolean(model.rawResultJson);
if (!hasRequest && !hasResult && !model.errorText) return null;
// Request args are intentionally not rendered here — the Inspector already
// surfaces the tool inputs, so duplicating them in the render is redundant.
if (!hasResult && !model.errorText) return null;
return (
<Flexbox className={styles.container} gap={12}>
{hasRequest && (
<Section title={model.actionLabel || 'Request'}>
<Block gap={8} padding={10} variant={'outlined'} width={'100%'}>
<FieldGrid fields={model.requestFields} />
<LinkList links={model.requestLinks} />
</Block>
</Section>
)}
{hasItems(model.resultEntities) && (
<Section title={'Result'}>
<Flexbox gap={8}>

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